- {KYB_STEPS.map((s, i) => {
- const isChain = i === 3;
- const done = isChain ? false : i < lit;
- const active = isChain ? lit >= 3 : i === lit;
- return (
-
-
- {done ? : active ? : }
-
- {s.label}
-
- );
- })}
-
Recording the decision in org_account. This is a real transaction and takes a few seconds.
);
}
+ const activeIndex = EERC_STEPS.findIndex((s) => !stepDone(onboarding, s.key));
+ const done = onboarding.status === "complete";
+
return (
-
-
We screen your registration and owners, then record the decision on-chain.
-
Run verification
+
+
+
+ {done ? : busy ? : }
+
+
+
{onboardingStatusLabel(onboarding)}
+
{onboarding.address} · chain {onboarding.chainId}
+
+
+
+ {EERC_STEPS.map((s, i) => {
+ const complete = stepDone(onboarding, s.key);
+ const active = !done && onboarding.status !== "failed" && (activeIndex === -1 ? i === EERC_STEPS.length - 1 : i === activeIndex);
+ const txHash = stepTx(onboarding, s.key);
+ return (
+
+
+ {complete ? : active ? : }
+
+
+
{s.label}
+ {txHash ?
tx {txHash}
: null}
+
+
+ );
+ })}
+
+ {error || onboarding.error ?
{error ?? onboarding.error}
: null}
+ {done ?
The wallet is approved for eERC private transfers. Treasury provisioning can now register the managed account.
: null}
);
}
+function slugify(input: string): string {
+ return input
+ .trim()
+ .toLowerCase()
+ .normalize("NFKD")
+ .replace(/[\u0300-\u036f]/g, "")
+ .replace(/[^a-z0-9]+/g, "-")
+ .replace(/^-+|-+$/g, "")
+ .replace(/-{2,}/g, "-") || "workspace";
+}
+
+function onboardingStatusLabel(onboarding: OnboardingStatus | null): string {
+ if (!onboarding) return "Not started";
+ return {
+ pending_kyc: "KYC pending",
+ kyc_approved: "KYC approved",
+ allowlisted: "Wallet allowlisted",
+ gas_dripped: "Gas dripped",
+ awaiting_registration: "Awaiting registration",
+ complete: "Complete",
+ failed: "Failed",
+ }[onboarding.status];
+}
+
+function stepDone(onboarding: OnboardingStatus, key: (typeof EERC_STEPS)[number]["key"]): boolean {
+ return !!onboarding.steps[key].completedAt;
+}
+
+function stepTx(onboarding: OnboardingStatus, key: (typeof EERC_STEPS)[number]["key"]): string | null {
+ return key === "allowlist" || key === "gas" ? onboarding.steps[key].txHash : null;
+}
+
function Step({ title, hint, children }: { title: string; hint: string; children: React.ReactNode }) {
return (
diff --git a/apps/console/src/app/RootGate.tsx b/apps/console/src/app/RootGate.tsx
index 93874dc..b3a1c46 100644
--- a/apps/console/src/app/RootGate.tsx
+++ b/apps/console/src/app/RootGate.tsx
@@ -1,9 +1,9 @@
/**
- * RootGate - decides between SIWE sign-in and the workspace. Real mode trusts the
- * cookie-backed /auth/me probe in the store; demo mode keeps the old local
- * onboarding flag so the seeded showcase still boots straight into the shell.
+ * RootGate - decides between SIWE sign-in, no-org onboarding, and the workspace.
+ * Real mode trusts the cookie-backed /auth/me probe in the store; demo mode keeps
+ * the old local onboarding flag so the seeded showcase still boots into the shell.
*/
-import { useState } from "react";
+import { useEffect, useState } from "react";
import { Shell } from "./Shell";
import { Onboarding } from "./Onboarding";
import { DesktopOnly, useIsDesktop } from "./DesktopOnly";
@@ -16,11 +16,23 @@ export function RootGate() {
// Demo mode skips SIWE onboarding entirely and boots into the Shell (the
// desktop-only gate below still applies — this stays a desktop product).
const [onboarded, setOnboarded] = useState(() => DEMO_MODE || localStorage.getItem("benzo.console.onboarded") === "1");
+ const [orgOnboarding, setOrgOnboarding] = useState(false);
function finish() {
localStorage.setItem("benzo.console.onboarded", "1");
setOnboarded(true);
void refresh();
}
+ function finishOrgOnboarding() {
+ setOrgOnboarding(false);
+ void refresh();
+ }
+ useEffect(() => {
+ if (!session) {
+ setOrgOnboarding(false);
+ return;
+ }
+ if (!session.activeOrg) setOrgOnboarding(true);
+ }, [session]);
if (!isDesktop) return ;
if (DEMO_MODE) return onboarded ? : ;
if (loading && !session) {
@@ -30,5 +42,6 @@ export function RootGate() {
);
}
- return session ?
:
void refresh()} />;
+ if (session && (orgOnboarding || !session.activeOrg)) return ;
+ return session?.activeOrg ? : void refresh()} />;
}
diff --git a/apps/console/src/demo/api.ts b/apps/console/src/demo/api.ts
index cbe46d1..3ea5223 100644
--- a/apps/console/src/demo/api.ts
+++ b/apps/console/src/demo/api.ts
@@ -8,9 +8,14 @@
*/
import type {
Counterparty,
+ CreateOrgResponse,
Invoice,
+ OnboardingStatus,
+ OnboardingStatusResponse,
PaymentOrder,
PayrollBatch,
+ ProvisionTreasuryResponse,
+ StartOnboardingResponse,
ViewingGrant,
} from "@benzo/types";
import type {
@@ -30,6 +35,7 @@ import { createDemoDb, dashboardSummary, treasuryView, usd } from "./data";
// PURE-annotated so a normal build (DEMO_MODE folds to false → demoApi unused)
// tree-shakes the entire demo graph away, leaving the production bundle unchanged.
const db = /* @__PURE__ */ createDemoDb();
+let demoMockKyc: { name?: string; country?: string } | undefined;
// ---- fake-chain helpers ---------------------------------------------------
function randHex(len: number): string {
@@ -65,6 +71,7 @@ const byId = (arr: T[], id: string) => arr.find((x) =>
const READ = 140;
const PROVE = 640;
const SETTLE = 720;
+const ONBOARDING_STEP = 520;
function activeOrg() {
const org = db.session.activeOrg;
@@ -72,6 +79,38 @@ function activeOrg() {
return org;
}
+const ONBOARDING_STATUSES = ["pending_kyc", "kyc_approved", "allowlisted", "gas_dripped", "awaiting_registration", "complete"] as const;
+
+function setDemoOnboardingStatus(status: OnboardingStatus["status"], mockKycPayload?: { name?: string; country?: string }): OnboardingStatus {
+ const order = ONBOARDING_STATUSES.indexOf(status as (typeof ONBOARDING_STATUSES)[number]);
+ const timestamp = now();
+ const kycDone = order >= 1;
+ const allowlistDone = order >= 2;
+ const gasDone = order >= 3;
+ const registrationChecked = order >= 4;
+ const registrationDone = order >= 5;
+ db.onboardingStatus = {
+ ...db.onboardingStatus,
+ status,
+ error: status === "failed" ? db.onboardingStatus.error ?? "Onboarding failed." : null,
+ updatedAt: timestamp,
+ mockKyc: kycDone
+ ? {
+ approvedAt: timestamp,
+ payload: mockKycPayload ?? db.onboardingStatus.mockKyc?.payload ?? {},
+ provider: "demo",
+ }
+ : null,
+ steps: {
+ kyc: { completedAt: kycDone ? timestamp : null, provider: kycDone ? "demo" : null },
+ allowlist: { completedAt: allowlistDone ? timestamp : null, result: allowlistDone ? { ok: true } : null, txHash: allowlistDone ? fakeTx() : null },
+ gas: { completedAt: gasDone ? timestamp : null, result: gasDone ? { ok: true } : null, txHash: gasDone ? fakeTx() : null },
+ registration: { completedAt: registrationDone ? timestamp : null, lastCheckedAt: registrationChecked ? timestamp : null },
+ },
+ };
+ return clone(db.onboardingStatus);
+}
+
export const demoApi = {
// ---- session / status ---------------------------------------------------
session: async () => (await delay(READ), clone(db.session)),
@@ -380,14 +419,54 @@ export const demoApi = {
return clone(p);
},
- // ---- onboarding / auth (unused: demo boots straight into the Shell) -----
- onboarding: async () => ({}),
- saveOnboarding: async (patch: unknown) => patch as Record,
- submitKyb: async () => ({ status: "approved" as const, provider: "demo", inquiryRef: `kyb_${randHex(6)}`, checks: ["identity", "sanctions"], onChain: true, txHash: fakeTx() }),
- kybStatus: async () => ({ status: "approved" as const, inquiryRef: `kyb_${randHex(6)}`, onChain: true }),
- provisionTreasury: async () => ({ onChain: true, txHash: fakeTx(), mvkRoot: fakeRoot(), treasuryAddress: db.publicAddress }),
- registerOwnerMvk: async () => ({ onChain: true, txHash: fakeTx(), mvkRoot: fakeRoot() }),
- finishOnboarding: async () => clone(db.session),
+ // ---- onboarding / auth -------------------------------------------------
+ createOrg: async (body: { name: string; slug: string }): Promise => {
+ await delay(READ);
+ const org = { id: `org_${randHex(6)}`, name: body.name, slug: body.slug, role: "owner" as const, createdAt: now() };
+ db.session.orgs = [org, ...db.session.orgs.filter((existing) => existing.id !== org.id)];
+ db.session.activeOrg = org;
+ db.session.role = "owner";
+ return { org: clone(org), role: "owner" };
+ },
+ startOnboarding: async (mockKyc?: { name?: string; country?: string }): Promise => {
+ await delay(READ);
+ demoMockKyc = mockKyc;
+ db.onboardingStatus = {
+ ...db.onboardingStatus,
+ id: `onb_${randHex(6)}`,
+ status: "pending_kyc",
+ error: null,
+ createdAt: now(),
+ updatedAt: now(),
+ mockKyc: null,
+ steps: {
+ kyc: { completedAt: null, provider: null },
+ allowlist: { completedAt: null, result: null, txHash: null },
+ gas: { completedAt: null, result: null, txHash: null },
+ registration: { completedAt: null, lastCheckedAt: null },
+ },
+ };
+ return { jobId: `job_${randHex(8)}`, onboarding: clone(db.onboardingStatus) };
+ },
+ onboardingStatus: async (): Promise => (await delay(READ), { onboarding: clone(db.onboardingStatus) }),
+ subscribeOnboardingStatus: (onStatus: (onboarding: OnboardingStatus) => void): { close: () => void } => {
+ let closed = false;
+ const timers = ONBOARDING_STATUSES.map((status, index) => window.setTimeout(() => {
+ if (closed) return;
+ onStatus(setDemoOnboardingStatus(status, demoMockKyc));
+ }, index * ONBOARDING_STEP));
+ return {
+ close: () => {
+ closed = true;
+ timers.forEach((timer) => window.clearTimeout(timer));
+ },
+ };
+ },
+ provisionTreasury: async (_orgId: string): Promise => {
+ await delay(SETTLE);
+ db.treasuryProvision = { ...db.treasuryProvision, registered: true, consented: true, registrationTxHash: fakeTx() };
+ return clone(db.treasuryProvision);
+ },
orgs: async () => ({ orgs: clone(db.session.orgs) }),
siweNonce: async () => ({ nonce: randHex(16) }),
siweVerify: async () => ({ user: clone(db.session.user) }),
diff --git a/apps/console/src/demo/data.ts b/apps/console/src/demo/data.ts
index fe5524e..3a9386d 100644
--- a/apps/console/src/demo/data.ts
+++ b/apps/console/src/demo/data.ts
@@ -15,8 +15,10 @@ import type {
LedgerEntry,
LiveStatusResponse,
Member,
+ OnboardingStatus,
PaymentOrder,
PayrollBatch,
+ ProvisionTreasuryResponse,
TreasuryView,
ViewingGrant,
} from "@benzo/types";
@@ -46,6 +48,8 @@ export interface DemoDb {
integrations: Integration[];
invites: OrgInvite[];
ledger: LedgerEntry[];
+ onboardingStatus: OnboardingStatus;
+ treasuryProvision: ProvisionTreasuryResponse;
/** Private (shielded) pool total, minor units — grows when "Make private" runs. */
privateTotal: string;
/** Public liquid USDC balance, minor units. */
@@ -64,6 +68,7 @@ const shielded = (handle: string, spend: string) => ({
});
export function createDemoDb(): DemoDb {
+ const publicAddress = "0x9Fb2c7A11e4D3f6B8a0C15e2D9f4A7c3B6e1D8a2";
const owner: Member = {
id: "mem_owner",
orgId: ORG_ID,
@@ -338,6 +343,33 @@ export function createDemoDb(): DemoDb {
{ id: "vg_1", kind: "grant", title: "Grant Thornton LLP · Q2", status: "active", amountLabel: "—", at: ISO("2026-07-01T00:00:00Z") },
];
+ const onboardingStatus: OnboardingStatus = {
+ id: "onb_demo",
+ userId: owner.id,
+ address: DEMO_OWNER_ADDRESS,
+ chainEnv: "demo",
+ chainId: 43113,
+ status: "complete",
+ error: null,
+ createdAt: ISO("2026-02-03"),
+ updatedAt: ISO("2026-02-03T00:05:00Z"),
+ mockKyc: { approvedAt: ISO("2026-02-03T00:01:00Z"), payload: { name: org.name, country: org.country }, provider: "demo" },
+ steps: {
+ kyc: { completedAt: ISO("2026-02-03T00:01:00Z"), provider: "demo" },
+ allowlist: { completedAt: ISO("2026-02-03T00:02:00Z"), result: { ok: true }, txHash: fakeHash("a110") },
+ gas: { completedAt: ISO("2026-02-03T00:03:00Z"), result: { ok: true }, txHash: fakeHash("9a5") },
+ registration: { completedAt: ISO("2026-02-03T00:04:00Z"), lastCheckedAt: ISO("2026-02-03T00:04:00Z") },
+ },
+ };
+
+ const treasuryProvision: ProvisionTreasuryResponse = {
+ address: publicAddress,
+ custody: "managed",
+ registered: true,
+ consented: true,
+ registrationTxHash: fakeHash("eerc"),
+ };
+
return {
session,
live: { live: true, mode: "live", missing: [] },
@@ -352,9 +384,11 @@ export function createDemoDb(): DemoDb {
integrations,
invites,
ledger,
+ onboardingStatus,
+ treasuryProvision,
privateTotal: usd(842300),
publicUnits: usd(48250),
- publicAddress: "0x9Fb2c7A11e4D3f6B8a0C15e2D9f4A7c3B6e1D8a2",
+ publicAddress,
usdcIssuer: "0x5425890298aed601595a70AB815c96711a31Bc65",
dashboardActivity,
};
diff --git a/apps/console/src/lib/api.test.ts b/apps/console/src/lib/api.test.ts
index c0513da..1869af1 100644
--- a/apps/console/src/lib/api.test.ts
+++ b/apps/console/src/lib/api.test.ts
@@ -1,5 +1,5 @@
import { afterEach, describe, expect, it, vi } from "vitest";
-import type { CreatePaymentRequest } from "@benzo/types";
+import type { CreatePaymentRequest, OnboardingStatus } from "@benzo/types";
import { ACTIVE_ORG_KEY, api, apiHref } from "./api";
function jsonResponse(body: unknown, status = 200): Response {
@@ -15,6 +15,27 @@ function callHeaders(call: unknown[]): Headers {
: new Headers();
}
+function onboardingStatus(status: OnboardingStatus["status"]): OnboardingStatus {
+ return {
+ id: "onb_1",
+ userId: "usr_1",
+ address: "0x1234567890abcdef1234567890abcdef12345678",
+ chainEnv: "testnet",
+ chainId: 43113,
+ status,
+ error: status === "failed" ? "failed" : null,
+ createdAt: "2026-07-10T00:00:00.000Z",
+ updatedAt: "2026-07-10T00:00:02.000Z",
+ mockKyc: null,
+ steps: {
+ kyc: { completedAt: status === "pending_kyc" ? null : "2026-07-10T00:00:01.000Z", provider: status === "pending_kyc" ? null : "mock" },
+ allowlist: { completedAt: ["allowlisted", "gas_dripped", "awaiting_registration", "complete"].includes(status) ? "2026-07-10T00:00:02.000Z" : null, result: null, txHash: null },
+ gas: { completedAt: ["gas_dripped", "awaiting_registration", "complete"].includes(status) ? "2026-07-10T00:00:03.000Z" : null, result: null, txHash: null },
+ registration: { completedAt: status === "complete" ? "2026-07-10T00:00:04.000Z" : null, lastCheckedAt: ["awaiting_registration", "complete"].includes(status) ? "2026-07-10T00:00:04.000Z" : null },
+ },
+ };
+}
+
describe("console API idempotency", () => {
afterEach(() => {
localStorage.clear();
@@ -73,12 +94,11 @@ describe("console API idempotency", () => {
amount: { amount: "10000000", assetCode: "USDC" },
} satisfies CreatePaymentRequest;
const actions: Array<() => Promise> = [
- () => api.saveOnboarding({ name: "Acme" }),
+ () => api.createOrg({ name: "Acme", slug: "acme" }),
+ () => api.startOnboarding({ name: "Acme Inc.", country: "US" }),
() => api.siweVerify("m", "0xsig"),
() => api.logout(),
- () => api.submitKyb({ legalName: "Acme Inc." }),
- () => api.provisionTreasury(),
- () => api.finishOnboarding(),
+ () => api.provisionTreasury("org_1"),
() => api.proveBalance("1"),
() => api.proveTotal(),
() => api.proveSolvency(),
@@ -129,6 +149,73 @@ describe("console API idempotency", () => {
expect(callHeaders(fetchMock.mock.calls[1]).get("idempotency-key")).toBe(firstKey);
});
+ it("uses the real org and eERC onboarding endpoints", async () => {
+ const complete = onboardingStatus("complete");
+ const fetchMock = vi.fn()
+ .mockResolvedValueOnce(jsonResponse({ org: { id: "org_1", name: "Acme", slug: "acme", role: "owner", createdAt: "2026-07-10T00:00:00.000Z" }, role: "owner" }))
+ .mockResolvedValueOnce(jsonResponse({ jobId: "job_1", onboarding: onboardingStatus("pending_kyc") }, 202))
+ .mockResolvedValueOnce(jsonResponse({ onboarding: complete }))
+ .mockResolvedValueOnce(jsonResponse({ address: "0xtreasury", custody: "managed", registered: true, consented: true, registrationTxHash: "0xreg" }, 201));
+ vi.stubGlobal("fetch", fetchMock);
+
+ await expect(api.createOrg({ name: "Acme", slug: "acme" })).resolves.toMatchObject({ role: "owner", org: { id: "org_1" } });
+ await expect(api.startOnboarding({ name: "Acme Inc.", country: "US" })).resolves.toMatchObject({ jobId: "job_1" });
+ await expect(api.onboardingStatus()).resolves.toMatchObject({ onboarding: { status: "complete" } });
+ await expect(api.provisionTreasury("org_1")).resolves.toMatchObject({ address: "0xtreasury", custody: "managed" });
+
+ expect(fetchMock.mock.calls.map((call) => call[0])).toEqual([
+ apiHref("/orgs"),
+ apiHref("/onboarding/start"),
+ apiHref("/onboarding/status"),
+ apiHref("/orgs/org_1/treasury"),
+ ]);
+ expect(fetchMock.mock.calls[0][1]).toMatchObject({ method: "POST", body: JSON.stringify({ name: "Acme", slug: "acme" }), credentials: "include" });
+ expect(fetchMock.mock.calls[1][1]).toMatchObject({ method: "POST", body: JSON.stringify({ mockKyc: { name: "Acme Inc.", country: "US" } }), credentials: "include" });
+ expect(callHeaders(fetchMock.mock.calls[2]).get("idempotency-key")).toBeNull();
+ expect(fetchMock.mock.calls[3][1]).toMatchObject({ method: "POST", body: JSON.stringify({ consent: true }), credentials: "include" });
+ });
+
+ it("subscribes to onboarding status with credentialed EventSource", () => {
+ class FakeEventSource {
+ static instances: FakeEventSource[] = [];
+ url: string | URL;
+ init?: EventSourceInit;
+ private listeners = new Map) => void>>();
+
+ constructor(url: string | URL, init?: EventSourceInit) {
+ this.url = url;
+ this.init = init;
+ FakeEventSource.instances.push(this);
+ }
+
+ addEventListener(type: string, listener: EventListenerOrEventListenerObject | null) {
+ if (typeof listener !== "function") return;
+ const listeners = this.listeners.get(type) ?? [];
+ listeners.push(listener as (event: MessageEvent) => void);
+ this.listeners.set(type, listeners);
+ }
+
+ close() {}
+
+ emit(type: string, data: unknown) {
+ const event = new MessageEvent(type, { data: JSON.stringify(data) });
+ for (const listener of this.listeners.get(type) ?? []) listener(event);
+ }
+ }
+ vi.stubGlobal("EventSource", FakeEventSource as unknown as typeof EventSource);
+ const seen: OnboardingStatus["status"][] = [];
+
+ const subscription = api.subscribeOnboardingStatus((status) => seen.push(status.status));
+ const source = FakeEventSource.instances[0];
+ source.emit("status", { onboarding: onboardingStatus("kyc_approved") });
+ source.emit("status", { onboarding: onboardingStatus("complete") });
+ subscription.close();
+
+ expect(source.url).toBe(apiHref("/onboarding/status/stream"));
+ expect(source.init).toMatchObject({ withCredentials: true });
+ expect(seen).toEqual(["kyc_approved", "complete"]);
+ });
+
it("loads proof receipts through the authenticated API without mutation idempotency", async () => {
const fetchMock = vi.fn().mockResolvedValue(jsonResponse([{ id: "prf_1", action: "payroll.policy.cap", vkId: "SPENDCAP", verified: true, createdAt: "2026-06-26T00:00:00.000Z" }]));
vi.stubGlobal("fetch", fetchMock);
diff --git a/apps/console/src/lib/api.ts b/apps/console/src/lib/api.ts
index 327dd18..b88b491 100644
--- a/apps/console/src/lib/api.ts
+++ b/apps/console/src/lib/api.ts
@@ -10,6 +10,8 @@ import type {
ApproveRequest,
AuthSession,
Counterparty,
+ CreateOrgRequest,
+ CreateOrgResponse,
CreateInvoiceRequest,
CreatePaymentRequest,
CreatePayrollRequest,
@@ -20,9 +22,13 @@ import type {
LedgerEntry,
LiveStatusResponse,
Member,
+ OnboardingStatus,
+ OnboardingStatusResponse,
OrgSummary,
PaymentOrder,
PayrollBatch,
+ ProvisionTreasuryResponse,
+ StartOnboardingResponse,
TreasuryView,
ViewingGrant,
} from "@benzo/types";
@@ -242,18 +248,101 @@ async function http(path: string, init?: RequestInit): Promise {
}
}
-export interface OnboardingDraft {
- name?: string;
- legalName?: string;
- country?: string;
- entityType?: string;
- registrationNumber?: string;
- taxId?: string;
- beneficialOwners?: Array<{ name: string; ownership?: string }>;
- complianceZoneId?: string;
- team?: Array<{ email: string; role: string }>;
- kyb?: { status: "approved" | "pending" | "rejected" | "unverified"; provider: string; inquiryRef: string; checks: string[]; onChain: boolean; txHash?: string };
- mvk?: { onChain: boolean; txHash?: string; mvkRoot?: string };
+export interface OnboardingStatusSubscription {
+ close: () => void;
+}
+
+export type OnboardingStatusHandler = (onboarding: OnboardingStatus) => void;
+export type OnboardingStatusErrorHandler = (error: Error) => void;
+
+function terminalOnboardingStatus(status: OnboardingStatus["status"]): boolean {
+ return status === "complete" || status === "failed";
+}
+
+function parseOnboardingEvent(data: string): OnboardingStatus | null {
+ if (!data) return null;
+ const parsed = JSON.parse(data) as OnboardingStatus | OnboardingStatusResponse;
+ return "onboarding" in parsed ? parsed.onboarding : parsed;
+}
+
+function subscribeOnboardingStatus(
+ onStatus: OnboardingStatusHandler,
+ onError?: OnboardingStatusErrorHandler,
+): OnboardingStatusSubscription {
+ let closed = false;
+ let source: EventSource | null = null;
+ let pollTimer: number | null = null;
+
+ const close = () => {
+ closed = true;
+ source?.close();
+ source = null;
+ if (pollTimer) window.clearTimeout(pollTimer);
+ pollTimer = null;
+ };
+
+ const emit = (onboarding: OnboardingStatus) => {
+ onStatus(onboarding);
+ if (terminalOnboardingStatus(onboarding.status)) close();
+ };
+
+ const poll = () => {
+ if (closed) return;
+ void realApi.onboardingStatus().then(
+ ({ onboarding }) => {
+ if (closed) return;
+ emit(onboarding);
+ if (!terminalOnboardingStatus(onboarding.status)) {
+ pollTimer = window.setTimeout(poll, 2_000);
+ }
+ },
+ (error) => {
+ if (closed) return;
+ onError?.(error instanceof Error ? error : new Error("Onboarding status polling failed."));
+ pollTimer = window.setTimeout(poll, 2_000);
+ },
+ );
+ };
+
+ const startPolling = () => {
+ source?.close();
+ source = null;
+ poll();
+ };
+
+ if (typeof EventSource === "undefined") {
+ startPolling();
+ return { close };
+ }
+
+ try {
+ source = new EventSource(apiHref("/onboarding/status/stream"), { withCredentials: true });
+ source.addEventListener("status", (event) => {
+ try {
+ const onboarding = parseOnboardingEvent((event as MessageEvent).data);
+ if (onboarding) emit(onboarding);
+ } catch (error) {
+ onError?.(error instanceof Error ? error : new Error("Onboarding status stream returned malformed data."));
+ }
+ });
+ source.addEventListener("terminal", (event) => {
+ try {
+ const onboarding = parseOnboardingEvent((event as MessageEvent).data);
+ if (onboarding) emit(onboarding);
+ } catch {
+ close();
+ }
+ close();
+ });
+ source.onerror = () => {
+ if (closed) return;
+ startPolling();
+ };
+ } catch {
+ startPolling();
+ }
+
+ return { close };
}
export interface SiweNonceResponse {
@@ -314,18 +403,14 @@ const realApi = {
siweVerify: (message: string, signature: string) =>
http("/auth/verify", { method: "POST", body: JSON.stringify({ message, signature }) }),
logout: () => http<{ ok: true }>("/auth/logout", { method: "POST", body: "{}" }).finally(clearHostedAuthState),
- onboarding: () => http("/onboarding"),
- saveOnboarding: (patch: OnboardingDraft) =>
- http("/onboarding", { method: "PATCH", body: JSON.stringify(patch) }),
- submitKyb: (patch: OnboardingDraft) =>
- http>("/onboarding/kyb", { method: "POST", body: JSON.stringify(patch) }),
- kybStatus: () =>
- http<{ status: "unverified" | "pending" | "approved" | "rejected"; inquiryRef: string; onChain: boolean }>("/onboarding/kyb-status"),
- provisionTreasury: () =>
- http<{ onChain: boolean; txHash?: string; mvkRoot?: string; treasuryAddress?: string }>("/treasury/provision", { method: "POST", body: "{}" }),
- registerOwnerMvk: () =>
- http<{ onChain: boolean; txHash?: string; mvkRoot?: string }>("/treasury/provision", { method: "POST", body: "{}" }),
- finishOnboarding: () => http("/onboarding/finish", { method: "POST", body: "{}" }),
+ createOrg: (body: CreateOrgRequest) =>
+ http("/orgs", { method: "POST", body: JSON.stringify(body) }),
+ startOnboarding: (mockKyc?: { name?: string; country?: string }) =>
+ http("/onboarding/start", { method: "POST", body: JSON.stringify(mockKyc ? { mockKyc } : {}) }),
+ onboardingStatus: () => http("/onboarding/status"),
+ subscribeOnboardingStatus,
+ provisionTreasury: (orgId: string) =>
+ http(`/orgs/${encodeURIComponent(orgId)}/treasury`, { method: "POST", body: JSON.stringify({ consent: true }) }),
live: () => http("/live"),
dashboard: () => http("/dashboard"),
treasury: () => http("/treasury"),
diff --git a/packages/types/src/api.ts b/packages/types/src/api.ts
index aa8926a..b749757 100644
--- a/packages/types/src/api.ts
+++ b/packages/types/src/api.ts
@@ -7,7 +7,7 @@
import type { Account, Counterparty } from "./accounts.js";
import type { Approval, ApprovalPolicy } from "./approvals.js";
import type { ComplianceZone, DisclosureTier, GrantScope, ViewingGrant } from "./compliance.js";
-import type { Money, Timestamp } from "./common.js";
+import type { EvmAddress, Money, Timestamp } from "./common.js";
import type { Integration, IntegrationProvider } from "./integrations.js";
import type { Invoice, LineItem } from "./invoices.js";
import type { LedgerEntry } from "./ledger.js";
@@ -32,6 +32,67 @@ export interface AppSession {
export type AuthSession = AppSession;
+// ---- org creation / eERC onboarding --------------------------------------
+
+export interface CreateOrgRequest {
+ name: string;
+ slug: string;
+}
+
+export interface CreateOrgResponse {
+ org: OrgSummary;
+ role: "owner";
+}
+
+export type OnboardingLifecycleStatus =
+ | "pending_kyc"
+ | "kyc_approved"
+ | "allowlisted"
+ | "gas_dripped"
+ | "awaiting_registration"
+ | "complete"
+ | "failed";
+
+export interface OnboardingStatus {
+ id: string;
+ userId: string;
+ address: EvmAddress;
+ chainEnv: string;
+ chainId: number;
+ status: OnboardingLifecycleStatus;
+ error: string | null;
+ createdAt: Timestamp;
+ updatedAt: Timestamp;
+ mockKyc: {
+ approvedAt: Timestamp;
+ payload: Record;
+ provider: string;
+ } | null;
+ steps: {
+ kyc: { completedAt: Timestamp | null; provider: string | null };
+ allowlist: { completedAt: Timestamp | null; result: unknown | null; txHash: string | null };
+ gas: { completedAt: Timestamp | null; result: unknown | null; txHash: string | null };
+ registration: { completedAt: Timestamp | null; lastCheckedAt: Timestamp | null };
+ };
+}
+
+export interface StartOnboardingResponse {
+ jobId: string;
+ onboarding: OnboardingStatus;
+}
+
+export interface OnboardingStatusResponse {
+ onboarding: OnboardingStatus;
+}
+
+export interface ProvisionTreasuryResponse {
+ address: EvmAddress;
+ custody: "managed";
+ registered: boolean;
+ consented: boolean;
+ registrationTxHash: string | null;
+}
+
// ---- dashboard / treasury (read-optimized projections) --------------------
export interface ActivityItem {
@@ -142,6 +203,10 @@ export interface InviteMemberRequest {
role: Role;
}
+export interface ProvisionTreasuryRequest {
+ consent: true;
+}
+
export interface CreateViewingGrantRequest {
auditorName: string;
auditorPubKey: string;
@@ -184,7 +249,12 @@ export const ENDPOINTS = {
session: { method: "GET", path: "/api/auth/me" },
authLogout: { method: "POST", path: "/api/auth/logout" },
orgs: { method: "GET", path: "/api/orgs" },
+ createOrg: { method: "POST", path: "/api/orgs" },
org: { method: "GET", path: "/api/orgs/:id" },
+ onboardingStart: { method: "POST", path: "/api/onboarding/start" },
+ onboardingStatus: { method: "GET", path: "/api/onboarding/status" },
+ onboardingStatusStream: { method: "GET", path: "/api/onboarding/status/stream" },
+ provisionTreasury: { method: "POST", path: "/api/orgs/:id/treasury" },
dashboard: { method: "GET", path: "/api/dashboard" },
treasury: { method: "GET", path: "/api/treasury" },
proveBalance: { method: "POST", path: "/api/treasury/prove-balance" },