Skip to content

Commit 2f6d55d

Browse files
w3(console): proxy /v1/proof-pairing/* through /api/console
The W3 desktop QR-proof sign-in demo (ADR-0009) needs the dashboard to drive four endpoints — create session, submit proof, subscribe to the SSE stream, fetch the current session — without holding a tenant API key in the browser. Wire those as transparent proxies under /api/console/proof-pairing/* gated by the console JWT. The proxies forward to ${PAIRING_UPSTREAM_BASE}/v1/proof-pairing/* (defaults to config.apiBaseUrl — same Node process in single-VPS deployments). Cookies round-trip both ways: incoming Cookie header to upstream, outgoing Set-Cookie path-rewritten from /v1/proof-pairing/ to /api/console/proof-pairing/ so the dashboard browser ships the zeroauth_pair_bind cookie back on subsequent reads. SSE is piped through with text/event-stream, X-Accel-Buffering: no, and a 25s heartbeat. Client disconnects abort the upstream fetch so the backend stops pushing into the void. Until /v1/proof-pairing/* ships, ECONNREFUSED surfaces as 503 pairing_backend_unavailable so the dashboard can hint at MOCK mode. The CSP imgSrc allow-list also gains api.qrserver.com — the only new external host the demo page touches (no extra JS dep in the dashboard bundle). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 31a4353 commit 2f6d55d

2 files changed

Lines changed: 328 additions & 1 deletion

File tree

‎src/app.ts‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,12 @@ export function createApp() {
5757
scriptSrc: ["'self'", "'unsafe-inline'"],
5858
styleSrc: ["'self'", "'unsafe-inline'", 'https://fonts.googleapis.com'],
5959
fontSrc: ["'self'", 'https://fonts.gstatic.com'],
60-
imgSrc: ["'self'", 'data:'],
60+
// QR images for the W3 QR-proof sign-in demo come from
61+
// api.qrserver.com (no extra JS dependency in the dashboard
62+
// bundle, see dashboard/src/routes/demo/QrProofLogin.tsx).
63+
// Strictly limited to that one host so the imgSrc relaxation
64+
// doesn't accumulate.
65+
imgSrc: ["'self'", 'data:', 'https://api.qrserver.com'],
6166
connectSrc: connectSources,
6267
},
6368
},

‎src/routes/console.ts‎

Lines changed: 322 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -843,4 +843,326 @@ router.get('/attendance', requireConsoleAuth, async (req: Request, res: Response
843843
}
844844
});
845845

846+
// ─── Proof pairing (W3 wrapper demo) ───────────────────────────────
847+
//
848+
// The dashboard's QR-proof sign-in page (dashboard/src/routes/demo/
849+
// QrProofLogin.tsx) talks to these five endpoints. They forward — same
850+
// shape — to the tenant-scoped /v1/proof-pairing/* layer that ADR-0009
851+
// owns. The console JWT gates access; the upstream /v1 layer enforces
852+
// the actual scope checks (`proof_pairing:create`, `proof_pairing:claim`).
853+
//
854+
// Backend contract is in docs/api_contract.md → Proof pairing section.
855+
//
856+
// Why a fetch-proxy and not a direct service call (like devices/users):
857+
// the /v1/proof-pairing surface manages its OWN session_bind cookie —
858+
// that cookie's value is bound to the issuing browser, not the server-
859+
// side tenant id, so it has to round-trip through the upstream layer.
860+
// We pipe Set-Cookie from upstream straight to the dashboard browser
861+
// (Path remapped from /v1/proof-pairing/ to /api/console/proof-pairing/
862+
// so the browser ships the cookie on subsequent /api/console/* calls)
863+
// and we forward the inbound Cookie header back upstream on every
864+
// follow-up read.
865+
//
866+
// SSE: we set text/event-stream + flushHeaders, then pipe the upstream
867+
// body straight to the response. Caddy + Node both flush by default
868+
// once content-type is set; the explicit flushHeaders() is belt-and-
869+
// braces against an absent X-Accel-Buffering on some proxies.
870+
//
871+
// When `/v1/proof-pairing/*` is not yet mounted (backend feature flag
872+
// or the W3 backend hasn't merged) the upstream returns 404 and we
873+
// pass it through verbatim. The dashboard runs an opt-in MOCK mode
874+
// (VITE_PAIRING_MOCK=1) that bypasses these routes entirely while the
875+
// real backend lands.
876+
877+
const PAIRING_UPSTREAM_BASE = (() => {
878+
// Loopback by default. Same-process call goes through the local
879+
// express server, which means the /v1 layer sees the same Node
880+
// instance. Override via PAIRING_UPSTREAM_BASE for split deployments
881+
// (api.zeroauth.dev when console.zeroauth.dev is on a different VPS).
882+
return (process.env.PAIRING_UPSTREAM_BASE ?? config.apiBaseUrl).replace(/\/$/, '');
883+
})();
884+
885+
const PAIRING_INTERNAL_HEADER = 'x-zeroauth-console-proxy';
886+
887+
// The dashboard browser sees the cookie at /api/console/proof-pairing/*
888+
// (because that's the path it called); the upstream /v1 layer sets it
889+
// at Path=/v1/proof-pairing/. We rewrite the Path attribute when piping
890+
// Set-Cookie back. Domain is left untouched — the upstream knows whether
891+
// it's same-host with the dashboard or a cross-subdomain deployment.
892+
function rewriteSetCookiePath(setCookie: string | string[] | undefined): string[] {
893+
if (!setCookie) return [];
894+
const items = Array.isArray(setCookie) ? setCookie : [setCookie];
895+
return items.map((raw) =>
896+
raw.replace(
897+
/Path=\/v1\/proof-pairing\//gi,
898+
'Path=/api/console/proof-pairing/',
899+
),
900+
);
901+
}
902+
903+
// The `Response` symbol is shadowed by the Express type import at the top
904+
// of this file; `FetchResponse` keeps the DOM/fetch Response distinct.
905+
type FetchResponse = globalThis.Response;
906+
907+
interface PairingProxyResult {
908+
status: number;
909+
headers: Headers;
910+
// For SSE we want the raw ReadableStream; for JSON the parsed body.
911+
body: FetchResponse['body'];
912+
}
913+
914+
async function pairingFetch(
915+
pathSuffix: string,
916+
init: {
917+
method: 'GET' | 'POST' | 'DELETE';
918+
headers?: Record<string, string>;
919+
body?: string;
920+
forwardCookie?: string;
921+
signal?: AbortSignal;
922+
},
923+
tenantContext: { tenantId: string; email: string; environment: ApiKeyEnvironment },
924+
): Promise<PairingProxyResult> {
925+
const url = `${PAIRING_UPSTREAM_BASE}/v1/proof-pairing/${pathSuffix.replace(/^\//, '')}`;
926+
const headers: Record<string, string> = {
927+
[PAIRING_INTERNAL_HEADER]: '1',
928+
'x-zeroauth-tenant-id': tenantContext.tenantId,
929+
'x-zeroauth-tenant-environment': tenantContext.environment,
930+
'x-zeroauth-console-actor': tenantContext.email,
931+
...(init.headers ?? {}),
932+
};
933+
if (init.forwardCookie) {
934+
headers.cookie = init.forwardCookie;
935+
}
936+
const upstream = await fetch(url, {
937+
method: init.method,
938+
headers,
939+
body: init.body,
940+
signal: init.signal,
941+
redirect: 'manual',
942+
});
943+
return {
944+
status: upstream.status,
945+
headers: upstream.headers,
946+
body: upstream.body,
947+
};
948+
}
949+
950+
function applyPairingCookies(res: Response, upstreamHeaders: Headers): void {
951+
// node-undici's Headers exposes getSetCookie() since Node 19.7.
952+
type GetSetCookieSupportingHeaders = Headers & { getSetCookie?: () => string[] };
953+
const headersWithSetCookie = upstreamHeaders as GetSetCookieSupportingHeaders;
954+
const rawSetCookie = typeof headersWithSetCookie.getSetCookie === 'function'
955+
? headersWithSetCookie.getSetCookie()
956+
: upstreamHeaders.get('set-cookie');
957+
const rewritten = rewriteSetCookiePath(rawSetCookie ?? undefined);
958+
if (rewritten.length > 0) {
959+
res.setHeader('Set-Cookie', rewritten);
960+
}
961+
}
962+
963+
async function pipeJson(
964+
res: Response,
965+
upstream: PairingProxyResult,
966+
): Promise<void> {
967+
applyPairingCookies(res, upstream.headers);
968+
const text = upstream.body ? await new Response(upstream.body).text() : '';
969+
res.status(upstream.status);
970+
const contentType = upstream.headers.get('content-type');
971+
if (contentType) res.setHeader('Content-Type', contentType);
972+
res.send(text);
973+
}
974+
975+
function buildPairingError(status: number, code: string, message: string): { status: number; body: { error: string; message: string } } {
976+
return { status, body: { error: code, message } };
977+
}
978+
979+
function handleUpstreamFailure(err: unknown, res: Response, op: string): void {
980+
const code = (err as { code?: string }).code;
981+
// ECONNREFUSED — backend not running. Surface 503 so the dashboard
982+
// can render the "backend offline; enable VITE_PAIRING_MOCK=1" hint.
983+
if (code === 'ECONNREFUSED' || code === 'ENOTFOUND' || code === 'ECONNRESET') {
984+
logger.warn(`Console: pairing ${op} — upstream unavailable`, { code });
985+
res.status(503).json({
986+
error: 'pairing_backend_unavailable',
987+
message: 'Proof-pairing backend is not reachable. Enable VITE_PAIRING_MOCK=1 to drive the demo locally without it.',
988+
});
989+
return;
990+
}
991+
logger.error(`Console: pairing ${op} failed`, { error: (err as Error).message });
992+
res.status(502).json({ error: 'pairing_upstream_failed', message: 'Failed to reach the proof-pairing backend.' });
993+
}
994+
995+
router.post('/proof-pairing/sessions', requireConsoleAuth, consoleWriteLimiter, async (req: Request, res: Response) => {
996+
try {
997+
const { tenantId, email } = (req as any).console;
998+
const environment = parseEnv(req.body?.environment ?? req.query.environment);
999+
const bodyPayload = { ...(req.body ?? {}), environment };
1000+
const upstream = await pairingFetch(
1001+
'sessions',
1002+
{
1003+
method: 'POST',
1004+
headers: { 'content-type': 'application/json' },
1005+
body: JSON.stringify(bodyPayload),
1006+
forwardCookie: req.headers.cookie,
1007+
},
1008+
{ tenantId, email, environment },
1009+
);
1010+
await pipeJson(res, upstream);
1011+
} catch (err) {
1012+
handleUpstreamFailure(err, res, 'createSession');
1013+
}
1014+
});
1015+
1016+
router.post('/proof-pairing/sessions/:id/submit', requireConsoleAuth, consoleWriteLimiter, async (req: Request, res: Response) => {
1017+
try {
1018+
const { tenantId, email } = (req as any).console;
1019+
const environment = parseEnv(req.body?.environment ?? req.query.environment);
1020+
const { id } = req.params;
1021+
if (!/^[0-9a-fA-F-]{8,64}$/.test(id)) {
1022+
const err = buildPairingError(400, 'invalid_session_id', 'Session id is not a valid UUID.');
1023+
res.status(err.status).json(err.body);
1024+
return;
1025+
}
1026+
const upstream = await pairingFetch(
1027+
`sessions/${encodeURIComponent(id)}/submit`,
1028+
{
1029+
method: 'POST',
1030+
headers: { 'content-type': 'application/json' },
1031+
body: JSON.stringify(req.body ?? {}),
1032+
forwardCookie: req.headers.cookie,
1033+
},
1034+
{ tenantId, email, environment },
1035+
);
1036+
await pipeJson(res, upstream);
1037+
} catch (err) {
1038+
handleUpstreamFailure(err, res, 'submitProof');
1039+
}
1040+
});
1041+
1042+
router.get('/proof-pairing/sessions/:id', requireConsoleAuth, async (req: Request, res: Response) => {
1043+
try {
1044+
const { tenantId, email } = (req as any).console;
1045+
const environment = parseEnv(req.query.environment);
1046+
const { id } = req.params;
1047+
if (!/^[0-9a-fA-F-]{8,64}$/.test(id)) {
1048+
const err = buildPairingError(400, 'invalid_session_id', 'Session id is not a valid UUID.');
1049+
res.status(err.status).json(err.body);
1050+
return;
1051+
}
1052+
const upstream = await pairingFetch(
1053+
`sessions/${encodeURIComponent(id)}`,
1054+
{
1055+
method: 'GET',
1056+
forwardCookie: req.headers.cookie,
1057+
},
1058+
{ tenantId, email, environment },
1059+
);
1060+
await pipeJson(res, upstream);
1061+
} catch (err) {
1062+
handleUpstreamFailure(err, res, 'getSession');
1063+
}
1064+
});
1065+
1066+
router.delete('/proof-pairing/sessions/:id', requireConsoleAuth, consoleWriteLimiter, async (req: Request, res: Response) => {
1067+
try {
1068+
const { tenantId, email } = (req as any).console;
1069+
const environment = parseEnv(req.query.environment);
1070+
const { id } = req.params;
1071+
if (!/^[0-9a-fA-F-]{8,64}$/.test(id)) {
1072+
const err = buildPairingError(400, 'invalid_session_id', 'Session id is not a valid UUID.');
1073+
res.status(err.status).json(err.body);
1074+
return;
1075+
}
1076+
const upstream = await pairingFetch(
1077+
`sessions/${encodeURIComponent(id)}`,
1078+
{
1079+
method: 'DELETE',
1080+
forwardCookie: req.headers.cookie,
1081+
},
1082+
{ tenantId, email, environment },
1083+
);
1084+
await pipeJson(res, upstream);
1085+
} catch (err) {
1086+
handleUpstreamFailure(err, res, 'cancelSession');
1087+
}
1088+
});
1089+
1090+
router.get('/proof-pairing/sessions/:id/stream', requireConsoleAuth, async (req: Request, res: Response) => {
1091+
const { tenantId, email } = (req as any).console;
1092+
const environment = parseEnv(req.query.environment);
1093+
const { id } = req.params;
1094+
if (!/^[0-9a-fA-F-]{8,64}$/.test(id)) {
1095+
res.status(400).json({ error: 'invalid_session_id', message: 'Session id is not a valid UUID.' });
1096+
return;
1097+
}
1098+
1099+
// Tie upstream cancellation to client disconnect.
1100+
const abortController = new AbortController();
1101+
req.on('close', () => abortController.abort());
1102+
1103+
let upstream: PairingProxyResult;
1104+
try {
1105+
upstream = await pairingFetch(
1106+
`sessions/${encodeURIComponent(id)}/stream`,
1107+
{
1108+
method: 'GET',
1109+
forwardCookie: req.headers.cookie,
1110+
signal: abortController.signal,
1111+
},
1112+
{ tenantId, email, environment },
1113+
);
1114+
} catch (err) {
1115+
handleUpstreamFailure(err, res, 'subscribeStream');
1116+
return;
1117+
}
1118+
1119+
if (upstream.status >= 400) {
1120+
await pipeJson(res, upstream);
1121+
return;
1122+
}
1123+
1124+
// SSE pipe-through. We set the response headers ourselves rather than
1125+
// copying upstream's wholesale — Express handles compression/encoding
1126+
// for the response object, and we want to keep the stream uncompressed.
1127+
applyPairingCookies(res, upstream.headers);
1128+
res.writeHead(200, {
1129+
'Content-Type': 'text/event-stream; charset=utf-8',
1130+
'Cache-Control': 'no-cache, no-transform',
1131+
Connection: 'keep-alive',
1132+
// Some reverse proxies buffer text/event-stream by default; the
1133+
// X-Accel-Buffering header turns that off for nginx and Caddy
1134+
// honours it via its FastCGI translator.
1135+
'X-Accel-Buffering': 'no',
1136+
});
1137+
1138+
// Heartbeat — every 25s send a comment so transparent proxies don't
1139+
// GC the connection during long no-event stretches.
1140+
const heartbeat = setInterval(() => {
1141+
if (!res.writableEnded) res.write(': ping\n\n');
1142+
}, 25_000);
1143+
1144+
try {
1145+
if (upstream.body) {
1146+
// Node 20 has Web ReadableStream-to-Node-stream interop via
1147+
// Readable.fromWeb, but pipeline accepts either; the simplest
1148+
// approach is the async iterator.
1149+
const reader = upstream.body.getReader();
1150+
while (!res.writableEnded) {
1151+
const { value, done } = await reader.read();
1152+
if (done) break;
1153+
if (value && !res.writableEnded) res.write(value);
1154+
}
1155+
}
1156+
} catch (err) {
1157+
if (!abortController.signal.aborted) {
1158+
logger.warn('Console: pairing SSE pipe error', { error: (err as Error).message });
1159+
}
1160+
} finally {
1161+
clearInterval(heartbeat);
1162+
if (!res.writableEnded) {
1163+
res.end();
1164+
}
1165+
}
1166+
});
1167+
8461168
export default router;

0 commit comments

Comments
 (0)