Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 8 additions & 2 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -13,15 +13,21 @@ 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
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.
Expand Down
13 changes: 8 additions & 5 deletions HACKATHON_PROVENANCE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
42 changes: 42 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions apps/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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:",
Expand All @@ -26,6 +29,7 @@
},
"devDependencies": {
"@types/node": "catalog:",
"@types/pg": "8.20.0",
"@types/react": "catalog:",
"@types/react-dom": "catalog:",
"typescript": "catalog:"
Expand Down
54 changes: 54 additions & 0 deletions apps/web/scripts/migrate-auth.mjs
Original file line number Diff line number Diff line change
@@ -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();
}
11 changes: 8 additions & 3 deletions apps/web/src/app/(app)/dashboard/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -68,9 +68,14 @@ export default async function DashboardPage() {
A product scenario beside independently verified public evidence.
</p>
</div>
<Link href="/invoices">
<Button variant="outline">Open queue</Button>
</Link>
<div className="flex gap-2">
<Link href="/sign-in">
<Button variant="outline">Company sign in</Button>
</Link>
<Link href="/invoices">
<Button variant="outline">Open demo queue</Button>
</Link>
</div>
</div>

<div className="grid grid-cols-2 gap-4 lg:grid-cols-4">
Expand Down
150 changes: 150 additions & 0 deletions apps/web/src/app/(marketing)/onboarding/company-onboarding-form.tsx
Original file line number Diff line number Diff line change
@@ -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<string>();
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<HTMLFormElement>) {
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 (
<p className="text-sm text-muted-foreground">
Checking your company workspace…
</p>
);
}

return (
<form className="space-y-5" onSubmit={createCompany}>
<div className="border border-border bg-muted/30 px-3 py-2">
<p className="text-sm font-medium">{userName}</p>
<p className="text-xs text-muted-foreground">{email}</p>
</div>
<div className="space-y-2">
<label className="text-sm font-medium" htmlFor="company-name">
Company name
</label>
<input
autoComplete="organization"
autoFocus
className="h-10 w-full border border-input bg-background px-3 text-sm outline-none focus:border-ring"
id="company-name"
maxLength={120}
onChange={(event) => setCompanyName(event.target.value)}
placeholder="Padel Peru, Lda"
required
value={companyName}
/>
<p className="text-xs text-muted-foreground">
You will be the workspace owner. Financial approval roles are assigned
separately.
</p>
</div>
{error === undefined ? null : (
<p className="text-sm text-destructive" role="alert">
{error}
</p>
)}
<Button className="w-full" disabled={isPending} type="submit">
{isPending ? 'Creating company…' : 'Create company workspace'}
</Button>
</form>
);
}
42 changes: 42 additions & 0 deletions apps/web/src/app/(marketing)/onboarding/page.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className="mx-auto flex min-h-[70vh] max-w-lg items-center">
<Card className="w-full">
<CardHeader>
<p className="microlabel">One last step</p>
<CardTitle>Create your company workspace</CardTitle>
<p className="text-sm text-muted-foreground">
This keeps invoices, suppliers, policies, and audit evidence scoped
to the correct company.
</p>
</CardHeader>
<CardContent>
<CompanyOnboardingForm
email={session.user.email}
userName={session.user.name}
/>
</CardContent>
</Card>
</div>
);
}
Loading
Loading