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 ( +
+
+

{userName}

+

{email}

+
+
+ + setCompanyName(event.target.value)} + placeholder="Padel Peru, Lda" + required + value={companyName} + /> +

+ You will be the workspace owner. Financial approval roles are assigned + separately. +

+
+ {error === undefined ? null : ( +

+ {error} +

+ )} + +
+ ); +} 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 - - -
+