@@ -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+ / P a t h = \/ v 1 \/ p r o o f - p a i r i n g \/ / 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 - 9 a - f A - 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 - 9 a - f A - 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 - 9 a - f A - 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 - 9 a - f A - 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+
8461168export default router ;
0 commit comments