@vielzeug/archive 26.10.13 → 26.10.14

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,2 +1,2 @@
1
- const e=require("../_crypto.cjs"),t=require("../contract.cjs"),n=require("../worker/_service.cjs"),r=require("../worker/http.cjs"),i=require("./errors.cjs"),a=require("./store.cjs"),o=require("./_confirmation.cjs"),s=require("./config.cjs");let c=require("jose");var l={account:/^\/api\/v1\/auth\/account$/,health:/^\/api\/v1\/auth\/health$/,jwks:/^\/api\/v1\/auth\/jwks\.json$/,session:/^\/api\/v1\/auth\/session$/,signIn:/^\/api\/v1\/auth\/sign-in$/,signOutAll:/^\/api\/v1\/auth\/sign-out-all$/,verify:/^\/api\/v1\/auth\/verify$/},u={account:[`GET`,`DELETE`],health:[`GET`],jwks:[`GET`],session:[`GET`],signIn:[`POST`],signOutAll:[`POST`],verify:[`GET`,`POST`]},d={account:`/auth/account`,health:`/auth/health`,jwks:`/auth/jwks.json`,session:`/auth/session`,signIn:`/auth/sign-in`,signOutAll:`/auth/sign-out-all`,verify:`/auth/verify`},matchRoute=e=>Object.keys(l).find(t=>l[t].test(new URL(e.url).pathname)),f=32,p=/^[A-Za-z0-9_-]{43}$/,m=/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/,h=256,g=5,randomToken=()=>btoa(String.fromCharCode(...crypto.getRandomValues(new Uint8Array(f)))).replaceAll(`+`,`-`).replaceAll(`/`,`_`).replace(/=+$/,``),unavailable=()=>new i.AuthOperationError(503,`catalog`);function requireMutationGuard(e,t,n=!1){let i=e.headers.get(`origin`);if(i===null||!t.allowedOrigins.has(i)&&!(n&&i===t.publicOrigin)||e.headers.get(`x-archive-request`)!==`1`)throw r.forbidden();return i}function corsHeaders(e,t){let n=e.headers.get(`origin`);return n===null||!t.allowedOrigins.has(n)&&(matchRoute(e)!==`verify`||n!==t.publicOrigin)?{}:{"access-control-allow-credentials":`true`,"access-control-allow-origin":n,vary:`Origin`}}function preflight(e,n,i){let a=corsHeaders(e,n);if(Object.keys(a).length===0)throw r.forbidden();return new Response(null,{headers:{...a,"access-control-allow-headers":`Content-Type, ${t.ACCOUNT_HEADER}, ${t.PUBLISHER_HEADER}, ${t.REQUEST_MARKER_HEADER}`,"access-control-allow-methods":[...i,`OPTIONS`].join(`, `),"access-control-max-age":`600`},status:204})}var secure=e=>e.appEnv===`production`;async function mintSession(e,n,r,i){let a=r?900:t.SESSION_MAX_AGE_SECONDS;return{maxAge:a,token:await new c.SignJWT({sessionVersion:i,...r?{roles:[t.ARCHIVE_OPERATOR_ROLE]}:{}}).setProtectedHeader({alg:`RS256`,kid:e.signing.kid,typ:`JWT`}).setIssuedAt().setJti(crypto.randomUUID()).setIssuer(e.issuer).setAudience(e.audience).setSubject(n).setExpirationTime(`${a}s`).sign(e.signing.key)}}async function requireSession(e,n,i){let o=r.sessionToken(e);if(o===null)throw r.unauthenticated();try{let{payload:e}=await(0,c.jwtVerify)(o,n.verifier,{algorithms:[`RS256`],audience:n.audience,clockTolerance:g,issuer:n.issuer,maxTokenAge:t.SESSION_MAX_AGE_SECONDS,requiredClaims:[`sub`,`iss`,`aud`,`iat`,`exp`,`sessionVersion`]}),s=e.sub??``;if(s.length===0||s.length>h||!t.isWellFormedText(s))throw r.unauthenticated();let l=e.sessionVersion;if(typeof l!=`number`||!Number.isSafeInteger(l)||l<0||typeof e.iat!=`number`||e.iat>Date.now()/1e3+g||Array.isArray(e.roles)&&e.roles.includes(`archive-operator`)&&(typeof e.exp!=`number`||e.exp-e.iat>900||Date.now()/1e3-e.iat>900+g))throw r.unauthenticated();let u=await a.readAccount(i.AUTH_DB,s);if(u===null||u.session_version!==l)throw r.unauthenticated();return{sessionVersion:l,subject:s,token:o}}catch(e){throw e instanceof c.errors.JOSEError?r.unauthenticated():e}}async function sendLink(e,t,n,r){let i=t.appName,s=`The link works once and expires in ${a.LINK_TTL_MS/6e4} minutes. If you did not request it, ignore this email.`;await e.AUTH_EMAIL.send({from:t.senderAddress,html:`<p>Open this link to sign in to ${o.escapeHtml(i)}:</p><p><a href="${o.escapeHtml(r)}">${o.escapeHtml(r)}</a></p><p>${s}</p>`,subject:`Your ${i} sign-in link`,text:`Open this link to sign in to ${i}:\n\n${r}\n\n${s}`,to:n})}async function handleSignIn(t,n,o){requireMutationGuard(t,o);let c=await r.readJsonBody(t);if(typeof c!=`object`||!c||Array.isArray(c))throw r.badRequest();let l=Object.keys(c),u=Reflect.get(c,`email`);if(l.length!==1||l[0]!==`email`||typeof u!=`string`)throw r.badRequest();let d=u.trim().toLowerCase();if(!s.isWellFormedEmail(d))throw r.badRequest();let f=Date.now(),p=await a.spendSignInBudget(n.AUTH_DB,f);if(p!==null)throw new r.ApiError(429,`rate-limited`,`Too many sign-in requests. Try again later.`,{retryAfterSeconds:p});let m=await a.ensureUser(n.AUTH_DB,d),h=await a.spendLinkBudget(n.AUTH_DB,m.id,f);if(h!==null)throw new r.ApiError(429,`rate-limited`,`Too many sign-in emails. Try again later.`,{retryAfterSeconds:h});let g=randomToken(),_=await e.sha256Hex(g);await a.insertToken(n.AUTH_DB,_,m.id,f);try{await sendLink(n,o,m.email,`${o.publicOrigin}/api/v1/auth/verify?token=${g}`)}catch(e){throw await a.deleteToken(n.AUTH_DB,_).catch(()=>void 0),new i.AuthOperationError(503,`email`,{cause:e})}return r.json(202,{ok:!0},r.noStore)}async function handleVerify(n,i,s){if(n.method===`GET`){let e=r.queryParams(new URL(n.url),[`token`]).get(`token`)??``;if(!p.test(e))throw r.badRequest(`The link is not valid.`);return o.confirmationPage({...s,nonce:randomToken(),token:e})}requireMutationGuard(n,s,!0),r.queryParams(new URL(n.url),[]);let c=await r.readJsonBody(n);if(typeof c!=`object`||!c||Array.isArray(c)||Object.keys(c).length!==1)throw r.badRequest(`The link is not valid.`);let l=Reflect.get(c,`token`);if(typeof l!=`string`||!p.test(l))throw r.badRequest(`The link is not valid.`);let u=await a.consumeToken(i.AUTH_DB,await e.sha256Hex(l),Date.now());if(u===null)throw new r.ApiError(410,`gone`,`That link has expired or was already used.`);let d=await mintSession(s,u.id,s.operatorEmails.has(u.email),u.sessionVersion);return new Response(null,{headers:{...r.noStore,"set-cookie":t.sessionCookieValue(d.token,{maxAgeSeconds:d.maxAge,secure:secure(s)})},status:204})}async function handleExport(e,t,n){let{subject:i}=await requireSession(e,n,t),o=await a.readAccount(t.AUTH_DB,i);if(o===null)throw r.unauthenticated();return r.json(200,{createdAt:o.created_at,email:o.email,id:o.id,lastSignInAt:o.last_sign_in_at},r.noStore)}async function archiveFailure(e){let n=await e.text().catch(()=>``),i=null;try{i=JSON.parse(n)}catch{i=null}let a=t.archiveErrorResponseSchema.safeParse(i);if(!a.success||e.status<400||e.status>599)throw unavailable();let{code:o,message:s,requestId:c}=a.data.error,l=e.headers.get(`retry-after`);return r.json(e.status,{error:{code:o,message:s,requestId:c}},l!==null&&/^\d{1,6}$/.test(l)?{...r.noStore,"retry-after":l}:r.noStore)}async function handleErase(e,o,s){let c=requireMutationGuard(e,s),{subject:l,token:u}=await requireSession(e,s,o),d=e.headers.get(t.ACCOUNT_HEADER);if(d===null||!t.uuidSchema.safeParse(d).success)throw r.badRequest();if(d!==l)throw new r.ApiError(409,`account-changed`,`The signed-in account changed since this request was prepared.`);let f=new Headers({cookie:`${t.SESSION_COOKIE_NAME}=${u}`,origin:c,[t.REQUEST_MARKER_HEADER]:`1`}),p=e.headers.get(t.PUBLISHER_HEADER);if(p!==null){if(!m.test(p))throw r.badRequest();f.set(t.PUBLISHER_HEADER,p)}let h;try{h=await n.serviceRequest(o.AUTH_ARCHIVE,new Request(`${s.publicOrigin}/api/v1/me/account`,{headers:f,method:`DELETE`,redirect:`manual`}),async e=>e.status===204?(await e.body?.cancel(),e):archiveFailure(e))}catch(e){throw new i.AuthOperationError(503,`catalog`,{cause:e})}return h.status===204?(await a.deleteAccount(o.AUTH_DB,l),new Response(null,{headers:{...r.noStore,"set-cookie":r.expiredSessionCookie(secure(s))},status:204})):h}async function handleSignOutAll(e,n,i){requireMutationGuard(e,i);let{subject:o,sessionVersion:s}=await requireSession(e,i,n),c=e.headers.get(t.ACCOUNT_HEADER);if(c===null||!t.uuidSchema.safeParse(c).success)throw r.badRequest();if(c!==o)throw new r.ApiError(409,`account-changed`,`The signed-in account changed since this request was prepared.`);if(await a.revokeSessions(n.AUTH_DB,o,s)===null)throw r.unauthenticated();return r.json(200,{ok:!0},{...r.noStore,"set-cookie":r.expiredSessionCookie(secure(i))})}async function handleHealth(e){let n=null,i=null;try{await s.authConfig(e)}catch{n=`configuration-unavailable`}if(n===null)try{i=await a.authReadiness(e.AUTH_DB),i!==1&&(n=`schema-mismatch`)}catch{n=`database-unavailable`}return r.json(n===null?200:503,t.archiveAuthHealthSchema.parse({dbSchemaVersion:i,reason:n,schemaVersion:1,status:n===null?`ok`:`degraded`}),r.noStore)}async function route(e,t){let n=e.method.toUpperCase(),i=matchRoute(e);if(i===void 0)throw new r.ApiError(404,`not-found`,`No such route.`);if(i===`health`&&n===`GET`)return handleHealth(t);let a=await s.authConfig(t),o=corsHeaders(e,a);if(n===`OPTIONS`)return preflight(e,a,u[i]);if(!u[i].includes(n))throw new r.ApiError(405,`method-not-allowed`,`That method is not allowed here.`);let c;switch(i){case`health`:c=await handleHealth(t);break;case`session`:await requireSession(e,a,t),c=r.json(200,{ok:!0},r.noStore);break;case`signOutAll`:c=await handleSignOutAll(e,t,a);break;case`jwks`:c=r.json(200,{keys:a.jwks},r.noStore);break;case`signIn`:c=await handleSignIn(e,t,a);break;case`verify`:c=await handleVerify(e,t,a);break;case`account`:c=n===`GET`?await handleExport(e,t,a):await handleErase(e,t,a)}return withCors(c,o)}function withCors(e,t){if(Object.keys(t).length===0)return e;let n=new Response(e.body,e);for(let[e,r]of Object.entries(t))n.headers.set(e,r);return n}var diagnostic=e=>e instanceof i.AuthOperationError?e.authDiagnostic:{kind:`internal`};function createAuthWorker(){let e=new Set,emit=t=>{for(let n of e)try{n(t)}catch{}};return{async fetch(n,i){let a=crypto.randomUUID(),c=Date.now(),l,u;try{l=await route(n,i)}catch(e){u=e,l=r.toErrorResponse(e,a);let t=await s.authConfig(i).catch(()=>null);t!==null&&(matchRoute(n)===`verify`&&n.method===`GET`&&e instanceof r.ApiError&&e.status===400&&(l=o.confirmationPage({...t,nonce:randomToken(),token:null})),l=withCors(l,corsHeaders(n,t)))}if(matchRoute(n)===`verify`&&(l.headers.set(`cache-control`,`no-store`),l.headers.set(`referrer-policy`,`no-referrer`),l.headers.set(`x-content-type-options`,`nosniff`),l.headers.has(`content-security-policy`)||l.headers.set(`content-security-policy`,`default-src 'none'; base-uri 'none'; frame-ancestors 'none'`)),e.size>0){let e=matchRoute(n),i=l.status<400?`ok`:e===`health`?`unavailable`:u instanceof r.ApiError?u.code:`internal`,o=u===void 0&&l.status>=400?t.archiveErrorResponseSchema.safeParse(await l.clone().json().catch(()=>null)):null,s=e===`health`&&l.status===503?t.archiveAuthHealthSchema.safeParse(await l.clone().json().catch(()=>null)):null;emit({code:o?.success?o.data.error.code:i,ms:Math.max(0,Date.now()-c),requestId:a,route:e===void 0?`unmatched`:d[e],status:l.status,type:`request`,...l.status>=500?{diagnostic:s?.success?{kind:s.data.reason===`configuration-unavailable`?`configuration`:s.data.reason===`schema-mismatch`?`schema`:`database`}:u===void 0?{kind:`catalog`}:diagnostic(u)}:{}})}return l},async scheduled(t,n){try{await s.authConfig(n);let t=await a.sweep(n.AUTH_DB,Date.now());e.size>0&&emit({removed:t,status:`ok`,type:`scheduled`})}catch(t){throw e.size>0&&emit({diagnostic:diagnostic(t),status:`failed`,type:`scheduled`}),t}},tap(t,n){if(n?.signal?.aborted)return()=>void 0;let observer=e=>t(e),unsubscribe=()=>{e.delete(observer),n?.signal?.removeEventListener(`abort`,unsubscribe)};return e.add(observer),n?.signal?.addEventListener(`abort`,unsubscribe,{once:!0}),unsubscribe}}}exports.createAuthWorker=createAuthWorker;
1
+ const e=require("../_crypto.cjs"),t=require("../contract.cjs"),n=require("../worker/_service.cjs"),r=require("../worker/http.cjs"),i=require("./errors.cjs"),a=require("./store.cjs"),o=require("./_confirmation.cjs"),s=require("./config.cjs");let c=require("jose");var l={account:/^\/api\/v1\/auth\/account$/,health:/^\/api\/v1\/auth\/health$/,jwks:/^\/api\/v1\/auth\/jwks\.json$/,session:/^\/api\/v1\/auth\/session$/,signIn:/^\/api\/v1\/auth\/sign-in$/,signOutAll:/^\/api\/v1\/auth\/sign-out-all$/,verify:/^\/api\/v1\/auth\/verify$/},u={account:[`GET`,`DELETE`],health:[`GET`],jwks:[`GET`],session:[`GET`],signIn:[`POST`],signOutAll:[`POST`],verify:[`GET`,`POST`]},d={account:`/auth/account`,health:`/auth/health`,jwks:`/auth/jwks.json`,session:`/auth/session`,signIn:`/auth/sign-in`,signOutAll:`/auth/sign-out-all`,verify:`/auth/verify`},matchRoute=e=>Object.keys(l).find(t=>l[t].test(new URL(e.url).pathname)),f=32,p=/^[A-Za-z0-9_-]{43}$/,m=/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/,h=256,g=5,randomToken=()=>btoa(String.fromCharCode(...crypto.getRandomValues(new Uint8Array(f)))).replaceAll(`+`,`-`).replaceAll(`/`,`_`).replace(/=+$/,``),unavailable=()=>new i.AuthOperationError(503,`catalog`);function requireMutationGuard(e,t,n=!1){let i=e.headers.get(`origin`);if(i===null||!t.allowedOrigins.has(i)&&!(n&&i===t.publicOrigin)||e.headers.get(`x-archive-request`)!==`1`)throw r.forbidden();return i}function corsHeaders(e,t){let n=e.headers.get(`origin`);return n===null||!t.allowedOrigins.has(n)&&(matchRoute(e)!==`verify`||n!==t.publicOrigin)?{}:{"access-control-allow-credentials":`true`,"access-control-allow-origin":n,vary:`Origin`}}function preflight(e,n,i){let a=corsHeaders(e,n);if(Object.keys(a).length===0)throw r.forbidden();return new Response(null,{headers:{...a,"access-control-allow-headers":`Content-Type, ${t.ACCOUNT_HEADER}, ${t.PUBLISHER_HEADER}, ${t.REQUEST_MARKER_HEADER}`,"access-control-allow-methods":[...i,`OPTIONS`].join(`, `),"access-control-max-age":`600`},status:204})}var secure=e=>e.appEnv===`production`;async function mintSession(e,n,r,i){let a=r?t.OPERATOR_SESSION_MAX_AGE_SECONDS:t.SESSION_MAX_AGE_SECONDS;return{maxAge:a,token:await new c.SignJWT({sessionVersion:i,...r?{roles:[t.ARCHIVE_OPERATOR_ROLE]}:{}}).setProtectedHeader({alg:`RS256`,kid:e.signing.kid,typ:`JWT`}).setIssuedAt().setJti(crypto.randomUUID()).setIssuer(e.issuer).setAudience(e.audience).setSubject(n).setExpirationTime(`${a}s`).sign(e.signing.key)}}async function requireSession(e,n,i){let o=r.sessionToken(e);if(o===null)throw r.unauthenticated();try{let{payload:e}=await(0,c.jwtVerify)(o,n.verifier,{algorithms:[`RS256`],audience:n.audience,clockTolerance:g,issuer:n.issuer,maxTokenAge:t.SESSION_MAX_AGE_SECONDS,requiredClaims:[`sub`,`iss`,`aud`,`iat`,`exp`,`sessionVersion`]}),s=e.sub??``;if(s.length===0||s.length>h||!t.isWellFormedText(s))throw r.unauthenticated();let l=e.sessionVersion;if(typeof l!=`number`||!Number.isSafeInteger(l)||l<0||typeof e.iat!=`number`||e.iat>Date.now()/1e3+g||Array.isArray(e.roles)&&e.roles.includes(`archive-operator`)&&(typeof e.exp!=`number`||e.exp-e.iat>3600||Date.now()/1e3-e.iat>3600+g))throw r.unauthenticated();let u=await a.readAccount(i.AUTH_DB,s);if(u===null||u.session_version!==l)throw r.unauthenticated();return{sessionVersion:l,subject:s,token:o}}catch(e){throw e instanceof c.errors.JOSEError?r.unauthenticated():e}}async function sendLink(e,t,n,r){let i=t.appName,s=`The link works once and expires in ${a.LINK_TTL_MS/6e4} minutes. If you did not request it, ignore this email.`;await e.AUTH_EMAIL.send({from:t.senderAddress,html:`<p>Open this link to sign in to ${o.escapeHtml(i)}:</p><p><a href="${o.escapeHtml(r)}">${o.escapeHtml(r)}</a></p><p>${s}</p>`,subject:`Your ${i} sign-in link`,text:`Open this link to sign in to ${i}:\n\n${r}\n\n${s}`,to:n})}async function handleSignIn(t,n,o){requireMutationGuard(t,o);let c=await r.readJsonBody(t);if(typeof c!=`object`||!c||Array.isArray(c))throw r.badRequest();let l=Object.keys(c),u=Reflect.get(c,`email`);if(l.length!==1||l[0]!==`email`||typeof u!=`string`)throw r.badRequest();let d=u.trim().toLowerCase();if(!s.isWellFormedEmail(d))throw r.badRequest();let f=Date.now(),p=await a.spendSignInBudget(n.AUTH_DB,f);if(p!==null)throw new r.ApiError(429,`rate-limited`,`Too many sign-in requests. Try again later.`,{retryAfterSeconds:p});let m=await a.ensureUser(n.AUTH_DB,d),h=await a.spendLinkBudget(n.AUTH_DB,m.id,f);if(h!==null)throw new r.ApiError(429,`rate-limited`,`Too many sign-in emails. Try again later.`,{retryAfterSeconds:h});let g=randomToken(),_=await e.sha256Hex(g);await a.insertToken(n.AUTH_DB,_,m.id,f);try{await sendLink(n,o,m.email,`${o.publicOrigin}/api/v1/auth/verify?token=${g}`)}catch(e){throw await a.deleteToken(n.AUTH_DB,_).catch(()=>void 0),new i.AuthOperationError(503,`email`,{cause:e})}return r.json(202,{ok:!0},r.noStore)}async function handleVerify(n,i,s){if(n.method===`GET`){let e=r.queryParams(new URL(n.url),[`token`]).get(`token`)??``;if(!p.test(e))throw r.badRequest(`The link is not valid.`);return o.confirmationPage({...s,nonce:randomToken(),token:e})}requireMutationGuard(n,s,!0),r.queryParams(new URL(n.url),[]);let c=await r.readJsonBody(n);if(typeof c!=`object`||!c||Array.isArray(c)||Object.keys(c).length!==1)throw r.badRequest(`The link is not valid.`);let l=Reflect.get(c,`token`);if(typeof l!=`string`||!p.test(l))throw r.badRequest(`The link is not valid.`);let u=await a.consumeToken(i.AUTH_DB,await e.sha256Hex(l),Date.now());if(u===null)throw new r.ApiError(410,`gone`,`That link has expired or was already used.`);let d=await mintSession(s,u.id,s.operatorEmails.has(u.email),u.sessionVersion);return new Response(null,{headers:{...r.noStore,"set-cookie":t.sessionCookieValue(d.token,{maxAgeSeconds:d.maxAge,secure:secure(s)})},status:204})}async function handleExport(e,t,n){let{subject:i}=await requireSession(e,n,t),o=await a.readAccount(t.AUTH_DB,i);if(o===null)throw r.unauthenticated();return r.json(200,{createdAt:o.created_at,email:o.email,id:o.id,lastSignInAt:o.last_sign_in_at},r.noStore)}async function archiveFailure(e){let n=await e.text().catch(()=>``),i=null;try{i=JSON.parse(n)}catch{i=null}let a=t.archiveErrorResponseSchema.safeParse(i);if(!a.success||e.status<400||e.status>599)throw unavailable();let{code:o,message:s,requestId:c}=a.data.error,l=e.headers.get(`retry-after`);return r.json(e.status,{error:{code:o,message:s,requestId:c}},l!==null&&/^\d{1,6}$/.test(l)?{...r.noStore,"retry-after":l}:r.noStore)}async function handleErase(e,o,s){let c=requireMutationGuard(e,s),{subject:l,token:u}=await requireSession(e,s,o),d=e.headers.get(t.ACCOUNT_HEADER);if(d===null||!t.uuidSchema.safeParse(d).success)throw r.badRequest();if(d!==l)throw new r.ApiError(409,`account-changed`,`The signed-in account changed since this request was prepared.`);let f=new Headers({cookie:`${t.SESSION_COOKIE_NAME}=${u}`,origin:c,[t.REQUEST_MARKER_HEADER]:`1`}),p=e.headers.get(t.PUBLISHER_HEADER);if(p!==null){if(!m.test(p))throw r.badRequest();f.set(t.PUBLISHER_HEADER,p)}let h;try{h=await n.serviceRequest(o.AUTH_ARCHIVE,new Request(`${s.publicOrigin}/api/v1/me/account`,{headers:f,method:`DELETE`,redirect:`manual`}),async e=>e.status===204?(await e.body?.cancel(),e):archiveFailure(e))}catch(e){throw new i.AuthOperationError(503,`catalog`,{cause:e})}return h.status===204?(await a.deleteAccount(o.AUTH_DB,l),new Response(null,{headers:{...r.noStore,"set-cookie":r.expiredSessionCookie(secure(s))},status:204})):h}async function handleSignOutAll(e,n,i){requireMutationGuard(e,i);let{subject:o,sessionVersion:s}=await requireSession(e,i,n),c=e.headers.get(t.ACCOUNT_HEADER);if(c===null||!t.uuidSchema.safeParse(c).success)throw r.badRequest();if(c!==o)throw new r.ApiError(409,`account-changed`,`The signed-in account changed since this request was prepared.`);if(await a.revokeSessions(n.AUTH_DB,o,s)===null)throw r.unauthenticated();return r.json(200,{ok:!0},{...r.noStore,"set-cookie":r.expiredSessionCookie(secure(i))})}async function handleHealth(e){let n=null,i=null;try{await s.authConfig(e)}catch{n=`configuration-unavailable`}if(n===null)try{i=await a.authReadiness(e.AUTH_DB),i!==1&&(n=`schema-mismatch`)}catch{n=`database-unavailable`}return r.json(n===null?200:503,t.archiveAuthHealthSchema.parse({dbSchemaVersion:i,reason:n,schemaVersion:1,status:n===null?`ok`:`degraded`}),r.noStore)}async function route(e,t){let n=e.method.toUpperCase(),i=matchRoute(e);if(i===void 0)throw new r.ApiError(404,`not-found`,`No such route.`);if(i===`health`&&n===`GET`)return handleHealth(t);let a=await s.authConfig(t),o=corsHeaders(e,a);if(n===`OPTIONS`)return preflight(e,a,u[i]);if(!u[i].includes(n))throw new r.ApiError(405,`method-not-allowed`,`That method is not allowed here.`);let c;switch(i){case`health`:c=await handleHealth(t);break;case`session`:await requireSession(e,a,t),c=r.json(200,{ok:!0},r.noStore);break;case`signOutAll`:c=await handleSignOutAll(e,t,a);break;case`jwks`:c=r.json(200,{keys:a.jwks},r.noStore);break;case`signIn`:c=await handleSignIn(e,t,a);break;case`verify`:c=await handleVerify(e,t,a);break;case`account`:c=n===`GET`?await handleExport(e,t,a):await handleErase(e,t,a)}return withCors(c,o)}function withCors(e,t){if(Object.keys(t).length===0)return e;let n=new Response(e.body,e);for(let[e,r]of Object.entries(t))n.headers.set(e,r);return n}var diagnostic=e=>e instanceof i.AuthOperationError?e.authDiagnostic:{kind:`internal`};function createAuthWorker(){let e=new Set,emit=t=>{for(let n of e)try{n(t)}catch{}};return{async fetch(n,i){let a=crypto.randomUUID(),c=Date.now(),l,u;try{l=await route(n,i)}catch(e){u=e,l=r.toErrorResponse(e,a);let t=await s.authConfig(i).catch(()=>null);t!==null&&(matchRoute(n)===`verify`&&n.method===`GET`&&e instanceof r.ApiError&&e.status===400&&(l=o.confirmationPage({...t,nonce:randomToken(),token:null})),l=withCors(l,corsHeaders(n,t)))}if(matchRoute(n)===`verify`&&(l.headers.set(`cache-control`,`no-store`),l.headers.set(`referrer-policy`,`no-referrer`),l.headers.set(`x-content-type-options`,`nosniff`),l.headers.has(`content-security-policy`)||l.headers.set(`content-security-policy`,`default-src 'none'; base-uri 'none'; frame-ancestors 'none'`)),e.size>0){let e=matchRoute(n),i=l.status<400?`ok`:e===`health`?`unavailable`:u instanceof r.ApiError?u.code:`internal`,o=u===void 0&&l.status>=400?t.archiveErrorResponseSchema.safeParse(await l.clone().json().catch(()=>null)):null,s=e===`health`&&l.status===503?t.archiveAuthHealthSchema.safeParse(await l.clone().json().catch(()=>null)):null;emit({code:o?.success?o.data.error.code:i,ms:Math.max(0,Date.now()-c),requestId:a,route:e===void 0?`unmatched`:d[e],status:l.status,type:`request`,...l.status>=500?{diagnostic:s?.success?{kind:s.data.reason===`configuration-unavailable`?`configuration`:s.data.reason===`schema-mismatch`?`schema`:`database`}:u===void 0?{kind:`catalog`}:diagnostic(u)}:{}})}return l},async scheduled(t,n){try{await s.authConfig(n);let t=await a.sweep(n.AUTH_DB,Date.now());e.size>0&&emit({removed:t,status:`ok`,type:`scheduled`})}catch(t){throw e.size>0&&emit({diagnostic:diagnostic(t),status:`failed`,type:`scheduled`}),t}},tap(t,n){if(n?.signal?.aborted)return()=>void 0;let observer=e=>t(e),unsubscribe=()=>{e.delete(observer),n?.signal?.removeEventListener(`abort`,unsubscribe)};return e.add(observer),n?.signal?.addEventListener(`abort`,unsubscribe,{once:!0}),unsubscribe}}}exports.createAuthWorker=createAuthWorker;
2
2
  //# sourceMappingURL=worker.cjs.map
