Skip to content

Commit 8e39425

Browse files
author
Pulkit Pareek
committed
dashboard: three-QR signup ceremony demo at /demo/registration
End-user-visible counterpart to the ADR 0023 backend. The operator opens the route, types a name + email, clicks "Open session & mint QR1", and the page walks through three QR steps: 1. QR1 ("Pair your phone") — encodes the pair-step deeplink. A phone scanning the QR (or, in this demo, the simulator panel) POSTs to /v1/registrations/pair-device with the code + a hardware fingerprint. Server flips state to awaiting_commitment and mints the enroll_code. 2. QR2 ("Submit your biometric commitment") — the phone captures the biometric locally (mobile/biometric/ pipeline), computes the Poseidon commitment + DID, scans QR2 to POST them to /v1/registrations/submit-commitment. State flips to awaiting_verification; server mints verify_code + a 128-bit challenge_nonce baked into QR3's deeplink. 3. QR3 ("Verify and create account") — phone re-captures the biometric, produces a Groth16 proof, scans QR3 to POST to /v1/registrations/complete. Server checks the challenge_nonce matches, asserts publicSignals[0] equals the stored commitment, verifies the proof off-chain, creates the tenant_user. The biometric never crosses a wire. Only the commitment (step 2) and the proof (step 3) do. The deeplink format is zeroauth://reg?step=<pair|enroll|verify>&session=<uuid>&code=<code> [&challenge=<hex>] — same format the android/ companion app handles. Right column is a "Simulate phone" panel that exercises the phone-side endpoints directly so an operator can drive the ceremony from one browser window without an actual companion app. Pair + commit steps run end-to-end against the live backend. The verify step intentionally lands a `verify_failed` because the demo posts a stub Groth16 proof — that's the verifier doing its job. The real green path goes through the android/ mobile prover (Phase 1 Sprint 4 integration). Server side: three new console proxies at /api/console/registrations (POST / GET :id / DELETE :id) mirror /v1/registrations but auth via console JWT. Both surfaces strip pair_code_hash, enroll_code_hash, verify_code_hash, verify_challenge_nonce out of the response shape before it touches the browser — the plaintext codes are returned only at issuance, the challenge nonce travels only in the QR3 deeplink. Nav: "QR sign-in" + "QR signup" now sit side by side under the shared /demo/ namespace; the old singular "Demos" label split to make the two flows distinguishable. Verify: - npx tsc --noEmit (dashboard + backend) clean - npm test (dashboard, vitest) 56/56 - npm test (backend, jest) 524/524 across 44 suites - npm run build (dashboard) 121 modules, QrRegistration lazy chunk = 28.4 kB / 9.95 kB gzip; main bundle unchanged
1 parent d64833c commit 8e39425

5 files changed

Lines changed: 757 additions & 1 deletion

File tree

dashboard/src/App.tsx

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,10 @@ import { NotFound } from './routes/NotFound';
2222
// host today; lazy-loading keeps it (and any future scanner deps) out
2323
// of the main bundle until the operator opens /demo/qr-proof-login.
2424
const QrProofLogin = lazy(() => import('./routes/demo/QrProofLogin'));
25+
// The three-QR end-user signup ceremony demo (ADR 0023). Lazy-loaded
26+
// because qrcode.react (~30kB after tree-shake) only matters when the
27+
// operator opens the demo. The bundle main path never imports it.
28+
const QrRegistration = lazy(() => import('./routes/demo/QrRegistration'));
2529

2630
// Live verifications view (SSE-streamed, ADR 0017 face-first flow).
2731
// Lazy-loaded so the EventSource cost is paid only when the operator
@@ -125,6 +129,14 @@ export function App() {
125129
</RouteSuspense>
126130
}
127131
/>
132+
<Route
133+
path="/demo/registration"
134+
element={
135+
<RouteSuspense>
136+
<QrRegistration />
137+
</RouteSuspense>
138+
}
139+
/>
128140

