Skip to content

Commit fe795ed

Browse files
author
Pulkit Pareek
committed
Fix: hosted bank demo registration stalled after QR1 on prod
The demo-portal three-QR signup renders QR2/QR3 by peeking the plaintext codes the server minted for the phone, from an in-memory cache. That cache was gated `NODE_ENV !== 'production'`, so on the hosted prod box it stayed empty: the SPA never got QR2/QR3, the phone could never commit, no tenant_user was created, and sign-in returned pairing_did_unknown. Gate the cache tenant-scoped instead (shouldCacheDemoCode): populate for the demo-portal tenant in any env (+ all non-prod envs for local dev). Real production tenants still never populate it — their codes stay private to the phone, exactly as the module's doc comment always claimed. Tests missed this because they run under NODE_ENV=test (gate was true). Added regression tests pinning the demo tenant caches under prod and a real tenant does not. Gate: tsc + eslint + jest (54 suites, 626 passed) green.
1 parent da1c1de commit fe795ed

3 files changed

Lines changed: 95 additions & 6 deletions

File tree

‎src/routes/demo-portal.ts‎

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1485,9 +1485,10 @@ router.post('/sessions/:id/claim', async (req: Request, res: Response) => {
14851485
// below are thin wrappers around the existing /v1/registrations
14861486
// service, plus a peek endpoint that lets the SPA see each freshly
14871487
// minted plaintext code so it can re-render the next QR. The peek
1488-
// only reads from the in-memory demo cache (registration.ts) which
1489-
// is populated only in non-production NODE_ENVs — production tenants
1490-
// remain unaffected.
1488+
// only reads from the in-memory demo cache (registration.ts), which is
1489+
// populated for the demo-portal tenant (any env) + all non-prod envs —
1490+
// see registration.ts::shouldCacheDemoCode. Real production tenants
1491+
// never populate it, so their codes stay private to the phone.
14911492
//
14921493
import {
14931494
startRegistration,

‎src/services/registration.ts‎

Lines changed: 23 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,7 @@ import {
7373
sha256Hex,
7474
} from './device-enrollment';
7575
import { recordAuditEvent } from './platform';
76+
import { DEMO_PORTAL_TENANT_ID } from './demo-portal-seed';
7677
import {
7778
ApiKeyEnvironment,
7879
Device,
@@ -135,6 +136,25 @@ export function clearPendingDemoCode(sessionId: string): void {
135136
demoPendingCodes.delete(sessionId);
136137
}
137138

139+
/**
140+
* Whether to populate the demo-code peek cache for [tenantId].
141+
*
142+
* The peek cache lets the demo-portal SPA render QR2/QR3 by reading the
143+
* plaintext codes the server minted for the phone. It must NOT be
144+
* populated for real tenants (they host their own signup page and never
145+
* peek), so historically it was gated on `NODE_ENV !== 'production'`.
146+
*
147+
* That gate broke the HOSTED bank demo: the demo-portal tenant runs on a
148+
* `NODE_ENV=production` box, so the cache stayed empty, the SPA couldn't
149+
* show QR2/QR3, and registration stalled after QR1 (no commit → no user
150+
* → sign-in `pairing_did_unknown`). The correct gate is tenant-scoped —
151+
* exactly what this module's doc comment always claimed: cache only for
152+
* the demo-portal tenant (plus all non-prod envs for local dev).
153+
*/
154+
export function shouldCacheDemoCode(tenantId: string): boolean {
155+
return process.env.NODE_ENV !== 'production' || tenantId === DEMO_PORTAL_TENANT_ID;
156+
}
157+
138158
/**
139159
* Length of the challenge nonce baked into QR3. 62 hex chars = 31 bytes.
140160
*
@@ -333,7 +353,7 @@ export async function startRegistration(
333353
}).catch(err => logger.warn('Failed to record audit event', { error: (err as Error).message }));
334354

335355
const pairDeeplink = `zeroauth://reg?step=pair&session=${session.id}&code=${encodeURIComponent(code)}`;
336-
if (process.env.NODE_ENV !== 'production') {
356+
if (shouldCacheDemoCode(tenantId)) {
337357
cachePendingDemoCode(session.id, { step: 'pair', code, deeplink: pairDeeplink });
338358
}
339359

@@ -449,7 +469,7 @@ export async function pairDeviceForRegistration(input: {
449469
}).catch(err => logger.warn('Failed to record audit event', { error: (err as Error).message }));
450470

451471
const enrollDeeplink = `zeroauth://reg?step=enroll&session=${session.id}&code=${encodeURIComponent(nextCode)}`;
452-
if (process.env.NODE_ENV !== 'production') {
472+
if (shouldCacheDemoCode(session.tenant_id)) {
453473
cachePendingDemoCode(session.id, { step: 'enroll', code: nextCode, deeplink: enrollDeeplink });
454474
}
455475
return {
@@ -555,7 +575,7 @@ export async function submitCommitmentForRegistration(input: {
555575
}).catch(err => logger.warn('Failed to record audit event', { error: (err as Error).message }));
556576

557577
const verifyDeeplink = `zeroauth://reg?step=verify&session=${session.id}&code=${encodeURIComponent(nextCode)}&challenge=${challengeNonce}`;
558-
if (process.env.NODE_ENV !== 'production') {
578+
if (shouldCacheDemoCode(session.tenant_id)) {
559579
cachePendingDemoCode(session.id, { step: 'verify', code: nextCode, deeplink: verifyDeeplink, challengeNonce });
560580
}
561581
return {

‎tests/registration-flow.test.ts‎

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,10 +36,13 @@ import {
3636
completeRegistration,
3737
getRegistrationSession,
3838
pairDeviceForRegistration,
39+
peekPendingDemoCode,
3940
RegistrationStateError,
41+
shouldCacheDemoCode,
4042
startRegistration,
4143
submitCommitmentForRegistration,
4244
} from '../src/services/registration';
45+
import { DEMO_PORTAL_TENANT_ID } from '../src/services/demo-portal-seed';
4346

4447
const TENANT = 'tenant-A';
4548
const ENV = 'live' as const;
@@ -401,3 +404,68 @@ describe('RegistrationStateError reasons', () => {
401404
expect(e.message).toBe('challenge_mismatch');
402405
});
403406
});
407+
408+
// ─── Regression: demo-code peek cache gating (prod hosted demo) ─────────
409+
//
410+
// The hosted bank demo (zeroauth.dev/bank-demo) runs the demo-portal
411+
// tenant on a NODE_ENV=production box. The SPA renders QR2/QR3 by peeking
412+
// the plaintext codes the server minted for the phone. That peek cache
413+
// was gated `NODE_ENV !== 'production'`, so on prod it stayed empty →
414+
// QR2/QR3 never rendered → registration stalled after QR1 → no user →
415+
// sign-in `pairing_did_unknown`. Tests missed it because they run under
416+
// NODE_ENV=test (gate was true). These pin the tenant-scoped gate.
417+
418+
describe('demo-code peek cache gating (shouldCacheDemoCode)', () => {
419+
const origEnv = process.env.NODE_ENV;
420+
afterEach(() => { process.env.NODE_ENV = origEnv; });
421+
422+
it('caches for ANY tenant when not in production (local dev / tests)', () => {
423+
process.env.NODE_ENV = 'test';
424+
expect(shouldCacheDemoCode('some-real-tenant')).toBe(true);
425+
expect(shouldCacheDemoCode(DEMO_PORTAL_TENANT_ID)).toBe(true);
426+
});
427+
428+
it('caches for the demo-portal tenant in production (hosted bank demo)', () => {
429+
process.env.NODE_ENV = 'production';
430+
expect(shouldCacheDemoCode(DEMO_PORTAL_TENANT_ID)).toBe(true);
431+
});
432+
433+
it('does NOT cache for a real tenant in production (codes stay private to the phone)', () => {
434+
process.env.NODE_ENV = 'production';
435+
expect(shouldCacheDemoCode('some-real-tenant')).toBe(false);
436+
});
437+
438+
it('regression: startRegistration for the demo tenant under prod populates the peek cache', async () => {
439+
process.env.NODE_ENV = 'production';
440+
const sid = 'demo-prod-sess-regression';
441+
mockQuery.mockResolvedValueOnce({
442+
rows: [{ id: sid, tenant_id: DEMO_PORTAL_TENANT_ID, environment: 'live', state: 'awaiting_device', profile: {} }],
443+
rowCount: 1,
444+
});
445+
await startRegistration(
446+
DEMO_PORTAL_TENANT_ID,
447+
'live',
448+
{ profile: { name: 'Demo', email: 'demo@neobank.example' } },
449+
{ type: 'api_key', id: null },
450+
);
451+
const cached = peekPendingDemoCode(sid);
452+
expect(cached).not.toBeNull();
453+
expect(cached?.step).toBe('pair');
454+
});
455+
456+
it('regression: startRegistration for a REAL tenant under prod does NOT populate the cache', async () => {
457+
process.env.NODE_ENV = 'production';
458+
const sid = 'real-prod-sess-regression';
459+
mockQuery.mockResolvedValueOnce({
460+
rows: [{ id: sid, tenant_id: 'real-tenant-xyz', environment: 'live', state: 'awaiting_device', profile: {} }],
461+
rowCount: 1,
462+
});
463+
await startRegistration(
464+
'real-tenant-xyz',
465+
'live',
466+
{ profile: { name: 'Real', email: 'real@bank.example' } },
467+
{ type: 'api_key', id: 'k-real' },
468+
);
469+
expect(peekPendingDemoCode(sid)).toBeNull();
470+
});
471+
});

0 commit comments

Comments
 (0)