@@ -1 +1 @@
1
- {"version":3,"file":"worker.cjs","names":[],"sources":["../../src/auth/worker.ts"],"sourcesContent":["import { errors, jwtVerify, SignJWT } from 'jose';\nimport { sha256Hex } from '../_crypto';\nimport {\n ACCOUNT_HEADER,\n ARCHIVE_OPERATOR_ROLE,\n type ArchiveErrorCode,\n AUTH_SCHEMA_VERSION,\n archiveAuthHealthSchema,\n archiveErrorResponseSchema,\n isWellFormedText,\n OPERATOR_SESSION_MAX_AGE_SECONDS,\n PUBLISHER_HEADER,\n REQUEST_MARKER_HEADER,\n SESSION_COOKIE_NAME,\n SESSION_MAX_AGE_SECONDS,\n sessionCookieValue,\n uuidSchema,\n} from '../contract';\nimport { serviceRequest } from '../worker/_service';\nimport {\n ApiError,\n badRequest,\n expiredSessionCookie,\n forbidden,\n json,\n noStore,\n queryParams,\n readJsonBody,\n sessionToken,\n toErrorResponse,\n unauthenticated,\n} from '../worker/http';\nimport type { ArchiveExecutionContext } from '../worker/worker';\nimport { confirmationPage, escapeHtml } from './_confirmation';\nimport { type AuthConfig, type AuthWorkerEnv, authConfig, isWellFormedEmail } from './config';\nimport { type AuthDiagnostic, AuthOperationError } from './errors';\nimport {\n authReadiness,\n consumeToken,\n deleteAccount,\n deleteToken,\n ensureUser,\n insertToken,\n LINK_TTL_MS,\n readAccount,\n revokeSessions,\n spendLinkBudget,\n spendSignInBudget,\n sweep,\n} from './store';\n\n/**\n * The reference identity issuer: passwordless email accounts. A POST with an email\n * address ensures the account row and sends a single-use magic link; consuming it\n * signs the browser in by setting the `archive_session` cookie a catalog Worker\n * verifies — an RS256 JWT minted by the active private key, verifiable through\n * `/jwks.json` alongside any configured retired public keys. `OPERATOR_EMAILS` is\n * the one operator-membership truth; operator sessions are short-lived so a revoked\n * operator loses authority within minutes. The account routes export the issuer's\n * own record and erase it together with the catalog profile.\n */\nexport interface AuthWorkerHandler<E extends AuthWorkerEnv = AuthWorkerEnv> {\n fetch(request: Request, env: E, ctx?: ArchiveExecutionContext): Promise<Response>;\n /** The cron sweep: expired links, closed send windows and never-verified accounts. */\n scheduled(controller: unknown, env: E, ctx?: ArchiveExecutionContext): Promise<void>;\n tap(handler: (event: AuthEvent) => void, options?: { readonly signal?: AbortSignal }): () => void;\n}\n\nexport type AuthEvent =\n | {\n readonly type: 'request';\n readonly requestId: string;\n readonly route:\n | '/auth/account'\n | '/auth/jwks.json'\n | '/auth/sign-in'\n | '/auth/verify'\n | '/auth/session'\n | '/auth/sign-out-all'\n | '/auth/health'\n | 'unmatched';\n readonly status: number;\n readonly code: ArchiveErrorCode | 'ok';\n readonly ms: number;\n readonly diagnostic?: AuthDiagnostic;\n }\n | { readonly type: 'scheduled'; readonly status: 'ok'; readonly removed: number }\n | { readonly type: 'scheduled'; readonly status: 'failed'; readonly diagnostic: AuthDiagnostic };\n\nconst ROUTES = {\n account: /^\\/api\\/v1\\/auth\\/account$/,\n health: /^\\/api\\/v1\\/auth\\/health$/,\n jwks: /^\\/api\\/v1\\/auth\\/jwks\\.json$/,\n session: /^\\/api\\/v1\\/auth\\/session$/,\n signIn: /^\\/api\\/v1\\/auth\\/sign-in$/,\n signOutAll: /^\\/api\\/v1\\/auth\\/sign-out-all$/,\n verify: /^\\/api\\/v1\\/auth\\/verify$/,\n} as const;\n\nconst ROUTE_METHODS: Record<keyof typeof ROUTES, readonly string[]> = {\n account: ['GET', 'DELETE'],\n health: ['GET'],\n jwks: ['GET'],\n session: ['GET'],\n signIn: ['POST'],\n signOutAll: ['POST'],\n verify: ['GET', 'POST'],\n};\n\nconst ROUTE_LABELS = {\n account: '/auth/account',\n health: '/auth/health',\n jwks: '/auth/jwks.json',\n session: '/auth/session',\n signIn: '/auth/sign-in',\n signOutAll: '/auth/sign-out-all',\n verify: '/auth/verify',\n} as const;\n\nconst matchRoute = (request: Request): keyof typeof ROUTES | undefined =>\n (Object.keys(ROUTES) as (keyof typeof ROUTES)[]).find((id) => ROUTES[id].test(new URL(request.url).pathname));\n\nconst TOKEN_BYTES = 32;\n// Base64url of TOKEN_BYTES random bytes, unpadded.\nconst LINK_TOKEN = /^[A-Za-z0-9_-]{43}$/;\nconst PUBLISHER_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;\nconst SUBJECT_MAX = 256;\nconst CLOCK_TOLERANCE_SECONDS = 5;\n\nconst randomToken = (): string =>\n btoa(String.fromCharCode(...crypto.getRandomValues(new Uint8Array(TOKEN_BYTES))))\n .replaceAll('+', '-')\n .replaceAll('/', '_')\n .replace(/=+$/, '');\n\nconst unavailable = (): ApiError => new AuthOperationError(503, 'catalog');\n\n/** Exact allowed Origin plus the request marker, like the catalog's mutation guard. */\nfunction requireMutationGuard(request: Request, config: AuthConfig, allowPublicOrigin = false): string {\n const origin = request.headers.get('origin');\n if (origin === null || (!config.allowedOrigins.has(origin) && !(allowPublicOrigin && origin === config.publicOrigin)))\n throw forbidden();\n if (request.headers.get(REQUEST_MARKER_HEADER) !== '1') throw forbidden();\n return origin;\n}\n\nfunction corsHeaders(request: Request, config: AuthConfig): Record<string, string> {\n const origin = request.headers.get('origin');\n if (\n origin === null ||\n (!config.allowedOrigins.has(origin) && !(matchRoute(request) === 'verify' && origin === config.publicOrigin))\n )\n return {};\n return { 'access-control-allow-credentials': 'true', 'access-control-allow-origin': origin, vary: 'Origin' };\n}\n\nfunction preflight(request: Request, config: AuthConfig, methods: readonly string[]): Response {\n const cors = corsHeaders(request, config);\n if (Object.keys(cors).length === 0) throw forbidden();\n return new Response(null, {\n headers: {\n ...cors,\n 'access-control-allow-headers': `Content-Type, ${ACCOUNT_HEADER}, ${PUBLISHER_HEADER}, ${REQUEST_MARKER_HEADER}`,\n 'access-control-allow-methods': [...methods, 'OPTIONS'].join(', '),\n 'access-control-max-age': '600',\n },\n status: 204,\n });\n}\n\nconst secure = (config: AuthConfig): boolean => config.appEnv === 'production';\n\nasync function mintSession(\n config: AuthConfig,\n subject: string,\n operator: boolean,\n sessionVersion: number,\n): Promise<{ maxAge: number; token: string }> {\n const maxAge = operator ? OPERATOR_SESSION_MAX_AGE_SECONDS : SESSION_MAX_AGE_SECONDS;\n const token = await new SignJWT({ sessionVersion, ...(operator ? { roles: [ARCHIVE_OPERATOR_ROLE] } : {}) })\n .setProtectedHeader({ alg: 'RS256', kid: config.signing.kid, typ: 'JWT' })\n .setIssuedAt()\n .setJti(crypto.randomUUID())\n .setIssuer(config.issuer)\n .setAudience(config.audience)\n .setSubject(subject)\n .setExpirationTime(`${maxAge}s`)\n .sign(config.signing.key);\n return { maxAge, token };\n}\n\n/** Verifies the request's own session cookie against every published key; returns the token and subject. */\nasync function requireSession(\n request: Request,\n config: AuthConfig,\n env: AuthWorkerEnv,\n): Promise<{ subject: string; token: string; sessionVersion: number }> {\n const token = sessionToken(request);\n if (token === null) throw unauthenticated();\n try {\n const { payload } = await jwtVerify(token, config.verifier, {\n algorithms: ['RS256'],\n audience: config.audience,\n clockTolerance: CLOCK_TOLERANCE_SECONDS,\n issuer: config.issuer,\n maxTokenAge: SESSION_MAX_AGE_SECONDS,\n requiredClaims: ['sub', 'iss', 'aud', 'iat', 'exp', 'sessionVersion'],\n });\n const subject = payload.sub ?? '';\n if (subject.length === 0 || subject.length > SUBJECT_MAX || !isWellFormedText(subject)) throw unauthenticated();\n const version = payload.sessionVersion;\n if (typeof version !== 'number' || !Number.isSafeInteger(version) || version < 0) throw unauthenticated();\n if (typeof payload.iat !== 'number' || payload.iat > Date.now() / 1000 + CLOCK_TOLERANCE_SECONDS)\n throw unauthenticated();\n if (\n Array.isArray(payload.roles) &&\n payload.roles.includes(ARCHIVE_OPERATOR_ROLE) &&\n (typeof payload.exp !== 'number' ||\n payload.exp - payload.iat > OPERATOR_SESSION_MAX_AGE_SECONDS ||\n Date.now() / 1000 - payload.iat > OPERATOR_SESSION_MAX_AGE_SECONDS + CLOCK_TOLERANCE_SECONDS)\n )\n throw unauthenticated();\n const account = await readAccount(env.AUTH_DB, subject);\n if (account === null || account.session_version !== version) throw unauthenticated();\n return { sessionVersion: version, subject, token };\n } catch (error) {\n if (error instanceof errors.JOSEError) throw unauthenticated();\n throw error;\n }\n}\n\nasync function sendLink(env: AuthWorkerEnv, config: AuthConfig, to: string, link: string): Promise<void> {\n const name = config.appName;\n const minutes = LINK_TTL_MS / 60_000;\n const notice = `The link works once and expires in ${minutes} minutes. If you did not request it, ignore this email.`;\n await env.AUTH_EMAIL.send({\n from: config.senderAddress,\n html: `<p>Open this link to sign in to ${escapeHtml(name)}:</p><p><a href=\"${escapeHtml(link)}\">${escapeHtml(link)}</a></p><p>${notice}</p>`,\n subject: `Your ${name} sign-in link`,\n text: `Open this link to sign in to ${name}:\\n\\n${link}\\n\\n${notice}`,\n to,\n });\n}\n\nasync function handleSignIn(request: Request, env: AuthWorkerEnv, config: AuthConfig): Promise<Response> {\n requireMutationGuard(request, config);\n const body = await readJsonBody(request);\n if (typeof body !== 'object' || body === null || Array.isArray(body)) throw badRequest();\n const keys = Object.keys(body);\n const requestedEmail: unknown = Reflect.get(body, 'email');\n if (keys.length !== 1 || keys[0] !== 'email' || typeof requestedEmail !== 'string') {\n throw badRequest();\n }\n const email = requestedEmail.trim().toLowerCase();\n if (!isWellFormedEmail(email)) {\n throw badRequest();\n }\n const nowMs = Date.now();\n const admissionRetry = await spendSignInBudget(env.AUTH_DB, nowMs);\n if (admissionRetry !== null) {\n throw new ApiError(429, 'rate-limited', 'Too many sign-in requests. Try again later.', {\n retryAfterSeconds: admissionRetry,\n });\n }\n const user = await ensureUser(env.AUTH_DB, email);\n const retryAfterSeconds = await spendLinkBudget(env.AUTH_DB, user.id, nowMs);\n if (retryAfterSeconds !== null) {\n throw new ApiError(429, 'rate-limited', 'Too many sign-in emails. Try again later.', { retryAfterSeconds });\n }\n const token = randomToken();\n const tokenHash = await sha256Hex(token);\n await insertToken(env.AUTH_DB, tokenHash, user.id, nowMs);\n try {\n await sendLink(env, config, user.email, `${config.publicOrigin}/api/v1/auth/verify?token=${token}`);\n } catch (error) {\n // An unsent link is useless; the spent allowance is deliberately not refunded.\n await deleteToken(env.AUTH_DB, tokenHash).catch(() => undefined);\n throw new AuthOperationError(503, 'email', { cause: error });\n }\n return json(202, { ok: true }, noStore);\n}\n\nasync function handleVerify(request: Request, env: AuthWorkerEnv, config: AuthConfig): Promise<Response> {\n if (request.method === 'GET') {\n const token = queryParams(new URL(request.url), ['token']).get('token') ?? '';\n if (!LINK_TOKEN.test(token)) throw badRequest('The link is not valid.');\n return confirmationPage({ ...config, nonce: randomToken(), token });\n }\n requireMutationGuard(request, config, true);\n queryParams(new URL(request.url), []);\n const body = await readJsonBody(request);\n if (typeof body !== 'object' || body === null || Array.isArray(body) || Object.keys(body).length !== 1) {\n throw badRequest('The link is not valid.');\n }\n const token: unknown = Reflect.get(body, 'token');\n if (typeof token !== 'string') throw badRequest('The link is not valid.');\n if (!LINK_TOKEN.test(token)) throw badRequest('The link is not valid.');\n const user = await consumeToken(env.AUTH_DB, await sha256Hex(token), Date.now());\n if (user === null) throw new ApiError(410, 'gone', 'That link has expired or was already used.');\n const session = await mintSession(config, user.id, config.operatorEmails.has(user.email), user.sessionVersion);\n return new Response(null, {\n headers: {\n ...noStore,\n 'set-cookie': sessionCookieValue(session.token, { maxAgeSeconds: session.maxAge, secure: secure(config) }),\n },\n status: 204,\n });\n}\n\n/** The issuer's half of a complete export: the account record only, never tokens or budget state. */\nasync function handleExport(request: Request, env: AuthWorkerEnv, config: AuthConfig): Promise<Response> {\n const { subject } = await requireSession(request, config, env);\n const row = await readAccount(env.AUTH_DB, subject);\n if (row === null) throw unauthenticated();\n return json(\n 200,\n { createdAt: row.created_at, email: row.email, id: row.id, lastSignInAt: row.last_sign_in_at },\n noStore,\n );\n}\n\n/** Re-serializes a failed catalog response into the public envelope; anything unrecognizable is 503. */\nasync function archiveFailure(response: Response): Promise<Response> {\n const text = await response.text().catch(() => '');\n let parsed: unknown = null;\n try {\n parsed = JSON.parse(text);\n } catch {\n parsed = null;\n }\n const envelope = archiveErrorResponseSchema.safeParse(parsed);\n if (!envelope.success || response.status < 400 || response.status > 599) throw unavailable();\n const { code, message, requestId } = envelope.data.error;\n const retryAfter = response.headers.get('retry-after');\n return json(\n response.status,\n { error: { code, message, requestId } },\n retryAfter !== null && /^\\d{1,6}$/.test(retryAfter) ? { ...noStore, 'retry-after': retryAfter } : noStore,\n );\n}\n\n/**\n * Full erasure: catalog session revocation and profile erasure first (through the AUTH_ARCHIVE service binding,\n * presenting the caller's own session), then the issuer's account row, and only then\n * the cookie is expired. Any failure keeps the browser session so the same request\n * can be retried; both halves are idempotent, so a retry after a partial success\n * re-runs the catalog no-op and finishes the issuer side. The account precondition\n * pins every attempt to the confirmed issuer subject, even without a publisher.\n */\nasync function handleErase(request: Request, env: AuthWorkerEnv, config: AuthConfig): Promise<Response> {\n const origin = requireMutationGuard(request, config);\n const { subject, token } = await requireSession(request, config, env);\n const account = request.headers.get(ACCOUNT_HEADER);\n if (account === null || !uuidSchema.safeParse(account).success) throw badRequest();\n if (account !== subject) {\n throw new ApiError(409, 'account-changed', 'The signed-in account changed since this request was prepared.');\n }\n const headers = new Headers({\n cookie: `${SESSION_COOKIE_NAME}=${token}`,\n origin,\n [REQUEST_MARKER_HEADER]: '1',\n });\n const publisher = request.headers.get(PUBLISHER_HEADER);\n if (publisher !== null) {\n if (!PUBLISHER_ID.test(publisher)) throw badRequest();\n headers.set(PUBLISHER_HEADER, publisher);\n }\n let archived: Response;\n try {\n archived = await serviceRequest(\n env.AUTH_ARCHIVE,\n new Request(`${config.publicOrigin}/api/v1/me/account`, { headers, method: 'DELETE', redirect: 'manual' }),\n async (response) => {\n if (response.status === 204) {\n await response.body?.cancel();\n return response;\n }\n return archiveFailure(response);\n },\n );\n } catch (error) {\n throw new AuthOperationError(503, 'catalog', { cause: error });\n }\n if (archived.status !== 204) return archived;\n await deleteAccount(env.AUTH_DB, subject);\n return new Response(null, {\n headers: { ...noStore, 'set-cookie': expiredSessionCookie(secure(config)) },\n status: 204,\n });\n}\n\nasync function handleSignOutAll(request: Request, env: AuthWorkerEnv, config: AuthConfig): Promise<Response> {\n requireMutationGuard(request, config);\n const { subject, sessionVersion } = await requireSession(request, config, env);\n const account = request.headers.get(ACCOUNT_HEADER);\n if (account === null || !uuidSchema.safeParse(account).success) throw badRequest();\n if (account !== subject)\n throw new ApiError(409, 'account-changed', 'The signed-in account changed since this request was prepared.');\n if ((await revokeSessions(env.AUTH_DB, subject, sessionVersion)) === null) throw unauthenticated();\n return json(200, { ok: true }, { ...noStore, 'set-cookie': expiredSessionCookie(secure(config)) });\n}\n\nasync function handleHealth(env: AuthWorkerEnv): Promise<Response> {\n let reason: 'configuration-unavailable' | 'database-unavailable' | 'schema-mismatch' | null = null;\n let dbSchemaVersion: number | null = null;\n try {\n await authConfig(env);\n } catch {\n reason = 'configuration-unavailable';\n }\n if (reason === null) {\n try {\n dbSchemaVersion = await authReadiness(env.AUTH_DB);\n if (dbSchemaVersion !== AUTH_SCHEMA_VERSION) reason = 'schema-mismatch';\n } catch {\n reason = 'database-unavailable';\n }\n }\n return json(\n reason === null ? 200 : 503,\n archiveAuthHealthSchema.parse({\n dbSchemaVersion,\n reason,\n schemaVersion: AUTH_SCHEMA_VERSION,\n status: reason === null ? 'ok' : 'degraded',\n }),\n noStore,\n );\n}\nasync function route(request: Request, env: AuthWorkerEnv): Promise<Response> {\n const method = request.method.toUpperCase();\n const routeId = matchRoute(request);\n if (routeId === undefined) throw new ApiError(404, 'not-found', 'No such route.');\n if (routeId === 'health' && method === 'GET') return handleHealth(env);\n const config = await authConfig(env);\n const cors = corsHeaders(request, config);\n if (method === 'OPTIONS') return preflight(request, config, ROUTE_METHODS[routeId]);\n if (!ROUTE_METHODS[routeId].includes(method)) {\n throw new ApiError(405, 'method-not-allowed', 'That method is not allowed here.');\n }\n let response: Response;\n switch (routeId) {\n case 'health':\n response = await handleHealth(env);\n break;\n case 'session':\n await requireSession(request, config, env);\n response = json(200, { ok: true }, noStore);\n break;\n case 'signOutAll':\n response = await handleSignOutAll(request, env, config);\n break;\n case 'jwks':\n response = json(200, { keys: config.jwks }, noStore);\n break;\n case 'signIn':\n response = await handleSignIn(request, env, config);\n break;\n case 'verify':\n response = await handleVerify(request, env, config);\n break;\n case 'account':\n response = method === 'GET' ? await handleExport(request, env, config) : await handleErase(request, env, config);\n break;\n }\n return withCors(response, cors);\n}\n\nfunction withCors(response: Response, cors: Record<string, string>): Response {\n if (Object.keys(cors).length === 0) return response;\n const next = new Response(response.body, response);\n for (const [key, value] of Object.entries(cors)) next.headers.set(key, value);\n return next;\n}\n\nconst diagnostic = (error: unknown): AuthDiagnostic =>\n error instanceof AuthOperationError ? error.authDiagnostic : { kind: 'internal' };\n\n/**\n * Builds the reference identity issuer. Route behavior is fixed; every deployment\n * difference is validated configuration — the app name, trusted origins, audience and\n * issuer claims, signing keys, operator roster, the catalog service binding, and\n * where links and redirects land.\n */\nexport function createAuthWorker<E extends AuthWorkerEnv>(): AuthWorkerHandler<E> {\n const tappers = new Set<(event: AuthEvent) => void>();\n const emit = (event: AuthEvent): void => {\n for (const handler of tappers) {\n try {\n handler(event);\n } catch {\n // Observers never change request or scheduled outcomes.\n }\n }\n };\n return {\n async fetch(request: Request, env: E): Promise<Response> {\n const requestId = crypto.randomUUID();\n const started = Date.now();\n let response: Response;\n let failure: unknown;\n try {\n response = await route(request, env);\n } catch (error) {\n failure = error;\n response = toErrorResponse(error, requestId);\n // Failures stay readable by allowed origins; config faults have no trusted origin set.\n const config = await authConfig(env).catch(() => null);\n if (config !== null) {\n if (\n matchRoute(request) === 'verify' &&\n request.method === 'GET' &&\n error instanceof ApiError &&\n error.status === 400\n )\n response = confirmationPage({ ...config, nonce: randomToken(), token: null });\n response = withCors(response, corsHeaders(request, config));\n }\n }\n if (matchRoute(request) === 'verify') {\n response.headers.set('cache-control', 'no-store');\n response.headers.set('referrer-policy', 'no-referrer');\n response.headers.set('x-content-type-options', 'nosniff');\n if (!response.headers.has('content-security-policy')) {\n response.headers.set(\n 'content-security-policy',\n \"default-src 'none'; base-uri 'none'; frame-ancestors 'none'\",\n );\n }\n }\n if (tappers.size > 0) {\n const routeId = matchRoute(request);\n const code =\n response.status < 400\n ? 'ok'\n : routeId === 'health'\n ? 'unavailable'\n : failure instanceof ApiError\n ? failure.code\n : 'internal';\n // Account erasure may forward a catalog error response rather than throw.\n const forwardedCode =\n failure === undefined && response.status >= 400\n ? archiveErrorResponseSchema.safeParse(\n await response\n .clone()\n .json()\n .catch(() => null),\n )\n : null;\n const health =\n routeId === 'health' && response.status === 503\n ? archiveAuthHealthSchema.safeParse(\n await response\n .clone()\n .json()\n .catch(() => null),\n )\n : null;\n emit({\n code: forwardedCode?.success ? forwardedCode.data.error.code : code,\n ms: Math.max(0, Date.now() - started),\n requestId,\n route: routeId === undefined ? 'unmatched' : ROUTE_LABELS[routeId],\n status: response.status,\n type: 'request',\n ...(response.status >= 500\n ? {\n diagnostic: health?.success\n ? {\n kind:\n health.data.reason === 'configuration-unavailable'\n ? 'configuration'\n : health.data.reason === 'schema-mismatch'\n ? 'schema'\n : 'database',\n }\n : failure === undefined\n ? { kind: 'catalog' as const }\n : diagnostic(failure),\n }\n : {}),\n });\n }\n return response;\n },\n\n async scheduled(_controller: unknown, env: E): Promise<void> {\n try {\n await authConfig(env);\n const removed = await sweep(env.AUTH_DB, Date.now());\n if (tappers.size > 0) emit({ removed, status: 'ok', type: 'scheduled' });\n } catch (error) {\n if (tappers.size > 0) emit({ diagnostic: diagnostic(error), status: 'failed', type: 'scheduled' });\n throw error;\n }\n },\n tap(handler, options) {\n if (options?.signal?.aborted) return () => undefined;\n const observer = (event: AuthEvent): void => handler(event);\n const unsubscribe = (): void => {\n tappers.delete(observer);\n options?.signal?.removeEventListener('abort', unsubscribe);\n };\n tappers.add(observer);\n options?.signal?.addEventListener('abort', unsubscribe, { once: true });\n return unsubscribe;\n },\n };\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"worker.cjs","names":[],"sources":["../../src/auth/worker.ts"],"sourcesContent":["import { errors, jwtVerify, SignJWT } from 'jose';\nimport { sha256Hex } from '../_crypto';\nimport {\n ACCOUNT_HEADER,\n ARCHIVE_OPERATOR_ROLE,\n type ArchiveErrorCode,\n AUTH_SCHEMA_VERSION,\n archiveAuthHealthSchema,\n archiveErrorResponseSchema,\n isWellFormedText,\n OPERATOR_SESSION_MAX_AGE_SECONDS,\n PUBLISHER_HEADER,\n REQUEST_MARKER_HEADER,\n SESSION_COOKIE_NAME,\n SESSION_MAX_AGE_SECONDS,\n sessionCookieValue,\n uuidSchema,\n} from '../contract';\nimport { serviceRequest } from '../worker/_service';\nimport {\n ApiError,\n badRequest,\n expiredSessionCookie,\n forbidden,\n json,\n noStore,\n queryParams,\n readJsonBody,\n sessionToken,\n toErrorResponse,\n unauthenticated,\n} from '../worker/http';\nimport type { ArchiveExecutionContext } from '../worker/worker';\nimport { confirmationPage, escapeHtml } from './_confirmation';\nimport { type AuthConfig, type AuthWorkerEnv, authConfig, isWellFormedEmail } from './config';\nimport { type AuthDiagnostic, AuthOperationError } from './errors';\nimport {\n authReadiness,\n consumeToken,\n deleteAccount,\n deleteToken,\n ensureUser,\n insertToken,\n LINK_TTL_MS,\n readAccount,\n revokeSessions,\n spendLinkBudget,\n spendSignInBudget,\n sweep,\n} from './store';\n\n/**\n * The reference identity issuer: passwordless email accounts. A POST with an email\n * address ensures the account row and sends a single-use magic link; consuming it\n * signs the browser in by setting the `archive_session` cookie a catalog Worker\n * verifies — an RS256 JWT minted by the active private key, verifiable through\n * `/jwks.json` alongside any configured retired public keys. `OPERATOR_EMAILS` is\n * the one operator-membership truth; operator sessions expire after one hour,\n * bounding authority from previously minted role claims. The account routes export the issuer's\n * own record and erase it together with the catalog profile.\n */\nexport interface AuthWorkerHandler<E extends AuthWorkerEnv = AuthWorkerEnv> {\n fetch(request: Request, env: E, ctx?: ArchiveExecutionContext): Promise<Response>;\n /** The cron sweep: expired links, closed send windows and never-verified accounts. */\n scheduled(controller: unknown, env: E, ctx?: ArchiveExecutionContext): Promise<void>;\n tap(handler: (event: AuthEvent) => void, options?: { readonly signal?: AbortSignal }): () => void;\n}\n\nexport type AuthEvent =\n | {\n readonly type: 'request';\n readonly requestId: string;\n readonly route:\n | '/auth/account'\n | '/auth/jwks.json'\n | '/auth/sign-in'\n | '/auth/verify'\n | '/auth/session'\n | '/auth/sign-out-all'\n | '/auth/health'\n | 'unmatched';\n readonly status: number;\n readonly code: ArchiveErrorCode | 'ok';\n readonly ms: number;\n readonly diagnostic?: AuthDiagnostic;\n }\n | { readonly type: 'scheduled'; readonly status: 'ok'; readonly removed: number }\n | { readonly type: 'scheduled'; readonly status: 'failed'; readonly diagnostic: AuthDiagnostic };\n\nconst ROUTES = {\n account: /^\\/api\\/v1\\/auth\\/account$/,\n health: /^\\/api\\/v1\\/auth\\/health$/,\n jwks: /^\\/api\\/v1\\/auth\\/jwks\\.json$/,\n session: /^\\/api\\/v1\\/auth\\/session$/,\n signIn: /^\\/api\\/v1\\/auth\\/sign-in$/,\n signOutAll: /^\\/api\\/v1\\/auth\\/sign-out-all$/,\n verify: /^\\/api\\/v1\\/auth\\/verify$/,\n} as const;\n\nconst ROUTE_METHODS: Record<keyof typeof ROUTES, readonly string[]> = {\n account: ['GET', 'DELETE'],\n health: ['GET'],\n jwks: ['GET'],\n session: ['GET'],\n signIn: ['POST'],\n signOutAll: ['POST'],\n verify: ['GET', 'POST'],\n};\n\nconst ROUTE_LABELS = {\n account: '/auth/account',\n health: '/auth/health',\n jwks: '/auth/jwks.json',\n session: '/auth/session',\n signIn: '/auth/sign-in',\n signOutAll: '/auth/sign-out-all',\n verify: '/auth/verify',\n} as const;\n\nconst matchRoute = (request: Request): keyof typeof ROUTES | undefined =>\n (Object.keys(ROUTES) as (keyof typeof ROUTES)[]).find((id) => ROUTES[id].test(new URL(request.url).pathname));\n\nconst TOKEN_BYTES = 32;\n// Base64url of TOKEN_BYTES random bytes, unpadded.\nconst LINK_TOKEN = /^[A-Za-z0-9_-]{43}$/;\nconst PUBLISHER_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;\nconst SUBJECT_MAX = 256;\nconst CLOCK_TOLERANCE_SECONDS = 5;\n\nconst randomToken = (): string =>\n btoa(String.fromCharCode(...crypto.getRandomValues(new Uint8Array(TOKEN_BYTES))))\n .replaceAll('+', '-')\n .replaceAll('/', '_')\n .replace(/=+$/, '');\n\nconst unavailable = (): ApiError => new AuthOperationError(503, 'catalog');\n\n/** Exact allowed Origin plus the request marker, like the catalog's mutation guard. */\nfunction requireMutationGuard(request: Request, config: AuthConfig, allowPublicOrigin = false): string {\n const origin = request.headers.get('origin');\n if (origin === null || (!config.allowedOrigins.has(origin) && !(allowPublicOrigin && origin === config.publicOrigin)))\n throw forbidden();\n if (request.headers.get(REQUEST_MARKER_HEADER) !== '1') throw forbidden();\n return origin;\n}\n\nfunction corsHeaders(request: Request, config: AuthConfig): Record<string, string> {\n const origin = request.headers.get('origin');\n if (\n origin === null ||\n (!config.allowedOrigins.has(origin) && !(matchRoute(request) === 'verify' && origin === config.publicOrigin))\n )\n return {};\n return { 'access-control-allow-credentials': 'true', 'access-control-allow-origin': origin, vary: 'Origin' };\n}\n\nfunction preflight(request: Request, config: AuthConfig, methods: readonly string[]): Response {\n const cors = corsHeaders(request, config);\n if (Object.keys(cors).length === 0) throw forbidden();\n return new Response(null, {\n headers: {\n ...cors,\n 'access-control-allow-headers': `Content-Type, ${ACCOUNT_HEADER}, ${PUBLISHER_HEADER}, ${REQUEST_MARKER_HEADER}`,\n 'access-control-allow-methods': [...methods, 'OPTIONS'].join(', '),\n 'access-control-max-age': '600',\n },\n status: 204,\n });\n}\n\nconst secure = (config: AuthConfig): boolean => config.appEnv === 'production';\n\nasync function mintSession(\n config: AuthConfig,\n subject: string,\n operator: boolean,\n sessionVersion: number,\n): Promise<{ maxAge: number; token: string }> {\n const maxAge = operator ? OPERATOR_SESSION_MAX_AGE_SECONDS : SESSION_MAX_AGE_SECONDS;\n const token = await new SignJWT({ sessionVersion, ...(operator ? { roles: [ARCHIVE_OPERATOR_ROLE] } : {}) })\n .setProtectedHeader({ alg: 'RS256', kid: config.signing.kid, typ: 'JWT' })\n .setIssuedAt()\n .setJti(crypto.randomUUID())\n .setIssuer(config.issuer)\n .setAudience(config.audience)\n .setSubject(subject)\n .setExpirationTime(`${maxAge}s`)\n .sign(config.signing.key);\n return { maxAge, token };\n}\n\n/** Verifies the request's own session cookie against every published key; returns the token and subject. */\nasync function requireSession(\n request: Request,\n config: AuthConfig,\n env: AuthWorkerEnv,\n): Promise<{ subject: string; token: string; sessionVersion: number }> {\n const token = sessionToken(request);\n if (token === null) throw unauthenticated();\n try {\n const { payload } = await jwtVerify(token, config.verifier, {\n algorithms: ['RS256'],\n audience: config.audience,\n clockTolerance: CLOCK_TOLERANCE_SECONDS,\n issuer: config.issuer,\n maxTokenAge: SESSION_MAX_AGE_SECONDS,\n requiredClaims: ['sub', 'iss', 'aud', 'iat', 'exp', 'sessionVersion'],\n });\n const subject = payload.sub ?? '';\n if (subject.length === 0 || subject.length > SUBJECT_MAX || !isWellFormedText(subject)) throw unauthenticated();\n const version = payload.sessionVersion;\n if (typeof version !== 'number' || !Number.isSafeInteger(version) || version < 0) throw unauthenticated();\n if (typeof payload.iat !== 'number' || payload.iat > Date.now() / 1000 + CLOCK_TOLERANCE_SECONDS)\n throw unauthenticated();\n if (\n Array.isArray(payload.roles) &&\n payload.roles.includes(ARCHIVE_OPERATOR_ROLE) &&\n (typeof payload.exp !== 'number' ||\n payload.exp - payload.iat > OPERATOR_SESSION_MAX_AGE_SECONDS ||\n Date.now() / 1000 - payload.iat > OPERATOR_SESSION_MAX_AGE_SECONDS + CLOCK_TOLERANCE_SECONDS)\n )\n throw unauthenticated();\n const account = await readAccount(env.AUTH_DB, subject);\n if (account === null || account.session_version !== version) throw unauthenticated();\n return { sessionVersion: version, subject, token };\n } catch (error) {\n if (error instanceof errors.JOSEError) throw unauthenticated();\n throw error;\n }\n}\n\nasync function sendLink(env: AuthWorkerEnv, config: AuthConfig, to: string, link: string): Promise<void> {\n const name = config.appName;\n const minutes = LINK_TTL_MS / 60_000;\n const notice = `The link works once and expires in ${minutes} minutes. If you did not request it, ignore this email.`;\n await env.AUTH_EMAIL.send({\n from: config.senderAddress,\n html: `<p>Open this link to sign in to ${escapeHtml(name)}:</p><p><a href=\"${escapeHtml(link)}\">${escapeHtml(link)}</a></p><p>${notice}</p>`,\n subject: `Your ${name} sign-in link`,\n text: `Open this link to sign in to ${name}:\\n\\n${link}\\n\\n${notice}`,\n to,\n });\n}\n\nasync function handleSignIn(request: Request, env: AuthWorkerEnv, config: AuthConfig): Promise<Response> {\n requireMutationGuard(request, config);\n const body = await readJsonBody(request);\n if (typeof body !== 'object' || body === null || Array.isArray(body)) throw badRequest();\n const keys = Object.keys(body);\n const requestedEmail: unknown = Reflect.get(body, 'email');\n if (keys.length !== 1 || keys[0] !== 'email' || typeof requestedEmail !== 'string') {\n throw badRequest();\n }\n const email = requestedEmail.trim().toLowerCase();\n if (!isWellFormedEmail(email)) {\n throw badRequest();\n }\n const nowMs = Date.now();\n const admissionRetry = await spendSignInBudget(env.AUTH_DB, nowMs);\n if (admissionRetry !== null) {\n throw new ApiError(429, 'rate-limited', 'Too many sign-in requests. Try again later.', {\n retryAfterSeconds: admissionRetry,\n });\n }\n const user = await ensureUser(env.AUTH_DB, email);\n const retryAfterSeconds = await spendLinkBudget(env.AUTH_DB, user.id, nowMs);\n if (retryAfterSeconds !== null) {\n throw new ApiError(429, 'rate-limited', 'Too many sign-in emails. Try again later.', { retryAfterSeconds });\n }\n const token = randomToken();\n const tokenHash = await sha256Hex(token);\n await insertToken(env.AUTH_DB, tokenHash, user.id, nowMs);\n try {\n await sendLink(env, config, user.email, `${config.publicOrigin}/api/v1/auth/verify?token=${token}`);\n } catch (error) {\n // An unsent link is useless; the spent allowance is deliberately not refunded.\n await deleteToken(env.AUTH_DB, tokenHash).catch(() => undefined);\n throw new AuthOperationError(503, 'email', { cause: error });\n }\n return json(202, { ok: true }, noStore);\n}\n\nasync function handleVerify(request: Request, env: AuthWorkerEnv, config: AuthConfig): Promise<Response> {\n if (request.method === 'GET') {\n const token = queryParams(new URL(request.url), ['token']).get('token') ?? '';\n if (!LINK_TOKEN.test(token)) throw badRequest('The link is not valid.');\n return confirmationPage({ ...config, nonce: randomToken(), token });\n }\n requireMutationGuard(request, config, true);\n queryParams(new URL(request.url), []);\n const body = await readJsonBody(request);\n if (typeof body !== 'object' || body === null || Array.isArray(body) || Object.keys(body).length !== 1) {\n throw badRequest('The link is not valid.');\n }\n const token: unknown = Reflect.get(body, 'token');\n if (typeof token !== 'string') throw badRequest('The link is not valid.');\n if (!LINK_TOKEN.test(token)) throw badRequest('The link is not valid.');\n const user = await consumeToken(env.AUTH_DB, await sha256Hex(token), Date.now());\n if (user === null) throw new ApiError(410, 'gone', 'That link has expired or was already used.');\n const session = await mintSession(config, user.id, config.operatorEmails.has(user.email), user.sessionVersion);\n return new Response(null, {\n headers: {\n ...noStore,\n 'set-cookie': sessionCookieValue(session.token, { maxAgeSeconds: session.maxAge, secure: secure(config) }),\n },\n status: 204,\n });\n}\n\n/** The issuer's half of a complete export: the account record only, never tokens or budget state. */\nasync function handleExport(request: Request, env: AuthWorkerEnv, config: AuthConfig): Promise<Response> {\n const { subject } = await requireSession(request, config, env);\n const row = await readAccount(env.AUTH_DB, subject);\n if (row === null) throw unauthenticated();\n return json(\n 200,\n { createdAt: row.created_at, email: row.email, id: row.id, lastSignInAt: row.last_sign_in_at },\n noStore,\n );\n}\n\n/** Re-serializes a failed catalog response into the public envelope; anything unrecognizable is 503. */\nasync function archiveFailure(response: Response): Promise<Response> {\n const text = await response.text().catch(() => '');\n let parsed: unknown = null;\n try {\n parsed = JSON.parse(text);\n } catch {\n parsed = null;\n }\n const envelope = archiveErrorResponseSchema.safeParse(parsed);\n if (!envelope.success || response.status < 400 || response.status > 599) throw unavailable();\n const { code, message, requestId } = envelope.data.error;\n const retryAfter = response.headers.get('retry-after');\n return json(\n response.status,\n { error: { code, message, requestId } },\n retryAfter !== null && /^\\d{1,6}$/.test(retryAfter) ? { ...noStore, 'retry-after': retryAfter } : noStore,\n );\n}\n\n/**\n * Full erasure: catalog session revocation and profile erasure first (through the AUTH_ARCHIVE service binding,\n * presenting the caller's own session), then the issuer's account row, and only then\n * the cookie is expired. Any failure keeps the browser session so the same request\n * can be retried; both halves are idempotent, so a retry after a partial success\n * re-runs the catalog no-op and finishes the issuer side. The account precondition\n * pins every attempt to the confirmed issuer subject, even without a publisher.\n */\nasync function handleErase(request: Request, env: AuthWorkerEnv, config: AuthConfig): Promise<Response> {\n const origin = requireMutationGuard(request, config);\n const { subject, token } = await requireSession(request, config, env);\n const account = request.headers.get(ACCOUNT_HEADER);\n if (account === null || !uuidSchema.safeParse(account).success) throw badRequest();\n if (account !== subject) {\n throw new ApiError(409, 'account-changed', 'The signed-in account changed since this request was prepared.');\n }\n const headers = new Headers({\n cookie: `${SESSION_COOKIE_NAME}=${token}`,\n origin,\n [REQUEST_MARKER_HEADER]: '1',\n });\n const publisher = request.headers.get(PUBLISHER_HEADER);\n if (publisher !== null) {\n if (!PUBLISHER_ID.test(publisher)) throw badRequest();\n headers.set(PUBLISHER_HEADER, publisher);\n }\n let archived: Response;\n try {\n archived = await serviceRequest(\n env.AUTH_ARCHIVE,\n new Request(`${config.publicOrigin}/api/v1/me/account`, { headers, method: 'DELETE', redirect: 'manual' }),\n async (response) => {\n if (response.status === 204) {\n await response.body?.cancel();\n return response;\n }\n return archiveFailure(response);\n },\n );\n } catch (error) {\n throw new AuthOperationError(503, 'catalog', { cause: error });\n }\n if (archived.status !== 204) return archived;\n await deleteAccount(env.AUTH_DB, subject);\n return new Response(null, {\n headers: { ...noStore, 'set-cookie': expiredSessionCookie(secure(config)) },\n status: 204,\n });\n}\n\nasync function handleSignOutAll(request: Request, env: AuthWorkerEnv, config: AuthConfig): Promise<Response> {\n requireMutationGuard(request, config);\n const { subject, sessionVersion } = await requireSession(request, config, env);\n const account = request.headers.get(ACCOUNT_HEADER);\n if (account === null || !uuidSchema.safeParse(account).success) throw badRequest();\n if (account !== subject)\n throw new ApiError(409, 'account-changed', 'The signed-in account changed since this request was prepared.');\n if ((await revokeSessions(env.AUTH_DB, subject, sessionVersion)) === null) throw unauthenticated();\n return json(200, { ok: true }, { ...noStore, 'set-cookie': expiredSessionCookie(secure(config)) });\n}\n\nasync function handleHealth(env: AuthWorkerEnv): Promise<Response> {\n let reason: 'configuration-unavailable' | 'database-unavailable' | 'schema-mismatch' | null = null;\n let dbSchemaVersion: number | null = null;\n try {\n await authConfig(env);\n } catch {\n reason = 'configuration-unavailable';\n }\n if (reason === null) {\n try {\n dbSchemaVersion = await authReadiness(env.AUTH_DB);\n if (dbSchemaVersion !== AUTH_SCHEMA_VERSION) reason = 'schema-mismatch';\n } catch {\n reason = 'database-unavailable';\n }\n }\n return json(\n reason === null ? 200 : 503,\n archiveAuthHealthSchema.parse({\n dbSchemaVersion,\n reason,\n schemaVersion: AUTH_SCHEMA_VERSION,\n status: reason === null ? 'ok' : 'degraded',\n }),\n noStore,\n );\n}\nasync function route(request: Request, env: AuthWorkerEnv): Promise<Response> {\n const method = request.method.toUpperCase();\n const routeId = matchRoute(request);\n if (routeId === undefined) throw new ApiError(404, 'not-found', 'No such route.');\n if (routeId === 'health' && method === 'GET') return handleHealth(env);\n const config = await authConfig(env);\n const cors = corsHeaders(request, config);\n if (method === 'OPTIONS') return preflight(request, config, ROUTE_METHODS[routeId]);\n if (!ROUTE_METHODS[routeId].includes(method)) {\n throw new ApiError(405, 'method-not-allowed', 'That method is not allowed here.');\n }\n let response: Response;\n switch (routeId) {\n case 'health':\n response = await handleHealth(env);\n break;\n case 'session':\n await requireSession(request, config, env);\n response = json(200, { ok: true }, noStore);\n break;\n case 'signOutAll':\n response = await handleSignOutAll(request, env, config);\n break;\n case 'jwks':\n response = json(200, { keys: config.jwks }, noStore);\n break;\n case 'signIn':\n response = await handleSignIn(request, env, config);\n break;\n case 'verify':\n response = await handleVerify(request, env, config);\n break;\n case 'account':\n response = method === 'GET' ? await handleExport(request, env, config) : await handleErase(request, env, config);\n break;\n }\n return withCors(response, cors);\n}\n\nfunction withCors(response: Response, cors: Record<string, string>): Response {\n if (Object.keys(cors).length === 0) return response;\n const next = new Response(response.body, response);\n for (const [key, value] of Object.entries(cors)) next.headers.set(key, value);\n return next;\n}\n\nconst diagnostic = (error: unknown): AuthDiagnostic =>\n error instanceof AuthOperationError ? error.authDiagnostic : { kind: 'internal' };\n\n/**\n * Builds the reference identity issuer. Route behavior is fixed; every deployment\n * difference is validated configuration — the app name, trusted origins, audience and\n * issuer claims, signing keys, operator roster, the catalog service binding, and\n * where links and redirects land.\n */\nexport function createAuthWorker<E extends AuthWorkerEnv>(): AuthWorkerHandler<E> {\n const tappers = new Set<(event: AuthEvent) => void>();\n const emit = (event: AuthEvent): void => {\n for (const handler of tappers) {\n try {\n handler(event);\n } catch {\n // Observers never change request or scheduled outcomes.\n }\n }\n };\n return {\n async fetch(request: Request, env: E): Promise<Response> {\n const requestId = crypto.randomUUID();\n const started = Date.now();\n let response: Response;\n let failure: unknown;\n try {\n response = await route(request, env);\n } catch (error) {\n failure = error;\n response = toErrorResponse(error, requestId);\n // Failures stay readable by allowed origins; config faults have no trusted origin set.\n const config = await authConfig(env).catch(() => null);\n if (config !== null) {\n if (\n matchRoute(request) === 'verify' &&\n request.method === 'GET' &&\n error instanceof ApiError &&\n error.status === 400\n )\n response = confirmationPage({ ...config, nonce: randomToken(), token: null });\n response = withCors(response, corsHeaders(request, config));\n }\n }\n if (matchRoute(request) === 'verify') {\n response.headers.set('cache-control', 'no-store');\n response.headers.set('referrer-policy', 'no-referrer');\n response.headers.set('x-content-type-options', 'nosniff');\n if (!response.headers.has('content-security-policy')) {\n response.headers.set(\n 'content-security-policy',\n \"default-src 'none'; base-uri 'none'; frame-ancestors 'none'\",\n );\n }\n }\n if (tappers.size > 0) {\n const routeId = matchRoute(request);\n const code =\n response.status < 400\n ? 'ok'\n : routeId === 'health'\n ? 'unavailable'\n : failure instanceof ApiError\n ? failure.code\n : 'internal';\n // Account erasure may forward a catalog error response rather than throw.\n const forwardedCode =\n failure === undefined && response.status >= 400\n ? archiveErrorResponseSchema.safeParse(\n await response\n .clone()\n .json()\n .catch(() => null),\n )\n : null;\n const health =\n routeId === 'health' && response.status === 503\n ? archiveAuthHealthSchema.safeParse(\n await response\n .clone()\n .json()\n .catch(() => null),\n )\n : null;\n emit({\n code: forwardedCode?.success ? forwardedCode.data.error.code : code,\n ms: Math.max(0, Date.now() - started),\n requestId,\n route: routeId === undefined ? 'unmatched' : ROUTE_LABELS[routeId],\n status: response.status,\n type: 'request',\n ...(response.status >= 500\n ? {\n diagnostic: health?.success\n ? {\n kind:\n health.data.reason === 'configuration-unavailable'\n ? 'configuration'\n : health.data.reason === 'schema-mismatch'\n ? 'schema'\n : 'database',\n }\n : failure === undefined\n ? { kind: 'catalog' as const }\n : diagnostic(failure),\n }\n : {}),\n });\n }\n return response;\n },\n\n async scheduled(_controller: unknown, env: E): Promise<void> {\n try {\n await authConfig(env);\n const removed = await sweep(env.AUTH_DB, Date.now());\n if (tappers.size > 0) emit({ removed, status: 'ok', type: 'scheduled' });\n } catch (error) {\n if (tappers.size > 0) emit({ diagnostic: diagnostic(error), status: 'failed', type: 'scheduled' });\n throw error;\n }\n },\n tap(handler, options) {\n if (options?.signal?.aborted) return () => undefined;\n const observer = (event: AuthEvent): void => handler(event);\n const unsubscribe = (): void => {\n tappers.delete(observer);\n options?.signal?.removeEventListener('abort', unsubscribe);\n };\n tappers.add(observer);\n options?.signal?.addEventListener('abort', unsubscribe, { once: true });\n return unsubscribe;\n },\n };\n}\n"],"mappings":"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"}
@@ -8,8 +8,8 @@ import { type AuthDiagnostic } from './errors';
8
8
  * signs the browser in by setting the `archive_session` cookie a catalog Worker
9
9
  * verifies — an RS256 JWT minted by the active private key, verifiable through
10
10
  * `/jwks.json` alongside any configured retired public keys. `OPERATOR_EMAILS` is
11
- * the one operator-membership truth; operator sessions are short-lived so a revoked
12
- * operator loses authority within minutes. The account routes export the issuer's
11
+ * the one operator-membership truth; operator sessions expire after one hour,
12
+ * bounding authority from previously minted role claims. The account routes export the issuer's
13
13
  * own record and erase it together with the catalog profile.
14
14
  */
15
15
  export interface AuthWorkerHandler<E extends AuthWorkerEnv = AuthWorkerEnv> {
@@ -1,2 +1,2 @@
1
- import{sha256Hex as e}from"../_crypto.js";import{ACCOUNT_HEADER as t,ARCHIVE_OPERATOR_ROLE as n,PUBLISHER_HEADER as r,REQUEST_MARKER_HEADER as i,SESSION_COOKIE_NAME as a,SESSION_MAX_AGE_SECONDS as o,archiveAuthHealthSchema as s,archiveErrorResponseSchema as c,isWellFormedText as l,sessionCookieValue as u,uuidSchema as d}from"../contract.js";import{serviceRequest as f}from"../worker/_service.js";import{ApiError as p,badRequest as m,expiredSessionCookie as h,forbidden as g,json as _,noStore as v,queryParams as y,readJsonBody as b,sessionToken as x,toErrorResponse as S,unauthenticated as C}from"../worker/http.js";import{AuthOperationError as w}from"./errors.js";import{LINK_TTL_MS as T,authReadiness as E,consumeToken as D,deleteAccount as O,deleteToken as k,ensureUser as A,insertToken as j,readAccount as M,revokeSessions as N,spendLinkBudget as P,spendSignInBudget as F,sweep as I}from"./store.js";import{confirmationPage as L,escapeHtml as R}from"./_confirmation.js";import{authConfig as z,isWellFormedEmail as B}from"./config.js";import{SignJWT as V,errors as H,jwtVerify as U}from"jose";var W={account:/^\/api\/v1\/auth\/account$/,health:/^\/api\/v1\/auth\/health$/,jwks:/^\/api\/v1\/auth\/jwks\.json$/,session:/^\/api\/v1\/auth\/session$/,signIn:/^\/api\/v1\/auth\/sign-in$/,signOutAll:/^\/api\/v1\/auth\/sign-out-all$/,verify:/^\/api\/v1\/auth\/verify$/},G={account:[`GET`,`DELETE`],health:[`GET`],jwks:[`GET`],session:[`GET`],signIn:[`POST`],signOutAll:[`POST`],verify:[`GET`,`POST`]},K={account:`/auth/account`,health:`/auth/health`,jwks:`/auth/jwks.json`,session:`/auth/session`,signIn:`/auth/sign-in`,signOutAll:`/auth/sign-out-all`,verify:`/auth/verify`},matchRoute=e=>Object.keys(W).find(t=>W[t].test(new URL(e.url).pathname)),q=32,J=/^[A-Za-z0-9_-]{43}$/,Y=/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/,X=256,Z=5,randomToken=()=>btoa(String.fromCharCode(...crypto.getRandomValues(new Uint8Array(q)))).replaceAll(`+`,`-`).replaceAll(`/`,`_`).replace(/=+$/,``),unavailable=()=>new w(503,`catalog`);function requireMutationGuard(e,t,n=!1){let r=e.headers.get(`origin`);if(r===null||!t.allowedOrigins.has(r)&&!(n&&r===t.publicOrigin)||e.headers.get(`x-archive-request`)!==`1`)throw g();return r}function corsHeaders(e,t){let n=e.headers.get(`origin`);return n===null||!t.allowedOrigins.has(n)&&(matchRoute(e)!==`verify`||n!==t.publicOrigin)?{}:{"access-control-allow-credentials":`true`,"access-control-allow-origin":n,vary:`Origin`}}function preflight(e,n,a){let o=corsHeaders(e,n);if(Object.keys(o).length===0)throw g();return new Response(null,{headers:{...o,"access-control-allow-headers":`Content-Type, ${t}, ${r}, ${i}`,"access-control-allow-methods":[...a,`OPTIONS`].join(`, `),"access-control-max-age":`600`},status:204})}var secure=e=>e.appEnv===`production`;async function mintSession(e,t,r,i){let a=r?900:o;return{maxAge:a,token:await new V({sessionVersion:i,...r?{roles:[n]}:{}}).setProtectedHeader({alg:`RS256`,kid:e.signing.kid,typ:`JWT`}).setIssuedAt().setJti(crypto.randomUUID()).setIssuer(e.issuer).setAudience(e.audience).setSubject(t).setExpirationTime(`${a}s`).sign(e.signing.key)}}async function requireSession(e,t,n){let r=x(e);if(r===null)throw C();try{let{payload:e}=await U(r,t.verifier,{algorithms:[`RS256`],audience:t.audience,clockTolerance:Z,issuer:t.issuer,maxTokenAge:o,requiredClaims:[`sub`,`iss`,`aud`,`iat`,`exp`,`sessionVersion`]}),i=e.sub??``;if(i.length===0||i.length>X||!l(i))throw C();let a=e.sessionVersion;if(typeof a!=`number`||!Number.isSafeInteger(a)||a<0||typeof e.iat!=`number`||e.iat>Date.now()/1e3+Z||Array.isArray(e.roles)&&e.roles.includes(`archive-operator`)&&(typeof e.exp!=`number`||e.exp-e.iat>900||Date.now()/1e3-e.iat>900+Z))throw C();let s=await M(n.AUTH_DB,i);if(s===null||s.session_version!==a)throw C();return{sessionVersion:a,subject:i,token:r}}catch(e){throw e instanceof H.JOSEError?C():e}}async function sendLink(e,t,n,r){let i=t.appName,a=`The link works once and expires in ${T/6e4} minutes. If you did not request it, ignore this email.`;await e.AUTH_EMAIL.send({from:t.senderAddress,html:`<p>Open this link to sign in to ${R(i)}:</p><p><a href="${R(r)}">${R(r)}</a></p><p>${a}</p>`,subject:`Your ${i} sign-in link`,text:`Open this link to sign in to ${i}:\n\n${r}\n\n${a}`,to:n})}async function handleSignIn(t,n,r){requireMutationGuard(t,r);let i=await b(t);if(typeof i!=`object`||!i||Array.isArray(i))throw m();let a=Object.keys(i),o=Reflect.get(i,`email`);if(a.length!==1||a[0]!==`email`||typeof o!=`string`)throw m();let s=o.trim().toLowerCase();if(!B(s))throw m();let c=Date.now(),l=await F(n.AUTH_DB,c);if(l!==null)throw new p(429,`rate-limited`,`Too many sign-in requests. Try again later.`,{retryAfterSeconds:l});let u=await A(n.AUTH_DB,s),d=await P(n.AUTH_DB,u.id,c);if(d!==null)throw new p(429,`rate-limited`,`Too many sign-in emails. Try again later.`,{retryAfterSeconds:d});let f=randomToken(),h=await e(f);await j(n.AUTH_DB,h,u.id,c);try{await sendLink(n,r,u.email,`${r.publicOrigin}/api/v1/auth/verify?token=${f}`)}catch(e){throw await k(n.AUTH_DB,h).catch(()=>void 0),new w(503,`email`,{cause:e})}return _(202,{ok:!0},v)}async function handleVerify(t,n,r){if(t.method===`GET`){let e=y(new URL(t.url),[`token`]).get(`token`)??``;if(!J.test(e))throw m(`The link is not valid.`);return L({...r,nonce:randomToken(),token:e})}requireMutationGuard(t,r,!0),y(new URL(t.url),[]);let i=await b(t);if(typeof i!=`object`||!i||Array.isArray(i)||Object.keys(i).length!==1)throw m(`The link is not valid.`);let a=Reflect.get(i,`token`);if(typeof a!=`string`||!J.test(a))throw m(`The link is not valid.`);let o=await D(n.AUTH_DB,await e(a),Date.now());if(o===null)throw new p(410,`gone`,`That link has expired or was already used.`);let s=await mintSession(r,o.id,r.operatorEmails.has(o.email),o.sessionVersion);return new Response(null,{headers:{...v,"set-cookie":u(s.token,{maxAgeSeconds:s.maxAge,secure:secure(r)})},status:204})}async function handleExport(e,t,n){let{subject:r}=await requireSession(e,n,t),i=await M(t.AUTH_DB,r);if(i===null)throw C();return _(200,{createdAt:i.created_at,email:i.email,id:i.id,lastSignInAt:i.last_sign_in_at},v)}async function archiveFailure(e){let t=await e.text().catch(()=>``),n=null;try{n=JSON.parse(t)}catch{n=null}let r=c.safeParse(n);if(!r.success||e.status<400||e.status>599)throw unavailable();let{code:i,message:a,requestId:o}=r.data.error,s=e.headers.get(`retry-after`);return _(e.status,{error:{code:i,message:a,requestId:o}},s!==null&&/^\d{1,6}$/.test(s)?{...v,"retry-after":s}:v)}async function handleErase(e,n,o){let s=requireMutationGuard(e,o),{subject:c,token:l}=await requireSession(e,o,n),u=e.headers.get(t);if(u===null||!d.safeParse(u).success)throw m();if(u!==c)throw new p(409,`account-changed`,`The signed-in account changed since this request was prepared.`);let g=new Headers({cookie:`${a}=${l}`,origin:s,[i]:`1`}),_=e.headers.get(r);if(_!==null){if(!Y.test(_))throw m();g.set(r,_)}let y;try{y=await f(n.AUTH_ARCHIVE,new Request(`${o.publicOrigin}/api/v1/me/account`,{headers:g,method:`DELETE`,redirect:`manual`}),async e=>e.status===204?(await e.body?.cancel(),e):archiveFailure(e))}catch(e){throw new w(503,`catalog`,{cause:e})}return y.status===204?(await O(n.AUTH_DB,c),new Response(null,{headers:{...v,"set-cookie":h(secure(o))},status:204})):y}async function handleSignOutAll(e,n,r){requireMutationGuard(e,r);let{subject:i,sessionVersion:a}=await requireSession(e,r,n),o=e.headers.get(t);if(o===null||!d.safeParse(o).success)throw m();if(o!==i)throw new p(409,`account-changed`,`The signed-in account changed since this request was prepared.`);if(await N(n.AUTH_DB,i,a)===null)throw C();return _(200,{ok:!0},{...v,"set-cookie":h(secure(r))})}async function handleHealth(e){let t=null,n=null;try{await z(e)}catch{t=`configuration-unavailable`}if(t===null)try{n=await E(e.AUTH_DB),n!==1&&(t=`schema-mismatch`)}catch{t=`database-unavailable`}return _(t===null?200:503,s.parse({dbSchemaVersion:n,reason:t,schemaVersion:1,status:t===null?`ok`:`degraded`}),v)}async function route(e,t){let n=e.method.toUpperCase(),r=matchRoute(e);if(r===void 0)throw new p(404,`not-found`,`No such route.`);if(r===`health`&&n===`GET`)return handleHealth(t);let i=await z(t),a=corsHeaders(e,i);if(n===`OPTIONS`)return preflight(e,i,G[r]);if(!G[r].includes(n))throw new p(405,`method-not-allowed`,`That method is not allowed here.`);let o;switch(r){case`health`:o=await handleHealth(t);break;case`session`:await requireSession(e,i,t),o=_(200,{ok:!0},v);break;case`signOutAll`:o=await handleSignOutAll(e,t,i);break;case`jwks`:o=_(200,{keys:i.jwks},v);break;case`signIn`:o=await handleSignIn(e,t,i);break;case`verify`:o=await handleVerify(e,t,i);break;case`account`:o=n===`GET`?await handleExport(e,t,i):await handleErase(e,t,i)}return withCors(o,a)}function withCors(e,t){if(Object.keys(t).length===0)return e;let n=new Response(e.body,e);for(let[e,r]of Object.entries(t))n.headers.set(e,r);return n}var diagnostic=e=>e instanceof w?e.authDiagnostic:{kind:`internal`};function createAuthWorker(){let e=new Set,emit=t=>{for(let n of e)try{n(t)}catch{}};return{async fetch(t,n){let r=crypto.randomUUID(),i=Date.now(),a,o;try{a=await route(t,n)}catch(e){o=e,a=S(e,r);let i=await z(n).catch(()=>null);i!==null&&(matchRoute(t)===`verify`&&t.method===`GET`&&e instanceof p&&e.status===400&&(a=L({...i,nonce:randomToken(),token:null})),a=withCors(a,corsHeaders(t,i)))}if(matchRoute(t)===`verify`&&(a.headers.set(`cache-control`,`no-store`),a.headers.set(`referrer-policy`,`no-referrer`),a.headers.set(`x-content-type-options`,`nosniff`),a.headers.has(`content-security-policy`)||a.headers.set(`content-security-policy`,`default-src 'none'; base-uri 'none'; frame-ancestors 'none'`)),e.size>0){let e=matchRoute(t),n=a.status<400?`ok`:e===`health`?`unavailable`:o instanceof p?o.code:`internal`,l=o===void 0&&a.status>=400?c.safeParse(await a.clone().json().catch(()=>null)):null,u=e===`health`&&a.status===503?s.safeParse(await a.clone().json().catch(()=>null)):null;emit({code:l?.success?l.data.error.code:n,ms:Math.max(0,Date.now()-i),requestId:r,route:e===void 0?`unmatched`:K[e],status:a.status,type:`request`,...a.status>=500?{diagnostic:u?.success?{kind:u.data.reason===`configuration-unavailable`?`configuration`:u.data.reason===`schema-mismatch`?`schema`:`database`}:o===void 0?{kind:`catalog`}:diagnostic(o)}:{}})}return a},async scheduled(t,n){try{await z(n);let t=await I(n.AUTH_DB,Date.now());e.size>0&&emit({removed:t,status:`ok`,type:`scheduled`})}catch(t){throw e.size>0&&emit({diagnostic:diagnostic(t),status:`failed`,type:`scheduled`}),t}},tap(t,n){if(n?.signal?.aborted)return()=>void 0;let observer=e=>t(e),unsubscribe=()=>{e.delete(observer),n?.signal?.removeEventListener(`abort`,unsubscribe)};return e.add(observer),n?.signal?.addEventListener(`abort`,unsubscribe,{once:!0}),unsubscribe}}}export{createAuthWorker};
1
+ import{sha256Hex as e}from"../_crypto.js";import{ACCOUNT_HEADER as t,ARCHIVE_OPERATOR_ROLE as n,OPERATOR_SESSION_MAX_AGE_SECONDS as r,PUBLISHER_HEADER as i,REQUEST_MARKER_HEADER as a,SESSION_COOKIE_NAME as o,SESSION_MAX_AGE_SECONDS as s,archiveAuthHealthSchema as c,archiveErrorResponseSchema as l,isWellFormedText as u,sessionCookieValue as d,uuidSchema as f}from"../contract.js";import{serviceRequest as p}from"../worker/_service.js";import{ApiError as m,badRequest as h,expiredSessionCookie as g,forbidden as _,json as v,noStore as y,queryParams as b,readJsonBody as x,sessionToken as S,toErrorResponse as C,unauthenticated as w}from"../worker/http.js";import{AuthOperationError as T}from"./errors.js";import{LINK_TTL_MS as E,authReadiness as D,consumeToken as O,deleteAccount as k,deleteToken as A,ensureUser as j,insertToken as M,readAccount as N,revokeSessions as P,spendLinkBudget as F,spendSignInBudget as I,sweep as L}from"./store.js";import{confirmationPage as R,escapeHtml as z}from"./_confirmation.js";import{authConfig as B,isWellFormedEmail as V}from"./config.js";import{SignJWT as H,errors as U,jwtVerify as W}from"jose";var G={account:/^\/api\/v1\/auth\/account$/,health:/^\/api\/v1\/auth\/health$/,jwks:/^\/api\/v1\/auth\/jwks\.json$/,session:/^\/api\/v1\/auth\/session$/,signIn:/^\/api\/v1\/auth\/sign-in$/,signOutAll:/^\/api\/v1\/auth\/sign-out-all$/,verify:/^\/api\/v1\/auth\/verify$/},K={account:[`GET`,`DELETE`],health:[`GET`],jwks:[`GET`],session:[`GET`],signIn:[`POST`],signOutAll:[`POST`],verify:[`GET`,`POST`]},q={account:`/auth/account`,health:`/auth/health`,jwks:`/auth/jwks.json`,session:`/auth/session`,signIn:`/auth/sign-in`,signOutAll:`/auth/sign-out-all`,verify:`/auth/verify`},matchRoute=e=>Object.keys(G).find(t=>G[t].test(new URL(e.url).pathname)),J=32,Y=/^[A-Za-z0-9_-]{43}$/,X=/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/,Z=256,Q=5,randomToken=()=>btoa(String.fromCharCode(...crypto.getRandomValues(new Uint8Array(J)))).replaceAll(`+`,`-`).replaceAll(`/`,`_`).replace(/=+$/,``),unavailable=()=>new T(503,`catalog`);function requireMutationGuard(e,t,n=!1){let r=e.headers.get(`origin`);if(r===null||!t.allowedOrigins.has(r)&&!(n&&r===t.publicOrigin)||e.headers.get(`x-archive-request`)!==`1`)throw _();return r}function corsHeaders(e,t){let n=e.headers.get(`origin`);return n===null||!t.allowedOrigins.has(n)&&(matchRoute(e)!==`verify`||n!==t.publicOrigin)?{}:{"access-control-allow-credentials":`true`,"access-control-allow-origin":n,vary:`Origin`}}function preflight(e,n,r){let o=corsHeaders(e,n);if(Object.keys(o).length===0)throw _();return new Response(null,{headers:{...o,"access-control-allow-headers":`Content-Type, ${t}, ${i}, ${a}`,"access-control-allow-methods":[...r,`OPTIONS`].join(`, `),"access-control-max-age":`600`},status:204})}var secure=e=>e.appEnv===`production`;async function mintSession(e,t,i,a){let o=i?r:s;return{maxAge:o,token:await new H({sessionVersion:a,...i?{roles:[n]}:{}}).setProtectedHeader({alg:`RS256`,kid:e.signing.kid,typ:`JWT`}).setIssuedAt().setJti(crypto.randomUUID()).setIssuer(e.issuer).setAudience(e.audience).setSubject(t).setExpirationTime(`${o}s`).sign(e.signing.key)}}async function requireSession(e,t,n){let r=S(e);if(r===null)throw w();try{let{payload:e}=await W(r,t.verifier,{algorithms:[`RS256`],audience:t.audience,clockTolerance:Q,issuer:t.issuer,maxTokenAge:s,requiredClaims:[`sub`,`iss`,`aud`,`iat`,`exp`,`sessionVersion`]}),i=e.sub??``;if(i.length===0||i.length>Z||!u(i))throw w();let a=e.sessionVersion;if(typeof a!=`number`||!Number.isSafeInteger(a)||a<0||typeof e.iat!=`number`||e.iat>Date.now()/1e3+Q||Array.isArray(e.roles)&&e.roles.includes(`archive-operator`)&&(typeof e.exp!=`number`||e.exp-e.iat>3600||Date.now()/1e3-e.iat>3600+Q))throw w();let o=await N(n.AUTH_DB,i);if(o===null||o.session_version!==a)throw w();return{sessionVersion:a,subject:i,token:r}}catch(e){throw e instanceof U.JOSEError?w():e}}async function sendLink(e,t,n,r){let i=t.appName,a=`The link works once and expires in ${E/6e4} minutes. If you did not request it, ignore this email.`;await e.AUTH_EMAIL.send({from:t.senderAddress,html:`<p>Open this link to sign in to ${z(i)}:</p><p><a href="${z(r)}">${z(r)}</a></p><p>${a}</p>`,subject:`Your ${i} sign-in link`,text:`Open this link to sign in to ${i}:\n\n${r}\n\n${a}`,to:n})}async function handleSignIn(t,n,r){requireMutationGuard(t,r);let i=await x(t);if(typeof i!=`object`||!i||Array.isArray(i))throw h();let a=Object.keys(i),o=Reflect.get(i,`email`);if(a.length!==1||a[0]!==`email`||typeof o!=`string`)throw h();let s=o.trim().toLowerCase();if(!V(s))throw h();let c=Date.now(),l=await I(n.AUTH_DB,c);if(l!==null)throw new m(429,`rate-limited`,`Too many sign-in requests. Try again later.`,{retryAfterSeconds:l});let u=await j(n.AUTH_DB,s),d=await F(n.AUTH_DB,u.id,c);if(d!==null)throw new m(429,`rate-limited`,`Too many sign-in emails. Try again later.`,{retryAfterSeconds:d});let f=randomToken(),p=await e(f);await M(n.AUTH_DB,p,u.id,c);try{await sendLink(n,r,u.email,`${r.publicOrigin}/api/v1/auth/verify?token=${f}`)}catch(e){throw await A(n.AUTH_DB,p).catch(()=>void 0),new T(503,`email`,{cause:e})}return v(202,{ok:!0},y)}async function handleVerify(t,n,r){if(t.method===`GET`){let e=b(new URL(t.url),[`token`]).get(`token`)??``;if(!Y.test(e))throw h(`The link is not valid.`);return R({...r,nonce:randomToken(),token:e})}requireMutationGuard(t,r,!0),b(new URL(t.url),[]);let i=await x(t);if(typeof i!=`object`||!i||Array.isArray(i)||Object.keys(i).length!==1)throw h(`The link is not valid.`);let a=Reflect.get(i,`token`);if(typeof a!=`string`||!Y.test(a))throw h(`The link is not valid.`);let o=await O(n.AUTH_DB,await e(a),Date.now());if(o===null)throw new m(410,`gone`,`That link has expired or was already used.`);let s=await mintSession(r,o.id,r.operatorEmails.has(o.email),o.sessionVersion);return new Response(null,{headers:{...y,"set-cookie":d(s.token,{maxAgeSeconds:s.maxAge,secure:secure(r)})},status:204})}async function handleExport(e,t,n){let{subject:r}=await requireSession(e,n,t),i=await N(t.AUTH_DB,r);if(i===null)throw w();return v(200,{createdAt:i.created_at,email:i.email,id:i.id,lastSignInAt:i.last_sign_in_at},y)}async function archiveFailure(e){let t=await e.text().catch(()=>``),n=null;try{n=JSON.parse(t)}catch{n=null}let r=l.safeParse(n);if(!r.success||e.status<400||e.status>599)throw unavailable();let{code:i,message:a,requestId:o}=r.data.error,s=e.headers.get(`retry-after`);return v(e.status,{error:{code:i,message:a,requestId:o}},s!==null&&/^\d{1,6}$/.test(s)?{...y,"retry-after":s}:y)}async function handleErase(e,n,r){let s=requireMutationGuard(e,r),{subject:c,token:l}=await requireSession(e,r,n),u=e.headers.get(t);if(u===null||!f.safeParse(u).success)throw h();if(u!==c)throw new m(409,`account-changed`,`The signed-in account changed since this request was prepared.`);let d=new Headers({cookie:`${o}=${l}`,origin:s,[a]:`1`}),_=e.headers.get(i);if(_!==null){if(!X.test(_))throw h();d.set(i,_)}let v;try{v=await p(n.AUTH_ARCHIVE,new Request(`${r.publicOrigin}/api/v1/me/account`,{headers:d,method:`DELETE`,redirect:`manual`}),async e=>e.status===204?(await e.body?.cancel(),e):archiveFailure(e))}catch(e){throw new T(503,`catalog`,{cause:e})}return v.status===204?(await k(n.AUTH_DB,c),new Response(null,{headers:{...y,"set-cookie":g(secure(r))},status:204})):v}async function handleSignOutAll(e,n,r){requireMutationGuard(e,r);let{subject:i,sessionVersion:a}=await requireSession(e,r,n),o=e.headers.get(t);if(o===null||!f.safeParse(o).success)throw h();if(o!==i)throw new m(409,`account-changed`,`The signed-in account changed since this request was prepared.`);if(await P(n.AUTH_DB,i,a)===null)throw w();return v(200,{ok:!0},{...y,"set-cookie":g(secure(r))})}async function handleHealth(e){let t=null,n=null;try{await B(e)}catch{t=`configuration-unavailable`}if(t===null)try{n=await D(e.AUTH_DB),n!==1&&(t=`schema-mismatch`)}catch{t=`database-unavailable`}return v(t===null?200:503,c.parse({dbSchemaVersion:n,reason:t,schemaVersion:1,status:t===null?`ok`:`degraded`}),y)}async function route(e,t){let n=e.method.toUpperCase(),r=matchRoute(e);if(r===void 0)throw new m(404,`not-found`,`No such route.`);if(r===`health`&&n===`GET`)return handleHealth(t);let i=await B(t),a=corsHeaders(e,i);if(n===`OPTIONS`)return preflight(e,i,K[r]);if(!K[r].includes(n))throw new m(405,`method-not-allowed`,`That method is not allowed here.`);let o;switch(r){case`health`:o=await handleHealth(t);break;case`session`:await requireSession(e,i,t),o=v(200,{ok:!0},y);break;case`signOutAll`:o=await handleSignOutAll(e,t,i);break;case`jwks`:o=v(200,{keys:i.jwks},y);break;case`signIn`:o=await handleSignIn(e,t,i);break;case`verify`:o=await handleVerify(e,t,i);break;case`account`:o=n===`GET`?await handleExport(e,t,i):await handleErase(e,t,i)}return withCors(o,a)}function withCors(e,t){if(Object.keys(t).length===0)return e;let n=new Response(e.body,e);for(let[e,r]of Object.entries(t))n.headers.set(e,r);return n}var diagnostic=e=>e instanceof T?e.authDiagnostic:{kind:`internal`};function createAuthWorker(){let e=new Set,emit=t=>{for(let n of e)try{n(t)}catch{}};return{async fetch(t,n){let r=crypto.randomUUID(),i=Date.now(),a,o;try{a=await route(t,n)}catch(e){o=e,a=C(e,r);let i=await B(n).catch(()=>null);i!==null&&(matchRoute(t)===`verify`&&t.method===`GET`&&e instanceof m&&e.status===400&&(a=R({...i,nonce:randomToken(),token:null})),a=withCors(a,corsHeaders(t,i)))}if(matchRoute(t)===`verify`&&(a.headers.set(`cache-control`,`no-store`),a.headers.set(`referrer-policy`,`no-referrer`),a.headers.set(`x-content-type-options`,`nosniff`),a.headers.has(`content-security-policy`)||a.headers.set(`content-security-policy`,`default-src 'none'; base-uri 'none'; frame-ancestors 'none'`)),e.size>0){let e=matchRoute(t),n=a.status<400?`ok`:e===`health`?`unavailable`:o instanceof m?o.code:`internal`,s=o===void 0&&a.status>=400?l.safeParse(await a.clone().json().catch(()=>null)):null,u=e===`health`&&a.status===503?c.safeParse(await a.clone().json().catch(()=>null)):null;emit({code:s?.success?s.data.error.code:n,ms:Math.max(0,Date.now()-i),requestId:r,route:e===void 0?`unmatched`:q[e],status:a.status,type:`request`,...a.status>=500?{diagnostic:u?.success?{kind:u.data.reason===`configuration-unavailable`?`configuration`:u.data.reason===`schema-mismatch`?`schema`:`database`}:o===void 0?{kind:`catalog`}:diagnostic(o)}:{}})}return a},async scheduled(t,n){try{await B(n);let t=await L(n.AUTH_DB,Date.now());e.size>0&&emit({removed:t,status:`ok`,type:`scheduled`})}catch(t){throw e.size>0&&emit({diagnostic:diagnostic(t),status:`failed`,type:`scheduled`}),t}},tap(t,n){if(n?.signal?.aborted)return()=>void 0;let observer=e=>t(e),unsubscribe=()=>{e.delete(observer),n?.signal?.removeEventListener(`abort`,unsubscribe)};return e.add(observer),n?.signal?.addEventListener(`abort`,unsubscribe,{once:!0}),unsubscribe}}}export{createAuthWorker};
2
2
  //# sourceMappingURL=worker.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"worker.js","names":[],"sources":["../../src/auth/worker.ts"],"sourcesContent":["import { errors, jwtVerify, SignJWT } from 'jose';\nimport { sha256Hex } from '../_crypto';\nimport {\n ACCOUNT_HEADER,\n ARCHIVE_OPERATOR_ROLE,\n type ArchiveErrorCode,\n AUTH_SCHEMA_VERSION,\n archiveAuthHealthSchema,\n archiveErrorResponseSchema,\n isWellFormedText,\n OPERATOR_SESSION_MAX_AGE_SECONDS,\n PUBLISHER_HEADER,\n REQUEST_MARKER_HEADER,\n SESSION_COOKIE_NAME,\n SESSION_MAX_AGE_SECONDS,\n sessionCookieValue,\n uuidSchema,\n} from '../contract';\nimport { serviceRequest } from '../worker/_service';\nimport {\n ApiError,\n badRequest,\n expiredSessionCookie,\n forbidden,\n json,\n noStore,\n queryParams,\n readJsonBody,\n sessionToken,\n toErrorResponse,\n unauthenticated,\n} from '../worker/http';\nimport type { ArchiveExecutionContext } from '../worker/worker';\nimport { confirmationPage, escapeHtml } from './_confirmation';\nimport { type AuthConfig, type AuthWorkerEnv, authConfig, isWellFormedEmail } from './config';\nimport { type AuthDiagnostic, AuthOperationError } from './errors';\nimport {\n authReadiness,\n consumeToken,\n deleteAccount,\n deleteToken,\n ensureUser,\n insertToken,\n LINK_TTL_MS,\n readAccount,\n revokeSessions,\n spendLinkBudget,\n spendSignInBudget,\n sweep,\n} from './store';\n\n/**\n * The reference identity issuer: passwordless email accounts. A POST with an email\n * address ensures the account row and sends a single-use magic link; consuming it\n * signs the browser in by setting the `archive_session` cookie a catalog Worker\n * verifies — an RS256 JWT minted by the active private key, verifiable through\n * `/jwks.json` alongside any configured retired public keys. `OPERATOR_EMAILS` is\n * the one operator-membership truth; operator sessions are short-lived so a revoked\n * operator loses authority within minutes. The account routes export the issuer's\n * own record and erase it together with the catalog profile.\n */\nexport interface AuthWorkerHandler<E extends AuthWorkerEnv = AuthWorkerEnv> {\n fetch(request: Request, env: E, ctx?: ArchiveExecutionContext): Promise<Response>;\n /** The cron sweep: expired links, closed send windows and never-verified accounts. */\n scheduled(controller: unknown, env: E, ctx?: ArchiveExecutionContext): Promise<void>;\n tap(handler: (event: AuthEvent) => void, options?: { readonly signal?: AbortSignal }): () => void;\n}\n\nexport type AuthEvent =\n | {\n readonly type: 'request';\n readonly requestId: string;\n readonly route:\n | '/auth/account'\n | '/auth/jwks.json'\n | '/auth/sign-in'\n | '/auth/verify'\n | '/auth/session'\n | '/auth/sign-out-all'\n | '/auth/health'\n | 'unmatched';\n readonly status: number;\n readonly code: ArchiveErrorCode | 'ok';\n readonly ms: number;\n readonly diagnostic?: AuthDiagnostic;\n }\n | { readonly type: 'scheduled'; readonly status: 'ok'; readonly removed: number }\n | { readonly type: 'scheduled'; readonly status: 'failed'; readonly diagnostic: AuthDiagnostic };\n\nconst ROUTES = {\n account: /^\\/api\\/v1\\/auth\\/account$/,\n health: /^\\/api\\/v1\\/auth\\/health$/,\n jwks: /^\\/api\\/v1\\/auth\\/jwks\\.json$/,\n session: /^\\/api\\/v1\\/auth\\/session$/,\n signIn: /^\\/api\\/v1\\/auth\\/sign-in$/,\n signOutAll: /^\\/api\\/v1\\/auth\\/sign-out-all$/,\n verify: /^\\/api\\/v1\\/auth\\/verify$/,\n} as const;\n\nconst ROUTE_METHODS: Record<keyof typeof ROUTES, readonly string[]> = {\n account: ['GET', 'DELETE'],\n health: ['GET'],\n jwks: ['GET'],\n session: ['GET'],\n signIn: ['POST'],\n signOutAll: ['POST'],\n verify: ['GET', 'POST'],\n};\n\nconst ROUTE_LABELS = {\n account: '/auth/account',\n health: '/auth/health',\n jwks: '/auth/jwks.json',\n session: '/auth/session',\n signIn: '/auth/sign-in',\n signOutAll: '/auth/sign-out-all',\n verify: '/auth/verify',\n} as const;\n\nconst matchRoute = (request: Request): keyof typeof ROUTES | undefined =>\n (Object.keys(ROUTES) as (keyof typeof ROUTES)[]).find((id) => ROUTES[id].test(new URL(request.url).pathname));\n\nconst TOKEN_BYTES = 32;\n// Base64url of TOKEN_BYTES random bytes, unpadded.\nconst LINK_TOKEN = /^[A-Za-z0-9_-]{43}$/;\nconst PUBLISHER_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;\nconst SUBJECT_MAX = 256;\nconst CLOCK_TOLERANCE_SECONDS = 5;\n\nconst randomToken = (): string =>\n btoa(String.fromCharCode(...crypto.getRandomValues(new Uint8Array(TOKEN_BYTES))))\n .replaceAll('+', '-')\n .replaceAll('/', '_')\n .replace(/=+$/, '');\n\nconst unavailable = (): ApiError => new AuthOperationError(503, 'catalog');\n\n/** Exact allowed Origin plus the request marker, like the catalog's mutation guard. */\nfunction requireMutationGuard(request: Request, config: AuthConfig, allowPublicOrigin = false): string {\n const origin = request.headers.get('origin');\n if (origin === null || (!config.allowedOrigins.has(origin) && !(allowPublicOrigin && origin === config.publicOrigin)))\n throw forbidden();\n if (request.headers.get(REQUEST_MARKER_HEADER) !== '1') throw forbidden();\n return origin;\n}\n\nfunction corsHeaders(request: Request, config: AuthConfig): Record<string, string> {\n const origin = request.headers.get('origin');\n if (\n origin === null ||\n (!config.allowedOrigins.has(origin) && !(matchRoute(request) === 'verify' && origin === config.publicOrigin))\n )\n return {};\n return { 'access-control-allow-credentials': 'true', 'access-control-allow-origin': origin, vary: 'Origin' };\n}\n\nfunction preflight(request: Request, config: AuthConfig, methods: readonly string[]): Response {\n const cors = corsHeaders(request, config);\n if (Object.keys(cors).length === 0) throw forbidden();\n return new Response(null, {\n headers: {\n ...cors,\n 'access-control-allow-headers': `Content-Type, ${ACCOUNT_HEADER}, ${PUBLISHER_HEADER}, ${REQUEST_MARKER_HEADER}`,\n 'access-control-allow-methods': [...methods, 'OPTIONS'].join(', '),\n 'access-control-max-age': '600',\n },\n status: 204,\n });\n}\n\nconst secure = (config: AuthConfig): boolean => config.appEnv === 'production';\n\nasync function mintSession(\n config: AuthConfig,\n subject: string,\n operator: boolean,\n sessionVersion: number,\n): Promise<{ maxAge: number; token: string }> {\n const maxAge = operator ? OPERATOR_SESSION_MAX_AGE_SECONDS : SESSION_MAX_AGE_SECONDS;\n const token = await new SignJWT({ sessionVersion, ...(operator ? { roles: [ARCHIVE_OPERATOR_ROLE] } : {}) })\n .setProtectedHeader({ alg: 'RS256', kid: config.signing.kid, typ: 'JWT' })\n .setIssuedAt()\n .setJti(crypto.randomUUID())\n .setIssuer(config.issuer)\n .setAudience(config.audience)\n .setSubject(subject)\n .setExpirationTime(`${maxAge}s`)\n .sign(config.signing.key);\n return { maxAge, token };\n}\n\n/** Verifies the request's own session cookie against every published key; returns the token and subject. */\nasync function requireSession(\n request: Request,\n config: AuthConfig,\n env: AuthWorkerEnv,\n): Promise<{ subject: string; token: string; sessionVersion: number }> {\n const token = sessionToken(request);\n if (token === null) throw unauthenticated();\n try {\n const { payload } = await jwtVerify(token, config.verifier, {\n algorithms: ['RS256'],\n audience: config.audience,\n clockTolerance: CLOCK_TOLERANCE_SECONDS,\n issuer: config.issuer,\n maxTokenAge: SESSION_MAX_AGE_SECONDS,\n requiredClaims: ['sub', 'iss', 'aud', 'iat', 'exp', 'sessionVersion'],\n });\n const subject = payload.sub ?? '';\n if (subject.length === 0 || subject.length > SUBJECT_MAX || !isWellFormedText(subject)) throw unauthenticated();\n const version = payload.sessionVersion;\n if (typeof version !== 'number' || !Number.isSafeInteger(version) || version < 0) throw unauthenticated();\n if (typeof payload.iat !== 'number' || payload.iat > Date.now() / 1000 + CLOCK_TOLERANCE_SECONDS)\n throw unauthenticated();\n if (\n Array.isArray(payload.roles) &&\n payload.roles.includes(ARCHIVE_OPERATOR_ROLE) &&\n (typeof payload.exp !== 'number' ||\n payload.exp - payload.iat > OPERATOR_SESSION_MAX_AGE_SECONDS ||\n Date.now() / 1000 - payload.iat > OPERATOR_SESSION_MAX_AGE_SECONDS + CLOCK_TOLERANCE_SECONDS)\n )\n throw unauthenticated();\n const account = await readAccount(env.AUTH_DB, subject);\n if (account === null || account.session_version !== version) throw unauthenticated();\n return { sessionVersion: version, subject, token };\n } catch (error) {\n if (error instanceof errors.JOSEError) throw unauthenticated();\n throw error;\n }\n}\n\nasync function sendLink(env: AuthWorkerEnv, config: AuthConfig, to: string, link: string): Promise<void> {\n const name = config.appName;\n const minutes = LINK_TTL_MS / 60_000;\n const notice = `The link works once and expires in ${minutes} minutes. If you did not request it, ignore this email.`;\n await env.AUTH_EMAIL.send({\n from: config.senderAddress,\n html: `<p>Open this link to sign in to ${escapeHtml(name)}:</p><p><a href=\"${escapeHtml(link)}\">${escapeHtml(link)}</a></p><p>${notice}</p>`,\n subject: `Your ${name} sign-in link`,\n text: `Open this link to sign in to ${name}:\\n\\n${link}\\n\\n${notice}`,\n to,\n });\n}\n\nasync function handleSignIn(request: Request, env: AuthWorkerEnv, config: AuthConfig): Promise<Response> {\n requireMutationGuard(request, config);\n const body = await readJsonBody(request);\n if (typeof body !== 'object' || body === null || Array.isArray(body)) throw badRequest();\n const keys = Object.keys(body);\n const requestedEmail: unknown = Reflect.get(body, 'email');\n if (keys.length !== 1 || keys[0] !== 'email' || typeof requestedEmail !== 'string') {\n throw badRequest();\n }\n const email = requestedEmail.trim().toLowerCase();\n if (!isWellFormedEmail(email)) {\n throw badRequest();\n }\n const nowMs = Date.now();\n const admissionRetry = await spendSignInBudget(env.AUTH_DB, nowMs);\n if (admissionRetry !== null) {\n throw new ApiError(429, 'rate-limited', 'Too many sign-in requests. Try again later.', {\n retryAfterSeconds: admissionRetry,\n });\n }\n const user = await ensureUser(env.AUTH_DB, email);\n const retryAfterSeconds = await spendLinkBudget(env.AUTH_DB, user.id, nowMs);\n if (retryAfterSeconds !== null) {\n throw new ApiError(429, 'rate-limited', 'Too many sign-in emails. Try again later.', { retryAfterSeconds });\n }\n const token = randomToken();\n const tokenHash = await sha256Hex(token);\n await insertToken(env.AUTH_DB, tokenHash, user.id, nowMs);\n try {\n await sendLink(env, config, user.email, `${config.publicOrigin}/api/v1/auth/verify?token=${token}`);\n } catch (error) {\n // An unsent link is useless; the spent allowance is deliberately not refunded.\n await deleteToken(env.AUTH_DB, tokenHash).catch(() => undefined);\n throw new AuthOperationError(503, 'email', { cause: error });\n }\n return json(202, { ok: true }, noStore);\n}\n\nasync function handleVerify(request: Request, env: AuthWorkerEnv, config: AuthConfig): Promise<Response> {\n if (request.method === 'GET') {\n const token = queryParams(new URL(request.url), ['token']).get('token') ?? '';\n if (!LINK_TOKEN.test(token)) throw badRequest('The link is not valid.');\n return confirmationPage({ ...config, nonce: randomToken(), token });\n }\n requireMutationGuard(request, config, true);\n queryParams(new URL(request.url), []);\n const body = await readJsonBody(request);\n if (typeof body !== 'object' || body === null || Array.isArray(body) || Object.keys(body).length !== 1) {\n throw badRequest('The link is not valid.');\n }\n const token: unknown = Reflect.get(body, 'token');\n if (typeof token !== 'string') throw badRequest('The link is not valid.');\n if (!LINK_TOKEN.test(token)) throw badRequest('The link is not valid.');\n const user = await consumeToken(env.AUTH_DB, await sha256Hex(token), Date.now());\n if (user === null) throw new ApiError(410, 'gone', 'That link has expired or was already used.');\n const session = await mintSession(config, user.id, config.operatorEmails.has(user.email), user.sessionVersion);\n return new Response(null, {\n headers: {\n ...noStore,\n 'set-cookie': sessionCookieValue(session.token, { maxAgeSeconds: session.maxAge, secure: secure(config) }),\n },\n status: 204,\n });\n}\n\n/** The issuer's half of a complete export: the account record only, never tokens or budget state. */\nasync function handleExport(request: Request, env: AuthWorkerEnv, config: AuthConfig): Promise<Response> {\n const { subject } = await requireSession(request, config, env);\n const row = await readAccount(env.AUTH_DB, subject);\n if (row === null) throw unauthenticated();\n return json(\n 200,\n { createdAt: row.created_at, email: row.email, id: row.id, lastSignInAt: row.last_sign_in_at },\n noStore,\n );\n}\n\n/** Re-serializes a failed catalog response into the public envelope; anything unrecognizable is 503. */\nasync function archiveFailure(response: Response): Promise<Response> {\n const text = await response.text().catch(() => '');\n let parsed: unknown = null;\n try {\n parsed = JSON.parse(text);\n } catch {\n parsed = null;\n }\n const envelope = archiveErrorResponseSchema.safeParse(parsed);\n if (!envelope.success || response.status < 400 || response.status > 599) throw unavailable();\n const { code, message, requestId } = envelope.data.error;\n const retryAfter = response.headers.get('retry-after');\n return json(\n response.status,\n { error: { code, message, requestId } },\n retryAfter !== null && /^\\d{1,6}$/.test(retryAfter) ? { ...noStore, 'retry-after': retryAfter } : noStore,\n );\n}\n\n/**\n * Full erasure: catalog session revocation and profile erasure first (through the AUTH_ARCHIVE service binding,\n * presenting the caller's own session), then the issuer's account row, and only then\n * the cookie is expired. Any failure keeps the browser session so the same request\n * can be retried; both halves are idempotent, so a retry after a partial success\n * re-runs the catalog no-op and finishes the issuer side. The account precondition\n * pins every attempt to the confirmed issuer subject, even without a publisher.\n */\nasync function handleErase(request: Request, env: AuthWorkerEnv, config: AuthConfig): Promise<Response> {\n const origin = requireMutationGuard(request, config);\n const { subject, token } = await requireSession(request, config, env);\n const account = request.headers.get(ACCOUNT_HEADER);\n if (account === null || !uuidSchema.safeParse(account).success) throw badRequest();\n if (account !== subject) {\n throw new ApiError(409, 'account-changed', 'The signed-in account changed since this request was prepared.');\n }\n const headers = new Headers({\n cookie: `${SESSION_COOKIE_NAME}=${token}`,\n origin,\n [REQUEST_MARKER_HEADER]: '1',\n });\n const publisher = request.headers.get(PUBLISHER_HEADER);\n if (publisher !== null) {\n if (!PUBLISHER_ID.test(publisher)) throw badRequest();\n headers.set(PUBLISHER_HEADER, publisher);\n }\n let archived: Response;\n try {\n archived = await serviceRequest(\n env.AUTH_ARCHIVE,\n new Request(`${config.publicOrigin}/api/v1/me/account`, { headers, method: 'DELETE', redirect: 'manual' }),\n async (response) => {\n if (response.status === 204) {\n await response.body?.cancel();\n return response;\n }\n return archiveFailure(response);\n },\n );\n } catch (error) {\n throw new AuthOperationError(503, 'catalog', { cause: error });\n }\n if (archived.status !== 204) return archived;\n await deleteAccount(env.AUTH_DB, subject);\n return new Response(null, {\n headers: { ...noStore, 'set-cookie': expiredSessionCookie(secure(config)) },\n status: 204,\n });\n}\n\nasync function handleSignOutAll(request: Request, env: AuthWorkerEnv, config: AuthConfig): Promise<Response> {\n requireMutationGuard(request, config);\n const { subject, sessionVersion } = await requireSession(request, config, env);\n const account = request.headers.get(ACCOUNT_HEADER);\n if (account === null || !uuidSchema.safeParse(account).success) throw badRequest();\n if (account !== subject)\n throw new ApiError(409, 'account-changed', 'The signed-in account changed since this request was prepared.');\n if ((await revokeSessions(env.AUTH_DB, subject, sessionVersion)) === null) throw unauthenticated();\n return json(200, { ok: true }, { ...noStore, 'set-cookie': expiredSessionCookie(secure(config)) });\n}\n\nasync function handleHealth(env: AuthWorkerEnv): Promise<Response> {\n let reason: 'configuration-unavailable' | 'database-unavailable' | 'schema-mismatch' | null = null;\n let dbSchemaVersion: number | null = null;\n try {\n await authConfig(env);\n } catch {\n reason = 'configuration-unavailable';\n }\n if (reason === null) {\n try {\n dbSchemaVersion = await authReadiness(env.AUTH_DB);\n if (dbSchemaVersion !== AUTH_SCHEMA_VERSION) reason = 'schema-mismatch';\n } catch {\n reason = 'database-unavailable';\n }\n }\n return json(\n reason === null ? 200 : 503,\n archiveAuthHealthSchema.parse({\n dbSchemaVersion,\n reason,\n schemaVersion: AUTH_SCHEMA_VERSION,\n status: reason === null ? 'ok' : 'degraded',\n }),\n noStore,\n );\n}\nasync function route(request: Request, env: AuthWorkerEnv): Promise<Response> {\n const method = request.method.toUpperCase();\n const routeId = matchRoute(request);\n if (routeId === undefined) throw new ApiError(404, 'not-found', 'No such route.');\n if (routeId === 'health' && method === 'GET') return handleHealth(env);\n const config = await authConfig(env);\n const cors = corsHeaders(request, config);\n if (method === 'OPTIONS') return preflight(request, config, ROUTE_METHODS[routeId]);\n if (!ROUTE_METHODS[routeId].includes(method)) {\n throw new ApiError(405, 'method-not-allowed', 'That method is not allowed here.');\n }\n let response: Response;\n switch (routeId) {\n case 'health':\n response = await handleHealth(env);\n break;\n case 'session':\n await requireSession(request, config, env);\n response = json(200, { ok: true }, noStore);\n break;\n case 'signOutAll':\n response = await handleSignOutAll(request, env, config);\n break;\n case 'jwks':\n response = json(200, { keys: config.jwks }, noStore);\n break;\n case 'signIn':\n response = await handleSignIn(request, env, config);\n break;\n case 'verify':\n response = await handleVerify(request, env, config);\n break;\n case 'account':\n response = method === 'GET' ? await handleExport(request, env, config) : await handleErase(request, env, config);\n break;\n }\n return withCors(response, cors);\n}\n\nfunction withCors(response: Response, cors: Record<string, string>): Response {\n if (Object.keys(cors).length === 0) return response;\n const next = new Response(response.body, response);\n for (const [key, value] of Object.entries(cors)) next.headers.set(key, value);\n return next;\n}\n\nconst diagnostic = (error: unknown): AuthDiagnostic =>\n error instanceof AuthOperationError ? error.authDiagnostic : { kind: 'internal' };\n\n/**\n * Builds the reference identity issuer. Route behavior is fixed; every deployment\n * difference is validated configuration — the app name, trusted origins, audience and\n * issuer claims, signing keys, operator roster, the catalog service binding, and\n * where links and redirects land.\n */\nexport function createAuthWorker<E extends AuthWorkerEnv>(): AuthWorkerHandler<E> {\n const tappers = new Set<(event: AuthEvent) => void>();\n const emit = (event: AuthEvent): void => {\n for (const handler of tappers) {\n try {\n handler(event);\n } catch {\n // Observers never change request or scheduled outcomes.\n }\n }\n };\n return {\n async fetch(request: Request, env: E): Promise<Response> {\n const requestId = crypto.randomUUID();\n const started = Date.now();\n let response: Response;\n let failure: unknown;\n try {\n response = await route(request, env);\n } catch (error) {\n failure = error;\n response = toErrorResponse(error, requestId);\n // Failures stay readable by allowed origins; config faults have no trusted origin set.\n const config = await authConfig(env).catch(() => null);\n if (config !== null) {\n if (\n matchRoute(request) === 'verify' &&\n request.method === 'GET' &&\n error instanceof ApiError &&\n error.status === 400\n )\n response = confirmationPage({ ...config, nonce: randomToken(), token: null });\n response = withCors(response, corsHeaders(request, config));\n }\n }\n if (matchRoute(request) === 'verify') {\n response.headers.set('cache-control', 'no-store');\n response.headers.set('referrer-policy', 'no-referrer');\n response.headers.set('x-content-type-options', 'nosniff');\n if (!response.headers.has('content-security-policy')) {\n response.headers.set(\n 'content-security-policy',\n \"default-src 'none'; base-uri 'none'; frame-ancestors 'none'\",\n );\n }\n }\n if (tappers.size > 0) {\n const routeId = matchRoute(request);\n const code =\n response.status < 400\n ? 'ok'\n : routeId === 'health'\n ? 'unavailable'\n : failure instanceof ApiError\n ? failure.code\n : 'internal';\n // Account erasure may forward a catalog error response rather than throw.\n const forwardedCode =\n failure === undefined && response.status >= 400\n ? archiveErrorResponseSchema.safeParse(\n await response\n .clone()\n .json()\n .catch(() => null),\n )\n : null;\n const health =\n routeId === 'health' && response.status === 503\n ? archiveAuthHealthSchema.safeParse(\n await response\n .clone()\n .json()\n .catch(() => null),\n )\n : null;\n emit({\n code: forwardedCode?.success ? forwardedCode.data.error.code : code,\n ms: Math.max(0, Date.now() - started),\n requestId,\n route: routeId === undefined ? 'unmatched' : ROUTE_LABELS[routeId],\n status: response.status,\n type: 'request',\n ...(response.status >= 500\n ? {\n diagnostic: health?.success\n ? {\n kind:\n health.data.reason === 'configuration-unavailable'\n ? 'configuration'\n : health.data.reason === 'schema-mismatch'\n ? 'schema'\n : 'database',\n }\n : failure === undefined\n ? { kind: 'catalog' as const }\n : diagnostic(failure),\n }\n : {}),\n });\n }\n return response;\n },\n\n async scheduled(_controller: unknown, env: E): Promise<void> {\n try {\n await authConfig(env);\n const removed = await sweep(env.AUTH_DB, Date.now());\n if (tappers.size > 0) emit({ removed, status: 'ok', type: 'scheduled' });\n } catch (error) {\n if (tappers.size > 0) emit({ diagnostic: diagnostic(error), status: 'failed', type: 'scheduled' });\n throw error;\n }\n },\n tap(handler, options) {\n if (options?.signal?.aborted) return () => undefined;\n const observer = (event: AuthEvent): void => handler(event);\n const unsubscribe = (): void => {\n tappers.delete(observer);\n options?.signal?.removeEventListener('abort', unsubscribe);\n };\n tappers.add(observer);\n options?.signal?.addEventListener('abort', unsubscribe, { once: true });\n return unsubscribe;\n },\n };\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"worker.js","names":[],"sources":["../../src/auth/worker.ts"],"sourcesContent":["import { errors, jwtVerify, SignJWT } from 'jose';\nimport { sha256Hex } from '../_crypto';\nimport {\n ACCOUNT_HEADER,\n ARCHIVE_OPERATOR_ROLE,\n type ArchiveErrorCode,\n AUTH_SCHEMA_VERSION,\n archiveAuthHealthSchema,\n archiveErrorResponseSchema,\n isWellFormedText,\n OPERATOR_SESSION_MAX_AGE_SECONDS,\n PUBLISHER_HEADER,\n REQUEST_MARKER_HEADER,\n SESSION_COOKIE_NAME,\n SESSION_MAX_AGE_SECONDS,\n sessionCookieValue,\n uuidSchema,\n} from '../contract';\nimport { serviceRequest } from '../worker/_service';\nimport {\n ApiError,\n badRequest,\n expiredSessionCookie,\n forbidden,\n json,\n noStore,\n queryParams,\n readJsonBody,\n sessionToken,\n toErrorResponse,\n unauthenticated,\n} from '../worker/http';\nimport type { ArchiveExecutionContext } from '../worker/worker';\nimport { confirmationPage, escapeHtml } from './_confirmation';\nimport { type AuthConfig, type AuthWorkerEnv, authConfig, isWellFormedEmail } from './config';\nimport { type AuthDiagnostic, AuthOperationError } from './errors';\nimport {\n authReadiness,\n consumeToken,\n deleteAccount,\n deleteToken,\n ensureUser,\n insertToken,\n LINK_TTL_MS,\n readAccount,\n revokeSessions,\n spendLinkBudget,\n spendSignInBudget,\n sweep,\n} from './store';\n\n/**\n * The reference identity issuer: passwordless email accounts. A POST with an email\n * address ensures the account row and sends a single-use magic link; consuming it\n * signs the browser in by setting the `archive_session` cookie a catalog Worker\n * verifies — an RS256 JWT minted by the active private key, verifiable through\n * `/jwks.json` alongside any configured retired public keys. `OPERATOR_EMAILS` is\n * the one operator-membership truth; operator sessions expire after one hour,\n * bounding authority from previously minted role claims. The account routes export the issuer's\n * own record and erase it together with the catalog profile.\n */\nexport interface AuthWorkerHandler<E extends AuthWorkerEnv = AuthWorkerEnv> {\n fetch(request: Request, env: E, ctx?: ArchiveExecutionContext): Promise<Response>;\n /** The cron sweep: expired links, closed send windows and never-verified accounts. */\n scheduled(controller: unknown, env: E, ctx?: ArchiveExecutionContext): Promise<void>;\n tap(handler: (event: AuthEvent) => void, options?: { readonly signal?: AbortSignal }): () => void;\n}\n\nexport type AuthEvent =\n | {\n readonly type: 'request';\n readonly requestId: string;\n readonly route:\n | '/auth/account'\n | '/auth/jwks.json'\n | '/auth/sign-in'\n | '/auth/verify'\n | '/auth/session'\n | '/auth/sign-out-all'\n | '/auth/health'\n | 'unmatched';\n readonly status: number;\n readonly code: ArchiveErrorCode | 'ok';\n readonly ms: number;\n readonly diagnostic?: AuthDiagnostic;\n }\n | { readonly type: 'scheduled'; readonly status: 'ok'; readonly removed: number }\n | { readonly type: 'scheduled'; readonly status: 'failed'; readonly diagnostic: AuthDiagnostic };\n\nconst ROUTES = {\n account: /^\\/api\\/v1\\/auth\\/account$/,\n health: /^\\/api\\/v1\\/auth\\/health$/,\n jwks: /^\\/api\\/v1\\/auth\\/jwks\\.json$/,\n session: /^\\/api\\/v1\\/auth\\/session$/,\n signIn: /^\\/api\\/v1\\/auth\\/sign-in$/,\n signOutAll: /^\\/api\\/v1\\/auth\\/sign-out-all$/,\n verify: /^\\/api\\/v1\\/auth\\/verify$/,\n} as const;\n\nconst ROUTE_METHODS: Record<keyof typeof ROUTES, readonly string[]> = {\n account: ['GET', 'DELETE'],\n health: ['GET'],\n jwks: ['GET'],\n session: ['GET'],\n signIn: ['POST'],\n signOutAll: ['POST'],\n verify: ['GET', 'POST'],\n};\n\nconst ROUTE_LABELS = {\n account: '/auth/account',\n health: '/auth/health',\n jwks: '/auth/jwks.json',\n session: '/auth/session',\n signIn: '/auth/sign-in',\n signOutAll: '/auth/sign-out-all',\n verify: '/auth/verify',\n} as const;\n\nconst matchRoute = (request: Request): keyof typeof ROUTES | undefined =>\n (Object.keys(ROUTES) as (keyof typeof ROUTES)[]).find((id) => ROUTES[id].test(new URL(request.url).pathname));\n\nconst TOKEN_BYTES = 32;\n// Base64url of TOKEN_BYTES random bytes, unpadded.\nconst LINK_TOKEN = /^[A-Za-z0-9_-]{43}$/;\nconst PUBLISHER_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;\nconst SUBJECT_MAX = 256;\nconst CLOCK_TOLERANCE_SECONDS = 5;\n\nconst randomToken = (): string =>\n btoa(String.fromCharCode(...crypto.getRandomValues(new Uint8Array(TOKEN_BYTES))))\n .replaceAll('+', '-')\n .replaceAll('/', '_')\n .replace(/=+$/, '');\n\nconst unavailable = (): ApiError => new AuthOperationError(503, 'catalog');\n\n/** Exact allowed Origin plus the request marker, like the catalog's mutation guard. */\nfunction requireMutationGuard(request: Request, config: AuthConfig, allowPublicOrigin = false): string {\n const origin = request.headers.get('origin');\n if (origin === null || (!config.allowedOrigins.has(origin) && !(allowPublicOrigin && origin === config.publicOrigin)))\n throw forbidden();\n if (request.headers.get(REQUEST_MARKER_HEADER) !== '1') throw forbidden();\n return origin;\n}\n\nfunction corsHeaders(request: Request, config: AuthConfig): Record<string, string> {\n const origin = request.headers.get('origin');\n if (\n origin === null ||\n (!config.allowedOrigins.has(origin) && !(matchRoute(request) === 'verify' && origin === config.publicOrigin))\n )\n return {};\n return { 'access-control-allow-credentials': 'true', 'access-control-allow-origin': origin, vary: 'Origin' };\n}\n\nfunction preflight(request: Request, config: AuthConfig, methods: readonly string[]): Response {\n const cors = corsHeaders(request, config);\n if (Object.keys(cors).length === 0) throw forbidden();\n return new Response(null, {\n headers: {\n ...cors,\n 'access-control-allow-headers': `Content-Type, ${ACCOUNT_HEADER}, ${PUBLISHER_HEADER}, ${REQUEST_MARKER_HEADER}`,\n 'access-control-allow-methods': [...methods, 'OPTIONS'].join(', '),\n 'access-control-max-age': '600',\n },\n status: 204,\n });\n}\n\nconst secure = (config: AuthConfig): boolean => config.appEnv === 'production';\n\nasync function mintSession(\n config: AuthConfig,\n subject: string,\n operator: boolean,\n sessionVersion: number,\n): Promise<{ maxAge: number; token: string }> {\n const maxAge = operator ? OPERATOR_SESSION_MAX_AGE_SECONDS : SESSION_MAX_AGE_SECONDS;\n const token = await new SignJWT({ sessionVersion, ...(operator ? { roles: [ARCHIVE_OPERATOR_ROLE] } : {}) })\n .setProtectedHeader({ alg: 'RS256', kid: config.signing.kid, typ: 'JWT' })\n .setIssuedAt()\n .setJti(crypto.randomUUID())\n .setIssuer(config.issuer)\n .setAudience(config.audience)\n .setSubject(subject)\n .setExpirationTime(`${maxAge}s`)\n .sign(config.signing.key);\n return { maxAge, token };\n}\n\n/** Verifies the request's own session cookie against every published key; returns the token and subject. */\nasync function requireSession(\n request: Request,\n config: AuthConfig,\n env: AuthWorkerEnv,\n): Promise<{ subject: string; token: string; sessionVersion: number }> {\n const token = sessionToken(request);\n if (token === null) throw unauthenticated();\n try {\n const { payload } = await jwtVerify(token, config.verifier, {\n algorithms: ['RS256'],\n audience: config.audience,\n clockTolerance: CLOCK_TOLERANCE_SECONDS,\n issuer: config.issuer,\n maxTokenAge: SESSION_MAX_AGE_SECONDS,\n requiredClaims: ['sub', 'iss', 'aud', 'iat', 'exp', 'sessionVersion'],\n });\n const subject = payload.sub ?? '';\n if (subject.length === 0 || subject.length > SUBJECT_MAX || !isWellFormedText(subject)) throw unauthenticated();\n const version = payload.sessionVersion;\n if (typeof version !== 'number' || !Number.isSafeInteger(version) || version < 0) throw unauthenticated();\n if (typeof payload.iat !== 'number' || payload.iat > Date.now() / 1000 + CLOCK_TOLERANCE_SECONDS)\n throw unauthenticated();\n if (\n Array.isArray(payload.roles) &&\n payload.roles.includes(ARCHIVE_OPERATOR_ROLE) &&\n (typeof payload.exp !== 'number' ||\n payload.exp - payload.iat > OPERATOR_SESSION_MAX_AGE_SECONDS ||\n Date.now() / 1000 - payload.iat > OPERATOR_SESSION_MAX_AGE_SECONDS + CLOCK_TOLERANCE_SECONDS)\n )\n throw unauthenticated();\n const account = await readAccount(env.AUTH_DB, subject);\n if (account === null || account.session_version !== version) throw unauthenticated();\n return { sessionVersion: version, subject, token };\n } catch (error) {\n if (error instanceof errors.JOSEError) throw unauthenticated();\n throw error;\n }\n}\n\nasync function sendLink(env: AuthWorkerEnv, config: AuthConfig, to: string, link: string): Promise<void> {\n const name = config.appName;\n const minutes = LINK_TTL_MS / 60_000;\n const notice = `The link works once and expires in ${minutes} minutes. If you did not request it, ignore this email.`;\n await env.AUTH_EMAIL.send({\n from: config.senderAddress,\n html: `<p>Open this link to sign in to ${escapeHtml(name)}:</p><p><a href=\"${escapeHtml(link)}\">${escapeHtml(link)}</a></p><p>${notice}</p>`,\n subject: `Your ${name} sign-in link`,\n text: `Open this link to sign in to ${name}:\\n\\n${link}\\n\\n${notice}`,\n to,\n });\n}\n\nasync function handleSignIn(request: Request, env: AuthWorkerEnv, config: AuthConfig): Promise<Response> {\n requireMutationGuard(request, config);\n const body = await readJsonBody(request);\n if (typeof body !== 'object' || body === null || Array.isArray(body)) throw badRequest();\n const keys = Object.keys(body);\n const requestedEmail: unknown = Reflect.get(body, 'email');\n if (keys.length !== 1 || keys[0] !== 'email' || typeof requestedEmail !== 'string') {\n throw badRequest();\n }\n const email = requestedEmail.trim().toLowerCase();\n if (!isWellFormedEmail(email)) {\n throw badRequest();\n }\n const nowMs = Date.now();\n const admissionRetry = await spendSignInBudget(env.AUTH_DB, nowMs);\n if (admissionRetry !== null) {\n throw new ApiError(429, 'rate-limited', 'Too many sign-in requests. Try again later.', {\n retryAfterSeconds: admissionRetry,\n });\n }\n const user = await ensureUser(env.AUTH_DB, email);\n const retryAfterSeconds = await spendLinkBudget(env.AUTH_DB, user.id, nowMs);\n if (retryAfterSeconds !== null) {\n throw new ApiError(429, 'rate-limited', 'Too many sign-in emails. Try again later.', { retryAfterSeconds });\n }\n const token = randomToken();\n const tokenHash = await sha256Hex(token);\n await insertToken(env.AUTH_DB, tokenHash, user.id, nowMs);\n try {\n await sendLink(env, config, user.email, `${config.publicOrigin}/api/v1/auth/verify?token=${token}`);\n } catch (error) {\n // An unsent link is useless; the spent allowance is deliberately not refunded.\n await deleteToken(env.AUTH_DB, tokenHash).catch(() => undefined);\n throw new AuthOperationError(503, 'email', { cause: error });\n }\n return json(202, { ok: true }, noStore);\n}\n\nasync function handleVerify(request: Request, env: AuthWorkerEnv, config: AuthConfig): Promise<Response> {\n if (request.method === 'GET') {\n const token = queryParams(new URL(request.url), ['token']).get('token') ?? '';\n if (!LINK_TOKEN.test(token)) throw badRequest('The link is not valid.');\n return confirmationPage({ ...config, nonce: randomToken(), token });\n }\n requireMutationGuard(request, config, true);\n queryParams(new URL(request.url), []);\n const body = await readJsonBody(request);\n if (typeof body !== 'object' || body === null || Array.isArray(body) || Object.keys(body).length !== 1) {\n throw badRequest('The link is not valid.');\n }\n const token: unknown = Reflect.get(body, 'token');\n if (typeof token !== 'string') throw badRequest('The link is not valid.');\n if (!LINK_TOKEN.test(token)) throw badRequest('The link is not valid.');\n const user = await consumeToken(env.AUTH_DB, await sha256Hex(token), Date.now());\n if (user === null) throw new ApiError(410, 'gone', 'That link has expired or was already used.');\n const session = await mintSession(config, user.id, config.operatorEmails.has(user.email), user.sessionVersion);\n return new Response(null, {\n headers: {\n ...noStore,\n 'set-cookie': sessionCookieValue(session.token, { maxAgeSeconds: session.maxAge, secure: secure(config) }),\n },\n status: 204,\n });\n}\n\n/** The issuer's half of a complete export: the account record only, never tokens or budget state. */\nasync function handleExport(request: Request, env: AuthWorkerEnv, config: AuthConfig): Promise<Response> {\n const { subject } = await requireSession(request, config, env);\n const row = await readAccount(env.AUTH_DB, subject);\n if (row === null) throw unauthenticated();\n return json(\n 200,\n { createdAt: row.created_at, email: row.email, id: row.id, lastSignInAt: row.last_sign_in_at },\n noStore,\n );\n}\n\n/** Re-serializes a failed catalog response into the public envelope; anything unrecognizable is 503. */\nasync function archiveFailure(response: Response): Promise<Response> {\n const text = await response.text().catch(() => '');\n let parsed: unknown = null;\n try {\n parsed = JSON.parse(text);\n } catch {\n parsed = null;\n }\n const envelope = archiveErrorResponseSchema.safeParse(parsed);\n if (!envelope.success || response.status < 400 || response.status > 599) throw unavailable();\n const { code, message, requestId } = envelope.data.error;\n const retryAfter = response.headers.get('retry-after');\n return json(\n response.status,\n { error: { code, message, requestId } },\n retryAfter !== null && /^\\d{1,6}$/.test(retryAfter) ? { ...noStore, 'retry-after': retryAfter } : noStore,\n );\n}\n\n/**\n * Full erasure: catalog session revocation and profile erasure first (through the AUTH_ARCHIVE service binding,\n * presenting the caller's own session), then the issuer's account row, and only then\n * the cookie is expired. Any failure keeps the browser session so the same request\n * can be retried; both halves are idempotent, so a retry after a partial success\n * re-runs the catalog no-op and finishes the issuer side. The account precondition\n * pins every attempt to the confirmed issuer subject, even without a publisher.\n */\nasync function handleErase(request: Request, env: AuthWorkerEnv, config: AuthConfig): Promise<Response> {\n const origin = requireMutationGuard(request, config);\n const { subject, token } = await requireSession(request, config, env);\n const account = request.headers.get(ACCOUNT_HEADER);\n if (account === null || !uuidSchema.safeParse(account).success) throw badRequest();\n if (account !== subject) {\n throw new ApiError(409, 'account-changed', 'The signed-in account changed since this request was prepared.');\n }\n const headers = new Headers({\n cookie: `${SESSION_COOKIE_NAME}=${token}`,\n origin,\n [REQUEST_MARKER_HEADER]: '1',\n });\n const publisher = request.headers.get(PUBLISHER_HEADER);\n if (publisher !== null) {\n if (!PUBLISHER_ID.test(publisher)) throw badRequest();\n headers.set(PUBLISHER_HEADER, publisher);\n }\n let archived: Response;\n try {\n archived = await serviceRequest(\n env.AUTH_ARCHIVE,\n new Request(`${config.publicOrigin}/api/v1/me/account`, { headers, method: 'DELETE', redirect: 'manual' }),\n async (response) => {\n if (response.status === 204) {\n await response.body?.cancel();\n return response;\n }\n return archiveFailure(response);\n },\n );\n } catch (error) {\n throw new AuthOperationError(503, 'catalog', { cause: error });\n }\n if (archived.status !== 204) return archived;\n await deleteAccount(env.AUTH_DB, subject);\n return new Response(null, {\n headers: { ...noStore, 'set-cookie': expiredSessionCookie(secure(config)) },\n status: 204,\n });\n}\n\nasync function handleSignOutAll(request: Request, env: AuthWorkerEnv, config: AuthConfig): Promise<Response> {\n requireMutationGuard(request, config);\n const { subject, sessionVersion } = await requireSession(request, config, env);\n const account = request.headers.get(ACCOUNT_HEADER);\n if (account === null || !uuidSchema.safeParse(account).success) throw badRequest();\n if (account !== subject)\n throw new ApiError(409, 'account-changed', 'The signed-in account changed since this request was prepared.');\n if ((await revokeSessions(env.AUTH_DB, subject, sessionVersion)) === null) throw unauthenticated();\n return json(200, { ok: true }, { ...noStore, 'set-cookie': expiredSessionCookie(secure(config)) });\n}\n\nasync function handleHealth(env: AuthWorkerEnv): Promise<Response> {\n let reason: 'configuration-unavailable' | 'database-unavailable' | 'schema-mismatch' | null = null;\n let dbSchemaVersion: number | null = null;\n try {\n await authConfig(env);\n } catch {\n reason = 'configuration-unavailable';\n }\n if (reason === null) {\n try {\n dbSchemaVersion = await authReadiness(env.AUTH_DB);\n if (dbSchemaVersion !== AUTH_SCHEMA_VERSION) reason = 'schema-mismatch';\n } catch {\n reason = 'database-unavailable';\n }\n }\n return json(\n reason === null ? 200 : 503,\n archiveAuthHealthSchema.parse({\n dbSchemaVersion,\n reason,\n schemaVersion: AUTH_SCHEMA_VERSION,\n status: reason === null ? 'ok' : 'degraded',\n }),\n noStore,\n );\n}\nasync function route(request: Request, env: AuthWorkerEnv): Promise<Response> {\n const method = request.method.toUpperCase();\n const routeId = matchRoute(request);\n if (routeId === undefined) throw new ApiError(404, 'not-found', 'No such route.');\n if (routeId === 'health' && method === 'GET') return handleHealth(env);\n const config = await authConfig(env);\n const cors = corsHeaders(request, config);\n if (method === 'OPTIONS') return preflight(request, config, ROUTE_METHODS[routeId]);\n if (!ROUTE_METHODS[routeId].includes(method)) {\n throw new ApiError(405, 'method-not-allowed', 'That method is not allowed here.');\n }\n let response: Response;\n switch (routeId) {\n case 'health':\n response = await handleHealth(env);\n break;\n case 'session':\n await requireSession(request, config, env);\n response = json(200, { ok: true }, noStore);\n break;\n case 'signOutAll':\n response = await handleSignOutAll(request, env, config);\n break;\n case 'jwks':\n response = json(200, { keys: config.jwks }, noStore);\n break;\n case 'signIn':\n response = await handleSignIn(request, env, config);\n break;\n case 'verify':\n response = await handleVerify(request, env, config);\n break;\n case 'account':\n response = method === 'GET' ? await handleExport(request, env, config) : await handleErase(request, env, config);\n break;\n }\n return withCors(response, cors);\n}\n\nfunction withCors(response: Response, cors: Record<string, string>): Response {\n if (Object.keys(cors).length === 0) return response;\n const next = new Response(response.body, response);\n for (const [key, value] of Object.entries(cors)) next.headers.set(key, value);\n return next;\n}\n\nconst diagnostic = (error: unknown): AuthDiagnostic =>\n error instanceof AuthOperationError ? error.authDiagnostic : { kind: 'internal' };\n\n/**\n * Builds the reference identity issuer. Route behavior is fixed; every deployment\n * difference is validated configuration — the app name, trusted origins, audience and\n * issuer claims, signing keys, operator roster, the catalog service binding, and\n * where links and redirects land.\n */\nexport function createAuthWorker<E extends AuthWorkerEnv>(): AuthWorkerHandler<E> {\n const tappers = new Set<(event: AuthEvent) => void>();\n const emit = (event: AuthEvent): void => {\n for (const handler of tappers) {\n try {\n handler(event);\n } catch {\n // Observers never change request or scheduled outcomes.\n }\n }\n };\n return {\n async fetch(request: Request, env: E): Promise<Response> {\n const requestId = crypto.randomUUID();\n const started = Date.now();\n let response: Response;\n let failure: unknown;\n try {\n response = await route(request, env);\n } catch (error) {\n failure = error;\n response = toErrorResponse(error, requestId);\n // Failures stay readable by allowed origins; config faults have no trusted origin set.\n const config = await authConfig(env).catch(() => null);\n if (config !== null) {\n if (\n matchRoute(request) === 'verify' &&\n request.method === 'GET' &&\n error instanceof ApiError &&\n error.status === 400\n )\n response = confirmationPage({ ...config, nonce: randomToken(), token: null });\n response = withCors(response, corsHeaders(request, config));\n }\n }\n if (matchRoute(request) === 'verify') {\n response.headers.set('cache-control', 'no-store');\n response.headers.set('referrer-policy', 'no-referrer');\n response.headers.set('x-content-type-options', 'nosniff');\n if (!response.headers.has('content-security-policy')) {\n response.headers.set(\n 'content-security-policy',\n \"default-src 'none'; base-uri 'none'; frame-ancestors 'none'\",\n );\n }\n }\n if (tappers.size > 0) {\n const routeId = matchRoute(request);\n const code =\n response.status < 400\n ? 'ok'\n : routeId === 'health'\n ? 'unavailable'\n : failure instanceof ApiError\n ? failure.code\n : 'internal';\n // Account erasure may forward a catalog error response rather than throw.\n const forwardedCode =\n failure === undefined && response.status >= 400\n ? archiveErrorResponseSchema.safeParse(\n await response\n .clone()\n .json()\n .catch(() => null),\n )\n : null;\n const health =\n routeId === 'health' && response.status === 503\n ? archiveAuthHealthSchema.safeParse(\n await response\n .clone()\n .json()\n .catch(() => null),\n )\n : null;\n emit({\n code: forwardedCode?.success ? forwardedCode.data.error.code : code,\n ms: Math.max(0, Date.now() - started),\n requestId,\n route: routeId === undefined ? 'unmatched' : ROUTE_LABELS[routeId],\n status: response.status,\n type: 'request',\n ...(response.status >= 500\n ? {\n diagnostic: health?.success\n ? {\n kind:\n health.data.reason === 'configuration-unavailable'\n ? 'configuration'\n : health.data.reason === 'schema-mismatch'\n ? 'schema'\n : 'database',\n }\n : failure === undefined\n ? { kind: 'catalog' as const }\n : diagnostic(failure),\n }\n : {}),\n });\n }\n return response;\n },\n\n async scheduled(_controller: unknown, env: E): Promise<void> {\n try {\n await authConfig(env);\n const removed = await sweep(env.AUTH_DB, Date.now());\n if (tappers.size > 0) emit({ removed, status: 'ok', type: 'scheduled' });\n } catch (error) {\n if (tappers.size > 0) emit({ diagnostic: diagnostic(error), status: 'failed', type: 'scheduled' });\n throw error;\n }\n },\n tap(handler, options) {\n if (options?.signal?.aborted) return () => undefined;\n const observer = (event: AuthEvent): void => handler(event);\n const unsubscribe = (): void => {\n tappers.delete(observer);\n options?.signal?.removeEventListener('abort', unsubscribe);\n };\n tappers.add(observer);\n options?.signal?.addEventListener('abort', unsubscribe, { once: true });\n return unsubscribe;\n },\n };\n}\n"],"mappings":"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"}
package/dist/contract.cjs CHANGED
@@ -1,2 +1,2 @@
1
- let e=require("@vielzeug/spell");var t=`archive_session`,n=`/api/v1`,r=2592e3,i=900,sessionCookieValue=(e,r)=>`${t}=${e}; HttpOnly; SameSite=Strict; Path=${n}; Max-Age=${r.maxAgeSeconds}${r.secure?`; Secure`:``}`,a=`x-archive-publisher`,o=`X-Archive-Account`,s=`x-archive-request`,c=1,l=1,u=1,d=`archive-operator`,f=/^[a-z0-9-]{1,32}$/,p=e.s.string().uuid(),m=e.s.string().isoDateTime(),h=e.s.string().regex(/^[0-9a-f]{64}$/),g=/[\p{Cs}\p{Cc}]/u,isWellFormedText=e=>!g.test(e),textString=()=>e.s.string().check(isWellFormedText),_=textString().trim().max(24),v=e.s.object({id:p,name:_}),y=e.s.object({id:p,name:_,operator:e.s.boolean()}).nullable(),b=e.s.object({createdAt:m,email:e.s.string().email(),id:p,lastSignInAt:m.nullable()}),x=e.s.object({ids:e.s.array(p)}),S=e.s.object({cursor:e.s.string().nullable(),ids:e.s.array(p)}),C=e.s.object({id:p,likeCount:e.s.number().int().min(0).max(2**53-1),liked:e.s.boolean()}),w=Object.freeze({aliasChars:24,cursorChars:1024,idempotencyRetentionMs:1728e5,likeStateIds:50,maxPageSize:50,maxPublications:50,pageSize:10,payloadBytes:8192,publicCacheSeconds:30,reportReasonChars:256,requestBytes:12288,searchChars:64,searchTerms:6,tagsPerEntry:16,titleChars:128,vocabChars:64}),T=e.s.object({aliasChars:e.s.number().int().min(1),cursorChars:e.s.number().int().min(1),idempotencyRetentionMs:e.s.number().int().min(1),likeStateIds:e.s.number().int().min(1),maxPageSize:e.s.number().int().min(1),maxPublications:e.s.number().int().min(1),pageSize:e.s.number().int().min(1),payloadBytes:e.s.number().int().min(1),publicCacheSeconds:e.s.number().int().min(0),reportReasonChars:e.s.number().int().min(1),requestBytes:e.s.number().int().min(1),searchChars:e.s.number().int().min(1),searchTerms:e.s.number().int().min(1),tagsPerEntry:e.s.number().int().min(1),titleChars:e.s.number().int().min(1),vocabChars:e.s.number().int().min(1)}),E=e.s.object({dbSchemaVersion:e.s.number().int().min(0).nullable(),features:e.s.object({likes:e.s.boolean(),publishing:e.s.boolean()}),limits:T,reason:e.s.enum([`database-unavailable`,`schema-mismatch`]).nullable(),schemaVersion:e.s.literal(1),status:e.s.enum([`ok`,`degraded`])}),D=e.s.object({dbSchemaVersion:e.s.number().int().min(0).nullable(),reason:e.s.enum([`configuration-unavailable`,`database-unavailable`,`schema-mismatch`]).nullable(),schemaVersion:e.s.literal(1),status:e.s.enum([`ok`,`degraded`])}),O=[`account-changed`,`auth-unavailable`,`bad-request`,`conflict`,`forbidden`,`gone`,`internal`,`invalid-payload`,`likes-disabled`,`method-not-allowed`,`not-found`,`publisher-changed`,`publishing-disabled`,`quota`,`rate-limited`,`record-unavailable`,`timeout`,`too-large`,`unauthenticated`,`unavailable`,`unsupported-media`],k=e.s.object({error:e.s.object({code:e.s.enum(O),message:e.s.string().min(1).max(256),requestId:p}).strict()}).strict(),A=e.s.object({ok:e.s.literal(!0)}).strict(),j=e.s.object({filters:e.s.array(e.s.string().min(1).max(w.vocabChars)),name:e.s.string().regex(f),tags:e.s.array(e.s.string().min(1).max(w.vocabChars))}),M=e.s.object({cursor:e.s.string().nullable(),reports:e.s.array(e.s.object({createdAt:m,entryId:p,entryTitle:e.s.string().min(1).max(w.titleChars),id:p,reason:e.s.string().min(1).max(w.reportReasonChars),reporter:v,resolvedAt:m.nullable()}))}),N=e.s.object({cursor:e.s.string().nullable(),reports:e.s.array(e.s.object({collection:e.s.string().regex(f),createdAt:m,entryId:p,id:p,reason:e.s.string().min(1).max(w.reportReasonChars),resolvedAt:m.nullable()}))}),P=[`hide`,`unhide`,`remove`,`erase-publisher`],F=e.s.object({actions:e.s.array(e.s.object({action:e.s.enum(P),collection:e.s.string().regex(f).nullable(),createdAt:m,id:p,operatorId:p,targetId:p})),cursor:e.s.string().nullable()}),I=e.s.enum([`liked`,`recent`]);exports.ACCOUNT_HEADER=o,exports.ARCHIVE_COLLECTION_NAME_PATTERN=f,exports.ARCHIVE_CONTRACT_VERSION=c,exports.ARCHIVE_ERROR_CODES=O,exports.ARCHIVE_LIMITS=w,exports.ARCHIVE_MODERATION_ACTIONS=P,exports.ARCHIVE_OPERATOR_ROLE=d,exports.ARCHIVE_SCHEMA_VERSION=l,exports.AUTH_SCHEMA_VERSION=u,exports.ILL_FORMED_TEXT=g,exports.OPERATOR_SESSION_MAX_AGE_SECONDS=i,exports.PUBLISHER_HEADER=a,exports.REQUEST_MARKER_HEADER=s,exports.SESSION_COOKIE_NAME=t,exports.SESSION_COOKIE_PATH=n,exports.SESSION_MAX_AGE_SECONDS=r,exports.archiveAccountSchema=b,exports.archiveAliasSchema=_,exports.archiveAuthHealthSchema=D,exports.archiveAuthorSchema=v,exports.archiveCollectionMetaSchema=j,exports.archiveErrorResponseSchema=k,exports.archiveHealthSchema=E,exports.archiveIdentitySchema=y,exports.archiveLikeStateSchema=C,exports.archiveLikedIdsSchema=x,exports.archiveLikesPageSchema=S,exports.archiveLimitsSchema=T,exports.archiveModerationLogSchema=F,exports.archiveOkSchema=A,exports.archiveOwnReportsSchema=N,exports.archiveReportsSchema=M,exports.archiveSortSchema=I,exports.hex64Schema=h,exports.isWellFormedText=isWellFormedText,exports.isoTimestampSchema=m,exports.sessionCookieValue=sessionCookieValue,exports.uuidSchema=p;
1
+ let e=require("@vielzeug/spell");var t=`archive_session`,n=`/api/v1`,r=2592e3,i=3600,sessionCookieValue=(e,r)=>`${t}=${e}; HttpOnly; SameSite=Strict; Path=${n}; Max-Age=${r.maxAgeSeconds}${r.secure?`; Secure`:``}`,a=`x-archive-publisher`,o=`X-Archive-Account`,s=`x-archive-request`,c=1,l=1,u=1,d=`archive-operator`,f=/^[a-z0-9-]{1,32}$/,p=e.s.string().uuid(),m=e.s.string().isoDateTime(),h=e.s.string().regex(/^[0-9a-f]{64}$/),g=/[\p{Cs}\p{Cc}]/u,isWellFormedText=e=>!g.test(e),textString=()=>e.s.string().check(isWellFormedText),_=textString().trim().max(24),v=e.s.object({id:p,name:_}),y=e.s.object({id:p,name:_,operator:e.s.boolean()}).nullable(),b=e.s.object({createdAt:m,email:e.s.string().email(),id:p,lastSignInAt:m.nullable()}),x=e.s.object({ids:e.s.array(p)}),S=e.s.object({cursor:e.s.string().nullable(),ids:e.s.array(p)}),C=e.s.object({id:p,likeCount:e.s.number().int().min(0).max(2**53-1),liked:e.s.boolean()}),w=Object.freeze({aliasChars:24,cursorChars:1024,idempotencyRetentionMs:1728e5,likeStateIds:50,maxPageSize:50,maxPublications:50,pageSize:10,payloadBytes:8192,publicCacheSeconds:30,reportReasonChars:256,requestBytes:12288,searchChars:64,searchTerms:6,tagsPerEntry:16,titleChars:128,vocabChars:64}),T=e.s.object({aliasChars:e.s.number().int().min(1),cursorChars:e.s.number().int().min(1),idempotencyRetentionMs:e.s.number().int().min(1),likeStateIds:e.s.number().int().min(1),maxPageSize:e.s.number().int().min(1),maxPublications:e.s.number().int().min(1),pageSize:e.s.number().int().min(1),payloadBytes:e.s.number().int().min(1),publicCacheSeconds:e.s.number().int().min(0),reportReasonChars:e.s.number().int().min(1),requestBytes:e.s.number().int().min(1),searchChars:e.s.number().int().min(1),searchTerms:e.s.number().int().min(1),tagsPerEntry:e.s.number().int().min(1),titleChars:e.s.number().int().min(1),vocabChars:e.s.number().int().min(1)}),E=e.s.object({dbSchemaVersion:e.s.number().int().min(0).nullable(),features:e.s.object({likes:e.s.boolean(),publishing:e.s.boolean()}),limits:T,reason:e.s.enum([`database-unavailable`,`schema-mismatch`]).nullable(),schemaVersion:e.s.literal(1),status:e.s.enum([`ok`,`degraded`])}),D=e.s.object({dbSchemaVersion:e.s.number().int().min(0).nullable(),reason:e.s.enum([`configuration-unavailable`,`database-unavailable`,`schema-mismatch`]).nullable(),schemaVersion:e.s.literal(1),status:e.s.enum([`ok`,`degraded`])}),O=[`account-changed`,`auth-unavailable`,`bad-request`,`conflict`,`forbidden`,`gone`,`internal`,`invalid-payload`,`likes-disabled`,`method-not-allowed`,`not-found`,`publisher-changed`,`publishing-disabled`,`quota`,`rate-limited`,`record-unavailable`,`timeout`,`too-large`,`unauthenticated`,`unavailable`,`unsupported-media`],k=e.s.object({error:e.s.object({code:e.s.enum(O),message:e.s.string().min(1).max(256),requestId:p}).strict()}).strict(),A=e.s.object({ok:e.s.literal(!0)}).strict(),j=e.s.object({filters:e.s.array(e.s.string().min(1).max(w.vocabChars)),name:e.s.string().regex(f),tags:e.s.array(e.s.string().min(1).max(w.vocabChars))}),M=e.s.object({cursor:e.s.string().nullable(),reports:e.s.array(e.s.object({createdAt:m,entryId:p,entryTitle:e.s.string().min(1).max(w.titleChars),id:p,reason:e.s.string().min(1).max(w.reportReasonChars),reporter:v,resolvedAt:m.nullable()}))}),N=e.s.object({cursor:e.s.string().nullable(),reports:e.s.array(e.s.object({collection:e.s.string().regex(f),createdAt:m,entryId:p,id:p,reason:e.s.string().min(1).max(w.reportReasonChars),resolvedAt:m.nullable()}))}),P=[`hide`,`unhide`,`remove`,`erase-publisher`],F=e.s.object({actions:e.s.array(e.s.object({action:e.s.enum(P),collection:e.s.string().regex(f).nullable(),createdAt:m,id:p,operatorId:p,targetId:p})),cursor:e.s.string().nullable()}),I=e.s.enum([`liked`,`recent`]);exports.ACCOUNT_HEADER=o,exports.ARCHIVE_COLLECTION_NAME_PATTERN=f,exports.ARCHIVE_CONTRACT_VERSION=c,exports.ARCHIVE_ERROR_CODES=O,exports.ARCHIVE_LIMITS=w,exports.ARCHIVE_MODERATION_ACTIONS=P,exports.ARCHIVE_OPERATOR_ROLE=d,exports.ARCHIVE_SCHEMA_VERSION=l,exports.AUTH_SCHEMA_VERSION=u,exports.ILL_FORMED_TEXT=g,exports.OPERATOR_SESSION_MAX_AGE_SECONDS=i,exports.PUBLISHER_HEADER=a,exports.REQUEST_MARKER_HEADER=s,exports.SESSION_COOKIE_NAME=t,exports.SESSION_COOKIE_PATH=n,exports.SESSION_MAX_AGE_SECONDS=r,exports.archiveAccountSchema=b,exports.archiveAliasSchema=_,exports.archiveAuthHealthSchema=D,exports.archiveAuthorSchema=v,exports.archiveCollectionMetaSchema=j,exports.archiveErrorResponseSchema=k,exports.archiveHealthSchema=E,exports.archiveIdentitySchema=y,exports.archiveLikeStateSchema=C,exports.archiveLikedIdsSchema=x,exports.archiveLikesPageSchema=S,exports.archiveLimitsSchema=T,exports.archiveModerationLogSchema=F,exports.archiveOkSchema=A,exports.archiveOwnReportsSchema=N,exports.archiveReportsSchema=M,exports.archiveSortSchema=I,exports.hex64Schema=h,exports.isWellFormedText=isWellFormedText,exports.isoTimestampSchema=m,exports.sessionCookieValue=sessionCookieValue,exports.uuidSchema=p;
2
2
  //# sourceMappingURL=contract.cjs.map
@@ -1 +1 @@
1
- {"version":3,"file":"contract.cjs","names":[],"sources":["../src/contract.ts"],"sourcesContent":["import { type InferOutput, type Schema, s } from '@vielzeug/spell';\n\n/** Shared naming: one place so the client, Worker and issuer cannot drift. */\nexport const SESSION_COOKIE_NAME = 'archive_session';\nexport const SESSION_COOKIE_PATH = '/api/v1';\n/**\n * The maximum age a session JWT may carry, and the cookie Max-Age an issuer sets:\n * a long-lived signed-in state. The Worker rejects older tokens at this bound.\n */\nexport const SESSION_MAX_AGE_SECONDS = 30 * 24 * 60 * 60;\nexport const OPERATOR_SESSION_MAX_AGE_SECONDS = 15 * 60;\n\n/**\n * The full Set-Cookie value for the session cookie: name, token and the security\n * attributes every issuer and the Worker must agree on — HttpOnly, SameSite=Strict,\n * the contract Path, Max-Age, and Secure in production. Issuers mint with the\n * session's max age; the Worker and issuers clear with `maxAgeSeconds: 0`. Never\n * hand-compose the attributes: the cookie's CSRF and theft posture is contract,\n * not per-issuer choice.\n */\nexport const sessionCookieValue = (token: string, options: { maxAgeSeconds: number; secure: boolean }): string =>\n `${SESSION_COOKIE_NAME}=${token}; HttpOnly; SameSite=Strict; Path=${SESSION_COOKIE_PATH}; Max-Age=${options.maxAgeSeconds}${options.secure ? '; Secure' : ''}`;\n\nexport const PUBLISHER_HEADER = 'x-archive-publisher';\n/** Pins account erasure to the issuer account shown when confirmation began. */\nexport const ACCOUNT_HEADER = 'X-Archive-Account';\nexport const REQUEST_MARKER_HEADER = 'x-archive-request';\n/** The wire-contract version advertised by health and stamped on every entry envelope. */\nexport const ARCHIVE_CONTRACT_VERSION = 1;\n/**\n * The storage schema version: the `archive_schema_meta` stamp the package's migration\n * writes and the Worker reads back through health. A mismatch answers `degraded`\n * instead of `ok` — a stale database is the most likely deployment fault.\n */\nexport const ARCHIVE_SCHEMA_VERSION = 1;\n/** Canonical issuer database initialization stamp. */\nexport const AUTH_SCHEMA_VERSION = 1;\n/** The `roles` claim value a session JWT carries for operator authority (moderation). */\nexport const ARCHIVE_OPERATOR_ROLE = 'archive-operator';\n/** The routable collection-name rule: lowercase `[a-z0-9-]`, at most 32 chars. */\nexport const ARCHIVE_COLLECTION_NAME_PATTERN = /^[a-z0-9-]{1,32}$/;\n\n// Exported schemas carry explicit public `Schema<Output>` annotations: the concrete\n// spell schema classes are not part of spell's public export surface, so inferred\n// types would not be portable across package declaration files.\nexport const uuidSchema: Schema<string> = s.string().uuid();\nexport const isoTimestampSchema: Schema<string> = s.string().isoDateTime();\nexport const hex64Schema: Schema<string> = s.string().regex(/^[0-9a-f]{64}$/);\n/** Well-formed user text: no lone UTF-16 surrogates, no C0/C1/DEL control characters. */\nexport const ILL_FORMED_TEXT = /[\\p{Cs}\\p{Cc}]/u;\nexport const isWellFormedText = (value: string): boolean => !ILL_FORMED_TEXT.test(value);\nconst textString = () => s.string().check(isWellFormedText);\n/** The one alias rule, shared by identity mutations and every emitted DTO. */\nexport const archiveAliasSchema: Schema<string> = textString().trim().max(24);\n\nexport const archiveAuthorSchema: Schema<{ id: string; name: string }> = s.object({\n id: uuidSchema,\n name: archiveAliasSchema,\n});\nexport const archiveIdentitySchema: Schema<{ id: string; name: string; operator: boolean } | null> = s\n .object({ id: uuidSchema, name: archiveAliasSchema, operator: s.boolean() })\n .nullable();\nexport type ArchiveIdentity = InferOutput<typeof archiveIdentitySchema>;\n/** The issuer account metadata export; its id is the session subject, not a publisher id. */\nexport const archiveAccountSchema: Schema<{\n createdAt: string;\n email: string;\n id: string;\n lastSignInAt: string | null;\n}> = s.object({\n createdAt: isoTimestampSchema,\n email: s.string().email(),\n id: uuidSchema,\n lastSignInAt: isoTimestampSchema.nullable(),\n});\nexport type ArchiveAccount = InferOutput<typeof archiveAccountSchema>;\nexport const archiveLikedIdsSchema: Schema<{ ids: string[] }> = s.object({ ids: s.array(uuidSchema) });\nexport const archiveLikesPageSchema: Schema<{ cursor: string | null; ids: string[] }> = s.object({\n cursor: s.string().nullable(),\n ids: s.array(uuidSchema),\n});\nexport type ArchiveLikesPage = InferOutput<typeof archiveLikesPageSchema>;\nexport const archiveLikeStateSchema: Schema<{ id: string; likeCount: number; liked: boolean }> = s.object({\n id: uuidSchema,\n likeCount: s.number().int().min(0).max(Number.MAX_SAFE_INTEGER),\n liked: s.boolean(),\n});\n\n/** Application bounds shared by handlers, the health route, and clients. */\nexport const ARCHIVE_LIMITS = Object.freeze({\n aliasChars: 24,\n cursorChars: 1024,\n idempotencyRetentionMs: 48 * 60 * 60 * 1000,\n likeStateIds: 50,\n maxPageSize: 50,\n maxPublications: 50,\n pageSize: 10,\n payloadBytes: 8192,\n publicCacheSeconds: 30,\n /** One free-text moderation reason: trimmed, non-empty, at most this many chars. */\n reportReasonChars: 256,\n requestBytes: 12 * 1024,\n searchChars: 64,\n searchTerms: 6,\n tagsPerEntry: 16,\n titleChars: 128,\n /** One declared filter or tag value: well-formed, non-empty, at most this many chars. */\n vocabChars: 64,\n});\nexport const archiveLimitsSchema: Schema<{\n aliasChars: number;\n cursorChars: number;\n idempotencyRetentionMs: number;\n likeStateIds: number;\n maxPageSize: number;\n maxPublications: number;\n pageSize: number;\n payloadBytes: number;\n publicCacheSeconds: number;\n reportReasonChars: number;\n requestBytes: number;\n searchChars: number;\n searchTerms: number;\n tagsPerEntry: number;\n titleChars: number;\n vocabChars: number;\n}> = s.object({\n aliasChars: s.number().int().min(1),\n cursorChars: s.number().int().min(1),\n idempotencyRetentionMs: s.number().int().min(1),\n likeStateIds: s.number().int().min(1),\n maxPageSize: s.number().int().min(1),\n maxPublications: s.number().int().min(1),\n pageSize: s.number().int().min(1),\n payloadBytes: s.number().int().min(1),\n publicCacheSeconds: s.number().int().min(0),\n reportReasonChars: s.number().int().min(1),\n requestBytes: s.number().int().min(1),\n searchChars: s.number().int().min(1),\n searchTerms: s.number().int().min(1),\n tagsPerEntry: s.number().int().min(1),\n titleChars: s.number().int().min(1),\n vocabChars: s.number().int().min(1),\n});\nexport const archiveHealthSchema: Schema<{\n /** The storage schema stamp (`archive_schema_meta`) the database actually carries. */\n dbSchemaVersion: number | null;\n features: { likes: boolean; publishing: boolean };\n limits: InferOutput<typeof archiveLimitsSchema>;\n schemaVersion: 1;\n /** 'degraded' when the database's storage schema does not match the Worker's. */\n status: 'ok' | 'degraded';\n reason: 'database-unavailable' | 'schema-mismatch' | null;\n}> = s.object({\n dbSchemaVersion: s.number().int().min(0).nullable(),\n features: s.object({ likes: s.boolean(), publishing: s.boolean() }),\n limits: archiveLimitsSchema,\n reason: s.enum(['database-unavailable', 'schema-mismatch'] as const).nullable(),\n schemaVersion: s.literal(ARCHIVE_CONTRACT_VERSION),\n status: s.enum(['ok', 'degraded'] as const),\n});\nexport type ArchiveHealth = InferOutput<typeof archiveHealthSchema>;\n\nexport const archiveAuthHealthSchema: Schema<{\n dbSchemaVersion: number | null;\n schemaVersion: 1;\n status: 'ok' | 'degraded';\n reason: 'configuration-unavailable' | 'database-unavailable' | 'schema-mismatch' | null;\n}> = s.object({\n dbSchemaVersion: s.number().int().min(0).nullable(),\n reason: s.enum(['configuration-unavailable', 'database-unavailable', 'schema-mismatch'] as const).nullable(),\n schemaVersion: s.literal(AUTH_SCHEMA_VERSION),\n status: s.enum(['ok', 'degraded'] as const),\n});\nexport type AuthHealth = InferOutput<typeof archiveAuthHealthSchema>;\n\n/** The Worker's full error-code vocabulary — the contract both sides test against. */\nexport const ARCHIVE_ERROR_CODES = [\n 'account-changed',\n 'auth-unavailable',\n 'bad-request',\n 'conflict',\n 'forbidden',\n 'gone',\n 'internal',\n 'invalid-payload',\n 'likes-disabled',\n 'method-not-allowed',\n 'not-found',\n 'publisher-changed',\n 'publishing-disabled',\n 'quota',\n 'rate-limited',\n 'record-unavailable',\n 'timeout',\n 'too-large',\n 'unauthenticated',\n 'unavailable',\n 'unsupported-media',\n] as const;\nexport type ArchiveErrorCode = (typeof ARCHIVE_ERROR_CODES)[number];\n\n/**\n * The error envelope every route answers failures with: `{ error: { code, message,\n * requestId } }`. Clients validate against this one schema — an envelope carrying an\n * unknown code or malformed fields is not an archive error at all.\n */\nexport const archiveErrorResponseSchema: Schema<{\n error: { code: ArchiveErrorCode; message: string; requestId: string };\n}> = s\n .object({\n error: s\n .object({\n code: s.enum(ARCHIVE_ERROR_CODES),\n message: s.string().min(1).max(256),\n requestId: uuidSchema,\n })\n .strict(),\n })\n .strict();\nexport type ArchiveErrorResponse = InferOutput<typeof archiveErrorResponseSchema>;\n\n/** The sign-out acknowledgement: the only non-collection response body, shared by both sides. */\nexport const archiveOkSchema: Schema<{ ok: true }> = s.object({ ok: s.literal(true) }).strict();\n\n/** One collection's public self-description: its name and declared query vocabularies. */\nexport const archiveCollectionMetaSchema: Schema<{\n filters: string[];\n name: string;\n tags: string[];\n}> = s.object({\n filters: s.array(s.string().min(1).max(ARCHIVE_LIMITS.vocabChars)),\n name: s.string().regex(ARCHIVE_COLLECTION_NAME_PATTERN),\n tags: s.array(s.string().min(1).max(ARCHIVE_LIMITS.vocabChars)),\n});\nexport type ArchiveCollectionMeta = InferOutput<typeof archiveCollectionMetaSchema>;\n\n/** One reader report an operator reviews: what was reported, why, and by whom. */\nexport const archiveReportsSchema: Schema<{\n cursor: string | null;\n reports: {\n id: string;\n createdAt: string;\n entryId: string;\n entryTitle: string;\n reason: string;\n reporter: { id: string; name: string };\n resolvedAt: string | null;\n }[];\n}> = s.object({\n cursor: s.string().nullable(),\n reports: s.array(\n s.object({\n createdAt: isoTimestampSchema,\n entryId: uuidSchema,\n entryTitle: s.string().min(1).max(ARCHIVE_LIMITS.titleChars),\n id: uuidSchema,\n reason: s.string().min(1).max(ARCHIVE_LIMITS.reportReasonChars),\n reporter: archiveAuthorSchema,\n resolvedAt: isoTimestampSchema.nullable(),\n }),\n ),\n});\nexport type ArchiveReports = InferOutput<typeof archiveReportsSchema>;\nexport const archiveOwnReportsSchema: Schema<{\n cursor: string | null;\n reports: {\n collection: string;\n createdAt: string;\n entryId: string;\n id: string;\n reason: string;\n resolvedAt: string | null;\n }[];\n}> = s.object({\n cursor: s.string().nullable(),\n reports: s.array(\n s.object({\n collection: s.string().regex(ARCHIVE_COLLECTION_NAME_PATTERN),\n createdAt: isoTimestampSchema,\n entryId: uuidSchema,\n id: uuidSchema,\n reason: s.string().min(1).max(ARCHIVE_LIMITS.reportReasonChars),\n resolvedAt: isoTimestampSchema.nullable(),\n }),\n ),\n});\nexport type ArchiveOwnReports = InferOutput<typeof archiveOwnReportsSchema>;\n\n/** The moderation actions an operator can take; the audit log's whole vocabulary. */\nexport const ARCHIVE_MODERATION_ACTIONS = ['hide', 'unhide', 'remove', 'erase-publisher'] as const;\nexport type ArchiveModerationAction = (typeof ARCHIVE_MODERATION_ACTIONS)[number];\n\n/**\n * The operator accountability log: one row per effective moderation action, newest\n * first. Operator and target travel as opaque ids only — no names (they change), no\n * content — so the log stays honest after renames and survives the erasures it records.\n */\nexport const archiveModerationLogSchema: Schema<{\n cursor: string | null;\n actions: {\n id: string;\n action: ArchiveModerationAction;\n collection: string | null;\n createdAt: string;\n operatorId: string;\n targetId: string;\n }[];\n}> = s.object({\n actions: s.array(\n s.object({\n action: s.enum(ARCHIVE_MODERATION_ACTIONS),\n collection: s.string().regex(ARCHIVE_COLLECTION_NAME_PATTERN).nullable(),\n createdAt: isoTimestampSchema,\n id: uuidSchema,\n operatorId: uuidSchema,\n targetId: uuidSchema,\n }),\n ),\n cursor: s.string().nullable(),\n});\nexport type ArchiveModerationLog = InferOutput<typeof archiveModerationLogSchema>;\n\n/** How the online list ranks entries: explicit modes only, no vague composites. */\nexport type ArchiveSort = 'liked' | 'recent';\nexport const archiveSortSchema: Schema<ArchiveSort> = s.enum(['liked', 'recent'] as const);\n\n/**\n * One page of archive entries. Every list in the package pages forward-only: the\n * cursor walks toward the oldest entry and is null after the last page.\n */\nexport interface ArchivePage<T> {\n /** Cursor for the next page; null after the last. */\n cursor: string | null;\n entries: readonly T[];\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"contract.cjs","names":[],"sources":["../src/contract.ts"],"sourcesContent":["import { type InferOutput, type Schema, s } from '@vielzeug/spell';\n\n/** Shared naming: one place so the client, Worker and issuer cannot drift. */\nexport const SESSION_COOKIE_NAME = 'archive_session';\nexport const SESSION_COOKIE_PATH = '/api/v1';\n/**\n * The maximum age a session JWT may carry, and the cookie Max-Age an issuer sets:\n * a long-lived signed-in state. The Worker rejects older tokens at this bound.\n */\nexport const SESSION_MAX_AGE_SECONDS = 30 * 24 * 60 * 60;\nexport const OPERATOR_SESSION_MAX_AGE_SECONDS = 60 * 60;\n\n/**\n * The full Set-Cookie value for the session cookie: name, token and the security\n * attributes every issuer and the Worker must agree on — HttpOnly, SameSite=Strict,\n * the contract Path, Max-Age, and Secure in production. Issuers mint with the\n * session's max age; the Worker and issuers clear with `maxAgeSeconds: 0`. Never\n * hand-compose the attributes: the cookie's CSRF and theft posture is contract,\n * not per-issuer choice.\n */\nexport const sessionCookieValue = (token: string, options: { maxAgeSeconds: number; secure: boolean }): string =>\n `${SESSION_COOKIE_NAME}=${token}; HttpOnly; SameSite=Strict; Path=${SESSION_COOKIE_PATH}; Max-Age=${options.maxAgeSeconds}${options.secure ? '; Secure' : ''}`;\n\nexport const PUBLISHER_HEADER = 'x-archive-publisher';\n/** Pins account erasure to the issuer account shown when confirmation began. */\nexport const ACCOUNT_HEADER = 'X-Archive-Account';\nexport const REQUEST_MARKER_HEADER = 'x-archive-request';\n/** The wire-contract version advertised by health and stamped on every entry envelope. */\nexport const ARCHIVE_CONTRACT_VERSION = 1;\n/**\n * The storage schema version: the `archive_schema_meta` stamp the package's migration\n * writes and the Worker reads back through health. A mismatch answers `degraded`\n * instead of `ok` — a stale database is the most likely deployment fault.\n */\nexport const ARCHIVE_SCHEMA_VERSION = 1;\n/** Canonical issuer database initialization stamp. */\nexport const AUTH_SCHEMA_VERSION = 1;\n/** The `roles` claim value a session JWT carries for operator authority (moderation). */\nexport const ARCHIVE_OPERATOR_ROLE = 'archive-operator';\n/** The routable collection-name rule: lowercase `[a-z0-9-]`, at most 32 chars. */\nexport const ARCHIVE_COLLECTION_NAME_PATTERN = /^[a-z0-9-]{1,32}$/;\n\n// Exported schemas carry explicit public `Schema<Output>` annotations: the concrete\n// spell schema classes are not part of spell's public export surface, so inferred\n// types would not be portable across package declaration files.\nexport const uuidSchema: Schema<string> = s.string().uuid();\nexport const isoTimestampSchema: Schema<string> = s.string().isoDateTime();\nexport const hex64Schema: Schema<string> = s.string().regex(/^[0-9a-f]{64}$/);\n/** Well-formed user text: no lone UTF-16 surrogates, no C0/C1/DEL control characters. */\nexport const ILL_FORMED_TEXT = /[\\p{Cs}\\p{Cc}]/u;\nexport const isWellFormedText = (value: string): boolean => !ILL_FORMED_TEXT.test(value);\nconst textString = () => s.string().check(isWellFormedText);\n/** The one alias rule, shared by identity mutations and every emitted DTO. */\nexport const archiveAliasSchema: Schema<string> = textString().trim().max(24);\n\nexport const archiveAuthorSchema: Schema<{ id: string; name: string }> = s.object({\n id: uuidSchema,\n name: archiveAliasSchema,\n});\nexport const archiveIdentitySchema: Schema<{ id: string; name: string; operator: boolean } | null> = s\n .object({ id: uuidSchema, name: archiveAliasSchema, operator: s.boolean() })\n .nullable();\nexport type ArchiveIdentity = InferOutput<typeof archiveIdentitySchema>;\n/** The issuer account metadata export; its id is the session subject, not a publisher id. */\nexport const archiveAccountSchema: Schema<{\n createdAt: string;\n email: string;\n id: string;\n lastSignInAt: string | null;\n}> = s.object({\n createdAt: isoTimestampSchema,\n email: s.string().email(),\n id: uuidSchema,\n lastSignInAt: isoTimestampSchema.nullable(),\n});\nexport type ArchiveAccount = InferOutput<typeof archiveAccountSchema>;\nexport const archiveLikedIdsSchema: Schema<{ ids: string[] }> = s.object({ ids: s.array(uuidSchema) });\nexport const archiveLikesPageSchema: Schema<{ cursor: string | null; ids: string[] }> = s.object({\n cursor: s.string().nullable(),\n ids: s.array(uuidSchema),\n});\nexport type ArchiveLikesPage = InferOutput<typeof archiveLikesPageSchema>;\nexport const archiveLikeStateSchema: Schema<{ id: string; likeCount: number; liked: boolean }> = s.object({\n id: uuidSchema,\n likeCount: s.number().int().min(0).max(Number.MAX_SAFE_INTEGER),\n liked: s.boolean(),\n});\n\n/** Application bounds shared by handlers, the health route, and clients. */\nexport const ARCHIVE_LIMITS = Object.freeze({\n aliasChars: 24,\n cursorChars: 1024,\n idempotencyRetentionMs: 48 * 60 * 60 * 1000,\n likeStateIds: 50,\n maxPageSize: 50,\n maxPublications: 50,\n pageSize: 10,\n payloadBytes: 8192,\n publicCacheSeconds: 30,\n /** One free-text moderation reason: trimmed, non-empty, at most this many chars. */\n reportReasonChars: 256,\n requestBytes: 12 * 1024,\n searchChars: 64,\n searchTerms: 6,\n tagsPerEntry: 16,\n titleChars: 128,\n /** One declared filter or tag value: well-formed, non-empty, at most this many chars. */\n vocabChars: 64,\n});\nexport const archiveLimitsSchema: Schema<{\n aliasChars: number;\n cursorChars: number;\n idempotencyRetentionMs: number;\n likeStateIds: number;\n maxPageSize: number;\n maxPublications: number;\n pageSize: number;\n payloadBytes: number;\n publicCacheSeconds: number;\n reportReasonChars: number;\n requestBytes: number;\n searchChars: number;\n searchTerms: number;\n tagsPerEntry: number;\n titleChars: number;\n vocabChars: number;\n}> = s.object({\n aliasChars: s.number().int().min(1),\n cursorChars: s.number().int().min(1),\n idempotencyRetentionMs: s.number().int().min(1),\n likeStateIds: s.number().int().min(1),\n maxPageSize: s.number().int().min(1),\n maxPublications: s.number().int().min(1),\n pageSize: s.number().int().min(1),\n payloadBytes: s.number().int().min(1),\n publicCacheSeconds: s.number().int().min(0),\n reportReasonChars: s.number().int().min(1),\n requestBytes: s.number().int().min(1),\n searchChars: s.number().int().min(1),\n searchTerms: s.number().int().min(1),\n tagsPerEntry: s.number().int().min(1),\n titleChars: s.number().int().min(1),\n vocabChars: s.number().int().min(1),\n});\nexport const archiveHealthSchema: Schema<{\n /** The storage schema stamp (`archive_schema_meta`) the database actually carries. */\n dbSchemaVersion: number | null;\n features: { likes: boolean; publishing: boolean };\n limits: InferOutput<typeof archiveLimitsSchema>;\n schemaVersion: 1;\n /** 'degraded' when the database's storage schema does not match the Worker's. */\n status: 'ok' | 'degraded';\n reason: 'database-unavailable' | 'schema-mismatch' | null;\n}> = s.object({\n dbSchemaVersion: s.number().int().min(0).nullable(),\n features: s.object({ likes: s.boolean(), publishing: s.boolean() }),\n limits: archiveLimitsSchema,\n reason: s.enum(['database-unavailable', 'schema-mismatch'] as const).nullable(),\n schemaVersion: s.literal(ARCHIVE_CONTRACT_VERSION),\n status: s.enum(['ok', 'degraded'] as const),\n});\nexport type ArchiveHealth = InferOutput<typeof archiveHealthSchema>;\n\nexport const archiveAuthHealthSchema: Schema<{\n dbSchemaVersion: number | null;\n schemaVersion: 1;\n status: 'ok' | 'degraded';\n reason: 'configuration-unavailable' | 'database-unavailable' | 'schema-mismatch' | null;\n}> = s.object({\n dbSchemaVersion: s.number().int().min(0).nullable(),\n reason: s.enum(['configuration-unavailable', 'database-unavailable', 'schema-mismatch'] as const).nullable(),\n schemaVersion: s.literal(AUTH_SCHEMA_VERSION),\n status: s.enum(['ok', 'degraded'] as const),\n});\nexport type AuthHealth = InferOutput<typeof archiveAuthHealthSchema>;\n\n/** The Worker's full error-code vocabulary — the contract both sides test against. */\nexport const ARCHIVE_ERROR_CODES = [\n 'account-changed',\n 'auth-unavailable',\n 'bad-request',\n 'conflict',\n 'forbidden',\n 'gone',\n 'internal',\n 'invalid-payload',\n 'likes-disabled',\n 'method-not-allowed',\n 'not-found',\n 'publisher-changed',\n 'publishing-disabled',\n 'quota',\n 'rate-limited',\n 'record-unavailable',\n 'timeout',\n 'too-large',\n 'unauthenticated',\n 'unavailable',\n 'unsupported-media',\n] as const;\nexport type ArchiveErrorCode = (typeof ARCHIVE_ERROR_CODES)[number];\n\n/**\n * The error envelope every route answers failures with: `{ error: { code, message,\n * requestId } }`. Clients validate against this one schema — an envelope carrying an\n * unknown code or malformed fields is not an archive error at all.\n */\nexport const archiveErrorResponseSchema: Schema<{\n error: { code: ArchiveErrorCode; message: string; requestId: string };\n}> = s\n .object({\n error: s\n .object({\n code: s.enum(ARCHIVE_ERROR_CODES),\n message: s.string().min(1).max(256),\n requestId: uuidSchema,\n })\n .strict(),\n })\n .strict();\nexport type ArchiveErrorResponse = InferOutput<typeof archiveErrorResponseSchema>;\n\n/** The sign-out acknowledgement: the only non-collection response body, shared by both sides. */\nexport const archiveOkSchema: Schema<{ ok: true }> = s.object({ ok: s.literal(true) }).strict();\n\n/** One collection's public self-description: its name and declared query vocabularies. */\nexport const archiveCollectionMetaSchema: Schema<{\n filters: string[];\n name: string;\n tags: string[];\n}> = s.object({\n filters: s.array(s.string().min(1).max(ARCHIVE_LIMITS.vocabChars)),\n name: s.string().regex(ARCHIVE_COLLECTION_NAME_PATTERN),\n tags: s.array(s.string().min(1).max(ARCHIVE_LIMITS.vocabChars)),\n});\nexport type ArchiveCollectionMeta = InferOutput<typeof archiveCollectionMetaSchema>;\n\n/** One reader report an operator reviews: what was reported, why, and by whom. */\nexport const archiveReportsSchema: Schema<{\n cursor: string | null;\n reports: {\n id: string;\n createdAt: string;\n entryId: string;\n entryTitle: string;\n reason: string;\n reporter: { id: string; name: string };\n resolvedAt: string | null;\n }[];\n}> = s.object({\n cursor: s.string().nullable(),\n reports: s.array(\n s.object({\n createdAt: isoTimestampSchema,\n entryId: uuidSchema,\n entryTitle: s.string().min(1).max(ARCHIVE_LIMITS.titleChars),\n id: uuidSchema,\n reason: s.string().min(1).max(ARCHIVE_LIMITS.reportReasonChars),\n reporter: archiveAuthorSchema,\n resolvedAt: isoTimestampSchema.nullable(),\n }),\n ),\n});\nexport type ArchiveReports = InferOutput<typeof archiveReportsSchema>;\nexport const archiveOwnReportsSchema: Schema<{\n cursor: string | null;\n reports: {\n collection: string;\n createdAt: string;\n entryId: string;\n id: string;\n reason: string;\n resolvedAt: string | null;\n }[];\n}> = s.object({\n cursor: s.string().nullable(),\n reports: s.array(\n s.object({\n collection: s.string().regex(ARCHIVE_COLLECTION_NAME_PATTERN),\n createdAt: isoTimestampSchema,\n entryId: uuidSchema,\n id: uuidSchema,\n reason: s.string().min(1).max(ARCHIVE_LIMITS.reportReasonChars),\n resolvedAt: isoTimestampSchema.nullable(),\n }),\n ),\n});\nexport type ArchiveOwnReports = InferOutput<typeof archiveOwnReportsSchema>;\n\n/** The moderation actions an operator can take; the audit log's whole vocabulary. */\nexport const ARCHIVE_MODERATION_ACTIONS = ['hide', 'unhide', 'remove', 'erase-publisher'] as const;\nexport type ArchiveModerationAction = (typeof ARCHIVE_MODERATION_ACTIONS)[number];\n\n/**\n * The operator accountability log: one row per effective moderation action, newest\n * first. Operator and target travel as opaque ids only — no names (they change), no\n * content — so the log stays honest after renames and survives the erasures it records.\n */\nexport const archiveModerationLogSchema: Schema<{\n cursor: string | null;\n actions: {\n id: string;\n action: ArchiveModerationAction;\n collection: string | null;\n createdAt: string;\n operatorId: string;\n targetId: string;\n }[];\n}> = s.object({\n actions: s.array(\n s.object({\n action: s.enum(ARCHIVE_MODERATION_ACTIONS),\n collection: s.string().regex(ARCHIVE_COLLECTION_NAME_PATTERN).nullable(),\n createdAt: isoTimestampSchema,\n id: uuidSchema,\n operatorId: uuidSchema,\n targetId: uuidSchema,\n }),\n ),\n cursor: s.string().nullable(),\n});\nexport type ArchiveModerationLog = InferOutput<typeof archiveModerationLogSchema>;\n\n/** How the online list ranks entries: explicit modes only, no vague composites. */\nexport type ArchiveSort = 'liked' | 'recent';\nexport const archiveSortSchema: Schema<ArchiveSort> = s.enum(['liked', 'recent'] as const);\n\n/**\n * One page of archive entries. Every list in the package pages forward-only: the\n * cursor walks toward the oldest entry and is null after the last page.\n */\nexport interface ArchivePage<T> {\n /** Cursor for the next page; null after the last. */\n cursor: string | null;\n entries: readonly T[];\n}\n"],"mappings":"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"}
package/dist/contract.js CHANGED
@@ -1,2 +1,2 @@
1
- import{s as e}from"@vielzeug/spell";var t=`archive_session`,n=`/api/v1`,r=2592e3,i=900,sessionCookieValue=(e,r)=>`${t}=${e}; HttpOnly; SameSite=Strict; Path=${n}; Max-Age=${r.maxAgeSeconds}${r.secure?`; Secure`:``}`,a=`x-archive-publisher`,o=`X-Archive-Account`,s=`x-archive-request`,c=1,l=1,u=1,d=`archive-operator`,f=/^[a-z0-9-]{1,32}$/,p=e.string().uuid(),m=e.string().isoDateTime(),h=e.string().regex(/^[0-9a-f]{64}$/),g=/[\p{Cs}\p{Cc}]/u,isWellFormedText=e=>!g.test(e),textString=()=>e.string().check(isWellFormedText),_=textString().trim().max(24),v=e.object({id:p,name:_}),y=e.object({id:p,name:_,operator:e.boolean()}).nullable(),b=e.object({createdAt:m,email:e.string().email(),id:p,lastSignInAt:m.nullable()}),x=e.object({ids:e.array(p)}),S=e.object({cursor:e.string().nullable(),ids:e.array(p)}),C=e.object({id:p,likeCount:e.number().int().min(0).max(2**53-1),liked:e.boolean()}),w=Object.freeze({aliasChars:24,cursorChars:1024,idempotencyRetentionMs:1728e5,likeStateIds:50,maxPageSize:50,maxPublications:50,pageSize:10,payloadBytes:8192,publicCacheSeconds:30,reportReasonChars:256,requestBytes:12288,searchChars:64,searchTerms:6,tagsPerEntry:16,titleChars:128,vocabChars:64}),T=e.object({aliasChars:e.number().int().min(1),cursorChars:e.number().int().min(1),idempotencyRetentionMs:e.number().int().min(1),likeStateIds:e.number().int().min(1),maxPageSize:e.number().int().min(1),maxPublications:e.number().int().min(1),pageSize:e.number().int().min(1),payloadBytes:e.number().int().min(1),publicCacheSeconds:e.number().int().min(0),reportReasonChars:e.number().int().min(1),requestBytes:e.number().int().min(1),searchChars:e.number().int().min(1),searchTerms:e.number().int().min(1),tagsPerEntry:e.number().int().min(1),titleChars:e.number().int().min(1),vocabChars:e.number().int().min(1)}),E=e.object({dbSchemaVersion:e.number().int().min(0).nullable(),features:e.object({likes:e.boolean(),publishing:e.boolean()}),limits:T,reason:e.enum([`database-unavailable`,`schema-mismatch`]).nullable(),schemaVersion:e.literal(1),status:e.enum([`ok`,`degraded`])}),D=e.object({dbSchemaVersion:e.number().int().min(0).nullable(),reason:e.enum([`configuration-unavailable`,`database-unavailable`,`schema-mismatch`]).nullable(),schemaVersion:e.literal(1),status:e.enum([`ok`,`degraded`])}),O=[`account-changed`,`auth-unavailable`,`bad-request`,`conflict`,`forbidden`,`gone`,`internal`,`invalid-payload`,`likes-disabled`,`method-not-allowed`,`not-found`,`publisher-changed`,`publishing-disabled`,`quota`,`rate-limited`,`record-unavailable`,`timeout`,`too-large`,`unauthenticated`,`unavailable`,`unsupported-media`],k=e.object({error:e.object({code:e.enum(O),message:e.string().min(1).max(256),requestId:p}).strict()}).strict(),A=e.object({ok:e.literal(!0)}).strict(),j=e.object({filters:e.array(e.string().min(1).max(w.vocabChars)),name:e.string().regex(f),tags:e.array(e.string().min(1).max(w.vocabChars))}),M=e.object({cursor:e.string().nullable(),reports:e.array(e.object({createdAt:m,entryId:p,entryTitle:e.string().min(1).max(w.titleChars),id:p,reason:e.string().min(1).max(w.reportReasonChars),reporter:v,resolvedAt:m.nullable()}))}),N=e.object({cursor:e.string().nullable(),reports:e.array(e.object({collection:e.string().regex(f),createdAt:m,entryId:p,id:p,reason:e.string().min(1).max(w.reportReasonChars),resolvedAt:m.nullable()}))}),P=[`hide`,`unhide`,`remove`,`erase-publisher`],F=e.object({actions:e.array(e.object({action:e.enum(P),collection:e.string().regex(f).nullable(),createdAt:m,id:p,operatorId:p,targetId:p})),cursor:e.string().nullable()}),I=e.enum([`liked`,`recent`]);export{o as ACCOUNT_HEADER,f as ARCHIVE_COLLECTION_NAME_PATTERN,c as ARCHIVE_CONTRACT_VERSION,O as ARCHIVE_ERROR_CODES,w as ARCHIVE_LIMITS,P as ARCHIVE_MODERATION_ACTIONS,d as ARCHIVE_OPERATOR_ROLE,l as ARCHIVE_SCHEMA_VERSION,u as AUTH_SCHEMA_VERSION,g as ILL_FORMED_TEXT,i as OPERATOR_SESSION_MAX_AGE_SECONDS,a as PUBLISHER_HEADER,s as REQUEST_MARKER_HEADER,t as SESSION_COOKIE_NAME,n as SESSION_COOKIE_PATH,r as SESSION_MAX_AGE_SECONDS,b as archiveAccountSchema,_ as archiveAliasSchema,D as archiveAuthHealthSchema,v as archiveAuthorSchema,j as archiveCollectionMetaSchema,k as archiveErrorResponseSchema,E as archiveHealthSchema,y as archiveIdentitySchema,C as archiveLikeStateSchema,x as archiveLikedIdsSchema,S as archiveLikesPageSchema,T as archiveLimitsSchema,F as archiveModerationLogSchema,A as archiveOkSchema,N as archiveOwnReportsSchema,M as archiveReportsSchema,I as archiveSortSchema,h as hex64Schema,isWellFormedText,m as isoTimestampSchema,sessionCookieValue,p as uuidSchema};
1
+ import{s as e}from"@vielzeug/spell";var t=`archive_session`,n=`/api/v1`,r=2592e3,i=3600,sessionCookieValue=(e,r)=>`${t}=${e}; HttpOnly; SameSite=Strict; Path=${n}; Max-Age=${r.maxAgeSeconds}${r.secure?`; Secure`:``}`,a=`x-archive-publisher`,o=`X-Archive-Account`,s=`x-archive-request`,c=1,l=1,u=1,d=`archive-operator`,f=/^[a-z0-9-]{1,32}$/,p=e.string().uuid(),m=e.string().isoDateTime(),h=e.string().regex(/^[0-9a-f]{64}$/),g=/[\p{Cs}\p{Cc}]/u,isWellFormedText=e=>!g.test(e),textString=()=>e.string().check(isWellFormedText),_=textString().trim().max(24),v=e.object({id:p,name:_}),y=e.object({id:p,name:_,operator:e.boolean()}).nullable(),b=e.object({createdAt:m,email:e.string().email(),id:p,lastSignInAt:m.nullable()}),x=e.object({ids:e.array(p)}),S=e.object({cursor:e.string().nullable(),ids:e.array(p)}),C=e.object({id:p,likeCount:e.number().int().min(0).max(2**53-1),liked:e.boolean()}),w=Object.freeze({aliasChars:24,cursorChars:1024,idempotencyRetentionMs:1728e5,likeStateIds:50,maxPageSize:50,maxPublications:50,pageSize:10,payloadBytes:8192,publicCacheSeconds:30,reportReasonChars:256,requestBytes:12288,searchChars:64,searchTerms:6,tagsPerEntry:16,titleChars:128,vocabChars:64}),T=e.object({aliasChars:e.number().int().min(1),cursorChars:e.number().int().min(1),idempotencyRetentionMs:e.number().int().min(1),likeStateIds:e.number().int().min(1),maxPageSize:e.number().int().min(1),maxPublications:e.number().int().min(1),pageSize:e.number().int().min(1),payloadBytes:e.number().int().min(1),publicCacheSeconds:e.number().int().min(0),reportReasonChars:e.number().int().min(1),requestBytes:e.number().int().min(1),searchChars:e.number().int().min(1),searchTerms:e.number().int().min(1),tagsPerEntry:e.number().int().min(1),titleChars:e.number().int().min(1),vocabChars:e.number().int().min(1)}),E=e.object({dbSchemaVersion:e.number().int().min(0).nullable(),features:e.object({likes:e.boolean(),publishing:e.boolean()}),limits:T,reason:e.enum([`database-unavailable`,`schema-mismatch`]).nullable(),schemaVersion:e.literal(1),status:e.enum([`ok`,`degraded`])}),D=e.object({dbSchemaVersion:e.number().int().min(0).nullable(),reason:e.enum([`configuration-unavailable`,`database-unavailable`,`schema-mismatch`]).nullable(),schemaVersion:e.literal(1),status:e.enum([`ok`,`degraded`])}),O=[`account-changed`,`auth-unavailable`,`bad-request`,`conflict`,`forbidden`,`gone`,`internal`,`invalid-payload`,`likes-disabled`,`method-not-allowed`,`not-found`,`publisher-changed`,`publishing-disabled`,`quota`,`rate-limited`,`record-unavailable`,`timeout`,`too-large`,`unauthenticated`,`unavailable`,`unsupported-media`],k=e.object({error:e.object({code:e.enum(O),message:e.string().min(1).max(256),requestId:p}).strict()}).strict(),A=e.object({ok:e.literal(!0)}).strict(),j=e.object({filters:e.array(e.string().min(1).max(w.vocabChars)),name:e.string().regex(f),tags:e.array(e.string().min(1).max(w.vocabChars))}),M=e.object({cursor:e.string().nullable(),reports:e.array(e.object({createdAt:m,entryId:p,entryTitle:e.string().min(1).max(w.titleChars),id:p,reason:e.string().min(1).max(w.reportReasonChars),reporter:v,resolvedAt:m.nullable()}))}),N=e.object({cursor:e.string().nullable(),reports:e.array(e.object({collection:e.string().regex(f),createdAt:m,entryId:p,id:p,reason:e.string().min(1).max(w.reportReasonChars),resolvedAt:m.nullable()}))}),P=[`hide`,`unhide`,`remove`,`erase-publisher`],F=e.object({actions:e.array(e.object({action:e.enum(P),collection:e.string().regex(f).nullable(),createdAt:m,id:p,operatorId:p,targetId:p})),cursor:e.string().nullable()}),I=e.enum([`liked`,`recent`]);export{o as ACCOUNT_HEADER,f as ARCHIVE_COLLECTION_NAME_PATTERN,c as ARCHIVE_CONTRACT_VERSION,O as ARCHIVE_ERROR_CODES,w as ARCHIVE_LIMITS,P as ARCHIVE_MODERATION_ACTIONS,d as ARCHIVE_OPERATOR_ROLE,l as ARCHIVE_SCHEMA_VERSION,u as AUTH_SCHEMA_VERSION,g as ILL_FORMED_TEXT,i as OPERATOR_SESSION_MAX_AGE_SECONDS,a as PUBLISHER_HEADER,s as REQUEST_MARKER_HEADER,t as SESSION_COOKIE_NAME,n as SESSION_COOKIE_PATH,r as SESSION_MAX_AGE_SECONDS,b as archiveAccountSchema,_ as archiveAliasSchema,D as archiveAuthHealthSchema,v as archiveAuthorSchema,j as archiveCollectionMetaSchema,k as archiveErrorResponseSchema,E as archiveHealthSchema,y as archiveIdentitySchema,C as archiveLikeStateSchema,x as archiveLikedIdsSchema,S as archiveLikesPageSchema,T as archiveLimitsSchema,F as archiveModerationLogSchema,A as archiveOkSchema,N as archiveOwnReportsSchema,M as archiveReportsSchema,I as archiveSortSchema,h as hex64Schema,isWellFormedText,m as isoTimestampSchema,sessionCookieValue,p as uuidSchema};
2
2
  //# sourceMappingURL=contract.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"contract.js","names":[],"sources":["../src/contract.ts"],"sourcesContent":["import { type InferOutput, type Schema, s } from '@vielzeug/spell';\n\n/** Shared naming: one place so the client, Worker and issuer cannot drift. */\nexport const SESSION_COOKIE_NAME = 'archive_session';\nexport const SESSION_COOKIE_PATH = '/api/v1';\n/**\n * The maximum age a session JWT may carry, and the cookie Max-Age an issuer sets:\n * a long-lived signed-in state. The Worker rejects older tokens at this bound.\n */\nexport const SESSION_MAX_AGE_SECONDS = 30 * 24 * 60 * 60;\nexport const OPERATOR_SESSION_MAX_AGE_SECONDS = 15 * 60;\n\n/**\n * The full Set-Cookie value for the session cookie: name, token and the security\n * attributes every issuer and the Worker must agree on — HttpOnly, SameSite=Strict,\n * the contract Path, Max-Age, and Secure in production. Issuers mint with the\n * session's max age; the Worker and issuers clear with `maxAgeSeconds: 0`. Never\n * hand-compose the attributes: the cookie's CSRF and theft posture is contract,\n * not per-issuer choice.\n */\nexport const sessionCookieValue = (token: string, options: { maxAgeSeconds: number; secure: boolean }): string =>\n `${SESSION_COOKIE_NAME}=${token}; HttpOnly; SameSite=Strict; Path=${SESSION_COOKIE_PATH}; Max-Age=${options.maxAgeSeconds}${options.secure ? '; Secure' : ''}`;\n\nexport const PUBLISHER_HEADER = 'x-archive-publisher';\n/** Pins account erasure to the issuer account shown when confirmation began. */\nexport const ACCOUNT_HEADER = 'X-Archive-Account';\nexport const REQUEST_MARKER_HEADER = 'x-archive-request';\n/** The wire-contract version advertised by health and stamped on every entry envelope. */\nexport const ARCHIVE_CONTRACT_VERSION = 1;\n/**\n * The storage schema version: the `archive_schema_meta` stamp the package's migration\n * writes and the Worker reads back through health. A mismatch answers `degraded`\n * instead of `ok` — a stale database is the most likely deployment fault.\n */\nexport const ARCHIVE_SCHEMA_VERSION = 1;\n/** Canonical issuer database initialization stamp. */\nexport const AUTH_SCHEMA_VERSION = 1;\n/** The `roles` claim value a session JWT carries for operator authority (moderation). */\nexport const ARCHIVE_OPERATOR_ROLE = 'archive-operator';\n/** The routable collection-name rule: lowercase `[a-z0-9-]`, at most 32 chars. */\nexport const ARCHIVE_COLLECTION_NAME_PATTERN = /^[a-z0-9-]{1,32}$/;\n\n// Exported schemas carry explicit public `Schema<Output>` annotations: the concrete\n// spell schema classes are not part of spell's public export surface, so inferred\n// types would not be portable across package declaration files.\nexport const uuidSchema: Schema<string> = s.string().uuid();\nexport const isoTimestampSchema: Schema<string> = s.string().isoDateTime();\nexport const hex64Schema: Schema<string> = s.string().regex(/^[0-9a-f]{64}$/);\n/** Well-formed user text: no lone UTF-16 surrogates, no C0/C1/DEL control characters. */\nexport const ILL_FORMED_TEXT = /[\\p{Cs}\\p{Cc}]/u;\nexport const isWellFormedText = (value: string): boolean => !ILL_FORMED_TEXT.test(value);\nconst textString = () => s.string().check(isWellFormedText);\n/** The one alias rule, shared by identity mutations and every emitted DTO. */\nexport const archiveAliasSchema: Schema<string> = textString().trim().max(24);\n\nexport const archiveAuthorSchema: Schema<{ id: string; name: string }> = s.object({\n id: uuidSchema,\n name: archiveAliasSchema,\n});\nexport const archiveIdentitySchema: Schema<{ id: string; name: string; operator: boolean } | null> = s\n .object({ id: uuidSchema, name: archiveAliasSchema, operator: s.boolean() })\n .nullable();\nexport type ArchiveIdentity = InferOutput<typeof archiveIdentitySchema>;\n/** The issuer account metadata export; its id is the session subject, not a publisher id. */\nexport const archiveAccountSchema: Schema<{\n createdAt: string;\n email: string;\n id: string;\n lastSignInAt: string | null;\n}> = s.object({\n createdAt: isoTimestampSchema,\n email: s.string().email(),\n id: uuidSchema,\n lastSignInAt: isoTimestampSchema.nullable(),\n});\nexport type ArchiveAccount = InferOutput<typeof archiveAccountSchema>;\nexport const archiveLikedIdsSchema: Schema<{ ids: string[] }> = s.object({ ids: s.array(uuidSchema) });\nexport const archiveLikesPageSchema: Schema<{ cursor: string | null; ids: string[] }> = s.object({\n cursor: s.string().nullable(),\n ids: s.array(uuidSchema),\n});\nexport type ArchiveLikesPage = InferOutput<typeof archiveLikesPageSchema>;\nexport const archiveLikeStateSchema: Schema<{ id: string; likeCount: number; liked: boolean }> = s.object({\n id: uuidSchema,\n likeCount: s.number().int().min(0).max(Number.MAX_SAFE_INTEGER),\n liked: s.boolean(),\n});\n\n/** Application bounds shared by handlers, the health route, and clients. */\nexport const ARCHIVE_LIMITS = Object.freeze({\n aliasChars: 24,\n cursorChars: 1024,\n idempotencyRetentionMs: 48 * 60 * 60 * 1000,\n likeStateIds: 50,\n maxPageSize: 50,\n maxPublications: 50,\n pageSize: 10,\n payloadBytes: 8192,\n publicCacheSeconds: 30,\n /** One free-text moderation reason: trimmed, non-empty, at most this many chars. */\n reportReasonChars: 256,\n requestBytes: 12 * 1024,\n searchChars: 64,\n searchTerms: 6,\n tagsPerEntry: 16,\n titleChars: 128,\n /** One declared filter or tag value: well-formed, non-empty, at most this many chars. */\n vocabChars: 64,\n});\nexport const archiveLimitsSchema: Schema<{\n aliasChars: number;\n cursorChars: number;\n idempotencyRetentionMs: number;\n likeStateIds: number;\n maxPageSize: number;\n maxPublications: number;\n pageSize: number;\n payloadBytes: number;\n publicCacheSeconds: number;\n reportReasonChars: number;\n requestBytes: number;\n searchChars: number;\n searchTerms: number;\n tagsPerEntry: number;\n titleChars: number;\n vocabChars: number;\n}> = s.object({\n aliasChars: s.number().int().min(1),\n cursorChars: s.number().int().min(1),\n idempotencyRetentionMs: s.number().int().min(1),\n likeStateIds: s.number().int().min(1),\n maxPageSize: s.number().int().min(1),\n maxPublications: s.number().int().min(1),\n pageSize: s.number().int().min(1),\n payloadBytes: s.number().int().min(1),\n publicCacheSeconds: s.number().int().min(0),\n reportReasonChars: s.number().int().min(1),\n requestBytes: s.number().int().min(1),\n searchChars: s.number().int().min(1),\n searchTerms: s.number().int().min(1),\n tagsPerEntry: s.number().int().min(1),\n titleChars: s.number().int().min(1),\n vocabChars: s.number().int().min(1),\n});\nexport const archiveHealthSchema: Schema<{\n /** The storage schema stamp (`archive_schema_meta`) the database actually carries. */\n dbSchemaVersion: number | null;\n features: { likes: boolean; publishing: boolean };\n limits: InferOutput<typeof archiveLimitsSchema>;\n schemaVersion: 1;\n /** 'degraded' when the database's storage schema does not match the Worker's. */\n status: 'ok' | 'degraded';\n reason: 'database-unavailable' | 'schema-mismatch' | null;\n}> = s.object({\n dbSchemaVersion: s.number().int().min(0).nullable(),\n features: s.object({ likes: s.boolean(), publishing: s.boolean() }),\n limits: archiveLimitsSchema,\n reason: s.enum(['database-unavailable', 'schema-mismatch'] as const).nullable(),\n schemaVersion: s.literal(ARCHIVE_CONTRACT_VERSION),\n status: s.enum(['ok', 'degraded'] as const),\n});\nexport type ArchiveHealth = InferOutput<typeof archiveHealthSchema>;\n\nexport const archiveAuthHealthSchema: Schema<{\n dbSchemaVersion: number | null;\n schemaVersion: 1;\n status: 'ok' | 'degraded';\n reason: 'configuration-unavailable' | 'database-unavailable' | 'schema-mismatch' | null;\n}> = s.object({\n dbSchemaVersion: s.number().int().min(0).nullable(),\n reason: s.enum(['configuration-unavailable', 'database-unavailable', 'schema-mismatch'] as const).nullable(),\n schemaVersion: s.literal(AUTH_SCHEMA_VERSION),\n status: s.enum(['ok', 'degraded'] as const),\n});\nexport type AuthHealth = InferOutput<typeof archiveAuthHealthSchema>;\n\n/** The Worker's full error-code vocabulary — the contract both sides test against. */\nexport const ARCHIVE_ERROR_CODES = [\n 'account-changed',\n 'auth-unavailable',\n 'bad-request',\n 'conflict',\n 'forbidden',\n 'gone',\n 'internal',\n 'invalid-payload',\n 'likes-disabled',\n 'method-not-allowed',\n 'not-found',\n 'publisher-changed',\n 'publishing-disabled',\n 'quota',\n 'rate-limited',\n 'record-unavailable',\n 'timeout',\n 'too-large',\n 'unauthenticated',\n 'unavailable',\n 'unsupported-media',\n] as const;\nexport type ArchiveErrorCode = (typeof ARCHIVE_ERROR_CODES)[number];\n\n/**\n * The error envelope every route answers failures with: `{ error: { code, message,\n * requestId } }`. Clients validate against this one schema — an envelope carrying an\n * unknown code or malformed fields is not an archive error at all.\n */\nexport const archiveErrorResponseSchema: Schema<{\n error: { code: ArchiveErrorCode; message: string; requestId: string };\n}> = s\n .object({\n error: s\n .object({\n code: s.enum(ARCHIVE_ERROR_CODES),\n message: s.string().min(1).max(256),\n requestId: uuidSchema,\n })\n .strict(),\n })\n .strict();\nexport type ArchiveErrorResponse = InferOutput<typeof archiveErrorResponseSchema>;\n\n/** The sign-out acknowledgement: the only non-collection response body, shared by both sides. */\nexport const archiveOkSchema: Schema<{ ok: true }> = s.object({ ok: s.literal(true) }).strict();\n\n/** One collection's public self-description: its name and declared query vocabularies. */\nexport const archiveCollectionMetaSchema: Schema<{\n filters: string[];\n name: string;\n tags: string[];\n}> = s.object({\n filters: s.array(s.string().min(1).max(ARCHIVE_LIMITS.vocabChars)),\n name: s.string().regex(ARCHIVE_COLLECTION_NAME_PATTERN),\n tags: s.array(s.string().min(1).max(ARCHIVE_LIMITS.vocabChars)),\n});\nexport type ArchiveCollectionMeta = InferOutput<typeof archiveCollectionMetaSchema>;\n\n/** One reader report an operator reviews: what was reported, why, and by whom. */\nexport const archiveReportsSchema: Schema<{\n cursor: string | null;\n reports: {\n id: string;\n createdAt: string;\n entryId: string;\n entryTitle: string;\n reason: string;\n reporter: { id: string; name: string };\n resolvedAt: string | null;\n }[];\n}> = s.object({\n cursor: s.string().nullable(),\n reports: s.array(\n s.object({\n createdAt: isoTimestampSchema,\n entryId: uuidSchema,\n entryTitle: s.string().min(1).max(ARCHIVE_LIMITS.titleChars),\n id: uuidSchema,\n reason: s.string().min(1).max(ARCHIVE_LIMITS.reportReasonChars),\n reporter: archiveAuthorSchema,\n resolvedAt: isoTimestampSchema.nullable(),\n }),\n ),\n});\nexport type ArchiveReports = InferOutput<typeof archiveReportsSchema>;\nexport const archiveOwnReportsSchema: Schema<{\n cursor: string | null;\n reports: {\n collection: string;\n createdAt: string;\n entryId: string;\n id: string;\n reason: string;\n resolvedAt: string | null;\n }[];\n}> = s.object({\n cursor: s.string().nullable(),\n reports: s.array(\n s.object({\n collection: s.string().regex(ARCHIVE_COLLECTION_NAME_PATTERN),\n createdAt: isoTimestampSchema,\n entryId: uuidSchema,\n id: uuidSchema,\n reason: s.string().min(1).max(ARCHIVE_LIMITS.reportReasonChars),\n resolvedAt: isoTimestampSchema.nullable(),\n }),\n ),\n});\nexport type ArchiveOwnReports = InferOutput<typeof archiveOwnReportsSchema>;\n\n/** The moderation actions an operator can take; the audit log's whole vocabulary. */\nexport const ARCHIVE_MODERATION_ACTIONS = ['hide', 'unhide', 'remove', 'erase-publisher'] as const;\nexport type ArchiveModerationAction = (typeof ARCHIVE_MODERATION_ACTIONS)[number];\n\n/**\n * The operator accountability log: one row per effective moderation action, newest\n * first. Operator and target travel as opaque ids only — no names (they change), no\n * content — so the log stays honest after renames and survives the erasures it records.\n */\nexport const archiveModerationLogSchema: Schema<{\n cursor: string | null;\n actions: {\n id: string;\n action: ArchiveModerationAction;\n collection: string | null;\n createdAt: string;\n operatorId: string;\n targetId: string;\n }[];\n}> = s.object({\n actions: s.array(\n s.object({\n action: s.enum(ARCHIVE_MODERATION_ACTIONS),\n collection: s.string().regex(ARCHIVE_COLLECTION_NAME_PATTERN).nullable(),\n createdAt: isoTimestampSchema,\n id: uuidSchema,\n operatorId: uuidSchema,\n targetId: uuidSchema,\n }),\n ),\n cursor: s.string().nullable(),\n});\nexport type ArchiveModerationLog = InferOutput<typeof archiveModerationLogSchema>;\n\n/** How the online list ranks entries: explicit modes only, no vague composites. */\nexport type ArchiveSort = 'liked' | 'recent';\nexport const archiveSortSchema: Schema<ArchiveSort> = s.enum(['liked', 'recent'] as const);\n\n/**\n * One page of archive entries. Every list in the package pages forward-only: the\n * cursor walks toward the oldest entry and is null after the last page.\n */\nexport interface ArchivePage<T> {\n /** Cursor for the next page; null after the last. */\n cursor: string | null;\n entries: readonly T[];\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"contract.js","names":[],"sources":["../src/contract.ts"],"sourcesContent":["import { type InferOutput, type Schema, s } from '@vielzeug/spell';\n\n/** Shared naming: one place so the client, Worker and issuer cannot drift. */\nexport const SESSION_COOKIE_NAME = 'archive_session';\nexport const SESSION_COOKIE_PATH = '/api/v1';\n/**\n * The maximum age a session JWT may carry, and the cookie Max-Age an issuer sets:\n * a long-lived signed-in state. The Worker rejects older tokens at this bound.\n */\nexport const SESSION_MAX_AGE_SECONDS = 30 * 24 * 60 * 60;\nexport const OPERATOR_SESSION_MAX_AGE_SECONDS = 60 * 60;\n\n/**\n * The full Set-Cookie value for the session cookie: name, token and the security\n * attributes every issuer and the Worker must agree on — HttpOnly, SameSite=Strict,\n * the contract Path, Max-Age, and Secure in production. Issuers mint with the\n * session's max age; the Worker and issuers clear with `maxAgeSeconds: 0`. Never\n * hand-compose the attributes: the cookie's CSRF and theft posture is contract,\n * not per-issuer choice.\n */\nexport const sessionCookieValue = (token: string, options: { maxAgeSeconds: number; secure: boolean }): string =>\n `${SESSION_COOKIE_NAME}=${token}; HttpOnly; SameSite=Strict; Path=${SESSION_COOKIE_PATH}; Max-Age=${options.maxAgeSeconds}${options.secure ? '; Secure' : ''}`;\n\nexport const PUBLISHER_HEADER = 'x-archive-publisher';\n/** Pins account erasure to the issuer account shown when confirmation began. */\nexport const ACCOUNT_HEADER = 'X-Archive-Account';\nexport const REQUEST_MARKER_HEADER = 'x-archive-request';\n/** The wire-contract version advertised by health and stamped on every entry envelope. */\nexport const ARCHIVE_CONTRACT_VERSION = 1;\n/**\n * The storage schema version: the `archive_schema_meta` stamp the package's migration\n * writes and the Worker reads back through health. A mismatch answers `degraded`\n * instead of `ok` — a stale database is the most likely deployment fault.\n */\nexport const ARCHIVE_SCHEMA_VERSION = 1;\n/** Canonical issuer database initialization stamp. */\nexport const AUTH_SCHEMA_VERSION = 1;\n/** The `roles` claim value a session JWT carries for operator authority (moderation). */\nexport const ARCHIVE_OPERATOR_ROLE = 'archive-operator';\n/** The routable collection-name rule: lowercase `[a-z0-9-]`, at most 32 chars. */\nexport const ARCHIVE_COLLECTION_NAME_PATTERN = /^[a-z0-9-]{1,32}$/;\n\n// Exported schemas carry explicit public `Schema<Output>` annotations: the concrete\n// spell schema classes are not part of spell's public export surface, so inferred\n// types would not be portable across package declaration files.\nexport const uuidSchema: Schema<string> = s.string().uuid();\nexport const isoTimestampSchema: Schema<string> = s.string().isoDateTime();\nexport const hex64Schema: Schema<string> = s.string().regex(/^[0-9a-f]{64}$/);\n/** Well-formed user text: no lone UTF-16 surrogates, no C0/C1/DEL control characters. */\nexport const ILL_FORMED_TEXT = /[\\p{Cs}\\p{Cc}]/u;\nexport const isWellFormedText = (value: string): boolean => !ILL_FORMED_TEXT.test(value);\nconst textString = () => s.string().check(isWellFormedText);\n/** The one alias rule, shared by identity mutations and every emitted DTO. */\nexport const archiveAliasSchema: Schema<string> = textString().trim().max(24);\n\nexport const archiveAuthorSchema: Schema<{ id: string; name: string }> = s.object({\n id: uuidSchema,\n name: archiveAliasSchema,\n});\nexport const archiveIdentitySchema: Schema<{ id: string; name: string; operator: boolean } | null> = s\n .object({ id: uuidSchema, name: archiveAliasSchema, operator: s.boolean() })\n .nullable();\nexport type ArchiveIdentity = InferOutput<typeof archiveIdentitySchema>;\n/** The issuer account metadata export; its id is the session subject, not a publisher id. */\nexport const archiveAccountSchema: Schema<{\n createdAt: string;\n email: string;\n id: string;\n lastSignInAt: string | null;\n}> = s.object({\n createdAt: isoTimestampSchema,\n email: s.string().email(),\n id: uuidSchema,\n lastSignInAt: isoTimestampSchema.nullable(),\n});\nexport type ArchiveAccount = InferOutput<typeof archiveAccountSchema>;\nexport const archiveLikedIdsSchema: Schema<{ ids: string[] }> = s.object({ ids: s.array(uuidSchema) });\nexport const archiveLikesPageSchema: Schema<{ cursor: string | null; ids: string[] }> = s.object({\n cursor: s.string().nullable(),\n ids: s.array(uuidSchema),\n});\nexport type ArchiveLikesPage = InferOutput<typeof archiveLikesPageSchema>;\nexport const archiveLikeStateSchema: Schema<{ id: string; likeCount: number; liked: boolean }> = s.object({\n id: uuidSchema,\n likeCount: s.number().int().min(0).max(Number.MAX_SAFE_INTEGER),\n liked: s.boolean(),\n});\n\n/** Application bounds shared by handlers, the health route, and clients. */\nexport const ARCHIVE_LIMITS = Object.freeze({\n aliasChars: 24,\n cursorChars: 1024,\n idempotencyRetentionMs: 48 * 60 * 60 * 1000,\n likeStateIds: 50,\n maxPageSize: 50,\n maxPublications: 50,\n pageSize: 10,\n payloadBytes: 8192,\n publicCacheSeconds: 30,\n /** One free-text moderation reason: trimmed, non-empty, at most this many chars. */\n reportReasonChars: 256,\n requestBytes: 12 * 1024,\n searchChars: 64,\n searchTerms: 6,\n tagsPerEntry: 16,\n titleChars: 128,\n /** One declared filter or tag value: well-formed, non-empty, at most this many chars. */\n vocabChars: 64,\n});\nexport const archiveLimitsSchema: Schema<{\n aliasChars: number;\n cursorChars: number;\n idempotencyRetentionMs: number;\n likeStateIds: number;\n maxPageSize: number;\n maxPublications: number;\n pageSize: number;\n payloadBytes: number;\n publicCacheSeconds: number;\n reportReasonChars: number;\n requestBytes: number;\n searchChars: number;\n searchTerms: number;\n tagsPerEntry: number;\n titleChars: number;\n vocabChars: number;\n}> = s.object({\n aliasChars: s.number().int().min(1),\n cursorChars: s.number().int().min(1),\n idempotencyRetentionMs: s.number().int().min(1),\n likeStateIds: s.number().int().min(1),\n maxPageSize: s.number().int().min(1),\n maxPublications: s.number().int().min(1),\n pageSize: s.number().int().min(1),\n payloadBytes: s.number().int().min(1),\n publicCacheSeconds: s.number().int().min(0),\n reportReasonChars: s.number().int().min(1),\n requestBytes: s.number().int().min(1),\n searchChars: s.number().int().min(1),\n searchTerms: s.number().int().min(1),\n tagsPerEntry: s.number().int().min(1),\n titleChars: s.number().int().min(1),\n vocabChars: s.number().int().min(1),\n});\nexport const archiveHealthSchema: Schema<{\n /** The storage schema stamp (`archive_schema_meta`) the database actually carries. */\n dbSchemaVersion: number | null;\n features: { likes: boolean; publishing: boolean };\n limits: InferOutput<typeof archiveLimitsSchema>;\n schemaVersion: 1;\n /** 'degraded' when the database's storage schema does not match the Worker's. */\n status: 'ok' | 'degraded';\n reason: 'database-unavailable' | 'schema-mismatch' | null;\n}> = s.object({\n dbSchemaVersion: s.number().int().min(0).nullable(),\n features: s.object({ likes: s.boolean(), publishing: s.boolean() }),\n limits: archiveLimitsSchema,\n reason: s.enum(['database-unavailable', 'schema-mismatch'] as const).nullable(),\n schemaVersion: s.literal(ARCHIVE_CONTRACT_VERSION),\n status: s.enum(['ok', 'degraded'] as const),\n});\nexport type ArchiveHealth = InferOutput<typeof archiveHealthSchema>;\n\nexport const archiveAuthHealthSchema: Schema<{\n dbSchemaVersion: number | null;\n schemaVersion: 1;\n status: 'ok' | 'degraded';\n reason: 'configuration-unavailable' | 'database-unavailable' | 'schema-mismatch' | null;\n}> = s.object({\n dbSchemaVersion: s.number().int().min(0).nullable(),\n reason: s.enum(['configuration-unavailable', 'database-unavailable', 'schema-mismatch'] as const).nullable(),\n schemaVersion: s.literal(AUTH_SCHEMA_VERSION),\n status: s.enum(['ok', 'degraded'] as const),\n});\nexport type AuthHealth = InferOutput<typeof archiveAuthHealthSchema>;\n\n/** The Worker's full error-code vocabulary — the contract both sides test against. */\nexport const ARCHIVE_ERROR_CODES = [\n 'account-changed',\n 'auth-unavailable',\n 'bad-request',\n 'conflict',\n 'forbidden',\n 'gone',\n 'internal',\n 'invalid-payload',\n 'likes-disabled',\n 'method-not-allowed',\n 'not-found',\n 'publisher-changed',\n 'publishing-disabled',\n 'quota',\n 'rate-limited',\n 'record-unavailable',\n 'timeout',\n 'too-large',\n 'unauthenticated',\n 'unavailable',\n 'unsupported-media',\n] as const;\nexport type ArchiveErrorCode = (typeof ARCHIVE_ERROR_CODES)[number];\n\n/**\n * The error envelope every route answers failures with: `{ error: { code, message,\n * requestId } }`. Clients validate against this one schema — an envelope carrying an\n * unknown code or malformed fields is not an archive error at all.\n */\nexport const archiveErrorResponseSchema: Schema<{\n error: { code: ArchiveErrorCode; message: string; requestId: string };\n}> = s\n .object({\n error: s\n .object({\n code: s.enum(ARCHIVE_ERROR_CODES),\n message: s.string().min(1).max(256),\n requestId: uuidSchema,\n })\n .strict(),\n })\n .strict();\nexport type ArchiveErrorResponse = InferOutput<typeof archiveErrorResponseSchema>;\n\n/** The sign-out acknowledgement: the only non-collection response body, shared by both sides. */\nexport const archiveOkSchema: Schema<{ ok: true }> = s.object({ ok: s.literal(true) }).strict();\n\n/** One collection's public self-description: its name and declared query vocabularies. */\nexport const archiveCollectionMetaSchema: Schema<{\n filters: string[];\n name: string;\n tags: string[];\n}> = s.object({\n filters: s.array(s.string().min(1).max(ARCHIVE_LIMITS.vocabChars)),\n name: s.string().regex(ARCHIVE_COLLECTION_NAME_PATTERN),\n tags: s.array(s.string().min(1).max(ARCHIVE_LIMITS.vocabChars)),\n});\nexport type ArchiveCollectionMeta = InferOutput<typeof archiveCollectionMetaSchema>;\n\n/** One reader report an operator reviews: what was reported, why, and by whom. */\nexport const archiveReportsSchema: Schema<{\n cursor: string | null;\n reports: {\n id: string;\n createdAt: string;\n entryId: string;\n entryTitle: string;\n reason: string;\n reporter: { id: string; name: string };\n resolvedAt: string | null;\n }[];\n}> = s.object({\n cursor: s.string().nullable(),\n reports: s.array(\n s.object({\n createdAt: isoTimestampSchema,\n entryId: uuidSchema,\n entryTitle: s.string().min(1).max(ARCHIVE_LIMITS.titleChars),\n id: uuidSchema,\n reason: s.string().min(1).max(ARCHIVE_LIMITS.reportReasonChars),\n reporter: archiveAuthorSchema,\n resolvedAt: isoTimestampSchema.nullable(),\n }),\n ),\n});\nexport type ArchiveReports = InferOutput<typeof archiveReportsSchema>;\nexport const archiveOwnReportsSchema: Schema<{\n cursor: string | null;\n reports: {\n collection: string;\n createdAt: string;\n entryId: string;\n id: string;\n reason: string;\n resolvedAt: string | null;\n }[];\n}> = s.object({\n cursor: s.string().nullable(),\n reports: s.array(\n s.object({\n collection: s.string().regex(ARCHIVE_COLLECTION_NAME_PATTERN),\n createdAt: isoTimestampSchema,\n entryId: uuidSchema,\n id: uuidSchema,\n reason: s.string().min(1).max(ARCHIVE_LIMITS.reportReasonChars),\n resolvedAt: isoTimestampSchema.nullable(),\n }),\n ),\n});\nexport type ArchiveOwnReports = InferOutput<typeof archiveOwnReportsSchema>;\n\n/** The moderation actions an operator can take; the audit log's whole vocabulary. */\nexport const ARCHIVE_MODERATION_ACTIONS = ['hide', 'unhide', 'remove', 'erase-publisher'] as const;\nexport type ArchiveModerationAction = (typeof ARCHIVE_MODERATION_ACTIONS)[number];\n\n/**\n * The operator accountability log: one row per effective moderation action, newest\n * first. Operator and target travel as opaque ids only — no names (they change), no\n * content — so the log stays honest after renames and survives the erasures it records.\n */\nexport const archiveModerationLogSchema: Schema<{\n cursor: string | null;\n actions: {\n id: string;\n action: ArchiveModerationAction;\n collection: string | null;\n createdAt: string;\n operatorId: string;\n targetId: string;\n }[];\n}> = s.object({\n actions: s.array(\n s.object({\n action: s.enum(ARCHIVE_MODERATION_ACTIONS),\n collection: s.string().regex(ARCHIVE_COLLECTION_NAME_PATTERN).nullable(),\n createdAt: isoTimestampSchema,\n id: uuidSchema,\n operatorId: uuidSchema,\n targetId: uuidSchema,\n }),\n ),\n cursor: s.string().nullable(),\n});\nexport type ArchiveModerationLog = InferOutput<typeof archiveModerationLogSchema>;\n\n/** How the online list ranks entries: explicit modes only, no vague composites. */\nexport type ArchiveSort = 'liked' | 'recent';\nexport const archiveSortSchema: Schema<ArchiveSort> = s.enum(['liked', 'recent'] as const);\n\n/**\n * One page of archive entries. Every list in the package pages forward-only: the\n * cursor walks toward the oldest entry and is null after the last page.\n */\nexport interface ArchivePage<T> {\n /** Cursor for the next page; null after the last. */\n cursor: string | null;\n entries: readonly T[];\n}\n"],"mappings":"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"}
@@ -1,2 +1,2 @@
1
- const e=require("../contract.cjs"),t=require("./_service.cjs"),n=require("./http.cjs"),r=require("./sql.cjs");let i=require("jose");var a=e.SESSION_MAX_AGE_SECONDS,o=256,authUnavailable=()=>new n.ApiError(503,`auth-unavailable`,`Sign-in is currently unavailable.`);async function verifySession(s,c,l,u=!1){let d=n.sessionToken(s);if(d===null)return null;let f;try{f=(await(0,i.jwtVerify)(d,c.jwks,{algorithms:[`RS256`],audience:c.audience,clockTolerance:5,issuer:c.issuer,maxTokenAge:a,requiredClaims:[`sub`,`iss`,`aud`,`iat`,`exp`,`sessionVersion`]})).payload}catch(e){throw e instanceof i.errors.JWKSTimeout||e instanceof i.errors.JWKSInvalid||e instanceof i.errors.JWKSMultipleMatchingKeys?authUnavailable():e instanceof i.errors.JOSEError?n.unauthenticated():authUnavailable()}let p=f.sub,m=f.iss,h=f.iat,g=f.roles;if(!Number.isSafeInteger(f.sessionVersion)||f.sessionVersion<0)throw n.unauthenticated();let _=Array.isArray(g)&&g.includes(`archive-operator`);if(typeof p!=`string`||p.length===0||p.length>o||!e.isWellFormedText(p)||typeof m!=`string`||typeof h==`number`&&h*1e3>Date.now()+5e3||_&&(typeof h!=`number`||typeof f.exp!=`number`||f.exp-h>900||Date.now()/1e3-h>905))throw n.unauthenticated();if(!(u&&await l.DB.prepare(r.SQL.revokedSubject).bind(m,p,Date.now()).first()!==null))try{await t.serviceRequest(l.AUTH_ISSUER_SERVICE,new Request(new URL(`/api/v1/auth/session`,c.issuer),{headers:{cookie:`archive_session=${d}`},redirect:`manual`}),async t=>{let r=await t.json();if(t.status===200&&e.archiveOkSchema.safeParse(r).success)return;let i=e.archiveErrorResponseSchema.safeParse(r);throw t.status===401&&i.success&&i.data.error.code===`unauthenticated`?n.unauthenticated():authUnavailable()})}catch(e){throw e instanceof n.ApiError&&e.status===401?e:authUnavailable()}if(!u&&await l.DB.prepare(r.SQL.revokedSubject).bind(m,p,Date.now()).first()!==null)throw n.unauthenticated();let v=await l.DB.prepare(r.SQL.publisher).bind(m,p).first();return{operator:_,publisher:v===null?null:{displayName:v.display_name,id:v.id},subject:{issuer:m,subject:p}}}function requireSession(e){if(e===null)throw n.unauthenticated();return e}exports.requireSession=requireSession,exports.verifySession=verifySession;
1
+ const e=require("../contract.cjs"),t=require("./_service.cjs"),n=require("./http.cjs"),r=require("./sql.cjs");let i=require("jose");var a=e.SESSION_MAX_AGE_SECONDS,o=256,authUnavailable=()=>new n.ApiError(503,`auth-unavailable`,`Sign-in is currently unavailable.`);async function verifySession(s,c,l,u=!1){let d=n.sessionToken(s);if(d===null)return null;let f;try{f=(await(0,i.jwtVerify)(d,c.jwks,{algorithms:[`RS256`],audience:c.audience,clockTolerance:5,issuer:c.issuer,maxTokenAge:a,requiredClaims:[`sub`,`iss`,`aud`,`iat`,`exp`,`sessionVersion`]})).payload}catch(e){throw e instanceof i.errors.JWKSTimeout||e instanceof i.errors.JWKSInvalid||e instanceof i.errors.JWKSMultipleMatchingKeys?authUnavailable():e instanceof i.errors.JOSEError?n.unauthenticated():authUnavailable()}let p=f.sub,m=f.iss,h=f.iat,g=f.roles;if(!Number.isSafeInteger(f.sessionVersion)||f.sessionVersion<0)throw n.unauthenticated();let _=Array.isArray(g)&&g.includes(`archive-operator`);if(typeof p!=`string`||p.length===0||p.length>o||!e.isWellFormedText(p)||typeof m!=`string`||typeof h==`number`&&h*1e3>Date.now()+5e3||_&&(typeof h!=`number`||typeof f.exp!=`number`||f.exp-h>3600||Date.now()/1e3-h>3605))throw n.unauthenticated();if(!(u&&await l.DB.prepare(r.SQL.revokedSubject).bind(m,p,Date.now()).first()!==null))try{await t.serviceRequest(l.AUTH_ISSUER_SERVICE,new Request(new URL(`/api/v1/auth/session`,c.issuer),{headers:{cookie:`archive_session=${d}`},redirect:`manual`}),async t=>{let r=await t.json();if(t.status===200&&e.archiveOkSchema.safeParse(r).success)return;let i=e.archiveErrorResponseSchema.safeParse(r);throw t.status===401&&i.success&&i.data.error.code===`unauthenticated`?n.unauthenticated():authUnavailable()})}catch(e){throw e instanceof n.ApiError&&e.status===401?e:authUnavailable()}if(!u&&await l.DB.prepare(r.SQL.revokedSubject).bind(m,p,Date.now()).first()!==null)throw n.unauthenticated();let v=await l.DB.prepare(r.SQL.publisher).bind(m,p).first();return{operator:_,publisher:v===null?null:{displayName:v.display_name,id:v.id},subject:{issuer:m,subject:p}}}function requireSession(e){if(e===null)throw n.unauthenticated();return e}exports.requireSession=requireSession,exports.verifySession=verifySession;
2
2
  //# sourceMappingURL=auth.cjs.map
@@ -1 +1 @@
1
- {"version":3,"file":"auth.cjs","names":[],"sources":["../../src/worker/auth.ts"],"sourcesContent":["import { errors, type JWTPayload, jwtVerify } from 'jose';\nimport {\n ARCHIVE_OPERATOR_ROLE,\n archiveErrorResponseSchema,\n archiveOkSchema,\n isWellFormedText,\n OPERATOR_SESSION_MAX_AGE_SECONDS,\n SESSION_MAX_AGE_SECONDS,\n} from '../contract';\nimport { serviceRequest } from './_service';\nimport type { ApiConfig, ArchiveWorkerEnv } from './env';\nimport { ApiError, sessionToken, unauthenticated } from './http';\nimport { SQL } from './sql';\n\n/**\n * Session authentication. The Worker trusts exactly one source: the archive_session\n * HttpOnly cookie holding an RS256 JWT from the configured issuer. Tokens are verified\n * before any database read and are never echoed into DTOs; the publisher row maps the\n * opaque (issuer, subject) pair to the public publisher id. An optional `roles` claim\n * from the same trusted issuer grants operator authority (moderation routes).\n */\nexport interface VerifiedSubject {\n issuer: string;\n subject: string;\n}\n\nexport interface Publisher {\n displayName: string;\n id: string;\n}\n\nexport interface Session {\n /** True only when the verified token's `roles` claim carries the operator role. */\n operator: boolean;\n /** The registered publisher row; null while the verified user has not registered. */\n publisher: Publisher | null;\n subject: VerifiedSubject;\n}\n\nconst JWT_MAX_AGE_SECONDS = SESSION_MAX_AGE_SECONDS;\nexport const JWT_CLOCK_TOLERANCE_SECONDS = 5;\nconst SUBJECT_MAX_CHARS = 256;\n\nconst authUnavailable = (): ApiError => new ApiError(503, 'auth-unavailable', 'Sign-in is currently unavailable.');\n\n/**\n * Verifies the session cookie; returns null for anonymous requests.\n * Token/signature/claim problems are 401; JWKS provider faults (fetch failure,\n * timeout, undecodable document) are 503 `auth-unavailable` — an outage is not a\n * reason to treat the caller as signed out.\n */\nexport async function verifySession(\n request: Request,\n config: ApiConfig,\n env: ArchiveWorkerEnv,\n allowErasureRetry = false,\n): Promise<Session | null> {\n const token = sessionToken(request);\n if (token === null) return null;\n\n let payload: JWTPayload;\n try {\n const result = await jwtVerify(token, config.jwks, {\n algorithms: ['RS256'],\n audience: config.audience,\n clockTolerance: JWT_CLOCK_TOLERANCE_SECONDS,\n issuer: config.issuer,\n maxTokenAge: JWT_MAX_AGE_SECONDS,\n requiredClaims: ['sub', 'iss', 'aud', 'iat', 'exp', 'sessionVersion'],\n });\n payload = result.payload;\n } catch (error) {\n // Provider faults are 503: a JWKS outage must never read as a signed-out user.\n if (\n error instanceof errors.JWKSTimeout ||\n error instanceof errors.JWKSInvalid ||\n error instanceof errors.JWKSMultipleMatchingKeys\n ) {\n throw authUnavailable();\n }\n // Everything else in the JOSE family — malformed, expired, wrong alg/aud/iss,\n // bad signature, no matching key — is a token problem.\n if (error instanceof errors.JOSEError) throw unauthenticated();\n // Network fetch failures surface outside the JOSE error family.\n throw authUnavailable();\n }\n const subject = payload.sub;\n const issuer = payload.iss;\n const iat = payload.iat;\n const roles = payload.roles;\n if (!Number.isSafeInteger(payload.sessionVersion) || (payload.sessionVersion as number) < 0) throw unauthenticated();\n // Least authority: a malformed roles claim grants nothing, and operator authority\n // only ever comes from this verified, audience- and issuer-checked token.\n const operator = Array.isArray(roles) && roles.includes(ARCHIVE_OPERATOR_ROLE);\n // Malformed subjects must reject outright: lone surrogates/control characters\n // could otherwise collapse distinct claims into the same stored identity.\n if (\n typeof subject !== 'string' ||\n subject.length === 0 ||\n subject.length > SUBJECT_MAX_CHARS ||\n !isWellFormedText(subject)\n ) {\n throw unauthenticated();\n }\n if (typeof issuer !== 'string') throw unauthenticated();\n // A token claiming to be issued in the future beyond clock tolerance is a claims failure.\n if (typeof iat === 'number' && iat * 1000 > Date.now() + JWT_CLOCK_TOLERANCE_SECONDS * 1000) {\n throw unauthenticated();\n }\n if (\n operator &&\n (typeof iat !== 'number' ||\n typeof payload.exp !== 'number' ||\n payload.exp - iat > OPERATOR_SESSION_MAX_AGE_SECONDS ||\n Date.now() / 1000 - iat > OPERATOR_SESSION_MAX_AGE_SECONDS + JWT_CLOCK_TOLERANCE_SECONDS)\n )\n throw unauthenticated();\n\n // Only a previously tombstoned principal can bypass live validation to finish\n // partial erasure. Its profile cannot be re-created while the tombstone lives.\n const erasureRetry =\n allowErasureRetry && (await env.DB.prepare(SQL.revokedSubject).bind(issuer, subject, Date.now()).first()) !== null;\n if (!erasureRetry) {\n try {\n await serviceRequest(\n env.AUTH_ISSUER_SERVICE,\n new Request(new URL('/api/v1/auth/session', config.issuer), {\n headers: { cookie: `archive_session=${token}` },\n redirect: 'manual',\n }),\n async (response) => {\n const body: unknown = await response.json();\n if (response.status === 200 && archiveOkSchema.safeParse(body).success) return;\n const failure = archiveErrorResponseSchema.safeParse(body);\n if (response.status === 401 && failure.success && failure.data.error.code === 'unauthenticated')\n throw unauthenticated();\n throw authUnavailable();\n },\n );\n } catch (error) {\n if (error instanceof ApiError && error.status === 401) throw error;\n throw authUnavailable();\n }\n }\n if (!allowErasureRetry) {\n const revoked = await env.DB.prepare(SQL.revokedSubject).bind(issuer, subject, Date.now()).first();\n if (revoked !== null) throw unauthenticated();\n }\n const row = await env.DB.prepare(SQL.publisher).bind(issuer, subject).first<{\n display_name: string;\n id: string;\n }>();\n return {\n operator,\n publisher: row === null ? null : { displayName: row.display_name, id: row.id },\n subject: { issuer, subject },\n };\n}\n\n/** Session required for private routes; anonymous gets 401. */\nexport function requireSession(session: Session | null): Session {\n if (session === null) throw unauthenticated();\n return session;\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"auth.cjs","names":[],"sources":["../../src/worker/auth.ts"],"sourcesContent":["import { errors, type JWTPayload, jwtVerify } from 'jose';\nimport {\n ARCHIVE_OPERATOR_ROLE,\n archiveErrorResponseSchema,\n archiveOkSchema,\n isWellFormedText,\n OPERATOR_SESSION_MAX_AGE_SECONDS,\n SESSION_MAX_AGE_SECONDS,\n} from '../contract';\nimport { serviceRequest } from './_service';\nimport type { ApiConfig, ArchiveWorkerEnv } from './env';\nimport { ApiError, sessionToken, unauthenticated } from './http';\nimport { SQL } from './sql';\n\n/**\n * Session authentication. The Worker trusts exactly one source: the archive_session\n * HttpOnly cookie holding an RS256 JWT from the configured issuer. Tokens are verified\n * before any database read and are never echoed into DTOs; the publisher row maps the\n * opaque (issuer, subject) pair to the public publisher id. An optional `roles` claim\n * from the same trusted issuer grants operator authority (moderation routes).\n */\nexport interface VerifiedSubject {\n issuer: string;\n subject: string;\n}\n\nexport interface Publisher {\n displayName: string;\n id: string;\n}\n\nexport interface Session {\n /** True only when the verified token's `roles` claim carries the operator role. */\n operator: boolean;\n /** The registered publisher row; null while the verified user has not registered. */\n publisher: Publisher | null;\n subject: VerifiedSubject;\n}\n\nconst JWT_MAX_AGE_SECONDS = SESSION_MAX_AGE_SECONDS;\nexport const JWT_CLOCK_TOLERANCE_SECONDS = 5;\nconst SUBJECT_MAX_CHARS = 256;\n\nconst authUnavailable = (): ApiError => new ApiError(503, 'auth-unavailable', 'Sign-in is currently unavailable.');\n\n/**\n * Verifies the session cookie; returns null for anonymous requests.\n * Token/signature/claim problems are 401; JWKS provider faults (fetch failure,\n * timeout, undecodable document) are 503 `auth-unavailable` — an outage is not a\n * reason to treat the caller as signed out.\n */\nexport async function verifySession(\n request: Request,\n config: ApiConfig,\n env: ArchiveWorkerEnv,\n allowErasureRetry = false,\n): Promise<Session | null> {\n const token = sessionToken(request);\n if (token === null) return null;\n\n let payload: JWTPayload;\n try {\n const result = await jwtVerify(token, config.jwks, {\n algorithms: ['RS256'],\n audience: config.audience,\n clockTolerance: JWT_CLOCK_TOLERANCE_SECONDS,\n issuer: config.issuer,\n maxTokenAge: JWT_MAX_AGE_SECONDS,\n requiredClaims: ['sub', 'iss', 'aud', 'iat', 'exp', 'sessionVersion'],\n });\n payload = result.payload;\n } catch (error) {\n // Provider faults are 503: a JWKS outage must never read as a signed-out user.\n if (\n error instanceof errors.JWKSTimeout ||\n error instanceof errors.JWKSInvalid ||\n error instanceof errors.JWKSMultipleMatchingKeys\n ) {\n throw authUnavailable();\n }\n // Everything else in the JOSE family — malformed, expired, wrong alg/aud/iss,\n // bad signature, no matching key — is a token problem.\n if (error instanceof errors.JOSEError) throw unauthenticated();\n // Network fetch failures surface outside the JOSE error family.\n throw authUnavailable();\n }\n const subject = payload.sub;\n const issuer = payload.iss;\n const iat = payload.iat;\n const roles = payload.roles;\n if (!Number.isSafeInteger(payload.sessionVersion) || (payload.sessionVersion as number) < 0) throw unauthenticated();\n // Least authority: a malformed roles claim grants nothing, and operator authority\n // only ever comes from this verified, audience- and issuer-checked token.\n const operator = Array.isArray(roles) && roles.includes(ARCHIVE_OPERATOR_ROLE);\n // Malformed subjects must reject outright: lone surrogates/control characters\n // could otherwise collapse distinct claims into the same stored identity.\n if (\n typeof subject !== 'string' ||\n subject.length === 0 ||\n subject.length > SUBJECT_MAX_CHARS ||\n !isWellFormedText(subject)\n ) {\n throw unauthenticated();\n }\n if (typeof issuer !== 'string') throw unauthenticated();\n // A token claiming to be issued in the future beyond clock tolerance is a claims failure.\n if (typeof iat === 'number' && iat * 1000 > Date.now() + JWT_CLOCK_TOLERANCE_SECONDS * 1000) {\n throw unauthenticated();\n }\n if (\n operator &&\n (typeof iat !== 'number' ||\n typeof payload.exp !== 'number' ||\n payload.exp - iat > OPERATOR_SESSION_MAX_AGE_SECONDS ||\n Date.now() / 1000 - iat > OPERATOR_SESSION_MAX_AGE_SECONDS + JWT_CLOCK_TOLERANCE_SECONDS)\n )\n throw unauthenticated();\n\n // Only a previously tombstoned principal can bypass live validation to finish\n // partial erasure. Its profile cannot be re-created while the tombstone lives.\n const erasureRetry =\n allowErasureRetry && (await env.DB.prepare(SQL.revokedSubject).bind(issuer, subject, Date.now()).first()) !== null;\n if (!erasureRetry) {\n try {\n await serviceRequest(\n env.AUTH_ISSUER_SERVICE,\n new Request(new URL('/api/v1/auth/session', config.issuer), {\n headers: { cookie: `archive_session=${token}` },\n redirect: 'manual',\n }),\n async (response) => {\n const body: unknown = await response.json();\n if (response.status === 200 && archiveOkSchema.safeParse(body).success) return;\n const failure = archiveErrorResponseSchema.safeParse(body);\n if (response.status === 401 && failure.success && failure.data.error.code === 'unauthenticated')\n throw unauthenticated();\n throw authUnavailable();\n },\n );\n } catch (error) {\n if (error instanceof ApiError && error.status === 401) throw error;\n throw authUnavailable();\n }\n }\n if (!allowErasureRetry) {\n const revoked = await env.DB.prepare(SQL.revokedSubject).bind(issuer, subject, Date.now()).first();\n if (revoked !== null) throw unauthenticated();\n }\n const row = await env.DB.prepare(SQL.publisher).bind(issuer, subject).first<{\n display_name: string;\n id: string;\n }>();\n return {\n operator,\n publisher: row === null ? null : { displayName: row.display_name, id: row.id },\n subject: { issuer, subject },\n };\n}\n\n/** Session required for private routes; anonymous gets 401. */\nexport function requireSession(session: Session | null): Session {\n if (session === null) throw unauthenticated();\n return session;\n}\n"],"mappings":"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"}
@@ -1,2 +1,2 @@
1
- import{SESSION_MAX_AGE_SECONDS as e,archiveErrorResponseSchema as t,archiveOkSchema as n,isWellFormedText as r}from"../contract.js";import{serviceRequest as i}from"./_service.js";import{ApiError as a,sessionToken as o,unauthenticated as s}from"./http.js";import{SQL as c}from"./sql.js";import{errors as l,jwtVerify as u}from"jose";var d=e,f=256,authUnavailable=()=>new a(503,`auth-unavailable`,`Sign-in is currently unavailable.`);async function verifySession(e,p,m,h=!1){let g=o(e);if(g===null)return null;let _;try{_=(await u(g,p.jwks,{algorithms:[`RS256`],audience:p.audience,clockTolerance:5,issuer:p.issuer,maxTokenAge:d,requiredClaims:[`sub`,`iss`,`aud`,`iat`,`exp`,`sessionVersion`]})).payload}catch(e){throw e instanceof l.JWKSTimeout||e instanceof l.JWKSInvalid||e instanceof l.JWKSMultipleMatchingKeys?authUnavailable():e instanceof l.JOSEError?s():authUnavailable()}let v=_.sub,y=_.iss,b=_.iat,x=_.roles;if(!Number.isSafeInteger(_.sessionVersion)||_.sessionVersion<0)throw s();let S=Array.isArray(x)&&x.includes(`archive-operator`);if(typeof v!=`string`||v.length===0||v.length>f||!r(v)||typeof y!=`string`||typeof b==`number`&&b*1e3>Date.now()+5e3||S&&(typeof b!=`number`||typeof _.exp!=`number`||_.exp-b>900||Date.now()/1e3-b>905))throw s();if(!(h&&await m.DB.prepare(c.revokedSubject).bind(y,v,Date.now()).first()!==null))try{await i(m.AUTH_ISSUER_SERVICE,new Request(new URL(`/api/v1/auth/session`,p.issuer),{headers:{cookie:`archive_session=${g}`},redirect:`manual`}),async e=>{let r=await e.json();if(e.status===200&&n.safeParse(r).success)return;let i=t.safeParse(r);throw e.status===401&&i.success&&i.data.error.code===`unauthenticated`?s():authUnavailable()})}catch(e){throw e instanceof a&&e.status===401?e:authUnavailable()}if(!h&&await m.DB.prepare(c.revokedSubject).bind(y,v,Date.now()).first()!==null)throw s();let C=await m.DB.prepare(c.publisher).bind(y,v).first();return{operator:S,publisher:C===null?null:{displayName:C.display_name,id:C.id},subject:{issuer:y,subject:v}}}function requireSession(e){if(e===null)throw s();return e}export{requireSession,verifySession};
1
+ import{SESSION_MAX_AGE_SECONDS as e,archiveErrorResponseSchema as t,archiveOkSchema as n,isWellFormedText as r}from"../contract.js";import{serviceRequest as i}from"./_service.js";import{ApiError as a,sessionToken as o,unauthenticated as s}from"./http.js";import{SQL as c}from"./sql.js";import{errors as l,jwtVerify as u}from"jose";var d=e,f=256,authUnavailable=()=>new a(503,`auth-unavailable`,`Sign-in is currently unavailable.`);async function verifySession(e,p,m,h=!1){let g=o(e);if(g===null)return null;let _;try{_=(await u(g,p.jwks,{algorithms:[`RS256`],audience:p.audience,clockTolerance:5,issuer:p.issuer,maxTokenAge:d,requiredClaims:[`sub`,`iss`,`aud`,`iat`,`exp`,`sessionVersion`]})).payload}catch(e){throw e instanceof l.JWKSTimeout||e instanceof l.JWKSInvalid||e instanceof l.JWKSMultipleMatchingKeys?authUnavailable():e instanceof l.JOSEError?s():authUnavailable()}let v=_.sub,y=_.iss,b=_.iat,x=_.roles;if(!Number.isSafeInteger(_.sessionVersion)||_.sessionVersion<0)throw s();let S=Array.isArray(x)&&x.includes(`archive-operator`);if(typeof v!=`string`||v.length===0||v.length>f||!r(v)||typeof y!=`string`||typeof b==`number`&&b*1e3>Date.now()+5e3||S&&(typeof b!=`number`||typeof _.exp!=`number`||_.exp-b>3600||Date.now()/1e3-b>3605))throw s();if(!(h&&await m.DB.prepare(c.revokedSubject).bind(y,v,Date.now()).first()!==null))try{await i(m.AUTH_ISSUER_SERVICE,new Request(new URL(`/api/v1/auth/session`,p.issuer),{headers:{cookie:`archive_session=${g}`},redirect:`manual`}),async e=>{let r=await e.json();if(e.status===200&&n.safeParse(r).success)return;let i=t.safeParse(r);throw e.status===401&&i.success&&i.data.error.code===`unauthenticated`?s():authUnavailable()})}catch(e){throw e instanceof a&&e.status===401?e:authUnavailable()}if(!h&&await m.DB.prepare(c.revokedSubject).bind(y,v,Date.now()).first()!==null)throw s();let C=await m.DB.prepare(c.publisher).bind(y,v).first();return{operator:S,publisher:C===null?null:{displayName:C.display_name,id:C.id},subject:{issuer:y,subject:v}}}function requireSession(e){if(e===null)throw s();return e}export{requireSession,verifySession};
2
2
  //# sourceMappingURL=auth.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"auth.js","names":[],"sources":["../../src/worker/auth.ts"],"sourcesContent":["import { errors, type JWTPayload, jwtVerify } from 'jose';\nimport {\n ARCHIVE_OPERATOR_ROLE,\n archiveErrorResponseSchema,\n archiveOkSchema,\n isWellFormedText,\n OPERATOR_SESSION_MAX_AGE_SECONDS,\n SESSION_MAX_AGE_SECONDS,\n} from '../contract';\nimport { serviceRequest } from './_service';\nimport type { ApiConfig, ArchiveWorkerEnv } from './env';\nimport { ApiError, sessionToken, unauthenticated } from './http';\nimport { SQL } from './sql';\n\n/**\n * Session authentication. The Worker trusts exactly one source: the archive_session\n * HttpOnly cookie holding an RS256 JWT from the configured issuer. Tokens are verified\n * before any database read and are never echoed into DTOs; the publisher row maps the\n * opaque (issuer, subject) pair to the public publisher id. An optional `roles` claim\n * from the same trusted issuer grants operator authority (moderation routes).\n */\nexport interface VerifiedSubject {\n issuer: string;\n subject: string;\n}\n\nexport interface Publisher {\n displayName: string;\n id: string;\n}\n\nexport interface Session {\n /** True only when the verified token's `roles` claim carries the operator role. */\n operator: boolean;\n /** The registered publisher row; null while the verified user has not registered. */\n publisher: Publisher | null;\n subject: VerifiedSubject;\n}\n\nconst JWT_MAX_AGE_SECONDS = SESSION_MAX_AGE_SECONDS;\nexport const JWT_CLOCK_TOLERANCE_SECONDS = 5;\nconst SUBJECT_MAX_CHARS = 256;\n\nconst authUnavailable = (): ApiError => new ApiError(503, 'auth-unavailable', 'Sign-in is currently unavailable.');\n\n/**\n * Verifies the session cookie; returns null for anonymous requests.\n * Token/signature/claim problems are 401; JWKS provider faults (fetch failure,\n * timeout, undecodable document) are 503 `auth-unavailable` — an outage is not a\n * reason to treat the caller as signed out.\n */\nexport async function verifySession(\n request: Request,\n config: ApiConfig,\n env: ArchiveWorkerEnv,\n allowErasureRetry = false,\n): Promise<Session | null> {\n const token = sessionToken(request);\n if (token === null) return null;\n\n let payload: JWTPayload;\n try {\n const result = await jwtVerify(token, config.jwks, {\n algorithms: ['RS256'],\n audience: config.audience,\n clockTolerance: JWT_CLOCK_TOLERANCE_SECONDS,\n issuer: config.issuer,\n maxTokenAge: JWT_MAX_AGE_SECONDS,\n requiredClaims: ['sub', 'iss', 'aud', 'iat', 'exp', 'sessionVersion'],\n });\n payload = result.payload;\n } catch (error) {\n // Provider faults are 503: a JWKS outage must never read as a signed-out user.\n if (\n error instanceof errors.JWKSTimeout ||\n error instanceof errors.JWKSInvalid ||\n error instanceof errors.JWKSMultipleMatchingKeys\n ) {\n throw authUnavailable();\n }\n // Everything else in the JOSE family — malformed, expired, wrong alg/aud/iss,\n // bad signature, no matching key — is a token problem.\n if (error instanceof errors.JOSEError) throw unauthenticated();\n // Network fetch failures surface outside the JOSE error family.\n throw authUnavailable();\n }\n const subject = payload.sub;\n const issuer = payload.iss;\n const iat = payload.iat;\n const roles = payload.roles;\n if (!Number.isSafeInteger(payload.sessionVersion) || (payload.sessionVersion as number) < 0) throw unauthenticated();\n // Least authority: a malformed roles claim grants nothing, and operator authority\n // only ever comes from this verified, audience- and issuer-checked token.\n const operator = Array.isArray(roles) && roles.includes(ARCHIVE_OPERATOR_ROLE);\n // Malformed subjects must reject outright: lone surrogates/control characters\n // could otherwise collapse distinct claims into the same stored identity.\n if (\n typeof subject !== 'string' ||\n subject.length === 0 ||\n subject.length > SUBJECT_MAX_CHARS ||\n !isWellFormedText(subject)\n ) {\n throw unauthenticated();\n }\n if (typeof issuer !== 'string') throw unauthenticated();\n // A token claiming to be issued in the future beyond clock tolerance is a claims failure.\n if (typeof iat === 'number' && iat * 1000 > Date.now() + JWT_CLOCK_TOLERANCE_SECONDS * 1000) {\n throw unauthenticated();\n }\n if (\n operator &&\n (typeof iat !== 'number' ||\n typeof payload.exp !== 'number' ||\n payload.exp - iat > OPERATOR_SESSION_MAX_AGE_SECONDS ||\n Date.now() / 1000 - iat > OPERATOR_SESSION_MAX_AGE_SECONDS + JWT_CLOCK_TOLERANCE_SECONDS)\n )\n throw unauthenticated();\n\n // Only a previously tombstoned principal can bypass live validation to finish\n // partial erasure. Its profile cannot be re-created while the tombstone lives.\n const erasureRetry =\n allowErasureRetry && (await env.DB.prepare(SQL.revokedSubject).bind(issuer, subject, Date.now()).first()) !== null;\n if (!erasureRetry) {\n try {\n await serviceRequest(\n env.AUTH_ISSUER_SERVICE,\n new Request(new URL('/api/v1/auth/session', config.issuer), {\n headers: { cookie: `archive_session=${token}` },\n redirect: 'manual',\n }),\n async (response) => {\n const body: unknown = await response.json();\n if (response.status === 200 && archiveOkSchema.safeParse(body).success) return;\n const failure = archiveErrorResponseSchema.safeParse(body);\n if (response.status === 401 && failure.success && failure.data.error.code === 'unauthenticated')\n throw unauthenticated();\n throw authUnavailable();\n },\n );\n } catch (error) {\n if (error instanceof ApiError && error.status === 401) throw error;\n throw authUnavailable();\n }\n }\n if (!allowErasureRetry) {\n const revoked = await env.DB.prepare(SQL.revokedSubject).bind(issuer, subject, Date.now()).first();\n if (revoked !== null) throw unauthenticated();\n }\n const row = await env.DB.prepare(SQL.publisher).bind(issuer, subject).first<{\n display_name: string;\n id: string;\n }>();\n return {\n operator,\n publisher: row === null ? null : { displayName: row.display_name, id: row.id },\n subject: { issuer, subject },\n };\n}\n\n/** Session required for private routes; anonymous gets 401. */\nexport function requireSession(session: Session | null): Session {\n if (session === null) throw unauthenticated();\n return session;\n}\n"],"mappings":"2UAuCA,IAAM,EAAsB,EAEtB,EAAoB,IAEpB,oBAAkC,IAAI,EAAS,IAAK,mBAAoB,mCAAmC,EAQjH,eAAsB,cACpB,EACA,EACA,EACA,EAAoB,GACK,CACzB,IAAM,EAAQ,EAAa,CAAO,EAClC,GAAI,IAAU,KAAM,OAAO,KAE3B,IAAI,EACJ,GAAI,CASF,GAAU,MARW,EAAU,EAAO,EAAO,KAAM,CACjD,WAAY,CAAC,OAAO,EACpB,SAAU,EAAO,SACjB,eAAA,EACA,OAAQ,EAAO,OACf,YAAa,EACb,eAAgB,CAAC,MAAO,MAAO,MAAO,MAAO,MAAO,gBAAgB,CACtE,CAAC,EAAA,CACgB,OACnB,OAAS,EAAO,CAad,MAVE,aAAiB,EAAO,aACxB,aAAiB,EAAO,aACxB,aAAiB,EAAO,yBAElB,gBAAgB,EAIpB,aAAiB,EAAO,UAAiB,EAAgB,EAEvD,gBAAgB,CACxB,CACA,IAAM,EAAU,EAAQ,IAClB,EAAS,EAAQ,IACjB,EAAM,EAAQ,IACd,EAAQ,EAAQ,MACtB,GAAI,CAAC,OAAO,cAAc,EAAQ,cAAc,GAAM,EAAQ,eAA4B,EAAG,MAAM,EAAgB,EAGnH,IAAM,EAAW,MAAM,QAAQ,CAAK,GAAK,EAAM,SAAA,kBAA8B,EAgB7E,GAZE,OAAO,GAAY,UACnB,EAAQ,SAAW,GACnB,EAAQ,OAAS,GACjB,CAAC,EAAiB,CAAO,GAIvB,OAAO,GAAW,UAElB,OAAO,GAAQ,UAAY,EAAM,IAAO,KAAK,IAAI,EAAA,KAInD,IACC,OAAO,GAAQ,UACd,OAAO,EAAQ,KAAQ,UACvB,EAAQ,IAAM,EAAA,KACd,KAAK,IAAI,EAAI,IAAO,EAAA,KAEtB,MAAM,EAAgB,EAMxB,GAAI,EADF,GAAsB,MAAM,EAAI,GAAG,QAAQ,EAAI,cAAc,CAAC,CAAC,KAAK,EAAQ,EAAS,KAAK,IAAI,CAAC,CAAC,CAAC,MAAM,IAAO,MAE9G,GAAI,CACF,MAAM,EACJ,EAAI,oBACJ,IAAI,QAAQ,IAAI,IAAI,uBAAwB,EAAO,MAAM,EAAG,CAC1D,QAAS,CAAE,OAAQ,mBAAmB,GAAQ,EAC9C,SAAU,QACZ,CAAC,EACD,KAAO,IAAa,CAClB,IAAM,EAAgB,MAAM,EAAS,KAAK,EAC1C,GAAI,EAAS,SAAW,KAAO,EAAgB,UAAU,CAAI,CAAC,CAAC,QAAS,OACxE,IAAM,EAAU,EAA2B,UAAU,CAAI,EAGzD,MAFI,EAAS,SAAW,KAAO,EAAQ,SAAW,EAAQ,KAAK,MAAM,OAAS,kBACtE,EAAgB,EAClB,gBAAgB,CACxB,CACF,CACF,OAAS,EAAO,CAEd,MADI,aAAiB,GAAY,EAAM,SAAW,IAAW,EACvD,gBAAgB,CACxB,CAEF,GAAI,CAAC,GAEC,MADkB,EAAI,GAAG,QAAQ,EAAI,cAAc,CAAC,CAAC,KAAK,EAAQ,EAAS,KAAK,IAAI,CAAC,CAAC,CAAC,MAAM,IACjF,KAAM,MAAM,EAAgB,EAE9C,IAAM,EAAM,MAAM,EAAI,GAAG,QAAQ,EAAI,SAAS,CAAC,CAAC,KAAK,EAAQ,CAAO,CAAC,CAAC,MAGnE,EACH,MAAO,CACL,WACA,UAAW,IAAQ,KAAO,KAAO,CAAE,YAAa,EAAI,aAAc,GAAI,EAAI,EAAG,EAC7E,QAAS,CAAE,SAAQ,SAAQ,CAC7B,CACF,CAGA,SAAgB,eAAe,EAAkC,CAC/D,GAAI,IAAY,KAAM,MAAM,EAAgB,EAC5C,OAAO,CACT"}
1
+ {"version":3,"file":"auth.js","names":[],"sources":["../../src/worker/auth.ts"],"sourcesContent":["import { errors, type JWTPayload, jwtVerify } from 'jose';\nimport {\n ARCHIVE_OPERATOR_ROLE,\n archiveErrorResponseSchema,\n archiveOkSchema,\n isWellFormedText,\n OPERATOR_SESSION_MAX_AGE_SECONDS,\n SESSION_MAX_AGE_SECONDS,\n} from '../contract';\nimport { serviceRequest } from './_service';\nimport type { ApiConfig, ArchiveWorkerEnv } from './env';\nimport { ApiError, sessionToken, unauthenticated } from './http';\nimport { SQL } from './sql';\n\n/**\n * Session authentication. The Worker trusts exactly one source: the archive_session\n * HttpOnly cookie holding an RS256 JWT from the configured issuer. Tokens are verified\n * before any database read and are never echoed into DTOs; the publisher row maps the\n * opaque (issuer, subject) pair to the public publisher id. An optional `roles` claim\n * from the same trusted issuer grants operator authority (moderation routes).\n */\nexport interface VerifiedSubject {\n issuer: string;\n subject: string;\n}\n\nexport interface Publisher {\n displayName: string;\n id: string;\n}\n\nexport interface Session {\n /** True only when the verified token's `roles` claim carries the operator role. */\n operator: boolean;\n /** The registered publisher row; null while the verified user has not registered. */\n publisher: Publisher | null;\n subject: VerifiedSubject;\n}\n\nconst JWT_MAX_AGE_SECONDS = SESSION_MAX_AGE_SECONDS;\nexport const JWT_CLOCK_TOLERANCE_SECONDS = 5;\nconst SUBJECT_MAX_CHARS = 256;\n\nconst authUnavailable = (): ApiError => new ApiError(503, 'auth-unavailable', 'Sign-in is currently unavailable.');\n\n/**\n * Verifies the session cookie; returns null for anonymous requests.\n * Token/signature/claim problems are 401; JWKS provider faults (fetch failure,\n * timeout, undecodable document) are 503 `auth-unavailable` — an outage is not a\n * reason to treat the caller as signed out.\n */\nexport async function verifySession(\n request: Request,\n config: ApiConfig,\n env: ArchiveWorkerEnv,\n allowErasureRetry = false,\n): Promise<Session | null> {\n const token = sessionToken(request);\n if (token === null) return null;\n\n let payload: JWTPayload;\n try {\n const result = await jwtVerify(token, config.jwks, {\n algorithms: ['RS256'],\n audience: config.audience,\n clockTolerance: JWT_CLOCK_TOLERANCE_SECONDS,\n issuer: config.issuer,\n maxTokenAge: JWT_MAX_AGE_SECONDS,\n requiredClaims: ['sub', 'iss', 'aud', 'iat', 'exp', 'sessionVersion'],\n });\n payload = result.payload;\n } catch (error) {\n // Provider faults are 503: a JWKS outage must never read as a signed-out user.\n if (\n error instanceof errors.JWKSTimeout ||\n error instanceof errors.JWKSInvalid ||\n error instanceof errors.JWKSMultipleMatchingKeys\n ) {\n throw authUnavailable();\n }\n // Everything else in the JOSE family — malformed, expired, wrong alg/aud/iss,\n // bad signature, no matching key — is a token problem.\n if (error instanceof errors.JOSEError) throw unauthenticated();\n // Network fetch failures surface outside the JOSE error family.\n throw authUnavailable();\n }\n const subject = payload.sub;\n const issuer = payload.iss;\n const iat = payload.iat;\n const roles = payload.roles;\n if (!Number.isSafeInteger(payload.sessionVersion) || (payload.sessionVersion as number) < 0) throw unauthenticated();\n // Least authority: a malformed roles claim grants nothing, and operator authority\n // only ever comes from this verified, audience- and issuer-checked token.\n const operator = Array.isArray(roles) && roles.includes(ARCHIVE_OPERATOR_ROLE);\n // Malformed subjects must reject outright: lone surrogates/control characters\n // could otherwise collapse distinct claims into the same stored identity.\n if (\n typeof subject !== 'string' ||\n subject.length === 0 ||\n subject.length > SUBJECT_MAX_CHARS ||\n !isWellFormedText(subject)\n ) {\n throw unauthenticated();\n }\n if (typeof issuer !== 'string') throw unauthenticated();\n // A token claiming to be issued in the future beyond clock tolerance is a claims failure.\n if (typeof iat === 'number' && iat * 1000 > Date.now() + JWT_CLOCK_TOLERANCE_SECONDS * 1000) {\n throw unauthenticated();\n }\n if (\n operator &&\n (typeof iat !== 'number' ||\n typeof payload.exp !== 'number' ||\n payload.exp - iat > OPERATOR_SESSION_MAX_AGE_SECONDS ||\n Date.now() / 1000 - iat > OPERATOR_SESSION_MAX_AGE_SECONDS + JWT_CLOCK_TOLERANCE_SECONDS)\n )\n throw unauthenticated();\n\n // Only a previously tombstoned principal can bypass live validation to finish\n // partial erasure. Its profile cannot be re-created while the tombstone lives.\n const erasureRetry =\n allowErasureRetry && (await env.DB.prepare(SQL.revokedSubject).bind(issuer, subject, Date.now()).first()) !== null;\n if (!erasureRetry) {\n try {\n await serviceRequest(\n env.AUTH_ISSUER_SERVICE,\n new Request(new URL('/api/v1/auth/session', config.issuer), {\n headers: { cookie: `archive_session=${token}` },\n redirect: 'manual',\n }),\n async (response) => {\n const body: unknown = await response.json();\n if (response.status === 200 && archiveOkSchema.safeParse(body).success) return;\n const failure = archiveErrorResponseSchema.safeParse(body);\n if (response.status === 401 && failure.success && failure.data.error.code === 'unauthenticated')\n throw unauthenticated();\n throw authUnavailable();\n },\n );\n } catch (error) {\n if (error instanceof ApiError && error.status === 401) throw error;\n throw authUnavailable();\n }\n }\n if (!allowErasureRetry) {\n const revoked = await env.DB.prepare(SQL.revokedSubject).bind(issuer, subject, Date.now()).first();\n if (revoked !== null) throw unauthenticated();\n }\n const row = await env.DB.prepare(SQL.publisher).bind(issuer, subject).first<{\n display_name: string;\n id: string;\n }>();\n return {\n operator,\n publisher: row === null ? null : { displayName: row.display_name, id: row.id },\n subject: { issuer, subject },\n };\n}\n\n/** Session required for private routes; anonymous gets 401. */\nexport function requireSession(session: Session | null): Session {\n if (session === null) throw unauthenticated();\n return session;\n}\n"],"mappings":"2UAuCA,IAAM,EAAsB,EAEtB,EAAoB,IAEpB,oBAAkC,IAAI,EAAS,IAAK,mBAAoB,mCAAmC,EAQjH,eAAsB,cACpB,EACA,EACA,EACA,EAAoB,GACK,CACzB,IAAM,EAAQ,EAAa,CAAO,EAClC,GAAI,IAAU,KAAM,OAAO,KAE3B,IAAI,EACJ,GAAI,CASF,GAAU,MARW,EAAU,EAAO,EAAO,KAAM,CACjD,WAAY,CAAC,OAAO,EACpB,SAAU,EAAO,SACjB,eAAA,EACA,OAAQ,EAAO,OACf,YAAa,EACb,eAAgB,CAAC,MAAO,MAAO,MAAO,MAAO,MAAO,gBAAgB,CACtE,CAAC,EAAA,CACgB,OACnB,OAAS,EAAO,CAad,MAVE,aAAiB,EAAO,aACxB,aAAiB,EAAO,aACxB,aAAiB,EAAO,yBAElB,gBAAgB,EAIpB,aAAiB,EAAO,UAAiB,EAAgB,EAEvD,gBAAgB,CACxB,CACA,IAAM,EAAU,EAAQ,IAClB,EAAS,EAAQ,IACjB,EAAM,EAAQ,IACd,EAAQ,EAAQ,MACtB,GAAI,CAAC,OAAO,cAAc,EAAQ,cAAc,GAAM,EAAQ,eAA4B,EAAG,MAAM,EAAgB,EAGnH,IAAM,EAAW,MAAM,QAAQ,CAAK,GAAK,EAAM,SAAA,kBAA8B,EAgB7E,GAZE,OAAO,GAAY,UACnB,EAAQ,SAAW,GACnB,EAAQ,OAAS,GACjB,CAAC,EAAiB,CAAO,GAIvB,OAAO,GAAW,UAElB,OAAO,GAAQ,UAAY,EAAM,IAAO,KAAK,IAAI,EAAA,KAInD,IACC,OAAO,GAAQ,UACd,OAAO,EAAQ,KAAQ,UACvB,EAAQ,IAAM,EAAA,MACd,KAAK,IAAI,EAAI,IAAO,EAAA,MAEtB,MAAM,EAAgB,EAMxB,GAAI,EADF,GAAsB,MAAM,EAAI,GAAG,QAAQ,EAAI,cAAc,CAAC,CAAC,KAAK,EAAQ,EAAS,KAAK,IAAI,CAAC,CAAC,CAAC,MAAM,IAAO,MAE9G,GAAI,CACF,MAAM,EACJ,EAAI,oBACJ,IAAI,QAAQ,IAAI,IAAI,uBAAwB,EAAO,MAAM,EAAG,CAC1D,QAAS,CAAE,OAAQ,mBAAmB,GAAQ,EAC9C,SAAU,QACZ,CAAC,EACD,KAAO,IAAa,CAClB,IAAM,EAAgB,MAAM,EAAS,KAAK,EAC1C,GAAI,EAAS,SAAW,KAAO,EAAgB,UAAU,CAAI,CAAC,CAAC,QAAS,OACxE,IAAM,EAAU,EAA2B,UAAU,CAAI,EAGzD,MAFI,EAAS,SAAW,KAAO,EAAQ,SAAW,EAAQ,KAAK,MAAM,OAAS,kBACtE,EAAgB,EAClB,gBAAgB,CACxB,CACF,CACF,OAAS,EAAO,CAEd,MADI,aAAiB,GAAY,EAAM,SAAW,IAAW,EACvD,gBAAgB,CACxB,CAEF,GAAI,CAAC,GAEC,MADkB,EAAI,GAAG,QAAQ,EAAI,cAAc,CAAC,CAAC,KAAK,EAAQ,EAAS,KAAK,IAAI,CAAC,CAAC,CAAC,MAAM,IACjF,KAAM,MAAM,EAAgB,EAE9C,IAAM,EAAM,MAAM,EAAI,GAAG,QAAQ,EAAI,SAAS,CAAC,CAAC,KAAK,EAAQ,CAAO,CAAC,CAAC,MAGnE,EACH,MAAO,CACL,WACA,UAAW,IAAQ,KAAO,KAAO,CAAE,YAAa,EAAI,aAAc,GAAI,EAAI,EAAG,EAC7E,QAAS,CAAE,SAAQ,SAAQ,CAC7B,CACF,CAGA,SAAgB,eAAe,EAAkC,CAC/D,GAAI,IAAY,KAAM,MAAM,EAAgB,EAC5C,OAAO,CACT"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vielzeug/archive",
3
- "version": "26.10.13",
3
+ "version": "26.10.14",
4
4
  "description": "Community content publishing for Cloudflare Workers + D1: a hardened Worker core over per-collection payload schemas",
5
5
  "repository": {
6
6
  "type": "git",
@@ -61,7 +61,7 @@
61
61
  "test:types": "tsc -p tsconfig.json --noEmit"
62
62
  },
63
63
  "dependencies": {
64
- "@vielzeug/spell": "26.10.13",
64
+ "@vielzeug/spell": "26.10.14",
65
65
  "jose": "6.2.12"
66
66
  },
67
67
  "devDependencies": {