129141
{/* ADR 0017 face-first views — live SSE counterparts to
130142
the polled /verifications + /users. Both coexist

dashboard/src/components/layout/AppShell.tsx

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,10 @@ const NAV = [
5555
// W3 wrapper demo: desktop QR-proof sign-in (ADR-0009). Sits under
5656
// its own /demo/ namespace so additional wrapper demos can co-locate
5757
// without polluting the primary console nav.
58-
{ to: '/demo/qr-proof-login', label: 'Demos', icon: 'qr' },
58+
{ to: '/demo/qr-proof-login', label: 'QR sign-in', icon: 'qr' },
59+
// ADR 0023 three-QR end-user signup ceremony. Demos sit side-by-side
60+
// under /demo/ so an operator can pick the flow they're presenting.
61+
{ to: '/demo/registration', label: 'QR signup', icon: 'qr' },
5962
{ to: '/settings', label: 'Settings', icon: 'gear' },
6063
] as const;
6164

dashboard/src/lib/api.ts

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -286,6 +286,46 @@ export interface DeviceEnrollmentInvite {
286286
};
287287
}
288288

289+
// ─── Three-QR end-user signup ceremony (ADR 0023) ────────────────
290+
291+
export type RegistrationSessionState =
292+
| 'awaiting_device'
293+
| 'awaiting_commitment'
294+
| 'awaiting_verification'
295+
| 'completed'
296+
| 'abandoned';
297+
298+
/**
299+
* Server-redacted shape of the registration_sessions row. The
300+
* console proxy strips pair_code_hash, enroll_code_hash,
301+
* verify_code_hash, and verify_challenge_nonce before this hits
302+
* the browser — the plaintext codes are returned only at issuance
303+
* (and only to the issuing browser).
304+
*/
305+
export interface RegistrationSession {
306+
id: string;
307+
tenant_id: string;
308+
environment: Environment;
309+
profile: Record<string, unknown>;
310+
state: RegistrationSessionState;
311+
device_id: string | null;
312+
did: string | null;
313+
commitment: string | null;
314+
tenant_user_id: string | null;
315+
pair_code_expires_at: string | null;
316+
enroll_code_expires_at: string | null;
317+
verify_code_expires_at: string | null;
318+
expires_at: string;
319+
created_at: string;
320+
updated_at: string;
321+
}
322+
323+
export interface RegistrationStartResponse {
324+
environment: Environment;
325+
session: RegistrationSession;
326+
pair: { code: string; expires_at: string; deeplink: string };
327+
}
328+
289329
export interface User {
290330
id: string;
291331
external_id: string;
@@ -722,6 +762,52 @@ export const api = {
722762
},
723763
) => request<{ environment: Environment; device: Device }>(`/api/console/devices/${encodeURIComponent(deviceId)}`, { method: 'PATCH', body: input }),
724764

765+
// Registrations — three-QR signup ceremony (ADR 0023). Console
766+
// proxies live at /api/console/registrations/*. The plaintext
767+
// pair_code is returned exactly once on POST; subsequent codes
768+
// (enroll_code, verify_code) only travel from the server to the
769+
// phone via the next-step deeplinks, never to the dashboard.
770+
startRegistration: (input: {
771+
environment: Environment;
772+
profile?: Record<string, unknown>;
773+
}) =>
774+
request<RegistrationStartResponse>('/api/console/registrations', { method: 'POST', body: input }),
775+
pollRegistration: (sessionId: string, params: { environment: Environment }) =>
776+
request<{ environment: Environment; session: RegistrationSession }>(
777+
`/api/console/registrations/${encodeURIComponent(sessionId)}`,
778+
{ query: params },
779+
),
780+
abandonRegistration: (sessionId: string, params: { environment: Environment }) =>
781+
request<{ environment: Environment; session: RegistrationSession }>(
782+
`/api/console/registrations/${encodeURIComponent(sessionId)}`,
783+
{ method: 'DELETE', body: params },
784+
),
785+
// Phone-side endpoints — the demo's "Simulate phone" panel calls
786+
// these directly so the operator can drive the ceremony from one
787+
// browser window without an actual phone. In production the phone
788+
// hits these via /v1/registrations/* on the public origin.
789+
__phonePair: (input: { pair_code: string; fingerprint: string; attestation_kind?: string }) =>
790+
request<{
791+
session_id: string;
792+
device_id: string | null;
793+
next: { step: string; code: string; expires_at: string; deeplink: string };
794+
}>('/v1/registrations/pair-device', { method: 'POST', body: input, auth: false }),
795+
__phoneSubmitCommitment: (input: { enroll_code: string; did: string; commitment: string; attestation_kind?: string }) =>
796+
request<{
797+
session_id: string;
798+
next: { step: string; code: string; expires_at: string; deeplink: string; challenge_nonce: string };
799+
}>('/v1/registrations/submit-commitment', { method: 'POST', body: input, auth: false }),
800+
__phoneComplete: (input: {
801+
verify_code: string;
802+
challenge_nonce: string;
803+
proof: unknown;
804+
public_signals: unknown[];
805+
}) =>
806+
request<{ session_id: string; tenant_user: Record<string, unknown>; device: Record<string, unknown> | null }>(
807+
'/v1/registrations/complete',
808+
{ method: 'POST', body: input, auth: false },
809+
),
810+
725811
// Users
726812
listUsers: (params: { environment: Environment; status?: User['status']; limit?: number }) =>
727813
request<{ environment: Environment; users: User[] }>('/api/console/users', { query: params }),

0 commit comments

Comments
 (0)