diff --git a/.gitignore b/.gitignore
index 81cd2eef5..daa145939 100644
--- a/.gitignore
+++ b/.gitignore
@@ -54,6 +54,9 @@ test-config.yml
dist/
build/
lib/
+# The GGA example app keeps decoupled source modules under src/lib/ (not a
+# compiled-output dir), so it must not be swept up by the `lib/` rule above.
+!apps/examples/grid-global-accounts-example-app/src/lib/
# Vim swap files
*.swp
@@ -126,3 +129,6 @@ stats.html
# Dev proxy cookies (contains ALB session tokens)
.dev-proxy-cookies
+
+# Playwright MCP logs
+.playwright-mcp/
diff --git a/.mise.toml b/.mise.toml
index c2ed2775e..cc3954caf 100644
--- a/.mise.toml
+++ b/.mise.toml
@@ -2,7 +2,7 @@
MISE_NODE_COREPACK = true
[tools]
-node = "20.19"
+node = "20.19.6"
[tasks.deps]
description = "Install all JS dependencies"
diff --git a/.yarnrc.yml b/.yarnrc.yml
index 614d67f60..13ab38360 100644
--- a/.yarnrc.yml
+++ b/.yarnrc.yml
@@ -1,10 +1,10 @@
compressionLevel: mixed
-enableGlobalCache: false
+enableGlobalCache: true
nodeLinker: node-modules
-npmMinimalAgeGate: 1440
+npmMinimalAgeGate: 4320 # 3 days
npmPreapprovedPackages:
- "@lightsparkdev/*"
diff --git a/CLAUDE.md b/CLAUDE.md
index 9cb1ab5d8..f16360a27 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -45,6 +45,9 @@ yarn workspace @lightsparkdev/ui test
- Use workspace protocol for internal deps: `"@lightsparkdev/ui": "*"`
- Shared configs: `@lightsparkdev/{tsconfig,eslint-config}`
+### Changesets
+Always prefer `patch` version increments unless a maintainer explicitly calls for `minor`/`major`.
+
### Enums
Prefer generated TypeScript enums from `src/generated/graphql` rather than raw strings when
available. This ensures type safety and keeps code in sync with the schema.
diff --git a/apps/examples/grid-global-accounts-example-app/.gitignore b/apps/examples/grid-global-accounts-example-app/.gitignore
new file mode 100644
index 000000000..a144fe599
--- /dev/null
+++ b/apps/examples/grid-global-accounts-example-app/.gitignore
@@ -0,0 +1,2 @@
+# Verification screenshots — captured locally, not tracked.
+.screenshots/
diff --git a/apps/examples/grid-global-accounts-example-app/index.html b/apps/examples/grid-global-accounts-example-app/index.html
new file mode 100644
index 000000000..f65f09a18
--- /dev/null
+++ b/apps/examples/grid-global-accounts-example-app/index.html
@@ -0,0 +1,12 @@
+
+
+
+
+
+ Grid Global Accounts
+
+
+
+
+
+
diff --git a/apps/examples/grid-global-accounts-example-app/package.json b/apps/examples/grid-global-accounts-example-app/package.json
new file mode 100644
index 000000000..0349edf13
--- /dev/null
+++ b/apps/examples/grid-global-accounts-example-app/package.json
@@ -0,0 +1,30 @@
+{
+ "name": "@lightsparkdev/grid-global-accounts-example-app",
+ "private": true,
+ "version": "0.0.1",
+ "scripts": {
+ "dev": "vite",
+ "build": "tsc && vite build",
+ "start": "vite",
+ "preview": "vite preview",
+ "test": "vitest run"
+ },
+ "devDependencies": {
+ "@types/react": "^19.2.15",
+ "@types/react-dom": "^19.2.3",
+ "@vitejs/plugin-react": "^5.2.0",
+ "typescript": "^5.6.2",
+ "vite": "^8.0.14",
+ "vitest": "^4.1.7"
+ },
+ "dependencies": {
+ "@emotion/react": "^11.14.0",
+ "@emotion/styled": "^11.14.1",
+ "@lightsparkdev/origin": "*",
+ "@turnkey/api-key-stamper": "^0.6.5",
+ "@turnkey/crypto": "^2.8.14",
+ "@turnkey/encoding": "^0.6.0",
+ "react": "^19.2.6",
+ "react-dom": "^19.2.6"
+ }
+}
diff --git a/apps/examples/grid-global-accounts-example-app/src/App.tsx b/apps/examples/grid-global-accounts-example-app/src/App.tsx
new file mode 100644
index 000000000..1b491ca9e
--- /dev/null
+++ b/apps/examples/grid-global-accounts-example-app/src/App.tsx
@@ -0,0 +1,19 @@
+import { Shell } from "./components/Shell";
+import { AppStateProvider, useAppState } from "./state/store";
+import { CustomerView } from "./views/customer/CustomerView";
+import { PlatformView } from "./views/platform/PlatformView";
+
+export function App() {
+ return (
+
+
+
+
+
+ );
+}
+
+function Router() {
+ const { persona } = useAppState();
+ return persona === "platform" ? : ;
+}
diff --git a/apps/examples/grid-global-accounts-example-app/src/__tests__/session.test.ts b/apps/examples/grid-global-accounts-example-app/src/__tests__/session.test.ts
new file mode 100644
index 000000000..b6e3f4b7a
--- /dev/null
+++ b/apps/examples/grid-global-accounts-example-app/src/__tests__/session.test.ts
@@ -0,0 +1,136 @@
+import { afterEach, describe, expect, it } from "vitest";
+
+import {
+ clearActiveSession,
+ getAccountId,
+ getSessionId,
+ getSessionModel,
+ hasSessionSigningKey,
+ resolveSessionKeys,
+ setAccountId,
+ setActiveSessionAccount,
+ setSessionId,
+ setSessionKeysFromTek,
+} from "../session";
+
+// Reset to logged-out between tests so module-level context state can't leak.
+afterEach(() => {
+ setActiveSessionAccount(null);
+});
+
+describe("per-customer session isolation", () => {
+ it("keeps signing keys, model, and account id independent per account key", () => {
+ // No active context → logged-out, getters return empty/null.
+ setActiveSessionAccount(null);
+ expect(hasSessionSigningKey()).toBe(false);
+ expect(resolveSessionKeys()).toBeNull();
+ expect(getAccountId()).toBe("");
+ expect(getSessionModel()).toBe("none");
+
+ // Customer A signs in (OTP-TEK model) under its own account key.
+ setActiveSessionAccount("InternalAccount:A");
+ setAccountId("InternalAccount:A");
+ setSessionKeysFromTek({ publicKey: "pubA", privateKey: "privA" });
+ expect(hasSessionSigningKey()).toBe(true);
+ expect(getSessionModel()).toBe("otp-tek");
+ expect(resolveSessionKeys()).toEqual({
+ apiPublicKey: "pubA",
+ apiPrivateKey: "privA",
+ });
+ expect(getAccountId()).toBe("InternalAccount:A");
+
+ // Switch to a fresh customer B: it inherits NOTHING from A.
+ setActiveSessionAccount("InternalAccount:B");
+ expect(hasSessionSigningKey()).toBe(false);
+ expect(resolveSessionKeys()).toBeNull();
+ expect(getSessionModel()).toBe("none");
+ expect(getAccountId()).toBe("");
+
+ // B establishes its own session with different keys.
+ setAccountId("InternalAccount:B");
+ setSessionKeysFromTek({ publicKey: "pubB", privateKey: "privB" });
+ expect(resolveSessionKeys()).toEqual({
+ apiPublicKey: "pubB",
+ apiPrivateKey: "privB",
+ });
+ expect(getAccountId()).toBe("InternalAccount:B");
+
+ // Switching back to A restores A's cached session, untouched by B.
+ setActiveSessionAccount("InternalAccount:A");
+ expect(hasSessionSigningKey()).toBe(true);
+ expect(getSessionModel()).toBe("otp-tek");
+ expect(resolveSessionKeys()).toEqual({
+ apiPublicKey: "pubA",
+ apiPrivateKey: "privA",
+ });
+ expect(getAccountId()).toBe("InternalAccount:A");
+ });
+
+ it("treats null as logged-out with no active context", () => {
+ setActiveSessionAccount("InternalAccount:A");
+ setSessionKeysFromTek({ publicKey: "pubA", privateKey: "privA" });
+ expect(hasSessionSigningKey()).toBe(true);
+
+ setActiveSessionAccount(null);
+ expect(hasSessionSigningKey()).toBe(false);
+ expect(resolveSessionKeys()).toBeNull();
+ expect(getAccountId()).toBe("");
+
+ // Mutators no-op while logged-out; later re-activation still has A cached.
+ setSessionKeysFromTek({ publicKey: "pubX", privateKey: "privX" });
+ setActiveSessionAccount("InternalAccount:A");
+ expect(resolveSessionKeys()).toEqual({
+ apiPublicKey: "pubA",
+ apiPrivateKey: "privA",
+ });
+ });
+
+ it("clearActiveSession wipes the active context's signing key without touching others", () => {
+ // Customer A signs in under its own account key.
+ setActiveSessionAccount("InternalAccount:clearA");
+ setAccountId("InternalAccount:clearA");
+ setSessionId("session-A");
+ setSessionKeysFromTek({ publicKey: "pubA", privateKey: "privA" });
+
+ // Customer B signs in under a different key.
+ setActiveSessionAccount("InternalAccount:clearB");
+ setAccountId("InternalAccount:clearB");
+ setSessionId("session-B");
+ setSessionKeysFromTek({ publicKey: "pubB", privateKey: "privB" });
+
+ // Back on A, clearing wipes A's signing key/model/session id but keeps the
+ // account id so the slot still belongs to A (logged out, can re-auth).
+ setActiveSessionAccount("InternalAccount:clearA");
+ expect(hasSessionSigningKey()).toBe(true);
+ clearActiveSession();
+ expect(hasSessionSigningKey()).toBe(false);
+ expect(resolveSessionKeys()).toBeNull();
+ expect(getSessionModel()).toBe("none");
+ expect(getSessionId()).toBe("");
+ expect(getAccountId()).toBe("InternalAccount:clearA");
+
+ // B is untouched by clearing A.
+ setActiveSessionAccount("InternalAccount:clearB");
+ expect(hasSessionSigningKey()).toBe(true);
+ expect(getSessionModel()).toBe("otp-tek");
+ expect(getSessionId()).toBe("session-B");
+ expect(resolveSessionKeys()).toEqual({
+ apiPublicKey: "pubB",
+ apiPrivateKey: "privB",
+ });
+ });
+
+ it("clearActiveSession is a no-op when logged out", () => {
+ setActiveSessionAccount(null);
+ expect(() => clearActiveSession()).not.toThrow();
+ expect(hasSessionSigningKey()).toBe(false);
+ });
+
+ it("preserves first-account-wins for setAccountId within a fresh context", () => {
+ // A distinct key so the context is fresh (contexts persist across tests).
+ setActiveSessionAccount("InternalAccount:C");
+ setAccountId("first");
+ setAccountId("second");
+ expect(getAccountId()).toBe("first");
+ });
+});
diff --git a/apps/examples/grid-global-accounts-example-app/src/__tests__/webauthn-options.test.ts b/apps/examples/grid-global-accounts-example-app/src/__tests__/webauthn-options.test.ts
new file mode 100644
index 000000000..4da827c3a
--- /dev/null
+++ b/apps/examples/grid-global-accounts-example-app/src/__tests__/webauthn-options.test.ts
@@ -0,0 +1,86 @@
+import { describe, expect, it } from "vitest";
+
+import {
+ buildAllowCredentials,
+ buildAssertionOptions,
+ buildCreationOptions,
+ bytesToB64Url,
+ SECURITY_KEY_TRANSPORTS,
+} from "../webauthn";
+
+const RP = "localhost";
+
+describe("buildCreationOptions — forces a cross-platform security key", () => {
+ const challenge = new Uint8Array([1, 2, 3]);
+ const userId = new Uint8Array([9, 9]);
+ const opts = buildCreationOptions("My key", RP, challenge, userId);
+
+ it("requests a cross-platform (roaming) authenticator, not the platform one", () => {
+ expect(opts.authenticatorSelection?.authenticatorAttachment).toBe(
+ "cross-platform",
+ );
+ });
+
+ it("uses security-key-friendly resident-key + UV settings", () => {
+ expect(opts.authenticatorSelection?.residentKey).toBe("discouraged");
+ expect(opts.authenticatorSelection?.requireResidentKey).toBe(false);
+ expect(opts.authenticatorSelection?.userVerification).toBe("preferred");
+ });
+
+ it("offers ES256 (-7) in pubKeyCredParams", () => {
+ expect(opts.pubKeyCredParams).toContainEqual({
+ type: "public-key",
+ alg: -7,
+ });
+ });
+
+ it("sets the rp id and passes the challenge/user through", () => {
+ expect(opts.rp.id).toBe(RP);
+ expect(opts.challenge).toBe(challenge);
+ expect(opts.user.id).toBe(userId);
+ });
+});
+
+describe("buildAllowCredentials — targets the security key over USB/NFC", () => {
+ // A valid base64url credential id (decodes cleanly via atob).
+ const idA = bytesToB64Url(new Uint8Array([10, 20, 30]));
+ const idB = bytesToB64Url(new Uint8Array([40, 50, 60]));
+
+ it("includes every registered id with usb/nfc transports", () => {
+ const out = buildAllowCredentials([idA, idB]);
+ expect(out).toHaveLength(2);
+ for (const d of out) {
+ expect(d.type).toBe("public-key");
+ expect(d.transports).toEqual(SECURITY_KEY_TRANSPORTS);
+ expect(d.transports).toEqual(["usb", "nfc"]);
+ }
+ });
+
+ it("drops blank and duplicate ids", () => {
+ const out = buildAllowCredentials([idA, "", " ", idA]);
+ expect(out).toHaveLength(1);
+ });
+
+ it("returns [] when no ids are known (discoverable-credential fallback)", () => {
+ expect(buildAllowCredentials([])).toEqual([]);
+ });
+});
+
+describe("buildAssertionOptions", () => {
+ const challenge = new Uint8Array([7]);
+ const id = bytesToB64Url(new Uint8Array([1, 2, 3, 4]));
+
+ it("wires the rp id, challenge, UV and the allowCredentials", () => {
+ const opts = buildAssertionOptions(challenge, [id], RP);
+ expect(opts.rpId).toBe(RP);
+ expect(opts.challenge).toBe(challenge);
+ expect(opts.userVerification).toBe("preferred");
+ expect(opts.allowCredentials).toHaveLength(1);
+ expect(opts.allowCredentials?.[0].transports).toEqual(["usb", "nfc"]);
+ });
+
+ it("yields an empty allowCredentials when no ids are known", () => {
+ const opts = buildAssertionOptions(challenge, [], RP);
+ expect(opts.allowCredentials).toEqual([]);
+ });
+});
diff --git a/apps/examples/grid-global-accounts-example-app/src/api-client.ts b/apps/examples/grid-global-accounts-example-app/src/api-client.ts
new file mode 100644
index 000000000..40e550d11
--- /dev/null
+++ b/apps/examples/grid-global-accounts-example-app/src/api-client.ts
@@ -0,0 +1,112 @@
+// HTTP client + auth header + mode resolution.
+//
+// DOM-free: the platform credentials (client id / secret) and the active mode
+// are passed in via an `ApiAuth` value instead of being read out of input
+// elements, so the same client works from React, tests, or any caller that can
+// supply the credentials it already holds.
+
+import { API_BASE, type Mode } from "./config";
+
+export interface ApiAuth {
+ clientId: string;
+ clientSecret: string;
+ mode: Mode;
+}
+
+// Fail a stalled request instead of spinning forever — a guided op that hangs
+// server-side surfaces as a clear timeout rather than an indefinite wait.
+const REQUEST_TIMEOUT_MS = 30_000;
+
+export function resolveMode(value: string | undefined): Mode {
+ return value === "production" ? "production" : "sandbox";
+}
+
+function authHeader(auth: ApiAuth): string {
+ return "Basic " + btoa(`${auth.clientId.trim()}:${auth.clientSecret.trim()}`);
+}
+
+async function timedFetch(path: string, init: RequestInit): Promise {
+ const controller = new AbortController();
+ const timer = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
+ try {
+ return await fetch(API_BASE + path, { ...init, signal: controller.signal });
+ } catch (err) {
+ if (controller.signal.aborted)
+ throw new Error(
+ `Request to ${path} timed out after ${REQUEST_TIMEOUT_MS / 1000}s.`,
+ );
+ throw err;
+ } finally {
+ clearTimeout(timer);
+ }
+}
+
+export async function apiPost(
+ auth: ApiAuth,
+ path: string,
+ body: Record | undefined,
+ extraHeaders: Record = {},
+): Promise<{ status: number; data: unknown }> {
+ const res = await timedFetch(path, {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ Authorization: authHeader(auth),
+ ...extraHeaders,
+ },
+ body: body === undefined ? undefined : JSON.stringify(body),
+ });
+ const raw = await res.text();
+ const data = raw ? JSON.parse(raw) : null;
+ if (!res.ok) throw new Error(`HTTP ${res.status}: ${raw}`);
+ return { status: res.status, data };
+}
+
+export async function apiDelete(
+ auth: ApiAuth,
+ path: string,
+ extraHeaders: Record = {},
+): Promise<{ status: number; data: unknown }> {
+ const res = await timedFetch(path, {
+ method: "DELETE",
+ headers: {
+ Authorization: authHeader(auth),
+ ...extraHeaders,
+ },
+ });
+ const raw = await res.text();
+ const data = raw ? JSON.parse(raw) : null;
+ if (!res.ok) throw new Error(`HTTP ${res.status}: ${raw}`);
+ return { status: res.status, data };
+}
+
+export async function apiPatch(
+ auth: ApiAuth,
+ path: string,
+ body: Record,
+ extraHeaders: Record = {},
+): Promise<{ status: number; data: unknown }> {
+ const res = await timedFetch(path, {
+ method: "PATCH",
+ headers: {
+ "Content-Type": "application/json",
+ Authorization: authHeader(auth),
+ ...extraHeaders,
+ },
+ body: JSON.stringify(body),
+ });
+ const raw = await res.text();
+ const data = raw ? JSON.parse(raw) : null;
+ if (!res.ok) throw new Error(`HTTP ${res.status}: ${raw}`);
+ return { status: res.status, data };
+}
+
+export async function apiGet(auth: ApiAuth, path: string): Promise {
+ const res = await timedFetch(path, {
+ headers: { Authorization: authHeader(auth) },
+ });
+ const raw = await res.text();
+ const data = raw ? JSON.parse(raw) : null;
+ if (!res.ok) throw new Error(`HTTP ${res.status}: ${raw}`);
+ return data;
+}
diff --git a/apps/examples/grid-global-accounts-example-app/src/components/ContextChip.tsx b/apps/examples/grid-global-accounts-example-app/src/components/ContextChip.tsx
new file mode 100644
index 000000000..5af4d2fab
--- /dev/null
+++ b/apps/examples/grid-global-accounts-example-app/src/components/ContextChip.tsx
@@ -0,0 +1,171 @@
+import styled from "@emotion/styled";
+
+import { useAppState } from "../state/store";
+
+/**
+ * The active-context indicator in the Shell header. Normally a quiet chip
+ * naming the customer the app is acting as (or "No customer" before one is
+ * picked). When debug mode is on it expands to reveal the actual identifiers
+ * the plumbing runs on — customer id, account id (if provisioned), and session
+ * id (if signed in) — inline as monospace key/value pairs.
+ *
+ * Nothing identifying is shown in the normal (non-debug) state, keeping the
+ * polished persona views free of raw IDs.
+ */
+export function ContextChip() {
+ const { activeCustomer, session, debugOn } = useAppState();
+
+ const name = activeCustomer?.name || activeCustomer?.email || null;
+ const sessionId = extractSessionId(session);
+
+ if (!activeCustomer) {
+ // Nothing to anchor to before a customer is active — stay out of the way.
+ if (!debugOn) return null;
+ return (
+
+
+ No customer
+
+ );
+ }
+
+ return (
+
+
+ {name}
+
+ {debugOn && (
+
+
+ cust
+ {activeCustomer.id}
+
+ {activeCustomer.accountId && (
+
+ acct
+ {activeCustomer.accountId}
+
+ )}
+ {sessionId && (
+
+ sess
+ {sessionId}
+
+ )}
+
+ )}
+
+ );
+}
+
+/**
+ * The session is held as `unknown` (the concrete bundle shape is owned by the
+ * reused login flows). Best-effort dig for a likely identifier so debug mode
+ * can surface *something* without coupling to one provider's shape; falls back
+ * to null when there's nothing id-like to show.
+ */
+function extractSessionId(session: unknown): string | null {
+ if (!session || typeof session !== "object") return null;
+ const s = session as Record;
+ const candidates = [
+ s.id,
+ s.sessionId,
+ s.session_id,
+ s.credentialId,
+ s.credential_id,
+ (s.session as Record | undefined)?.id,
+ ];
+ for (const c of candidates) {
+ if (typeof c === "string" && c.length > 0) return c;
+ }
+ return null;
+}
+
+const Chip = styled.div`
+ display: inline-flex;
+ align-items: center;
+ gap: var(--spacing-2xs, 6px);
+ min-width: 0;
+ max-width: 360px;
+ padding: var(--spacing-3xs, 4px) var(--spacing-xs, 8px);
+ border-radius: var(--corner-radius-md, 8px);
+ background: var(--surface-secondary, #f0f0ee);
+ border: var(--stroke-xs, 0.5px) solid
+ var(--border-primary, rgba(38, 38, 35, 0.1));
+
+ &[data-debug] {
+ /* Fixed dark "console" surface (matches DebugDrawer's PANEL_BG), not the
+ * mode-flipping --surface-inverse — its light text would vanish on the
+ * near-white --surface-inverse in dark mode. */
+ background: #16161a;
+ border-color: rgba(255, 255, 255, 0.14);
+ }
+
+ @media (width <= 760px) {
+ display: none;
+ }
+`;
+
+const Dot = styled.span`
+ flex: 0 0 auto;
+ width: 6px;
+ height: 6px;
+ border-radius: 50%;
+ background: var(--text-tertiary, #8a8a8a);
+
+ &[data-on="true"] {
+ background: #3dd68c;
+ box-shadow: 0 0 0 3px rgba(61, 214, 140, 0.22);
+ }
+`;
+
+const Name = styled.span`
+ flex: 0 0 auto;
+ font-size: var(--font-size-xs, 12px);
+ font-weight: var(--font-weight-medium, 500);
+ color: var(--text-primary, #1a1a1a);
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+
+ &[data-debug] {
+ color: rgba(255, 255, 255, 0.92);
+ }
+`;
+
+const Ids = styled.span`
+ display: inline-flex;
+ align-items: center;
+ gap: var(--spacing-2xs, 6px);
+ padding-left: var(--spacing-2xs, 6px);
+ margin-left: var(--spacing-3xs, 4px);
+ border-left: var(--stroke-xs, 0.5px) solid rgba(255, 255, 255, 0.16);
+ min-width: 0;
+`;
+
+const Id = styled.span`
+ display: inline-flex;
+ align-items: baseline;
+ gap: var(--spacing-3xs, 4px);
+ min-width: 0;
+`;
+
+const K = styled.span`
+ flex: 0 0 auto;
+ font-family: var(--font-family-mono, ui-monospace, monospace);
+ font-size: var(--font-size-2xs, 10px);
+ text-transform: uppercase;
+ letter-spacing: 0.4px;
+ color: rgba(255, 255, 255, 0.45);
+`;
+
+const V = styled.span`
+ font-family: var(--font-family-mono, ui-monospace, monospace);
+ font-size: var(--font-size-2xs, 10px);
+ color: #b8e8ff;
+ max-width: 110px;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ font-variant-numeric: tabular-nums;
+`;
diff --git a/apps/examples/grid-global-accounts-example-app/src/components/DebugDrawer.tsx b/apps/examples/grid-global-accounts-example-app/src/components/DebugDrawer.tsx
new file mode 100644
index 000000000..d2a0b4368
--- /dev/null
+++ b/apps/examples/grid-global-accounts-example-app/src/components/DebugDrawer.tsx
@@ -0,0 +1,338 @@
+import styled from "@emotion/styled";
+import { Badge, Collapsible } from "@lightsparkdev/origin";
+import { useState, type ComponentProps } from "react";
+
+import type { LogEntry } from "../lib/reporter";
+import { useAppState } from "../state/store";
+
+type BadgeVariant = ComponentProps["variant"];
+
+/** Map each log level to an Origin badge variant + short tag. */
+const LEVEL: Record =
+ {
+ info: { variant: "gray", tag: "INFO" },
+ error: { variant: "red", tag: "ERR" },
+ request: { variant: "blue", tag: "REQ" },
+ response: { variant: "green", tag: "RES" },
+ };
+
+/**
+ * The debug surface's main instrument: a docked panel pinned to the bottom of
+ * the viewport that streams the structured `store.log` — one row per entry with
+ * a level badge, label, timestamp, and an expandable raw `detail` JSON. Rendered
+ * only when debug mode is on, across both personas (wired into the Shell), and
+ * non-modal so the app stays fully usable behind it. Collapsible to a slim
+ * header bar so it can be parked out of the way.
+ */
+export function DebugDrawer() {
+ const { debugOn, log } = useAppState();
+ const [open, setOpen] = useState(true);
+
+ if (!debugOn) return null;
+
+ // Newest first — the reporter appends, so reverse a shallow copy.
+ const entries = [...log].reverse();
+
+ return (
+
+ setOpen((v) => !v)} aria-expanded={open}>
+
+
+ Debug console
+ {log.length}
+
+ {open ? "▾" : "▴"}
+
+
+ {open && (
+
+ {entries.length === 0 ? (
+
+ No events yet. Connect, create a customer, or sign in — every
+ request and response lands here.
+
+ ) : (
+
+ {entries.map((entry) => (
+
+ ))}
+
+ )}
+
+ )}
+
+ );
+}
+
+function LogRow({ entry }: { entry: LogEntry }) {
+ const level = LEVEL[entry.level];
+ const hasDetail = entry.detail !== undefined && entry.detail !== null;
+
+ return (
+
+
+
+ {level.tag}
+
+ {entry.label}
+
+
+
+ {hasDetail && (
+
+
+ detail
+
+
+
+ {stringify(entry.detail)}
+
+
+
+ )}
+
+ );
+}
+
+function stringify(value: unknown): string {
+ if (value === undefined) return "undefined";
+ if (typeof value === "string") return value;
+ try {
+ return JSON.stringify(value, null, 2);
+ } catch {
+ return String(value);
+ }
+}
+
+function formatTime(ts: number): string {
+ const d = new Date(ts);
+ const hh = String(d.getHours()).padStart(2, "0");
+ const mm = String(d.getMinutes()).padStart(2, "0");
+ const ss = String(d.getSeconds()).padStart(2, "0");
+ const ms = String(d.getMilliseconds()).padStart(3, "0");
+ return `${hh}:${mm}:${ss}.${ms}`;
+}
+
+const PANEL_BG = "#16161a";
+const PANEL_BORDER = "rgba(255, 255, 255, 0.1)";
+const PANEL_TEXT = "rgba(255, 255, 255, 0.92)";
+const PANEL_DIM = "rgba(255, 255, 255, 0.45)";
+
+const Dock = styled.aside`
+ position: fixed;
+ inset: auto 0 0 0;
+ z-index: 40;
+ display: flex;
+ flex-direction: column;
+ background: ${PANEL_BG};
+ color: ${PANEL_TEXT};
+ border-top: var(--stroke-sm, 1px) solid ${PANEL_BORDER};
+ box-shadow: 0 -12px 32px rgba(0, 0, 0, 0.28);
+ font-family: var(--font-family-mono, ui-monospace, monospace);
+
+ /* A hairline of accent at the very top edge, like a live wire. */
+ &::before {
+ content: "";
+ position: absolute;
+ top: -1px;
+ left: 0;
+ right: 0;
+ height: 1px;
+ background: linear-gradient(
+ 90deg,
+ transparent,
+ var(--surface-blue-strong, #0072db) 30%,
+ #3dd68c 70%,
+ transparent
+ );
+ opacity: 0.7;
+ }
+`;
+
+const Bar = styled.button`
+ all: unset;
+ box-sizing: border-box;
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: var(--spacing-sm, 12px);
+ padding: var(--spacing-xs, 8px) var(--spacing-lg, 20px);
+ cursor: pointer;
+ user-select: none;
+
+ &:hover {
+ background: rgba(255, 255, 255, 0.04);
+ }
+ &:focus-visible {
+ outline: 2px solid var(--surface-blue-strong, #0072db);
+ outline-offset: -2px;
+ }
+`;
+
+const BarLeft = styled.span`
+ display: inline-flex;
+ align-items: center;
+ gap: var(--spacing-xs, 8px);
+`;
+
+const Pulse = styled.span`
+ width: 7px;
+ height: 7px;
+ border-radius: 50%;
+ background: #3dd68c;
+ box-shadow: 0 0 0 0 rgba(61, 214, 140, 0.6);
+ animation: dbg-pulse 2.4s ease-out infinite;
+
+ @keyframes dbg-pulse {
+ 0% {
+ box-shadow: 0 0 0 0 rgba(61, 214, 140, 0.5);
+ }
+ 70% {
+ box-shadow: 0 0 0 6px rgba(61, 214, 140, 0);
+ }
+ 100% {
+ box-shadow: 0 0 0 0 rgba(61, 214, 140, 0);
+ }
+ }
+
+ @media (prefers-reduced-motion: reduce) {
+ animation: none;
+ }
+`;
+
+const BarTitle = styled.span`
+ font-size: var(--font-size-xs, 12px);
+ font-weight: var(--font-weight-semibold, 600);
+ letter-spacing: 0.6px;
+ text-transform: uppercase;
+`;
+
+const Count = styled.span`
+ font-size: var(--font-size-2xs, 10px);
+ color: ${PANEL_DIM};
+ background: rgba(255, 255, 255, 0.08);
+ border-radius: 999px;
+ padding: 1px var(--spacing-2xs, 6px);
+ font-variant-numeric: tabular-nums;
+`;
+
+const BarRight = styled.span`
+ font-size: var(--font-size-sm, 13px);
+ color: ${PANEL_DIM};
+`;
+
+const Body = styled.div`
+ max-height: min(42vh, 380px);
+ overflow: auto;
+ border-top: var(--stroke-xs, 0.5px) solid ${PANEL_BORDER};
+`;
+
+const Empty = styled.div`
+ padding: var(--spacing-md, 16px) var(--spacing-lg, 20px);
+ font-size: var(--font-size-xs, 12px);
+ color: ${PANEL_DIM};
+ line-height: 1.5;
+`;
+
+const List = styled.ol`
+ list-style: none;
+ margin: 0;
+ padding: 0;
+`;
+
+const Row = styled.li`
+ padding: var(--spacing-xs, 8px) var(--spacing-lg, 20px);
+ border-top: var(--stroke-xs, 0.5px) solid rgba(255, 255, 255, 0.06);
+
+ &:first-of-type {
+ border-top: none;
+ }
+`;
+
+const RowHead = styled.div`
+ display: flex;
+ align-items: center;
+ gap: var(--spacing-sm, 12px);
+`;
+
+const LevelBadge = styled(Badge)`
+ flex: 0 0 auto;
+ font-family: var(--font-family-mono, ui-monospace, monospace);
+ font-variant-numeric: tabular-nums;
+ letter-spacing: 0.5px;
+`;
+
+const RowLabel = styled.span`
+ flex: 1;
+ min-width: 0;
+ font-size: var(--font-size-xs, 12px);
+ color: ${PANEL_TEXT};
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+`;
+
+const Time = styled.span`
+ flex: 0 0 auto;
+ font-size: var(--font-size-2xs, 10px);
+ color: ${PANEL_DIM};
+ font-variant-numeric: tabular-nums;
+`;
+
+const DetailTrigger = styled(Collapsible.Trigger)`
+ display: inline-flex;
+ align-items: center;
+ gap: var(--spacing-3xs, 4px);
+ width: auto;
+ margin-top: var(--spacing-3xs, 4px);
+ margin-left: 52px; /* align under the label, clear of the badge */
+ padding: 0;
+ background: transparent;
+ border: none;
+ cursor: pointer;
+ color: ${PANEL_DIM};
+
+ &:hover {
+ color: ${PANEL_TEXT};
+ }
+ &:hover span {
+ text-decoration: none;
+ }
+
+ [class*="icon"] svg {
+ width: 14px;
+ height: 14px;
+ }
+ [class*="icon"] {
+ width: auto;
+ height: auto;
+ color: currentColor;
+ }
+`;
+
+const DetailTriggerLabel = styled.span`
+ font-size: var(--font-size-2xs, 10px);
+ text-transform: uppercase;
+ letter-spacing: 0.6px;
+ flex: 0 0 auto;
+`;
+
+const Pre = styled.pre`
+ margin: var(--spacing-3xs, 4px) 0 var(--spacing-2xs, 6px) 52px;
+ padding: var(--spacing-sm, 12px);
+ background: rgba(0, 0, 0, 0.4);
+ border: var(--stroke-xs, 0.5px) solid ${PANEL_BORDER};
+ border-radius: var(--corner-radius-md, 8px);
+ color: #b8e8ff;
+ font-family: var(--font-family-mono, ui-monospace, monospace);
+ font-size: var(--font-size-2xs, 10px);
+ line-height: 1.55;
+ overflow: auto;
+ max-height: 240px;
+ white-space: pre;
+ tab-size: 2;
+
+ code {
+ font-family: inherit;
+ }
+`;
diff --git a/apps/examples/grid-global-accounts-example-app/src/components/DebugToggle.tsx b/apps/examples/grid-global-accounts-example-app/src/components/DebugToggle.tsx
new file mode 100644
index 000000000..22c05493e
--- /dev/null
+++ b/apps/examples/grid-global-accounts-example-app/src/components/DebugToggle.tsx
@@ -0,0 +1,49 @@
+import styled from "@emotion/styled";
+import { Switch } from "@lightsparkdev/origin";
+
+import { useAppState } from "../state/store";
+
+/**
+ * Top-bar control that flips the app between the two polished personas
+ * (off) and the dev-tools view that surfaces the request/response log and
+ * raw IDs (on). Defaults to off — the store seeds `debugOn = false`.
+ */
+export function DebugToggle() {
+ const { debugOn, toggleDebug } = useAppState();
+
+ return (
+
+
+
+
+ );
+}
+
+const Root = styled.div`
+ display: inline-flex;
+ align-items: center;
+ gap: var(--spacing-xs, 8px);
+`;
+
+const Label = styled.label`
+ font-size: var(--font-size-xs, 12px);
+ font-weight: var(--font-weight-medium, 500);
+ letter-spacing: 0.4px;
+ text-transform: uppercase;
+ color: var(--text-tertiary, #8a8a8a);
+ cursor: pointer;
+ transition: color 120ms ease;
+ user-select: none;
+
+ &[data-active="true"] {
+ color: var(--text-primary, #1a1a1a);
+ }
+`;
diff --git a/apps/examples/grid-global-accounts-example-app/src/components/DismissibleAlert.tsx b/apps/examples/grid-global-accounts-example-app/src/components/DismissibleAlert.tsx
new file mode 100644
index 000000000..7c680d6ef
--- /dev/null
+++ b/apps/examples/grid-global-accounts-example-app/src/components/DismissibleAlert.tsx
@@ -0,0 +1,58 @@
+import styled from "@emotion/styled";
+import { Alert } from "@lightsparkdev/origin";
+import type { ComponentProps } from "react";
+
+type DismissibleAlertProps = ComponentProps & {
+ /** Called when the user clicks the close (✕) button. */
+ onClose: () => void;
+};
+
+/**
+ * An Origin with a close button. Origin's Alert has no dismiss
+ * affordance, so we overlay one at the top-right and reserve room for it so a
+ * long description doesn't run underneath.
+ */
+export function DismissibleAlert({
+ onClose,
+ ...alertProps
+}: DismissibleAlertProps) {
+ return (
+
+
+
+ ✕
+
+
+ );
+}
+
+const Wrap = styled.div`
+ position: relative;
+ /* Higher specificity than Origin's .root class, so it wins reliably. */
+ & [role="alert"] {
+ padding-right: 44px;
+ }
+`;
+
+const CloseButton = styled.button`
+ position: absolute;
+ top: 10px;
+ right: 10px;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: 24px;
+ height: 24px;
+ padding: 0;
+ border: none;
+ background: transparent;
+ border-radius: var(--corner-radius-sm, 6px);
+ color: var(--text-tertiary, #8a8a8a);
+ font-size: 13px;
+ line-height: 1;
+ cursor: pointer;
+ &:hover {
+ color: var(--text-primary, #1a1a1a);
+ background: var(--surface-hover, rgba(0, 0, 0, 0.04));
+ }
+`;
diff --git a/apps/examples/grid-global-accounts-example-app/src/components/PersonaSwitcher.tsx b/apps/examples/grid-global-accounts-example-app/src/components/PersonaSwitcher.tsx
new file mode 100644
index 000000000..0e3bd09d0
--- /dev/null
+++ b/apps/examples/grid-global-accounts-example-app/src/components/PersonaSwitcher.tsx
@@ -0,0 +1,40 @@
+import styled from "@emotion/styled";
+import { Tabs } from "@lightsparkdev/origin";
+
+import { useAppState, type Persona } from "../state/store";
+
+const PERSONAS: { value: Persona; label: string }[] = [
+ { value: "platform", label: "Platform" },
+ { value: "customer", label: "Customer" },
+];
+
+/**
+ * Segmented control that toggles between the Platform and Customer views.
+ * Only one persona is on screen at a time; this drives `persona` in the
+ * app store. Built on Origin's Tabs so we get the sliding indicator + a
+ * roving-tabindex keyboard model for free.
+ */
+export function PersonaSwitcher() {
+ const { persona, setPersona } = useAppState();
+
+ return (
+
+ setPersona(value as Persona)}
+ >
+
+ {PERSONAS.map(({ value, label }) => (
+
+ {label}
+
+ ))}
+
+
+
+ );
+}
+
+const Root = styled.div`
+ display: inline-flex;
+`;
diff --git a/apps/examples/grid-global-accounts-example-app/src/components/RawExpander.tsx b/apps/examples/grid-global-accounts-example-app/src/components/RawExpander.tsx
new file mode 100644
index 000000000..e346573f3
--- /dev/null
+++ b/apps/examples/grid-global-accounts-example-app/src/components/RawExpander.tsx
@@ -0,0 +1,146 @@
+import styled from "@emotion/styled";
+import { Collapsible } from "@lightsparkdev/origin";
+
+import { useAppState } from "../state/store";
+
+export interface RawExpanderProps {
+ /** Raw payload to pretty-print. Anything JSON-serializable (objects, arrays). */
+ value: unknown;
+ /** Trigger label. Defaults to "Raw response". */
+ label?: string;
+}
+
+/**
+ * A reusable disclosure that reveals a raw JSON blob — but only when debug mode
+ * is on. Off by default and renders nothing when debug is off, so it can be
+ * dropped next to a polished value without leaking the plumbing into the
+ * happy-path UI. Collapsed by default when shown.
+ *
+ * Used to attach the real API payload behind a value the user already sees
+ * formatted (e.g. a balance, a connection summary), so the demo can show "here's
+ * the pretty number, and here's exactly what the API returned".
+ */
+export function RawExpander({
+ value,
+ label = "Raw response",
+}: RawExpanderProps) {
+ const { debugOn } = useAppState();
+ if (!debugOn) return null;
+
+ const json = stringify(value);
+
+ return (
+
+
+
+
+
+ {label}
+
+
+
+
+ {json}
+
+
+
+
+ );
+}
+
+/** Pretty-print, falling back gracefully on cyclic / non-serializable input. */
+function stringify(value: unknown): string {
+ if (value === undefined) return "undefined";
+ try {
+ return JSON.stringify(value, null, 2);
+ } catch {
+ return String(value);
+ }
+}
+
+const Root = styled.div`
+ margin-top: var(--spacing-sm, 12px);
+`;
+
+const Trigger = styled(Collapsible.Trigger)`
+ /* Override the default Collapsible trigger to a compact, monospace "dev" tag
+ so it reads as instrumentation rather than primary content. The base
+ component already rotates its chevron on open via .root[data-open]. */
+ display: inline-flex;
+ align-items: center;
+ gap: var(--spacing-3xs, 4px);
+ width: auto;
+ padding: var(--spacing-3xs, 4px) var(--spacing-xs, 8px);
+ background: var(--surface-secondary, #f0f0ee);
+ border: var(--stroke-xs, 0.5px) solid
+ var(--border-primary, rgba(38, 38, 35, 0.1));
+ border-radius: var(--corner-radius-sm, 6px);
+ cursor: pointer;
+ color: var(--text-secondary, #555);
+
+ &:hover {
+ background: var(--surface-tertiary, #c1c0b8);
+ color: var(--text-primary, #1a1a1a);
+ }
+
+ /* Suppress the base trigger's underline-on-hover; this reads as a tag. */
+ span {
+ flex: 0 0 auto;
+ }
+ &:hover span {
+ text-decoration: none;
+ }
+
+ /* Shrink the oversized 24px chevron to fit the compact tag. */
+ [class*="icon"] svg {
+ width: 14px;
+ height: 14px;
+ }
+ [class*="icon"] {
+ width: auto;
+ height: auto;
+ }
+`;
+
+const TriggerInner = styled.span`
+ display: inline-flex;
+ align-items: center;
+ gap: var(--spacing-2xs, 6px);
+`;
+
+const Spark = styled.span`
+ width: 5px;
+ height: 5px;
+ border-radius: 1px;
+ background: var(--surface-blue-strong, #0072db);
+ transform: rotate(45deg);
+`;
+
+const TriggerLabel = styled.span`
+ font-family: var(--font-family-mono, ui-monospace, monospace);
+ font-size: var(--font-size-2xs, 10px);
+ font-weight: var(--font-weight-medium, 500);
+ letter-spacing: 0.4px;
+ text-transform: uppercase;
+`;
+
+const Pre = styled.pre`
+ margin: var(--spacing-2xs, 6px) 0 0;
+ padding: var(--spacing-sm, 12px);
+ /* Fixed dark "terminal" surface, not the mode-flipping --surface-inverse:
+ * the light-green text would vanish on its near-white value in dark mode. */
+ background: #16161a;
+ color: #d6f7c2;
+ border-radius: var(--corner-radius-md, 8px);
+ font-family: var(--font-family-mono, ui-monospace, monospace);
+ font-size: var(--font-size-2xs, 10px);
+ line-height: 1.55;
+ overflow: auto;
+ max-height: 280px;
+ white-space: pre;
+ tab-size: 2;
+
+ code {
+ font-family: inherit;
+ }
+`;
diff --git a/apps/examples/grid-global-accounts-example-app/src/components/Shell.tsx b/apps/examples/grid-global-accounts-example-app/src/components/Shell.tsx
new file mode 100644
index 000000000..944ae9748
--- /dev/null
+++ b/apps/examples/grid-global-accounts-example-app/src/components/Shell.tsx
@@ -0,0 +1,137 @@
+import styled from "@emotion/styled";
+import { Badge, Logo } from "@lightsparkdev/origin";
+import { type ReactNode } from "react";
+
+import { useAppState } from "../state/store";
+import { ContextChip } from "./ContextChip";
+import { DebugDrawer } from "./DebugDrawer";
+import { DebugToggle } from "./DebugToggle";
+import { PersonaSwitcher } from "./PersonaSwitcher";
+
+/**
+ * App frame: a sticky top bar (brand · persona switcher · debug toggle) over
+ * a centered content column. View routing happens in `App`; the Shell only
+ * owns the chrome so each persona view can stay focused on its own content.
+ */
+export function Shell({ children }: { children: ReactNode }) {
+ const { persona, debugOn } = useAppState();
+
+ return (
+
+
+
+
+
+ Global Accounts
+
+
+
+
+
+
+
+
+
+ {persona === "platform" ? "Platform" : "Customer"}
+
+
+
+
+
+
+ {children}
+
+
+ {/* Docked dev console — renders only when debugOn, spans both personas. */}
+
+
+ );
+}
+
+const Page = styled.div`
+ min-height: 100vh;
+ display: flex;
+ flex-direction: column;
+ background: var(--surface-base, #f5f5f7);
+ color: var(--text-primary, #1a1a1a);
+ font-family: var(--font-family-sans);
+`;
+
+const TopBar = styled.header`
+ position: sticky;
+ top: 0;
+ z-index: 10;
+ display: grid;
+ grid-template-columns: 1fr auto 1fr;
+ align-items: center;
+ gap: var(--spacing-md, 16px);
+ padding: var(--spacing-sm, 12px) var(--spacing-lg, 24px);
+ background: var(--surface-primary, #fff);
+ border-bottom: var(--stroke-xs, 1px) solid var(--border-primary, #e6e6e9);
+ backdrop-filter: saturate(180%) blur(8px);
+`;
+
+const Brand = styled.div`
+ display: inline-flex;
+ align-items: center;
+ gap: var(--spacing-xs, 8px);
+ min-width: 0;
+`;
+
+const BrandDivider = styled.span`
+ width: var(--stroke-xs, 1px);
+ height: 18px;
+ background: var(--border-primary, #e6e6e9);
+`;
+
+const BrandLabel = styled.span`
+ font-size: var(--font-size-sm, 13px);
+ font-weight: var(--font-weight-medium, 500);
+ color: var(--text-secondary, #555);
+ white-space: nowrap;
+ letter-spacing: -0.1px;
+`;
+
+const SwitcherSlot = styled.div`
+ display: flex;
+ justify-content: center;
+`;
+
+const Controls = styled.div`
+ display: inline-flex;
+ align-items: center;
+ justify-content: flex-end;
+ gap: var(--spacing-md, 16px);
+`;
+
+const PersonaBadge = styled(Badge)`
+ /* Hidden on narrow widths; the switcher already names the persona. */
+ @media (width <= 640px) {
+ display: none;
+ }
+`;
+
+const Content = styled.main`
+ flex: 1;
+ display: flex;
+ justify-content: center;
+ padding: var(--spacing-2xl, 40px) var(--spacing-lg, 24px)
+ var(--spacing-4xl, 64px);
+
+ /* Reserve room for the docked debug console so it never hides content; the
+ console's body scrolls internally, so the collapsed-bar clearance is enough. */
+ &[data-debug] {
+ padding-bottom: var(--spacing-9xl, 96px);
+ }
+`;
+
+const Column = styled.div`
+ width: 100%;
+ max-width: 920px;
+ display: flex;
+ flex-direction: column;
+ gap: var(--spacing-lg, 24px);
+`;
diff --git a/apps/examples/grid-global-accounts-example-app/src/components/StatusBanner.tsx b/apps/examples/grid-global-accounts-example-app/src/components/StatusBanner.tsx
new file mode 100644
index 000000000..f8e883b89
--- /dev/null
+++ b/apps/examples/grid-global-accounts-example-app/src/components/StatusBanner.tsx
@@ -0,0 +1,19 @@
+import { useAppState } from "../state/store";
+import { DismissibleAlert } from "./DismissibleAlert";
+
+/**
+ * The transient, app-wide status line fed by `reporter.status(...)` (e.g.
+ * "Payment executed.", errors), shown at the top of each persona view and
+ * dismissable — clicking ✕ clears it via `clearStatus`.
+ */
+export function StatusBanner() {
+ const { status, clearStatus } = useAppState();
+ if (!status) return null;
+ return (
+
+ );
+}
diff --git a/apps/examples/grid-global-accounts-example-app/src/config.ts b/apps/examples/grid-global-accounts-example-app/src/config.ts
new file mode 100644
index 000000000..9a6a620c5
--- /dev/null
+++ b/apps/examples/grid-global-accounts-example-app/src/config.ts
@@ -0,0 +1,46 @@
+// Grid Global Accounts — Example App: shared config + constants.
+
+export type Mode = "sandbox" | "production";
+export type CredType = "email_otp" | "oauth" | "passkey";
+
+// Sandbox magic signature injected into signed-retry headers and the execute
+// signature. In production these are wrong — a real stamp must be supplied.
+export const SANDBOX_SIG = "sandbox-valid-signature";
+
+// All requests proxy through Vite at `/api` and forward to the configured Grid
+// backend. Credentials are entered manually in the UI — never embedded.
+export const API_BASE = "/api";
+
+// Turnkey API stamp scheme — must match what `@turnkey/api-key-stamper` emits.
+export const TURNKEY_STAMP_SCHEME = "SIGNATURE_SCHEME_TK_API_P256";
+
+// `localStorage` key for the persisted mode so a reload keeps the chosen mode
+// instead of silently reverting to sandbox.
+export const MODE_STORAGE_KEY = "gga-example-app-mode";
+
+// ----- Sandbox magic values -----
+//
+// The single source of truth for every fake "looks-real" field. Keyed by input
+// element id → the magic value the sandbox backend accepts. In sandbox mode
+// these are seeded into the fields (and the field gets a "magic" pill) by
+// `mode.ts`; in production mode the same fields are hidden so nothing fake is
+// ever on screen. This replaces the scattered `value="sandbox-..."` attributes
+// that made fake data indistinguishable from real values.
+export const SANDBOX_MAGIC: Record = {
+ // EMAIL_OTP — sandbox always accepts the fixed code.
+ "email_otp-v3-code": "000000",
+ // OAUTH — magic OIDC tokens (verify input + JWT-shaped create/add identities).
+ "oauth-verify-oidc": "sandbox-valid-oidc-token",
+ "oauth-create-oidc":
+ "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJodHRwczovL2FjY291bnRzLmdvb2dsZS5jb20iLCJzdWIiOiJzYW5kYm94LXVzZXItMSJ9.sig",
+ "oauth-add-oidc":
+ "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJodHRwczovL2FwcGxlaWQuYXBwbGUuY29tIiwic3ViIjoic2FuZGJveC11c2VyLTIifQ.sig",
+ // PASSKEY — magic attestation (create) + assertion (verify) blobs.
+ "passkey-create-challenge": "c2FuZGJveC1jaGFsbGVuZ2U",
+ "passkey-create-cred-id-raw": "c2FuZGJveC1jcmVkLWlk",
+ "passkey-create-client-data-json": "c2FuZGJveC1jbGllbnREYXRhSlNPTg",
+ "passkey-create-attestation-object": "c2FuZGJveC1hdHRlc3RhdGlvbk9iamVjdA",
+ "passkey-verify-signature": "sandbox-valid-passkey-signature",
+ "passkey-verify-auth-data": "c2FuZGJveC1hdXRoLWRhdGE",
+ "passkey-verify-client-data-json": "c2FuZGJveC1jbGllbnQtZGF0YQ",
+};
diff --git a/apps/examples/grid-global-accounts-example-app/src/declarations.d.ts b/apps/examples/grid-global-accounts-example-app/src/declarations.d.ts
new file mode 100644
index 000000000..85b884cbc
--- /dev/null
+++ b/apps/examples/grid-global-accounts-example-app/src/declarations.d.ts
@@ -0,0 +1,15 @@
+// tsc resolves @lightsparkdev/origin's component imports against its source
+// files (the package's `main` points at src/index.ts), so when we compile
+// this app it walks into Origin's *.module.scss imports. Origin ships its
+// own scss shim under its src/declarations.d.ts, but TypeScript only picks
+// up .d.ts files inside the current compilation root — we need our own.
+
+declare module "*.module.scss" {
+ const classes: { readonly [key: string]: string };
+ export default classes;
+}
+
+declare module "*.module.css" {
+ const classes: { readonly [key: string]: string };
+ export default classes;
+}
diff --git a/apps/examples/grid-global-accounts-example-app/src/flows/__tests__/customer-external-account.test.ts b/apps/examples/grid-global-accounts-example-app/src/flows/__tests__/customer-external-account.test.ts
new file mode 100644
index 000000000..58acbd138
--- /dev/null
+++ b/apps/examples/grid-global-accounts-example-app/src/flows/__tests__/customer-external-account.test.ts
@@ -0,0 +1,179 @@
+import { beforeEach, describe, expect, it, vi } from "vitest";
+
+import { createCollectingReporter } from "../../lib/collecting-reporter";
+import type { ApiAuth } from "../../api-client";
+import {
+ createCustomerExternalAccount,
+ listCustomerExternalAccounts,
+} from "../money";
+
+const auth: ApiAuth = {
+ clientId: "id",
+ clientSecret: "secret",
+ mode: "sandbox",
+};
+
+// Mock at the api-client boundary so no real API is hit.
+vi.mock("../../api-client", () => ({
+ apiGet: vi.fn(),
+ apiPost: vi.fn(),
+}));
+import { apiGet, apiPost } from "../../api-client";
+const mockGet = vi.mocked(apiGet);
+const mockPost = vi.mocked(apiPost);
+
+beforeEach(() => {
+ mockGet.mockReset();
+ mockPost.mockReset();
+});
+
+describe("createCustomerExternalAccount", () => {
+ it("POSTs /customers/external-accounts with customerId + USD bank body", async () => {
+ const { reporter } = createCollectingReporter();
+ mockPost.mockResolvedValueOnce({
+ status: 200,
+ data: { id: "ExternalAccount:ext1" },
+ });
+
+ const id = await createCustomerExternalAccount(reporter, auth, {
+ customerId: "Customer:c1",
+ accountNumber: "000123456789",
+ routingNumber: "021000021",
+ beneficiaryName: "Ada Lovelace",
+ });
+
+ expect(id).toBe("ExternalAccount:ext1");
+ expect(mockPost).toHaveBeenCalledTimes(1);
+ const [, path, body] = mockPost.mock.calls[0];
+ expect(path).toBe("/customers/external-accounts");
+ const sent = body as Record;
+ expect(sent.customerId).toBe("Customer:c1");
+ expect(sent.currency).toBe("USD");
+ const info = sent.accountInfo as Record;
+ expect(info.accountType).toBe("USD_ACCOUNT");
+ expect(info.accountNumber).toBe("000123456789");
+ expect(info.routingNumber).toBe("021000021");
+ expect((info.beneficiary as Record).fullName).toBe(
+ "Ada Lovelace",
+ );
+ });
+
+ it("trims inputs and requires a customer + bank fields", async () => {
+ const { reporter } = createCollectingReporter();
+ await expect(
+ createCustomerExternalAccount(reporter, auth, {
+ customerId: " ",
+ accountNumber: "000123456789",
+ routingNumber: "021000021",
+ }),
+ ).rejects.toThrow(/customer/i);
+ await expect(
+ createCustomerExternalAccount(reporter, auth, {
+ customerId: "Customer:c1",
+ accountNumber: " ",
+ routingNumber: "021000021",
+ }),
+ ).rejects.toThrow(/account number/i);
+ expect(mockPost).not.toHaveBeenCalled();
+ });
+
+ it("throws when the create response has no id", async () => {
+ const { reporter } = createCollectingReporter();
+ mockPost.mockResolvedValueOnce({ status: 200, data: {} });
+ await expect(
+ createCustomerExternalAccount(reporter, auth, {
+ customerId: "Customer:c1",
+ accountNumber: "000123456789",
+ routingNumber: "021000021",
+ }),
+ ).rejects.toThrow(/no id/i);
+ });
+});
+
+describe("listCustomerExternalAccounts", () => {
+ it("GETs /customers/external-accounts with customerId + currency and parses labels", async () => {
+ const { reporter } = createCollectingReporter();
+ mockGet.mockResolvedValueOnce({
+ data: [
+ {
+ id: "ExternalAccount:ext1",
+ currency: "USD",
+ accountInfo: {
+ accountType: "USD_ACCOUNT",
+ accountNumber: "123456789",
+ },
+ },
+ {
+ id: "ExternalAccount:ext2",
+ currency: "USD",
+ accountInfo: {
+ accountType: "USD_ACCOUNT",
+ accountNumber: "987654321",
+ },
+ },
+ ],
+ hasMore: false,
+ });
+
+ const rows = await listCustomerExternalAccounts(
+ reporter,
+ auth,
+ "Customer:c1",
+ "USD",
+ );
+
+ expect(mockGet).toHaveBeenCalledTimes(1);
+ const [, path] = mockGet.mock.calls[0];
+ expect(path).toBe(
+ "/customers/external-accounts?customerId=Customer%3Ac1¤cy=USD",
+ );
+ expect(rows).toEqual([
+ { id: "ExternalAccount:ext1", label: "USD •••6789" },
+ { id: "ExternalAccount:ext2", label: "USD •••4321" },
+ ]);
+ });
+
+ it("omits the currency query param when not supplied", async () => {
+ const { reporter } = createCollectingReporter();
+ mockGet.mockResolvedValueOnce({ data: [], hasMore: false });
+
+ await listCustomerExternalAccounts(reporter, auth, "Customer:c1");
+
+ const [, path] = mockGet.mock.calls[0];
+ expect(path).toBe("/customers/external-accounts?customerId=Customer%3Ac1");
+ });
+
+ it("skips rows without an id and tolerates a null/empty response", async () => {
+ const { reporter } = createCollectingReporter();
+ mockGet.mockResolvedValueOnce({
+ data: [
+ { currency: "USD" },
+ { id: "ExternalAccount:ok", currency: "USD" },
+ ],
+ hasMore: false,
+ });
+
+ const rows = await listCustomerExternalAccounts(
+ reporter,
+ auth,
+ "Customer:c1",
+ );
+ expect(rows).toEqual([{ id: "ExternalAccount:ok", label: "USD" }]);
+
+ mockGet.mockResolvedValueOnce(null);
+ const empty = await listCustomerExternalAccounts(
+ reporter,
+ auth,
+ "Customer:c1",
+ );
+ expect(empty).toEqual([]);
+ });
+
+ it("requires a customer id", async () => {
+ const { reporter } = createCollectingReporter();
+ await expect(
+ listCustomerExternalAccounts(reporter, auth, " "),
+ ).rejects.toThrow(/customer/i);
+ expect(mockGet).not.toHaveBeenCalled();
+ });
+});
diff --git a/apps/examples/grid-global-accounts-example-app/src/flows/__tests__/customer-internal-accounts.test.ts b/apps/examples/grid-global-accounts-example-app/src/flows/__tests__/customer-internal-accounts.test.ts
new file mode 100644
index 000000000..2df61de2b
--- /dev/null
+++ b/apps/examples/grid-global-accounts-example-app/src/flows/__tests__/customer-internal-accounts.test.ts
@@ -0,0 +1,268 @@
+import { beforeEach, describe, expect, it, vi } from "vitest";
+
+import { createCollectingReporter } from "../../lib/collecting-reporter";
+import type { ApiAuth } from "../../api-client";
+import {
+ groupCustomerWallets,
+ listAllInternalAccounts,
+ parseInternalAccount,
+ type ParsedInternalAccount,
+} from "../customer";
+
+const auth: ApiAuth = {
+ clientId: "id",
+ clientSecret: "secret",
+ mode: "sandbox",
+};
+
+// Mock at the api-client boundary so no real API is hit.
+vi.mock("../../api-client", () => ({
+ apiGet: vi.fn(),
+}));
+import { apiGet } from "../../api-client";
+const mockGet = vi.mocked(apiGet);
+
+// Reset call history + queued resolutions between tests so the multi-page tests
+// (which assert exact call counts) don't see earlier tests' calls or leftovers.
+beforeEach(() => {
+ mockGet.mockReset();
+});
+
+/** Convenience builder for a parsed account in grouping tests. */
+function acct(
+ over: Partial & { customerId: string },
+): ParsedInternalAccount {
+ return {
+ id: `InternalAccount:${Math.random().toString(36).slice(2)}`,
+ type: "INTERNAL_FIAT",
+ status: "ACTIVE",
+ amount: 0,
+ currency: { code: "USD", decimals: 2 },
+ ...over,
+ };
+}
+
+describe("parseInternalAccount", () => {
+ it("maps id, customerId, type, status, and balance", () => {
+ const row = {
+ id: "InternalAccount:1",
+ customerId: "Customer:abc",
+ type: "EMBEDDED_WALLET",
+ status: "ACTIVE",
+ balance: { amount: 123456, currency: { code: "USDB", decimals: 6 } },
+ };
+ expect(parseInternalAccount(row)).toEqual({
+ id: "InternalAccount:1",
+ customerId: "Customer:abc",
+ type: "EMBEDDED_WALLET",
+ status: "ACTIVE",
+ amount: 123456,
+ currency: { code: "USDB", decimals: 6 },
+ });
+ });
+
+ it("treats a missing customerId as platform-owned (empty string)", () => {
+ const out = parseInternalAccount({
+ id: "InternalAccount:2",
+ type: "INTERNAL_FIAT",
+ balance: { amount: 1, currency: { code: "USD" } },
+ });
+ expect(out?.customerId).toBe("");
+ });
+
+ it("defaults a missing amount to 0 and currency to {}", () => {
+ expect(parseInternalAccount({ id: "InternalAccount:3" })).toEqual({
+ id: "InternalAccount:3",
+ customerId: "",
+ type: "",
+ status: "",
+ amount: 0,
+ currency: {},
+ });
+ });
+
+ it("returns null for rows without a usable id", () => {
+ expect(parseInternalAccount({ balance: { amount: 1 } })).toBeNull();
+ expect(parseInternalAccount(null)).toBeNull();
+ expect(parseInternalAccount("nope")).toBeNull();
+ });
+});
+
+describe("groupCustomerWallets", () => {
+ it("groups by customerId into one wallet row each", () => {
+ const out = groupCustomerWallets([
+ acct({ customerId: "Customer:1", type: "EMBEDDED_WALLET", amount: 100 }),
+ acct({ customerId: "Customer:2", type: "EMBEDDED_WALLET", amount: 200 }),
+ ]);
+ expect(out.map((w) => w.customerId).sort()).toEqual([
+ "Customer:1",
+ "Customer:2",
+ ]);
+ });
+
+ it("drops platform-owned accounts (empty customerId)", () => {
+ const out = groupCustomerWallets([
+ acct({ customerId: "", type: "INTERNAL_FIAT", amount: 999 }),
+ acct({ customerId: "Customer:1", type: "EMBEDDED_WALLET", amount: 50 }),
+ ]);
+ expect(out).toHaveLength(1);
+ expect(out[0].customerId).toBe("Customer:1");
+ });
+
+ it("keeps a USDB account as a customer wallet even without the embedded type", () => {
+ const out = groupCustomerWallets([
+ acct({
+ customerId: "Customer:1",
+ type: "INTERNAL_CRYPTO",
+ currency: { code: "USDB", decimals: 6 },
+ amount: 7,
+ }),
+ ]);
+ expect(out).toHaveLength(1);
+ expect(out[0].amount).toBe(7);
+ });
+
+ it("omits a customer with no wallet account (only non-USDB fiat)", () => {
+ const out = groupCustomerWallets([
+ acct({
+ customerId: "Customer:1",
+ type: "INTERNAL_FIAT",
+ currency: { code: "EUR", decimals: 2 },
+ }),
+ ]);
+ expect(out).toEqual([]);
+ });
+
+ it("picks the embedded-wallet account when a customer has several candidates", () => {
+ const out = groupCustomerWallets([
+ acct({
+ id: "InternalAccount:usdb",
+ customerId: "Customer:1",
+ type: "INTERNAL_CRYPTO",
+ currency: { code: "USDB", decimals: 6 },
+ amount: 1,
+ }),
+ acct({
+ id: "InternalAccount:wallet",
+ customerId: "Customer:1",
+ type: "EMBEDDED_WALLET",
+ currency: { code: "USDB", decimals: 6 },
+ amount: 500,
+ }),
+ ]);
+ expect(out).toHaveLength(1);
+ expect(out[0].accountId).toBe("InternalAccount:wallet");
+ expect(out[0].amount).toBe(500);
+ });
+
+ it("projects the wallet's accountId, currency, and amount onto the row", () => {
+ const out = groupCustomerWallets([
+ acct({
+ id: "InternalAccount:w",
+ customerId: "Customer:9",
+ type: "EMBEDDED_WALLET",
+ currency: { code: "USDB", decimals: 6 },
+ amount: 4242,
+ }),
+ ]);
+ expect(out[0]).toEqual({
+ customerId: "Customer:9",
+ accountId: "InternalAccount:w",
+ currency: { code: "USDB", decimals: 6 },
+ amount: 4242,
+ });
+ });
+});
+
+describe("listAllInternalAccounts (api-client boundary)", () => {
+ it("fetches a single page with no customerId filter and parses accounts", async () => {
+ const { reporter } = createCollectingReporter();
+ mockGet.mockResolvedValueOnce({
+ data: [
+ {
+ id: "InternalAccount:1",
+ customerId: "Customer:1",
+ type: "EMBEDDED_WALLET",
+ status: "ACTIVE",
+ balance: { amount: 10, currency: { code: "USDB", decimals: 6 } },
+ },
+ ],
+ hasMore: false,
+ });
+
+ const out = await listAllInternalAccounts(reporter, auth);
+
+ expect(mockGet).toHaveBeenCalledTimes(1);
+ expect(mockGet).toHaveBeenCalledWith(
+ auth,
+ "/customers/internal-accounts?limit=100",
+ );
+ expect(out.accounts.map((a) => a.id)).toEqual(["InternalAccount:1"]);
+ expect(out.truncated).toBe(false);
+ });
+
+ it("follows hasMore/nextCursor across pages and concatenates accounts", async () => {
+ const { reporter } = createCollectingReporter();
+ mockGet
+ .mockResolvedValueOnce({
+ data: [{ id: "InternalAccount:1", customerId: "Customer:1" }],
+ hasMore: true,
+ nextCursor: "cursor-2",
+ })
+ .mockResolvedValueOnce({
+ data: [{ id: "InternalAccount:2", customerId: "Customer:2" }],
+ hasMore: false,
+ });
+
+ const out = await listAllInternalAccounts(reporter, auth);
+
+ expect(mockGet).toHaveBeenCalledTimes(2);
+ expect(mockGet).toHaveBeenNthCalledWith(
+ 1,
+ auth,
+ "/customers/internal-accounts?limit=100",
+ );
+ expect(mockGet).toHaveBeenNthCalledWith(
+ 2,
+ auth,
+ "/customers/internal-accounts?limit=100&cursor=cursor-2",
+ );
+ expect(out.accounts.map((a) => a.id)).toEqual([
+ "InternalAccount:1",
+ "InternalAccount:2",
+ ]);
+ expect(out.truncated).toBe(false);
+ });
+
+ it("stops at the page cap and flags truncation when the API keeps reporting more", async () => {
+ const { reporter } = createCollectingReporter();
+ // Always claim there's another page, so the cap (10) is what stops us.
+ mockGet.mockResolvedValue({
+ data: [{ id: "InternalAccount:x", customerId: "Customer:x" }],
+ hasMore: true,
+ nextCursor: "next",
+ });
+
+ const out = await listAllInternalAccounts(reporter, auth);
+
+ expect(mockGet).toHaveBeenCalledTimes(10);
+ expect(out.truncated).toBe(true);
+ });
+
+ it("tolerates a bare array (no envelope)", async () => {
+ const { reporter } = createCollectingReporter();
+ mockGet.mockResolvedValueOnce([
+ { id: "InternalAccount:1", customerId: "Customer:1" },
+ ]);
+ const out = await listAllInternalAccounts(reporter, auth);
+ expect(out.accounts.map((a) => a.id)).toEqual(["InternalAccount:1"]);
+ });
+
+ it("returns [] for an empty payload", async () => {
+ const { reporter } = createCollectingReporter();
+ mockGet.mockResolvedValueOnce({ data: [] });
+ const out = await listAllInternalAccounts(reporter, auth);
+ expect(out.accounts).toEqual([]);
+ expect(out.truncated).toBe(false);
+ });
+});
diff --git a/apps/examples/grid-global-accounts-example-app/src/flows/__tests__/export-wallet.test.ts b/apps/examples/grid-global-accounts-example-app/src/flows/__tests__/export-wallet.test.ts
new file mode 100644
index 000000000..c9d997f7b
--- /dev/null
+++ b/apps/examples/grid-global-accounts-example-app/src/flows/__tests__/export-wallet.test.ts
@@ -0,0 +1,154 @@
+import { beforeEach, describe, expect, it, vi } from "vitest";
+
+import { createCollectingReporter } from "../../lib/collecting-reporter";
+import type { ApiAuth } from "../../api-client";
+import { exportWallet } from "../manage";
+
+// Mock the api-client boundary so no real API is hit. `apiPost` is called twice
+// by the guided flow: the 202 issue leg, then the signed retry leg.
+vi.mock("../../api-client", () => ({
+ apiPost: vi.fn(),
+ apiGet: vi.fn(),
+ apiDelete: vi.fn(),
+}));
+import { apiPost } from "../../api-client";
+const mockPost = vi.mocked(apiPost);
+
+// Production signs the retry with a live session stamp; stub it so the test can
+// reach the decrypt path without standing up a real session.
+vi.mock("../../turnkey", () => ({
+ turnkeyStamp: vi.fn().mockResolvedValue("stamped-sig"),
+}));
+
+// Mock the crypto: a fixed keypair, and decrypt helpers that yield a known
+// mnemonic, so the test asserts the wiring (bundle → decrypt → mnemonic)
+// without any real enclave material.
+const decryptExportBundle = vi.fn();
+const hpkeDecrypt = vi.fn();
+vi.mock("@turnkey/crypto", () => ({
+ generateP256KeyPair: () => ({
+ privateKey: "priv-hex",
+ publicKey: "pub-hex",
+ publicKeyUncompressed: "04-uncompressed-hex",
+ }),
+ decryptExportBundle: (...args: unknown[]) => decryptExportBundle(...args),
+ hpkeDecrypt: (...args: unknown[]) => hpkeDecrypt(...args),
+}));
+
+const MNEMONIC = "legal winner thank year wave sausage worth useful legal";
+
+// Build an export bundle whose `data` blob hex-decodes to the signed-data JSON
+// (encappedPublic / ciphertext / organizationId), matching the real shape.
+function makeBundle(data: {
+ encappedPublic: string;
+ ciphertext: string;
+ organizationId: string;
+}): string {
+ const hex = Array.from(new TextEncoder().encode(JSON.stringify(data)))
+ .map((b) => b.toString(16).padStart(2, "0"))
+ .join("");
+ return JSON.stringify({
+ version: "v1.0.0",
+ data: hex,
+ dataSignature: "",
+ enclaveQuorumPublic: "",
+ });
+}
+
+function queueGuidedExport(bundle: string) {
+ mockPost
+ // Issue leg → 202 challenge.
+ .mockResolvedValueOnce({
+ status: 202,
+ data: { requestId: "Request:abc", payloadToSign: "payload" },
+ })
+ // Signed retry → 200 with the sealed bundle.
+ .mockResolvedValueOnce({
+ status: 200,
+ data: { id: "InternalAccount:1", encryptedWalletCredentials: bundle },
+ });
+}
+
+beforeEach(() => {
+ mockPost.mockReset();
+ decryptExportBundle.mockReset();
+ hpkeDecrypt.mockReset();
+});
+
+describe("exportWallet", () => {
+ it("sandbox: HPKE-decrypts the bundle and returns the mnemonic", async () => {
+ const { reporter } = createCollectingReporter();
+ const auth: ApiAuth = {
+ clientId: "id",
+ clientSecret: "secret",
+ mode: "sandbox",
+ };
+ const bundle = makeBundle({
+ encappedPublic: "0a0b",
+ ciphertext: "0c0d",
+ organizationId: "org-123",
+ });
+ queueGuidedExport(bundle);
+ hpkeDecrypt.mockReturnValue(new TextEncoder().encode(MNEMONIC));
+
+ const out = await exportWallet(reporter, auth, "InternalAccount:1");
+
+ // Sandbox bypasses enclave attestation: hpkeDecrypt, not decryptExportBundle.
+ expect(decryptExportBundle).not.toHaveBeenCalled();
+ expect(hpkeDecrypt).toHaveBeenCalledWith(
+ expect.objectContaining({ receiverPriv: "priv-hex" }),
+ );
+ expect(out.mnemonic).toBe(MNEMONIC);
+ // Raw guided result is preserved for the debug log.
+ expect(out.retried).toMatchObject({ encryptedWalletCredentials: bundle });
+ });
+
+ it("production: verifies via decryptExportBundle with the org id from the bundle", async () => {
+ const { reporter } = createCollectingReporter();
+ const auth: ApiAuth = {
+ clientId: "id",
+ clientSecret: "secret",
+ mode: "production",
+ };
+ const bundle = makeBundle({
+ encappedPublic: "0a0b",
+ ciphertext: "0c0d",
+ organizationId: "org-xyz",
+ });
+ queueGuidedExport(bundle);
+ decryptExportBundle.mockResolvedValue(MNEMONIC);
+
+ const out = await exportWallet(reporter, auth, "InternalAccount:1");
+
+ expect(hpkeDecrypt).not.toHaveBeenCalled();
+ expect(decryptExportBundle).toHaveBeenCalledWith({
+ exportBundle: bundle,
+ embeddedKey: "priv-hex",
+ organizationId: "org-xyz",
+ returnMnemonic: true,
+ });
+ expect(out.mnemonic).toBe(MNEMONIC);
+ });
+
+ it("throws when the export response has no sealed bundle", async () => {
+ const { reporter } = createCollectingReporter();
+ const auth: ApiAuth = {
+ clientId: "id",
+ clientSecret: "secret",
+ mode: "sandbox",
+ };
+ mockPost
+ .mockResolvedValueOnce({
+ status: 202,
+ data: { requestId: "Request:abc", payloadToSign: "payload" },
+ })
+ .mockResolvedValueOnce({
+ status: 200,
+ data: { id: "InternalAccount:1" },
+ });
+
+ await expect(
+ exportWallet(reporter, auth, "InternalAccount:1"),
+ ).rejects.toThrow(/encryptedWalletCredentials/);
+ });
+});
diff --git a/apps/examples/grid-global-accounts-example-app/src/flows/__tests__/fetch-balance.test.ts b/apps/examples/grid-global-accounts-example-app/src/flows/__tests__/fetch-balance.test.ts
new file mode 100644
index 000000000..2f79719bd
--- /dev/null
+++ b/apps/examples/grid-global-accounts-example-app/src/flows/__tests__/fetch-balance.test.ts
@@ -0,0 +1,106 @@
+import { describe, expect, it, vi } from "vitest";
+
+import type { ApiAuth } from "../../api-client";
+import { createCollectingReporter } from "../../lib/collecting-reporter";
+import { formatMoney } from "../../lib/format-money";
+import { fetchBalance, mapBalanceRow } from "../customer";
+
+const auth: ApiAuth = {
+ clientId: "id",
+ clientSecret: "secret",
+ mode: "sandbox",
+};
+
+// Mock at the api-client boundary so no real API is hit.
+vi.mock("../../api-client", () => ({
+ apiGet: vi.fn(),
+}));
+import { apiGet } from "../../api-client";
+const mockGet = vi.mocked(apiGet);
+
+describe("mapBalanceRow", () => {
+ it("pulls amount + currency from `balance` (not the top level)", () => {
+ // The Grid internal-account shape: the currency object (with `decimals`)
+ // lives INSIDE `balance`, not at the top level.
+ const row = {
+ id: "InternalAccount:abc",
+ balance: { amount: 3_000_000, currency: { code: "USDB", decimals: 6 } },
+ };
+ expect(mapBalanceRow(row)).toEqual({
+ id: "InternalAccount:abc",
+ currency: { code: "USDB", decimals: 6 },
+ balance: 3_000_000,
+ });
+ });
+
+ it("renders 3 USDB (3,000,000 minor, 6 decimals) as 3, not 30,000", () => {
+ const mapped = mapBalanceRow({
+ id: "InternalAccount:abc",
+ balance: { amount: 3_000_000, currency: { code: "USDB", decimals: 6 } },
+ });
+ const out = formatMoney(mapped.balance, mapped.currency);
+ expect(out).toBe("3.000000 USDB");
+ expect(out).not.toContain("30,000");
+ });
+
+ it("tolerates a bare-number balance (no currency block)", () => {
+ expect(mapBalanceRow({ id: "InternalAccount:x", balance: 4200 })).toEqual({
+ id: "InternalAccount:x",
+ currency: undefined,
+ balance: 4200,
+ });
+ });
+
+ it("defaults a missing/odd balance to 0", () => {
+ expect(mapBalanceRow({ id: "InternalAccount:y" })).toEqual({
+ id: "InternalAccount:y",
+ currency: undefined,
+ balance: 0,
+ });
+ expect(
+ mapBalanceRow({ id: "InternalAccount:z", balance: { currency: {} } }),
+ ).toEqual({ id: "InternalAccount:z", currency: {}, balance: 0 });
+ });
+});
+
+describe("fetchBalance (api-client boundary)", () => {
+ it("maps each account row's amount + currency from `balance`", async () => {
+ const { reporter } = createCollectingReporter();
+ const raw = {
+ data: [
+ {
+ id: "InternalAccount:1",
+ balance: {
+ amount: 3_000_000,
+ currency: { code: "USDB", decimals: 6 },
+ },
+ },
+ ],
+ };
+ mockGet.mockResolvedValueOnce(raw);
+
+ const { rows } = await fetchBalance(reporter, auth, "Customer:c1");
+
+ expect(mockGet).toHaveBeenCalledWith(
+ auth,
+ "/customers/internal-accounts?customerId=Customer%3Ac1",
+ );
+ expect(rows).toEqual([
+ {
+ id: "InternalAccount:1",
+ currency: { code: "USDB", decimals: 6 },
+ balance: 3_000_000,
+ },
+ ]);
+ expect(formatMoney(rows[0].balance, rows[0].currency)).toBe(
+ "3.000000 USDB",
+ );
+ });
+
+ it("throws when the customer id is blank", async () => {
+ const { reporter } = createCollectingReporter();
+ await expect(fetchBalance(reporter, auth, " ")).rejects.toThrow(
+ "Customer ID is required.",
+ );
+ });
+});
diff --git a/apps/examples/grid-global-accounts-example-app/src/flows/__tests__/fund-customer.test.ts b/apps/examples/grid-global-accounts-example-app/src/flows/__tests__/fund-customer.test.ts
new file mode 100644
index 000000000..110e77833
--- /dev/null
+++ b/apps/examples/grid-global-accounts-example-app/src/flows/__tests__/fund-customer.test.ts
@@ -0,0 +1,319 @@
+import { beforeEach, describe, expect, it, vi } from "vitest";
+
+import { createCollectingReporter } from "../../lib/collecting-reporter";
+import type { ApiAuth } from "../../api-client";
+import {
+ fundCustomerFromPlatform,
+ pollTransaction,
+ type Sleep,
+} from "../money";
+
+const auth: ApiAuth = {
+ clientId: "id",
+ clientSecret: "secret",
+ mode: "sandbox",
+};
+
+// Mock at the api-client boundary so no real API is hit.
+vi.mock("../../api-client", () => ({
+ apiGet: vi.fn(),
+ apiPost: vi.fn(),
+}));
+import { apiGet, apiPost } from "../../api-client";
+const mockGet = vi.mocked(apiGet);
+const mockPost = vi.mocked(apiPost);
+
+// A sleep that never actually waits — keeps the poll loop synchronous in tests.
+const noSleep: Sleep = () => Promise.resolve();
+
+beforeEach(() => {
+ mockGet.mockReset();
+ mockPost.mockReset();
+});
+
+describe("fundCustomerFromPlatform — request shaping", () => {
+ it("builds the RECEIVING-locked quote body with platform source + customer destination", async () => {
+ const { reporter } = createCollectingReporter();
+ mockPost
+ .mockResolvedValueOnce({ status: 200, data: { id: "Quote:q1" } }) // quote
+ .mockResolvedValueOnce({
+ status: 200,
+ data: { transactionId: "Transaction:t1" },
+ }); // execute
+ mockGet.mockResolvedValueOnce({
+ id: "Transaction:t1",
+ status: "COMPLETED",
+ });
+
+ await fundCustomerFromPlatform(
+ reporter,
+ auth,
+ {
+ fundingAccountId: "InternalAccount:fund",
+ destinationAccountId: "InternalAccount:cust",
+ amountMinor: 2500,
+ },
+ { poll: { sleep: noSleep } },
+ );
+
+ // First POST is the quote with the exact reference shape.
+ expect(mockPost).toHaveBeenNthCalledWith(1, auth, "/quotes", {
+ source: { sourceType: "ACCOUNT", accountId: "InternalAccount:fund" },
+ destination: {
+ destinationType: "ACCOUNT",
+ accountId: "InternalAccount:cust",
+ },
+ lockedCurrencySide: "RECEIVING",
+ lockedCurrencyAmount: 2500,
+ });
+ });
+
+ it("executes with an EMPTY body and NO Grid-Wallet-Signature header", async () => {
+ const { reporter } = createCollectingReporter();
+ mockPost
+ .mockResolvedValueOnce({ status: 200, data: { id: "Quote:q1" } })
+ .mockResolvedValueOnce({
+ status: 200,
+ data: { transactionId: "Transaction:t1" },
+ });
+ mockGet.mockResolvedValueOnce({ status: "COMPLETED" });
+
+ await fundCustomerFromPlatform(
+ reporter,
+ auth,
+ {
+ fundingAccountId: "InternalAccount:fund",
+ destinationAccountId: "InternalAccount:cust",
+ amountMinor: 100,
+ },
+ { poll: { sleep: noSleep } },
+ );
+
+ // Second POST is the execute: path includes the quote id, body is {} and
+ // there is NO fourth (extraHeaders) argument — i.e. no signature header.
+ const executeCall = mockPost.mock.calls[1];
+ expect(executeCall[1]).toBe("/quotes/Quote%3Aq1/execute");
+ expect(executeCall[2]).toEqual({});
+ expect(executeCall[3]).toBeUndefined();
+ });
+
+ it("trims ids and rejects a non-positive amount before calling the API", async () => {
+ const { reporter } = createCollectingReporter();
+ await expect(
+ fundCustomerFromPlatform(reporter, auth, {
+ fundingAccountId: "InternalAccount:fund",
+ destinationAccountId: "InternalAccount:cust",
+ amountMinor: 0,
+ }),
+ ).rejects.toThrow(/amount/i);
+ expect(mockPost).not.toHaveBeenCalled();
+ });
+
+ it("requires a funding account and a customer account", async () => {
+ const { reporter } = createCollectingReporter();
+ await expect(
+ fundCustomerFromPlatform(reporter, auth, {
+ fundingAccountId: " ",
+ destinationAccountId: "InternalAccount:cust",
+ amountMinor: 100,
+ }),
+ ).rejects.toThrow(/funding account/i);
+ await expect(
+ fundCustomerFromPlatform(reporter, auth, {
+ fundingAccountId: "InternalAccount:fund",
+ destinationAccountId: "",
+ amountMinor: 100,
+ }),
+ ).rejects.toThrow(/internal account/i);
+ expect(mockPost).not.toHaveBeenCalled();
+ });
+});
+
+describe("fundCustomerFromPlatform — orchestration result", () => {
+ it("returns quoteId, transactionId, and the terminal COMPLETED status", async () => {
+ const { reporter } = createCollectingReporter();
+ mockPost
+ .mockResolvedValueOnce({ status: 200, data: { id: "Quote:q9" } })
+ .mockResolvedValueOnce({
+ status: 200,
+ data: { transactionId: "Transaction:t9" },
+ });
+ mockGet.mockResolvedValueOnce({
+ id: "Transaction:t9",
+ status: "COMPLETED",
+ });
+
+ const out = await fundCustomerFromPlatform(
+ reporter,
+ auth,
+ {
+ fundingAccountId: "InternalAccount:fund",
+ destinationAccountId: "InternalAccount:cust",
+ amountMinor: 500,
+ },
+ { poll: { sleep: noSleep } },
+ );
+
+ expect(out.quoteId).toBe("Quote:q9");
+ expect(out.transactionId).toBe("Transaction:t9");
+ expect(out.status).toBe("COMPLETED");
+ });
+
+ it("surfaces a FAILED terminal status without throwing", async () => {
+ const { reporter } = createCollectingReporter();
+ mockPost
+ .mockResolvedValueOnce({ status: 200, data: { id: "Quote:q1" } })
+ .mockResolvedValueOnce({
+ status: 200,
+ data: { transactionId: "Transaction:t1" },
+ });
+ mockGet.mockResolvedValueOnce({ status: "FAILED" });
+
+ const out = await fundCustomerFromPlatform(
+ reporter,
+ auth,
+ {
+ fundingAccountId: "InternalAccount:fund",
+ destinationAccountId: "InternalAccount:cust",
+ amountMinor: 500,
+ },
+ { poll: { sleep: noSleep } },
+ );
+
+ expect(out.status).toBe("FAILED");
+ });
+
+ it("throws when execute returns no transactionId", async () => {
+ const { reporter } = createCollectingReporter();
+ mockPost
+ .mockResolvedValueOnce({ status: 200, data: { id: "Quote:q1" } })
+ .mockResolvedValueOnce({ status: 200, data: {} });
+
+ await expect(
+ fundCustomerFromPlatform(
+ reporter,
+ auth,
+ {
+ fundingAccountId: "InternalAccount:fund",
+ destinationAccountId: "InternalAccount:cust",
+ amountMinor: 500,
+ },
+ { poll: { sleep: noSleep } },
+ ),
+ ).rejects.toThrow(/transactionId/i);
+ });
+});
+
+describe("fundCustomerFromPlatform — onStage sequence", () => {
+ const baseParams = {
+ fundingAccountId: "InternalAccount:fund",
+ destinationAccountId: "InternalAccount:cust",
+ amountMinor: 500,
+ };
+
+ it("fires quoting → executing → processing → completed on a COMPLETED txn", async () => {
+ const { reporter } = createCollectingReporter();
+ mockPost
+ .mockResolvedValueOnce({ status: 200, data: { id: "Quote:q1" } })
+ .mockResolvedValueOnce({
+ status: 200,
+ data: { transactionId: "Transaction:t1" },
+ });
+ mockGet.mockResolvedValueOnce({ status: "COMPLETED" });
+
+ const stages: string[] = [];
+ await fundCustomerFromPlatform(reporter, auth, baseParams, {
+ poll: { sleep: noSleep },
+ onStage: (s) => stages.push(s),
+ });
+
+ expect(stages).toEqual(["quoting", "executing", "processing", "completed"]);
+ });
+
+ it("ends with failed on a FAILED txn", async () => {
+ const { reporter } = createCollectingReporter();
+ mockPost
+ .mockResolvedValueOnce({ status: 200, data: { id: "Quote:q1" } })
+ .mockResolvedValueOnce({
+ status: 200,
+ data: { transactionId: "Transaction:t1" },
+ });
+ mockGet.mockResolvedValueOnce({ status: "FAILED" });
+
+ const stages: string[] = [];
+ await fundCustomerFromPlatform(reporter, auth, baseParams, {
+ poll: { sleep: noSleep },
+ onStage: (s) => stages.push(s),
+ });
+
+ expect(stages).toEqual(["quoting", "executing", "processing", "failed"]);
+ });
+
+ it("stays at processing (no terminal stage) when the poll times out", async () => {
+ const { reporter } = createCollectingReporter();
+ mockPost
+ .mockResolvedValueOnce({ status: 200, data: { id: "Quote:q1" } })
+ .mockResolvedValueOnce({
+ status: 200,
+ data: { transactionId: "Transaction:t1" },
+ });
+ mockGet.mockResolvedValue({ status: "PROCESSING" });
+
+ const stages: string[] = [];
+ await fundCustomerFromPlatform(reporter, auth, baseParams, {
+ poll: { sleep: noSleep, intervalMs: 10, timeoutMs: 25 },
+ onStage: (s) => stages.push(s),
+ });
+
+ expect(stages).toEqual(["quoting", "executing", "processing"]);
+ });
+});
+
+describe("pollTransaction", () => {
+ it("polls GET /transactions/{id} and resolves on the first COMPLETED", async () => {
+ const { reporter } = createCollectingReporter();
+ mockGet.mockResolvedValueOnce({ status: "COMPLETED" });
+
+ const out = await pollTransaction(reporter, auth, "Transaction:abc", {
+ sleep: noSleep,
+ });
+
+ expect(mockGet).toHaveBeenCalledWith(
+ auth,
+ "/transactions/Transaction%3Aabc",
+ );
+ expect(out.status).toBe("COMPLETED");
+ expect(mockGet).toHaveBeenCalledTimes(1);
+ });
+
+ it("keeps polling through PENDING/PROCESSING until a terminal status", async () => {
+ const { reporter } = createCollectingReporter();
+ mockGet
+ .mockResolvedValueOnce({ status: "PENDING" })
+ .mockResolvedValueOnce({ status: "PROCESSING" })
+ .mockResolvedValueOnce({ status: "COMPLETED" });
+
+ const out = await pollTransaction(reporter, auth, "Transaction:abc", {
+ sleep: noSleep,
+ intervalMs: 1,
+ timeoutMs: 1000,
+ });
+
+ expect(mockGet).toHaveBeenCalledTimes(3);
+ expect(out.status).toBe("COMPLETED");
+ });
+
+ it("returns the last-seen status when the timeout elapses (non-terminal)", async () => {
+ const { reporter } = createCollectingReporter();
+ mockGet.mockResolvedValue({ status: "PROCESSING" });
+
+ const out = await pollTransaction(reporter, auth, "Transaction:abc", {
+ sleep: noSleep,
+ intervalMs: 10,
+ timeoutMs: 25,
+ });
+
+ // Polls at t=0, 10, 20 (next would exceed 25), then gives up.
+ expect(out.status).toBe("PROCESSING");
+ });
+});
diff --git a/apps/examples/grid-global-accounts-example-app/src/flows/__tests__/login-decision.test.ts b/apps/examples/grid-global-accounts-example-app/src/flows/__tests__/login-decision.test.ts
new file mode 100644
index 000000000..0395c89fb
--- /dev/null
+++ b/apps/examples/grid-global-accounts-example-app/src/flows/__tests__/login-decision.test.ts
@@ -0,0 +1,137 @@
+import { describe, expect, it, vi } from "vitest";
+
+import { createCollectingReporter } from "../../lib/collecting-reporter";
+import type { ApiAuth } from "../../api-client";
+import {
+ decideLogin,
+ existingCredentialFor,
+ parseCredentials,
+} from "../login-decision";
+import { signInEmailOtp, type EmailOtpSignInDeps } from "../email-otp";
+
+const auth: ApiAuth = {
+ clientId: "id",
+ clientSecret: "secret",
+ mode: "production",
+};
+
+describe("parseCredentials", () => {
+ it("unwraps the { data: [...] } envelope the API returns", () => {
+ const raw = { data: [{ id: "c1", type: "EMAIL_OTP" }] };
+ expect(parseCredentials(raw)).toEqual([{ id: "c1", type: "EMAIL_OTP" }]);
+ });
+
+ it("tolerates a bare array", () => {
+ const raw = [{ id: "c1", type: "OAUTH" }];
+ expect(parseCredentials(raw)).toEqual([{ id: "c1", type: "OAUTH" }]);
+ });
+
+ it("returns [] for a missing/empty payload (loading state)", () => {
+ expect(parseCredentials(null)).toEqual([]);
+ expect(parseCredentials(undefined)).toEqual([]);
+ expect(parseCredentials({})).toEqual([]);
+ });
+});
+
+describe("existingCredentialFor", () => {
+ const creds = [
+ { id: "otp-1", type: "EMAIL_OTP" },
+ { id: "oauth-1", type: "OAUTH" },
+ ];
+
+ it("maps each method to the matching credential type", () => {
+ expect(existingCredentialFor(creds, "email_otp")?.id).toBe("otp-1");
+ expect(existingCredentialFor(creds, "oauth")?.id).toBe("oauth-1");
+ expect(existingCredentialFor(creds, "passkey")).toBeUndefined();
+ });
+
+ it("ignores credentials without a usable id", () => {
+ const broken = [{ id: "", type: "EMAIL_OTP" }];
+ expect(existingCredentialFor(broken, "email_otp")).toBeUndefined();
+ });
+});
+
+describe("decideLogin", () => {
+ it("authenticates with the existing credential id when one exists", () => {
+ const creds = [{ id: "otp-1", type: "EMAIL_OTP" }];
+ expect(decideLogin(creds, "email_otp")).toEqual({
+ action: "authenticate",
+ credId: "otp-1",
+ });
+ });
+
+ it("creates when no credential of that method exists", () => {
+ const creds = [{ id: "oauth-1", type: "OAUTH" }];
+ expect(decideLogin(creds, "email_otp")).toEqual({ action: "create" });
+ expect(decideLogin([], "email_otp")).toEqual({ action: "create" });
+ });
+});
+
+describe("signInEmailOtp (create-vs-authenticate routing)", () => {
+ it("authenticates with the existing credential id and does NOT create", async () => {
+ const { reporter } = createCollectingReporter();
+ const create = vi.fn();
+ const login = vi
+ .fn()
+ .mockResolvedValue({ leg1: {}, session: { id: "sess-1" } });
+ const deps = { create, login } as unknown as EmailOtpSignInDeps;
+
+ const session = await signInEmailOtp(
+ reporter,
+ auth,
+ "acct-1",
+ "000000",
+ "existing-otp-cred",
+ deps,
+ );
+
+ expect(create).not.toHaveBeenCalled();
+ expect(login).toHaveBeenCalledWith(
+ reporter,
+ auth,
+ "existing-otp-cred",
+ "000000",
+ );
+ expect(session).toEqual({ id: "sess-1" });
+ });
+
+ it("creates a credential first when none exists, then logs in with the new id", async () => {
+ const { reporter } = createCollectingReporter();
+ const create = vi.fn().mockResolvedValue({ id: "new-otp-cred" });
+ const login = vi
+ .fn()
+ .mockResolvedValue({ leg1: {}, session: { id: "sess-2" } });
+ const deps = { create, login } as unknown as EmailOtpSignInDeps;
+
+ const session = await signInEmailOtp(
+ reporter,
+ auth,
+ "acct-1",
+ "000000",
+ null,
+ deps,
+ );
+
+ expect(create).toHaveBeenCalledOnce();
+ expect(create).toHaveBeenCalledWith(reporter, auth, "acct-1");
+ expect(login).toHaveBeenCalledWith(
+ reporter,
+ auth,
+ "new-otp-cred",
+ "000000",
+ );
+ expect(session).toEqual({ id: "sess-2" });
+ });
+
+ it("throws if create returns no id (does not silently log in)", async () => {
+ const { reporter } = createCollectingReporter();
+ const create = vi.fn().mockResolvedValue({});
+ const login = vi.fn();
+ const deps = { create, login } as unknown as EmailOtpSignInDeps;
+
+ await expect(
+ signInEmailOtp(reporter, auth, "acct-1", "000000", null, deps),
+ ).rejects.toThrow(/no id/i);
+ expect(login).not.toHaveBeenCalled();
+ });
+});
diff --git a/apps/examples/grid-global-accounts-example-app/src/flows/__tests__/otp-step.test.ts b/apps/examples/grid-global-accounts-example-app/src/flows/__tests__/otp-step.test.ts
new file mode 100644
index 000000000..fe864303a
--- /dev/null
+++ b/apps/examples/grid-global-accounts-example-app/src/flows/__tests__/otp-step.test.ts
@@ -0,0 +1,128 @@
+import { describe, expect, it, vi } from "vitest";
+
+import { createCollectingReporter } from "../../lib/collecting-reporter";
+import type { ApiAuth } from "../../api-client";
+import { sendOtpChallenge, verifyOtpStep, type OtpStepDeps } from "../otp-step";
+
+const auth: ApiAuth = {
+ clientId: "id",
+ clientSecret: "secret",
+ mode: "sandbox",
+};
+
+function makeDeps() {
+ const requestChallenge = vi.fn().mockResolvedValue("bundle-from-challenge");
+ const runVerify = vi
+ .fn()
+ .mockResolvedValue({ leg1: {}, session: { id: "sess-1" } });
+ const deps = { requestChallenge, runVerify } as unknown as OtpStepDeps;
+ return { requestChallenge, runVerify, deps };
+}
+
+describe("EMAIL_OTP two-step sign-in (challenge decoupled from verify)", () => {
+ it("sendOtpChallenge fires the challenge exactly once and returns the bundle", async () => {
+ const { reporter } = createCollectingReporter();
+ const { requestChallenge, runVerify, deps } = makeDeps();
+
+ const next = await sendOtpChallenge(reporter, auth, "otp-cred", deps);
+
+ expect(requestChallenge).toHaveBeenCalledOnce();
+ expect(requestChallenge).toHaveBeenCalledWith(reporter, auth, "otp-cred");
+ expect(next).toEqual({
+ status: "awaiting_code",
+ targetBundle: "bundle-from-challenge",
+ });
+ // Sending the challenge must not verify anything.
+ expect(runVerify).not.toHaveBeenCalled();
+ });
+
+ it("verifyOtpStep never issues a challenge — it only runs verify against the cached bundle", async () => {
+ const { reporter } = createCollectingReporter();
+ const { requestChallenge, runVerify, deps } = makeDeps();
+
+ const session = await verifyOtpStep(
+ reporter,
+ auth,
+ "otp-cred",
+ "bundle-from-challenge",
+ "000000",
+ deps,
+ );
+
+ expect(requestChallenge).not.toHaveBeenCalled();
+ expect(runVerify).toHaveBeenCalledOnce();
+ expect(runVerify).toHaveBeenCalledWith(
+ reporter,
+ auth,
+ "otp-cred",
+ "bundle-from-challenge",
+ "000000",
+ );
+ expect(session).toEqual({ id: "sess-1" });
+ });
+
+ it("a full send → verify only sends ONE OTP; a verify retry sends none", async () => {
+ const { reporter } = createCollectingReporter();
+ const { requestChallenge, runVerify, deps } = makeDeps();
+
+ // Step 1: explicit Send.
+ const step = await sendOtpChallenge(reporter, auth, "otp-cred", deps);
+ // Step 2: verify with the bundle from step 1 — a first attempt that fails…
+ runVerify.mockRejectedValueOnce(new Error("bad code"));
+ await expect(
+ verifyOtpStep(
+ reporter,
+ auth,
+ "otp-cred",
+ step.targetBundle,
+ "wrong",
+ deps,
+ ),
+ ).rejects.toThrow(/bad code/);
+ // …then a retry with the SAME bundle succeeds — still no extra challenge.
+ const session = await verifyOtpStep(
+ reporter,
+ auth,
+ "otp-cred",
+ step.targetBundle,
+ "000000",
+ deps,
+ );
+
+ // Exactly one OTP was sent across the whole interaction.
+ expect(requestChallenge).toHaveBeenCalledOnce();
+ expect(runVerify).toHaveBeenCalledTimes(2);
+ expect(session).toEqual({ id: "sess-1" });
+ });
+
+ it("verifyOtpStep refuses to verify without a challenge bundle", async () => {
+ const { reporter } = createCollectingReporter();
+ const { requestChallenge, runVerify, deps } = makeDeps();
+
+ await expect(
+ verifyOtpStep(reporter, auth, "otp-cred", "", "000000", deps),
+ ).rejects.toThrow(/send the code first/i);
+ expect(requestChallenge).not.toHaveBeenCalled();
+ expect(runVerify).not.toHaveBeenCalled();
+ });
+
+ it("verifyOtpStep requires a code (does not verify an empty OTP)", async () => {
+ const { reporter } = createCollectingReporter();
+ const { runVerify, deps } = makeDeps();
+
+ await expect(
+ verifyOtpStep(reporter, auth, "otp-cred", "bundle", " ", deps),
+ ).rejects.toThrow(/one-time code/i);
+ expect(runVerify).not.toHaveBeenCalled();
+ });
+
+ it("Resend is just another explicit challenge — one call per click", async () => {
+ const { reporter } = createCollectingReporter();
+ const { requestChallenge, deps } = makeDeps();
+
+ await sendOtpChallenge(reporter, auth, "otp-cred", deps); // initial Send
+ await sendOtpChallenge(reporter, auth, "otp-cred", deps); // Resend click
+
+ expect(requestChallenge).toHaveBeenCalledTimes(2);
+ });
+});
diff --git a/apps/examples/grid-global-accounts-example-app/src/flows/__tests__/platform-funding-accounts.test.ts b/apps/examples/grid-global-accounts-example-app/src/flows/__tests__/platform-funding-accounts.test.ts
new file mode 100644
index 000000000..e5b403a64
--- /dev/null
+++ b/apps/examples/grid-global-accounts-example-app/src/flows/__tests__/platform-funding-accounts.test.ts
@@ -0,0 +1,100 @@
+import { describe, expect, it, vi } from "vitest";
+
+import type { ApiAuth } from "../../api-client";
+import { createCollectingReporter } from "../../lib/collecting-reporter";
+import {
+ listPlatformFundingAccounts,
+ parsePlatformFundingAccount,
+} from "../customer";
+
+const auth: ApiAuth = {
+ clientId: "id",
+ clientSecret: "secret",
+ mode: "sandbox",
+};
+
+// Mock at the api-client boundary so no real API is hit.
+vi.mock("../../api-client", () => ({
+ apiGet: vi.fn(),
+}));
+import { apiGet } from "../../api-client";
+const mockGet = vi.mocked(apiGet);
+
+describe("parsePlatformFundingAccount", () => {
+ it("maps an account to {id, amount, currency}", () => {
+ const row = {
+ id: "InternalAccount:abc",
+ balance: { amount: 100_000, currency: { code: "USD", decimals: 2 } },
+ };
+ expect(parsePlatformFundingAccount(row)).toEqual({
+ id: "InternalAccount:abc",
+ amount: 100_000,
+ currency: { code: "USD", decimals: 2 },
+ });
+ });
+
+ it("defaults a missing amount to 0 and currency to {}", () => {
+ expect(parsePlatformFundingAccount({ id: "InternalAccount:x" })).toEqual({
+ id: "InternalAccount:x",
+ amount: 0,
+ currency: {},
+ });
+ });
+
+ it("returns null for rows without a usable id", () => {
+ expect(parsePlatformFundingAccount({ balance: { amount: 1 } })).toBeNull();
+ expect(parsePlatformFundingAccount(null)).toBeNull();
+ expect(parsePlatformFundingAccount("nope")).toBeNull();
+ });
+});
+
+describe("listPlatformFundingAccounts (api-client boundary)", () => {
+ it("queries the platform's own internal accounts and parses the envelope", async () => {
+ const { reporter } = createCollectingReporter();
+ const raw = {
+ data: [
+ {
+ id: "InternalAccount:1",
+ balance: { amount: 5000, currency: { code: "USD", decimals: 2 } },
+ },
+ {
+ id: "InternalAccount:2",
+ balance: { amount: 0, currency: { code: "EUR", decimals: 2 } },
+ },
+ ],
+ };
+ mockGet.mockResolvedValueOnce(raw);
+
+ const out = await listPlatformFundingAccounts(reporter, auth);
+
+ expect(mockGet).toHaveBeenCalledWith(auth, "/platform/internal-accounts");
+ expect(out.accounts.map((a) => a.id)).toEqual([
+ "InternalAccount:1",
+ "InternalAccount:2",
+ ]);
+ expect(out.raw).toBe(raw);
+ });
+
+ it("returns [] for an empty pool so the picker can render an empty state", async () => {
+ const { reporter } = createCollectingReporter();
+ mockGet.mockResolvedValueOnce({ data: [] });
+ const out = await listPlatformFundingAccounts(reporter, auth);
+ expect(out.accounts).toEqual([]);
+ });
+
+ it("tolerates a bare array (no envelope)", async () => {
+ const { reporter } = createCollectingReporter();
+ mockGet.mockResolvedValueOnce([{ id: "InternalAccount:9" }]);
+ const out = await listPlatformFundingAccounts(reporter, auth);
+ expect(out.accounts.map((a) => a.id)).toEqual(["InternalAccount:9"]);
+ });
+
+ it("drops rows without an id", async () => {
+ const { reporter } = createCollectingReporter();
+ mockGet.mockResolvedValueOnce({
+ data: [{ id: "InternalAccount:1" }, { balance: { amount: 1 } }],
+ });
+ const out = await listPlatformFundingAccounts(reporter, auth);
+ expect(out.accounts.map((a) => a.id)).toEqual(["InternalAccount:1"]);
+ });
+});
diff --git a/apps/examples/grid-global-accounts-example-app/src/flows/__tests__/transactions.test.ts b/apps/examples/grid-global-accounts-example-app/src/flows/__tests__/transactions.test.ts
new file mode 100644
index 000000000..aadc490c7
--- /dev/null
+++ b/apps/examples/grid-global-accounts-example-app/src/flows/__tests__/transactions.test.ts
@@ -0,0 +1,148 @@
+import { beforeEach, describe, expect, it, vi } from "vitest";
+
+import type { ApiAuth } from "../../api-client";
+import { createCollectingReporter } from "../../lib/collecting-reporter";
+import { listTransactions } from "../transactions";
+
+const auth: ApiAuth = {
+ clientId: "id",
+ clientSecret: "secret",
+ mode: "sandbox",
+};
+
+// Mock at the api-client boundary so no real API is hit.
+vi.mock("../../api-client", () => ({
+ apiGet: vi.fn(),
+}));
+import { apiGet } from "../../api-client";
+const mockGet = vi.mocked(apiGet);
+
+beforeEach(() => {
+ mockGet.mockReset();
+});
+
+/** The single path argument passed to `apiGet`, split into base + params. */
+function calledPath(): { path: string; params: URLSearchParams } {
+ const path = mockGet.mock.calls[0][1];
+ const query = path.split("?")[1] ?? "";
+ return { path, params: new URLSearchParams(query) };
+}
+
+describe("listTransactions query string", () => {
+ it("always sends customerId, limit (default 20), and sortOrder=desc", async () => {
+ const { reporter } = createCollectingReporter();
+ mockGet.mockResolvedValueOnce({ data: [], hasMore: false });
+
+ await listTransactions(reporter, auth, { customerId: "Customer:c1" });
+
+ const { path, params } = calledPath();
+ expect(path.startsWith("/transactions?")).toBe(true);
+ expect(params.get("customerId")).toBe("Customer:c1");
+ expect(params.get("limit")).toBe("20");
+ expect(params.get("sortOrder")).toBe("desc");
+ });
+
+ it("honors an explicit limit", async () => {
+ const { reporter } = createCollectingReporter();
+ mockGet.mockResolvedValueOnce({ data: [], hasMore: false });
+
+ await listTransactions(reporter, auth, {
+ customerId: "Customer:c1",
+ limit: 50,
+ });
+
+ expect(calledPath().params.get("limit")).toBe("50");
+ });
+
+ it("omits `type` for ALL and includes it otherwise", async () => {
+ const { reporter } = createCollectingReporter();
+
+ mockGet.mockResolvedValueOnce({ data: [], hasMore: false });
+ await listTransactions(reporter, auth, {
+ customerId: "Customer:c1",
+ type: "ALL",
+ });
+ expect(calledPath().params.has("type")).toBe(false);
+
+ mockGet.mockReset();
+ mockGet.mockResolvedValueOnce({ data: [], hasMore: false });
+ await listTransactions(reporter, auth, {
+ customerId: "Customer:c1",
+ type: "INCOMING",
+ });
+ expect(calledPath().params.get("type")).toBe("INCOMING");
+ });
+
+ it("includes `cursor` only when provided", async () => {
+ const { reporter } = createCollectingReporter();
+
+ mockGet.mockResolvedValueOnce({ data: [], hasMore: false });
+ await listTransactions(reporter, auth, {
+ customerId: "Customer:c1",
+ cursor: null,
+ });
+ expect(calledPath().params.has("cursor")).toBe(false);
+
+ mockGet.mockReset();
+ mockGet.mockResolvedValueOnce({ data: [], hasMore: false });
+ await listTransactions(reporter, auth, {
+ customerId: "Customer:c1",
+ cursor: "cursor-uuid",
+ });
+ expect(calledPath().params.get("cursor")).toBe("cursor-uuid");
+ });
+});
+
+describe("listTransactions response mapping", () => {
+ it("maps the camelCase envelope to a TransactionPage", async () => {
+ const { reporter } = createCollectingReporter();
+ const raw = {
+ data: [
+ {
+ id: "Transaction:1",
+ type: "OUTGOING",
+ status: "COMPLETED",
+ sentAmount: { amount: 1250, currency: { code: "USD", decimals: 2 } },
+ destination: { destinationType: "UMA_ADDRESS", umaAddress: "$bob@x" },
+ },
+ {
+ id: "Transaction:2",
+ type: "INCOMING",
+ status: "PENDING",
+ receivedAmount: {
+ amount: 3_000_000,
+ currency: { code: "USDB", decimals: 6 },
+ },
+ source: { sourceType: "ACCOUNT", accountId: "InternalAccount:9" },
+ },
+ ],
+ hasMore: true,
+ nextCursor: "next-uuid",
+ totalCount: 42,
+ };
+ mockGet.mockResolvedValueOnce(raw);
+
+ const page = await listTransactions(reporter, auth, {
+ customerId: "Customer:c1",
+ });
+
+ expect(page.data).toEqual(raw.data);
+ expect(page.hasMore).toBe(true);
+ expect(page.nextCursor).toBe("next-uuid");
+ expect(page.totalCount).toBe(42);
+ });
+
+ it("coerces missing data/hasMore/nextCursor/totalCount", async () => {
+ const { reporter } = createCollectingReporter();
+ mockGet.mockResolvedValueOnce({});
+
+ const page = await listTransactions(reporter, auth, {
+ customerId: "Customer:c1",
+ });
+
+ expect(page.data).toEqual([]);
+ expect(page.hasMore).toBe(false);
+ expect(page.nextCursor).toBeNull();
+ expect(page.totalCount).toBe(0);
+ });
+});
diff --git a/apps/examples/grid-global-accounts-example-app/src/flows/context.ts b/apps/examples/grid-global-accounts-example-app/src/flows/context.ts
new file mode 100644
index 000000000..dc76f6cf3
--- /dev/null
+++ b/apps/examples/grid-global-accounts-example-app/src/flows/context.ts
@@ -0,0 +1,47 @@
+// Cross-flow wallet context: account / credential / session ids shared between
+// the per-credential-type tabs and the money + manage flows.
+//
+// State + chip rendering live in `session.ts`; these are the require-or-throw
+// guards the flows use, plus thin re-exports of the setters so the chip stays
+// in sync.
+
+import {
+ getAccountId,
+ getCredentialId,
+ getSessionId,
+ setAccountId,
+ setCredentialId,
+ setSessionId,
+} from "../session";
+
+// Thin re-exports of the session setters so flows keep their familiar names.
+// Note: `setCtxAccount`/`setAccountId` is first-call-wins by design (see
+// session.ts) — the account id is established once and shared across tabs.
+export const setCtxAccount = setAccountId;
+export const setCtxCredential = setCredentialId;
+export const setCtxSession = setSessionId;
+
+export function requireAccountId(): string {
+ const id = getAccountId();
+ if (!id)
+ throw new Error(
+ "Internal Account ID is required — run Create Customer first.",
+ );
+ return id;
+}
+
+export function requireCredentialId(): string {
+ const id = getCredentialId();
+ if (!id)
+ throw new Error(
+ "Credential ID is required — run Create for this type first.",
+ );
+ return id;
+}
+
+export function requireSessionId(): string {
+ const id = getSessionId();
+ if (!id)
+ throw new Error("Session ID is required — run Verify for this type first.");
+ return id;
+}
diff --git a/apps/examples/grid-global-accounts-example-app/src/flows/customer.ts b/apps/examples/grid-global-accounts-example-app/src/flows/customer.ts
new file mode 100644
index 000000000..9050e1a22
--- /dev/null
+++ b/apps/examples/grid-global-accounts-example-app/src/flows/customer.ts
@@ -0,0 +1,438 @@
+// Shared setup: create customer, platform config (OTP + branding), balance.
+//
+// DOM-free operation functions: each takes the platform `auth`, the form values
+// it needs, and a `Reporter` to emit request/response log events through, then
+// returns its result. The React layer collects the inputs and renders.
+
+import { apiGet, apiPatch, apiPost, type ApiAuth } from "../api-client";
+import type { Reporter } from "../lib/reporter";
+import { setCtxAccount } from "./context";
+
+export interface CreateCustomerParams {
+ platformCustomerId?: string;
+ fullName?: string;
+ email?: string;
+}
+
+export interface CreateCustomerResult {
+ customer: unknown;
+ accounts: unknown;
+ customerId: string;
+ accountId: string | null;
+}
+
+// ----- Create customer -----
+
+export async function createCustomer(
+ reporter: Reporter,
+ auth: ApiAuth,
+ params: CreateCustomerParams,
+): Promise {
+ const platformCustomerId =
+ params.platformCustomerId?.trim() || `test-${Date.now()}`;
+ const fullName = params.fullName?.trim() || "Test User";
+ const email = params.email?.trim();
+ const body: Record = {
+ customerType: "BUSINESS",
+ platformCustomerId,
+ region: "US",
+ currencies: ["USDB"],
+ businessInfo: {
+ legalName: fullName,
+ taxId: "12-3456789",
+ incorporatedOn: "2020-01-01",
+ },
+ };
+ if (email) body.email = email;
+ reporter.log({ level: "request", label: "POST /customers", detail: body });
+ const { data: customer } = await apiPost(auth, "/customers", body);
+ reporter.log({
+ level: "response",
+ label: "Create Customer",
+ detail: customer,
+ });
+ const customerId = (customer as Record).id as string;
+
+ const accounts = (await apiGet(
+ auth,
+ `/customers/internal-accounts?customerId=${customerId}¤cy=USDB`,
+ )) as { data: Array<{ id: string }> };
+ reporter.log({
+ level: "response",
+ label: "Internal Accounts",
+ detail: accounts,
+ });
+
+ let accountId: string | null = null;
+ if (accounts.data && accounts.data.length > 0) {
+ accountId = accounts.data[0].id;
+ setCtxAccount(accountId);
+ }
+ return { customer, accounts, customerId, accountId };
+}
+
+// ----- All customer internal accounts (one fetch → every customer) -----
+
+/** A single internal account projected to the fields the platform table needs. */
+export interface ParsedInternalAccount {
+ /** LSID, e.g. `InternalAccount:`. */
+ id: string;
+ /** Owning customer's LSID. Empty string means platform-owned. */
+ customerId: string;
+ /** `INTERNAL_FIAT` / `INTERNAL_CRYPTO` / `EMBEDDED_WALLET`, when present. */
+ type: string;
+ /** `ACTIVE` / `PENDING` / `CLOSED` / `FROZEN`, when present. */
+ status: string;
+ /** Balance in minor units (per `currency.decimals`), per `CurrencyAmount.amount`. */
+ amount: number;
+ /** Currency metadata: `{ code, name, symbol, decimals }` (any may be absent). */
+ currency: Record;
+}
+
+/** One customer's wallet row, derived from grouping internal accounts by owner. */
+export interface CustomerWallet {
+ /** Owning customer's LSID. */
+ customerId: string;
+ /** The wallet account's LSID (act-as / fund destination). */
+ accountId: string;
+ /** Currency metadata for the wallet balance. */
+ currency: Record;
+ /** Wallet balance in minor units. */
+ amount: number;
+}
+
+export interface ListAllInternalAccountsResult {
+ accounts: ParsedInternalAccount[];
+ /** True if pagination was capped before the API ran out of pages. */
+ truncated: boolean;
+}
+
+// Stop after this many pages / accounts so a misbehaving or huge tenant can't
+// spin forever; the caller is told (via `truncated`) rather than silently cut.
+const MAX_PAGES = 10;
+const PAGE_LIMIT = 100;
+const MAX_ACCOUNTS = MAX_PAGES * PAGE_LIMIT;
+
+/** Project a single internal-account row to the fields the table groups on. */
+export function parseInternalAccount(
+ row: unknown,
+): ParsedInternalAccount | null {
+ if (!row || typeof row !== "object") return null;
+ const a = row as Record;
+ const id = typeof a.id === "string" ? a.id : "";
+ if (!id) return null;
+
+ const balance = a.balance as Record | undefined;
+ const amount =
+ balance && typeof balance.amount === "number" ? balance.amount : 0;
+ const currency =
+ balance && balance.currency && typeof balance.currency === "object"
+ ? (balance.currency as Record)
+ : {};
+ return {
+ id,
+ customerId: typeof a.customerId === "string" ? a.customerId : "",
+ type: typeof a.type === "string" ? a.type : "",
+ status: typeof a.status === "string" ? a.status : "",
+ amount,
+ currency,
+ };
+}
+
+/**
+ * Page `GET /customers/internal-accounts` (no `customerId` — the param is an
+ * optional filter, see `GridListCustomerInternalAccountsRequestArgs.customer_id`
+ * in `list_customer_internal_accounts.py`) to return EVERY customer account in
+ * one sweep. The handler reports `hasMore` / `nextCursor`; we follow the cursor
+ * until exhausted, capped at `MAX_ACCOUNTS` so we never loop unbounded — if the
+ * cap is hit we set `truncated` and log it rather than silently dropping pages.
+ */
+export async function listAllInternalAccounts(
+ reporter: Reporter,
+ auth: ApiAuth,
+): Promise {
+ const accounts: ParsedInternalAccount[] = [];
+ let cursor: string | null = null;
+ let truncated = false;
+
+ for (let page = 0; page < MAX_PAGES; page++) {
+ const query = cursor
+ ? `/customers/internal-accounts?limit=${PAGE_LIMIT}&cursor=${encodeURIComponent(
+ cursor,
+ )}`
+ : `/customers/internal-accounts?limit=${PAGE_LIMIT}`;
+ const raw = await apiGet(auth, query);
+ reporter.log({
+ level: "response",
+ label: "GET /customers/internal-accounts",
+ detail: raw,
+ });
+
+ const env = (raw && typeof raw === "object" ? raw : {}) as Record<
+ string,
+ unknown
+ >;
+ const rows = Array.isArray(raw)
+ ? raw
+ : Array.isArray(env.data)
+ ? env.data
+ : [];
+ for (const row of rows) {
+ const parsed = parseInternalAccount(row);
+ if (parsed) accounts.push(parsed);
+ }
+
+ const hasMore = env.hasMore === true;
+ cursor = typeof env.nextCursor === "string" ? env.nextCursor : null;
+ if (!hasMore || !cursor) break;
+ if (page === MAX_PAGES - 1) truncated = true;
+ }
+
+ if (truncated) {
+ reporter.log({
+ level: "response",
+ label: "Internal accounts truncated",
+ detail: { cappedAt: MAX_ACCOUNTS, returned: accounts.length },
+ });
+ }
+ return { accounts, truncated };
+}
+
+/** True for a customer's spendable wallet account (USDB or embedded-wallet). */
+function isCustomerWalletAccount(a: ParsedInternalAccount): boolean {
+ return (
+ a.type === "EMBEDDED_WALLET" ||
+ String((a.currency as { code?: unknown }).code ?? "").toUpperCase() ===
+ "USDB"
+ );
+}
+
+/**
+ * Group internal accounts into one wallet row per customer. Platform-owned
+ * accounts (empty `customerId`) are dropped, leaving only customer wallets. Of a
+ * customer's accounts we keep the spendable wallet — the `EMBEDDED_WALLET` /
+ * USDB account — preferring an explicit `EMBEDDED_WALLET` when several qualify.
+ * Customers with no wallet account yet are omitted (no row to show a balance on).
+ */
+export function groupCustomerWallets(
+ accounts: ParsedInternalAccount[],
+): CustomerWallet[] {
+ const byCustomer = new Map();
+ for (const a of accounts) {
+ if (!a.customerId) continue; // platform-owned
+ if (!isCustomerWalletAccount(a)) continue;
+ const existing = byCustomer.get(a.customerId);
+ // Prefer an explicit embedded wallet when a customer has several candidates.
+ if (
+ !existing ||
+ (existing.type !== "EMBEDDED_WALLET" && a.type === "EMBEDDED_WALLET")
+ ) {
+ byCustomer.set(a.customerId, a);
+ }
+ }
+ return [...byCustomer.values()].map((a) => ({
+ customerId: a.customerId,
+ accountId: a.id,
+ currency: a.currency,
+ amount: a.amount,
+ }));
+}
+
+// ----- Platform funding accounts -----
+
+/** A platform-owned internal account projected to the funding-picker fields. */
+export interface PlatformFundingAccount {
+ /** LSID, e.g. `InternalAccount:` — used as the funding `source`. */
+ id: string;
+ /** Balance in minor units (cents / satoshis), per `CurrencyAmount.amount`. */
+ amount: number;
+ /** Currency metadata: `{ code, name, symbol, decimals }` (any may be absent). */
+ currency: Record;
+}
+
+/**
+ * Project a single `GET /platform/internal-accounts` row (an `InternalAccount`,
+ * see `gen_internal_account_from_entity`) to the funding-picker shape: the LSID
+ * plus its `balance` (`{ amount, currency }`). Rows without an `id` are dropped.
+ */
+export function parsePlatformFundingAccount(
+ row: unknown,
+): PlatformFundingAccount | null {
+ if (!row || typeof row !== "object") return null;
+ const a = row as Record;
+ const id = typeof a.id === "string" ? a.id : "";
+ if (!id) return null;
+
+ const balance = a.balance as Record | undefined;
+ const amount =
+ balance && typeof balance.amount === "number" ? balance.amount : 0;
+ const currency =
+ balance && balance.currency && typeof balance.currency === "object"
+ ? (balance.currency as Record)
+ : {};
+ return { id, amount, currency };
+}
+
+/**
+ * List the platform's own (non-customer) internal accounts — the funding pool —
+ * via `GET /platform/internal-accounts`, which scopes to accounts owned by the
+ * authenticated platform itself (`is_customers=False` in
+ * `get_internal_accounts_query`). Unwraps the `{ data: [...] }` envelope
+ * (`PlatformInternalAccountListResponse`); tolerates a missing/empty payload by
+ * returning [] so the picker can render an empty state.
+ */
+export async function listPlatformFundingAccounts(
+ reporter: Reporter,
+ auth: ApiAuth,
+): Promise<{ accounts: PlatformFundingAccount[]; raw: unknown }> {
+ const raw = await apiGet(auth, "/platform/internal-accounts");
+ reporter.log({
+ level: "response",
+ label: "GET /platform/internal-accounts",
+ detail: raw,
+ });
+
+ let rows: unknown[] = [];
+ if (Array.isArray(raw)) {
+ rows = raw;
+ } else if (raw && typeof raw === "object") {
+ const data = (raw as Record).data;
+ if (Array.isArray(data)) rows = data;
+ }
+
+ const accounts = rows
+ .map(parsePlatformFundingAccount)
+ .filter((a): a is PlatformFundingAccount => a !== null);
+ return { accounts, raw };
+}
+
+// ----- Fetch balance -----
+
+/** A wallet balance row: account id, minor-unit amount, and currency block. */
+export interface BalanceRow {
+ id: unknown;
+ /** Currency metadata `{ code, name, symbol, decimals }` — drives formatting. */
+ currency: unknown;
+ /** Amount in minor units (per `currency.decimals`), as returned by the API. */
+ balance: number;
+}
+
+export interface FetchBalanceResult {
+ /** Projected rows the wallet UI renders. */
+ rows: BalanceRow[];
+ /** The unmodified API response, for the debug raw-payload expander. */
+ raw: unknown;
+}
+
+/**
+ * Map one `GET /customers/internal-accounts` row to a wallet balance row. The
+ * account's `balance` is a `CurrencyAmount` — `{ amount, currency }` where
+ * `amount` is minor units and `currency` is `{ code, name, symbol, decimals }`.
+ * So `currency` comes from `balance.currency` (NOT the top level) and `balance`
+ * is the minor-unit `balance.amount`. Tolerates the fallback where `balance` is
+ * already a bare number (then no currency block is present).
+ */
+export function mapBalanceRow(row: Record): BalanceRow {
+ const balance = row.balance;
+ if (typeof balance === "number") {
+ return { id: row.id, currency: undefined, balance };
+ }
+ if (balance && typeof balance === "object") {
+ const b = balance as Record;
+ return {
+ id: row.id,
+ currency: b.currency,
+ balance: typeof b.amount === "number" ? b.amount : 0,
+ };
+ }
+ return { id: row.id, currency: undefined, balance: 0 };
+}
+
+export async function fetchBalance(
+ reporter: Reporter,
+ auth: ApiAuth,
+ customerId: string,
+): Promise {
+ const id = customerId.trim();
+ if (!id) throw new Error("Customer ID is required.");
+ const data = (await apiGet(
+ auth,
+ `/customers/internal-accounts?customerId=${encodeURIComponent(id)}`,
+ )) as { data: Array> };
+ reporter.log({ level: "response", label: "Fetch Balance", detail: data });
+ const rows = data.data?.map(mapBalanceRow) ?? [];
+ return { rows, raw: data };
+}
+
+// ----- Platform config (OTP + branding) -----
+
+export interface PlatformConfigForm {
+ appName?: string;
+ otpLength?: number;
+ alphanumeric?: boolean;
+ expirationSeconds?: number;
+ sendFromEmailAddress?: string;
+ sendFromEmailSenderName?: string;
+ replyToEmailAddress?: string;
+ logoUrl?: string;
+}
+
+// GET the platform config and project its embedded-wallet block into the form
+// shape the React layer renders.
+export async function loadPlatformConfig(
+ reporter: Reporter,
+ auth: ApiAuth,
+): Promise {
+ const cfg = await apiGet(auth, "/config");
+ reporter.log({ level: "response", label: "GET /config", detail: cfg });
+ const ewc = (cfg as { embeddedWalletConfig?: Record })
+ ?.embeddedWalletConfig;
+ const form: PlatformConfigForm = {};
+ if (!ewc) return form;
+ if (typeof ewc.appName === "string") form.appName = ewc.appName;
+ if (typeof ewc.otpLength === "number") form.otpLength = ewc.otpLength;
+ if (typeof ewc.alphanumeric === "boolean")
+ form.alphanumeric = ewc.alphanumeric;
+ if (typeof ewc.expirationSeconds === "number")
+ form.expirationSeconds = ewc.expirationSeconds;
+ if (typeof ewc.sendFromEmailAddress === "string")
+ form.sendFromEmailAddress = ewc.sendFromEmailAddress;
+ if (typeof ewc.sendFromEmailSenderName === "string")
+ form.sendFromEmailSenderName = ewc.sendFromEmailSenderName;
+ if (typeof ewc.replyToEmailAddress === "string")
+ form.replyToEmailAddress = ewc.replyToEmailAddress;
+ if (typeof ewc.logoUrl === "string") form.logoUrl = ewc.logoUrl;
+ return form;
+}
+
+// PATCH the platform config with only the fields the caller actually set, so we
+// send a real partial (mirrors the original "only non-empty fields" behaviour).
+export async function savePlatformConfig(
+ reporter: Reporter,
+ auth: ApiAuth,
+ form: PlatformConfigForm,
+): Promise {
+ const ewc: Record = {};
+ if (form.appName?.trim()) ewc.appName = form.appName.trim();
+ if (typeof form.otpLength === "number" && !Number.isNaN(form.otpLength))
+ ewc.otpLength = form.otpLength;
+ if (typeof form.alphanumeric === "boolean")
+ ewc.alphanumeric = form.alphanumeric;
+ if (
+ typeof form.expirationSeconds === "number" &&
+ !Number.isNaN(form.expirationSeconds)
+ )
+ ewc.expirationSeconds = form.expirationSeconds;
+ if (form.sendFromEmailAddress?.trim())
+ ewc.sendFromEmailAddress = form.sendFromEmailAddress.trim();
+ if (form.sendFromEmailSenderName?.trim())
+ ewc.sendFromEmailSenderName = form.sendFromEmailSenderName.trim();
+ if (form.replyToEmailAddress?.trim())
+ ewc.replyToEmailAddress = form.replyToEmailAddress.trim();
+ if (form.logoUrl?.trim()) ewc.logoUrl = form.logoUrl.trim();
+ const body = { embeddedWalletConfig: ewc };
+ reporter.log({ level: "request", label: "PATCH /config", detail: body });
+ const { data } = await apiPatch(auth, "/config", body);
+ reporter.log({ level: "response", label: "PATCH /config", detail: data });
+ return data;
+}
diff --git a/apps/examples/grid-global-accounts-example-app/src/flows/email-otp.ts b/apps/examples/grid-global-accounts-example-app/src/flows/email-otp.ts
new file mode 100644
index 000000000..c6b7dec1e
--- /dev/null
+++ b/apps/examples/grid-global-accounts-example-app/src/flows/email-otp.ts
@@ -0,0 +1,223 @@
+// EMAIL_OTP lifecycle: create, secure-OTP challenge/verify, rechallenge, add.
+//
+// DOM-free operation functions. The secure-OTP code never leaves the client in
+// plaintext; the TEK private key stays client-side (no encryptedSessionSigningKey
+// is returned). Each function takes the platform `auth`, the values it needs,
+// and a `Reporter`, and returns its result.
+
+import { generateP256KeyPair } from "@turnkey/crypto";
+
+import { SANDBOX_SIG } from "../config";
+import { apiPost, type ApiAuth } from "../api-client";
+import type { Reporter } from "../lib/reporter";
+import { buildWalletSignature, sealOtpBundle } from "../turnkey";
+import { setSessionKeysFromTek } from "../session";
+import { setCtxCredential, setCtxSession } from "./context";
+
+// ----- Create credential -----
+
+export async function createEmailOtpCredential(
+ reporter: Reporter,
+ auth: ApiAuth,
+ accountId: string,
+): Promise {
+ const { data } = await apiPost(auth, "/auth/credentials", {
+ type: "EMAIL_OTP",
+ accountId,
+ });
+ reporter.log({ level: "response", label: "EMAIL_OTP Create", detail: data });
+ const d = data as Record;
+ if (d.id) setCtxCredential(d.id as string);
+ return data;
+}
+
+// ----- Secure OTP -----
+//
+// /challenge issues the INIT_OTP and returns the enclave's target bundle; verify
+// HPKE-seals the entered code under it, runs /verify first leg (202 +
+// payloadToSign), signs the token with the TEK, then runs /verify retry (200
+// session).
+
+// Request a challenge for `credId` and return the enclave target bundle.
+export async function requestV3Challenge(
+ reporter: Reporter,
+ auth: ApiAuth,
+ credId: string,
+): Promise {
+ const { data: challengeData } = await apiPost(
+ auth,
+ `/auth/credentials/${encodeURIComponent(credId)}/challenge`,
+ {},
+ );
+ reporter.log({
+ level: "response",
+ label: "V3 Challenge",
+ detail: challengeData,
+ });
+ const targetBundle = (challengeData as Record)
+ .otpEncryptionTargetBundle as string | undefined;
+ if (!targetBundle)
+ throw new Error(
+ "Challenge response missing otpEncryptionTargetBundle — is the local " +
+ "backend running the secure-OTP branch?",
+ );
+ return targetBundle;
+}
+
+export interface V3VerifyResult {
+ leg1: unknown;
+ session: unknown;
+}
+
+// Run the two verify legs against `targetBundle` with the entered `otp`, caching
+// the TEK as the session signing key on success.
+export async function runV3Verify(
+ reporter: Reporter,
+ auth: ApiAuth,
+ credId: string,
+ targetBundle: string,
+ otp: string,
+): Promise {
+ // Generate a TEK and HPKE-seal the entered OTP under the challenge bundle.
+ const tek = generateP256KeyPair();
+ const encryptedOtpBundle = sealOtpBundle(targetBundle, tek.publicKey, otp);
+
+ // First leg → expect 202 with payloadToSign (verificationToken) + requestId.
+ const leg1 = await apiPost(
+ auth,
+ `/auth/credentials/${encodeURIComponent(credId)}/verify`,
+ { type: "EMAIL_OTP", encryptedOtpBundle },
+ );
+ const l1 = (leg1.data ?? {}) as Record;
+ reporter.log({
+ level: "response",
+ label: "V3 Verify leg 1 (expect 202)",
+ detail: { status: leg1.status, ...l1 },
+ });
+ const payloadToSign = l1.payloadToSign as string | undefined;
+ const requestId = l1.requestId as string | undefined;
+ if (leg1.status !== 202 || !payloadToSign || !requestId)
+ throw new Error(`Unexpected first-leg response: ${JSON.stringify(leg1)}`);
+
+ // Sign the verificationToken with the TEK private key.
+ const signature = await buildWalletSignature(
+ tek.publicKey,
+ tek.privateKey,
+ payloadToSign,
+ );
+
+ // Retry with the signature → expect 200 AuthSession.
+ const leg2 = await apiPost(
+ auth,
+ `/auth/credentials/${encodeURIComponent(credId)}/verify`,
+ { type: "EMAIL_OTP", encryptedOtpBundle },
+ { "Grid-Wallet-Signature": signature, "Request-Id": requestId },
+ );
+ const session = (leg2.data ?? {}) as Record;
+ reporter.log({
+ level: "response",
+ label: "V3 Verify leg 2 (expect 200 session)",
+ detail: { status: leg2.status, ...session },
+ });
+ if (session.id) setCtxSession(session.id as string);
+ // The TEK is now the session's API key (OTP_LOGIN registered it). Cache it as
+ // the active session signing key so later signed retries (add passkey, quote
+ // execute, etc.) can stamp with this session via turnkeyStamp().
+ // MIGRATION (P6): this OTP-TEK caching is the model passkey/oauth login
+ // converge on once the login-family knob is ON — see oauth.ts/passkey.ts.
+ if (leg2.status === 200) setSessionKeysFromTek(tek);
+ return { leg1: leg1.data, session: leg2.data };
+}
+
+// Guided log in: /challenge → verify legs → cache TEK as session.
+export async function loginEmailOtp(
+ reporter: Reporter,
+ auth: ApiAuth,
+ credId: string,
+ otp: string,
+): Promise {
+ const targetBundle = await requestV3Challenge(reporter, auth, credId);
+ if (!otp.trim()) throw new Error("OTP code is required.");
+ return runV3Verify(reporter, auth, credId, targetBundle, otp.trim());
+}
+
+// ----- Sign-in entry point (create-vs-authenticate) -----
+//
+// The fix for EMAIL_OTP_CREDENTIAL_ALREADY_EXISTS: only create a credential when
+// the caller doesn't already have one. If `existingCredId` is provided, we
+// authenticate against it directly (challenge → verify) and never POST
+// /auth/credentials; otherwise we run the original create + verify ceremony.
+//
+// The create/login functions are injected so the decision is unit-testable at
+// the flow boundary without exercising real Turnkey.
+export interface EmailOtpSignInDeps {
+ create: typeof createEmailOtpCredential;
+ login: typeof loginEmailOtp;
+}
+
+const defaultEmailOtpSignInDeps: EmailOtpSignInDeps = {
+ create: createEmailOtpCredential,
+ login: loginEmailOtp,
+};
+
+export async function signInEmailOtp(
+ reporter: Reporter,
+ auth: ApiAuth,
+ accountId: string,
+ otp: string,
+ existingCredId: string | null,
+ deps: EmailOtpSignInDeps = defaultEmailOtpSignInDeps,
+): Promise {
+ let credId = existingCredId;
+ if (!credId) {
+ // No existing EMAIL_OTP credential — run the create leg first.
+ const cred = await deps.create(reporter, auth, accountId);
+ credId = (cred as { id?: string }).id ?? null;
+ if (!credId) throw new Error("Create credential returned no id.");
+ }
+ const result = await deps.login(reporter, auth, credId, otp);
+ return result.session;
+}
+
+// ----- Add an additional EMAIL_OTP credential (issue → signed retry) -----
+
+export async function addEmailOtpIssue(
+ reporter: Reporter,
+ auth: ApiAuth,
+ accountId: string,
+): Promise<{ data: unknown; requestId: string | undefined }> {
+ const { data } = await apiPost(auth, "/auth/credentials", {
+ type: "EMAIL_OTP",
+ accountId,
+ });
+ reporter.log({
+ level: "response",
+ label: "EMAIL_OTP Add (issue)",
+ detail: data,
+ });
+ const d = data as Record;
+ const requestId = typeof d.requestId === "string" ? d.requestId : undefined;
+ return { data, requestId };
+}
+
+export async function addEmailOtpRetry(
+ reporter: Reporter,
+ auth: ApiAuth,
+ accountId: string,
+ requestId: string,
+): Promise {
+ if (!requestId.trim())
+ throw new Error("Request-Id is required — run the issue step first.");
+ const { data } = await apiPost(
+ auth,
+ "/auth/credentials",
+ { type: "EMAIL_OTP", accountId },
+ { "Grid-Wallet-Signature": SANDBOX_SIG, "Request-Id": requestId.trim() },
+ );
+ reporter.log({
+ level: "response",
+ label: "EMAIL_OTP Add (retry)",
+ detail: data,
+ });
+ return data;
+}
diff --git a/apps/examples/grid-global-accounts-example-app/src/flows/login-decision.ts b/apps/examples/grid-global-accounts-example-app/src/flows/login-decision.ts
new file mode 100644
index 000000000..7aac67986
--- /dev/null
+++ b/apps/examples/grid-global-accounts-example-app/src/flows/login-decision.ts
@@ -0,0 +1,104 @@
+// Login decision logic: given a wallet's existing credentials, decide whether a
+// sign-in for a given method should AUTHENTICATE with an existing credential or
+// CREATE a new one first.
+//
+// This is the fix for the production EMAIL_OTP bug where Login unconditionally
+// ran the create-a-credential ceremony on every sign-in: once a credential of
+// that type exists, POST /auth/credentials 400s with
+// EMAIL_OTP_CREDENTIAL_ALREADY_EXISTS. The rule is simple — if a credential of
+// the chosen method already exists, skip create and authenticate with its id;
+// otherwise run the existing create+verify ceremony.
+//
+// Pure + DOM-free so it can be unit-tested at the flow boundary without touching
+// real Turnkey.
+
+/** The three sign-in methods the wallet supports. */
+export type Method = "email_otp" | "oauth" | "passkey";
+
+/** The credential `type` strings the Grid API returns for each method. */
+const TYPE_FOR_METHOD: Record = {
+ email_otp: "EMAIL_OTP",
+ oauth: "OAUTH",
+ passkey: "PASSKEY",
+};
+
+/** Reverse of `TYPE_FOR_METHOD`: the credential's API `type` → its `Method`. */
+const METHOD_FOR_TYPE: Record = {
+ EMAIL_OTP: "email_otp",
+ OAUTH: "oauth",
+ PASSKEY: "passkey",
+};
+
+/**
+ * The `Method` a credential authenticates with, derived from its API `type`, or
+ * undefined for an unrecognised type. The new login screen iterates the FULL
+ * credential list (a wallet can hold multiple passkeys / oauth identities), so
+ * each row maps its own credential to a method rather than collapsing the list
+ * to one-per-type.
+ */
+export function methodForCredential(
+ credential: ExistingCredential,
+): Method | undefined {
+ return credential.type ? METHOD_FOR_TYPE[credential.type] : undefined;
+}
+
+/** True when the wallet already has at least one EMAIL_OTP credential. A wallet
+ * may hold only one, so "Add Email OTP" is offered only when this is false. */
+export function hasEmailOtpCredential(
+ credentials: ExistingCredential[],
+): boolean {
+ return Boolean(existingCredentialFor(credentials, "email_otp"));
+}
+
+/** A credential as returned by GET /auth/credentials. */
+export interface ExistingCredential {
+ id: string;
+ type?: string;
+ nickname?: string;
+ status?: string;
+}
+
+/** The decision for a single method: authenticate with an existing credential
+ * (skip create), or create a new one first. */
+export type LoginDecision =
+ | { action: "authenticate"; credId: string }
+ | { action: "create" };
+
+/**
+ * Pull the credentials array out of a `listCredentials` response. The API wraps
+ * the list as `{ data: Credential[] }`; we tolerate a bare array or a missing
+ * payload too so callers don't have to special-case the empty/loading state.
+ */
+export function parseCredentials(raw: unknown): ExistingCredential[] {
+ if (Array.isArray(raw)) return raw as ExistingCredential[];
+ const data = (raw as { data?: unknown })?.data;
+ return Array.isArray(data) ? (data as ExistingCredential[]) : [];
+}
+
+/**
+ * The first existing credential for `method`, or undefined if the wallet has
+ * none of that type. Credentials without a usable `id` are ignored (they can't
+ * be authenticated against).
+ */
+export function existingCredentialFor(
+ credentials: ExistingCredential[],
+ method: Method,
+): ExistingCredential | undefined {
+ const wanted = TYPE_FOR_METHOD[method];
+ return credentials.find((c) => c.type === wanted && Boolean(c.id));
+}
+
+/**
+ * Decide whether signing in with `method` should authenticate against an
+ * existing credential or create one first. This is the single source of truth
+ * the Login UI and tests share.
+ */
+export function decideLogin(
+ credentials: ExistingCredential[],
+ method: Method,
+): LoginDecision {
+ const existing = existingCredentialFor(credentials, method);
+ return existing
+ ? { action: "authenticate", credId: existing.id }
+ : { action: "create" };
+}
diff --git a/apps/examples/grid-global-accounts-example-app/src/flows/manage.ts b/apps/examples/grid-global-accounts-example-app/src/flows/manage.ts
new file mode 100644
index 000000000..6f003a312
--- /dev/null
+++ b/apps/examples/grid-global-accounts-example-app/src/flows/manage.ts
@@ -0,0 +1,240 @@
+// Manage flows: delete credential / session / export (guided issue → sign →
+// retry), plus list credentials / sessions.
+//
+// DOM-free operation functions. The endpoints are identical across credential
+// types, so these take the ids + platform `auth` directly. Each guided action
+// owns the whole issue → sign → retry chain in one call, pulling the signature
+// from the live session in production and from `SANDBOX_SIG` in sandbox; the
+// separate issue/retry functions remain for inspecting the 202 between legs.
+
+import {
+ decryptExportBundle,
+ generateP256KeyPair,
+ hpkeDecrypt,
+} from "@turnkey/crypto";
+
+import { SANDBOX_SIG } from "../config";
+import { apiDelete, apiGet, apiPost, type ApiAuth } from "../api-client";
+import type { Reporter } from "../lib/reporter";
+import { turnkeyStamp } from "../turnkey";
+
+// A 202-issuing leg: hit the issue endpoint and return its response data, from
+// which the guided runner pulls `requestId` + `payloadToSign`.
+type IssueLeg = () => Promise;
+// A signed-retry leg: re-hit the endpoint with the resolved signature headers.
+type RetryLeg = (
+ headers: Record,
+) => Promise<{ status: number; data: unknown }>;
+
+// Resolve the Grid-Wallet-Signature for a guided signed retry: the sandbox
+// magic value, or a real session stamp over the 202's payloadToSign in
+// production. Throws a clear error if production lacks a payload/session.
+async function guidedSignature(
+ auth: ApiAuth,
+ payloadToSign: string | undefined,
+): Promise {
+ if (auth.mode !== "production") return SANDBOX_SIG;
+ if (!payloadToSign)
+ throw new Error(
+ "No payloadToSign in the 202 challenge — cannot stamp this retry.",
+ );
+ return turnkeyStamp(payloadToSign);
+}
+
+function payloadFrom(data: unknown): string | undefined {
+ const v = (data as Record)?.payloadToSign;
+ return typeof v === "string" ? v : undefined;
+}
+function requestIdFrom(data: unknown): string | undefined {
+ const v = (data as Record)?.requestId;
+ return typeof v === "string" ? v : undefined;
+}
+
+export interface GuidedRetryResult {
+ issued: unknown;
+ retried: unknown;
+}
+
+// Run a guided issue → sign → retry chain: issue the 202, derive the signature
+// (session stamp in production, magic value in sandbox), then forward the
+// signed retry.
+async function runGuidedRetry(
+ reporter: Reporter,
+ auth: ApiAuth,
+ label: string,
+ issue: IssueLeg,
+ retry: RetryLeg,
+): Promise {
+ const issued = await issue();
+ reporter.log({
+ level: "response",
+ label: `${label} (issue)`,
+ detail: issued,
+ });
+ const requestId = requestIdFrom(issued);
+ if (!requestId)
+ throw new Error(`No requestId in the ${label} 202 challenge.`);
+ const signature = await guidedSignature(auth, payloadFrom(issued));
+ const { data } = await retry({
+ "Grid-Wallet-Signature": signature,
+ "Request-Id": requestId,
+ });
+ reporter.log({ level: "response", label: `${label} (retry)`, detail: data });
+ return { issued, retried: data };
+}
+
+// ----- Delete credential -----
+
+export function deleteCredential(
+ reporter: Reporter,
+ auth: ApiAuth,
+ credId: string,
+): Promise {
+ const path = `/auth/credentials/${encodeURIComponent(credId)}`;
+ return runGuidedRetry(
+ reporter,
+ auth,
+ "Delete Credential",
+ () => apiDelete(auth, path).then((r) => r.data),
+ (headers) => apiDelete(auth, path, headers),
+ );
+}
+
+// ----- Delete session -----
+
+export function deleteSession(
+ reporter: Reporter,
+ auth: ApiAuth,
+ sessionId: string,
+): Promise {
+ const path = `/auth/sessions/${encodeURIComponent(sessionId)}`;
+ return runGuidedRetry(
+ reporter,
+ auth,
+ "Delete Session",
+ () => apiDelete(auth, path).then((r) => r.data),
+ (headers) => apiDelete(auth, path, headers),
+ );
+}
+
+// ----- Wallet export -----
+
+// The export bundle is a signed enclave envelope; its `data` field hex-decodes
+// to JSON carrying the HPKE encapsulated key, ciphertext, and the wallet's
+// Turnkey sub-org id — so the org id `decryptExportBundle` checks comes from the
+// bundle itself, not from session state.
+interface ExportBundleData {
+ encappedPublic: string;
+ ciphertext: string;
+ organizationId: string;
+}
+
+function hexToBytes(hex: string): Uint8Array {
+ const bytes = new Uint8Array(hex.length / 2);
+ for (let i = 0; i < bytes.length; i++) {
+ bytes[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);
+ }
+ return bytes;
+}
+
+function parseBundleData(exportBundle: string): ExportBundleData {
+ const { data } = JSON.parse(exportBundle) as { data: string };
+ return JSON.parse(
+ new TextDecoder().decode(hexToBytes(data)),
+ ) as ExportBundleData;
+}
+
+// Pull the recovered mnemonic out of a sealed export bundle. In production the
+// bundle is signed by the enclave, so `decryptExportBundle` verifies that
+// signature before HPKE-decrypting. The sandbox backend returns an unsigned
+// bundle (empty `dataSignature`/`enclaveQuorumPublic`), which that verification
+// can't pass — so there we HPKE-decrypt the bundle directly, the same crypto
+// minus the attestation check.
+async function recoverMnemonic(
+ auth: ApiAuth,
+ exportBundle: string,
+ privateKey: string,
+): Promise {
+ if (auth.mode === "production") {
+ const { organizationId } = parseBundleData(exportBundle);
+ return decryptExportBundle({
+ exportBundle,
+ embeddedKey: privateKey,
+ organizationId,
+ returnMnemonic: true,
+ });
+ }
+ const { encappedPublic, ciphertext } = parseBundleData(exportBundle);
+ const decrypted = hpkeDecrypt({
+ ciphertextBuf: hexToBytes(ciphertext),
+ encappedKeyBuf: hexToBytes(encappedPublic),
+ receiverPriv: privateKey,
+ });
+ return new TextDecoder().decode(decrypted);
+}
+
+function exportBundleFrom(retried: unknown): string {
+ const v = (retried as Record)?.encryptedWalletCredentials;
+ if (typeof v !== "string" || !v)
+ throw new Error("Export response missing encryptedWalletCredentials.");
+ return v;
+}
+
+export interface ExportWalletResult extends GuidedRetryResult {
+ mnemonic: string;
+}
+
+// Run the guided export, then decrypt the sealed bundle with the matching
+// private key (kept client-side, never sent) to recover the wallet mnemonic.
+export async function exportWallet(
+ reporter: Reporter,
+ auth: ApiAuth,
+ accountId: string,
+): Promise {
+ const path = `/internal-accounts/${encodeURIComponent(accountId)}/export`;
+ // The enclave encrypts the exported mnemonic to this client key; the matching
+ // private key stays here and decrypts the returned bundle.
+ const keyPair = generateP256KeyPair();
+ const body = { clientPublicKey: keyPair.publicKeyUncompressed };
+ const result = await runGuidedRetry(
+ reporter,
+ auth,
+ "Wallet Export",
+ () => apiPost(auth, path, body).then((r) => r.data),
+ (headers) => apiPost(auth, path, body, headers),
+ );
+ const mnemonic = await recoverMnemonic(
+ auth,
+ exportBundleFrom(result.retried),
+ keyPair.privateKey,
+ );
+ return { ...result, mnemonic };
+}
+
+// ----- List -----
+
+export async function listCredentials(
+ reporter: Reporter,
+ auth: ApiAuth,
+ accountId: string,
+): Promise {
+ const data = await apiGet(
+ auth,
+ `/auth/credentials?accountId=${encodeURIComponent(accountId)}`,
+ );
+ reporter.log({ level: "response", label: "List Credentials", detail: data });
+ return data;
+}
+
+export async function listSessions(
+ reporter: Reporter,
+ auth: ApiAuth,
+ accountId: string,
+): Promise {
+ const data = await apiGet(
+ auth,
+ `/auth/sessions?accountId=${encodeURIComponent(accountId)}`,
+ );
+ reporter.log({ level: "response", label: "List Sessions", detail: data });
+ return data;
+}
diff --git a/apps/examples/grid-global-accounts-example-app/src/flows/money.ts b/apps/examples/grid-global-accounts-example-app/src/flows/money.ts
new file mode 100644
index 000000000..2c8441669
--- /dev/null
+++ b/apps/examples/grid-global-accounts-example-app/src/flows/money.ts
@@ -0,0 +1,498 @@
+// Money movement: external account, quote, sign payload, execute.
+//
+// DOM-free operation functions. The React layer collects the form values
+// (account type + fields, amounts) and renders results; this module builds the
+// request bodies, talks to Grid + Turnkey, and emits log events through the
+// injected `Reporter`.
+
+import { SANDBOX_SIG, type Mode } from "../config";
+import { apiGet, apiPost, type ApiAuth } from "../api-client";
+import type { Reporter } from "../lib/reporter";
+import { turnkeyStamp } from "../turnkey";
+
+export interface BankExternalAccount {
+ kind: "bank";
+ accountNumber: string;
+ routingNumber: string;
+ beneficiaryName?: string;
+}
+
+export type ExternalAccountParams = BankExternalAccount;
+
+export interface CreateExternalAccountResult {
+ data: unknown;
+ externalAccountId: string | undefined;
+}
+
+export async function createExternalAccount(
+ reporter: Reporter,
+ auth: ApiAuth,
+ params: ExternalAccountParams,
+): Promise {
+ const accountNumber = params.accountNumber.trim();
+ const routingNumber = params.routingNumber.trim();
+ const fullName = params.beneficiaryName?.trim() || "Sandbox Test User";
+ if (!accountNumber || !routingNumber)
+ throw new Error("Account number and routing number are required.");
+ const body: Record = {
+ currency: "USD",
+ accountInfo: {
+ accountType: "USD_ACCOUNT",
+ countries: ["US"],
+ paymentRails: ["ACH", "WIRE", "RTP", "FEDNOW"],
+ accountNumber,
+ routingNumber,
+ beneficiary: {
+ beneficiaryType: "INDIVIDUAL",
+ fullName,
+ birthDate: "1990-01-15",
+ nationality: "US",
+ address: {
+ line1: "100 Test St",
+ city: "SF",
+ postalCode: "94102",
+ country: "US",
+ },
+ },
+ },
+ };
+ const { data } = await apiPost(auth, "/platform/external-accounts", body);
+ reporter.log({
+ level: "response",
+ label: "Create External Account",
+ detail: data,
+ });
+ const d = data as Record;
+ const externalAccountId = typeof d.id === "string" ? d.id : undefined;
+ return { data, externalAccountId };
+}
+
+// ----- Customer-owned external accounts (offramp destination) -----
+//
+// A customer offramp quote (embedded wallet → external account) requires the
+// destination to be owned by that customer, created via
+// `POST /customers/external-accounts` with a `customerId`. A platform-owned
+// external account (`POST /platform/external-accounts`) does not belong to the
+// customer and is rejected with `sparkcore_to_account_id does not belong to the
+// specified user`.
+
+export interface CreateCustomerExternalAccountParams {
+ /** The customer LSID the external account is created for. */
+ customerId: string;
+ accountNumber: string;
+ routingNumber: string;
+ beneficiaryName?: string;
+}
+
+/** Build the USD/ACH `accountInfo` body shared by platform + customer creates. */
+function usdBankAccountInfo(
+ accountNumber: string,
+ routingNumber: string,
+ fullName: string,
+): Record {
+ return {
+ accountType: "USD_ACCOUNT",
+ countries: ["US"],
+ paymentRails: ["ACH", "WIRE", "RTP", "FEDNOW"],
+ accountNumber,
+ routingNumber,
+ beneficiary: {
+ beneficiaryType: "INDIVIDUAL",
+ fullName,
+ birthDate: "1990-01-15",
+ nationality: "US",
+ address: {
+ line1: "100 Test St",
+ city: "SF",
+ postalCode: "94102",
+ country: "US",
+ },
+ },
+ };
+}
+
+/**
+ * Create a customer-owned USD bank external account
+ * (`POST /customers/external-accounts`). Sends `customerId` + `currency: "USD"`
+ * + the USD/ACH `accountInfo`, and returns the new external account id.
+ */
+export async function createCustomerExternalAccount(
+ reporter: Reporter,
+ auth: ApiAuth,
+ params: CreateCustomerExternalAccountParams,
+): Promise {
+ const customerId = params.customerId.trim();
+ const accountNumber = params.accountNumber.trim();
+ const routingNumber = params.routingNumber.trim();
+ const fullName = params.beneficiaryName?.trim() || "Sandbox Test User";
+ if (!customerId) throw new Error("A customer is required.");
+ if (!accountNumber || !routingNumber)
+ throw new Error("Account number and routing number are required.");
+ const body: Record = {
+ customerId,
+ currency: "USD",
+ accountInfo: usdBankAccountInfo(accountNumber, routingNumber, fullName),
+ };
+ const { data } = await apiPost(auth, "/customers/external-accounts", body);
+ reporter.log({
+ level: "response",
+ label: "Create Customer External Account",
+ detail: data,
+ });
+ const id = (data as Record)?.id;
+ if (typeof id !== "string" || !id)
+ throw new Error("External account create returned no id.");
+ return id;
+}
+
+/** A customer external account, flattened to the bits the picker renders. */
+export interface CustomerExternalAccount {
+ id: string;
+ /** Human label, e.g. `USD •••6789`. */
+ label: string;
+}
+
+/**
+ * List a customer's external accounts
+ * (`GET /customers/external-accounts?customerId=...`), optionally filtered by
+ * currency. Returns each account's id and a human label (currency + last-4 of
+ * the bank account number when present).
+ */
+export async function listCustomerExternalAccounts(
+ reporter: Reporter,
+ auth: ApiAuth,
+ customerId: string,
+ currency?: string,
+): Promise {
+ const id = customerId.trim();
+ if (!id) throw new Error("A customer is required.");
+ const query = new URLSearchParams({ customerId: id });
+ if (currency) query.set("currency", currency);
+ const data = await apiGet(auth, `/customers/external-accounts?${query}`);
+ reporter.log({
+ level: "response",
+ label: "List Customer External Accounts",
+ detail: data,
+ });
+ const rows = ((data as Record | null)?.data ?? []) as Array<
+ Record
+ >;
+ const accounts: CustomerExternalAccount[] = [];
+ for (const row of rows) {
+ if (typeof row.id !== "string" || !row.id) continue;
+ accounts.push({ id: row.id, label: externalAccountLabel(row) });
+ }
+ return accounts;
+}
+
+/** Build a `USD •••6789`-style label from an external account response item. */
+function externalAccountLabel(row: Record): string {
+ const currency = typeof row.currency === "string" ? row.currency : "";
+ const info = row.accountInfo as Record | undefined;
+ const number =
+ info && typeof info.accountNumber === "string" ? info.accountNumber : "";
+ const last4 = number ? `•••${number.slice(-4)}` : "";
+ return [currency, last4].filter(Boolean).join(" ") || (row.id as string);
+}
+
+export interface CreateQuoteParams {
+ sourceAccountId: string;
+ destinationAccountId: string;
+ lockedCurrencySide: string;
+ lockedCurrencyAmount: number;
+ mode: Mode;
+}
+
+export interface CreateQuoteResult {
+ data: unknown;
+ quoteId: string | undefined;
+ /** payloadToSign from the EMBEDDED_WALLET payment instruction, if present. */
+ payloadToSign: string | undefined;
+ /** Pre-filled signature: the magic value in sandbox, blank in production. */
+ signature: string;
+}
+
+export async function createQuote(
+ reporter: Reporter,
+ auth: ApiAuth,
+ params: CreateQuoteParams,
+): Promise {
+ const destinationAccountId = params.destinationAccountId.trim();
+ if (!destinationAccountId || !params.lockedCurrencyAmount)
+ throw new Error("Destination external account and amount are required.");
+ const { data } = await apiPost(auth, "/quotes", {
+ source: { sourceType: "ACCOUNT", accountId: params.sourceAccountId },
+ destination: {
+ destinationType: "ACCOUNT",
+ accountId: destinationAccountId,
+ },
+ lockedCurrencySide: params.lockedCurrencySide,
+ lockedCurrencyAmount: params.lockedCurrencyAmount,
+ });
+ reporter.log({ level: "response", label: "Create Quote", detail: data });
+ const d = data as Record;
+ const quoteId = typeof d.id === "string" ? d.id : undefined;
+
+ // Extract `payloadToSign` from the EMBEDDED_WALLET payment instruction
+ // (find by accountType match).
+ let payloadToSign: string | undefined;
+ const instructions = (d.paymentInstructions ?? []) as Array<
+ Record
+ >;
+ for (const inst of instructions) {
+ const info = inst.accountOrWalletInfo as
+ | Record
+ | undefined;
+ if (info && info.accountType === "EMBEDDED_WALLET" && info.payloadToSign) {
+ payloadToSign = info.payloadToSign as string;
+ break;
+ }
+ }
+
+ // In sandbox mode, pre-fill the magic signature so the user can Execute
+ // immediately. In production, leave blank — `signPayload` decrypts the
+ // session bundle and stamps the payload.
+ const signature = params.mode === "sandbox" ? SANDBOX_SIG : "";
+ return { data, quoteId, payloadToSign, signature };
+}
+
+export interface SignPayloadResult {
+ signature: string;
+ message: string;
+}
+
+export async function signPayload(
+ mode: Mode,
+ payloadToSign: string,
+): Promise {
+ if (mode === "sandbox") {
+ return {
+ signature: SANDBOX_SIG,
+ message: "Mode: sandbox — filled magic signature.",
+ };
+ }
+ const payload = payloadToSign.trim();
+ if (!payload)
+ throw new Error(
+ "payloadToSign is empty — run Create Quote first or paste it manually.",
+ );
+ const stamp = await turnkeyStamp(payload);
+ return { signature: stamp, message: `Stamped (${stamp.length} chars).` };
+}
+
+export async function executeQuote(
+ reporter: Reporter,
+ auth: ApiAuth,
+ quoteId: string,
+ signature: string,
+): Promise {
+ const id = quoteId.trim();
+ const sig = signature.trim();
+ if (!id || !sig)
+ throw new Error("Quote ID and Grid-Wallet-Signature are required.");
+ const { data } = await apiPost(
+ auth,
+ `/quotes/${encodeURIComponent(id)}/execute`,
+ {},
+ { "Grid-Wallet-Signature": sig },
+ );
+ reporter.log({ level: "response", label: "Execute Quote", detail: data });
+ return data;
+}
+
+// ----- Platform-funded transfer (no wallet signature) -----
+//
+// Mirrors the proven platform→customer flow in
+// `sparkcore/sparkcore/grid/__itests__/test_token_fund_in_live.py`
+// (`_gen_create_and_execute_quote`, lines 476-507, and
+// `_gen_poll_transaction_status`, lines 373-393):
+// POST /quotes { source: ACCOUNT, destination: ACCOUNT, lockedCurrencySide:
+// "SENDING", lockedCurrencyAmount } → { id }
+// POST /quotes/{id}/execute {} (EMPTY body, NO Grid-Wallet-Signature — the
+// platform's Basic-auth token authorizes spending its own source account)
+// poll GET /transactions/{transactionId} until status ∈ {COMPLETED, FAILED}.
+// Unlike the customer-signed `executeQuote`, the platform funds its own customer
+// so there is no embedded-wallet payload to sign.
+
+/** Terminal + happy-path statuses a transaction can reach. */
+const TERMINAL_STATUSES = new Set(["COMPLETED", "FAILED"]);
+
+export interface FundCustomerParams {
+ /** The platform's funded source internal account LSID. */
+ fundingAccountId: string;
+ /** The customer's destination internal account LSID. */
+ destinationAccountId: string;
+ /** Amount to send, in minor units (cents / micro-units / sats per currency). */
+ amountMinor: number;
+}
+
+/**
+ * Coarse stages the fund flow passes through, surfaced to the UI for a staged
+ * progress indicator. `quoting` → `executing` → `processing` are the in-flight
+ * steps; `completed` / `failed` are terminal. PROCESSING is the only real
+ * backend signal, so the bar advances approximately between steps.
+ */
+export type FundStage =
+ | "quoting"
+ | "executing"
+ | "processing"
+ | "completed"
+ | "failed";
+
+export interface FundCustomerResult {
+ quoteId: string;
+ transactionId: string;
+ /** The terminal transaction `status` (COMPLETED / FAILED), or the last seen. */
+ status: string;
+ /** The full transaction payload from the final `GET /transactions/{id}`. */
+ transaction: unknown;
+}
+
+/**
+ * Inject the wait between polls so tests don't sleep on real timers. Production
+ * callers leave it defaulted to a real `setTimeout`-backed delay.
+ */
+export type Sleep = (ms: number) => Promise;
+const realSleep: Sleep = (ms) =>
+ new Promise((resolve) => setTimeout(resolve, ms));
+
+export interface PollTransactionOptions {
+ /** Total time budget before giving up and returning the last seen txn. */
+ timeoutMs?: number;
+ /** Delay between polls. */
+ intervalMs?: number;
+ /** Injected sleep (tests pass a no-op / fake-timer-driven one). */
+ sleep?: Sleep;
+}
+
+/**
+ * Poll `GET /transactions/{id}` until `status` is terminal (COMPLETED / FAILED)
+ * or the timeout elapses, then return the last-seen transaction. Mirrors
+ * `_gen_poll_transaction_status` in the reference itest.
+ */
+export async function pollTransaction(
+ reporter: Reporter,
+ auth: ApiAuth,
+ transactionId: string,
+ opts: PollTransactionOptions = {},
+): Promise<{ status: string; transaction: unknown }> {
+ const id = transactionId.trim();
+ if (!id) throw new Error("Transaction ID is required to poll.");
+ const timeoutMs = opts.timeoutMs ?? 30_000;
+ const intervalMs = opts.intervalMs ?? 1_000;
+ const sleep = opts.sleep ?? realSleep;
+
+ let elapsed = 0;
+ let txn: unknown = null;
+ let status = "";
+ // Poll at least once even if timeoutMs is 0.
+ do {
+ txn = await apiGet(auth, `/transactions/${encodeURIComponent(id)}`);
+ reporter.log({
+ level: "response",
+ label: "GET /transactions",
+ detail: txn,
+ });
+ const s = (txn as Record | null)?.status;
+ status = typeof s === "string" ? s : "";
+ if (TERMINAL_STATUSES.has(status)) return { status, transaction: txn };
+ if (elapsed + intervalMs >= timeoutMs) break;
+ await sleep(intervalMs);
+ elapsed += intervalMs;
+ } while (elapsed < timeoutMs);
+
+ return { status, transaction: txn };
+}
+
+/**
+ * Options for `fundCustomerFromPlatform`: poll tuning + a staged-progress hook.
+ */
+export interface FundCustomerOptions {
+ /** Poll tuning (timeout / interval / injected sleep). */
+ poll?: PollTransactionOptions;
+ /**
+ * Stage callback for a staged UI indicator. Invoked with `quoting` before the
+ * quote, `executing` before execute, `processing` before the poll, then the
+ * terminal `completed` / `failed` (or left at `processing` if the poll times
+ * out before a terminal status).
+ */
+ onStage?: (stage: FundStage) => void;
+}
+
+/**
+ * Fund a customer from the platform's own funded internal account:
+ * quote (RECEIVING-locked) → execute (empty body, platform Basic auth, no
+ * signature) → poll the transaction to a terminal status. Returns the quote id,
+ * transaction id, and final status. The caller refreshes the customer's balance
+ * and surfaces the status. DOM-free: takes a `Reporter`, `auth`, and params.
+ */
+export async function fundCustomerFromPlatform(
+ reporter: Reporter,
+ auth: ApiAuth,
+ params: FundCustomerParams,
+ opts: FundCustomerOptions = {},
+): Promise {
+ const onStage = opts.onStage ?? (() => {});
+ const fundingAccountId = params.fundingAccountId.trim();
+ const destinationAccountId = params.destinationAccountId.trim();
+ if (!fundingAccountId)
+ throw new Error("A platform funding account is required.");
+ if (!destinationAccountId)
+ throw new Error("The customer has no internal account to fund.");
+ if (!params.amountMinor || params.amountMinor <= 0)
+ throw new Error("Enter an amount to fund.");
+
+ // 1) Quote: platform source → customer destination. The amount is in the
+ // customer's (receiving) currency, so lock RECEIVING and let the quote derive
+ // the source amount.
+ onStage("quoting");
+ const quoteBody = {
+ source: { sourceType: "ACCOUNT", accountId: fundingAccountId },
+ destination: {
+ destinationType: "ACCOUNT",
+ accountId: destinationAccountId,
+ },
+ lockedCurrencySide: "RECEIVING",
+ lockedCurrencyAmount: params.amountMinor,
+ };
+ reporter.log({ level: "request", label: "POST /quotes", detail: quoteBody });
+ const { data: quoteData } = await apiPost(auth, "/quotes", quoteBody);
+ reporter.log({ level: "response", label: "Create Quote", detail: quoteData });
+ const quoteId = (quoteData as Record)?.id;
+ if (typeof quoteId !== "string" || !quoteId)
+ throw new Error("Quote creation returned no id.");
+
+ // 2) Execute: EMPTY body, NO Grid-Wallet-Signature. Platform Basic auth
+ // authorizes spending its own source account.
+ onStage("executing");
+ reporter.log({
+ level: "request",
+ label: "POST /quotes/{id}/execute",
+ detail: {},
+ });
+ const { data: execData } = await apiPost(
+ auth,
+ `/quotes/${encodeURIComponent(quoteId)}/execute`,
+ {},
+ );
+ reporter.log({ level: "response", label: "Execute Quote", detail: execData });
+ const transactionId = (execData as Record)?.transactionId;
+ if (typeof transactionId !== "string" || !transactionId)
+ throw new Error("Execute returned no transactionId.");
+
+ // 3) Poll the transaction to a terminal status.
+ onStage("processing");
+ const { status, transaction } = await pollTransaction(
+ reporter,
+ auth,
+ transactionId,
+ opts.poll,
+ );
+
+ // Terminal stage: COMPLETED / FAILED flip to that stage; a poll timeout leaves
+ // the indicator at `processing` (the balance may still settle).
+ if (status === "COMPLETED") onStage("completed");
+ else if (status === "FAILED") onStage("failed");
+
+ return { quoteId, transactionId, status, transaction };
+}
diff --git a/apps/examples/grid-global-accounts-example-app/src/flows/oauth.ts b/apps/examples/grid-global-accounts-example-app/src/flows/oauth.ts
new file mode 100644
index 000000000..8f85a0351
--- /dev/null
+++ b/apps/examples/grid-global-accounts-example-app/src/flows/oauth.ts
@@ -0,0 +1,144 @@
+// OAUTH lifecycle: guided login, create, verify (→ session), add.
+//
+// DOM-free operation functions: the React layer supplies the OIDC token + client
+// public key and renders the returned data; this module only talks to Grid and
+// emits log events through the injected `Reporter`.
+
+import { SANDBOX_SIG } from "../config";
+import { apiPost, type ApiAuth } from "../api-client";
+import type { Reporter } from "../lib/reporter";
+import { generateClientKeyPair } from "../turnkey";
+import { rememberEncryptedSessionSigningKey } from "../session";
+import { setCtxCredential, setCtxSession } from "./context";
+
+// Run /verify with the OIDC token + client public key, caching the session
+// bundle on success. Shared by the guided login and the manual verify path.
+export async function runOauthVerify(
+ reporter: Reporter,
+ auth: ApiAuth,
+ credId: string,
+ oidc: string,
+ pubkey: string,
+): Promise {
+ if (!oidc.trim()) throw new Error("OIDC token is required.");
+ if (!pubkey.trim()) throw new Error("Client public key is required.");
+ const { data } = await apiPost(
+ auth,
+ `/auth/credentials/${encodeURIComponent(credId)}/verify`,
+ { type: "OAUTH", oidcToken: oidc.trim(), clientPublicKey: pubkey.trim() },
+ );
+ reporter.log({ level: "response", label: "OAUTH Verify", detail: data });
+ const d = data as Record;
+ if (d.id) setCtxSession(d.id as string);
+ // MIGRATION (P6): OAUTH login moves to OAUTH_LOGIN; the knob-ON response
+ // drops `encryptedSessionSigningKey`, so this becomes the OTP-style
+ // `setSessionKeysFromTek(clientKeyPair)` path. The shape-detection in
+ // `rememberEncryptedSessionSigningKey` already no-ops when the field is
+ // absent — flip this one call once the P3 wire shape settles.
+ rememberEncryptedSessionSigningKey(d.encryptedSessionSigningKey);
+ return data;
+}
+
+export interface OauthLoginResult {
+ data: unknown;
+ /** The freshly generated client public key that was sent to /verify. */
+ clientPublicKey: string;
+}
+
+// Guided log in: gen client key → /verify with OIDC token + clientPublicKey →
+// remember the session bundle.
+export async function loginOauth(
+ reporter: Reporter,
+ auth: ApiAuth,
+ credId: string,
+ oidc: string,
+): Promise {
+ if (!oidc.trim()) throw new Error("OIDC token is required.");
+ const kp = generateClientKeyPair();
+ const data = await runOauthVerify(
+ reporter,
+ auth,
+ credId,
+ oidc,
+ kp.publicKeyUncompressed,
+ );
+ return { data, clientPublicKey: kp.publicKeyUncompressed };
+}
+
+// ----- Sign-in entry point (create-vs-authenticate) -----
+//
+// Mirror of EMAIL_OTP's signIn: only create an OAUTH credential when the wallet
+// doesn't already have one (a second create would 400 with
+// OAUTH_CREDENTIAL_ALREADY_EXISTS). When `existingCredId` is provided we verify
+// directly against it; otherwise we run the original create + verify ceremony.
+export async function signInOauth(
+ reporter: Reporter,
+ auth: ApiAuth,
+ accountId: string,
+ oidc: string,
+ existingCredId: string | null,
+): Promise {
+ let credId = existingCredId;
+ if (!credId) {
+ const cred = await createOauthCredential(reporter, auth, accountId, oidc);
+ credId = (cred as { id?: string }).id ?? null;
+ if (!credId) throw new Error("Create credential returned no id.");
+ }
+ const { data } = await loginOauth(reporter, auth, credId, oidc);
+ return data;
+}
+
+export async function createOauthCredential(
+ reporter: Reporter,
+ auth: ApiAuth,
+ accountId: string,
+ oidc: string,
+): Promise {
+ if (!oidc.trim()) throw new Error("OIDC token is required.");
+ const { data } = await apiPost(auth, "/auth/credentials", {
+ type: "OAUTH",
+ accountId,
+ oidcToken: oidc.trim(),
+ });
+ reporter.log({ level: "response", label: "OAUTH Create", detail: data });
+ const d = data as Record;
+ if (d.id) setCtxCredential(d.id as string);
+ return data;
+}
+
+export async function addOauthIssue(
+ reporter: Reporter,
+ auth: ApiAuth,
+ accountId: string,
+ oidc: string,
+): Promise<{ data: unknown; requestId: string | undefined }> {
+ if (!oidc.trim()) throw new Error("OIDC token is required.");
+ const { data } = await apiPost(auth, "/auth/credentials", {
+ type: "OAUTH",
+ accountId,
+ oidcToken: oidc.trim(),
+ });
+ reporter.log({ level: "response", label: "OAUTH Add (issue)", detail: data });
+ const d = data as Record;
+ const requestId = typeof d.requestId === "string" ? d.requestId : undefined;
+ return { data, requestId };
+}
+
+export async function addOauthRetry(
+ reporter: Reporter,
+ auth: ApiAuth,
+ accountId: string,
+ oidc: string,
+ requestId: string,
+): Promise {
+ if (!requestId.trim())
+ throw new Error("Request-Id is required — run the issue step first.");
+ const { data } = await apiPost(
+ auth,
+ "/auth/credentials",
+ { type: "OAUTH", accountId, oidcToken: oidc.trim() },
+ { "Grid-Wallet-Signature": SANDBOX_SIG, "Request-Id": requestId.trim() },
+ );
+ reporter.log({ level: "response", label: "OAUTH Add (retry)", detail: data });
+ return data;
+}
diff --git a/apps/examples/grid-global-accounts-example-app/src/flows/otp-step.ts b/apps/examples/grid-global-accounts-example-app/src/flows/otp-step.ts
new file mode 100644
index 000000000..d2825c862
--- /dev/null
+++ b/apps/examples/grid-global-accounts-example-app/src/flows/otp-step.ts
@@ -0,0 +1,102 @@
+// Two-step EMAIL_OTP sign-in orchestration (pure + DOM-free).
+//
+// The production bug this fixes: the old login coupled the OTP *challenge* (which
+// SENDS the email) to *verify* in a single call, so every sign-in attempt — and
+// every retry — fired a fresh OTP, invalidating the prior code and tripping the
+// rate limit. Here the challenge and verify are two distinct, explicit steps:
+//
+// step "idle" → user must click Send to fire requestV3Challenge ONCE.
+// step "awaiting_code" → the challenge bundle is held; the user enters the code
+// and clicks Verify, which runs runV3Verify against the
+// *cached* bundle. Verify NEVER issues a challenge.
+// "Resend" is an explicit re-challenge from the awaiting_code step.
+//
+// The challenge is injected (`requestChallenge`) and the verify is injected
+// (`runVerify`) so the two-step guarantee is unit-testable at the flow boundary
+// without exercising real Turnkey: a test can assert the challenge dependency is
+// called exactly once per send and that verify is reachable only after a
+// challenge, carrying the bundle the challenge produced.
+
+import type { ApiAuth } from "../api-client";
+import type { Reporter } from "../lib/reporter";
+import {
+ requestV3Challenge,
+ runV3Verify,
+ type V3VerifyResult,
+} from "./email-otp";
+
+/** Where a single credential's OTP sign-in currently is. */
+export type OtpStep =
+ | { status: "idle" }
+ | { status: "challenging" }
+ | { status: "awaiting_code"; targetBundle: string }
+ | { status: "verifying"; targetBundle: string };
+
+/** The challenge leg: send the OTP for `credId`, returning the enclave bundle. */
+export type ChallengeFn = (
+ reporter: Reporter,
+ auth: ApiAuth,
+ credId: string,
+) => Promise;
+
+/** The verify leg: run the two verify legs against an already-issued bundle. */
+export type VerifyFn = (
+ reporter: Reporter,
+ auth: ApiAuth,
+ credId: string,
+ targetBundle: string,
+ otp: string,
+) => Promise;
+
+export interface OtpStepDeps {
+ requestChallenge: ChallengeFn;
+ runVerify: VerifyFn;
+}
+
+const defaultOtpStepDeps: OtpStepDeps = {
+ requestChallenge: requestV3Challenge,
+ runVerify: runV3Verify,
+};
+
+/**
+ * Fire the challenge for `credId` exactly once and return the bundle-bearing
+ * next step. This is the ONLY path that calls the challenge dependency, so the
+ * OTP email is sent only when a caller explicitly invokes this (Send / Resend) —
+ * never on render and never from `verify`.
+ */
+export async function sendOtpChallenge(
+ reporter: Reporter,
+ auth: ApiAuth,
+ credId: string,
+ deps: OtpStepDeps = defaultOtpStepDeps,
+): Promise> {
+ const targetBundle = await deps.requestChallenge(reporter, auth, credId);
+ return { status: "awaiting_code", targetBundle };
+}
+
+/**
+ * Verify the entered `otp` against the bundle the challenge already produced.
+ * Requires a `targetBundle` from a prior `sendOtpChallenge`; it deliberately
+ * does NOT call the challenge dependency, so a verify (or a failed verify retry)
+ * can never send a fresh OTP. Returns the auth session on success.
+ */
+export async function verifyOtpStep(
+ reporter: Reporter,
+ auth: ApiAuth,
+ credId: string,
+ targetBundle: string,
+ otp: string,
+ deps: OtpStepDeps = defaultOtpStepDeps,
+): Promise {
+ if (!targetBundle)
+ throw new Error("Send the code first — no challenge bundle to verify.");
+ if (!otp.trim()) throw new Error("Enter the one-time code.");
+ const { session } = await deps.runVerify(
+ reporter,
+ auth,
+ credId,
+ targetBundle,
+ otp.trim(),
+ );
+ return session;
+}
diff --git a/apps/examples/grid-global-accounts-example-app/src/flows/passkey.ts b/apps/examples/grid-global-accounts-example-app/src/flows/passkey.ts
new file mode 100644
index 000000000..fe3c1be9c
--- /dev/null
+++ b/apps/examples/grid-global-accounts-example-app/src/flows/passkey.ts
@@ -0,0 +1,370 @@
+// PASSKEY lifecycle: create (real registration), challenge, verify (assertion),
+// add (signed retry, session-stamped in production).
+//
+// DOM-free operation functions. Attestation / assertion material is passed in
+// as plain values (the React layer captures it from the real WebAuthn ceremony
+// in production, or the seeded magic values in sandbox); this module talks to
+// Grid + Turnkey and emits log events through the injected `Reporter`.
+
+import { SANDBOX_SIG, type Mode } from "../config";
+import { apiPost, type ApiAuth } from "../api-client";
+import type { Reporter } from "../lib/reporter";
+import { generateClientKeyPair, turnkeyStamp } from "../turnkey";
+import { rememberEncryptedSessionSigningKey } from "../session";
+import { rememberRawCredentialId } from "../passkey-store";
+import {
+ createRealPasskey,
+ signWithPasskey,
+ type RealAssertion,
+} from "../webauthn";
+import { setCtxCredential, setCtxSession } from "./context";
+
+export interface PasskeyAttestation {
+ challenge: string;
+ credentialId: string;
+ clientDataJson: string;
+ attestationObject: string;
+}
+
+export interface PasskeyAssertion {
+ credentialId: string;
+ clientDataJson: string;
+ authenticatorData: string;
+ signature: string;
+}
+
+// ----- Create / Add a credential (attestation) -----
+
+function buildCredentialBody(
+ accountId: string,
+ nickname: string,
+ attestation: PasskeyAttestation,
+): Record {
+ return {
+ type: "PASSKEY",
+ accountId,
+ nickname,
+ challenge: attestation.challenge,
+ attestation: {
+ credentialId: attestation.credentialId,
+ clientDataJson: attestation.clientDataJson,
+ attestationObject: attestation.attestationObject,
+ },
+ };
+}
+
+export async function createPasskeyCredential(
+ reporter: Reporter,
+ auth: ApiAuth,
+ accountId: string,
+ nickname: string,
+ attestation: PasskeyAttestation,
+): Promise {
+ const body = buildCredentialBody(accountId, nickname, attestation);
+ const { data } = await apiPost(auth, "/auth/credentials", body);
+ reporter.log({ level: "response", label: "PASSKEY Create", detail: data });
+ const d = data as Record;
+ if (d.id) {
+ setCtxCredential(d.id as string);
+ // Map the new Grid credential id → the raw WebAuthn credential id so a later
+ // sign-in can target this security key via allowCredentials.
+ rememberRawCredentialId(d.id as string, attestation.credentialId);
+ }
+ return data;
+}
+
+// Drive a real WebAuthn registration on a roaming security key (YubiKey) and
+// return the attestation the Create / Add flows send.
+export async function registerRealPasskey(
+ reporter: Reporter,
+ nickname: string,
+ rpId?: string,
+): Promise {
+ const att = await createRealPasskey(nickname, rpId);
+ reporter.log({
+ level: "info",
+ label: "Passkey Registered (real)",
+ detail: att,
+ });
+ return att;
+}
+
+// ----- Session challenge + verify -----
+
+export interface PasskeyChallengeResult {
+ data: unknown;
+ requestId: string | undefined;
+ challenge: string;
+}
+
+export async function requestPasskeyChallenge(
+ reporter: Reporter,
+ auth: ApiAuth,
+ credId: string,
+ clientPublicKey: string,
+): Promise {
+ if (!clientPublicKey.trim())
+ throw new Error("Client public key is required — generate one first.");
+ const { data } = await apiPost(
+ auth,
+ `/auth/credentials/${encodeURIComponent(credId)}/challenge`,
+ { clientPublicKey: clientPublicKey.trim() },
+ );
+ reporter.log({ level: "response", label: "PASSKEY Challenge", detail: data });
+ const d = data as Record;
+ const requestId = typeof d.requestId === "string" ? d.requestId : undefined;
+ const challenge = typeof d.challenge === "string" ? d.challenge : "";
+ return { data, requestId, challenge };
+}
+
+// Run /verify with the supplied assertion, caching the session bundle on
+// success. Shared by guided login + the manual verify path.
+export async function runPasskeyVerify(
+ reporter: Reporter,
+ auth: ApiAuth,
+ credId: string,
+ clientPublicKey: string,
+ assertion: PasskeyAssertion,
+ requestId: string | undefined,
+): Promise {
+ const body = {
+ type: "PASSKEY",
+ clientPublicKey: clientPublicKey.trim(),
+ assertion: {
+ credentialId: assertion.credentialId.trim(),
+ clientDataJson: assertion.clientDataJson.trim(),
+ authenticatorData: assertion.authenticatorData.trim(),
+ signature: assertion.signature.trim(),
+ },
+ };
+ const headers: Record = {};
+ if (requestId) headers["Request-Id"] = requestId;
+ const { data } = await apiPost(
+ auth,
+ `/auth/credentials/${encodeURIComponent(credId)}/verify`,
+ body,
+ headers,
+ );
+ reporter.log({ level: "response", label: "PASSKEY Verify", detail: data });
+ const d = data as Record;
+ if (d.id) setCtxSession(d.id as string);
+ // MIGRATION (P6): PASSKEY login moves to STAMP_LOGIN; the knob-ON response
+ // drops `encryptedSessionSigningKey`, so this becomes the OTP-style
+ // `setSessionKeysFromTek(clientKeyPair)` path. The shape-detection in
+ // `rememberEncryptedSessionSigningKey` already no-ops when the field is
+ // absent — flip this one call once the P2 wire shape settles.
+ rememberEncryptedSessionSigningKey(d.encryptedSessionSigningKey);
+ return data;
+}
+
+export interface PasskeyLoginParams {
+ credId: string;
+ mode: Mode;
+ /**
+ * Raw WebAuthn credential id(s) for the assertion's allowCredentials
+ * (production). Pass every registered passkey's raw id so the security key
+ * can satisfy the assertion; empty/omitted falls back to a discoverable
+ * credential on the key.
+ */
+ credentialIds?: string[];
+ rpId?: string;
+ /** Sandbox-seeded assertion fields, used when not running a real ceremony. */
+ sandboxAssertion?: PasskeyAssertion;
+}
+
+export interface PasskeyLoginResult {
+ data: unknown;
+ clientPublicKey: string;
+ assertion: PasskeyAssertion;
+}
+
+// Drive a real WebAuthn assertion against the issued challenge, targeting the
+// security key via the supplied raw credential id(s).
+export async function signRealPasskey(
+ reporter: Reporter,
+ challenge: string,
+ credentialIds: string[],
+ rpId?: string,
+): Promise {
+ const assertion = await signWithPasskey(challenge, credentialIds, rpId);
+ reporter.log({
+ level: "info",
+ label: "Passkey Signed (real)",
+ detail: assertion,
+ });
+ return assertion;
+}
+
+// Guided log in: gen client key → /challenge → assertion (a real security-key
+// ceremony in production, the seeded sandbox assertion otherwise) → /verify.
+export async function loginPasskey(
+ reporter: Reporter,
+ auth: ApiAuth,
+ params: PasskeyLoginParams,
+): Promise {
+ const kp = generateClientKeyPair();
+ const { requestId, challenge } = await requestPasskeyChallenge(
+ reporter,
+ auth,
+ params.credId,
+ kp.publicKeyUncompressed,
+ );
+
+ let assertion: PasskeyAssertion;
+ if (params.mode === "production") {
+ const real = await signRealPasskey(
+ reporter,
+ challenge,
+ params.credentialIds ?? [],
+ params.rpId,
+ );
+ assertion = {
+ credentialId: real.credentialId,
+ clientDataJson: real.clientDataJson,
+ authenticatorData: real.authenticatorData,
+ signature: real.signature,
+ };
+ } else {
+ if (!params.sandboxAssertion)
+ throw new Error("Sandbox assertion fields are required.");
+ assertion = params.sandboxAssertion;
+ }
+
+ const data = await runPasskeyVerify(
+ reporter,
+ auth,
+ params.credId,
+ kp.publicKeyUncompressed,
+ assertion,
+ requestId,
+ );
+ return { data, clientPublicKey: kp.publicKeyUncompressed, assertion };
+}
+
+// ----- Sign-in entry point (create-vs-authenticate) -----
+//
+// Mirror of EMAIL_OTP's signIn: only register (create) a PASSKEY credential when
+// the wallet doesn't already have one. When `existingCredId` is provided we run
+// the challenge → assertion → verify ceremony directly against it (no create);
+// otherwise we register a new passkey first, then verify it into a session.
+//
+// `register` produces the attestation for the create leg (a real Touch ID
+// ceremony in production, the seeded magic attestation in sandbox). It is only
+// invoked when there is no existing credential, so callers don't pay for a
+// registration prompt on an authenticate-with-existing sign-in.
+export interface PasskeySignInParams {
+ accountId: string;
+ nickname: string;
+ existingCredId: string | null;
+ /** Login params reused for the verify ceremony (mode, rpId, sandbox seed, and
+ * the raw `credentialIds` for the assertion). `credId` is filled in by
+ * signInPasskey once the credential to use is known. */
+ loginParams: Omit;
+ /** Produce the attestation for the create leg. Only called when registering a
+ * new credential (no existing one). */
+ register: () => Promise;
+}
+
+export async function signInPasskey(
+ reporter: Reporter,
+ auth: ApiAuth,
+ params: PasskeySignInParams,
+): Promise {
+ let credId = params.existingCredId;
+ // When we register a fresh passkey, its raw WebAuthn credential id is only
+ // known here — feed it into the assertion's allowCredentials so the verify
+ // leg targets the security key we just created on.
+ let freshRawId: string | undefined;
+ if (!credId) {
+ const attestation = await params.register();
+ freshRawId = attestation.credentialId;
+ const cred = await createPasskeyCredential(
+ reporter,
+ auth,
+ params.accountId,
+ params.nickname,
+ attestation,
+ );
+ credId = (cred as { id?: string }).id ?? null;
+ if (!credId) throw new Error("Create credential returned no id.");
+ }
+ const credentialIds = [
+ ...(params.loginParams.credentialIds ?? []),
+ ...(freshRawId ? [freshRawId] : []),
+ ];
+ const { data } = await loginPasskey(reporter, auth, {
+ ...params.loginParams,
+ credentialIds,
+ credId,
+ });
+ return data;
+}
+
+// ----- Add an additional passkey (issue → signed retry) -----
+
+export interface PasskeyAddIssueResult {
+ data: unknown;
+ requestId: string | undefined;
+ payloadToSign: string | undefined;
+}
+
+export async function addPasskeyIssue(
+ reporter: Reporter,
+ auth: ApiAuth,
+ accountId: string,
+ nickname: string,
+ attestation: PasskeyAttestation,
+): Promise {
+ const body = buildCredentialBody(accountId, nickname, attestation);
+ const { data } = await apiPost(auth, "/auth/credentials", body);
+ reporter.log({
+ level: "response",
+ label: "PASSKEY Add (issue)",
+ detail: data,
+ });
+ const d = data as Record;
+ const requestId = typeof d.requestId === "string" ? d.requestId : undefined;
+ const payloadToSign =
+ typeof d.payloadToSign === "string" ? d.payloadToSign : undefined;
+ return { data, requestId, payloadToSign };
+}
+
+export async function addPasskeyRetry(
+ reporter: Reporter,
+ auth: ApiAuth,
+ accountId: string,
+ nickname: string,
+ attestation: PasskeyAttestation,
+ requestId: string,
+ payloadToSign: string | undefined,
+): Promise {
+ if (!requestId.trim())
+ throw new Error("Request-Id is required — run the issue step first.");
+ // Sandbox accepts the magic value, but real Turnkey requires the
+ // CREATE_AUTHENTICATORS payload to be stamped by an authorized credential —
+ // the active session's signing key. Establish a session (e.g. OTP login or
+ // passkey verify) first so the session signing key is available.
+ let signature = SANDBOX_SIG;
+ if (auth.mode === "production") {
+ if (!payloadToSign)
+ throw new Error("Missing payloadToSign — run the issue step first.");
+ signature = await turnkeyStamp(payloadToSign);
+ }
+ const { data } = await apiPost(
+ auth,
+ "/auth/credentials",
+ buildCredentialBody(accountId, nickname, attestation),
+ { "Grid-Wallet-Signature": signature, "Request-Id": requestId.trim() },
+ );
+ reporter.log({
+ level: "response",
+ label: "PASSKEY Add (retry)",
+ detail: data,
+ });
+ // Map the added Grid credential id → its raw WebAuthn credential id so a later
+ // sign-in can target this additional security key via allowCredentials.
+ const addedId = (data as Record)?.id;
+ if (typeof addedId === "string" && addedId)
+ rememberRawCredentialId(addedId, attestation.credentialId);
+ return data;
+}
diff --git a/apps/examples/grid-global-accounts-example-app/src/flows/transactions.ts b/apps/examples/grid-global-accounts-example-app/src/flows/transactions.ts
new file mode 100644
index 000000000..fa2ba8aef
--- /dev/null
+++ b/apps/examples/grid-global-accounts-example-app/src/flows/transactions.ts
@@ -0,0 +1,99 @@
+// List a customer's real, server-persisted transactions (onramps / offramps /
+// payments) from the Grid API.
+//
+// DOM-free: takes the platform `auth`, the query params, and a `Reporter` to
+// emit a response log event through, then returns a normalized page. The React
+// layer owns paging state + rendering.
+
+import { apiGet, type ApiAuth } from "../api-client";
+import type { Reporter } from "../lib/reporter";
+
+/** A money amount: `amount` in minor units + a `currency` block. */
+export interface CurrencyAmount {
+ amount?: number;
+ /** `{ code, name, symbol, decimals }` — drives money formatting. */
+ currency?: unknown;
+}
+
+/**
+ * A permissive transaction shape covering the fields the UI renders. The Grid
+ * response is a OneOf of incoming/outgoing transactions; rather than model both
+ * exactly, we keep every field optional and read what we need defensively.
+ * Outgoing carries `sentAmount`, incoming carries `receivedAmount`.
+ */
+export interface Transaction {
+ id?: string;
+ /** "INCOMING" | "OUTGOING". */
+ type?: string;
+ status?: string;
+ /** Outgoing amount (sender's currency). */
+ sentAmount?: CurrencyAmount;
+ /** Incoming amount (recipient's currency). */
+ receivedAmount?: CurrencyAmount;
+ /** Generic fallback amount, if a row ever uses a plain `amount` block. */
+ amount?: CurrencyAmount;
+ /** OneOf: `{ sourceType, accountId | umaAddress, ... }`. */
+ source?: unknown;
+ /** OneOf: `{ destinationType, accountId | umaAddress, ... }`. */
+ destination?: unknown;
+ createdAt?: string;
+ description?: string;
+ [key: string]: unknown;
+}
+
+/** One normalized page of transactions. */
+export interface TransactionPage {
+ data: Transaction[];
+ hasMore: boolean;
+ nextCursor: string | null;
+ totalCount: number;
+}
+
+/** Direction filter; "ALL" omits the `type` query param entirely. */
+export type TransactionTypeFilter = "ALL" | "INCOMING" | "OUTGOING";
+
+const DEFAULT_LIMIT = 20;
+
+export interface ListTransactionsParams {
+ customerId: string;
+ limit?: number;
+ cursor?: string | null;
+ type?: TransactionTypeFilter;
+}
+
+/**
+ * GET `/transactions` scoped to a customer, newest first. Always sends
+ * `customerId`, `limit` (default 20), and `sortOrder=desc`; adds `cursor` when
+ * paging and `type` only when filtering to a single direction. Normalizes the
+ * `{ data, hasMore, nextCursor, totalCount }` envelope (camelCase, verified
+ * against the generated `TransactionListResponse`), coercing a missing `data`
+ * to `[]`, `hasMore` to false, and `nextCursor` to null.
+ */
+export async function listTransactions(
+ reporter: Reporter,
+ auth: ApiAuth,
+ params: ListTransactionsParams,
+): Promise {
+ const limit = params.limit ?? DEFAULT_LIMIT;
+ const query = new URLSearchParams({
+ customerId: params.customerId,
+ limit: String(limit),
+ sortOrder: "desc",
+ });
+ if (params.cursor) query.set("cursor", params.cursor);
+ if (params.type && params.type !== "ALL") query.set("type", params.type);
+
+ const detail = await apiGet(auth, `/transactions?${query.toString()}`);
+ reporter.log({ level: "response", label: "List Transactions", detail });
+
+ const env = (detail && typeof detail === "object" ? detail : {}) as Record<
+ string,
+ unknown
+ >;
+ return {
+ data: Array.isArray(env.data) ? (env.data as Transaction[]) : [],
+ hasMore: env.hasMore === true,
+ nextCursor: typeof env.nextCursor === "string" ? env.nextCursor : null,
+ totalCount: typeof env.totalCount === "number" ? env.totalCount : 0,
+ };
+}
diff --git a/apps/examples/grid-global-accounts-example-app/src/lib/__tests__/format-money.test.ts b/apps/examples/grid-global-accounts-example-app/src/lib/__tests__/format-money.test.ts
new file mode 100644
index 000000000..a5ae4ce8d
--- /dev/null
+++ b/apps/examples/grid-global-accounts-example-app/src/lib/__tests__/format-money.test.ts
@@ -0,0 +1,47 @@
+import { describe, expect, it } from "vitest";
+
+import { currencyCode, formatMoney } from "../format-money";
+
+describe("formatMoney", () => {
+ it("formats USD minor units (2 decimals) with the code", () => {
+ expect(formatMoney(123456, { code: "USD", decimals: 2 })).toBe(
+ "1,234.56 USD",
+ );
+ });
+
+ it("honors a non-cent decimals count (USDB at 6)", () => {
+ expect(formatMoney(5_000_000, { code: "USDB", decimals: 6 })).toBe(
+ "5.000000 USDB",
+ );
+ });
+
+ it("renders 3 USDB (3,000,000 minor, 6 decimals) as 3, not 30,000", () => {
+ const out = formatMoney(3_000_000, { code: "USDB", decimals: 6 });
+ expect(out).toBe("3.000000 USDB");
+ expect(out).not.toContain("30,000");
+ });
+
+ it("falls back to 2 decimals when the currency omits decimals", () => {
+ expect(formatMoney(100, { code: "USD" })).toBe("1.00 USD");
+ });
+
+ it("accepts a bare currency-code string", () => {
+ expect(formatMoney(100, "USD")).toBe("1.00 USD");
+ });
+
+ it("omits the code when none is present", () => {
+ expect(formatMoney(100, {})).toBe("1.00");
+ });
+});
+
+describe("currencyCode", () => {
+ it("reads code from a Currency object", () => {
+ expect(currencyCode({ code: "USDB", decimals: 6 })).toBe("USDB");
+ });
+
+ it("accepts a bare string and tolerates missing data", () => {
+ expect(currencyCode("BTC")).toBe("BTC");
+ expect(currencyCode({})).toBe("");
+ expect(currencyCode(null)).toBe("");
+ });
+});
diff --git a/apps/examples/grid-global-accounts-example-app/src/lib/__tests__/reporter.test.ts b/apps/examples/grid-global-accounts-example-app/src/lib/__tests__/reporter.test.ts
new file mode 100644
index 000000000..e8f3fdd59
--- /dev/null
+++ b/apps/examples/grid-global-accounts-example-app/src/lib/__tests__/reporter.test.ts
@@ -0,0 +1,79 @@
+import { describe, expect, it } from "vitest";
+
+import { createCollectingReporter } from "../collecting-reporter";
+import type { LogEntry } from "../reporter";
+
+describe("createCollectingReporter", () => {
+ it("records log entries in the order they were reported", () => {
+ const collector = createCollectingReporter();
+
+ collector.reporter.log({ level: "request", label: "POST /customers" });
+ collector.reporter.log({ level: "response", label: "201 Created" });
+ collector.reporter.log({ level: "info", label: "done" });
+
+ expect(collector.entries.map((e) => e.label)).toEqual([
+ "POST /customers",
+ "201 Created",
+ "done",
+ ]);
+ expect(collector.entries.map((e) => e.level)).toEqual([
+ "request",
+ "response",
+ "info",
+ ]);
+ });
+
+ it("assigns a unique id and a numeric timestamp to each entry", () => {
+ const collector = createCollectingReporter();
+ const before = Date.now();
+
+ collector.reporter.log({ level: "info", label: "a" });
+ collector.reporter.log({ level: "info", label: "b" });
+
+ const after = Date.now();
+ const [first, second] = collector.entries;
+
+ expect(typeof first.id).toBe("string");
+ expect(first.id).not.toEqual("");
+ expect(first.id).not.toEqual(second.id);
+
+ expect(typeof first.ts).toBe("number");
+ expect(first.ts).toBeGreaterThanOrEqual(before);
+ expect(first.ts).toBeLessThanOrEqual(after);
+ });
+
+ it("preserves the level, label, and raw detail payload", () => {
+ const collector = createCollectingReporter();
+ const detail = { id: "wallet-123", nested: { code: 202 } };
+
+ collector.reporter.log({ level: "response", label: "verify", detail });
+
+ const entry: LogEntry = collector.entries[0];
+ expect(entry.level).toBe("response");
+ expect(entry.label).toBe("verify");
+ expect(entry.detail).toEqual(detail);
+ });
+
+ it("surfaces the latest status message and kind", () => {
+ const collector = createCollectingReporter();
+
+ expect(collector.lastStatus).toBeNull();
+
+ collector.reporter.status("Creating customer...");
+ expect(collector.lastStatus).toEqual({
+ message: "Creating customer...",
+ kind: "info",
+ });
+
+ collector.reporter.status("Failed", "error");
+ expect(collector.lastStatus).toEqual({ message: "Failed", kind: "error" });
+ });
+
+ it("defaults the status kind to info when omitted", () => {
+ const collector = createCollectingReporter();
+
+ collector.reporter.status("hello");
+
+ expect(collector.lastStatus?.kind).toBe("info");
+ });
+});
diff --git a/apps/examples/grid-global-accounts-example-app/src/lib/collecting-reporter.ts b/apps/examples/grid-global-accounts-example-app/src/lib/collecting-reporter.ts
new file mode 100644
index 000000000..484fee6f8
--- /dev/null
+++ b/apps/examples/grid-global-accounts-example-app/src/lib/collecting-reporter.ts
@@ -0,0 +1,41 @@
+// A `Reporter` that simply collects what it's told, for tests and any non-React
+// consumer. The React store has its own state-backed reporter; this one keeps
+// the recorded entries + latest status in plain arrays/fields you can assert on.
+
+import type { LogEntry, Reporter } from "./reporter";
+
+export type StatusKind = "info" | "error" | "success";
+
+export interface ReportedStatus {
+ message: string;
+ kind: StatusKind;
+}
+
+export interface CollectingReporter {
+ reporter: Reporter;
+ entries: LogEntry[];
+ lastStatus: ReportedStatus | null;
+}
+
+let counter = 0;
+
+function nextId(): string {
+ counter += 1;
+ return `log-${Date.now().toString(36)}-${counter}`;
+}
+
+export function createCollectingReporter(): CollectingReporter {
+ const collector: CollectingReporter = {
+ entries: [],
+ lastStatus: null,
+ reporter: {
+ log(entry) {
+ collector.entries.push({ id: nextId(), ts: Date.now(), ...entry });
+ },
+ status(message, kind = "info") {
+ collector.lastStatus = { message, kind };
+ },
+ },
+ };
+ return collector;
+}
diff --git a/apps/examples/grid-global-accounts-example-app/src/lib/format-money.ts b/apps/examples/grid-global-accounts-example-app/src/lib/format-money.ts
new file mode 100644
index 000000000..af426a871
--- /dev/null
+++ b/apps/examples/grid-global-accounts-example-app/src/lib/format-money.ts
@@ -0,0 +1,49 @@
+// Money formatting shared by the platform customers table and the customer
+// wallet view, so a balance reads identically on both sides.
+//
+// Grid balances are a `CurrencyAmount`: `amount` in MINOR units plus a
+// `currency` block (`{ code, name, symbol, decimals }`, any field optional).
+// The number of minor-unit decimals comes from `currency.decimals` when the
+// API provides it (USD/USDB = 2, BTC = 8, …); we fall back to 2 only when it's
+// absent rather than assuming every currency is cents.
+
+const DEFAULT_DECIMALS = 2;
+
+/** Pull a three-letter (or ticker) code out of a Currency block. */
+export function currencyCode(currency: unknown): string {
+ if (currency && typeof currency === "object") {
+ const c = currency as Record;
+ if (typeof c.code === "string" && c.code) return c.code;
+ }
+ if (typeof currency === "string") return currency;
+ return "";
+}
+
+/**
+ * Minor-unit decimal count from a Currency block (USD/USDB = 2, BTC = 8, …),
+ * defaulting to 2 when absent. Shared so amount→minor conversions in the money
+ * flows match how balances are formatted.
+ */
+export function currencyDecimals(currency: unknown): number {
+ if (currency && typeof currency === "object") {
+ const c = currency as Record;
+ if (typeof c.decimals === "number" && c.decimals >= 0) return c.decimals;
+ }
+ return DEFAULT_DECIMALS;
+}
+
+/**
+ * Format a minor-unit amount + Currency block as a major-unit string with the
+ * code appended, e.g. `1,234.56 USD`. The fraction-digit count follows
+ * `currency.decimals` so non-cent currencies (e.g. BTC at 8) render correctly.
+ */
+export function formatMoney(amount: number, currency: unknown): string {
+ const code = currencyCode(currency);
+ const decimals = currencyDecimals(currency);
+ const major = amount / 10 ** decimals;
+ const formatted = major.toLocaleString(undefined, {
+ minimumFractionDigits: decimals,
+ maximumFractionDigits: decimals,
+ });
+ return code ? `${formatted} ${code}` : formatted;
+}
diff --git a/apps/examples/grid-global-accounts-example-app/src/lib/reporter.ts b/apps/examples/grid-global-accounts-example-app/src/lib/reporter.ts
new file mode 100644
index 000000000..132eb7d9c
--- /dev/null
+++ b/apps/examples/grid-global-accounts-example-app/src/lib/reporter.ts
@@ -0,0 +1,19 @@
+// The output sink the integration logic talks to instead of the DOM.
+//
+// Every lib/flow module that used to call `ui.addLog(...)` / `ui.showStatus(...)`
+// now takes a `Reporter` and emits structured `LogEntry`s + status messages
+// through it. The renderer (React store, a collecting test double, etc.) owns
+// what to do with them — keeping the integration logic DOM-free and reusable.
+
+export type LogEntry = {
+ id: string;
+ ts: number;
+ level: "info" | "error" | "request" | "response";
+ label: string;
+ detail?: unknown; // raw payload / IDs / JSON, shown only in debug mode
+};
+
+export interface Reporter {
+ log(entry: Omit): void;
+ status(message: string, kind?: "info" | "error" | "success"): void;
+}
diff --git a/apps/examples/grid-global-accounts-example-app/src/main.tsx b/apps/examples/grid-global-accounts-example-app/src/main.tsx
new file mode 100644
index 000000000..e0c91be04
--- /dev/null
+++ b/apps/examples/grid-global-accounts-example-app/src/main.tsx
@@ -0,0 +1,17 @@
+import { StrictMode } from "react";
+import { createRoot } from "react-dom/client";
+
+import "@lightsparkdev/origin/styles.css";
+// App-level dark-mode contrast overrides. MUST come after Origin's styles so
+// our token re-definitions win by source order. See theme-overrides.css.
+import "./theme-overrides.css";
+
+import { App } from "./App";
+
+const container = document.getElementById("root");
+if (!container) throw new Error("#root not found");
+createRoot(container).render(
+
+
+ ,
+);
diff --git a/apps/examples/grid-global-accounts-example-app/src/mode.ts b/apps/examples/grid-global-accounts-example-app/src/mode.ts
new file mode 100644
index 000000000..a18abce10
--- /dev/null
+++ b/apps/examples/grid-global-accounts-example-app/src/mode.ts
@@ -0,0 +1,40 @@
+// Sandbox / production mode: the chosen mode is persisted to localStorage and
+// drives magic-value seeding + field visibility.
+//
+// - production: every magic-value field is hidden (nothing fake on screen);
+// real-ceremony (Touch ID) buttons are shown. Values come from real
+// ceremonies or guided flows.
+// - sandbox: magic-value fields are shown, seeded from `SANDBOX_MAGIC`;
+// real-ceremony buttons are hidden.
+//
+// DOM-free: this module owns the *mode value* (persistence + magic-value
+// lookups) only. The React layer decides what to show/seed based on the mode it
+// reads here — no input elements are touched.
+
+import { MODE_STORAGE_KEY, SANDBOX_MAGIC, type Mode } from "./config";
+
+export function readPersistedMode(): Mode {
+ try {
+ return localStorage.getItem(MODE_STORAGE_KEY) === "production"
+ ? "production"
+ : "sandbox";
+ } catch {
+ return "sandbox";
+ }
+}
+
+export function persistMode(mode: Mode): void {
+ try {
+ localStorage.setItem(MODE_STORAGE_KEY, mode);
+ } catch {
+ // localStorage unavailable (private mode etc.) — non-fatal, mode just
+ // won't survive a reload.
+ }
+}
+
+// The sandbox magic value seeded into a given field, if any. In sandbox mode
+// the React layer pre-fills empty magic fields with this; in production these
+// fields are hidden so nothing fake is ever submitted.
+export function sandboxMagicFor(fieldId: string): string | undefined {
+ return SANDBOX_MAGIC[fieldId];
+}
diff --git a/apps/examples/grid-global-accounts-example-app/src/passkey-store.ts b/apps/examples/grid-global-accounts-example-app/src/passkey-store.ts
new file mode 100644
index 000000000..3465c6a3f
--- /dev/null
+++ b/apps/examples/grid-global-accounts-example-app/src/passkey-store.ts
@@ -0,0 +1,75 @@
+// Local map of Grid credential id → raw WebAuthn credential id (base64url).
+//
+// WebAuthn assertions target a specific security key via `allowCredentials`,
+// which needs the RAW credential id the authenticator returned at
+// registration. The Grid credential id (from POST /auth/credentials) is a
+// DIFFERENT, server-side id and can't be used there. The raw id is only
+// available at the moment of `navigator.credentials.create()`, so we persist it
+// here keyed by the Grid id when a passkey is registered, and look it up at
+// sign-in to drive the assertion against the YubiKey.
+//
+// LIMITATION: a passkey registered before this store existed (or on another
+// device / browser) has no entry — `getRawCredentialId` returns null and the
+// assertion falls back to an empty allowCredentials, letting the security key
+// present a discoverable credential instead.
+
+const STORAGE_KEY = "grid-example-passkey-raw-ids";
+
+type IdMap = Record;
+
+function read(): IdMap {
+ try {
+ const raw = localStorage.getItem(STORAGE_KEY);
+ if (!raw) return {};
+ const parsed = JSON.parse(raw) as unknown;
+ return parsed && typeof parsed === "object" ? (parsed as IdMap) : {};
+ } catch {
+ return {};
+ }
+}
+
+function write(map: IdMap): void {
+ try {
+ localStorage.setItem(STORAGE_KEY, JSON.stringify(map));
+ } catch {
+ // localStorage unavailable (private mode etc.) — non-fatal; the assertion
+ // just falls back to a discoverable credential.
+ }
+}
+
+/** Remember the raw WebAuthn credential id for a freshly-registered passkey. */
+export function rememberRawCredentialId(
+ gridCredentialId: string,
+ rawCredentialId: string,
+): void {
+ const grid = gridCredentialId?.trim();
+ const raw = rawCredentialId?.trim();
+ if (!grid || !raw) return;
+ const map = read();
+ map[grid] = raw;
+ write(map);
+}
+
+/** The raw WebAuthn credential id for a Grid credential id, or null if unknown. */
+export function getRawCredentialId(gridCredentialId: string): string | null {
+ const grid = gridCredentialId?.trim();
+ if (!grid) return null;
+ return read()[grid] ?? null;
+}
+
+/** Every raw WebAuthn credential id we've stored (for the given Grid ids, or
+ * all of them). Used to populate `allowCredentials` with all the wallet's
+ * passkeys so any registered security key can satisfy the assertion. */
+export function getRawCredentialIds(gridCredentialIds?: string[]): string[] {
+ const map = read();
+ const grids =
+ gridCredentialIds && gridCredentialIds.length > 0
+ ? gridCredentialIds
+ : Object.keys(map);
+ const out: string[] = [];
+ for (const grid of grids) {
+ const raw = map[grid?.trim()];
+ if (raw && !out.includes(raw)) out.push(raw);
+ }
+ return out;
+}
diff --git a/apps/examples/grid-global-accounts-example-app/src/session.ts b/apps/examples/grid-global-accounts-example-app/src/session.ts
new file mode 100644
index 000000000..0f6696049
--- /dev/null
+++ b/apps/examples/grid-global-accounts-example-app/src/session.ts
@@ -0,0 +1,243 @@
+// Session state — the ONE place that holds the client keypair / encrypted
+// session-signing-key bundle / TEK, plus the account / credential / session
+// ids. DOM-free: it holds this state in module variables and notifies
+// subscribers on change (the React layer reads it via the getters and renders
+// the session chip; gated flows re-evaluate their disabled state). The two
+// session models still funnel through here:
+// - "Verify-bundle": a client keypair + an `encryptedSessionSigningKey`
+// bundle Grid returns on passkey/oauth Verify, HPKE-decrypted on demand.
+// - "OTP-TEK": the TEK private key *is* the session key (OTP login, and —
+// post-migration — passkey/oauth too); cached directly, no bundle.
+//
+// Sessions are PER-CUSTOMER, keyed by the customer's wallet account id (the
+// natural session domain). A `Map` holds one context
+// per customer and `activeKey` points at the active customer's context, so
+// acting as customer A then B never lets B inherit A's keys. All exported
+// getters/setters operate on the *active* context; `setActiveSessionAccount`
+// switches which context is active (creating an empty one for a new key, or
+// `null` for logged-out / no active customer).
+
+import { decryptCredentialBundle, getPublicKey } from "@turnkey/crypto";
+
+export interface ClientKeyPair {
+ privateKey: string; // hex
+ publicKey: string; // hex, compressed
+ publicKeyUncompressed: string; // hex, 130 chars (0x04 prefix)
+}
+
+export interface SessionKeys {
+ apiPublicKey: string; // hex, compressed P-256
+ apiPrivateKey: string; // hex
+}
+
+// Which model established the current signing key, for the chip badge.
+export type SessionModel = "none" | "otp-tek" | "verify-bundle";
+
+// All the per-customer session material, grouped so each account key owns an
+// independent copy.
+interface SessionContext {
+ clientKeyPair: ClientKeyPair | null;
+ lastEncryptedSessionSigningKey: string | null;
+ cachedSessionKeys: SessionKeys | null;
+ model: SessionModel;
+ accountId: string;
+ credentialId: string;
+ sessionId: string;
+}
+
+function emptyContext(): SessionContext {
+ return {
+ clientKeyPair: null,
+ lastEncryptedSessionSigningKey: null,
+ cachedSessionKeys: null,
+ model: "none",
+ accountId: "",
+ credentialId: "",
+ sessionId: "",
+ };
+}
+
+const contexts = new Map();
+let activeKey: string | null = null;
+
+// The active context, or null when no customer is active (logged-out). Getters
+// fall back to an empty context's defaults; mutators no-op when null.
+function active(): SessionContext | null {
+ if (activeKey === null) return null;
+ return contexts.get(activeKey) ?? null;
+}
+
+// ----- Active-context switching -----
+//
+// Point the session at a customer's account. A new key gets a fresh empty
+// context (logged-out); `null` means no active customer (logged-out, no
+// context). Notifies subscribers since the visible session changes.
+export function setActiveSessionAccount(accountId: string | null): void {
+ if (accountId === null) {
+ activeKey = null;
+ notify();
+ return;
+ }
+ if (!contexts.has(accountId)) contexts.set(accountId, emptyContext());
+ activeKey = accountId;
+ notify();
+}
+
+// Wipe the active context's signing material (keys / model / ids) so
+// `hasSessionSigningKey()` is false and `getSessionModel()` is "none", while
+// keeping the account id so the slot still belongs to this customer. Used when
+// the session this client holds is revoked: the customer stays selected but is
+// logged out locally and can re-authenticate. No-op when logged out.
+export function clearActiveSession(): void {
+ const ctx = active();
+ if (!ctx) return;
+ ctx.clientKeyPair = null;
+ ctx.lastEncryptedSessionSigningKey = null;
+ ctx.cachedSessionKeys = null;
+ ctx.model = "none";
+ ctx.credentialId = "";
+ ctx.sessionId = "";
+ notify();
+}
+
+// ----- Client keypair (Verify-bundle model) -----
+
+export function setClientKeyPair(kp: ClientKeyPair): void {
+ const ctx = active();
+ if (!ctx) return;
+ ctx.clientKeyPair = kp;
+ // A fresh client key invalidates any session decrypted under the old one.
+ ctx.cachedSessionKeys = null;
+ ctx.lastEncryptedSessionSigningKey = null;
+ ctx.model = "none";
+ notify();
+}
+
+export function getClientKeyPair(): ClientKeyPair | null {
+ return active()?.clientKeyPair ?? null;
+}
+
+export function rememberEncryptedSessionSigningKey(value: unknown): void {
+ // MIGRATION (P6): once the login-family knob is ON, passkey/oauth Verify drop
+ // `encryptedSessionSigningKey` and behave like OTP (the client key is the
+ // session key). Shape-detection on field-presence already no-ops here when
+ // the field is absent, so both knob states work unchanged.
+ const ctx = active();
+ if (!ctx) return;
+ if (typeof value === "string" && value) {
+ ctx.lastEncryptedSessionSigningKey = value;
+ ctx.cachedSessionKeys = null;
+ ctx.model = "verify-bundle";
+ notify();
+ }
+}
+
+// ----- OTP-TEK model -----
+//
+// There is no encryptedSessionSigningKey bundle — the TEK private key *is* the
+// session's API key once login registers it. Cache it directly so
+// `turnkeyStamp` can authorize later signed retries without the Verify-style
+// clientKeyPair + bundle.
+export function setSessionKeysFromTek(tek: {
+ publicKey: string;
+ privateKey: string;
+}): void {
+ const ctx = active();
+ if (!ctx) return;
+ ctx.cachedSessionKeys = {
+ apiPublicKey: tek.publicKey,
+ apiPrivateKey: tek.privateKey,
+ };
+ ctx.model = "otp-tek";
+ notify();
+}
+
+// Resolve the session signing keys, decrypting the Verify bundle on demand.
+// Returns null (rather than throwing) when no session is established yet — the
+// caller decides how to surface that. Crypto callers use this; UI gates use
+// `hasSessionSigningKey()`.
+export function resolveSessionKeys(): SessionKeys | null {
+ const ctx = active();
+ if (!ctx) return null;
+ if (ctx.cachedSessionKeys) return ctx.cachedSessionKeys;
+ if (!ctx.clientKeyPair || !ctx.lastEncryptedSessionSigningKey) return null;
+ const apiPrivateKey = decryptCredentialBundle(
+ ctx.lastEncryptedSessionSigningKey,
+ ctx.clientKeyPair.privateKey,
+ );
+ const apiPublicKeyBytes = getPublicKey(apiPrivateKey, /*isCompressed*/ true);
+ const apiPublicKey = Array.from(apiPublicKeyBytes)
+ .map((b) => b.toString(16).padStart(2, "0"))
+ .join("");
+ ctx.cachedSessionKeys = { apiPublicKey, apiPrivateKey };
+ return ctx.cachedSessionKeys;
+}
+
+// True once a signing key is available *or* derivable (cached TEK, or a client
+// keypair + bundle). This is the gate the UI uses to enable/disable buttons.
+export function hasSessionSigningKey(): boolean {
+ const ctx = active();
+ if (!ctx) return false;
+ if (ctx.cachedSessionKeys) return true;
+ return Boolean(ctx.clientKeyPair && ctx.lastEncryptedSessionSigningKey);
+}
+
+export function getSessionModel(): SessionModel {
+ return active()?.model ?? "none";
+}
+
+// ----- Change subscribers -----
+//
+// Buttons / views that need a live session (e.g. "Add passkey" retry in
+// production) subscribe here so they can re-evaluate their disabled-with-tooltip
+// state whenever the session changes — surfacing the requirement instead of
+// throwing on click. Subscribers are notified on any active-context change AND
+// on an active-key switch.
+
+type SessionListener = () => void;
+const listeners: SessionListener[] = [];
+
+export function onSessionChange(listener: SessionListener): void {
+ listeners.push(listener);
+ listener(); // run once so the initial state is applied
+}
+
+function notify(): void {
+ for (const listener of listeners) listener();
+}
+
+// ----- Cross-flow ids (account / credential / session) -----
+//
+// Held per-context; the React layer reads them via the getters to render the
+// context chip and re-fires gates through `notify()` when they change.
+
+export function setAccountId(id: string): void {
+ const ctx = active();
+ if (!ctx) return;
+ // Match the original behaviour: only adopt the first account id seen, so a
+ // later flow doesn't clobber the customer's primary account.
+ if (!ctx.accountId) ctx.accountId = id;
+ notify();
+}
+export function setCredentialId(id: string): void {
+ const ctx = active();
+ if (!ctx) return;
+ ctx.credentialId = id;
+ notify();
+}
+export function setSessionId(id: string): void {
+ const ctx = active();
+ if (!ctx) return;
+ ctx.sessionId = id;
+ notify();
+}
+
+export function getAccountId(): string {
+ return (active()?.accountId ?? "").trim();
+}
+export function getCredentialId(): string {
+ return (active()?.credentialId ?? "").trim();
+}
+export function getSessionId(): string {
+ return (active()?.sessionId ?? "").trim();
+}
diff --git a/apps/examples/grid-global-accounts-example-app/src/state/store.tsx b/apps/examples/grid-global-accounts-example-app/src/state/store.tsx
new file mode 100644
index 000000000..95a10b0d6
--- /dev/null
+++ b/apps/examples/grid-global-accounts-example-app/src/state/store.tsx
@@ -0,0 +1,267 @@
+import {
+ createContext,
+ useCallback,
+ useContext,
+ useMemo,
+ useRef,
+ useState,
+ type ReactNode,
+} from "react";
+
+import type { ApiAuth } from "../api-client";
+import type { LogEntry, Reporter } from "../lib/reporter";
+import { clearActiveSession, setActiveSessionAccount } from "../session";
+
+/** Which side of the integration is currently on screen. */
+export type Persona = "platform" | "customer";
+
+/** A customer the platform has created and can "act as". */
+export interface ActiveCustomer {
+ id: string;
+ name: string;
+ /**
+ * Optional display email. Only set when a flow has it (e.g. create); the
+ * customers table identifies rows by `id`, not email, so act-as omits it.
+ */
+ email?: string;
+ /**
+ * The customer's spendable wallet internal account id. The customers table
+ * carries it straight from the grouped internal-accounts fetch so act-as can
+ * scope the Customer view without an extra request. May be null if no account
+ * was provisioned yet.
+ */
+ accountId?: string | null;
+ /** Coarse lifecycle state surfaced in the customers table. */
+ status?: string;
+ /** Wallet/account state surfaced in the customers table. */
+ walletState?: string;
+}
+
+export type StatusKind = "info" | "error" | "success";
+
+/** The latest status message surfaced by the reporter. */
+export interface StatusState {
+ message: string;
+ kind: StatusKind;
+}
+
+export interface AppState {
+ persona: Persona;
+ setPersona: (persona: Persona) => void;
+
+ /**
+ * The platform API credentials + mode entered in the Platform config panel.
+ * Null until the operator connects. The decoupled flows take an `ApiAuth`
+ * argument, so this is the gate that unlocks every platform operation.
+ */
+ platformAuth: ApiAuth | null;
+ setPlatformAuth: (auth: ApiAuth | null) => void;
+
+ /**
+ * The platform's funded source internal account LSID (`InternalAccount:`)
+ * the operator pastes in the config panel. Used as the `source` when funding a
+ * customer from the platform (quote → execute → poll). Not a secret, but lives
+ * alongside `platformAuth` as connect config. Empty until set.
+ */
+ platformFundingAccountId: string;
+ setPlatformFundingAccountId: (id: string) => void;
+
+ /**
+ * The platform's customers, derived once connected from a single grouped
+ * `GET /customers/internal-accounts` fetch (one row per customer). `setCustomers`
+ * replaces the list after a fetch; `addCustomer` optimistically prepends a
+ * just-created customer ahead of the refetch so it shows immediately. De-duped
+ * by id.
+ */
+ customers: ActiveCustomer[];
+ addCustomer: (customer: ActiveCustomer) => void;
+ setCustomers: (customers: ActiveCustomer[]) => void;
+
+ activeCustomer: ActiveCustomer | null;
+ setActiveCustomer: (customer: ActiveCustomer | null) => void;
+
+ /**
+ * Held session material for the *active customer* once logged in. Per-customer:
+ * cached by the customer's account id, so switching away and back restores the
+ * cached session without re-authenticating, while a fresh customer reads null
+ * (logged-out). Typed as `unknown` — the concrete session shape is owned by the
+ * reused `session`/`turnkey` logic, whose crypto context is kept in lockstep via
+ * `setActiveSessionAccount` on every customer switch.
+ */
+ session: unknown | null;
+ setSession: (session: unknown | null) => void;
+ /**
+ * Sign the active customer out locally: wipe their crypto signing context
+ * (`clearActiveSession`) and clear their session slot, so `CustomerView`
+ * falls back to `` and they can re-authenticate. No-op when no
+ * customer is active.
+ */
+ signOut: () => void;
+
+ debugOn: boolean;
+ toggleDebug: () => void;
+
+ /** Structured log the debug drawer renders (newest entries appended last). */
+ log: LogEntry[];
+ /** Latest status message, or null before anything has been reported. */
+ status: StatusState | null;
+ /** Clear the current app-wide status (e.g. when the user dismisses it). */
+ clearStatus: () => void;
+ /**
+ * The sink the reused integration logic (`lib/*`, `flows/*`) emits through.
+ * Appends to `log` and records the latest `status` as React state.
+ */
+ reporter: Reporter;
+}
+
+const AppStateContext = createContext(null);
+
+let logCounter = 0;
+function nextLogId(): string {
+ logCounter += 1;
+ return `log-${Date.now().toString(36)}-${logCounter}`;
+}
+
+export function AppStateProvider({ children }: { children: ReactNode }) {
+ const [persona, setPersona] = useState("platform");
+ const [platformAuth, setPlatformAuth] = useState(null);
+ const [platformFundingAccountId, setPlatformFundingAccountId] =
+ useState("");
+ const [customers, setCustomersState] = useState([]);
+ const [activeCustomer, setActiveCustomerState] =
+ useState(null);
+ // Per-customer session cache, keyed by wallet account id (the session domain).
+ // A customer with no accountId has no session slot. `setActiveCustomer` syncs
+ // the crypto context (`setActiveSessionAccount`) and surfaces this slot.
+ const [sessions, setSessions] = useState>({});
+ const activeKey = activeCustomer?.accountId ?? null;
+ const session = activeKey ? sessions[activeKey] ?? null : null;
+ const [debugOn, setDebugOn] = useState(false);
+ const [log, setLog] = useState([]);
+ const [status, setStatus] = useState(null);
+
+ // De-dupe by id and keep newest first so a re-created customer doesn't double
+ // up in the table. Stable identity so effects that depend on it don't re-fire.
+ const addCustomer = useCallback(
+ (customer: ActiveCustomer) =>
+ setCustomersState((prev) => [
+ customer,
+ ...prev.filter((c) => c.id !== customer.id),
+ ]),
+ [],
+ );
+
+ // De-dupe on replace too, so an optimistic add already present in the fetched
+ // page doesn't double up. Stable identity (only closes over a setter).
+ const setCustomers = useCallback((next: ActiveCustomer[]) => {
+ const seen = new Set();
+ setCustomersState(
+ next.filter((c) => (seen.has(c.id) ? false : (seen.add(c.id), true))),
+ );
+ }, []);
+
+ // Switching the active customer must move the crypto session context with it:
+ // point `session.ts` at this customer's account (or null → logged-out) so
+ // signing material follows the customer, never leaks across them. The cached
+ // session slot is then surfaced by the derived `session` above. Stable
+ // identity (only closes over a setter) — it's used in effects.
+ const setActiveCustomer = useCallback((customer: ActiveCustomer | null) => {
+ setActiveSessionAccount(customer?.accountId ?? null);
+ setActiveCustomerState(customer);
+ }, []);
+
+ // Write the active customer's session slot. Keyed by account id so each
+ // customer's session is independent and survives switching away and back.
+ // No active account id → nothing to key on, so this is a no-op.
+ const setSession = useCallback(
+ (next: unknown | null) => {
+ if (!activeKey) return;
+ setSessions((prev) => ({ ...prev, [activeKey]: next }));
+ },
+ [activeKey],
+ );
+
+ // Local sign-out: wipe the active customer's signing material AND clear their
+ // session slot so the view returns to Login. Used by the WalletHome "Sign out"
+ // button and by Settings when the session this client uses is revoked.
+ const signOut = useCallback(() => {
+ if (!activeKey) return;
+ clearActiveSession();
+ setSessions((prev) => ({ ...prev, [activeKey]: null }));
+ }, [activeKey]);
+
+ // The reporter identity is stable across renders so it can be threaded into
+ // long-lived flow closures without re-subscribing; the setters it closes over
+ // are stable too (React guarantees `setX` identity).
+ const reporterRef = useRef({
+ log(entry) {
+ setLog((prev) => [
+ ...prev,
+ { id: nextLogId(), ts: Date.now(), ...entry },
+ ]);
+ },
+ status(message, kind = "info") {
+ setStatus({ message, kind });
+ },
+ });
+
+ const toggleDebug = useCallback(() => setDebugOn((prev) => !prev), []);
+ const clearStatus = useCallback(() => setStatus(null), []);
+
+ const value = useMemo(
+ () => ({
+ persona,
+ setPersona,
+ platformAuth,
+ setPlatformAuth,
+ platformFundingAccountId,
+ setPlatformFundingAccountId,
+ customers,
+ addCustomer,
+ setCustomers,
+ activeCustomer,
+ setActiveCustomer,
+ session,
+ setSession,
+ signOut,
+ debugOn,
+ toggleDebug,
+ log,
+ status,
+ clearStatus,
+ reporter: reporterRef.current,
+ }),
+ [
+ persona,
+ platformAuth,
+ platformFundingAccountId,
+ customers,
+ addCustomer,
+ setCustomers,
+ activeCustomer,
+ setActiveCustomer,
+ session,
+ setSession,
+ signOut,
+ debugOn,
+ toggleDebug,
+ log,
+ status,
+ clearStatus,
+ ],
+ );
+
+ return (
+
+ {children}
+
+ );
+}
+
+export function useAppState(): AppState {
+ const ctx = useContext(AppStateContext);
+ if (!ctx) {
+ throw new Error("useAppState must be used within an ");
+ }
+ return ctx;
+}
diff --git a/apps/examples/grid-global-accounts-example-app/src/theme-overrides.css b/apps/examples/grid-global-accounts-example-app/src/theme-overrides.css
new file mode 100644
index 000000000..ed7544fb0
--- /dev/null
+++ b/apps/examples/grid-global-accounts-example-app/src/theme-overrides.css
@@ -0,0 +1,67 @@
+/*
+ * App-level dark-mode contrast overrides.
+ *
+ * Origin's dark-mode palette sets several "soft surface + soft text" color
+ * pairs to two near-identical dark tones, which fails WCAG AA (>=4.5:1) for
+ * normal-weight body text. Most visibly, the critical Alert renders dark-red
+ * text (--text-critical #ef0d0d) on a dark-red surface (--surface-red #cc0909)
+ * — a 1.31:1 ratio, i.e. effectively invisible.
+ *
+ * We fix this at the APP level only (never in @lightsparkdev/origin, which is
+ * shared by every other app) by re-defining the offending design tokens, and
+ * we do it ONLY in dark mode, mirroring the exact selectors Origin uses so we
+ * don't touch light mode at all:
+ * - [data-theme="dark"], .dark (explicit dark theme)
+ * - @media (prefers-color-scheme: dark) :root:not([data-theme="light"])
+ *
+ * This must be imported in src/main.tsx AFTER "@lightsparkdev/origin/styles.css"
+ * so these declarations win by source order at equal specificity.
+ *
+ * Overriding the tokens (rather than the hashed CSS-module classnames) keeps
+ * the fix robust against Origin's build-time class hashing and automatically
+ * covers every consumer of the token (critical Alert title/description, the
+ * red Badge, etc.).
+ */
+
+/* The token values applied in dark mode. Kept in one place and referenced from
+ * both selector blocks below so the two stay in sync. */
+
+/* Explicit dark theme (data-theme="dark" or a .dark container). */
+[data-theme="dark"],
+.dark {
+ /* Critical Alert: dark red-tinted surface + light red text.
+ * surface #4c0303 (red-950) vs text #ffc4c4 (red-200) = 10.55:1 (AA/AAA).
+ * Keeps the red-on-dark aesthetic; just makes the text legible. */
+ --surface-red: #4c0303;
+ --text-critical: #ffc4c4;
+ /* The critical icon shares the surface; lift it to match the text so the
+ * leading "!" icon is visible too (was #ef0d0d on #4c0303 = 2.6:1). */
+ --icon-critical: #ffc4c4;
+ /* Border on the same dark surface (was #ef0d0d, barely visible). */
+ --border-critical: #ff6161;
+
+ /* Badge "red" reads text-red (#ff9a9a) on --surface-red. With the darker
+ * surface above this is now #ff9a9a on #4c0303 = 7.82:1 (was 2.86:1). */
+
+ /* Badge "sky" (used live in the Login mode badge): text #00b3e0 on
+ * surface #067198 was 2.23:1. Darken the soft surface to restore contrast.
+ * surface #022f3f vs text #00b3e0 = 5.0:1. */
+ --surface-sky: #022f3f;
+
+ /* Badge "pink": text #ff99e5 on surface #cf00a7 was 2.6:1. Darken surface.
+ * surface #3d0031 vs text #ff99e5 = 7.4:1. */
+ --surface-pink: #3d0031;
+}
+
+/* System dark mode, when the app hasn't pinned an explicit theme. Mirrors the
+ * exact selector Origin uses for its system-dark token block. */
+@media (prefers-color-scheme: dark) {
+ :root:not([data-theme="light"]) {
+ --surface-red: #4c0303;
+ --text-critical: #ffc4c4;
+ --icon-critical: #ffc4c4;
+ --border-critical: #ff6161;
+ --surface-sky: #022f3f;
+ --surface-pink: #3d0031;
+ }
+}
diff --git a/apps/examples/grid-global-accounts-example-app/src/turnkey.ts b/apps/examples/grid-global-accounts-example-app/src/turnkey.ts
new file mode 100644
index 000000000..ef9cb0645
--- /dev/null
+++ b/apps/examples/grid-global-accounts-example-app/src/turnkey.ts
@@ -0,0 +1,125 @@
+// Turnkey crypto: P-256 keygen, HPKE seal, wallet signature, and the X-Stamp
+// builder. Session-key *state* lives in `session.ts`; this module only does the
+// crypto and reads/writes that state through it.
+
+import {
+ formatHpkeBuf,
+ generateP256KeyPair,
+ hpkeEncrypt,
+} from "@turnkey/crypto";
+import { signWithApiKey } from "@turnkey/api-key-stamper";
+
+import { TURNKEY_STAMP_SCHEME } from "./config";
+import {
+ type ClientKeyPair,
+ resolveSessionKeys,
+ setClientKeyPair,
+} from "./session";
+
+// Generate the client-side P-256 keypair (Verify-bundle model). The
+// uncompressed public key (130 hex chars, 0x04-prefixed) goes to Grid as
+// `clientPublicKey` on Verify; the private key stays client-side to
+// HPKE-decrypt the `encryptedSessionSigningKey` Grid hands back. Stored in
+// `session.ts` so a session decrypted under one keypair stays valid across tabs.
+export function generateClientKeyPair(): ClientKeyPair {
+ const kp = generateP256KeyPair();
+ const clientKeyPair: ClientKeyPair = {
+ privateKey: kp.privateKey,
+ publicKey: kp.publicKey,
+ publicKeyUncompressed: kp.publicKeyUncompressed,
+ };
+ setClientKeyPair(clientKeyPair);
+ return clientKeyPair;
+}
+
+export async function turnkeyStamp(payload: string): Promise {
+ const keys = resolveSessionKeys();
+ if (!keys)
+ throw new Error(
+ "No session signing key — log in (Verify) first to establish a session.",
+ );
+ const { apiPublicKey, apiPrivateKey } = keys;
+ // `signWithApiKey` returns the hex DER signature; the X-Stamp header
+ // value is base64url(JSON({publicKey, scheme, signature})) with that
+ // hex signature embedded as-is. Mirrors what `@turnkey/api-key-stamper`
+ // produces internally; replicated here so we can fill the field on the
+ // test UI rather than going through the stamper's `stamp(payload)` shape
+ // (which returns `{stampHeaderName, stampHeaderValue}`).
+ const signature = await signWithApiKey({
+ content: payload,
+ publicKey: apiPublicKey,
+ privateKey: apiPrivateKey,
+ });
+ const stamp = {
+ publicKey: apiPublicKey,
+ scheme: TURNKEY_STAMP_SCHEME,
+ signature,
+ };
+ const json = JSON.stringify(stamp);
+ // base64url(json) — no padding.
+ return btoa(json).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
+}
+
+// ----- V3 secure OTP client crypto -----
+//
+// HPKE-seal {clientPublicKey, otpCodeAttempt} under the enclave's
+// `otpEncryptionTargetBundle`. That bundle is a signed enclave envelope —
+// {version, data, dataSignature, enclaveQuorumPublic} — where `data` is a
+// hex-encoded JSON blob carrying the enclave's uncompressed HPKE target key as
+// `targetPublic`. We pull `targetPublic` out, HPKE-encrypt under it, and emit
+// Turnkey's `formatHpkeBuf` wire shape {"encappedPublic","ciphertext"} — exactly
+// what `@turnkey/crypto`'s `encryptPrivateKeyToBundle` produces for the
+// analogous key-import flow. (A production client would also verify
+// `dataSignature` against `enclaveQuorumPublic`; skipped here because the bundle
+// originates from our own backend in this test app.)
+function hexToBytes(hex: string): Uint8Array {
+ const bytes = new Uint8Array(hex.length / 2);
+ for (let i = 0; i < bytes.length; i++) {
+ bytes[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);
+ }
+ return bytes;
+}
+
+export function sealOtpBundle(
+ targetBundle: string,
+ clientPublicKeyHex: string,
+ otpCode: string,
+): string {
+ const parsed = JSON.parse(targetBundle) as { data: string };
+ const signedData = JSON.parse(
+ new TextDecoder().decode(hexToBytes(parsed.data)),
+ ) as { targetPublic: string };
+ const targetKeyBuf = hexToBytes(signedData.targetPublic); // 65-byte uncompressed
+ const plainTextBuf = new TextEncoder().encode(
+ // The enclave expects snake_case {otp_code, public_key} — NOT the
+ // {clientPublicKey, otpCodeAttempt} shown in Turnkey's docs sequence
+ // diagram. Matches @turnkey/crypto's encryptOtpCodeToBundle.
+ JSON.stringify({ otp_code: otpCode, public_key: clientPublicKeyHex }),
+ );
+ const encryptedBuf = hpkeEncrypt({ plainTextBuf, targetKeyBuf }); // compressed_enc[33] || ciphertext
+ return formatHpkeBuf(encryptedBuf); // {"encappedPublic","ciphertext"}
+}
+
+// Build the `Grid-Wallet-Signature` stamp over the verificationToken using a
+// specific keypair (the V3 TEK), not the session key — base64url(JSON({
+// publicKey, scheme, signature})), the shape `parse_api_key_stamp` expects.
+export async function buildWalletSignature(
+ publicKeyHex: string,
+ privateKeyHex: string,
+ payload: string,
+): Promise {
+ const signature = await signWithApiKey({
+ content: payload,
+ publicKey: publicKeyHex,
+ privateKey: privateKeyHex,
+ });
+ const stamp = {
+ publicKey: publicKeyHex,
+ scheme: TURNKEY_STAMP_SCHEME,
+ signature,
+ };
+ return btoa(JSON.stringify(stamp))
+ .replace(/\+/g, "-")
+ .replace(/\//g, "_")
+ .replace(/=+$/, "");
+}
diff --git a/apps/examples/grid-global-accounts-example-app/src/views/customer/Activity.tsx b/apps/examples/grid-global-accounts-example-app/src/views/customer/Activity.tsx
new file mode 100644
index 000000000..a9e6beb40
--- /dev/null
+++ b/apps/examples/grid-global-accounts-example-app/src/views/customer/Activity.tsx
@@ -0,0 +1,210 @@
+import styled from "@emotion/styled";
+import { Badge, Card } from "@lightsparkdev/origin";
+
+import { useAppState } from "../../state/store";
+import type { LogEntry } from "../../lib/reporter";
+
+// Labels emitted by the flows that read as customer-facing wallet activity.
+// (The full request/response log lives behind the Debug drawer in Task 5; this
+// is the curated, human-readable slice.)
+const ACTIVITY_LABELS = new Set([
+ "Create Quote",
+ "Execute Quote",
+ "Create External Account",
+ "V3 Verify leg 2 (expect 200 session)",
+ "OAUTH Verify",
+ "PASSKEY Verify",
+ "Wallet Export",
+ "Wallet Export (retry)",
+ "Delete Credential (retry)",
+ "Delete Session (retry)",
+]);
+
+// Friendly title + tone per raw flow label.
+const TITLES: Record<
+ string,
+ { title: string; tone: "blue" | "green" | "gray" | "yellow" }
+> = {
+ "Create Quote": { title: "Quote created", tone: "gray" },
+ "Execute Quote": { title: "Payment executed", tone: "green" },
+ "Create External Account": { title: "External account added", tone: "blue" },
+ "V3 Verify leg 2 (expect 200 session)": {
+ title: "Signed in (Email OTP)",
+ tone: "blue",
+ },
+ "OAUTH Verify": { title: "Signed in (OAuth)", tone: "blue" },
+ "PASSKEY Verify": { title: "Signed in (Passkey)", tone: "blue" },
+ "Wallet Export": { title: "Wallet export started", tone: "yellow" },
+ "Wallet Export (retry)": { title: "Wallet exported", tone: "yellow" },
+ "Delete Credential (retry)": { title: "Credential removed", tone: "gray" },
+ "Delete Session (retry)": { title: "Session revoked", tone: "gray" },
+};
+
+/**
+ * Activity feed — a curated, human-readable slice of the reporter log scoped to
+ * customer-facing wallet events (funding, payments, sign-ins, exports). Newest
+ * first. `compact` trims it to the latest few for the wallet overview.
+ */
+export function Activity({ compact = false }: { compact?: boolean }) {
+ const { log } = useAppState();
+ const events = selectActivity(log);
+ const shown = compact ? events.slice(0, 4) : events;
+
+ if (shown.length === 0) {
+ const empty = (
+
+ No activity yet
+
+ Funding, payments, and account changes appear here.
+
+
+ );
+ return compact ? (
+ empty
+ ) : (
+
+ {empty}
+
+ );
+ }
+
+ const list = (
+
+ {shown.map((e) => {
+ const meta = TITLES[e.label] ?? {
+ title: e.label,
+ tone: "gray" as const,
+ };
+ return (
+
+
+
+ {meta.title}
+
+
+ {e.level}
+ {formatTime(e.ts)}
+
+
+ );
+ })}
+
+ );
+
+ if (compact) return list;
+ return (
+
+
+
+ Activity
+
+ Wallet events from this session, newest first.
+
+
+
+ {list}
+
+ );
+}
+
+function selectActivity(log: LogEntry[]): LogEntry[] {
+ return log
+ .filter((e) => ACTIVITY_LABELS.has(e.label))
+ .slice()
+ .reverse();
+}
+
+function formatTime(ts: number): string {
+ return new Date(ts).toLocaleTimeString(undefined, {
+ hour: "2-digit",
+ minute: "2-digit",
+ second: "2-digit",
+ });
+}
+
+const List = styled.div`
+ display: flex;
+ flex-direction: column;
+`;
+
+const Row = styled.div`
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: var(--spacing-md, 16px);
+ padding: var(--spacing-sm, 12px) var(--spacing-md, 16px);
+ border-top: var(--stroke-xs, 1px) solid var(--border-primary, #e6e6e9);
+
+ &:first-of-type {
+ border-top: none;
+ }
+`;
+
+const RowLeft = styled.div`
+ display: inline-flex;
+ align-items: center;
+ gap: var(--spacing-sm, 12px);
+ min-width: 0;
+`;
+
+const Dot = styled.span`
+ flex: none;
+ width: 8px;
+ height: 8px;
+ border-radius: 50%;
+ background: var(--text-tertiary, #8a8a8a);
+
+ &[data-tone="green"] {
+ background: var(--brand-green, #16a34a);
+ }
+ &[data-tone="blue"] {
+ background: var(--brand-blue, #2563eb);
+ }
+ &[data-tone="yellow"] {
+ background: var(--brand-yellow, #d97706);
+ }
+`;
+
+const RowTitle = styled.span`
+ font-size: var(--font-size-sm, 13px);
+ font-weight: var(--font-weight-medium, 500);
+ color: var(--text-primary, #1a1a1a);
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+`;
+
+const RowRight = styled.div`
+ display: inline-flex;
+ align-items: center;
+ gap: var(--spacing-sm, 12px);
+`;
+
+const When = styled.span`
+ font-size: var(--font-size-xs, 11px);
+ color: var(--text-tertiary, #8a8a8a);
+ font-variant-numeric: tabular-nums;
+ white-space: nowrap;
+`;
+
+const Empty = styled.div`
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: var(--spacing-2xs, 6px);
+ text-align: center;
+ padding: var(--spacing-2xl, 40px) var(--spacing-lg, 24px);
+`;
+
+const EmptyTitle = styled.div`
+ font-size: var(--font-size-sm, 13px);
+ font-weight: var(--font-weight-semibold, 600);
+ color: var(--text-primary, #1a1a1a);
+`;
+
+const EmptyBody = styled.div`
+ font-size: var(--font-size-sm, 13px);
+ color: var(--text-tertiary, #8a8a8a);
+ max-width: 320px;
+ line-height: 1.45;
+`;
diff --git a/apps/examples/grid-global-accounts-example-app/src/views/customer/CustomerView.tsx b/apps/examples/grid-global-accounts-example-app/src/views/customer/CustomerView.tsx
new file mode 100644
index 000000000..0527ee9e9
--- /dev/null
+++ b/apps/examples/grid-global-accounts-example-app/src/views/customer/CustomerView.tsx
@@ -0,0 +1,86 @@
+import styled from "@emotion/styled";
+import { Alert, Badge, Button } from "@lightsparkdev/origin";
+
+import { StatusBanner } from "../../components/StatusBanner";
+import { useAppState } from "../../state/store";
+import { Login } from "./Login";
+import { WalletHome } from "./WalletHome";
+
+/**
+ * Customer view — the consumer-wallet side of a Grid integration, scoped to the
+ * `activeCustomer` the platform is acting as. Authenticates with `platformAuth`
+ * and runs the decoupled flow operations; every action is a real ceremony,
+ * nothing auto-signed.
+ *
+ * Routing inside the view is session-gated: no `session` → the login screen;
+ * a `session` → the wallet home (balance, fund, pay, activity) with Settings.
+ * If no customer is active (e.g. someone flipped the persona switch directly),
+ * we prompt them back to the Platform view to pick one.
+ */
+export function CustomerView() {
+ const { activeCustomer, session, setPersona } = useAppState();
+
+ if (!activeCustomer) {
+ return (
+
+
+
+
+ );
+ }
+
+ return (
+
+
+
+ Acting as
+
+ {activeCustomer.name || activeCustomer.email}
+ {activeCustomer.email && activeCustomer.name && (
+ {activeCustomer.email}
+ )}
+
+
+
+
+ {session ? : }
+
+ );
+}
+
+const Stack = styled.div`
+ display: flex;
+ flex-direction: column;
+ gap: var(--spacing-lg, 24px);
+`;
+
+const Empty = styled.div`
+ display: flex;
+ flex-direction: column;
+ align-items: flex-start;
+ gap: var(--spacing-md, 16px);
+`;
+
+const ActingAs = styled.div`
+ display: inline-flex;
+ align-items: center;
+ gap: var(--spacing-xs, 8px);
+ flex-wrap: wrap;
+`;
+
+const ActingName = styled.span`
+ font-size: var(--font-size-sm, 13px);
+ font-weight: var(--font-weight-semibold, 600);
+ color: var(--text-primary, #1a1a1a);
+`;
+
+const ActingEmail = styled.span`
+ font-size: var(--font-size-sm, 13px);
+ color: var(--text-tertiary, #8a8a8a);
+`;
diff --git a/apps/examples/grid-global-accounts-example-app/src/views/customer/Fund.tsx b/apps/examples/grid-global-accounts-example-app/src/views/customer/Fund.tsx
new file mode 100644
index 000000000..4d87373b2
--- /dev/null
+++ b/apps/examples/grid-global-accounts-example-app/src/views/customer/Fund.tsx
@@ -0,0 +1,224 @@
+import styled from "@emotion/styled";
+import { Badge, Button, Card, Field, Input } from "@lightsparkdev/origin";
+import { useState } from "react";
+
+import {
+ createExternalAccount,
+ createQuote,
+ executeQuote,
+ signPayload,
+ type ExternalAccountParams,
+} from "../../flows/money";
+import { currencyCode, currencyDecimals } from "../../lib/format-money";
+import { useAppState } from "../../state/store";
+import { DismissibleAlert } from "../../components/DismissibleAlert";
+import type { AccountBalance } from "./WalletHome";
+
+/**
+ * Fund — money-in. Registers an external US bank source account on the platform,
+ * then quotes a transfer from that external source into the active customer's
+ * internal account, signs the embedded-wallet payload, and executes. Sandbox
+ * pre-fills the magic signature so Execute runs immediately; production stamps
+ * with the live session key.
+ */
+export function Fund({
+ accounts,
+ onDone,
+}: {
+ accounts: AccountBalance[];
+ onDone: () => void;
+}) {
+ const { activeCustomer, platformAuth, reporter } = useAppState();
+ const mode = platformAuth?.mode ?? "sandbox";
+
+ const destinationAccountId =
+ activeCustomer?.accountId ?? (accounts[0] ? String(accounts[0].id) : "");
+
+ // The destination is the customer's own account. Convert the entered major
+ // amount to minor units using THAT account's currency decimals (USDB = 6),
+ // not a hardcoded *100, so non-cent currencies fund the right amount.
+ const destinationAccount =
+ accounts.find((a) => String(a.id) === destinationAccountId) ?? accounts[0];
+ const destinationDecimals = currencyDecimals(destinationAccount?.currency);
+
+ const [accountNumber, setAccountNumber] = useState("");
+ const [routingNumber, setRoutingNumber] = useState("");
+ const [amount, setAmount] = useState("");
+
+ const [busy, setBusy] = useState(false);
+ const [error, setError] = useState(null);
+ const [done, setDone] = useState(null);
+
+ async function submit() {
+ if (!platformAuth) return;
+ setBusy(true);
+ setError(null);
+ setDone(null);
+ try {
+ if (!destinationAccountId)
+ throw new Error("No destination account for this customer.");
+ const amountMinor = Math.round(
+ parseFloat(amount || "0") * 10 ** destinationDecimals,
+ );
+ if (!amountMinor) throw new Error("Enter an amount to fund.");
+
+ // 1) Register the external bank source account.
+ const params: ExternalAccountParams = {
+ kind: "bank",
+ accountNumber,
+ routingNumber,
+ beneficiaryName: activeCustomer?.name,
+ };
+ const { externalAccountId } = await createExternalAccount(
+ reporter,
+ platformAuth,
+ params,
+ );
+ if (!externalAccountId)
+ throw new Error("External account create returned no id.");
+
+ // 2) Quote external -> customer's internal account, 3) sign, 4) execute.
+ const quote = await createQuote(reporter, platformAuth, {
+ sourceAccountId: externalAccountId,
+ destinationAccountId,
+ // Money-in: the entered amount is the customer's (receiving) amount.
+ lockedCurrencySide: "RECEIVING",
+ lockedCurrencyAmount: amountMinor,
+ mode,
+ });
+ if (!quote.quoteId) throw new Error("Quote returned no id.");
+ let signature = quote.signature;
+ if (!signature) {
+ const signed = await signPayload(mode, quote.payloadToSign ?? "");
+ signature = signed.signature;
+ }
+ await executeQuote(reporter, platformAuth, quote.quoteId, signature);
+ setDone(`Funded ${amount} into ${destinationAccountId}.`);
+ reporter.status("Funding complete.", "success");
+ onDone();
+ } catch (err) {
+ const message = err instanceof Error ? err.message : String(err);
+ setError(message);
+ reporter.status("Funding failed.", "error");
+ } finally {
+ setBusy(false);
+ }
+ }
+
+ const currency = currencyCode(accounts[0]?.currency);
+
+ return (
+
+
+
+
+
+ Money in
+
+
+ Fund wallet
+
+ Pull funds from an external account into this wallet. We register
+ the source, quote the transfer, then sign & execute.
+
+
+
+
+
+
+
+
+ );
+}
+
+const EyebrowRow = styled.div`
+ display: inline-flex;
+ align-items: center;
+ gap: var(--spacing-2xs, 6px);
+ margin-bottom: var(--spacing-2xs, 6px);
+`;
+
+const Form = styled.form`
+ display: flex;
+ flex-direction: column;
+ gap: var(--spacing-md, 16px);
+`;
+
+const Row2 = styled.div`
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ gap: var(--spacing-md, 16px);
+
+ @media (width <= 560px) {
+ grid-template-columns: 1fr;
+ }
+`;
+
+const Mono = styled.code`
+ font-family: var(--font-family-mono, ui-monospace, monospace);
+ font-size: 0.92em;
+`;
diff --git a/apps/examples/grid-global-accounts-example-app/src/views/customer/Login.tsx b/apps/examples/grid-global-accounts-example-app/src/views/customer/Login.tsx
new file mode 100644
index 000000000..54102f408
--- /dev/null
+++ b/apps/examples/grid-global-accounts-example-app/src/views/customer/Login.tsx
@@ -0,0 +1,967 @@
+import styled from "@emotion/styled";
+import {
+ Alert,
+ Badge,
+ Button,
+ Card,
+ Field,
+ Input,
+} from "@lightsparkdev/origin";
+import { useCallback, useEffect, useState } from "react";
+
+import { sandboxMagicFor } from "../../mode";
+import { DismissibleAlert } from "../../components/DismissibleAlert";
+import { useAppState } from "../../state/store";
+import { addEmailOtpIssue, addEmailOtpRetry } from "../../flows/email-otp";
+import { addOauthIssue, addOauthRetry, signInOauth } from "../../flows/oauth";
+import {
+ addPasskeyIssue,
+ addPasskeyRetry,
+ registerRealPasskey,
+ signInPasskey,
+ type PasskeyAttestation,
+} from "../../flows/passkey";
+import { listCredentials } from "../../flows/manage";
+import { getRawCredentialId, getRawCredentialIds } from "../../passkey-store";
+import { sendOtpChallenge, verifyOtpStep } from "../../flows/otp-step";
+import {
+ hasEmailOtpCredential,
+ methodForCredential,
+ parseCredentials,
+ type ExistingCredential,
+ type Method,
+} from "../../flows/login-decision";
+
+const METHOD_LABEL: Record = {
+ email_otp: "Email OTP",
+ oauth: "OAuth",
+ passkey: "Security key",
+};
+
+const METHOD_BADGE: Record = {
+ email_otp: "blue",
+ oauth: "purple",
+ passkey: "green",
+};
+
+/** What the login view knows about an account's existing credentials. */
+type CredentialsState =
+ | { status: "loading" }
+ | { status: "error"; message: string }
+ | { status: "ready"; credentials: ExistingCredential[] };
+
+/**
+ * Customer login screen (logged-out state). It is organized around the wallet's
+ * EXISTING credentials rather than method tabs:
+ *
+ * 1. "Your sign-in methods" lists EVERY credential the wallet has (a wallet can
+ * hold multiple passkeys / oauth identities — we render the full list, never
+ * collapsing to one-per-type). Each row authenticates against THAT row's
+ * credential id.
+ * 2. "Add a sign-in method" runs the create + verify ceremony for a brand-new
+ * method. Passkey/OAuth are always addable (multiple allowed); Email OTP is
+ * offered only when the wallet has none yet (single).
+ *
+ * The production EMAIL_OTP bug is fixed by the per-row sign-in being a TWO-STEP
+ * action: clicking "Sign in" on an Email-OTP row fires `requestV3Challenge` ONCE
+ * (the only thing that sends the OTP email), then reveals a code input. Verify
+ * runs `runV3Verify` against the bundle the challenge produced — it never issues
+ * a challenge, so a failed verify / retry can't re-send (and invalidate) the
+ * code. The challenge fires only on an explicit Send / Resend click, never on
+ * render. See `flows/otp-step.ts` for the pure two-step orchestration.
+ *
+ * On a verified session it calls `setSession(...)`, flipping `CustomerView` to
+ * the wallet home. In sandbox the magic values are pre-filled (but never
+ * auto-submitted); in production the user supplies real material.
+ */
+export function Login() {
+ const { activeCustomer, platformAuth, reporter } = useAppState();
+
+ // Login needs both an account to attach the credential to and platform auth
+ // to talk to Grid. Both come from the platform side ("Act as" + Connect).
+ const accountId = activeCustomer?.accountId ?? null;
+
+ const [creds, setCreds] = useState({ status: "loading" });
+
+ const refreshCredentials = useCallback(async () => {
+ if (!platformAuth || !accountId) {
+ setCreds({ status: "ready", credentials: [] });
+ return;
+ }
+ setCreds({ status: "loading" });
+ try {
+ const raw = await listCredentials(reporter, platformAuth, accountId);
+ setCreds({ status: "ready", credentials: parseCredentials(raw) });
+ } catch (err) {
+ setCreds({
+ status: "error",
+ message: err instanceof Error ? err.message : String(err),
+ });
+ }
+ }, [platformAuth, accountId, reporter]);
+
+ // Single-shot per (account, auth): refreshCredentials' deps are stable, so this
+ // fires once per account rather than looping.
+ useEffect(() => {
+ void refreshCredentials();
+ }, [refreshCredentials]);
+
+ if (!platformAuth) {
+ return (
+
+
+
+
+
+ );
+ }
+
+ const credentials =
+ creds.status === "ready" ? creds.credentials : ([] as ExistingCredential[]);
+ const canAddEmailOtp =
+ creds.status === "ready" && !hasEmailOtpCredential(credentials);
+ const empty = creds.status === "ready" && credentials.length === 0;
+
+ return (
+
+
+
+
+
+ Sign in
+
+
+ {platformAuth.mode}
+
+
+ Access your wallet
+
+ Sign in with a method already on this wallet, or add a new one.
+ Every ceremony runs for real — nothing is auto-signed.
+
+
+
+
+
+ {!accountId && (
+
+
+
+ )}
+
+ {creds.status === "error" && (
+
+
+
+ )}
+
+ {/* 1. Existing credentials → per-credential sign-in. */}
+ {!empty && (
+
+
+ Your sign-in methods
+
+ Authenticate with a credential already registered on this
+ wallet.
+
+
+
+ {creds.status === "loading" && (
+ Loading sign-in methods…
+ )}
+
+ {creds.status === "ready" && credentials.length > 0 && (
+
+ {credentials.map((cred, i) => (
+
+ ))}
+
+ )}
+
+ )}
+
+ {/* 4. Empty state: only the add section shows. */}
+ {empty && (
+
+
+
+ )}
+
+ {/* 3. Add a sign-in method. */}
+
+
+
+ );
+}
+
+/** Shared "run an action + surface error/busy + set session on success" hook. */
+function useLoginAction() {
+ const { reporter, platformAuth, setSession, activeCustomer } = useAppState();
+ const [busy, setBusy] = useState(false);
+ const [error, setError] = useState(null);
+
+ async function run(action: () => Promise) {
+ if (!platformAuth) return;
+ setBusy(true);
+ setError(null);
+ try {
+ const session = await action();
+ setSession(session ?? { loggedIn: true });
+ reporter.status(
+ `Signed in as ${activeCustomer?.name ?? "customer"}.`,
+ "success",
+ );
+ } catch (err) {
+ const message = err instanceof Error ? err.message : String(err);
+ setError(message);
+ reporter.status("Sign in failed.", "error");
+ } finally {
+ setBusy(false);
+ }
+ }
+
+ return { busy, error, setError, run, reporter, platformAuth };
+}
+
+/** One existing-credential row: a labeled header plus the method-specific
+ * authenticate UI. Renders for EVERY credential the wallet returns. */
+function CredentialRow({
+ accountId,
+ credential,
+ onChanged,
+}: {
+ accountId: string | null;
+ credential: ExistingCredential;
+ onChanged: () => Promise | void;
+}) {
+ const { debugOn } = useAppState();
+ const method = methodForCredential(credential);
+
+ return (
+
+
+
+
+ {method ? METHOD_LABEL[method] : credential.type ?? "Credential"}
+
+
+
+ {credential.nickname ||
+ (method ? METHOD_LABEL[method] : "Credential")}
+
+ {debugOn && credential.id && (
+ {credential.id}
+ )}
+
+
+ {credential.status && (
+
+ {credential.status}
+
+ )}
+
+
+
+ {method === "email_otp" && (
+
+ )}
+ {method === "oauth" && (
+
+ )}
+ {method === "passkey" && (
+
+ )}
+ {!method && (
+
+ Unrecognized credential type — no sign-in handler available.
+
+ )}
+
+
+ );
+}
+
+/** Holds the two-step OTP state for a single credential row. `targetBundle` is
+ * null until the explicit Send fires the challenge. */
+type OtpUi =
+ | { phase: "idle" }
+ | { phase: "awaiting_code"; targetBundle: string };
+
+function EmailOtpSignIn({
+ credential,
+ onChanged,
+}: {
+ credential: ExistingCredential;
+ onChanged: () => Promise | void;
+}) {
+ const { busy, error, setError, run, reporter, platformAuth } =
+ useLoginAction();
+ const sandbox = platformAuth?.mode !== "production";
+ const [ui, setUi] = useState({ phase: "idle" });
+ const [sending, setSending] = useState(false);
+ const [otp, setOtp] = useState("");
+
+ // Step 1 — explicit Send / Resend: this is the ONLY thing that fires the
+ // challenge (and thus sends the OTP email). It never verifies.
+ async function send() {
+ if (!platformAuth) return;
+ setSending(true);
+ setError(null);
+ try {
+ const next = await sendOtpChallenge(
+ reporter,
+ platformAuth,
+ credential.id,
+ );
+ setUi({ phase: "awaiting_code", targetBundle: next.targetBundle });
+ // Sandbox may pre-fill the magic code AFTER the challenge — but never
+ // auto-submit; the user still clicks Verify.
+ if (sandbox)
+ setOtp((prev) => prev || (sandboxMagicFor("email_otp-v3-code") ?? ""));
+ reporter.status("One-time code sent.", "info");
+ } catch (err) {
+ setError(err instanceof Error ? err.message : String(err));
+ reporter.status("Couldn't send the code.", "error");
+ } finally {
+ setSending(false);
+ }
+ }
+
+ // Step 2 — verify against the bundle the challenge produced. Never challenges.
+ async function verify() {
+ if (ui.phase !== "awaiting_code") return;
+ await run(async () => {
+ const session = await verifyOtpStep(
+ reporter,
+ platformAuth!,
+ credential.id,
+ ui.targetBundle,
+ otp,
+ );
+ await onChanged();
+ return session;
+ });
+ }
+
+ return (
+
+ );
+}
+
+function OauthSignIn({
+ accountId,
+ credential,
+ onChanged,
+}: {
+ accountId: string | null;
+ credential: ExistingCredential;
+ onChanged: () => Promise | void;
+}) {
+ const { busy, error, setError, run, reporter, platformAuth } =
+ useLoginAction();
+ const sandbox = platformAuth?.mode !== "production";
+ const [oidc, setOidc] = useState(
+ sandbox ? sandboxMagicFor("oauth-verify-oidc") ?? "" : "",
+ );
+
+ async function submit() {
+ await run(async () => {
+ if (!accountId) throw new Error("No wallet account to sign in to.");
+ // Authenticate against THIS credential id (existingCredId set → no create).
+ const data = await signInOauth(
+ reporter,
+ platformAuth!,
+ accountId,
+ oidc,
+ credential.id,
+ );
+ await onChanged();
+ return data;
+ });
+ }
+
+ return (
+
+ );
+}
+
+function PasskeySignIn({
+ accountId,
+ credential,
+ onChanged,
+}: {
+ accountId: string | null;
+ credential: ExistingCredential;
+ onChanged: () => Promise | void;
+}) {
+ const { busy, error, setError, run, reporter, platformAuth } =
+ useLoginAction();
+ const mode = platformAuth?.mode ?? "sandbox";
+ const sandbox = mode !== "production";
+ const [rpId, setRpId] = useState("");
+
+ // Raw WebAuthn credential id for THIS passkey, if we registered it on this
+ // browser (the Grid credential id is NOT the WebAuthn id). When unknown we
+ // fall back to every raw id we have stored, so a wallet with several security
+ // keys can still match one; if we have none, the assertion uses an empty
+ // allowCredentials and lets the key present a discoverable credential.
+ const ownRawId = getRawCredentialId(credential.id);
+ const knownRawIds = ownRawId ? [ownRawId] : getRawCredentialIds();
+ const rawIdKnown = !sandbox && knownRawIds.length > 0;
+
+ async function submit() {
+ await run(async () => {
+ if (!accountId) throw new Error("No wallet account to sign in to.");
+ // Authenticate against THIS existing passkey via the WebAuthn assertion
+ // (security key in prod, seeded assertion in sandbox). existingCredId set
+ // → `register` is never invoked.
+ const data = await signInPasskey(reporter, platformAuth!, {
+ accountId,
+ nickname: credential.nickname || "Security key",
+ existingCredId: credential.id,
+ register: () =>
+ Promise.reject(
+ new Error("Passkey already exists — registration not needed."),
+ ),
+ loginParams: {
+ mode,
+ // Target the security key by its raw WebAuthn id(s) over USB/NFC.
+ credentialIds: sandbox
+ ? [sandboxMagicFor("passkey-create-cred-id-raw") ?? ""]
+ : knownRawIds,
+ rpId: rpId || undefined,
+ sandboxAssertion: sandbox
+ ? {
+ credentialId:
+ sandboxMagicFor("passkey-create-cred-id-raw") ?? "",
+ clientDataJson:
+ sandboxMagicFor("passkey-verify-client-data-json") ?? "",
+ authenticatorData:
+ sandboxMagicFor("passkey-verify-auth-data") ?? "",
+ signature: sandboxMagicFor("passkey-verify-signature") ?? "",
+ }
+ : undefined,
+ },
+ });
+ await onChanged();
+ return data;
+ });
+ }
+
+ return (
+
+ );
+}
+
+/**
+ * "Add a sign-in method" section. Passkey + OAuth are always available (a wallet
+ * may hold several). Email OTP is offered only when the wallet has none yet.
+ * Each add runs the real create + verify ceremony via the existing add flows
+ * (issue 202 → signed retry); afterwards the credentials list refreshes so the
+ * new method appears with its own per-credential sign-in (Email OTP still goes
+ * through the explicit challenge → verify steps once added).
+ */
+function AddMethods({
+ accountId,
+ canAddEmailOtp,
+ loading,
+ onChanged,
+}: {
+ accountId: string | null;
+ canAddEmailOtp: boolean;
+ loading: boolean;
+ onChanged: () => Promise | void;
+}) {
+ const { reporter, platformAuth } = useAppState();
+ const mode = platformAuth?.mode ?? "sandbox";
+ const sandbox = mode !== "production";
+ const [busy, setBusy] = useState(null);
+ const [error, setError] = useState(null);
+ const [note, setNote] = useState(null);
+
+ async function add(key: string, action: () => Promise, ok: string) {
+ if (!platformAuth || !accountId) {
+ setError("No wallet account to add a credential to.");
+ return;
+ }
+ setBusy(key);
+ setError(null);
+ setNote(null);
+ try {
+ await action();
+ setNote(ok);
+ reporter.status(ok, "success");
+ await onChanged();
+ } catch (err) {
+ const message = err instanceof Error ? err.message : String(err);
+ setError(message);
+ reporter.status("Couldn't add that method.", "error");
+ } finally {
+ setBusy(null);
+ }
+ }
+
+ function sandboxPasskeyAttestation(): PasskeyAttestation {
+ return {
+ challenge: sandboxMagicFor("passkey-create-challenge") ?? "",
+ credentialId: sandboxMagicFor("passkey-create-cred-id-raw") ?? "",
+ clientDataJson: sandboxMagicFor("passkey-create-client-data-json") ?? "",
+ attestationObject:
+ sandboxMagicFor("passkey-create-attestation-object") ?? "",
+ };
+ }
+
+ async function addPasskey() {
+ await add(
+ "add-passkey",
+ async () => {
+ const nickname = "Security key (YubiKey)";
+ const attestation =
+ mode === "production"
+ ? await registerRealPasskey(reporter, nickname)
+ : sandboxPasskeyAttestation();
+ const { requestId, payloadToSign } = await addPasskeyIssue(
+ reporter,
+ platformAuth!,
+ accountId!,
+ nickname,
+ attestation,
+ );
+ if (!requestId)
+ throw new Error("Add security key: no requestId in the 202.");
+ return addPasskeyRetry(
+ reporter,
+ platformAuth!,
+ accountId!,
+ nickname,
+ attestation,
+ requestId,
+ payloadToSign,
+ );
+ },
+ "Security key added.",
+ );
+ }
+
+ async function addOauth() {
+ await add(
+ "add-oauth",
+ async () => {
+ const oidc = sandboxMagicFor("oauth-add-oidc") ?? "";
+ const { requestId } = await addOauthIssue(
+ reporter,
+ platformAuth!,
+ accountId!,
+ oidc,
+ );
+ if (!requestId) throw new Error("Add OAuth: no requestId in the 202.");
+ return addOauthRetry(
+ reporter,
+ platformAuth!,
+ accountId!,
+ oidc,
+ requestId,
+ );
+ },
+ "OAuth credential added.",
+ );
+ }
+
+ async function addEmailOtp() {
+ await add(
+ "add-email-otp",
+ async () => {
+ const { requestId } = await addEmailOtpIssue(
+ reporter,
+ platformAuth!,
+ accountId!,
+ );
+ if (!requestId)
+ throw new Error("Add Email OTP: no requestId in the 202.");
+ return addEmailOtpRetry(reporter, platformAuth!, accountId!, requestId);
+ },
+ "Email OTP method added — sign in with it above.",
+ );
+ }
+
+ const disabled = !accountId || loading;
+
+ return (
+
+
+ Add a sign-in method
+
+ Register another way to unlock this wallet. Adding a security key
+ prompts for a roaming hardware key (YubiKey) over USB/NFC, not the
+ platform passkey. Security keys and OAuth identities can be added more
+ than once.
+ {sandbox && " Sandbox uses seeded ceremony material."}
+
+
+
+ {error && (
+ setError(null)}
+ />
+ )}
+ {note && (
+ setNote(null)}
+ />
+ )}
+
+
+
+
+ {canAddEmailOtp && (
+
+ )}
+
+
+ );
+}
+
+const EyebrowRow = styled.div`
+ display: inline-flex;
+ align-items: center;
+ gap: var(--spacing-2xs, 6px);
+ margin-bottom: var(--spacing-2xs, 6px);
+`;
+
+const Note = styled.div`
+ margin-bottom: var(--spacing-md, 16px);
+`;
+
+const Section = styled.section`
+ display: flex;
+ flex-direction: column;
+ gap: var(--spacing-md, 16px);
+
+ & + & {
+ margin-top: var(--spacing-xl, 32px);
+ padding-top: var(--spacing-lg, 24px);
+ border-top: var(--stroke-xs, 1px) solid var(--border-primary, #e6e6e9);
+ }
+`;
+
+const SectionHead = styled.div`
+ display: flex;
+ flex-direction: column;
+ gap: var(--spacing-3xs, 4px);
+`;
+
+const SectionTitle = styled.h3`
+ margin: 0;
+ font-size: var(--font-size-base, 15px);
+ font-weight: var(--font-weight-semibold, 600);
+ color: var(--text-primary, #1a1a1a);
+`;
+
+const SectionHint = styled.p`
+ margin: 0;
+ font-size: var(--font-size-sm, 13px);
+ color: var(--text-secondary, #555);
+ line-height: 1.5;
+`;
+
+const CredList = styled.div`
+ display: flex;
+ flex-direction: column;
+ gap: var(--spacing-md, 16px);
+`;
+
+const Row = styled.div`
+ display: flex;
+ flex-direction: column;
+ gap: var(--spacing-md, 16px);
+ padding: var(--spacing-md, 16px);
+ border: var(--stroke-xs, 1px) solid var(--border-primary, #e6e6e9);
+ border-radius: var(--corner-radius-lg, 12px);
+ background: var(--surface-base, #fff);
+`;
+
+const RowHead = styled.div`
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: var(--spacing-sm, 12px);
+`;
+
+const RowHeadLeft = styled.div`
+ display: inline-flex;
+ align-items: center;
+ gap: var(--spacing-sm, 12px);
+ min-width: 0;
+`;
+
+const RowText = styled.div`
+ display: flex;
+ flex-direction: column;
+ min-width: 0;
+`;
+
+const RowName = styled.span`
+ font-size: var(--font-size-sm, 13px);
+ font-weight: var(--font-weight-medium, 500);
+ color: var(--text-primary, #1a1a1a);
+`;
+
+const RowId = styled.span`
+ font-size: var(--font-size-xs, 11px);
+ color: var(--text-tertiary, #8a8a8a);
+ font-variant-numeric: tabular-nums;
+ max-width: 260px;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+`;
+
+const RowBody = styled.div`
+ display: flex;
+ flex-direction: column;
+`;
+
+const Form = styled.form`
+ display: flex;
+ flex-direction: column;
+ gap: var(--spacing-md, 16px);
+`;
+
+const Actions = styled.div`
+ display: inline-flex;
+ align-items: center;
+ gap: var(--spacing-sm, 12px);
+ flex-wrap: wrap;
+`;
+
+const AddRow = styled.div`
+ display: inline-flex;
+ align-items: center;
+ gap: var(--spacing-sm, 12px);
+ flex-wrap: wrap;
+`;
+
+const Lede = styled.p`
+ margin: 0;
+ font-size: var(--font-size-sm, 13px);
+ color: var(--text-secondary, #555);
+ line-height: 1.5;
+`;
+
+const MutedRow = styled.div`
+ font-size: var(--font-size-sm, 13px);
+ color: var(--text-tertiary, #8a8a8a);
+`;
+
+const Mono = styled.code`
+ font-family: var(--font-family-mono, ui-monospace, monospace);
+ background: var(--surface-base, #f5f5f7);
+ border-radius: var(--corner-radius-sm, 6px);
+ padding: 1px 5px;
+ font-size: 0.92em;
+`;
diff --git a/apps/examples/grid-global-accounts-example-app/src/views/customer/Pay.tsx b/apps/examples/grid-global-accounts-example-app/src/views/customer/Pay.tsx
new file mode 100644
index 000000000..bcf1df1bf
--- /dev/null
+++ b/apps/examples/grid-global-accounts-example-app/src/views/customer/Pay.tsx
@@ -0,0 +1,534 @@
+import styled from "@emotion/styled";
+import {
+ Badge,
+ Button,
+ Card,
+ Field,
+ Input,
+ Select,
+} from "@lightsparkdev/origin";
+import { useCallback, useEffect, useState } from "react";
+
+import {
+ createCustomerExternalAccount,
+ createQuote,
+ executeQuote,
+ listCustomerExternalAccounts,
+ signPayload,
+ type CreateQuoteResult,
+ type CustomerExternalAccount,
+} from "../../flows/money";
+import { currencyCode, currencyDecimals } from "../../lib/format-money";
+import { useAppState } from "../../state/store";
+import { DismissibleAlert } from "../../components/DismissibleAlert";
+import type { AccountBalance } from "./WalletHome";
+
+interface PendingQuote {
+ quote: CreateQuoteResult;
+ amount: string;
+ destinationLabel: string;
+}
+
+/**
+ * Pay — money-out (offramp). The destination is a *customer-owned* USD external
+ * account, so the embedded wallet → external account quote passes ownership
+ * checks. On entry we list the customer's USD external accounts and let them
+ * pick one; "Add USD bank account" registers a new one and selects it. Then:
+ * amount → quote (SENDING-locked) → review → sign the embedded-wallet payload →
+ * execute. Signature is the magic value in sandbox, a live session stamp in
+ * production.
+ */
+export function Pay({
+ accounts,
+ onDone,
+}: {
+ accounts: AccountBalance[];
+ onDone: () => void;
+}) {
+ const { activeCustomer, platformAuth, reporter } = useAppState();
+ const mode = platformAuth?.mode ?? "sandbox";
+ const customerId = activeCustomer?.id ?? "";
+
+ const sourceAccountId =
+ activeCustomer?.accountId ?? (accounts[0] ? String(accounts[0].id) : "");
+
+ // The source is the customer's own wallet account. Convert the entered major
+ // amount to minor units using THAT account's currency decimals (USDB = 6),
+ // not a hardcoded *100, so non-cent currencies send the right amount.
+ const sourceAccount =
+ accounts.find((a) => String(a.id) === sourceAccountId) ?? accounts[0];
+ const sourceDecimals = currencyDecimals(sourceAccount?.currency);
+
+ // Existing customer USD external accounts + the selected destination id.
+ const [externalAccounts, setExternalAccounts] = useState<
+ CustomerExternalAccount[]
+ >([]);
+ const [selectedId, setSelectedId] = useState("");
+ const [listing, setListing] = useState(false);
+
+ // "Add USD bank account" form (secondary path), collapsed by default.
+ const [adding, setAdding] = useState(false);
+ const [accountNumber, setAccountNumber] = useState("");
+ const [routingNumber, setRoutingNumber] = useState("");
+
+ const [amount, setAmount] = useState("");
+
+ const [pending, setPending] = useState(null);
+ const [busy, setBusy] = useState(false);
+ const [error, setError] = useState(null);
+ const [done, setDone] = useState(null);
+
+ // Load the customer's USD external accounts and preselect the first one.
+ const refreshExternal = useCallback(async () => {
+ if (!platformAuth || !customerId) return;
+ setListing(true);
+ try {
+ const rows = await listCustomerExternalAccounts(
+ reporter,
+ platformAuth,
+ customerId,
+ "USD",
+ );
+ setExternalAccounts(rows);
+ setSelectedId((prev) =>
+ rows.some((r) => r.id === prev) ? prev : rows[0]?.id ?? "",
+ );
+ } catch (err) {
+ setError(err instanceof Error ? err.message : String(err));
+ } finally {
+ setListing(false);
+ }
+ }, [platformAuth, customerId, reporter]);
+
+ useEffect(() => {
+ void refreshExternal();
+ }, [refreshExternal]);
+
+ async function addBank() {
+ if (!platformAuth) return;
+ setBusy(true);
+ setError(null);
+ setDone(null);
+ try {
+ const id = await createCustomerExternalAccount(reporter, platformAuth, {
+ customerId,
+ accountNumber,
+ routingNumber,
+ beneficiaryName: activeCustomer?.name,
+ });
+ await refreshExternal();
+ setSelectedId(id);
+ setAdding(false);
+ setAccountNumber("");
+ setRoutingNumber("");
+ reporter.status("Bank account added.", "success");
+ } catch (err) {
+ const message = err instanceof Error ? err.message : String(err);
+ setError(message);
+ reporter.status("Couldn't add bank account.", "error");
+ } finally {
+ setBusy(false);
+ }
+ }
+
+ async function getQuote() {
+ if (!platformAuth) return;
+ setBusy(true);
+ setError(null);
+ setDone(null);
+ try {
+ if (!sourceAccountId)
+ throw new Error("No source account for this customer.");
+ if (!selectedId)
+ throw new Error("Select a destination bank account first.");
+ const amountMinor = Math.round(
+ parseFloat(amount || "0") * 10 ** sourceDecimals,
+ );
+ if (!amountMinor) throw new Error("Enter an amount to send.");
+
+ const quote = await createQuote(reporter, platformAuth, {
+ sourceAccountId,
+ // Customer-owned external account — passes the ownership check.
+ destinationAccountId: selectedId,
+ // Money-out: the entered amount is what leaves the wallet (sending).
+ lockedCurrencySide: "SENDING",
+ lockedCurrencyAmount: amountMinor,
+ mode,
+ });
+ if (!quote.quoteId) throw new Error("Quote returned no id.");
+ const destinationLabel =
+ externalAccounts.find((a) => a.id === selectedId)?.label ?? selectedId;
+ setPending({ quote, amount, destinationLabel });
+ reporter.status("Quote ready — review and confirm.", "info");
+ } catch (err) {
+ const message = err instanceof Error ? err.message : String(err);
+ setError(message);
+ reporter.status("Quote failed.", "error");
+ } finally {
+ setBusy(false);
+ }
+ }
+
+ async function confirm() {
+ if (!platformAuth || !pending?.quote.quoteId) return;
+ setBusy(true);
+ setError(null);
+ try {
+ let signature = pending.quote.signature;
+ if (!signature) {
+ const signed = await signPayload(
+ mode,
+ pending.quote.payloadToSign ?? "",
+ );
+ signature = signed.signature;
+ }
+ await executeQuote(
+ reporter,
+ platformAuth,
+ pending.quote.quoteId,
+ signature,
+ );
+ setDone(`Sent ${pending.amount} to ${pending.destinationLabel}.`);
+ setPending(null);
+ setAmount("");
+ reporter.status("Payment executed.", "success");
+ onDone();
+ } catch (err) {
+ const message = err instanceof Error ? err.message : String(err);
+ setError(message);
+ reporter.status("Payment failed.", "error");
+ } finally {
+ setBusy(false);
+ }
+ }
+
+ const currency = currencyCode(accounts[0]?.currency);
+ const value = selectedId || null;
+
+ return (
+
+
+
+
+
+ Money out
+
+
+ Send a payment
+
+ Quote a transfer from this wallet to a USD bank account, then
+ confirm to sign & execute.
+
+
+
+
+
+ {pending ? (
+
+ {error && (
+ setError(null)}
+ />
+ )}
+
+
+ Amount
+
+ {pending.amount} {currency}
+
+
+
+ To
+ {pending.destinationLabel}
+
+
+ From
+ {sourceAccountId}
+
+
+ Quote
+
+ {pending.quote.quoteId}
+
+
+
+ Signature
+
+ {mode === "sandbox" ? "Magic (sandbox)" : "Session-stamped"}
+
+
+
+
+
+
+
+
+ ) : (
+
+ )}
+
+
+ );
+}
+
+const EyebrowRow = styled.div`
+ display: inline-flex;
+ align-items: center;
+ gap: var(--spacing-2xs, 6px);
+ margin-bottom: var(--spacing-2xs, 6px);
+`;
+
+const Form = styled.form`
+ display: flex;
+ flex-direction: column;
+ gap: var(--spacing-md, 16px);
+`;
+
+const Row2 = styled.div`
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ gap: var(--spacing-md, 16px);
+
+ @media (width <= 560px) {
+ grid-template-columns: 1fr;
+ }
+`;
+
+const AddCard = styled.div`
+ border: var(--stroke-xs, 1px) solid var(--border-primary, #e6e6e9);
+ border-radius: var(--corner-radius-lg, 12px);
+ padding: var(--spacing-md, 16px);
+ display: flex;
+ flex-direction: column;
+ gap: var(--spacing-md, 16px);
+`;
+
+const AddLink = styled.button`
+ align-self: flex-start;
+ background: none;
+ border: none;
+ padding: 0;
+ cursor: pointer;
+ font-size: var(--font-size-sm, 13px);
+ font-weight: var(--font-weight-medium, 500);
+ color: var(--text-link, #6c4cf6);
+
+ &:hover {
+ text-decoration: underline;
+ }
+`;
+
+const Review = styled.div`
+ display: flex;
+ flex-direction: column;
+ gap: var(--spacing-md, 16px);
+`;
+
+const QuoteCard = styled.div`
+ border: var(--stroke-xs, 1px) solid var(--border-primary, #e6e6e9);
+ border-radius: var(--corner-radius-lg, 12px);
+ background: var(--surface-base, #f5f5f7);
+ padding: var(--spacing-md, 16px);
+ display: flex;
+ flex-direction: column;
+ gap: var(--spacing-sm, 12px);
+`;
+
+const QuoteRow = styled.div`
+ display: flex;
+ align-items: baseline;
+ justify-content: space-between;
+ gap: var(--spacing-md, 16px);
+`;
+
+const QuoteLabel = styled.span`
+ font-size: var(--font-size-xs, 12px);
+ text-transform: uppercase;
+ letter-spacing: 0.5px;
+ color: var(--text-tertiary, #8a8a8a);
+`;
+
+const QuoteValue = styled.span`
+ font-size: var(--font-size-sm, 13px);
+ font-weight: var(--font-weight-medium, 500);
+ color: var(--text-primary, #1a1a1a);
+ font-variant-numeric: tabular-nums;
+`;
+
+const QuoteMono = styled.span`
+ font-family: var(--font-family-mono, ui-monospace, monospace);
+ font-size: var(--font-size-xs, 12px);
+ color: var(--text-secondary, #555);
+ max-width: 60%;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+`;
+
+const Actions = styled.div`
+ display: inline-flex;
+ align-items: center;
+ gap: var(--spacing-sm, 12px);
+`;
diff --git a/apps/examples/grid-global-accounts-example-app/src/views/customer/Settings.tsx b/apps/examples/grid-global-accounts-example-app/src/views/customer/Settings.tsx
new file mode 100644
index 000000000..ae0ef92a2
--- /dev/null
+++ b/apps/examples/grid-global-accounts-example-app/src/views/customer/Settings.tsx
@@ -0,0 +1,546 @@
+import styled from "@emotion/styled";
+import { Alert, Badge, Button, Card } from "@lightsparkdev/origin";
+import { useCallback, useEffect, useState } from "react";
+
+import { sandboxMagicFor } from "../../mode";
+import { addOauthIssue, addOauthRetry } from "../../flows/oauth";
+import {
+ addPasskeyIssue,
+ addPasskeyRetry,
+ registerRealPasskey,
+ type PasskeyAttestation,
+} from "../../flows/passkey";
+import {
+ deleteCredential,
+ deleteSession,
+ exportWallet,
+ listCredentials,
+ listSessions,
+} from "../../flows/manage";
+import { getSessionId } from "../../session";
+import { useAppState } from "../../state/store";
+import { DismissibleAlert } from "../../components/DismissibleAlert";
+import type { AccountBalance } from "./WalletHome";
+
+interface Credential {
+ id: string;
+ type?: string;
+ nickname?: string;
+ status?: string;
+}
+interface Session {
+ id: string;
+ status?: string;
+ expiresAt?: string;
+}
+
+/**
+ * Settings — manage the active customer's wallet credentials & sessions, plus
+ * wallet export. Every action runs the real guided flow in `flows/manage`
+ * (issue 202 -> sign -> retry) or the credential add flows; in sandbox the
+ * magic signature/attestation is used, in production a live session stamp.
+ */
+export function Settings({ accounts }: { accounts: AccountBalance[] }) {
+ const { activeCustomer, platformAuth, reporter, signOut } = useAppState();
+ const mode = platformAuth?.mode ?? "sandbox";
+ const accountId =
+ activeCustomer?.accountId ?? (accounts[0] ? String(accounts[0].id) : "");
+
+ const [credentials, setCredentials] = useState([]);
+ const [sessions, setSessions] = useState([]);
+ const [busy, setBusy] = useState(null);
+ const [error, setError] = useState(null);
+ const [note, setNote] = useState(null);
+ const [mnemonic, setMnemonic] = useState(null);
+ const [mnemonicRevealed, setMnemonicRevealed] = useState(false);
+ const [copied, setCopied] = useState(false);
+
+ const refresh = useCallback(async () => {
+ if (!platformAuth || !accountId) return;
+ try {
+ const creds = (await listCredentials(
+ reporter,
+ platformAuth,
+ accountId,
+ )) as {
+ data?: Credential[];
+ };
+ setCredentials(creds.data ?? []);
+ const sess = (await listSessions(reporter, platformAuth, accountId)) as {
+ data?: Session[];
+ };
+ setSessions(sess.data ?? []);
+ } catch (err) {
+ setError(err instanceof Error ? err.message : String(err));
+ }
+ }, [platformAuth, accountId, reporter]);
+
+ useEffect(() => {
+ void refresh();
+ }, [refresh]);
+
+ async function run(
+ key: string,
+ action: () => Promise,
+ ok: string,
+ onSuccess?: () => void,
+ ) {
+ if (!platformAuth) return;
+ setBusy(key);
+ setError(null);
+ setNote(null);
+ try {
+ await action();
+ setNote(ok);
+ reporter.status(ok, "success");
+ onSuccess?.();
+ await refresh();
+ } catch (err) {
+ const message = err instanceof Error ? err.message : String(err);
+ setError(message);
+ reporter.status("Action failed.", "error");
+ } finally {
+ setBusy(null);
+ }
+ }
+
+ // After a session is revoked server-side, decide whether it's the session
+ // THIS client is signing with: compare the revoked row id to the active
+ // session id (`getSessionId`). If it matches, the local signing key is now
+ // dead, so sign out locally — `CustomerView` falls back to and the
+ // customer can re-authenticate. A different session leaves ours intact.
+ function onSessionRevoked(revokedId: string) {
+ // Compare on the uuid tail so "Session:" and a bare "" still match.
+ const tail = (id: string) => id.split(":").pop() ?? id;
+ const current = getSessionId();
+ if (revokedId && current && tail(revokedId) === tail(current)) {
+ signOut();
+ reporter.status(
+ "Your session was revoked — please sign in again.",
+ "info",
+ );
+ }
+ }
+
+ function sandboxPasskeyAttestation(): PasskeyAttestation {
+ return {
+ challenge: sandboxMagicFor("passkey-create-challenge") ?? "",
+ credentialId: sandboxMagicFor("passkey-create-cred-id-raw") ?? "",
+ clientDataJson: sandboxMagicFor("passkey-create-client-data-json") ?? "",
+ attestationObject:
+ sandboxMagicFor("passkey-create-attestation-object") ?? "",
+ };
+ }
+
+ async function addPasskey() {
+ await run(
+ "add-passkey",
+ async () => {
+ const nickname = "Security key (YubiKey)";
+ const attestation =
+ mode === "production"
+ ? await registerRealPasskey(reporter, nickname)
+ : sandboxPasskeyAttestation();
+ const { requestId, payloadToSign } = await addPasskeyIssue(
+ reporter,
+ platformAuth!,
+ accountId,
+ nickname,
+ attestation,
+ );
+ if (!requestId)
+ throw new Error("Add security key: no requestId in the 202.");
+ return addPasskeyRetry(
+ reporter,
+ platformAuth!,
+ accountId,
+ nickname,
+ attestation,
+ requestId,
+ payloadToSign,
+ );
+ },
+ "Security key added.",
+ );
+ }
+
+ async function addOauth() {
+ await run(
+ "add-oauth",
+ async () => {
+ const oidc = sandboxMagicFor("oauth-add-oidc") ?? "";
+ const { requestId } = await addOauthIssue(
+ reporter,
+ platformAuth!,
+ accountId,
+ oidc,
+ );
+ if (!requestId) throw new Error("Add OAuth: no requestId in the 202.");
+ return addOauthRetry(
+ reporter,
+ platformAuth!,
+ accountId,
+ oidc,
+ requestId,
+ );
+ },
+ "OAuth credential added.",
+ );
+ }
+
+ // Export runs its own handler (not the generic `run`) so it can capture the
+ // recovered mnemonic and reveal it in the card.
+ async function exportAndReveal() {
+ if (!platformAuth) return;
+ setBusy("export");
+ setError(null);
+ setNote(null);
+ setMnemonic(null);
+ setMnemonicRevealed(false);
+ setCopied(false);
+ try {
+ const { mnemonic: recovered } = await exportWallet(
+ reporter,
+ platformAuth,
+ accountId,
+ );
+ setMnemonic(recovered);
+ setNote("Wallet exported — your recovery phrase is shown below.");
+ reporter.status("Wallet exported.", "success");
+ } catch (err) {
+ setError(err instanceof Error ? err.message : String(err));
+ reporter.status("Action failed.", "error");
+ } finally {
+ setBusy(null);
+ }
+ }
+
+ async function copyMnemonic() {
+ if (!mnemonic) return;
+ try {
+ await navigator.clipboard.writeText(mnemonic);
+ setCopied(true);
+ setTimeout(() => setCopied(false), 2000);
+ } catch {
+ setError("Couldn't copy to clipboard.");
+ }
+ }
+
+ if (!accountId) {
+ return (
+
+
+
+
+
+ );
+ }
+
+ return (
+
+ {error && (
+ setError(null)}
+ />
+ )}
+ {note && (
+ setNote(null)}
+ />
+ )}
+
+ {/* Credentials */}
+
+
+
+
+ Sign-in credentials
+
+ The security keys and identities that can unlock this wallet.
+
+
+
+
+
+
+
+
+
+ {credentials.length === 0 ? (
+ No credentials.
+ ) : (
+
+ {credentials.map((c) => (
+
+
+ {c.type ?? "CREDENTIAL"}
+
+ {c.nickname || c.type || "Credential"}
+ {c.id}
+
+
+
+
+ ))}
+
+ )}
+
+
+
+ {/* Sessions */}
+
+
+
+ Active sessions
+
+ Revoke a session to sign it out everywhere.
+
+
+
+
+ {sessions.length === 0 ? (
+ No active sessions.
+ ) : (
+
+ {sessions.map((s) => (
+
+
+
+ {s.status ?? "SESSION"}
+
+
+ {s.id}
+ {s.expiresAt && (
+ Expires {formatDate(s.expiresAt)}
+ )}
+
+
+
+
+ ))}
+
+ )}
+
+
+
+ {/* Export */}
+
+
+
+ Export wallet
+
+ Export the wallet's private key material (HPKE-sealed to the
+ client). Self-custody escape hatch — handle with care.
+
+
+
+ {mnemonic && (
+
+
+
+
+ {mnemonicRevealed ? mnemonic : "•".repeat(48)}
+
+
+
+
+
+
+
+ )}
+
+
+
+
+
+ );
+}
+
+function formatDate(iso: string): string {
+ const d = new Date(iso);
+ return Number.isNaN(d.getTime()) ? iso : d.toLocaleDateString();
+}
+
+const Stack = styled.div`
+ display: flex;
+ flex-direction: column;
+ gap: var(--spacing-lg, 24px);
+`;
+
+const HeaderLayout = styled.div`
+ display: flex;
+ align-items: flex-start;
+ justify-content: space-between;
+ gap: var(--spacing-md, 16px);
+ width: 100%;
+ flex-wrap: wrap;
+`;
+
+const Actions = styled.div`
+ display: inline-flex;
+ align-items: center;
+ gap: var(--spacing-sm, 12px);
+`;
+
+const List = styled.div`
+ display: flex;
+ flex-direction: column;
+`;
+
+const Row = styled.div`
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: var(--spacing-md, 16px);
+ padding: var(--spacing-sm, 12px) var(--spacing-md, 16px);
+ border-top: var(--stroke-xs, 1px) solid var(--border-primary, #e6e6e9);
+
+ &:first-of-type {
+ border-top: none;
+ }
+`;
+
+const RowLeft = styled.div`
+ display: inline-flex;
+ align-items: center;
+ gap: var(--spacing-sm, 12px);
+ min-width: 0;
+`;
+
+const RowText = styled.div`
+ display: flex;
+ flex-direction: column;
+ min-width: 0;
+`;
+
+const RowName = styled.span`
+ font-size: var(--font-size-sm, 13px);
+ font-weight: var(--font-weight-medium, 500);
+ color: var(--text-primary, #1a1a1a);
+`;
+
+const RowId = styled.span`
+ font-size: var(--font-size-xs, 11px);
+ color: var(--text-tertiary, #8a8a8a);
+ font-variant-numeric: tabular-nums;
+ max-width: 260px;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+`;
+
+const RowSub = styled.span`
+ font-size: var(--font-size-xs, 11px);
+ color: var(--text-tertiary, #8a8a8a);
+`;
+
+const EmptyRow = styled.div`
+ font-size: var(--font-size-sm, 13px);
+ color: var(--text-tertiary, #8a8a8a);
+ padding: var(--spacing-md, 16px);
+`;
+
+const MnemonicBox = styled.div`
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: var(--spacing-md, 16px);
+ margin-top: var(--spacing-sm, 12px);
+ padding: var(--spacing-md, 16px);
+ border: var(--stroke-xs, 1px) solid var(--border-primary, #e6e6e9);
+ border-radius: var(--corner-radius-md, 8px);
+ background: var(--surface-secondary, #f0f0ee);
+`;
+
+const MnemonicText = styled.code`
+ font-size: var(--font-size-sm, 13px);
+ line-height: 1.5;
+ word-break: break-word;
+ flex: 1;
+ min-width: 0;
+ color: var(--text-primary, #1a1a1a);
+
+ &[data-hidden="true"] {
+ color: var(--text-tertiary, #8a8a8a);
+ letter-spacing: 2px;
+ }
+`;
+
+const MnemonicActions = styled.div`
+ display: inline-flex;
+ align-items: center;
+ gap: var(--spacing-sm, 12px);
+ flex-shrink: 0;
+`;
diff --git a/apps/examples/grid-global-accounts-example-app/src/views/customer/Transactions.tsx b/apps/examples/grid-global-accounts-example-app/src/views/customer/Transactions.tsx
new file mode 100644
index 000000000..8082bad8e
--- /dev/null
+++ b/apps/examples/grid-global-accounts-example-app/src/views/customer/Transactions.tsx
@@ -0,0 +1,364 @@
+import styled from "@emotion/styled";
+import { Badge, Button, Card } from "@lightsparkdev/origin";
+import { useCallback, useEffect, useState } from "react";
+
+import { DismissibleAlert } from "../../components/DismissibleAlert";
+import { RawExpander } from "../../components/RawExpander";
+import {
+ listTransactions,
+ type Transaction,
+ type TransactionTypeFilter,
+} from "../../flows/transactions";
+import { formatMoney } from "../../lib/format-money";
+import { useAppState } from "../../state/store";
+
+const PAGE_LIMIT = 20;
+
+const FILTERS: { value: TransactionTypeFilter; label: string }[] = [
+ { value: "ALL", label: "All" },
+ { value: "INCOMING", label: "Incoming" },
+ { value: "OUTGOING", label: "Outgoing" },
+];
+
+/**
+ * Transactions tab — the customer's real, server-persisted movement of funds
+ * (onramps / offramps / payments) from `GET /transactions`, scoped to the
+ * active customer and newest first. Distinct from the Activity tab, which shows
+ * this session's client-action log. Supports an All / Incoming / Outgoing
+ * filter and cursor-based "Load more" paging.
+ */
+export function Transactions() {
+ const { activeCustomer, platformAuth, reporter } = useAppState();
+ const customerId = activeCustomer?.id ?? "";
+
+ const [items, setItems] = useState([]);
+ const [loading, setLoading] = useState(false);
+ const [loadingMore, setLoadingMore] = useState(false);
+ const [error, setError] = useState(null);
+ const [hasMore, setHasMore] = useState(false);
+ const [nextCursor, setNextCursor] = useState(null);
+ const [filter, setFilter] = useState("ALL");
+
+ // Memoized so the mount/refetch effect deps stay stable and we don't refetch
+ // on every render. `cursor: null` resets to the first page; a cursor appends.
+ const load = useCallback(
+ async (cursor: string | null) => {
+ if (!platformAuth || !customerId) return;
+ const append = cursor !== null;
+ if (append) setLoadingMore(true);
+ else setLoading(true);
+ try {
+ const page = await listTransactions(reporter, platformAuth, {
+ customerId,
+ limit: PAGE_LIMIT,
+ cursor,
+ type: filter,
+ });
+ setItems((prev) => (append ? [...prev, ...page.data] : page.data));
+ setHasMore(page.hasMore);
+ setNextCursor(page.nextCursor);
+ } catch (err) {
+ setError(
+ err instanceof Error ? err.message : "Couldn't load transactions.",
+ );
+ } finally {
+ if (append) setLoadingMore(false);
+ else setLoading(false);
+ }
+ },
+ [platformAuth, customerId, filter, reporter],
+ );
+
+ // On mount and whenever the customer or filter changes, reset and fetch fresh.
+ useEffect(() => {
+ setItems([]);
+ setHasMore(false);
+ setNextCursor(null);
+ setError(null);
+ void load(null);
+ }, [load]);
+
+ if (!customerId || !platformAuth) {
+ return (
+
+
+
+ Not connected
+
+ Connect the platform and act as a customer to see their
+ transactions.
+
+
+
+
+ );
+ }
+
+ return (
+
+ {error && (
+ setError(null)}
+ />
+ )}
+
+
+
+
+ Transactions
+
+ Funds moving in and out of this wallet, newest first.
+
+
+
+
+
+ {FILTERS.map(({ value, label }) => (
+
+ ))}
+
+
+ {loading ? (
+ Loading transactions…
+ ) : items.length === 0 ? (
+
+ No transactions yet
+
+ Funding, payments, and transfers for this customer appear here.
+
+
+ ) : (
+
+ {items.map((tx, i) => (
+
+ ))}
+
+ )}
+
+ {hasMore && !loading && (
+
+
+
+ )}
+
+
+
+ );
+}
+
+/** One transaction row: direction + amount, counterparty, status, date, raw. */
+function TransactionRow({ tx }: { tx: Transaction }) {
+ const incoming = tx.type === "INCOMING";
+ const money = incoming ? tx.receivedAmount : tx.sentAmount;
+ const amount = money ?? tx.amount;
+ const status = statusBadge(tx.status);
+
+ return (
+
+
+
+
+ {incoming ? "Received" : "Sent"}
+
+
+ {counterparty(tx)}
+
+
+
+
+ {formatSignedAmount(amount, incoming)}
+
+ {status.label}
+ {formatDate(tx.createdAt)}
+
+
+
+
+ );
+}
+
+/** Map a transaction status to a label + Badge variant. */
+function statusBadge(status: string | undefined): {
+ variant: "green" | "yellow" | "red" | "gray";
+ label: string;
+} {
+ const s = (status ?? "").toUpperCase();
+ const label = status || "Unknown";
+ if (s === "COMPLETED" || s === "SETTLED" || s === "SUCCEEDED")
+ return { variant: "green", label };
+ if (s === "PENDING" || s === "PROCESSING" || s === "CREATED")
+ return { variant: "yellow", label };
+ if (s === "FAILED" || s === "REJECTED" || s === "CANCELLED")
+ return { variant: "red", label };
+ return { variant: "gray", label };
+}
+
+/**
+ * Extract a readable counterparty identifier. Incoming reads `source`, outgoing
+ * reads `destination`; both are a OneOf keyed by `sourceType`/`destinationType`.
+ * Prefer a UMA address, then an account id; fall back to the OneOf's type tag.
+ */
+function counterparty(tx: Transaction): string {
+ const incoming = tx.type === "INCOMING";
+ const party = incoming ? tx.source : tx.destination;
+ if (!party || typeof party !== "object") return "—";
+ const p = party as Record;
+ if (typeof p.umaAddress === "string" && p.umaAddress) return p.umaAddress;
+ if (typeof p.accountId === "string" && p.accountId) return p.accountId;
+ const tag = incoming ? p.sourceType : p.destinationType;
+ return typeof tag === "string" && tag ? tag : "—";
+}
+
+/** Signed major-unit amount, e.g. `+ 12.50 USD` / `− 3.000000 USDB`. */
+function formatSignedAmount(
+ amount: { amount?: number; currency?: unknown } | undefined,
+ incoming: boolean,
+): string {
+ if (!amount || typeof amount.amount !== "number") return "—";
+ const sign = incoming ? "+" : "−";
+ return `${sign} ${formatMoney(amount.amount, amount.currency)}`;
+}
+
+/** Locale date + time; guards an invalid/missing timestamp. */
+function formatDate(value: string | undefined): string {
+ if (!value) return "—";
+ const d = new Date(value);
+ if (Number.isNaN(d.getTime())) return "—";
+ return d.toLocaleString(undefined, {
+ year: "numeric",
+ month: "short",
+ day: "numeric",
+ hour: "2-digit",
+ minute: "2-digit",
+ });
+}
+
+const Stack = styled.div`
+ display: flex;
+ flex-direction: column;
+ gap: var(--spacing-md, 16px);
+`;
+
+const Filters = styled.div`
+ display: flex;
+ gap: var(--spacing-2xs, 6px);
+ padding: 0 var(--spacing-md, 16px) var(--spacing-sm, 12px);
+`;
+
+const List = styled.div`
+ display: flex;
+ flex-direction: column;
+`;
+
+const Row = styled.div`
+ display: flex;
+ flex-direction: column;
+ gap: var(--spacing-2xs, 6px);
+ padding: var(--spacing-sm, 12px) var(--spacing-md, 16px);
+ border-top: var(--stroke-xs, 1px) solid var(--border-primary, #e6e6e9);
+
+ &:first-of-type {
+ border-top: none;
+ }
+`;
+
+const RowMain = styled.div`
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: var(--spacing-md, 16px);
+`;
+
+const RowLeft = styled.div`
+ display: inline-flex;
+ align-items: center;
+ gap: var(--spacing-sm, 12px);
+ min-width: 0;
+`;
+
+const RowRight = styled.div`
+ display: inline-flex;
+ align-items: center;
+ gap: var(--spacing-sm, 12px);
+ flex: none;
+`;
+
+const Counterparty = styled.span`
+ font-size: var(--font-size-sm, 13px);
+ color: var(--text-secondary, #555);
+ font-variant-numeric: tabular-nums;
+ max-width: 220px;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+`;
+
+const Amount = styled.span`
+ font-size: var(--font-size-sm, 13px);
+ font-weight: var(--font-weight-medium, 500);
+ color: var(--text-primary, #1a1a1a);
+ font-variant-numeric: tabular-nums;
+ white-space: nowrap;
+
+ &[data-direction="in"] {
+ color: var(--text-green, var(--text-primary, #1a1a1a));
+ }
+`;
+
+const When = styled.span`
+ font-size: var(--font-size-xs, 11px);
+ color: var(--text-tertiary, #8a8a8a);
+ font-variant-numeric: tabular-nums;
+ white-space: nowrap;
+`;
+
+const LoadMore = styled.div`
+ display: flex;
+ justify-content: center;
+ padding: var(--spacing-md, 16px);
+`;
+
+const Notice = styled.div`
+ font-size: var(--font-size-sm, 13px);
+ color: var(--text-tertiary, #8a8a8a);
+ padding: var(--spacing-md, 16px);
+`;
+
+const Empty = styled.div`
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: var(--spacing-2xs, 6px);
+ text-align: center;
+ padding: var(--spacing-2xl, 40px) var(--spacing-lg, 24px);
+`;
+
+const EmptyTitle = styled.div`
+ font-size: var(--font-size-sm, 13px);
+ font-weight: var(--font-weight-semibold, 600);
+ color: var(--text-primary, #1a1a1a);
+`;
+
+const EmptyBody = styled.div`
+ font-size: var(--font-size-sm, 13px);
+ color: var(--text-tertiary, #8a8a8a);
+ max-width: 320px;
+ line-height: 1.45;
+`;
diff --git a/apps/examples/grid-global-accounts-example-app/src/views/customer/WalletHome.tsx b/apps/examples/grid-global-accounts-example-app/src/views/customer/WalletHome.tsx
new file mode 100644
index 000000000..e3277db34
--- /dev/null
+++ b/apps/examples/grid-global-accounts-example-app/src/views/customer/WalletHome.tsx
@@ -0,0 +1,316 @@
+import styled from "@emotion/styled";
+import { Badge, Button, Card, Tabs } from "@lightsparkdev/origin";
+import { useCallback, useEffect, useState } from "react";
+
+import { RawExpander } from "../../components/RawExpander";
+import { fetchBalance } from "../../flows/customer";
+import { currencyCode, formatMoney } from "../../lib/format-money";
+import { useAppState } from "../../state/store";
+import { Activity } from "./Activity";
+import { Fund } from "./Fund";
+import { Pay } from "./Pay";
+import { Settings } from "./Settings";
+import { Transactions } from "./Transactions";
+
+type Section =
+ | "wallet"
+ | "fund"
+ | "pay"
+ | "activity"
+ | "transactions"
+ | "settings";
+
+const SECTIONS: { value: Section; label: string }[] = [
+ { value: "wallet", label: "Wallet" },
+ { value: "fund", label: "Fund" },
+ { value: "pay", label: "Pay" },
+ { value: "activity", label: "Activity" },
+ { value: "transactions", label: "Transactions" },
+ { value: "settings", label: "Settings" },
+];
+
+export interface AccountBalance {
+ id: unknown;
+ /** Currency metadata `{ code, name, symbol, decimals }` from `balance.currency`. */
+ currency: unknown;
+ /** Amount in minor units (per `currency.decimals`). */
+ balance: number;
+}
+
+/**
+ * Wallet home (logged-in state). The consumer surface for the active customer:
+ * a balance hero + account list, with tab navigation into Fund / Pay / Activity
+ * / Settings. Balance comes from `fetchBalance` (real GET) scoped to the active
+ * customer; the money + settings flows act on the customer's `accountId` and
+ * the live session established at login.
+ */
+export function WalletHome() {
+ const { activeCustomer, platformAuth, reporter, signOut } = useAppState();
+ const [section, setSection] = useState("wallet");
+ const [accounts, setAccounts] = useState([]);
+ const [rawBalance, setRawBalance] = useState(null);
+ const [loading, setLoading] = useState(false);
+
+ const customerId = activeCustomer?.id ?? "";
+
+ const refresh = useCallback(async () => {
+ if (!platformAuth || !customerId) return;
+ setLoading(true);
+ try {
+ const { rows, raw } = await fetchBalance(
+ reporter,
+ platformAuth,
+ customerId,
+ );
+ setAccounts(rows);
+ setRawBalance(raw);
+ } catch (err) {
+ reporter.status(
+ err instanceof Error ? err.message : "Couldn't load balance.",
+ "error",
+ );
+ } finally {
+ setLoading(false);
+ }
+ }, [platformAuth, customerId, reporter]);
+
+ useEffect(() => {
+ void refresh();
+ }, [refresh]);
+
+ const primary = accounts[0];
+
+ return (
+
+
+
+
+
+ Total balance
+
+ {primary ? formatMoney(primary.balance, primary.currency) : "—"}
+
+
+
+
+
+
+ {accounts.length === 0 ? (
+
+ {loading
+ ? "Loading accounts…"
+ : "No accounts found for this customer."}
+
+ ) : (
+ accounts.map((a, i) => (
+
+
+
+ {currencyCode(a.currency) || "—"}
+
+ {String(a.id)}
+
+
+ {formatMoney(a.balance, a.currency)}
+
+
+ ))
+ )}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {section === "wallet" && }
+ {section === "fund" && (
+ void refresh()} />
+ )}
+ {section === "pay" && (
+ void refresh()} />
+ )}
+ {section === "activity" && }
+ {section === "transactions" && }
+ {section === "settings" && }
+
+ );
+}
+
+/** The "Wallet" tab body: a short orientation card + recent activity preview. */
+function WalletOverview() {
+ return (
+
+
+
+ Recent activity
+
+ Funding, payments, and session events from this session.
+
+
+
+
+
+
+
+ );
+}
+
+const Stack = styled.div`
+ display: flex;
+ flex-direction: column;
+ gap: var(--spacing-lg, 24px);
+`;
+
+const HeroCard = styled(Card.Root)`
+ background: linear-gradient(
+ 160deg,
+ var(--surface-primary, #fff) 0%,
+ color-mix(
+ in srgb,
+ var(--brand-blue, #2563eb) 5%,
+ var(--surface-primary, #fff)
+ )
+ 100%
+ );
+`;
+
+const HeroTop = styled.div`
+ display: flex;
+ align-items: flex-start;
+ justify-content: space-between;
+ gap: var(--spacing-md, 16px);
+ margin-bottom: var(--spacing-lg, 24px);
+`;
+
+const HeroLabel = styled.div`
+ font-size: var(--font-size-xs, 12px);
+ font-weight: var(--font-weight-medium, 500);
+ text-transform: uppercase;
+ letter-spacing: 0.6px;
+ color: var(--text-tertiary, #8a8a8a);
+ margin-bottom: var(--spacing-2xs, 6px);
+`;
+
+const HeroAmount = styled.div`
+ font-size: 38px;
+ font-weight: var(--font-weight-semibold, 600);
+ letter-spacing: -0.5px;
+ color: var(--text-primary, #1a1a1a);
+ font-variant-numeric: tabular-nums;
+ line-height: 1.1;
+`;
+
+const Accounts = styled.div`
+ display: flex;
+ flex-direction: column;
+ gap: var(--spacing-2xs, 6px);
+`;
+
+const EmptyAccounts = styled.div`
+ font-size: var(--font-size-sm, 13px);
+ color: var(--text-tertiary, #8a8a8a);
+ padding: var(--spacing-sm, 12px) 0;
+`;
+
+const AccountRow = styled.div`
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: var(--spacing-md, 16px);
+ padding: var(--spacing-sm, 12px) 0;
+ border-top: var(--stroke-xs, 1px) solid var(--border-primary, #e6e6e9);
+
+ &:first-of-type {
+ border-top: none;
+ }
+`;
+
+const AccountLeft = styled.div`
+ display: inline-flex;
+ align-items: center;
+ gap: var(--spacing-sm, 12px);
+ min-width: 0;
+`;
+
+const CurrencyBadge = styled(Badge)`
+ font-variant-numeric: tabular-nums;
+`;
+
+const AccountId = styled.span`
+ font-size: var(--font-size-xs, 11px);
+ color: var(--text-tertiary, #8a8a8a);
+ font-variant-numeric: tabular-nums;
+ max-width: 220px;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+`;
+
+const AccountBalanceText = styled.span`
+ font-size: var(--font-size-sm, 13px);
+ font-weight: var(--font-weight-medium, 500);
+ color: var(--text-primary, #1a1a1a);
+ font-variant-numeric: tabular-nums;
+`;
+
+const FooterRow = styled.div`
+ display: flex;
+ align-items: center;
+ gap: var(--spacing-sm, 12px);
+ width: 100%;
+`;
+
+const Spacer = styled.div`
+ flex: 1;
+`;
+
+const Nav = styled.div`
+ display: flex;
+`;
diff --git a/apps/examples/grid-global-accounts-example-app/src/views/platform/Config.tsx b/apps/examples/grid-global-accounts-example-app/src/views/platform/Config.tsx
new file mode 100644
index 000000000..f1f3b46d2
--- /dev/null
+++ b/apps/examples/grid-global-accounts-example-app/src/views/platform/Config.tsx
@@ -0,0 +1,484 @@
+import styled from "@emotion/styled";
+import {
+ Alert,
+ Badge,
+ Button,
+ Card,
+ Field,
+ Input,
+ Select,
+ Tabs,
+} from "@lightsparkdev/origin";
+import { useCallback, useEffect, useRef, useState } from "react";
+
+import { type ApiAuth, resolveMode } from "../../api-client";
+import { DismissibleAlert } from "../../components/DismissibleAlert";
+import { RawExpander } from "../../components/RawExpander";
+import type { Mode } from "../../config";
+import {
+ listPlatformFundingAccounts,
+ type PlatformFundingAccount,
+} from "../../flows/customer";
+import { formatMoney } from "../../lib/format-money";
+import { useAppState } from "../../state/store";
+
+/** Funding-account picker state: loading the list, resolved, or fetch failed. */
+type FundingState =
+ | { kind: "loading" }
+ | { kind: "ready"; accounts: PlatformFundingAccount[] }
+ | { kind: "error"; message: string };
+
+/**
+ * Platform config / auth panel — the entry point for the whole Platform view.
+ *
+ * Captures the platform API credentials (`clientId` / `clientSecret`) and the
+ * target `mode`, then stores them in app state as `platformAuth`. Until that's
+ * set, nothing else on the platform side can run (the decoupled flows all take
+ * an `ApiAuth` argument). Shows live connection status: disconnected when no
+ * auth is held, connected (with a masked summary) once it is.
+ */
+export function Config() {
+ const {
+ platformAuth,
+ setPlatformAuth,
+ platformFundingAccountId,
+ setPlatformFundingAccountId,
+ reporter,
+ } = useAppState();
+ const connected = platformAuth !== null;
+
+ // Local draft so the operator can edit credentials without clobbering the
+ // live connection mid-keystroke; committed to the store on "Connect".
+ const [clientId, setClientId] = useState(platformAuth?.clientId ?? "");
+ const [clientSecret, setClientSecret] = useState(
+ platformAuth?.clientSecret ?? "",
+ );
+ const [mode, setMode] = useState(platformAuth?.mode ?? "sandbox");
+ const [editing, setEditing] = useState(false);
+ const [error, setError] = useState(null);
+
+ // The funding account is chosen — not pasted — from the platform's own funding
+ // pool, listed via `GET /platform/internal-accounts` once connected.
+ const [funding, setFunding] = useState({ kind: "loading" });
+ // Guards against a stale fetch (after disconnect/reconnect) clobbering a newer one.
+ const fundingSeq = useRef(0);
+
+ const loadFundingAccounts = useCallback(async () => {
+ if (!platformAuth) return;
+ const seq = ++fundingSeq.current;
+ setFunding({ kind: "loading" });
+ try {
+ const { accounts } = await listPlatformFundingAccounts(
+ reporter,
+ platformAuth,
+ );
+ if (seq !== fundingSeq.current) return; // superseded
+ setFunding({ kind: "ready", accounts });
+ } catch (err) {
+ if (seq !== fundingSeq.current) return;
+ setFunding({
+ kind: "error",
+ message: err instanceof Error ? err.message : "Couldn't load accounts.",
+ });
+ }
+ }, [platformAuth, reporter]);
+
+ // Fetch the funding pool on connect; clear it when disconnected.
+ useEffect(() => {
+ if (connected) {
+ void loadFundingAccounts();
+ } else {
+ fundingSeq.current++;
+ setFunding({ kind: "loading" });
+ }
+ }, [connected, loadFundingAccounts]);
+
+ function connect() {
+ const id = clientId.trim();
+ const secret = clientSecret.trim();
+ if (!id || !secret) {
+ setError("Both a client ID and a client secret are required to connect.");
+ return;
+ }
+ setError(null);
+ const auth: ApiAuth = { clientId: id, clientSecret: secret, mode };
+ setPlatformAuth(auth);
+ setEditing(false);
+ }
+
+ function disconnect() {
+ setPlatformAuth(null);
+ setPlatformFundingAccountId("");
+ setClientSecret("");
+ setEditing(false);
+ setError(null);
+ }
+
+ return (
+
+
+
+
+
+ Platform
+
+
+
+ {connected ? "Connected" : "Not connected"}
+
+
+ Platform configuration
+
+ Connect with your Grid platform API credentials. These authenticate
+ every platform-side request — creating customers, reading config —
+ and never leave the browser.
+
+
+
+
+
+ {connected && !editing ? (
+
+
+
+ Client ID
+ {maskMiddle(platformAuth.clientId)}
+
+
+ Client secret
+ ••••••••••••
+
+
+ Mode
+
+
+ {platformAuth.mode}
+
+
+
+
+
+ void loadFundingAccounts()}
+ />
+
+
+
+ ) : (
+
+ )}
+
+
+
+ {connected && !editing ? (
+
+
+
+
+ ) : (
+
+
+ {connected && (
+
+ )}
+
+ )}
+
+
+ );
+}
+
+/**
+ * Funding-account picker shown once connected. Lets the operator choose the
+ * platform's funding source from its own funded accounts (the funding pool)
+ * instead of pasting an LSID. Renders the load/empty/error states and, on
+ * selection, sets `platformFundingAccountId` from the chosen account.
+ */
+function FundingPicker({
+ state,
+ selectedId,
+ onSelect,
+ onRetry,
+}: {
+ state: FundingState;
+ selectedId: string;
+ onSelect: (id: string) => void;
+ onRetry: () => void;
+}) {
+ if (state.kind === "loading") {
+ return (
+
+ Funding account
+ Loading your funded accounts…
+
+ );
+ }
+
+ if (state.kind === "error") {
+ return (
+
+ Funding account
+
+
+
+ );
+ }
+
+ if (state.accounts.length === 0) {
+ return (
+
+ Funding account
+
+
+ );
+ }
+
+ // Value the Select is bound to: the selected LSID, or null when none is set.
+ const value = selectedId || null;
+
+ return (
+
+ Funding account
+ onSelect(typeof next === "string" ? next : "")}
+ >
+
+
+ {(selected) =>
+ fundingOptionLabel(
+ state.accounts.find((a) => a.id === selected),
+ selected,
+ )
+ }
+
+
+
+
+
+
+
+ {state.accounts.map((account) => (
+
+
+
+ {fundingOptionLabel(account)}
+
+
+ ))}
+
+
+
+
+
+
+ Used as the source when funding a customer from the platform. Leave
+ unset if you only act as customers.
+
+
+ );
+}
+
+/**
+ * Label an account option as ` · `, e.g.
+ * `Internal…a1b2 · 1,000.00 USD`, so it's recognizable without the operator
+ * reading a raw LSID. Falls back to the raw value when the account is unknown.
+ */
+function fundingOptionLabel(
+ account: PlatformFundingAccount | undefined,
+ fallback?: string | null,
+): string {
+ if (!account) return fallback ?? "";
+ return `${maskMiddle(account.id)} · ${formatMoney(
+ account.amount,
+ account.currency,
+ )}`;
+}
+
+/** `grid_pl…a1b2` — keep the prefix + tail, hide the middle. */
+function maskMiddle(value: string): string {
+ const v = value.trim();
+ if (v.length <= 10) return v;
+ return `${v.slice(0, 6)}…${v.slice(-4)}`;
+}
+
+const EyebrowRow = styled.div`
+ display: inline-flex;
+ align-items: center;
+ gap: var(--spacing-2xs, 6px);
+ margin-bottom: var(--spacing-2xs, 6px);
+`;
+
+const StatusBadge = styled(Badge)`
+ display: inline-flex;
+ align-items: center;
+ gap: var(--spacing-2xs, 6px);
+`;
+
+const StatusDot = styled.span`
+ width: 6px;
+ height: 6px;
+ border-radius: 50%;
+ background: var(--text-tertiary, #8a8a8a);
+
+ &[data-connected="true"] {
+ background: currentColor;
+ box-shadow: 0 0 0 3px color-mix(in srgb, currentColor 22%, transparent);
+ }
+`;
+
+const Form = styled.form`
+ display: flex;
+ flex-direction: column;
+ gap: var(--spacing-lg, 24px);
+`;
+
+const Summary = styled.div`
+ border: var(--stroke-xs, 1px) solid var(--border-primary, #e6e6e9);
+ border-radius: var(--corner-radius-lg, 12px);
+ background: var(--surface-base, #f5f5f7);
+ padding: var(--spacing-md, 16px);
+`;
+
+const SummaryGrid = styled.div`
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
+ gap: var(--spacing-md, 16px);
+ margin-bottom: var(--spacing-md, 16px);
+`;
+
+const PickerSection = styled.div`
+ display: flex;
+ flex-direction: column;
+ gap: var(--spacing-sm, 12px);
+ align-items: flex-start;
+`;
+
+const PickerNote = styled.span`
+ font-size: var(--font-size-sm, 13px);
+ color: var(--text-secondary, #5a5a5a);
+`;
+
+const SummaryItem = styled.div`
+ display: flex;
+ flex-direction: column;
+ gap: var(--spacing-2xs, 6px);
+ min-width: 0;
+`;
+
+const SummaryLabel = styled.span`
+ font-size: var(--font-size-xs, 12px);
+ font-weight: var(--font-weight-medium, 500);
+ text-transform: uppercase;
+ letter-spacing: 0.5px;
+ color: var(--text-tertiary, #8a8a8a);
+`;
+
+const SummaryValue = styled.span`
+ font-size: var(--font-size-sm, 13px);
+ color: var(--text-primary, #1a1a1a);
+ font-variant-numeric: tabular-nums;
+ word-break: break-all;
+`;
+
+const FooterRow = styled.div`
+ display: inline-flex;
+ align-items: center;
+ gap: var(--spacing-sm, 12px);
+`;
diff --git a/apps/examples/grid-global-accounts-example-app/src/views/platform/CreateCustomer.tsx b/apps/examples/grid-global-accounts-example-app/src/views/platform/CreateCustomer.tsx
new file mode 100644
index 000000000..187a41e40
--- /dev/null
+++ b/apps/examples/grid-global-accounts-example-app/src/views/platform/CreateCustomer.tsx
@@ -0,0 +1,175 @@
+import styled from "@emotion/styled";
+import { Button, Dialog, Field, Input } from "@lightsparkdev/origin";
+import { useState } from "react";
+
+import { DismissibleAlert } from "../../components/DismissibleAlert";
+import { createCustomer } from "../../flows/customer";
+import { useAppState, type ActiveCustomer } from "../../state/store";
+
+/**
+ * Create-customer action: a Button that opens an Origin Dialog with the
+ * customer form. On submit it calls the decoupled `createCustomer` operation
+ * (`flows/customer.ts`) with the held `reporter` + `platformAuth`, then adds
+ * the result to the session-local customers list and selects it as active.
+ *
+ * Disabled until the platform is connected — `createCustomer` needs an
+ * `ApiAuth`, which only exists once the Config panel has stored `platformAuth`.
+ *
+ * On success it optimistically prepends the new customer (`addCustomer`) so it
+ * shows immediately, then fires `onCreated` so the parent table re-runs its
+ * single grouped `GET /customers/internal-accounts` fetch (the authoritative
+ * row + balance).
+ */
+export function CreateCustomer({ onCreated }: { onCreated?: () => void }) {
+ const { platformAuth, reporter, addCustomer, setActiveCustomer } =
+ useAppState();
+ const [open, setOpen] = useState(false);
+ const [fullName, setFullName] = useState("");
+ const [email, setEmail] = useState("");
+ const [platformCustomerId, setPlatformCustomerId] = useState("");
+ const [submitting, setSubmitting] = useState(false);
+ const [error, setError] = useState(null);
+
+ const connected = platformAuth !== null;
+
+ function reset() {
+ setFullName("");
+ setEmail("");
+ setPlatformCustomerId("");
+ setError(null);
+ setSubmitting(false);
+ }
+
+ async function submit() {
+ if (!platformAuth) return;
+ setSubmitting(true);
+ setError(null);
+ try {
+ const result = await createCustomer(reporter, platformAuth, {
+ fullName,
+ email,
+ platformCustomerId,
+ });
+ const customer: ActiveCustomer = {
+ id: result.customerId,
+ name: fullName.trim() || "Test User",
+ email: email.trim(),
+ accountId: result.accountId,
+ status: "Active",
+ walletState: result.accountId ? "Provisioned" : "Pending",
+ };
+ addCustomer(customer);
+ setActiveCustomer(customer);
+ reporter.status(`Customer ${customer.name} created.`, "success");
+ onCreated?.();
+ setOpen(false);
+ reset();
+ } catch (err) {
+ const message = err instanceof Error ? err.message : String(err);
+ setError(message);
+ reporter.status("Create customer failed.", "error");
+ } finally {
+ setSubmitting(false);
+ }
+ }
+
+ return (
+ {
+ setOpen(next);
+ if (!next) reset();
+ }}
+ >
+ }
+ >
+ Create customer
+
+
+
+
+
+
+ Create customer
+
+ Provisions a business customer and a USDB internal account on the
+ connected platform.
+
+
+
+
+
+
+ }>
+ Cancel
+
+
+
+
+
+
+ );
+}
+
+const Form = styled.form`
+ display: flex;
+ flex-direction: column;
+ gap: var(--spacing-lg, 24px);
+`;
diff --git a/apps/examples/grid-global-accounts-example-app/src/views/platform/CustomersTable.tsx b/apps/examples/grid-global-accounts-example-app/src/views/platform/CustomersTable.tsx
new file mode 100644
index 000000000..2970f1519
--- /dev/null
+++ b/apps/examples/grid-global-accounts-example-app/src/views/platform/CustomersTable.tsx
@@ -0,0 +1,320 @@
+import styled from "@emotion/styled";
+import { Button, Card, Table } from "@lightsparkdev/origin";
+import { useCallback, useEffect, useRef, useState } from "react";
+
+import {
+ groupCustomerWallets,
+ listAllInternalAccounts,
+ type CustomerWallet,
+} from "../../flows/customer";
+import { formatMoney } from "../../lib/format-money";
+import { useAppState, type ActiveCustomer } from "../../state/store";
+import { CreateCustomer } from "./CreateCustomer";
+import { FundCustomer } from "./FundCustomer";
+
+/**
+ * Customers table — one row per customer, derived from a SINGLE
+ * `GET /customers/internal-accounts` sweep (no `customerId` filter), grouped by
+ * owning customer. Each row shows the customer's shortened id (full on hover)
+ * and its spendable-wallet balance, both straight from that one fetch — no
+ * per-customer follow-up calls. Each row's wallet `accountId` is carried inline,
+ * so "Act as" scopes into the Customer view (`setActiveCustomer` +
+ * `setPersona("customer")`) without an extra request.
+ */
+export function CustomersTable() {
+ const {
+ platformAuth,
+ reporter,
+ setCustomers,
+ setActiveCustomer,
+ setPersona,
+ } = useAppState();
+ const connected = platformAuth !== null;
+
+ const [loading, setLoading] = useState(false);
+ const [truncated, setTruncated] = useState(false);
+ const [wallets, setWallets] = useState([]);
+ // Guards against a stale fetch (e.g. after disconnect/reconnect) clobbering a
+ // newer one's results.
+ const fetchSeq = useRef(0);
+
+ const refresh = useCallback(async () => {
+ if (!platformAuth) return;
+ const seq = ++fetchSeq.current;
+ setLoading(true);
+ try {
+ const { accounts, truncated } = await listAllInternalAccounts(
+ reporter,
+ platformAuth,
+ );
+ if (seq !== fetchSeq.current) return; // superseded
+ const grouped = groupCustomerWallets(accounts);
+ setWallets(grouped);
+ setTruncated(truncated);
+ // Mirror the customer ids into the store so other views (ContextChip,
+ // CustomerView) and the de-dupe logic stay consistent.
+ setCustomers(
+ grouped.map((w) => ({
+ id: w.customerId,
+ name: shortenId(w.customerId),
+ accountId: w.accountId,
+ status: "Active",
+ })),
+ );
+ } catch (err) {
+ if (seq !== fetchSeq.current) return;
+ reporter.status(
+ err instanceof Error ? err.message : "Couldn't load customers.",
+ "error",
+ );
+ } finally {
+ if (seq === fetchSeq.current) setLoading(false);
+ }
+ }, [platformAuth, reporter, setCustomers]);
+
+ // Fetch on connect (and clear when disconnected).
+ useEffect(() => {
+ if (connected) {
+ void refresh();
+ } else {
+ fetchSeq.current++;
+ setWallets([]);
+ setTruncated(false);
+ setCustomers([]);
+ }
+ // setCustomers is stable per the store contract; refresh changes with auth.
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [connected, refresh]);
+
+ function actAs(wallet: CustomerWallet) {
+ // The grouped fetch already carried the wallet account id, so we scope the
+ // Customer view directly — no per-customer fetch.
+ const customer: ActiveCustomer = {
+ id: wallet.customerId,
+ name: shortenId(wallet.customerId) || "Customer",
+ accountId: wallet.accountId,
+ status: "Active",
+ };
+ setActiveCustomer(customer);
+ setPersona("customer");
+ }
+
+ const subtitle = buildSubtitle({
+ connected,
+ loading,
+ shown: wallets.length,
+ truncated,
+ });
+
+ return (
+
+
+
+
+ Customers
+ {subtitle}
+
+ void refresh()} />
+
+
+
+
+ {wallets.length === 0 ? (
+
+ {connected ? (
+ <>
+
+ {loading ? "Loading customers…" : "No customers yet"}
+
+
+ {loading
+ ? "Fetching this platform's customer wallets from the Grid API."
+ : "Create your first customer to provision a wallet and act as them."}
+
+ >
+ ) : (
+ <>
+ Connect to get started
+
+ Add your platform credentials above to list your customers.
+
+ >
+ )}
+
+ ) : (
+
+
+
+ Customer
+
+ Balance
+
+
+ Action
+
+
+
+
+ {wallets.map((wallet) => (
+
+
+
+ {initials(wallet.customerId)}
+
+ {shortenId(wallet.customerId)}
+
+
+
+
+
+
+ {formatMoney(wallet.amount, wallet.currency)}
+
+
+
+
+
+
+ void refresh()}
+ />
+
+
+
+
+
+ ))}
+
+
+ )}
+
+
+ );
+}
+
+/** Subtitle: connect prompt, loading, or a "showing N" summary. */
+function buildSubtitle(args: {
+ connected: boolean;
+ loading: boolean;
+ shown: number;
+ truncated: boolean;
+}): string {
+ const { connected, loading, shown, truncated } = args;
+ if (!connected) return "Connect your platform credentials to list customers.";
+ if (shown === 0)
+ return loading ? "Loading customers…" : "Customers you create appear here.";
+ if (truncated) return `Showing ${shown} customers (more available).`;
+ return `${shown} customer${shown === 1 ? "" : "s"}.`;
+}
+
+/** Shorten an LSID for display: keep the prefix and the last few id chars. */
+function shortenId(id: string): string {
+ if (!id) return "";
+ const [prefix, rest] = id.includes(":") ? id.split(/:(.*)/s) : ["", id];
+ const tail = rest.length > 8 ? `…${rest.slice(-6)}` : rest;
+ return prefix ? `${prefix}:${tail}` : tail;
+}
+
+/** First letters of the last id segment, uppercased. */
+function initials(id: string): string {
+ const rest = id.includes(":") ? id.slice(id.indexOf(":") + 1) : id;
+ const trimmed = rest.replace(/[^a-zA-Z0-9]/g, "");
+ if (!trimmed) return "?";
+ return trimmed.slice(0, 2).toUpperCase();
+}
+
+const HeaderLayout = styled.div`
+ display: flex;
+ align-items: flex-start;
+ justify-content: space-between;
+ gap: var(--spacing-md, 16px);
+ width: 100%;
+`;
+
+const Empty = styled.div`
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: var(--spacing-2xs, 6px);
+ text-align: center;
+ padding: var(--spacing-2xl, 40px) var(--spacing-lg, 24px);
+`;
+
+const EmptyTitle = styled.div`
+ font-size: var(--font-size-sm, 13px);
+ font-weight: var(--font-weight-semibold, 600);
+ color: var(--text-primary, #1a1a1a);
+`;
+
+const EmptyBody = styled.div`
+ font-size: var(--font-size-sm, 13px);
+ color: var(--text-tertiary, #8a8a8a);
+ max-width: 320px;
+ line-height: 1.45;
+`;
+
+const NameCell = styled.div`
+ display: inline-flex;
+ align-items: center;
+ gap: var(--spacing-sm, 12px);
+ min-width: 0;
+`;
+
+const Avatar = styled.span`
+ flex: none;
+ width: 28px;
+ height: 28px;
+ border-radius: 50%;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ font-size: 10px;
+ font-weight: var(--font-weight-semibold, 600);
+ letter-spacing: 0.3px;
+ /* Pair with --surface-inverse: both flip by mode (dark surface + light text
+ * in light mode, light surface + dark text in dark mode). --text-on-primary
+ * was undefined and fell back to #fff, going white-on-near-white in dark. */
+ color: var(--text-inverse, #f8f8f7);
+ background: var(--surface-inverse, #1a1a1a);
+`;
+
+const CustomerId = styled.span`
+ font-size: var(--font-size-sm, 13px);
+ color: var(--text-primary, #1a1a1a);
+ font-variant-numeric: tabular-nums;
+ max-width: 240px;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+`;
+
+const BalanceText = styled.span`
+ font-size: var(--font-size-sm, 13px);
+ font-weight: var(--font-weight-medium, 500);
+ color: var(--text-primary, #1a1a1a);
+ font-variant-numeric: tabular-nums;
+`;
+
+const RightAlign = styled.div`
+ display: flex;
+ justify-content: flex-end;
+`;
+
+const ActionGroup = styled.div`
+ display: inline-flex;
+ align-items: center;
+ gap: var(--spacing-sm, 12px);
+`;
diff --git a/apps/examples/grid-global-accounts-example-app/src/views/platform/FundCustomer.tsx b/apps/examples/grid-global-accounts-example-app/src/views/platform/FundCustomer.tsx
new file mode 100644
index 000000000..bcadc6833
--- /dev/null
+++ b/apps/examples/grid-global-accounts-example-app/src/views/platform/FundCustomer.tsx
@@ -0,0 +1,323 @@
+import styled from "@emotion/styled";
+import {
+ Alert,
+ Button,
+ Dialog,
+ Field,
+ Input,
+ Progress,
+} from "@lightsparkdev/origin";
+import { useState } from "react";
+
+import { DismissibleAlert } from "../../components/DismissibleAlert";
+import { fundCustomerFromPlatform, type FundStage } from "../../flows/money";
+import { currencyCode } from "../../lib/format-money";
+import { useAppState, type ActiveCustomer } from "../../state/store";
+
+/**
+ * Staged progress for the fund flow. The backend only signals PROCESSING →
+ * COMPLETE, so the earlier steps are approximated: each stage maps to a label
+ * and a determinate percentage, advancing the bar as `onStage` fires.
+ */
+const STAGE_META: Record = {
+ quoting: { label: "Creating quote…", value: 25 },
+ executing: { label: "Executing…", value: 55 },
+ processing: { label: "Processing…", value: 80 },
+ completed: { label: "Complete", value: 100 },
+ failed: { label: "Failed", value: 100 },
+};
+
+/**
+ * Per-customer Fund action: a compact Button that opens an Origin Dialog with an
+ * amount input, then funds the customer from the platform's configured funding
+ * account via the proven quote → execute → poll flow (`fundCustomerFromPlatform`
+ * in `flows/money.ts`, mirroring
+ * `sparkcore/.../test_token_fund_in_live.py::_gen_create_and_execute_quote`).
+ *
+ * The amount is collected in major units and converted to minor units using the
+ * destination account's `currency.decimals` (the same block the balance cell
+ * renders). On a terminal status the parent refreshes this customer's balance.
+ *
+ * Disabled (with an explanation) when no platform funding account is configured
+ * or the customer has no provisioned internal account.
+ */
+export function FundCustomer({
+ customer,
+ destinationAccountId,
+ currency,
+ onFunded,
+}: {
+ customer: ActiveCustomer;
+ /** The customer's internal account LSID, from its balance fetch. */
+ destinationAccountId: string | null;
+ /** The destination account's currency block (for decimals + code). */
+ currency: unknown;
+ /** Called after a terminal transaction so the table can refresh the balance. */
+ onFunded: () => void;
+}) {
+ const { platformAuth, platformFundingAccountId, reporter } = useAppState();
+ const [open, setOpen] = useState(false);
+ const [amount, setAmount] = useState("");
+ const [submitting, setSubmitting] = useState(false);
+ const [stage, setStage] = useState(null);
+ const [error, setError] = useState(null);
+ const [result, setResult] = useState(null);
+
+ const connected = platformAuth !== null;
+ const hasFundingAccount = platformFundingAccountId.trim().length > 0;
+ const hasDestination = Boolean(destinationAccountId);
+ const code = currencyCode(currency);
+ const decimals = currencyDecimals(currency);
+
+ // Why the action can't run, surfaced both as a disabled-state tooltip and an
+ // in-dialog notice.
+ const blockedReason = !connected
+ ? "Connect the platform first."
+ : !hasFundingAccount
+ ? "Set a platform funding account in the config panel above to fund customers."
+ : !hasDestination
+ ? "This customer has no provisioned internal account yet."
+ : null;
+
+ function reset() {
+ setAmount("");
+ setError(null);
+ setResult(null);
+ setSubmitting(false);
+ setStage(null);
+ }
+
+ async function submit() {
+ if (!platformAuth || !destinationAccountId) return;
+ setSubmitting(true);
+ setError(null);
+ setResult(null);
+ setStage(null);
+ try {
+ const major = parseFloat(amount || "0");
+ if (!Number.isFinite(major) || major <= 0)
+ throw new Error("Enter an amount to fund.");
+ const amountMinor = Math.round(major * 10 ** decimals);
+ if (amountMinor <= 0) throw new Error("Enter an amount to fund.");
+
+ reporter.status(`Funding ${customer.name || customer.id}…`, "info");
+ const out = await fundCustomerFromPlatform(
+ reporter,
+ platformAuth,
+ {
+ fundingAccountId: platformFundingAccountId,
+ destinationAccountId,
+ amountMinor,
+ },
+ { onStage: setStage },
+ );
+
+ if (out.status === "COMPLETED") {
+ setResult(`Funded — transaction ${out.transactionId} COMPLETED.`);
+ reporter.status("Funding complete.", "success");
+ onFunded();
+ } else if (out.status === "FAILED") {
+ setError(`Transaction ${out.transactionId} FAILED.`);
+ reporter.status("Funding failed.", "error");
+ } else {
+ // Non-terminal: the poll timed out. Surface the last-seen status; the
+ // balance may still settle, so refresh anyway.
+ setResult(
+ `Submitted — transaction ${out.transactionId} is ${
+ out.status || "pending"
+ }.`,
+ );
+ reporter.status("Funding submitted (still settling).", "info");
+ onFunded();
+ }
+ } catch (err) {
+ const message = err instanceof Error ? err.message : String(err);
+ setError(message);
+ reporter.status("Funding failed.", "error");
+ } finally {
+ setSubmitting(false);
+ }
+ }
+
+ const formId = `fund-customer-form-${customer.id}`;
+
+ return (
+ {
+ setOpen(next);
+ if (!next) reset();
+ }}
+ >
+
+ }
+ >
+ Fund
+
+
+
+
+
+
+ Fund {customer.name || "customer"}
+
+ Send value from the platform funding account to this customer.
+ Creates a quote, executes it with the platform's API token (no
+ wallet signature), then polls the transaction to completion.
+
+
+
+
+
+
+ }>
+ Close
+
+
+
+
+
+
+ );
+}
+
+/** Minor-unit decimals from a Currency block; default 2 (mirrors format-money). */
+function currencyDecimals(currency: unknown): number {
+ if (currency && typeof currency === "object") {
+ const c = currency as Record;
+ if (typeof c.decimals === "number" && c.decimals >= 0) return c.decimals;
+ }
+ return 2;
+}
+
+const Form = styled.form`
+ display: flex;
+ flex-direction: column;
+ gap: var(--spacing-lg, 24px);
+`;
+
+const ProgressWrap = styled.div`
+ display: flex;
+ flex-direction: column;
+`;
+
+const Detail = styled.div`
+ display: flex;
+ flex-direction: column;
+ gap: var(--spacing-sm, 12px);
+ border: var(--stroke-xs, 1px) solid var(--border-primary, #e6e6e9);
+ border-radius: var(--corner-radius-lg, 12px);
+ background: var(--surface-base, #f5f5f7);
+ padding: var(--spacing-md, 16px);
+`;
+
+const DetailRow = styled.div`
+ display: flex;
+ align-items: baseline;
+ justify-content: space-between;
+ gap: var(--spacing-md, 16px);
+`;
+
+const DetailLabel = styled.span`
+ font-size: var(--font-size-xs, 12px);
+ text-transform: uppercase;
+ letter-spacing: 0.5px;
+ color: var(--text-tertiary, #8a8a8a);
+`;
+
+const Mono = styled.span`
+ font-family: var(--font-family-mono, ui-monospace, monospace);
+ font-size: var(--font-size-xs, 12px);
+ color: var(--text-secondary, #555);
+ max-width: 60%;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+`;
diff --git a/apps/examples/grid-global-accounts-example-app/src/views/platform/PlatformView.tsx b/apps/examples/grid-global-accounts-example-app/src/views/platform/PlatformView.tsx
new file mode 100644
index 000000000..6ddfc5e8c
--- /dev/null
+++ b/apps/examples/grid-global-accounts-example-app/src/views/platform/PlatformView.tsx
@@ -0,0 +1,29 @@
+import styled from "@emotion/styled";
+
+import { StatusBanner } from "../../components/StatusBanner";
+import { Config } from "./Config";
+import { CustomersTable } from "./CustomersTable";
+
+/**
+ * Platform view — the admin-dashboard side of a Grid integration.
+ *
+ * Composes the config / auth panel (the entry point) over the customers table
+ * (create + "act as"). A transient status line surfaces the latest reporter
+ * message so platform operations give feedback without opening the debug
+ * drawer. Until the platform is connected, only the config panel is actionable.
+ */
+export function PlatformView() {
+ return (
+
+
+
+
+
+ );
+}
+
+const Stack = styled.div`
+ display: flex;
+ flex-direction: column;
+ gap: var(--spacing-lg, 24px);
+`;
diff --git a/apps/examples/grid-global-accounts-example-app/src/webauthn.ts b/apps/examples/grid-global-accounts-example-app/src/webauthn.ts
new file mode 100644
index 000000000..4ee7df415
--- /dev/null
+++ b/apps/examples/grid-global-accounts-example-app/src/webauthn.ts
@@ -0,0 +1,215 @@
+// WebAuthn ceremony helpers (real passkeys).
+//
+// The sandbox flows accept magic placeholder strings, but a real Turnkey
+// sub-org needs a genuine WebAuthn credential. These helpers drive the
+// browser's authenticator and base64url-encode the results into the same
+// fields the sandbox flow uses, so Create / Add / Verify work unchanged
+// against production Turnkey.
+//
+// AUTHENTICATOR: we target a ROAMING security key (e.g. a YubiKey), not the
+// platform authenticator (Touch ID / Windows Hello). Registration asks the
+// browser for a cross-platform authenticator; authentication targets the
+// registered credential id(s) over USB / NFC. This keeps the wallet's signing
+// key on a removable hardware key rather than the laptop's secure enclave.
+//
+// NOTE: WebAuthn binds a credential to an RP ID that must be a suffix of the
+// page origin — on localhost that means rpId="localhost". The Turnkey sub-org
+// must have been created with the SAME RP ID or verification will fail.
+
+// COSE algorithm identifiers we accept for the credential public key. ES256
+// (-7) is universally supported by security keys; RS256 (-257) is a fallback.
+const PUB_KEY_CRED_PARAMS: PublicKeyCredentialParameters[] = [
+ { type: "public-key", alg: -7 }, // ES256
+ { type: "public-key", alg: -257 }, // RS256
+];
+
+// Transports a roaming security key (YubiKey) is reached over. Listed in the
+// assertion's allowCredentials so the browser steers the user to the security
+// key rather than offering a platform passkey.
+export const SECURITY_KEY_TRANSPORTS: AuthenticatorTransport[] = ["usb", "nfc"];
+
+export function bytesToB64Url(bytes: Uint8Array): string {
+ let bin = "";
+ for (let i = 0; i < bytes.length; i++) bin += String.fromCharCode(bytes[i]);
+ return btoa(bin).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
+}
+
+export function b64UrlToBytes(value: string): Uint8Array {
+ const b64 = value.replace(/-/g, "+").replace(/_/g, "/");
+ const padded = b64 + "=".repeat((4 - (b64.length % 4)) % 4);
+ const bin = atob(padded);
+ const bytes = new Uint8Array(bin.length);
+ for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i);
+ return bytes;
+}
+
+// Resolve the WebAuthn RP ID: a caller-supplied value (the passkey-rp-id form
+// field, in the UI), falling back to the page hostname. Must be a suffix of the
+// page origin and match the RP ID the Turnkey sub-org was created with.
+export function passkeyRpId(rpId?: string): string {
+ return rpId?.trim() || location.hostname;
+}
+
+export interface RealAttestation {
+ challenge: string;
+ credentialId: string;
+ clientDataJson: string;
+ attestationObject: string;
+}
+
+/**
+ * Build the `PublicKeyCredentialCreationOptions` for a registration ceremony
+ * that targets a ROAMING SECURITY KEY (YubiKey):
+ * - `authenticatorAttachment: "cross-platform"` makes the browser prompt for
+ * a removable security key (USB / NFC), NOT the platform authenticator.
+ * - `residentKey: "discouraged"` + `requireResidentKey: false`: the key need
+ * not store a discoverable credential — we always have its id to put in
+ * `allowCredentials`, which lets cheaper non-resident keys work and avoids
+ * burning the key's limited resident-credential slots.
+ * - `userVerification: "preferred"`: use the PIN/biometric if the key has one,
+ * but don't hard-require it (a basic touch-only key still works).
+ * - `pubKeyCredParams` includes ES256 (-7), which every security key supports.
+ *
+ * Pure (no DOM / crypto side effects) so it can be unit-tested; the caller
+ * supplies the random challenge + user id.
+ */
+export function buildCreationOptions(
+ nickname: string,
+ rpId: string | undefined,
+ challenge: BufferSource,
+ userId: BufferSource,
+): PublicKeyCredentialCreationOptions {
+ return {
+ rp: { id: passkeyRpId(rpId), name: "Grid Example App" },
+ user: {
+ id: userId,
+ name: nickname || "grid-example-user",
+ displayName: nickname || "Grid Example User",
+ },
+ challenge,
+ pubKeyCredParams: PUB_KEY_CRED_PARAMS,
+ authenticatorSelection: {
+ authenticatorAttachment: "cross-platform",
+ residentKey: "discouraged",
+ requireResidentKey: false,
+ userVerification: "preferred",
+ },
+ attestation: "none",
+ timeout: 60000,
+ };
+}
+
+// Real registration ceremony — produces the attestation that Create/Add send.
+// `attestation.credentialId` is the RAW WebAuthn credential id (base64url): the
+// caller should persist it so a later assertion can target this security key
+// via allowCredentials. (The Grid credential id returned by POST
+// /auth/credentials is a DIFFERENT, server-side id — not usable here.)
+export async function createRealPasskey(
+ nickname: string,
+ rpId?: string,
+): Promise {
+ const challenge = crypto.getRandomValues(new Uint8Array(32));
+ const userId = crypto.getRandomValues(new Uint8Array(16));
+ const credential = (await navigator.credentials.create({
+ publicKey: buildCreationOptions(nickname, rpId, challenge, userId),
+ })) as PublicKeyCredential | null;
+ if (!credential) throw new Error("Passkey creation returned no credential");
+ const response = credential.response as AuthenticatorAttestationResponse;
+ return {
+ challenge: bytesToB64Url(challenge),
+ credentialId: bytesToB64Url(new Uint8Array(credential.rawId)),
+ clientDataJson: bytesToB64Url(new Uint8Array(response.clientDataJSON)),
+ attestationObject: bytesToB64Url(
+ new Uint8Array(response.attestationObject),
+ ),
+ };
+}
+
+export interface RealAssertion {
+ credentialId: string;
+ authenticatorData: string;
+ clientDataJson: string;
+ signature: string;
+}
+
+/**
+ * Build `allowCredentials` from the registered passkeys' RAW WebAuthn credential
+ * ids (base64url). Each entry carries `transports: ["usb","nfc"]` so the browser
+ * targets the roaming security key. Blank / duplicate ids are dropped.
+ *
+ * When NO ids are known (e.g. a credential registered before we started storing
+ * raw ids), this returns `[]` — an empty allowCredentials lets a discoverable
+ * (resident) credential on the security key be presented, which is the best we
+ * can do without the id. Pure + DOM-free for unit testing.
+ */
+export function buildAllowCredentials(
+ credentialIds: string[],
+): PublicKeyCredentialDescriptor[] {
+ const seen = new Set();
+ const out: PublicKeyCredentialDescriptor[] = [];
+ for (const id of credentialIds) {
+ const trimmed = id?.trim();
+ if (!trimmed || seen.has(trimmed)) continue;
+ seen.add(trimmed);
+ out.push({
+ type: "public-key",
+ id: b64UrlToBytes(trimmed) as BufferSource,
+ transports: SECURITY_KEY_TRANSPORTS,
+ });
+ }
+ return out;
+}
+
+/**
+ * Build the `PublicKeyCredentialRequestOptions` for an assertion that targets a
+ * roaming security key. Pure (no DOM); the caller encodes the challenge bytes.
+ */
+export function buildAssertionOptions(
+ challenge: BufferSource,
+ credentialIds: string[],
+ rpId?: string,
+): PublicKeyCredentialRequestOptions {
+ return {
+ rpId: passkeyRpId(rpId),
+ challenge,
+ allowCredentials: buildAllowCredentials(credentialIds),
+ userVerification: "preferred",
+ timeout: 60000,
+ };
+}
+
+// Real assertion ceremony — signs the issued session challenge with the security
+// key. `credentialIds` are the RAW WebAuthn credential ids (base64url) of the
+// registered passkey(s); pass every one a wallet has so the key can match any.
+export async function signWithPasskey(
+ challengeValue: string,
+ credentialIds: string | string[],
+ rpId?: string,
+): Promise {
+ if (!challengeValue) {
+ throw new Error(
+ "No challenge — issue a session challenge (step above) first.",
+ );
+ }
+ // Turnkey's WebAuthn challenge is the UTF-8 bytes of the sha256-hex challenge
+ // string returned by /challenge — NOT base64url-decoded.
+ const challenge = new TextEncoder().encode(challengeValue);
+ const ids = Array.isArray(credentialIds)
+ ? credentialIds
+ : credentialIds
+ ? [credentialIds]
+ : [];
+ const credential = (await navigator.credentials.get({
+ publicKey: buildAssertionOptions(challenge, ids, rpId),
+ })) as PublicKeyCredential | null;
+ if (!credential) throw new Error("Passkey assertion returned no credential");
+ const response = credential.response as AuthenticatorAssertionResponse;
+ return {
+ credentialId: bytesToB64Url(new Uint8Array(credential.rawId)),
+ authenticatorData: bytesToB64Url(
+ new Uint8Array(response.authenticatorData),
+ ),
+ clientDataJson: bytesToB64Url(new Uint8Array(response.clientDataJSON)),
+ signature: bytesToB64Url(new Uint8Array(response.signature)),
+ };
+}
diff --git a/apps/examples/grid-global-accounts-example-app/tsconfig.json b/apps/examples/grid-global-accounts-example-app/tsconfig.json
new file mode 100644
index 000000000..e392ebf28
--- /dev/null
+++ b/apps/examples/grid-global-accounts-example-app/tsconfig.json
@@ -0,0 +1,16 @@
+{
+ "compilerOptions": {
+ "target": "ES2022",
+ "module": "ESNext",
+ "moduleResolution": "bundler",
+ "jsx": "react-jsx",
+ "strict": true,
+ "noEmit": true,
+ "isolatedModules": true,
+ "esModuleInterop": true,
+ "skipLibCheck": true,
+ "forceConsistentCasingInFileNames": true,
+ "lib": ["ES2022", "DOM", "DOM.Iterable"]
+ },
+ "include": ["src"]
+}
diff --git a/apps/examples/grid-global-accounts-example-app/vite.config.ts b/apps/examples/grid-global-accounts-example-app/vite.config.ts
new file mode 100644
index 000000000..e8dda07de
--- /dev/null
+++ b/apps/examples/grid-global-accounts-example-app/vite.config.ts
@@ -0,0 +1,25 @@
+import react from "@vitejs/plugin-react";
+import { defineConfig } from "vite";
+import settings from "../settings.json";
+
+// Grid API base for the dev proxy (strips the `/api` prefix and rewrites the
+// path to the versioned API channel). Defaults to production; override locally
+// for a dev backend via the GRID_URL env var, e.g.
+// GRID_URL=https://api.dev.dev.sparkinfra.net yarn dev
+// Credentials are entered manually in the UI — never embedded here.
+const PROD_GRID_URL = process.env.GRID_URL ?? "https://api.lightspark.com";
+
+export default defineConfig({
+ plugins: [react()],
+ server: {
+ port: settings.gridGlobalAccountsExampleApp.port,
+ proxy: {
+ "/api": {
+ target: PROD_GRID_URL,
+ changeOrigin: true,
+ secure: true,
+ rewrite: (path) => path.replace(/^\/api/, "/grid/2025-10-13"),
+ },
+ },
+ },
+});
diff --git a/apps/examples/grid-kyc-demo/README.md b/apps/examples/grid-kyc-demo/README.md
new file mode 100644
index 000000000..890c3c763
--- /dev/null
+++ b/apps/examples/grid-kyc-demo/README.md
@@ -0,0 +1,47 @@
+# grid-kyc-demo
+
+Internal demo tool for exercising the Grid hosted KYC/KYB link API end-to-end.
+Single-page Vite + React app, no backend. Credentials are entered at the top
+and live only in this tab's `sessionStorage`.
+
+## What it does
+
+- **Create a customer** via `POST /customers` (INDIVIDUAL or BUSINESS).
+- **Generate a hosted KYC link** via `POST /customers/{id}/kyc-link` and open it
+ in a new tab.
+- **Poll customer status** via `GET /customers/{id}` so you can watch
+ `kycStatus` / `kybStatus` flip after the hosted flow completes.
+
+Every request and response is appended to a rolling log at the bottom of the
+page so you can see exactly what's going over the wire.
+
+## Run it locally
+
+```bash
+cd js/apps/examples/grid-kyc-demo
+yarn dev
+```
+
+Opens on .
+
+The Vite dev server proxies API calls to one of three environments — pick from
+the **Environment** dropdown in the UI:
+
+| Env | Target |
+| ----- | --------------------------------------------------------- |
+| prod | `https://api.lightspark.com/grid/2025-10-13` |
+| dev | `https://api.dev.dev.sparkinfra.net/grid/rc` |
+| local | `http://localhost:5000/grid/rc` (sparkcore on port 5000) |
+
+Credentials are stored under `grid-kyc-demo:creds:` so prod and dev keys
+don't get mixed up. Switching env swaps the visible credential pair.
+
+## Tips
+
+- The platform you're calling against needs `customer_kyc_mode = GRID_SWITCH_OWNED`
+ on at least one of its currencies, otherwise grid auto-approves new customers
+ on creation and the link flow has nothing to do.
+- For INDIVIDUAL customers on the LSP grid switch, the
+ `LSP_INDIVIDUAL_KYC_ENABLED` gatekeeper also has to be on for the platform.
+- The redirect URI must be `https://` — Sumsub rejects `http://` and localhost.
+ Leave the field blank to use Sumsub's default post-flow page.
diff --git a/apps/examples/grid-kyc-demo/index.html b/apps/examples/grid-kyc-demo/index.html
new file mode 100644
index 000000000..074b28d71
--- /dev/null
+++ b/apps/examples/grid-kyc-demo/index.html
@@ -0,0 +1,30 @@
+
+
+
+
+
+ Grid KYC/KYB Demo
+
+
+
+
+
+
+
+
diff --git a/apps/examples/grid-kyc-demo/package.json b/apps/examples/grid-kyc-demo/package.json
new file mode 100644
index 000000000..fcd9fb8f3
--- /dev/null
+++ b/apps/examples/grid-kyc-demo/package.json
@@ -0,0 +1,25 @@
+{
+ "name": "@lightsparkdev/grid-kyc-demo",
+ "private": true,
+ "version": "0.0.1",
+ "scripts": {
+ "dev": "vite",
+ "build": "tsc && vite build",
+ "start": "vite",
+ "preview": "vite preview"
+ },
+ "dependencies": {
+ "@emotion/react": "^11.14.0",
+ "@emotion/styled": "^11.14.1",
+ "@lightsparkdev/origin": "*",
+ "react": "^19.2.6",
+ "react-dom": "^19.2.6"
+ },
+ "devDependencies": {
+ "@types/react": "^19.2.15",
+ "@types/react-dom": "^19.2.3",
+ "@vitejs/plugin-react": "^5.2.0",
+ "typescript": "^5.6.2",
+ "vite": "^8.0.14"
+ }
+}
diff --git a/apps/examples/grid-kyc-demo/public/fonts b/apps/examples/grid-kyc-demo/public/fonts
new file mode 120000
index 000000000..7bf131b0d
--- /dev/null
+++ b/apps/examples/grid-kyc-demo/public/fonts
@@ -0,0 +1 @@
+../../../../packages/origin/public/fonts
\ No newline at end of file
diff --git a/apps/examples/grid-kyc-demo/src/App.tsx b/apps/examples/grid-kyc-demo/src/App.tsx
new file mode 100644
index 000000000..0fcc0880f
--- /dev/null
+++ b/apps/examples/grid-kyc-demo/src/App.tsx
@@ -0,0 +1,1370 @@
+import styled from "@emotion/styled";
+import {
+ Alert,
+ Badge,
+ Button,
+ Card,
+ Field,
+ Input,
+ Select,
+ Textarea,
+} from "@lightsparkdev/origin";
+import { useCallback, useEffect, useMemo, useRef, useState } from "react";
+
+import {
+ callGrid,
+ ENV_LABELS,
+ nowTs,
+ randomSuffix,
+ type CustomerCreateResponse,
+ type GridCredentials,
+ type GridEnv,
+ type KycLinkResponse,
+ type LogEntry,
+} from "./api";
+
+type CustomerType = "INDIVIDUAL" | "BUSINESS";
+type FlowMode = "HOSTED" | "SDK";
+type Status = { kind: "ok" | "err"; message: string } | null;
+
+const ENV_STORAGE_KEY = "grid-kyc-demo:env";
+const CREDS_STORAGE_KEY_PREFIX = "grid-kyc-demo:creds:";
+
+const ENTITY_TYPES = [
+ "SOLE_PROPRIETORSHIP",
+ "PARTNERSHIP",
+ "LLC",
+ "CORPORATION",
+ "S_CORPORATION",
+ "NON_PROFIT",
+ "OTHER",
+] as const;
+
+const BUSINESS_TYPES = [
+ "AGRICULTURE_FORESTRY_FISHING_AND_HUNTING",
+ "MINING_QUARRYING_AND_OIL_AND_GAS_EXTRACTION",
+ "UTILITIES",
+ "CONSTRUCTION",
+ "MANUFACTURING",
+ "WHOLESALE_TRADE",
+ "RETAIL_TRADE",
+ "TRANSPORTATION_AND_WAREHOUSING",
+ "INFORMATION",
+ "FINANCE_AND_INSURANCE",
+ "REAL_ESTATE_AND_RENTAL_AND_LEASING",
+ "PROFESSIONAL_SCIENTIFIC_AND_TECHNICAL_SERVICES",
+ "MANAGEMENT_OF_COMPANIES_AND_ENTERPRISES",
+ "ADMINISTRATIVE_AND_SUPPORT_AND_WASTE_MANAGEMENT_AND_REMEDIATION_SERVICES",
+ "EDUCATIONAL_SERVICES",
+ "HEALTH_CARE_AND_SOCIAL_ASSISTANCE",
+ "ARTS_ENTERTAINMENT_AND_RECREATION",
+ "ACCOMMODATION_AND_FOOD_SERVICES",
+ "OTHER_SERVICES",
+ "PUBLIC_ADMINISTRATION",
+] as const;
+
+const PURPOSE_OF_ACCOUNT = [
+ "CONTRACTOR_PAYOUTS",
+ "CREATOR_PAYOUTS",
+ "EMPLOYEE_PAYOUTS",
+ "MARKETPLACE_SELLER_PAYOUTS",
+ "SUPPLIER_PAYMENTS",
+ "CROSS_BORDER_B2B",
+ "AR_AUTOMATION",
+ "AP_AUTOMATION",
+ "EMBEDDED_PAYMENTS",
+ "PLATFORM_FEE_COLLECTION",
+ "P2P_TRANSFERS",
+ "CHARITABLE_DONATIONS",
+ "OTHER",
+] as const;
+
+const TX_COUNT = [
+ "COUNT_UNDER_10",
+ "COUNT_10_TO_100",
+ "COUNT_100_TO_500",
+ "COUNT_500_TO_1000",
+ "COUNT_OVER_1000",
+] as const;
+
+const TX_VOLUME = [
+ "VOLUME_UNDER_10K",
+ "VOLUME_10K_TO_100K",
+ "VOLUME_100K_TO_1M",
+ "VOLUME_1M_TO_10M",
+ "VOLUME_OVER_10M",
+] as const;
+
+interface IndividualForm {
+ platformCustomerId: string;
+ region: string;
+ fullName: string;
+ birthDate: string;
+ nationality: string;
+ email: string;
+ currencies: string;
+}
+
+interface BusinessForm {
+ platformCustomerId: string;
+ region: string;
+ currencies: string;
+ legalName: string;
+ doingBusinessAs: string;
+ country: string;
+ registrationNumber: string;
+ incorporatedOn: string;
+ entityType: string;
+ taxId: string;
+ countriesOfOperation: string;
+ businessType: string;
+ purposeOfAccount: string;
+ sourceOfFunds: string;
+ txCount: string;
+ txVolume: string;
+ recipientJurisdictions: string;
+ addrLine1: string;
+ addrLine2: string;
+ addrCity: string;
+ addrState: string;
+ addrPostal: string;
+ addrCountry: string;
+}
+
+function defaultIndividual(): IndividualForm {
+ return {
+ platformCustomerId: `ind-${randomSuffix()}`,
+ region: "US",
+ fullName: "Jane Smith",
+ birthDate: "1990-01-15",
+ nationality: "US",
+ email: "",
+ currencies: "USD,USDC",
+ };
+}
+
+function defaultBusiness(): BusinessForm {
+ return {
+ platformCustomerId: `biz-${randomSuffix()}`,
+ region: "US",
+ currencies: "USD,USDC",
+ legalName: "Acme Corporation",
+ doingBusinessAs: "Acme",
+ country: "US",
+ registrationNumber: "5523041",
+ incorporatedOn: "2018-03-14",
+ entityType: "LLC",
+ taxId: "47-1234567",
+ countriesOfOperation: "US",
+ businessType: "INFORMATION",
+ purposeOfAccount: "CONTRACTOR_PAYOUTS",
+ sourceOfFunds: "Funds derived from customer payments for software services",
+ txCount: "COUNT_100_TO_500",
+ txVolume: "VOLUME_100K_TO_1M",
+ recipientJurisdictions: "US,MX",
+ addrLine1: "123 Market Street",
+ addrLine2: "Suite 400",
+ addrCity: "San Francisco",
+ addrState: "CA",
+ addrPostal: "94105",
+ addrCountry: "US",
+ };
+}
+
+function splitCsv(value: string): string[] {
+ return value
+ .split(",")
+ .map((s) => s.trim())
+ .filter(Boolean);
+}
+
+function buildIndividualPayload(form: IndividualForm): Record {
+ const currencies = splitCsv(form.currencies);
+ const payload: Record = {
+ customerType: "INDIVIDUAL",
+ platformCustomerId: form.platformCustomerId.trim(),
+ region: form.region.trim(),
+ fullName: form.fullName.trim(),
+ birthDate: form.birthDate,
+ nationality: form.nationality.trim(),
+ };
+ if (currencies.length) payload.currencies = currencies;
+ if (form.email.trim()) payload.email = form.email.trim();
+ return payload;
+}
+
+function buildBusinessPayload(form: BusinessForm): Record {
+ const currencies = splitCsv(form.currencies);
+ const businessInfo: Record = {
+ legalName: form.legalName.trim(),
+ country: form.country.trim(),
+ registrationNumber: form.registrationNumber.trim(),
+ incorporatedOn: form.incorporatedOn,
+ entityType: form.entityType,
+ taxId: form.taxId.trim(),
+ countriesOfOperation: splitCsv(form.countriesOfOperation),
+ businessType: form.businessType,
+ purposeOfAccount: form.purposeOfAccount,
+ sourceOfFunds: form.sourceOfFunds.trim(),
+ expectedMonthlyTransactionCount: form.txCount,
+ expectedMonthlyTransactionVolume: form.txVolume,
+ expectedRecipientJurisdictions: splitCsv(form.recipientJurisdictions),
+ };
+ if (form.doingBusinessAs.trim())
+ businessInfo.doingBusinessAs = form.doingBusinessAs.trim();
+
+ const address: Record = {
+ line1: form.addrLine1.trim(),
+ city: form.addrCity.trim(),
+ state: form.addrState.trim(),
+ postalCode: form.addrPostal.trim(),
+ country: form.addrCountry.trim(),
+ };
+ if (form.addrLine2.trim()) address.line2 = form.addrLine2.trim();
+
+ const payload: Record = {
+ customerType: "BUSINESS",
+ platformCustomerId: form.platformCustomerId.trim(),
+ region: form.region.trim(),
+ businessInfo,
+ address,
+ };
+ if (currencies.length) payload.currencies = currencies;
+ return payload;
+}
+
+export function App() {
+ const [env, setEnv] = useState(envInitial);
+ const [creds, setCreds] = useState(() =>
+ loadCreds(envInitial()),
+ );
+ const [customerType, setCustomerType] = useState("INDIVIDUAL");
+ const [individual, setIndividual] = useState(
+ defaultIndividual,
+ );
+ const [business, setBusiness] = useState(defaultBusiness);
+ const [customerId, setCustomerId] = useState("");
+ const [redirectUri, setRedirectUri] = useState("");
+ const [flowMode, setFlowMode] = useState("HOSTED");
+ const [kycLink, setKycLink] = useState(null);
+ // SdkLauncher owns its own launched-or-not state so it resets cleanly on
+ // remount (Hosted ⇄ SDK mode cycles, regenerating the link). Lifting it
+ // here would leave a stale `true` value that hides the Launch button and
+ // shows an empty iframe container.
+
+ const [pingStatus, setPingStatus] = useState(null);
+ const [createStatus, setCreateStatus] = useState(null);
+ const [linkStatus, setLinkStatus] = useState(null);
+ const [fetchStatus, setFetchStatus] = useState(null);
+
+ const [log, setLog] = useState([]);
+ const logIdRef = useRef(0);
+
+ // Persist env across reloads; swap creds when env changes.
+ useEffect(() => {
+ sessionStorage.setItem(ENV_STORAGE_KEY, env);
+ setCreds(loadCreds(env));
+ }, [env]);
+
+ // Persist creds synchronously when the user edits them. We can't run this
+ // through a `[creds, env]` effect: that fires once with (oldCreds, newEnv)
+ // mid-transition during an env switch, briefly writing the previous
+ // env's credentials into the new env's storage slot before the next
+ // render corrects it. Driving the write from the input handlers and
+ // `onClearCreds` keeps persistence in lockstep with the action that
+ // caused it, and the env-swap effect above owns its own loadCreds
+ // round-trip.
+ const persistCreds = useCallback(
+ (next: GridCredentials) => {
+ const id = next.id.trim();
+ const secret = next.secret.trim();
+ const key = CREDS_STORAGE_KEY_PREFIX + env;
+ if (!id && !secret) sessionStorage.removeItem(key);
+ else sessionStorage.setItem(key, JSON.stringify({ id, secret }));
+ },
+ [env],
+ );
+
+ const appendLog = useCallback((entry: Omit) => {
+ const id = ++logIdRef.current;
+ setLog((prev) => [{ id, ts: nowTs(), ...entry }, ...prev].slice(0, 100));
+ }, []);
+
+ const runCall = useCallback(
+ async (
+ method: "GET" | "POST",
+ path: string,
+ body?: unknown,
+ ): Promise => {
+ try {
+ const result = await callGrid({ env, creds, method, path, body });
+ appendLog({
+ env,
+ method,
+ path,
+ requestBody: body,
+ status: result.status,
+ responseBody: result.data,
+ });
+ return result.data;
+ } catch (err) {
+ const e = err as Error & { status?: number; body?: unknown };
+ appendLog({
+ env,
+ method,
+ path,
+ requestBody: body,
+ status: e.status,
+ responseBody: e.body,
+ error: e.message,
+ });
+ throw err;
+ }
+ },
+ [env, creds, appendLog],
+ );
+
+ const onPing = useCallback(async () => {
+ try {
+ const data = await runCall<{ data?: unknown[] }>(
+ "GET",
+ "/customers?limit=1",
+ );
+ const count = Array.isArray(data?.data) ? data.data.length : 0;
+ setPingStatus({ kind: "ok", message: `OK — listed ${count} customer(s).` });
+ } catch (err) {
+ setPingStatus({ kind: "err", message: (err as Error).message });
+ }
+ }, [runCall]);
+
+ const onClearCreds = useCallback(() => {
+ const empty = { id: "", secret: "" };
+ setCreds(empty);
+ persistCreds(empty);
+ }, [persistCreds]);
+
+ const onCreateCustomer = useCallback(async () => {
+ try {
+ const payload =
+ customerType === "INDIVIDUAL"
+ ? buildIndividualPayload(individual)
+ : buildBusinessPayload(business);
+ const data = await runCall(
+ "POST",
+ "/customers",
+ payload,
+ );
+ if (data) {
+ setCustomerId(data.id);
+ setCreateStatus({
+ kind: "ok",
+ message: `Created ${data.customerType} customer ${data.id}`,
+ });
+ }
+ } catch (err) {
+ setCreateStatus({ kind: "err", message: (err as Error).message });
+ }
+ }, [customerType, individual, business, runCall]);
+
+ const onGenerateLink = useCallback(async () => {
+ setKycLink(null);
+ try {
+ const id = customerId.trim();
+ if (!id) throw new Error("Customer ID required.");
+ // The hosted-link redirect URI is irrelevant for the embedded-SDK
+ // flow (the SDK runs inline; the page never navigates away), so
+ // omit it when the user is in SDK mode.
+ const body =
+ flowMode === "HOSTED" && redirectUri.trim()
+ ? { redirectUri: redirectUri.trim() }
+ : undefined;
+ const data = await runCall(
+ "POST",
+ `/customers/${encodeURIComponent(id)}/kyc-link`,
+ body,
+ );
+ if (data) {
+ if (flowMode === "SDK" && !data.token) {
+ throw new Error(
+ "Provider returned no SDK access token. " +
+ "Switch to Hosted mode or use a provider that supports embedded SDK.",
+ );
+ }
+ setKycLink(data);
+ setLinkStatus({
+ kind: "ok",
+ message: `Link generated — expires ${data.expiresAt}`,
+ });
+ }
+ } catch (err) {
+ setLinkStatus({ kind: "err", message: (err as Error).message });
+ }
+ }, [customerId, flowMode, redirectUri, runCall]);
+
+ // The Sumsub SDK calls our token-refresh callback when the current
+ // access token expires. We re-call the same endpoint to mint a fresh
+ // one for the same customer.
+ const refreshSdkToken = useCallback(async (): Promise => {
+ const id = customerId.trim();
+ if (!id) throw new Error("Customer ID required.");
+ const data = await runCall(
+ "POST",
+ `/customers/${encodeURIComponent(id)}/kyc-link`,
+ );
+ if (!data?.token) throw new Error("Provider returned no SDK token on refresh");
+ return data.token;
+ }, [customerId, runCall]);
+
+ const onFetchCustomer = useCallback(async () => {
+ try {
+ const id = customerId.trim();
+ if (!id) throw new Error("Customer ID required.");
+ const data = await runCall(
+ "GET",
+ `/customers/${encodeURIComponent(id)}`,
+ );
+ if (data) {
+ const status = data.kycStatus ?? data.kybStatus ?? "(unknown)";
+ setFetchStatus({
+ kind: "ok",
+ message: `${data.customerType} status: ${status}`,
+ });
+ }
+ } catch (err) {
+ setFetchStatus({ kind: "err", message: (err as Error).message });
+ }
+ }, [customerId, runCall]);
+
+ const customerTypeOptions = useMemo(
+ () => [
+ { value: "INDIVIDUAL", label: "INDIVIDUAL — KYC hosted link" },
+ { value: "BUSINESS", label: "BUSINESS — KYB hosted link" },
+ ],
+ [],
+ );
+
+ return (
+
+
+
+ Grid KYC/KYB Demo
+
+ Internal demo tool for exercising the Grid hosted KYC/KYB link
+ API. Everything runs client-side — credentials live in this
+ browser tab only. Requests are proxied through Vite to the
+ selected environment.
+
+
+
+
+
+
+ Environment & credentials
+
+ Credentials are stored per environment in sessionStorage so
+ prod and dev keys don't get mixed up.
+
+
+
+
+
+
+ Environment
+ setEnv(v as GridEnv)}
+ items={[
+ { value: "prod", label: ENV_LABELS.prod },
+ { value: "dev", label: ENV_LABELS.dev },
+ { value: "local", label: ENV_LABELS.local },
+ ]}
+ />
+
+
+
+ API Client ID
+ {
+ const next = { ...creds, id: e.target.value };
+ setCreds(next);
+ persistCreds(next);
+ }}
+ autoComplete="off"
+ />
+
+
+ API Client Secret
+ {
+ const next = { ...creds, secret: e.target.value };
+ setCreds(next);
+ persistCreds(next);
+ }}
+ autoComplete="off"
+ />
+
+
+
+
+
+
+ {pingStatus && (
+
+ )}
+
+
+
+
+
+
+
+ Customer
+
+ The customer type determines the create payload and whether the
+ link is KYC (individual) or KYB (business). Either way the link
+ is generated by POST /customers/<id>/kyc-link.
+
+
+
+
+
+
+ Customer type
+ setCustomerType(v as CustomerType)}
+ items={customerTypeOptions}
+ />
+
+
+ {customerType === "INDIVIDUAL" ? (
+
+ ) : (
+
+ )}
+
+
+
+
+
+
+
+ Run the flow
+
+
+
+
+
+ {createStatus && (
+
+ )}
+
+
+
+
+ Customer ID
+ setCustomerId(e.target.value)}
+ placeholder="auto-filled from Create Customer"
+ />
+
+
+ Verification mode
+ setFlowMode(v as FlowMode)}
+ items={[
+ { value: "HOSTED", label: "Hosted link (open in new tab)" },
+ { value: "SDK", label: "Embedded SDK (Sumsub WebSDK inline)" },
+ ]}
+ />
+
+ Both modes call the same /kyc-link endpoint —
+ hosted mode uses the returned kycUrl, embedded
+ mode uses the returned provider token via
+ Sumsub's WebSDK script.
+
+
+ {flowMode === "HOSTED" && (
+
+ Redirect URI (optional)
+ setRedirectUri(e.target.value)}
+ placeholder="https://app.example.com/onboarding/done"
+ />
+
+ Where Sumsub sends the customer after the hosted flow. Must
+ be https://; Sumsub rejects http://{" "}
+ and localhost URLs. Leave blank to use Sumsub's default
+ post-flow page.
+
+
+ )}
+
+ {linkStatus && (
+
+ )}
+ {kycLink && flowMode === "HOSTED" && (
+
+ )}
+ {kycLink && flowMode === "SDK" && (
+
+ )}
+
+
+
+
+ {fetchStatus && (
+
+ )}
+
+
+
+
+
+
+
+ Response log
+
+ Most recent first. Cleared on reload.
+
+
+
+
+ {log.length === 0 ? (
+ No requests yet.
+ ) : (
+
+ {log.map((entry) => (
+
+ ))}
+
+ )}
+
+
+
+
+ );
+}
+
+function IndividualFields({
+ form,
+ onChange,
+}: {
+ form: IndividualForm;
+ onChange: (next: IndividualForm) => void;
+}) {
+ const set = (
+ key: K,
+ value: IndividualForm[K],
+ ) => onChange({ ...form, [key]: value });
+ return (
+ <>
+
+
+ Platform customer ID
+ set("platformCustomerId", e.target.value)}
+ />
+
+
+ Region (ISO 3166-1)
+ set("region", e.target.value)}
+ />
+
+
+
+
+ Full name
+ set("fullName", e.target.value)}
+ />
+
+
+ Birth date
+ set("birthDate", e.target.value)}
+ />
+
+
+
+
+ Nationality (ISO 3166-1)
+ set("nationality", e.target.value)}
+ />
+
+
+ Email (optional)
+ set("email", e.target.value)}
+ />
+
+
+
+ Currencies (comma-separated, optional)
+ set("currencies", e.target.value)}
+ />
+
+ >
+ );
+}
+
+function BusinessFields({
+ form,
+ onChange,
+}: {
+ form: BusinessForm;
+ onChange: (next: BusinessForm) => void;
+}) {
+ const set = (key: K, value: BusinessForm[K]) =>
+ onChange({ ...form, [key]: value });
+ return (
+ <>
+
+
+ Platform customer ID
+ set("platformCustomerId", e.target.value)}
+ />
+
+
+ Region (ISO 3166-1)
+ set("region", e.target.value)}
+ />
+
+
+
+ Currencies (comma-separated, optional)
+ set("currencies", e.target.value)}
+ />
+
+
+ Business info
+
+
+ Legal name
+ set("legalName", e.target.value)}
+ />
+
+
+ Doing business as (optional)
+ set("doingBusinessAs", e.target.value)}
+ />
+
+
+
+
+ Country of incorporation
+ set("country", e.target.value)}
+ />
+
+
+ Registration number
+ set("registrationNumber", e.target.value)}
+ />
+
+
+
+
+ Incorporated on
+ set("incorporatedOn", e.target.value)}
+ />
+
+
+ Entity type
+ set("entityType", v)}
+ items={ENTITY_TYPES.map((v) => ({ value: v, label: v }))}
+ />
+
+
+
+
+ Tax ID
+ set("taxId", e.target.value)}
+ />
+
+
+ Countries of operation
+ set("countriesOfOperation", e.target.value)}
+ />
+
+
+
+
+ Business type
+ set("businessType", v)}
+ items={BUSINESS_TYPES.map((v) => ({ value: v, label: v }))}
+ />
+
+
+ Purpose of account
+ set("purposeOfAccount", v)}
+ items={PURPOSE_OF_ACCOUNT.map((v) => ({ value: v, label: v }))}
+ />
+
+
+
+ Source of funds
+ set("sourceOfFunds", e.target.value)}
+ />
+
+
+
+ Expected monthly tx count
+ set("txCount", v)}
+ items={TX_COUNT.map((v) => ({ value: v, label: v }))}
+ />
+
+
+ Expected monthly tx volume
+ set("txVolume", v)}
+ items={TX_VOLUME.map((v) => ({ value: v, label: v }))}
+ />
+
+
+
+ Recipient jurisdictions
+ set("recipientJurisdictions", e.target.value)}
+ />
+
+
+ Business address
+
+
+ Line 1
+ set("addrLine1", e.target.value)}
+ />
+
+
+ Line 2 (optional)
+ set("addrLine2", e.target.value)}
+ />
+
+
+
+
+ City
+ set("addrCity", e.target.value)}
+ />
+
+
+ State
+ set("addrState", e.target.value)}
+ />
+
+
+
+
+ Postal code
+ set("addrPostal", e.target.value)}
+ />
+
+
+ Country (ISO 3166-1)
+ set("addrCountry", e.target.value)}
+ />
+
+
+ >
+ );
+}
+
+function SelectControl({
+ value,
+ onValueChange,
+ items,
+}: {
+ value: string;
+ onValueChange: (next: string) => void;
+ items: { value: string; label: string }[];
+}) {
+ return (
+ {
+ if (next != null) onValueChange(next);
+ }}
+ >
+
+
+ {(v: string) => items.find((i) => i.value === v)?.label ?? v}
+
+
+
+
+
+
+
+ {items.map((item) => (
+
+
+ {item.label}
+
+ ))}
+
+
+
+
+
+ );
+}
+
+function KycLinkResult({ result }: { result: KycLinkResponse }) {
+ const [copied, setCopied] = useState(false);
+ return (
+
+
+ {result.provider}
+ expires {result.expiresAt}
+
+ {result.kycUrl}
+
+
+
+
+ {result.token && (
+
+ Provider token (consumed by embedded SDK mode):{" "}
+ {result.token.slice(0, 32)}…
+
+ )}
+
+ );
+}
+
+function SdkLauncher({
+ token,
+ provider,
+ onTokenRefresh,
+}: {
+ token: string;
+ provider: string;
+ onTokenRefresh: () => Promise;
+}) {
+ const containerRef = useRef(null);
+ const [sdkError, setSdkError] = useState(null);
+ // Owned here (not lifted) so the launched state resets every time the
+ // launcher remounts — toggling Hosted ⇄ SDK mode or regenerating the
+ // link always lands back at the Launch button, never a stale empty
+ // iframe container.
+ const [launched, setLaunched] = useState(false);
+
+ // Tear down the embedded SDK when the launcher unmounts (switching back
+ // to Hosted mode, regenerating the link, etc.). Sumsub's WebSDK builds
+ // its iframe + event listeners inside the container we hand it; without
+ // an explicit cleanup those would leak. The SDK doesn't expose a public
+ // destroy API, so we clear the container's children — which removes the
+ // iframe (and the listeners it owns) deterministically.
+ useEffect(() => {
+ const container = containerRef.current;
+ return () => {
+ if (container) container.replaceChildren();
+ };
+ }, []);
+
+ const launch = useCallback(() => {
+ const sdk = snsWebSdk;
+ if (sdk === undefined) {
+ setSdkError(
+ "Sumsub WebSDK script didn't load — check the
+