diff --git a/.env.example b/.env.example
index ed85a5b..da23369 100644
--- a/.env.example
+++ b/.env.example
@@ -13,6 +13,13 @@ SETTLEMENT_WORKER_PORT=4500
# Persistence
DATABASE_URL=postgresql://remit:remit@localhost:5432/remit
+DATABASE_URL_UNPOOLED=postgresql://remit:remit@localhost:5432/remit
+
+# Application identity (Better Auth + Google)
+BETTER_AUTH_URL=http://localhost:3000
+BETTER_AUTH_SECRET=replace-with-at-least-32-random-characters
+GOOGLE_CLIENT_ID=replace-with-google-oauth-client-id
+GOOGLE_CLIENT_SECRET=replace-with-google-oauth-client-secret
# Public, non-secret network configuration
WORLD_CHAIN_ID=eip155:480
@@ -20,8 +27,7 @@ WORLD_AGENTBOOK_ADDRESS=0xA23aB2712eA7BBa896930544C7d6636a96b944dA
HEDERA_NETWORK=hedera:testnet
HEDERA_MIRROR_BASE_URL=https://testnet.mirrornode.hedera.com
-# Secrets are intentionally unnamed until the owning integration PR defines
-# its exact process boundary. Never place real values in this file.
+# Never place real credentials in this file.
# Demo and gate scripts (scripts/demo.ts, scripts/verify-humans.ts).
# Required only for live runs; `pnpm demo -- --offline` works without them.
diff --git a/HACKATHON_PROVENANCE.md b/HACKATHON_PROVENANCE.md
index 1fdfcb6..aad75f6 100644
--- a/HACKATHON_PROVENANCE.md
+++ b/HACKATHON_PROVENANCE.md
@@ -28,11 +28,14 @@ The Hedera x402 slice consumes these exact public packages. pnpm installs their
published artifacts under the repository lockfile; no third-party package source
is copied, vendored, patched, or modified.
-| Package | Exact version | Published source | Upstream source | License | Modifications |
-| ------------------- | ------------: | ------------------------------------------------------------------------------ | -------------------------------------------------------------------- | ---------- | --------------------------------------- |
-| `@x402/core` | `2.19.0` | [npm registry manifest](https://registry.npmjs.org/@x402%2Fcore/2.19.0) | [x402 Foundation `x402`](https://github.com/x402-foundation/x402) | Apache-2.0 | None; installed artifact is unmodified. |
-| `@x402/hedera` | `2.19.0` | [npm registry manifest](https://registry.npmjs.org/@x402%2Fhedera/2.19.0) | [x402 Foundation `x402`](https://github.com/x402-foundation/x402) | Apache-2.0 | None; installed artifact is unmodified. |
-| `@hiero-ledger/sdk` | `2.85.0` | [npm registry manifest](https://registry.npmjs.org/@hiero-ledger%2Fsdk/2.85.0) | [Hiero JavaScript SDK](https://github.com/hiero-ledger/hiero-sdk-js) | Apache-2.0 | None; installed artifact is unmodified. |
+| Package | Exact version | Published source | Upstream source | License | Modifications |
+| ------------------- | ------------: | ------------------------------------------------------------------------------ | --------------------------------------------------------------------- | ---------- | --------------------------------------- |
+| `@x402/core` | `2.19.0` | [npm registry manifest](https://registry.npmjs.org/@x402%2Fcore/2.19.0) | [x402 Foundation `x402`](https://github.com/x402-foundation/x402) | Apache-2.0 | None; installed artifact is unmodified. |
+| `@x402/hedera` | `2.19.0` | [npm registry manifest](https://registry.npmjs.org/@x402%2Fhedera/2.19.0) | [x402 Foundation `x402`](https://github.com/x402-foundation/x402) | Apache-2.0 | None; installed artifact is unmodified. |
+| `@hiero-ledger/sdk` | `2.85.0` | [npm registry manifest](https://registry.npmjs.org/@hiero-ledger%2Fsdk/2.85.0) | [Hiero JavaScript SDK](https://github.com/hiero-ledger/hiero-sdk-js) | Apache-2.0 | None; installed artifact is unmodified. |
+| `better-auth` | `1.6.23` | [npm registry manifest](https://registry.npmjs.org/better-auth/1.6.23) | [Better Auth](https://github.com/better-auth/better-auth) | MIT | None; installed artifact is unmodified. |
+| `pg` | `8.22.0` | [npm registry manifest](https://registry.npmjs.org/pg/8.22.0) | [node-postgres](https://github.com/brianc/node-postgres) | MIT | None; installed artifact is unmodified. |
+| `@types/pg` | `8.20.0` | [npm registry manifest](https://registry.npmjs.org/@types%2Fpg/8.20.0) | [DefinitelyTyped](https://github.com/DefinitelyTyped/DefinitelyTyped) | MIT | None; development types are unmodified. |
Remit's adapter, trust-policy checks, canonical bindings, recovery seam, and
tests are original event-window code outside those packages.
diff --git a/README.md b/README.md
index 55becf4..8bdc34f 100644
--- a/README.md
+++ b/README.md
@@ -218,6 +218,48 @@ pnpm dev
| Payment agent | 4400 |
| Settlement worker | 4500 |
+### Company sign-in
+
+The web app uses Google through Better Auth for application identity and
+organization membership. Copy only the variable names from `.env.example` into
+`apps/web/.env.local` and provide:
+
+- `BETTER_AUTH_URL` (`http://localhost:3000` locally);
+- a random `BETTER_AUTH_SECRET` of at least 32 characters;
+- `GOOGLE_CLIENT_ID` and `GOOGLE_CLIENT_SECRET`;
+- pooled `DATABASE_URL`; and
+- unpooled `DATABASE_URL_UNPOOLED` for migrations.
+
+The Google OAuth client must allow
+`http://localhost:3000/api/auth/callback/google` locally and the corresponding
+production origin callback. Apply the isolated, idempotent auth migration before
+enabling sign-in:
+
+```bash
+cd apps/web
+pnpm auth:migrate
+```
+
+The migration owns only the `invoiceguard_auth` schema. Google and Better Auth
+organization roles never grant payment authority; that remains a separate
+control-API decision.
+
+| Process | Local port |
+| ----------------- | ---------: |
+| Web | 3000 |
+| Control API | 4100 |
+| Extraction worker | 4150 |
+| Verifier | 4200 |
+| x402 facilitator | 4300 |
+| Payment agent | 4400 |
+| Settlement worker | 4500 |
+
+Run the complete local quality gate before every push:
+
+```bash
+pnpm check
+```
+
## Provenance
Work began in this repository during ETHGlobal Lisbon 2026. See
diff --git a/apps/web/package.json b/apps/web/package.json
index 433e419..079885b 100644
--- a/apps/web/package.json
+++ b/apps/web/package.json
@@ -4,6 +4,7 @@
"private": true,
"type": "module",
"scripts": {
+ "auth:migrate": "node --env-file-if-exists=.env.local scripts/migrate-auth.mjs",
"build": "next build",
"clean": "rm -rf .next",
"dev": "next dev --port 3000",
@@ -14,10 +15,12 @@
"@remit/persistence": "workspace:*",
"@remit/world-adapter": "workspace:*",
"@tailwindcss/postcss": "^4.3.3",
+ "better-auth": "1.6.23",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-react": "^1.26.0",
"next": "16.2.11",
+ "pg": "8.22.0",
"postgres": "catalog:",
"react": "catalog:",
"react-dom": "catalog:",
@@ -26,6 +29,7 @@
},
"devDependencies": {
"@types/node": "catalog:",
+ "@types/pg": "8.20.0",
"@types/react": "catalog:",
"@types/react-dom": "catalog:",
"typescript": "catalog:"
diff --git a/apps/web/scripts/migrate-auth.mjs b/apps/web/scripts/migrate-auth.mjs
new file mode 100644
index 0000000..df0a198
--- /dev/null
+++ b/apps/web/scripts/migrate-auth.mjs
@@ -0,0 +1,54 @@
+import { getMigrations } from 'better-auth/db/migration';
+import { organization } from 'better-auth/plugins';
+import process from 'node:process';
+import pg from 'pg';
+
+const schema = 'invoiceguard_auth';
+const connectionString =
+ process.env.DATABASE_URL_UNPOOLED ?? process.env.DATABASE_URL;
+
+if (connectionString === undefined || connectionString.length === 0) {
+ throw new Error('DATABASE_URL_UNPOOLED or DATABASE_URL is required');
+}
+
+const administrativePool = new pg.Pool({
+ connectionString,
+ connectionTimeoutMillis: 10_000,
+ max: 1,
+});
+
+await administrativePool.query(`CREATE SCHEMA IF NOT EXISTS ${schema}`);
+await administrativePool.end();
+
+const migrationPool = new pg.Pool({
+ connectionString,
+ connectionTimeoutMillis: 10_000,
+ max: 1,
+ options: `-c search_path=${schema},public`,
+});
+
+try {
+ const migration = await getMigrations({
+ database: migrationPool,
+ plugins: [
+ organization({
+ creatorRole: 'owner',
+ requireEmailVerificationOnInvitation: true,
+ }),
+ ],
+ });
+
+ if (process.argv.includes('--check')) {
+ if (migration.toBeCreated.length > 0 || migration.toBeAdded.length > 0) {
+ process.exitCode = 1;
+ process.stderr.write('Better Auth schema is not current\n');
+ } else {
+ process.stdout.write('Better Auth schema is current\n');
+ }
+ } else {
+ await migration.runMigrations();
+ process.stdout.write('Better Auth schema is current\n');
+ }
+} finally {
+ await migrationPool.end();
+}
diff --git a/apps/web/src/app/(app)/dashboard/page.tsx b/apps/web/src/app/(app)/dashboard/page.tsx
index 093f14b..0d2f364 100644
--- a/apps/web/src/app/(app)/dashboard/page.tsx
+++ b/apps/web/src/app/(app)/dashboard/page.tsx
@@ -68,9 +68,14 @@ export default async function DashboardPage() {
A product scenario beside independently verified public evidence.
-
-
-
+
+
+
+
+
+
+
+
diff --git a/apps/web/src/app/(marketing)/onboarding/company-onboarding-form.tsx b/apps/web/src/app/(marketing)/onboarding/company-onboarding-form.tsx
new file mode 100644
index 0000000..0bfac09
--- /dev/null
+++ b/apps/web/src/app/(marketing)/onboarding/company-onboarding-form.tsx
@@ -0,0 +1,150 @@
+'use client';
+
+import { useRouter } from 'next/navigation';
+import { type FormEvent, useEffect, useState } from 'react';
+
+import { Button } from '../../../components/ui/button';
+import { authClient } from '../../../lib/auth-client';
+import { createCompanySlug } from '../../../lib/company-slug';
+
+type CompanyOnboardingFormProperties = Readonly<{
+ email: string;
+ userName: string;
+}>;
+
+export function CompanyOnboardingForm({
+ email,
+ userName,
+}: CompanyOnboardingFormProperties) {
+ const router = useRouter();
+ const [companyName, setCompanyName] = useState('');
+ const [error, setError] = useState
();
+ const [isChecking, setIsChecking] = useState(true);
+ const [isPending, setIsPending] = useState(false);
+
+ useEffect(() => {
+ let isCurrent = true;
+
+ async function selectExistingCompany() {
+ const organizations = await authClient.organization.list();
+
+ if (!isCurrent) {
+ return;
+ }
+
+ const firstOrganization = organizations.data?.[0];
+
+ if (firstOrganization !== undefined) {
+ await authClient.organization.setActive({
+ organizationId: firstOrganization.id,
+ });
+ router.replace('/invoices');
+ router.refresh();
+ return;
+ }
+
+ if (organizations.error !== null) {
+ setError(
+ organizations.error.message ??
+ 'Your company workspaces could not be loaded.',
+ );
+ }
+
+ setIsChecking(false);
+ }
+
+ void selectExistingCompany();
+
+ return () => {
+ isCurrent = false;
+ };
+ }, [router]);
+
+ async function createCompany(event: FormEvent) {
+ event.preventDefault();
+ const name = companyName.trim();
+
+ if (name.length < 2) {
+ setError('Enter the legal or trading name of your company.');
+ return;
+ }
+
+ setError(undefined);
+ setIsPending(true);
+
+ const created = await authClient.organization.create({
+ name,
+ slug: createCompanySlug(name, crypto.randomUUID()),
+ });
+
+ if (created.error !== null || created.data === null) {
+ setError(
+ created.error?.message ??
+ 'Your company workspace could not be created.',
+ );
+ setIsPending(false);
+ return;
+ }
+
+ const activated = await authClient.organization.setActive({
+ organizationId: created.data.id,
+ });
+
+ if (activated.error !== null) {
+ setError(
+ activated.error.message ??
+ 'The company was created but could not be selected.',
+ );
+ setIsPending(false);
+ return;
+ }
+
+ router.replace('/invoices');
+ router.refresh();
+ }
+
+ if (isChecking) {
+ return (
+
+ Checking your company workspace…
+
+ );
+ }
+
+ return (
+
+ );
+}
diff --git a/apps/web/src/app/(marketing)/onboarding/page.tsx b/apps/web/src/app/(marketing)/onboarding/page.tsx
new file mode 100644
index 0000000..37fc3fa
--- /dev/null
+++ b/apps/web/src/app/(marketing)/onboarding/page.tsx
@@ -0,0 +1,42 @@
+import { headers } from 'next/headers';
+import { redirect } from 'next/navigation';
+
+import {
+ Card,
+ CardContent,
+ CardHeader,
+ CardTitle,
+} from '../../../components/ui/card';
+import { getAuth } from '../../../lib/auth.server';
+import { CompanyOnboardingForm } from './company-onboarding-form';
+
+export const dynamic = 'force-dynamic';
+
+export default async function OnboardingPage() {
+ const session = await getAuth().api.getSession({ headers: await headers() });
+
+ if (session === null) {
+ redirect('/sign-in');
+ }
+
+ return (
+
+
+
+ One last step
+ Create your company workspace
+
+ This keeps invoices, suppliers, policies, and audit evidence scoped
+ to the correct company.
+
+
+
+
+
+
+
+ );
+}
diff --git a/apps/web/src/app/(marketing)/sign-in/google-sign-in-button.tsx b/apps/web/src/app/(marketing)/sign-in/google-sign-in-button.tsx
new file mode 100644
index 0000000..dd4e28a
--- /dev/null
+++ b/apps/web/src/app/(marketing)/sign-in/google-sign-in-button.tsx
@@ -0,0 +1,45 @@
+'use client';
+
+import { useState } from 'react';
+
+import { Button } from '../../../components/ui/button';
+import { authClient } from '../../../lib/auth-client';
+
+export function GoogleSignInButton() {
+ const [error, setError] = useState();
+ const [isPending, setIsPending] = useState(false);
+
+ async function signIn() {
+ setError(undefined);
+ setIsPending(true);
+
+ const result = await authClient.signIn.social({
+ callbackURL: '/onboarding',
+ newUserCallbackURL: '/onboarding',
+ provider: 'google',
+ });
+
+ if (result.error !== null) {
+ setError(result.error.message ?? 'Google sign-in could not be started.');
+ setIsPending(false);
+ }
+ }
+
+ return (
+
+
+ {error === undefined ? null : (
+
+ {error}
+
+ )}
+
+ );
+}
diff --git a/apps/web/src/app/(marketing)/sign-in/page.tsx b/apps/web/src/app/(marketing)/sign-in/page.tsx
new file mode 100644
index 0000000..63b92a9
--- /dev/null
+++ b/apps/web/src/app/(marketing)/sign-in/page.tsx
@@ -0,0 +1,43 @@
+import { headers } from 'next/headers';
+import { redirect } from 'next/navigation';
+
+import {
+ Card,
+ CardContent,
+ CardHeader,
+ CardTitle,
+} from '../../../components/ui/card';
+import { getAuth } from '../../../lib/auth.server';
+import { GoogleSignInButton } from './google-sign-in-button';
+
+export const dynamic = 'force-dynamic';
+
+export default async function SignInPage() {
+ const session = await getAuth().api.getSession({ headers: await headers() });
+
+ if (session !== null) {
+ redirect('/onboarding');
+ }
+
+ return (
+
+
+
+ Company workspace
+ Sign in to InvoiceGuard
+
+ Use your work Google account. Payment approval remains a separate,
+ explicit authority check.
+
+
+
+
+
+ Signing in identifies you to this application. It does not grant
+ permission to approve or settle supplier payments.
+
+
+
+
+ );
+}
diff --git a/apps/web/src/app/api/auth/[...all]/route.ts b/apps/web/src/app/api/auth/[...all]/route.ts
new file mode 100644
index 0000000..a837e18
--- /dev/null
+++ b/apps/web/src/app/api/auth/[...all]/route.ts
@@ -0,0 +1,23 @@
+import { toNextJsHandler } from 'better-auth/next-js';
+
+import { getAuth } from '../../../../lib/auth.server';
+
+/**
+ * Better Auth's HTTP surface.
+ *
+ * The handler is built per request rather than at module scope. `next build`
+ * imports this file while collecting page data, and constructing the auth
+ * instance there would make the build itself require production secrets —
+ * failing CI, which has none, for no benefit. `getAuth()` memoises, so the
+ * cost after the first request is a property read.
+ */
+
+export const dynamic = 'force-dynamic';
+
+const handle = (method: 'GET' | 'POST') =>
+ async function route(request: Request): Promise {
+ return toNextJsHandler(getAuth())[method](request);
+ };
+
+export const GET = handle('GET');
+export const POST = handle('POST');
diff --git a/apps/web/src/app/api/people/route.ts b/apps/web/src/app/api/people/route.ts
index 735f0f1..121c96c 100644
--- a/apps/web/src/app/api/people/route.ts
+++ b/apps/web/src/app/api/people/route.ts
@@ -7,7 +7,13 @@ import {
type WorkspacePerson,
} from '@remit/persistence';
-import { DEMO_ORGANIZATION_ID, db } from '../../../lib/workspace.server';
+import { headers } from 'next/headers';
+
+import {
+ DEMO_ORGANIZATION_ID,
+ db,
+ resolveOrganizationId,
+} from '../../../lib/workspace.server';
/**
* The workspace roster.
@@ -46,25 +52,35 @@ const SEED = [
},
];
-async function roster(): Promise {
+async function roster(
+ organizationId: string,
+): Promise {
const sql = db();
- const existing = await listWorkspacePeople(sql, DEMO_ORGANIZATION_ID);
+ const existing = await listWorkspacePeople(sql, organizationId);
if (existing.length > 0) return existing;
+ // Only the public demo organisation is ever pre-populated. A real workspace
+ // starts empty; inserting these agents into someone's company would be
+ // inventing approvers they never added.
+ if (organizationId !== DEMO_ORGANIZATION_ID) return existing;
+
for (const person of SEED) {
try {
- await addWorkspacePerson(sql, DEMO_ORGANIZATION_ID, person);
+ await addWorkspacePerson(sql, organizationId, person);
} catch {
/* a concurrent request seeded first — harmless */
}
}
- return listWorkspacePeople(sql, DEMO_ORGANIZATION_ID);
+ return listWorkspacePeople(sql, organizationId);
}
export async function GET(): Promise {
try {
+ const { organizationId, isDemo } = await resolveOrganizationId(
+ await headers(),
+ );
return Response.json(
- { people: await roster() },
+ { people: await roster(organizationId), isDemo },
{ headers: { 'cache-control': 'no-store' } },
);
} catch (error) {
@@ -126,7 +142,8 @@ export async function POST(request: Request): Promise {
typeof role === 'string' && ROLES.has(role) ? (role as PersonRole) : null;
try {
- const person = await addWorkspacePerson(db(), DEMO_ORGANIZATION_ID, {
+ const { organizationId } = await resolveOrganizationId(await headers());
+ const person = await addWorkspacePerson(db(), organizationId, {
personId: `p-${resolvedAddress.slice(2, 10).toLowerCase()}`,
displayName,
agentAddress: resolvedAddress,
@@ -165,9 +182,10 @@ export async function PATCH(request: Request): Promise {
typeof role === 'string' && ROLES.has(role) ? (role as PersonRole) : null;
try {
+ const { organizationId } = await resolveOrganizationId(await headers());
const person = await setWorkspacePersonRole(
db(),
- DEMO_ORGANIZATION_ID,
+ organizationId,
personId,
nextRole,
);
diff --git a/apps/web/src/components/sidebar.tsx b/apps/web/src/components/sidebar.tsx
index a2bc749..1a52f5e 100644
--- a/apps/web/src/components/sidebar.tsx
+++ b/apps/web/src/components/sidebar.tsx
@@ -4,6 +4,8 @@ import {
ArrowLeftRight,
Building2,
LayoutGrid,
+ LogIn,
+ LogOut,
ReceiptText,
ScrollText,
Users,
@@ -11,9 +13,10 @@ import {
SlidersHorizontal,
} from 'lucide-react';
import Link from 'next/link';
-import { usePathname } from 'next/navigation';
+import { usePathname, useRouter } from 'next/navigation';
import { useState } from 'react';
+import { authClient } from '../lib/auth-client';
import { cn } from '../lib/utils';
/* midday's sidebar recipe: fixed rail, 70px -> 240px on hover, 200ms
@@ -35,6 +38,81 @@ const items = [
{ href: '/policies', icon: SlidersHorizontal, label: 'Policies' },
] as const;
+function initials(value: string): string {
+ return value
+ .split(/\s+/)
+ .filter(Boolean)
+ .slice(0, 2)
+ .map((part) => part[0]?.toUpperCase())
+ .join('');
+}
+
+function SidebarAccount({ isExpanded }: Readonly<{ isExpanded: boolean }>) {
+ const router = useRouter();
+ const session = authClient.useSession();
+ const activeOrganization = authClient.useActiveOrganization();
+
+ if (session.data === null || session.data === undefined) {
+ return (
+
+
+
+
+
+ Company sign in
+
+
+ );
+ }
+
+ const organizationName = activeOrganization.data?.name ?? 'Company workspace';
+
+ async function signOut() {
+ await authClient.signOut();
+ router.push('/');
+ router.refresh();
+ }
+
+ return (
+
+
+
+ {initials(organizationName) || 'IG'}
+
+
+
+
+ {organizationName}
+
+
+ {session.data.user.email}
+
+
+
+
+ );
+}
+
export function Sidebar() {
const pathname = usePathname();
const [isExpanded, setIsExpanded] = useState(false);
@@ -102,26 +180,7 @@ export function Sidebar() {
})}
-
-
-
- PP
-
-
-
-
- Padel Peru, Lda
-
-
- synthetic scenario
-
-
-
+