@vielzeug/archive 26.10.14 → 26.10.16

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.
Files changed (68) hide show
  1. package/dist/_crypto.cjs.map +1 -1
  2. package/dist/_crypto.js +5 -1
  3. package/dist/_crypto.js.map +1 -1
  4. package/dist/_json.cjs.map +1 -1
  5. package/dist/_json.js +51 -1
  6. package/dist/_json.js.map +1 -1
  7. package/dist/auth/_confirmation.cjs +5 -1
  8. package/dist/auth/_confirmation.cjs.map +1 -1
  9. package/dist/auth/_confirmation.d.ts.map +1 -1
  10. package/dist/auth/_confirmation.js +38 -14
  11. package/dist/auth/_confirmation.js.map +1 -1
  12. package/dist/auth/config.cjs.map +1 -1
  13. package/dist/auth/config.js +156 -1
  14. package/dist/auth/config.js.map +1 -1
  15. package/dist/auth/errors.cjs.map +1 -1
  16. package/dist/auth/errors.js +18 -1
  17. package/dist/auth/errors.js.map +1 -1
  18. package/dist/auth/store.cjs.map +1 -1
  19. package/dist/auth/store.js +102 -18
  20. package/dist/auth/store.js.map +1 -1
  21. package/dist/auth/worker.cjs.map +1 -1
  22. package/dist/auth/worker.js +360 -1
  23. package/dist/auth/worker.js.map +1 -1
  24. package/dist/auth.js +1 -1
  25. package/dist/collection.cjs.map +1 -1
  26. package/dist/collection.js +108 -1
  27. package/dist/collection.js.map +1 -1
  28. package/dist/contract.cjs.map +1 -1
  29. package/dist/contract.js +144 -1
  30. package/dist/contract.js.map +1 -1
  31. package/dist/errors.cjs.map +1 -1
  32. package/dist/errors.js +14 -1
  33. package/dist/errors.js.map +1 -1
  34. package/dist/index.js +4 -1
  35. package/dist/worker/_pagination.cjs.map +1 -1
  36. package/dist/worker/_pagination.js +55 -1
  37. package/dist/worker/_pagination.js.map +1 -1
  38. package/dist/worker/_service.cjs.map +1 -1
  39. package/dist/worker/_service.js +16 -1
  40. package/dist/worker/_service.js.map +1 -1
  41. package/dist/worker/auth.cjs.map +1 -1
  42. package/dist/worker/auth.js +68 -1
  43. package/dist/worker/auth.js.map +1 -1
  44. package/dist/worker/env.cjs.map +1 -1
  45. package/dist/worker/env.js +76 -1
  46. package/dist/worker/env.js.map +1 -1
  47. package/dist/worker/http.cjs +1 -1
  48. package/dist/worker/http.cjs.map +1 -1
  49. package/dist/worker/http.d.ts +3 -1
  50. package/dist/worker/http.d.ts.map +1 -1
  51. package/dist/worker/http.js +118 -1
  52. package/dist/worker/http.js.map +1 -1
  53. package/dist/worker/publications.cjs +1 -1
  54. package/dist/worker/publications.cjs.map +1 -1
  55. package/dist/worker/publications.d.ts.map +1 -1
  56. package/dist/worker/publications.js +470 -1
  57. package/dist/worker/publications.js.map +1 -1
  58. package/dist/worker/sql.cjs +3 -3
  59. package/dist/worker/sql.cjs.map +1 -1
  60. package/dist/worker/sql.d.ts +4 -2
  61. package/dist/worker/sql.d.ts.map +1 -1
  62. package/dist/worker/sql.js +100 -56
  63. package/dist/worker/sql.js.map +1 -1
  64. package/dist/worker/worker.cjs.map +1 -1
  65. package/dist/worker/worker.js +352 -1
  66. package/dist/worker/worker.js.map +1 -1
  67. package/dist/worker.js +1 -1
  68. package/package.json +6 -6
@@ -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,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"}
1
+ {"version":3,"file":"auth.js","names":["JWT_MAX_AGE_SECONDS","SESSION_MAX_AGE_SECONDS","SUBJECT_MAX_CHARS","ApiError","token","sessionToken","request","payload","jwtVerify","config","error","errors","unauthenticated","subject","issuer","iat","roles","operator","isWellFormedText","allowErasureRetry","env","SQL","serviceRequest","body","response","archiveOkSchema","failure","archiveErrorResponseSchema","row","session"],"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":";;;;;;AAuCA,IAAMA,IAAsBC,GAEtBC,IAAoB,KAEpB,wBAAkC,IAAIC,EAAS,KAAK,oBAAoB,mCAAmC;AAQjH,eAAsB,cACpB,GACA,GACA,GACA,IAAoB,IACK;CACzB,IAAMC,IAAQC,EAAaC,CAAO;CAClC,IAAIF,MAAU,MAAM,OAAO;CAE3B,IAAIG;CACJ,IAAI;EASF,KAAU,MARWC,EAAUJ,GAAOK,EAAO,MAAM;GACjD,YAAY,CAAC,OAAO;GACpB,UAAUA,EAAO;GACjB,gBAAA;GACA,QAAQA,EAAO;GACf,aAAaT;GACb,gBAAgB;IAAC;IAAO;IAAO;IAAO;IAAO;IAAO;GAAgB;EACtE,CAAC,EAAA,CACgB;CACnB,SAASU,GAAO;EAad,MAVEA,aAAiBC,EAAO,eACxBD,aAAiBC,EAAO,eACxBD,aAAiBC,EAAO,2BAElB,gBAAgB,IAIpBD,aAAiBC,EAAO,YAAiBC,EAAgB,IAEvD,gBAAgB;CACxB;CACA,IAAMC,IAAUN,EAAQ,KAClBO,IAASP,EAAQ,KACjBQ,IAAMR,EAAQ,KACdS,IAAQT,EAAQ;CACtB,IAAI,CAAC,OAAO,cAAcA,EAAQ,cAAc,KAAMA,EAAQ,iBAA4B,GAAG,MAAMK,EAAgB;CAGnH,IAAMK,IAAW,MAAM,QAAQD,CAAK,KAAKA,EAAM,SAAA,kBAA8B;CAgB7E,IAZE,OAAOH,KAAY,YACnBA,EAAQ,WAAW,KACnBA,EAAQ,SAASX,KACjB,CAACgB,EAAiBL,CAAO,KAIvB,OAAOC,KAAW,YAElB,OAAOC,KAAQ,YAAYA,IAAM,MAAO,KAAK,IAAI,IAAA,OAInDE,MACC,OAAOF,KAAQ,YACd,OAAOR,EAAQ,OAAQ,YACvBA,EAAQ,MAAMQ,IAAA,QACd,KAAK,IAAI,IAAI,MAAOA,IAAA,OAEtB,MAAMH,EAAgB;CAMxB,IAAI,EADFO,KAAsB,MAAMC,EAAI,GAAG,QAAQC,EAAI,cAAc,CAAC,CAAC,KAAKP,GAAQD,GAAS,KAAK,IAAI,CAAC,CAAC,CAAC,MAAM,MAAO,OAE9G,IAAI;EACF,MAAMS,EACJF,EAAI,qBACJ,IAAI,QAAQ,IAAI,IAAI,wBAAwBX,EAAO,MAAM,GAAG;GAC1D,SAAS,EAAE,QAAQ,mBAAmBL,IAAQ;GAC9C,UAAU;EACZ,CAAC,GACD,OAAO,MAAa;GAClB,IAAMmB,IAAgB,MAAMC,EAAS,KAAK;GAC1C,IAAIA,EAAS,WAAW,OAAOC,EAAgB,UAAUF,CAAI,CAAC,CAAC,SAAS;GACxE,IAAMG,IAAUC,EAA2B,UAAUJ,CAAI;GAGzD,MAFIC,EAAS,WAAW,OAAOE,EAAQ,WAAWA,EAAQ,KAAK,MAAM,SAAS,oBACtEd,EAAgB,IAClB,gBAAgB;EACxB,CACF;CACF,SAASF,GAAO;EAEd,MADIA,aAAiBP,KAAYO,EAAM,WAAW,MAAWA,IACvD,gBAAgB;CACxB;CAEF,IAAI,CAACS,KAEC,MADkBC,EAAI,GAAG,QAAQC,EAAI,cAAc,CAAC,CAAC,KAAKP,GAAQD,GAAS,KAAK,IAAI,CAAC,CAAC,CAAC,MAAM,MACjF,MAAM,MAAMD,EAAgB;CAE9C,IAAMgB,IAAM,MAAMR,EAAI,GAAG,QAAQC,EAAI,SAAS,CAAC,CAAC,KAAKP,GAAQD,CAAO,CAAC,CAAC,MAGnE;CACH,OAAO;EACL;EACA,WAAWe,MAAQ,OAAO,OAAO;GAAE,aAAaA,EAAI;GAAc,IAAIA,EAAI;EAAG;EAC7E,SAAS;GAAE;GAAQ;EAAQ;CAC7B;AACF;AAGA,SAAgB,eAAe,GAAkC;CAC/D,IAAIC,MAAY,MAAM,MAAMjB,EAAgB;CAC5C,OAAOiB;AACT"}
@@ -1 +1 @@
1
- {"version":3,"file":"env.cjs","names":[],"sources":["../../src/worker/env.ts"],"sourcesContent":["import { createRemoteJWKSet, customFetch } from 'jose';\nimport { ApiError } from './http';\n\n/**\n * Worker configuration. Every value the worker trusts comes from the validated Env —\n * never from request-supplied URLs or headers. Anything missing or malformed fails\n * closed (503) rather than falling back to a permissive default.\n */\nexport type AppEnv = 'local' | 'production';\n\nexport interface ApiConfig {\n allowedOrigins: ReadonlySet<string>;\n appEnv: AppEnv;\n audience: string;\n issuer: string;\n jwks: ReturnType<typeof createRemoteJWKSet>;\n likesEnabled: boolean;\n publishingEnabled: boolean;\n rateLimitPepper: string;\n}\n\n/** The rate-limit binding subset the core calls; the runtime's RateLimit satisfies it. */\nexport interface RateLimiterBinding {\n limit(input: { key: string }): Promise<{ success: boolean }>;\n}\n\n/** The D1 statement subset the core binds; null params model nullable columns. */\nexport interface D1Statement {\n all<T>(): Promise<{ results?: T[] }>;\n bind(...params: (string | number | null)[]): D1Statement;\n first<T>(): Promise<T | null>;\n run(): Promise<{ meta?: { changes?: number } }>;\n}\n\n/** The D1 database subset the core uses; a deployment's generated D1Database satisfies it. */\nexport interface D1Like {\n batch(statements: D1Statement[]): Promise<{ meta?: { changes?: number } }[]>;\n prepare(sql: string): D1Statement;\n}\n\n/**\n * The bindings the archive Worker reads. The package declares its own minimal\n * structural types instead of `@cloudflare/workers-types`: a deployment's generated\n * Env (with real D1Database/RateLimit bindings) satisfies this shape structurally.\n */\nexport interface ArchiveWorkerEnv {\n ALLOWED_ORIGINS: string;\n APP_ENV: string;\n /** Required issuer session-validation service; every private request checks live authority. */\n AUTH_ISSUER_SERVICE: AuthIssuerServiceBinding;\n DB: D1Like;\n JWT_AUDIENCE: string;\n JWT_ISSUER: string;\n JWT_JWKS_URL: string;\n LIKES_ENABLED: string;\n PUBLISHING_ENABLED: string;\n RATE_LIMIT_KEY_SECRET: string;\n READ_LIMIT: RateLimiterBinding;\n WRITE_LIMIT: RateLimiterBinding;\n}\n\nexport interface AuthIssuerServiceBinding {\n fetch(request: Request): Promise<Response>;\n}\n\nconst LOOPBACK_HOSTS = new Set(['127.0.0.1', 'localhost', '[::1]']);\n\nconst isLoopback = (url: URL): boolean => LOOPBACK_HOSTS.has(url.hostname);\n\n// Configuration faults never name the offending field — the response must not\n// leak Env names or framework details, only a generic temporary unavailability.\nconst configError = (field: string): ApiError =>\n new ApiError(503, 'unavailable', 'The service is temporarily unavailable.', {\n diagnostic: { field, kind: 'configuration' },\n });\n\nconst requireVar = (env: ArchiveWorkerEnv, name: string): string => {\n const value = env[name as keyof ArchiveWorkerEnv];\n if (typeof value !== 'string' || value.length === 0) throw configError(name);\n return value;\n};\n\nconst requireUrl = (raw: string, name: string): URL => {\n try {\n return new URL(raw);\n } catch {\n throw configError(name);\n }\n};\n\n/** Validates one trusted endpoint URL against the environment's transport rules. */\nconst validateUrl = (url: URL, appEnv: AppEnv, name: string): URL => {\n // No embedded credentials or fragments can ever be part of a trusted endpoint.\n if (url.username !== '' || url.password !== '' || url.hash !== '') throw configError(name);\n if (appEnv === 'local') {\n // Loopback HTTP is permitted only in local development.\n if (url.protocol !== 'http:' || !isLoopback(url)) throw configError(name);\n return url;\n }\n if (url.protocol !== 'https:' || isLoopback(url)) throw configError(name);\n return url;\n};\n\n/**\n * An allowed origin is an exact canonical origin: scheme + host (+port) and\n * nothing else — no wildcard, `null` origin, credentials, path, query or fragment.\n * Local permits loopback HTTP; production requires non-loopback HTTPS.\n */\nconst validateOrigin = (raw: string, appEnv: AppEnv, name: string): string => {\n if (raw === 'null' || raw === '*') throw configError(name);\n const url = validateUrl(requireUrl(raw, name), appEnv, name);\n if (url.pathname !== '/' || url.search !== '') throw configError(name);\n const canonical = url.origin;\n if (canonical !== raw) throw configError(name);\n return canonical;\n};\n\nconst flag = (raw: string, name: string): boolean => {\n if (raw === 'true') return true;\n if (raw === 'false') return false;\n throw configError(name);\n};\n\n/** Local-only fallback pepper for rate-limit keys; never a production secret. */\nconst DEV_RATE_LIMIT_PEPPER = 'dev-only-insecure-pepper';\n\n/**\n * The deployment secret behind rate-limit key HMACs: a deployment-specific\n * pepper makes the hashed keys useless outside this deployment. Production\n * fails closed on a missing, empty or too-short value; local development only\n * may fall back to the constant insecure pepper — local only, never production.\n */\nconst rateLimitPepperFor = (env: ArchiveWorkerEnv, appEnv: AppEnv): string => {\n const raw = env.RATE_LIMIT_KEY_SECRET;\n if (typeof raw !== 'string' || raw.length === 0) {\n if (appEnv === 'local') return DEV_RATE_LIMIT_PEPPER;\n throw configError('RATE_LIMIT_KEY_SECRET');\n }\n if (appEnv === 'production' && raw.length < 32) throw configError('RATE_LIMIT_KEY_SECRET');\n return raw;\n};\n\nconst configs = new WeakMap<ArchiveWorkerEnv, ApiConfig>();\n\n/** Parses and validates the env once per bindings object; throws ApiError(503). */\nexport function apiConfig(env: ArchiveWorkerEnv): ApiConfig {\n const cached = configs.get(env);\n if (cached) return cached;\n\n const appEnvRaw = requireVar(env, 'APP_ENV');\n if (appEnvRaw !== 'local' && appEnvRaw !== 'production') throw configError('APP_ENV');\n const appEnv = appEnvRaw;\n\n // The issuer URL is validated for transport/safety but kept as the exact\n // configured string for JWT claim matching.\n validateUrl(requireUrl(requireVar(env, 'JWT_ISSUER'), 'JWT_ISSUER'), appEnv, 'JWT_ISSUER');\n const jwksUrl = validateUrl(requireUrl(requireVar(env, 'JWT_JWKS_URL'), 'JWT_JWKS_URL'), appEnv, 'JWT_JWKS_URL');\n const audience = requireVar(env, 'JWT_AUDIENCE');\n\n const origins = new Set(\n requireVar(env, 'ALLOWED_ORIGINS')\n .split(',')\n .map((o) => o.trim())\n .filter((o) => o.length > 0)\n .map((o) => validateOrigin(o, appEnv, 'ALLOWED_ORIGINS')),\n );\n if (origins.size === 0) throw configError('ALLOWED_ORIGINS');\n\n if (typeof env.DB?.prepare !== 'function' || typeof env.DB?.batch !== 'function') throw configError('DB');\n if (typeof env.READ_LIMIT?.limit !== 'function') throw configError('READ_LIMIT');\n if (typeof env.WRITE_LIMIT?.limit !== 'function') throw configError('WRITE_LIMIT');\n if (typeof env.AUTH_ISSUER_SERVICE?.fetch !== 'function') throw configError('AUTH_ISSUER_SERVICE');\n\n const config: ApiConfig = {\n allowedOrigins: origins,\n appEnv,\n audience,\n // The exact configured issuer string is the match target — a trailing slash\n // is meaningful, never normalized away.\n issuer: requireVar(env, 'JWT_ISSUER'),\n jwks: createRemoteJWKSet(jwksUrl, {\n [customFetch]: (input, init) => env.AUTH_ISSUER_SERVICE.fetch(new Request(input, init)),\n }),\n likesEnabled: flag(requireVar(env, 'LIKES_ENABLED'), 'LIKES_ENABLED'),\n publishingEnabled: flag(requireVar(env, 'PUBLISHING_ENABLED'), 'PUBLISHING_ENABLED'),\n rateLimitPepper: rateLimitPepperFor(env, appEnv),\n };\n configs.set(env, config);\n return config;\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"env.cjs","names":["ApiError","createRemoteJWKSet","customFetch","require_http","jose","LOOPBACK_HOSTS","url","value","env","name","raw","appEnv","canonical","DEV_RATE_LIMIT_PEPPER","configs","cached","appEnvRaw","jwksUrl","audience","origins","o","config","input","init"],"sources":["../../src/worker/env.ts"],"sourcesContent":["import { createRemoteJWKSet, customFetch } from 'jose';\nimport { ApiError } from './http';\n\n/**\n * Worker configuration. Every value the worker trusts comes from the validated Env —\n * never from request-supplied URLs or headers. Anything missing or malformed fails\n * closed (503) rather than falling back to a permissive default.\n */\nexport type AppEnv = 'local' | 'production';\n\nexport interface ApiConfig {\n allowedOrigins: ReadonlySet<string>;\n appEnv: AppEnv;\n audience: string;\n issuer: string;\n jwks: ReturnType<typeof createRemoteJWKSet>;\n likesEnabled: boolean;\n publishingEnabled: boolean;\n rateLimitPepper: string;\n}\n\n/** The rate-limit binding subset the core calls; the runtime's RateLimit satisfies it. */\nexport interface RateLimiterBinding {\n limit(input: { key: string }): Promise<{ success: boolean }>;\n}\n\n/** The D1 statement subset the core binds; null params model nullable columns. */\nexport interface D1Statement {\n all<T>(): Promise<{ results?: T[] }>;\n bind(...params: (string | number | null)[]): D1Statement;\n first<T>(): Promise<T | null>;\n run(): Promise<{ meta?: { changes?: number } }>;\n}\n\n/** The D1 database subset the core uses; a deployment's generated D1Database satisfies it. */\nexport interface D1Like {\n batch(statements: D1Statement[]): Promise<{ meta?: { changes?: number } }[]>;\n prepare(sql: string): D1Statement;\n}\n\n/**\n * The bindings the archive Worker reads. The package declares its own minimal\n * structural types instead of `@cloudflare/workers-types`: a deployment's generated\n * Env (with real D1Database/RateLimit bindings) satisfies this shape structurally.\n */\nexport interface ArchiveWorkerEnv {\n ALLOWED_ORIGINS: string;\n APP_ENV: string;\n /** Required issuer session-validation service; every private request checks live authority. */\n AUTH_ISSUER_SERVICE: AuthIssuerServiceBinding;\n DB: D1Like;\n JWT_AUDIENCE: string;\n JWT_ISSUER: string;\n JWT_JWKS_URL: string;\n LIKES_ENABLED: string;\n PUBLISHING_ENABLED: string;\n RATE_LIMIT_KEY_SECRET: string;\n READ_LIMIT: RateLimiterBinding;\n WRITE_LIMIT: RateLimiterBinding;\n}\n\nexport interface AuthIssuerServiceBinding {\n fetch(request: Request): Promise<Response>;\n}\n\nconst LOOPBACK_HOSTS = new Set(['127.0.0.1', 'localhost', '[::1]']);\n\nconst isLoopback = (url: URL): boolean => LOOPBACK_HOSTS.has(url.hostname);\n\n// Configuration faults never name the offending field — the response must not\n// leak Env names or framework details, only a generic temporary unavailability.\nconst configError = (field: string): ApiError =>\n new ApiError(503, 'unavailable', 'The service is temporarily unavailable.', {\n diagnostic: { field, kind: 'configuration' },\n });\n\nconst requireVar = (env: ArchiveWorkerEnv, name: string): string => {\n const value = env[name as keyof ArchiveWorkerEnv];\n if (typeof value !== 'string' || value.length === 0) throw configError(name);\n return value;\n};\n\nconst requireUrl = (raw: string, name: string): URL => {\n try {\n return new URL(raw);\n } catch {\n throw configError(name);\n }\n};\n\n/** Validates one trusted endpoint URL against the environment's transport rules. */\nconst validateUrl = (url: URL, appEnv: AppEnv, name: string): URL => {\n // No embedded credentials or fragments can ever be part of a trusted endpoint.\n if (url.username !== '' || url.password !== '' || url.hash !== '') throw configError(name);\n if (appEnv === 'local') {\n // Loopback HTTP is permitted only in local development.\n if (url.protocol !== 'http:' || !isLoopback(url)) throw configError(name);\n return url;\n }\n if (url.protocol !== 'https:' || isLoopback(url)) throw configError(name);\n return url;\n};\n\n/**\n * An allowed origin is an exact canonical origin: scheme + host (+port) and\n * nothing else — no wildcard, `null` origin, credentials, path, query or fragment.\n * Local permits loopback HTTP; production requires non-loopback HTTPS.\n */\nconst validateOrigin = (raw: string, appEnv: AppEnv, name: string): string => {\n if (raw === 'null' || raw === '*') throw configError(name);\n const url = validateUrl(requireUrl(raw, name), appEnv, name);\n if (url.pathname !== '/' || url.search !== '') throw configError(name);\n const canonical = url.origin;\n if (canonical !== raw) throw configError(name);\n return canonical;\n};\n\nconst flag = (raw: string, name: string): boolean => {\n if (raw === 'true') return true;\n if (raw === 'false') return false;\n throw configError(name);\n};\n\n/** Local-only fallback pepper for rate-limit keys; never a production secret. */\nconst DEV_RATE_LIMIT_PEPPER = 'dev-only-insecure-pepper';\n\n/**\n * The deployment secret behind rate-limit key HMACs: a deployment-specific\n * pepper makes the hashed keys useless outside this deployment. Production\n * fails closed on a missing, empty or too-short value; local development only\n * may fall back to the constant insecure pepper — local only, never production.\n */\nconst rateLimitPepperFor = (env: ArchiveWorkerEnv, appEnv: AppEnv): string => {\n const raw = env.RATE_LIMIT_KEY_SECRET;\n if (typeof raw !== 'string' || raw.length === 0) {\n if (appEnv === 'local') return DEV_RATE_LIMIT_PEPPER;\n throw configError('RATE_LIMIT_KEY_SECRET');\n }\n if (appEnv === 'production' && raw.length < 32) throw configError('RATE_LIMIT_KEY_SECRET');\n return raw;\n};\n\nconst configs = new WeakMap<ArchiveWorkerEnv, ApiConfig>();\n\n/** Parses and validates the env once per bindings object; throws ApiError(503). */\nexport function apiConfig(env: ArchiveWorkerEnv): ApiConfig {\n const cached = configs.get(env);\n if (cached) return cached;\n\n const appEnvRaw = requireVar(env, 'APP_ENV');\n if (appEnvRaw !== 'local' && appEnvRaw !== 'production') throw configError('APP_ENV');\n const appEnv = appEnvRaw;\n\n // The issuer URL is validated for transport/safety but kept as the exact\n // configured string for JWT claim matching.\n validateUrl(requireUrl(requireVar(env, 'JWT_ISSUER'), 'JWT_ISSUER'), appEnv, 'JWT_ISSUER');\n const jwksUrl = validateUrl(requireUrl(requireVar(env, 'JWT_JWKS_URL'), 'JWT_JWKS_URL'), appEnv, 'JWT_JWKS_URL');\n const audience = requireVar(env, 'JWT_AUDIENCE');\n\n const origins = new Set(\n requireVar(env, 'ALLOWED_ORIGINS')\n .split(',')\n .map((o) => o.trim())\n .filter((o) => o.length > 0)\n .map((o) => validateOrigin(o, appEnv, 'ALLOWED_ORIGINS')),\n );\n if (origins.size === 0) throw configError('ALLOWED_ORIGINS');\n\n if (typeof env.DB?.prepare !== 'function' || typeof env.DB?.batch !== 'function') throw configError('DB');\n if (typeof env.READ_LIMIT?.limit !== 'function') throw configError('READ_LIMIT');\n if (typeof env.WRITE_LIMIT?.limit !== 'function') throw configError('WRITE_LIMIT');\n if (typeof env.AUTH_ISSUER_SERVICE?.fetch !== 'function') throw configError('AUTH_ISSUER_SERVICE');\n\n const config: ApiConfig = {\n allowedOrigins: origins,\n appEnv,\n audience,\n // The exact configured issuer string is the match target — a trailing slash\n // is meaningful, never normalized away.\n issuer: requireVar(env, 'JWT_ISSUER'),\n jwks: createRemoteJWKSet(jwksUrl, {\n [customFetch]: (input, init) => env.AUTH_ISSUER_SERVICE.fetch(new Request(input, init)),\n }),\n likesEnabled: flag(requireVar(env, 'LIKES_ENABLED'), 'LIKES_ENABLED'),\n publishingEnabled: flag(requireVar(env, 'PUBLISHING_ENABLED'), 'PUBLISHING_ENABLED'),\n rateLimitPepper: rateLimitPepperFor(env, appEnv),\n };\n configs.set(env, config);\n return config;\n}\n"],"mappings":"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"}
@@ -1,2 +1,77 @@
1
- import{ApiError as e}from"./http.js";import{createRemoteJWKSet as t,customFetch as n}from"jose";var r=new Set([`127.0.0.1`,`localhost`,`[::1]`]),isLoopback=e=>r.has(e.hostname),configError=t=>new e(503,`unavailable`,`The service is temporarily unavailable.`,{diagnostic:{field:t,kind:`configuration`}}),requireVar=(e,t)=>{let n=e[t];if(typeof n!=`string`||n.length===0)throw configError(t);return n},requireUrl=(e,t)=>{try{return new URL(e)}catch{throw configError(t)}},validateUrl=(e,t,n)=>{if(e.username!==``||e.password!==``||e.hash!==``)throw configError(n);if(t===`local`){if(e.protocol!==`http:`||!isLoopback(e))throw configError(n);return e}if(e.protocol!==`https:`||isLoopback(e))throw configError(n);return e},validateOrigin=(e,t,n)=>{if(e===`null`||e===`*`)throw configError(n);let r=validateUrl(requireUrl(e,n),t,n);if(r.pathname!==`/`||r.search!==``)throw configError(n);let i=r.origin;if(i!==e)throw configError(n);return i},flag=(e,t)=>{if(e===`true`)return!0;if(e===`false`)return!1;throw configError(t)},i=`dev-only-insecure-pepper`,rateLimitPepperFor=(e,t)=>{let n=e.RATE_LIMIT_KEY_SECRET;if(typeof n!=`string`||n.length===0){if(t===`local`)return i;throw configError(`RATE_LIMIT_KEY_SECRET`)}if(t===`production`&&n.length<32)throw configError(`RATE_LIMIT_KEY_SECRET`);return n},a=new WeakMap;function apiConfig(e){let r=a.get(e);if(r)return r;let i=requireVar(e,`APP_ENV`);if(i!==`local`&&i!==`production`)throw configError(`APP_ENV`);let o=i;validateUrl(requireUrl(requireVar(e,`JWT_ISSUER`),`JWT_ISSUER`),o,`JWT_ISSUER`);let s=validateUrl(requireUrl(requireVar(e,`JWT_JWKS_URL`),`JWT_JWKS_URL`),o,`JWT_JWKS_URL`),c=requireVar(e,`JWT_AUDIENCE`),l=new Set(requireVar(e,`ALLOWED_ORIGINS`).split(`,`).map(e=>e.trim()).filter(e=>e.length>0).map(e=>validateOrigin(e,o,`ALLOWED_ORIGINS`)));if(l.size===0)throw configError(`ALLOWED_ORIGINS`);if(typeof e.DB?.prepare!=`function`||typeof e.DB?.batch!=`function`)throw configError(`DB`);if(typeof e.READ_LIMIT?.limit!=`function`)throw configError(`READ_LIMIT`);if(typeof e.WRITE_LIMIT?.limit!=`function`)throw configError(`WRITE_LIMIT`);if(typeof e.AUTH_ISSUER_SERVICE?.fetch!=`function`)throw configError(`AUTH_ISSUER_SERVICE`);let u={allowedOrigins:l,appEnv:o,audience:c,issuer:requireVar(e,`JWT_ISSUER`),jwks:t(s,{[n]:(t,n)=>e.AUTH_ISSUER_SERVICE.fetch(new Request(t,n))}),likesEnabled:flag(requireVar(e,`LIKES_ENABLED`),`LIKES_ENABLED`),publishingEnabled:flag(requireVar(e,`PUBLISHING_ENABLED`),`PUBLISHING_ENABLED`),rateLimitPepper:rateLimitPepperFor(e,o)};return a.set(e,u),u}export{apiConfig};
1
+ import { ApiError as e } from "./http.js";
2
+ import { createRemoteJWKSet as t, customFetch as n } from "jose";
3
+ //#region src/worker/env.ts
4
+ var r = /* @__PURE__ */ new Set([
5
+ "127.0.0.1",
6
+ "localhost",
7
+ "[::1]"
8
+ ]), isLoopback = (e) => r.has(e.hostname), configError = (t) => new e(503, "unavailable", "The service is temporarily unavailable.", { diagnostic: {
9
+ field: t,
10
+ kind: "configuration"
11
+ } }), requireVar = (e, t) => {
12
+ let n = e[t];
13
+ if (typeof n != "string" || n.length === 0) throw configError(t);
14
+ return n;
15
+ }, requireUrl = (e, t) => {
16
+ try {
17
+ return new URL(e);
18
+ } catch {
19
+ throw configError(t);
20
+ }
21
+ }, validateUrl = (e, t, n) => {
22
+ if (e.username !== "" || e.password !== "" || e.hash !== "") throw configError(n);
23
+ if (t === "local") {
24
+ if (e.protocol !== "http:" || !isLoopback(e)) throw configError(n);
25
+ return e;
26
+ }
27
+ if (e.protocol !== "https:" || isLoopback(e)) throw configError(n);
28
+ return e;
29
+ }, validateOrigin = (e, t, n) => {
30
+ if (e === "null" || e === "*") throw configError(n);
31
+ let r = validateUrl(requireUrl(e, n), t, n);
32
+ if (r.pathname !== "/" || r.search !== "") throw configError(n);
33
+ let i = r.origin;
34
+ if (i !== e) throw configError(n);
35
+ return i;
36
+ }, flag = (e, t) => {
37
+ if (e === "true") return !0;
38
+ if (e === "false") return !1;
39
+ throw configError(t);
40
+ }, i = "dev-only-insecure-pepper", rateLimitPepperFor = (e, t) => {
41
+ let n = e.RATE_LIMIT_KEY_SECRET;
42
+ if (typeof n != "string" || n.length === 0) {
43
+ if (t === "local") return i;
44
+ throw configError("RATE_LIMIT_KEY_SECRET");
45
+ }
46
+ if (t === "production" && n.length < 32) throw configError("RATE_LIMIT_KEY_SECRET");
47
+ return n;
48
+ }, a = /* @__PURE__ */ new WeakMap();
49
+ function apiConfig(e) {
50
+ let r = a.get(e);
51
+ if (r) return r;
52
+ let i = requireVar(e, "APP_ENV");
53
+ if (i !== "local" && i !== "production") throw configError("APP_ENV");
54
+ let o = i;
55
+ validateUrl(requireUrl(requireVar(e, "JWT_ISSUER"), "JWT_ISSUER"), o, "JWT_ISSUER");
56
+ let s = validateUrl(requireUrl(requireVar(e, "JWT_JWKS_URL"), "JWT_JWKS_URL"), o, "JWT_JWKS_URL"), c = requireVar(e, "JWT_AUDIENCE"), l = new Set(requireVar(e, "ALLOWED_ORIGINS").split(",").map((e) => e.trim()).filter((e) => e.length > 0).map((e) => validateOrigin(e, o, "ALLOWED_ORIGINS")));
57
+ if (l.size === 0) throw configError("ALLOWED_ORIGINS");
58
+ if (typeof e.DB?.prepare != "function" || typeof e.DB?.batch != "function") throw configError("DB");
59
+ if (typeof e.READ_LIMIT?.limit != "function") throw configError("READ_LIMIT");
60
+ if (typeof e.WRITE_LIMIT?.limit != "function") throw configError("WRITE_LIMIT");
61
+ if (typeof e.AUTH_ISSUER_SERVICE?.fetch != "function") throw configError("AUTH_ISSUER_SERVICE");
62
+ let u = {
63
+ allowedOrigins: l,
64
+ appEnv: o,
65
+ audience: c,
66
+ issuer: requireVar(e, "JWT_ISSUER"),
67
+ jwks: t(s, { [n]: (t, n) => e.AUTH_ISSUER_SERVICE.fetch(new Request(t, n)) }),
68
+ likesEnabled: flag(requireVar(e, "LIKES_ENABLED"), "LIKES_ENABLED"),
69
+ publishingEnabled: flag(requireVar(e, "PUBLISHING_ENABLED"), "PUBLISHING_ENABLED"),
70
+ rateLimitPepper: rateLimitPepperFor(e, o)
71
+ };
72
+ return a.set(e, u), u;
73
+ }
74
+ //#endregion
75
+ export { apiConfig };
76
+
2
77
  //# sourceMappingURL=env.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"env.js","names":[],"sources":["../../src/worker/env.ts"],"sourcesContent":["import { createRemoteJWKSet, customFetch } from 'jose';\nimport { ApiError } from './http';\n\n/**\n * Worker configuration. Every value the worker trusts comes from the validated Env —\n * never from request-supplied URLs or headers. Anything missing or malformed fails\n * closed (503) rather than falling back to a permissive default.\n */\nexport type AppEnv = 'local' | 'production';\n\nexport interface ApiConfig {\n allowedOrigins: ReadonlySet<string>;\n appEnv: AppEnv;\n audience: string;\n issuer: string;\n jwks: ReturnType<typeof createRemoteJWKSet>;\n likesEnabled: boolean;\n publishingEnabled: boolean;\n rateLimitPepper: string;\n}\n\n/** The rate-limit binding subset the core calls; the runtime's RateLimit satisfies it. */\nexport interface RateLimiterBinding {\n limit(input: { key: string }): Promise<{ success: boolean }>;\n}\n\n/** The D1 statement subset the core binds; null params model nullable columns. */\nexport interface D1Statement {\n all<T>(): Promise<{ results?: T[] }>;\n bind(...params: (string | number | null)[]): D1Statement;\n first<T>(): Promise<T | null>;\n run(): Promise<{ meta?: { changes?: number } }>;\n}\n\n/** The D1 database subset the core uses; a deployment's generated D1Database satisfies it. */\nexport interface D1Like {\n batch(statements: D1Statement[]): Promise<{ meta?: { changes?: number } }[]>;\n prepare(sql: string): D1Statement;\n}\n\n/**\n * The bindings the archive Worker reads. The package declares its own minimal\n * structural types instead of `@cloudflare/workers-types`: a deployment's generated\n * Env (with real D1Database/RateLimit bindings) satisfies this shape structurally.\n */\nexport interface ArchiveWorkerEnv {\n ALLOWED_ORIGINS: string;\n APP_ENV: string;\n /** Required issuer session-validation service; every private request checks live authority. */\n AUTH_ISSUER_SERVICE: AuthIssuerServiceBinding;\n DB: D1Like;\n JWT_AUDIENCE: string;\n JWT_ISSUER: string;\n JWT_JWKS_URL: string;\n LIKES_ENABLED: string;\n PUBLISHING_ENABLED: string;\n RATE_LIMIT_KEY_SECRET: string;\n READ_LIMIT: RateLimiterBinding;\n WRITE_LIMIT: RateLimiterBinding;\n}\n\nexport interface AuthIssuerServiceBinding {\n fetch(request: Request): Promise<Response>;\n}\n\nconst LOOPBACK_HOSTS = new Set(['127.0.0.1', 'localhost', '[::1]']);\n\nconst isLoopback = (url: URL): boolean => LOOPBACK_HOSTS.has(url.hostname);\n\n// Configuration faults never name the offending field — the response must not\n// leak Env names or framework details, only a generic temporary unavailability.\nconst configError = (field: string): ApiError =>\n new ApiError(503, 'unavailable', 'The service is temporarily unavailable.', {\n diagnostic: { field, kind: 'configuration' },\n });\n\nconst requireVar = (env: ArchiveWorkerEnv, name: string): string => {\n const value = env[name as keyof ArchiveWorkerEnv];\n if (typeof value !== 'string' || value.length === 0) throw configError(name);\n return value;\n};\n\nconst requireUrl = (raw: string, name: string): URL => {\n try {\n return new URL(raw);\n } catch {\n throw configError(name);\n }\n};\n\n/** Validates one trusted endpoint URL against the environment's transport rules. */\nconst validateUrl = (url: URL, appEnv: AppEnv, name: string): URL => {\n // No embedded credentials or fragments can ever be part of a trusted endpoint.\n if (url.username !== '' || url.password !== '' || url.hash !== '') throw configError(name);\n if (appEnv === 'local') {\n // Loopback HTTP is permitted only in local development.\n if (url.protocol !== 'http:' || !isLoopback(url)) throw configError(name);\n return url;\n }\n if (url.protocol !== 'https:' || isLoopback(url)) throw configError(name);\n return url;\n};\n\n/**\n * An allowed origin is an exact canonical origin: scheme + host (+port) and\n * nothing else — no wildcard, `null` origin, credentials, path, query or fragment.\n * Local permits loopback HTTP; production requires non-loopback HTTPS.\n */\nconst validateOrigin = (raw: string, appEnv: AppEnv, name: string): string => {\n if (raw === 'null' || raw === '*') throw configError(name);\n const url = validateUrl(requireUrl(raw, name), appEnv, name);\n if (url.pathname !== '/' || url.search !== '') throw configError(name);\n const canonical = url.origin;\n if (canonical !== raw) throw configError(name);\n return canonical;\n};\n\nconst flag = (raw: string, name: string): boolean => {\n if (raw === 'true') return true;\n if (raw === 'false') return false;\n throw configError(name);\n};\n\n/** Local-only fallback pepper for rate-limit keys; never a production secret. */\nconst DEV_RATE_LIMIT_PEPPER = 'dev-only-insecure-pepper';\n\n/**\n * The deployment secret behind rate-limit key HMACs: a deployment-specific\n * pepper makes the hashed keys useless outside this deployment. Production\n * fails closed on a missing, empty or too-short value; local development only\n * may fall back to the constant insecure pepper — local only, never production.\n */\nconst rateLimitPepperFor = (env: ArchiveWorkerEnv, appEnv: AppEnv): string => {\n const raw = env.RATE_LIMIT_KEY_SECRET;\n if (typeof raw !== 'string' || raw.length === 0) {\n if (appEnv === 'local') return DEV_RATE_LIMIT_PEPPER;\n throw configError('RATE_LIMIT_KEY_SECRET');\n }\n if (appEnv === 'production' && raw.length < 32) throw configError('RATE_LIMIT_KEY_SECRET');\n return raw;\n};\n\nconst configs = new WeakMap<ArchiveWorkerEnv, ApiConfig>();\n\n/** Parses and validates the env once per bindings object; throws ApiError(503). */\nexport function apiConfig(env: ArchiveWorkerEnv): ApiConfig {\n const cached = configs.get(env);\n if (cached) return cached;\n\n const appEnvRaw = requireVar(env, 'APP_ENV');\n if (appEnvRaw !== 'local' && appEnvRaw !== 'production') throw configError('APP_ENV');\n const appEnv = appEnvRaw;\n\n // The issuer URL is validated for transport/safety but kept as the exact\n // configured string for JWT claim matching.\n validateUrl(requireUrl(requireVar(env, 'JWT_ISSUER'), 'JWT_ISSUER'), appEnv, 'JWT_ISSUER');\n const jwksUrl = validateUrl(requireUrl(requireVar(env, 'JWT_JWKS_URL'), 'JWT_JWKS_URL'), appEnv, 'JWT_JWKS_URL');\n const audience = requireVar(env, 'JWT_AUDIENCE');\n\n const origins = new Set(\n requireVar(env, 'ALLOWED_ORIGINS')\n .split(',')\n .map((o) => o.trim())\n .filter((o) => o.length > 0)\n .map((o) => validateOrigin(o, appEnv, 'ALLOWED_ORIGINS')),\n );\n if (origins.size === 0) throw configError('ALLOWED_ORIGINS');\n\n if (typeof env.DB?.prepare !== 'function' || typeof env.DB?.batch !== 'function') throw configError('DB');\n if (typeof env.READ_LIMIT?.limit !== 'function') throw configError('READ_LIMIT');\n if (typeof env.WRITE_LIMIT?.limit !== 'function') throw configError('WRITE_LIMIT');\n if (typeof env.AUTH_ISSUER_SERVICE?.fetch !== 'function') throw configError('AUTH_ISSUER_SERVICE');\n\n const config: ApiConfig = {\n allowedOrigins: origins,\n appEnv,\n audience,\n // The exact configured issuer string is the match target — a trailing slash\n // is meaningful, never normalized away.\n issuer: requireVar(env, 'JWT_ISSUER'),\n jwks: createRemoteJWKSet(jwksUrl, {\n [customFetch]: (input, init) => env.AUTH_ISSUER_SERVICE.fetch(new Request(input, init)),\n }),\n likesEnabled: flag(requireVar(env, 'LIKES_ENABLED'), 'LIKES_ENABLED'),\n publishingEnabled: flag(requireVar(env, 'PUBLISHING_ENABLED'), 'PUBLISHING_ENABLED'),\n rateLimitPepper: rateLimitPepperFor(env, appEnv),\n };\n configs.set(env, config);\n return config;\n}\n"],"mappings":"gGAiEA,IAAM,EAAiB,IAAI,IAAI,CAAC,YAAa,YAAa,OAAO,CAAC,EAE5D,WAAc,GAAsB,EAAe,IAAI,EAAI,QAAQ,EAInE,YAAe,GACnB,IAAI,EAAS,IAAK,cAAe,0CAA2C,CAC1E,WAAY,CAAE,QAAO,KAAM,eAAgB,CAC7C,CAAC,EAEG,YAAc,EAAuB,IAAyB,CAClE,IAAM,EAAQ,EAAI,GAClB,GAAI,OAAO,GAAU,UAAY,EAAM,SAAW,EAAG,MAAM,YAAY,CAAI,EAC3E,OAAO,CACT,EAEM,YAAc,EAAa,IAAsB,CACrD,GAAI,CACF,OAAO,IAAI,IAAI,CAAG,CACpB,MAAQ,CACN,MAAM,YAAY,CAAI,CACxB,CACF,EAGM,aAAe,EAAU,EAAgB,IAAsB,CAEnE,GAAI,EAAI,WAAa,IAAM,EAAI,WAAa,IAAM,EAAI,OAAS,GAAI,MAAM,YAAY,CAAI,EACzF,GAAI,IAAW,QAAS,CAEtB,GAAI,EAAI,WAAa,SAAW,CAAC,WAAW,CAAG,EAAG,MAAM,YAAY,CAAI,EACxE,OAAO,CACT,CACA,GAAI,EAAI,WAAa,UAAY,WAAW,CAAG,EAAG,MAAM,YAAY,CAAI,EACxE,OAAO,CACT,EAOM,gBAAkB,EAAa,EAAgB,IAAyB,CAC5E,GAAI,IAAQ,QAAU,IAAQ,IAAK,MAAM,YAAY,CAAI,EACzD,IAAM,EAAM,YAAY,WAAW,EAAK,CAAI,EAAG,EAAQ,CAAI,EAC3D,GAAI,EAAI,WAAa,KAAO,EAAI,SAAW,GAAI,MAAM,YAAY,CAAI,EACrE,IAAM,EAAY,EAAI,OACtB,GAAI,IAAc,EAAK,MAAM,YAAY,CAAI,EAC7C,OAAO,CACT,EAEM,MAAQ,EAAa,IAA0B,CACnD,GAAI,IAAQ,OAAQ,MAAO,GAC3B,GAAI,IAAQ,QAAS,MAAO,GAC5B,MAAM,YAAY,CAAI,CACxB,EAGM,EAAwB,2BAQxB,oBAAsB,EAAuB,IAA2B,CAC5E,IAAM,EAAM,EAAI,sBAChB,GAAI,OAAO,GAAQ,UAAY,EAAI,SAAW,EAAG,CAC/C,GAAI,IAAW,QAAS,OAAO,EAC/B,MAAM,YAAY,uBAAuB,CAC3C,CACA,GAAI,IAAW,cAAgB,EAAI,OAAS,GAAI,MAAM,YAAY,uBAAuB,EACzF,OAAO,CACT,EAEM,EAAU,IAAI,QAGpB,SAAgB,UAAU,EAAkC,CAC1D,IAAM,EAAS,EAAQ,IAAI,CAAG,EAC9B,GAAI,EAAQ,OAAO,EAEnB,IAAM,EAAY,WAAW,EAAK,SAAS,EAC3C,GAAI,IAAc,SAAW,IAAc,aAAc,MAAM,YAAY,SAAS,EACpF,IAAM,EAAS,EAIf,YAAY,WAAW,WAAW,EAAK,YAAY,EAAG,YAAY,EAAG,EAAQ,YAAY,EACzF,IAAM,EAAU,YAAY,WAAW,WAAW,EAAK,cAAc,EAAG,cAAc,EAAG,EAAQ,cAAc,EACzG,EAAW,WAAW,EAAK,cAAc,EAEzC,EAAU,IAAI,IAClB,WAAW,EAAK,iBAAiB,CAAC,CAC/B,MAAM,GAAG,CAAC,CACV,IAAK,GAAM,EAAE,KAAK,CAAC,CAAC,CACpB,OAAQ,GAAM,EAAE,OAAS,CAAC,CAAC,CAC3B,IAAK,GAAM,eAAe,EAAG,EAAQ,iBAAiB,CAAC,CAC5D,EACA,GAAI,EAAQ,OAAS,EAAG,MAAM,YAAY,iBAAiB,EAE3D,GAAI,OAAO,EAAI,IAAI,SAAY,YAAc,OAAO,EAAI,IAAI,OAAU,WAAY,MAAM,YAAY,IAAI,EACxG,GAAI,OAAO,EAAI,YAAY,OAAU,WAAY,MAAM,YAAY,YAAY,EAC/E,GAAI,OAAO,EAAI,aAAa,OAAU,WAAY,MAAM,YAAY,aAAa,EACjF,GAAI,OAAO,EAAI,qBAAqB,OAAU,WAAY,MAAM,YAAY,qBAAqB,EAEjG,IAAM,EAAoB,CACxB,eAAgB,EAChB,SACA,WAGA,OAAQ,WAAW,EAAK,YAAY,EACpC,KAAM,EAAmB,EAAS,EAC/B,IAAe,EAAO,IAAS,EAAI,oBAAoB,MAAM,IAAI,QAAQ,EAAO,CAAI,CAAC,CACxF,CAAC,EACD,aAAc,KAAK,WAAW,EAAK,eAAe,EAAG,eAAe,EACpE,kBAAmB,KAAK,WAAW,EAAK,oBAAoB,EAAG,oBAAoB,EACnF,gBAAiB,mBAAmB,EAAK,CAAM,CACjD,EAEA,OADA,EAAQ,IAAI,EAAK,CAAM,EAChB,CACT"}
1
+ {"version":3,"file":"env.js","names":["LOOPBACK_HOSTS","url","ApiError","value","env","name","raw","appEnv","canonical","DEV_RATE_LIMIT_PEPPER","configs","cached","appEnvRaw","jwksUrl","audience","origins","o","config","createRemoteJWKSet","customFetch","input","init"],"sources":["../../src/worker/env.ts"],"sourcesContent":["import { createRemoteJWKSet, customFetch } from 'jose';\nimport { ApiError } from './http';\n\n/**\n * Worker configuration. Every value the worker trusts comes from the validated Env —\n * never from request-supplied URLs or headers. Anything missing or malformed fails\n * closed (503) rather than falling back to a permissive default.\n */\nexport type AppEnv = 'local' | 'production';\n\nexport interface ApiConfig {\n allowedOrigins: ReadonlySet<string>;\n appEnv: AppEnv;\n audience: string;\n issuer: string;\n jwks: ReturnType<typeof createRemoteJWKSet>;\n likesEnabled: boolean;\n publishingEnabled: boolean;\n rateLimitPepper: string;\n}\n\n/** The rate-limit binding subset the core calls; the runtime's RateLimit satisfies it. */\nexport interface RateLimiterBinding {\n limit(input: { key: string }): Promise<{ success: boolean }>;\n}\n\n/** The D1 statement subset the core binds; null params model nullable columns. */\nexport interface D1Statement {\n all<T>(): Promise<{ results?: T[] }>;\n bind(...params: (string | number | null)[]): D1Statement;\n first<T>(): Promise<T | null>;\n run(): Promise<{ meta?: { changes?: number } }>;\n}\n\n/** The D1 database subset the core uses; a deployment's generated D1Database satisfies it. */\nexport interface D1Like {\n batch(statements: D1Statement[]): Promise<{ meta?: { changes?: number } }[]>;\n prepare(sql: string): D1Statement;\n}\n\n/**\n * The bindings the archive Worker reads. The package declares its own minimal\n * structural types instead of `@cloudflare/workers-types`: a deployment's generated\n * Env (with real D1Database/RateLimit bindings) satisfies this shape structurally.\n */\nexport interface ArchiveWorkerEnv {\n ALLOWED_ORIGINS: string;\n APP_ENV: string;\n /** Required issuer session-validation service; every private request checks live authority. */\n AUTH_ISSUER_SERVICE: AuthIssuerServiceBinding;\n DB: D1Like;\n JWT_AUDIENCE: string;\n JWT_ISSUER: string;\n JWT_JWKS_URL: string;\n LIKES_ENABLED: string;\n PUBLISHING_ENABLED: string;\n RATE_LIMIT_KEY_SECRET: string;\n READ_LIMIT: RateLimiterBinding;\n WRITE_LIMIT: RateLimiterBinding;\n}\n\nexport interface AuthIssuerServiceBinding {\n fetch(request: Request): Promise<Response>;\n}\n\nconst LOOPBACK_HOSTS = new Set(['127.0.0.1', 'localhost', '[::1]']);\n\nconst isLoopback = (url: URL): boolean => LOOPBACK_HOSTS.has(url.hostname);\n\n// Configuration faults never name the offending field — the response must not\n// leak Env names or framework details, only a generic temporary unavailability.\nconst configError = (field: string): ApiError =>\n new ApiError(503, 'unavailable', 'The service is temporarily unavailable.', {\n diagnostic: { field, kind: 'configuration' },\n });\n\nconst requireVar = (env: ArchiveWorkerEnv, name: string): string => {\n const value = env[name as keyof ArchiveWorkerEnv];\n if (typeof value !== 'string' || value.length === 0) throw configError(name);\n return value;\n};\n\nconst requireUrl = (raw: string, name: string): URL => {\n try {\n return new URL(raw);\n } catch {\n throw configError(name);\n }\n};\n\n/** Validates one trusted endpoint URL against the environment's transport rules. */\nconst validateUrl = (url: URL, appEnv: AppEnv, name: string): URL => {\n // No embedded credentials or fragments can ever be part of a trusted endpoint.\n if (url.username !== '' || url.password !== '' || url.hash !== '') throw configError(name);\n if (appEnv === 'local') {\n // Loopback HTTP is permitted only in local development.\n if (url.protocol !== 'http:' || !isLoopback(url)) throw configError(name);\n return url;\n }\n if (url.protocol !== 'https:' || isLoopback(url)) throw configError(name);\n return url;\n};\n\n/**\n * An allowed origin is an exact canonical origin: scheme + host (+port) and\n * nothing else — no wildcard, `null` origin, credentials, path, query or fragment.\n * Local permits loopback HTTP; production requires non-loopback HTTPS.\n */\nconst validateOrigin = (raw: string, appEnv: AppEnv, name: string): string => {\n if (raw === 'null' || raw === '*') throw configError(name);\n const url = validateUrl(requireUrl(raw, name), appEnv, name);\n if (url.pathname !== '/' || url.search !== '') throw configError(name);\n const canonical = url.origin;\n if (canonical !== raw) throw configError(name);\n return canonical;\n};\n\nconst flag = (raw: string, name: string): boolean => {\n if (raw === 'true') return true;\n if (raw === 'false') return false;\n throw configError(name);\n};\n\n/** Local-only fallback pepper for rate-limit keys; never a production secret. */\nconst DEV_RATE_LIMIT_PEPPER = 'dev-only-insecure-pepper';\n\n/**\n * The deployment secret behind rate-limit key HMACs: a deployment-specific\n * pepper makes the hashed keys useless outside this deployment. Production\n * fails closed on a missing, empty or too-short value; local development only\n * may fall back to the constant insecure pepper — local only, never production.\n */\nconst rateLimitPepperFor = (env: ArchiveWorkerEnv, appEnv: AppEnv): string => {\n const raw = env.RATE_LIMIT_KEY_SECRET;\n if (typeof raw !== 'string' || raw.length === 0) {\n if (appEnv === 'local') return DEV_RATE_LIMIT_PEPPER;\n throw configError('RATE_LIMIT_KEY_SECRET');\n }\n if (appEnv === 'production' && raw.length < 32) throw configError('RATE_LIMIT_KEY_SECRET');\n return raw;\n};\n\nconst configs = new WeakMap<ArchiveWorkerEnv, ApiConfig>();\n\n/** Parses and validates the env once per bindings object; throws ApiError(503). */\nexport function apiConfig(env: ArchiveWorkerEnv): ApiConfig {\n const cached = configs.get(env);\n if (cached) return cached;\n\n const appEnvRaw = requireVar(env, 'APP_ENV');\n if (appEnvRaw !== 'local' && appEnvRaw !== 'production') throw configError('APP_ENV');\n const appEnv = appEnvRaw;\n\n // The issuer URL is validated for transport/safety but kept as the exact\n // configured string for JWT claim matching.\n validateUrl(requireUrl(requireVar(env, 'JWT_ISSUER'), 'JWT_ISSUER'), appEnv, 'JWT_ISSUER');\n const jwksUrl = validateUrl(requireUrl(requireVar(env, 'JWT_JWKS_URL'), 'JWT_JWKS_URL'), appEnv, 'JWT_JWKS_URL');\n const audience = requireVar(env, 'JWT_AUDIENCE');\n\n const origins = new Set(\n requireVar(env, 'ALLOWED_ORIGINS')\n .split(',')\n .map((o) => o.trim())\n .filter((o) => o.length > 0)\n .map((o) => validateOrigin(o, appEnv, 'ALLOWED_ORIGINS')),\n );\n if (origins.size === 0) throw configError('ALLOWED_ORIGINS');\n\n if (typeof env.DB?.prepare !== 'function' || typeof env.DB?.batch !== 'function') throw configError('DB');\n if (typeof env.READ_LIMIT?.limit !== 'function') throw configError('READ_LIMIT');\n if (typeof env.WRITE_LIMIT?.limit !== 'function') throw configError('WRITE_LIMIT');\n if (typeof env.AUTH_ISSUER_SERVICE?.fetch !== 'function') throw configError('AUTH_ISSUER_SERVICE');\n\n const config: ApiConfig = {\n allowedOrigins: origins,\n appEnv,\n audience,\n // The exact configured issuer string is the match target — a trailing slash\n // is meaningful, never normalized away.\n issuer: requireVar(env, 'JWT_ISSUER'),\n jwks: createRemoteJWKSet(jwksUrl, {\n [customFetch]: (input, init) => env.AUTH_ISSUER_SERVICE.fetch(new Request(input, init)),\n }),\n likesEnabled: flag(requireVar(env, 'LIKES_ENABLED'), 'LIKES_ENABLED'),\n publishingEnabled: flag(requireVar(env, 'PUBLISHING_ENABLED'), 'PUBLISHING_ENABLED'),\n rateLimitPepper: rateLimitPepperFor(env, appEnv),\n };\n configs.set(env, config);\n return config;\n}\n"],"mappings":"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"}
@@ -1,2 +1,2 @@
1
- const e=require("../contract.cjs");var ApiError=class extends Error{code;retryAfterSeconds;status;diagnostic;constructor(e,t,n,r){super(n,r),this.name=`ApiError`,this.code=t,this.retryAfterSeconds=r?.retryAfterSeconds,this.status=e,this.diagnostic=r?.diagnostic}},badRequest=(e=`The request is not valid.`)=>new ApiError(400,`bad-request`,e),unauthenticated=()=>new ApiError(401,`unauthenticated`,`Sign-in is required.`),forbidden=(e=`That action is not allowed.`)=>new ApiError(403,`forbidden`,e),notFound=()=>new ApiError(404,`not-found`,`No such entry.`),conflict=(e=`The entry changed since you saw it.`)=>new ApiError(409,`conflict`,e),invalid=(e=`The payload is not valid.`)=>new ApiError(422,`invalid-payload`,e),json=(e,t,n={})=>new Response(JSON.stringify(t),{headers:{"content-type":`application/json; charset=utf-8`,...n},status:e}),t={"cache-control":`no-store`},errorBody=(e,n,r,i)=>json(e,{error:{code:n,message:r,requestId:i}},t);function toErrorResponse(e,n){if(e instanceof ApiError){let r=e.retryAfterSeconds===void 0?t:{...t,"retry-after":String(e.retryAfterSeconds)};return json(e.status,{error:{code:e.code,message:e.message,requestId:n}},r)}return errorBody(500,`internal`,`Something went wrong.`,n)}function queryParams(e,t){let n=e.searchParams,r=new Set;for(let e of n.keys()){if(!t.includes(e)||r.has(e))throw badRequest();r.add(e)}return n}function requireMutationGuard(e,t){let n=e.headers.get(`origin`);if(n===null||!t.allowedOrigins.has(n)||e.headers.get(`x-archive-request`)!==`1`)throw forbidden()}function corsHeaders(e,t){let n=e.headers.get(`origin`);return n===null||!t.allowedOrigins.has(n)?{}:{"access-control-allow-credentials":`true`,"access-control-allow-origin":n,vary:`Origin`}}function preflight(t,n){let r=t.headers.get(`origin`);return r===null||!n.allowedOrigins.has(r)?errorBody(403,`forbidden`,`That action is not allowed.`,crypto.randomUUID()):new Response(null,{headers:{"access-control-allow-credentials":`true`,"access-control-allow-headers":`Content-Type, ${e.PUBLISHER_HEADER}, ${e.REQUEST_MARKER_HEADER}`,"access-control-allow-methods":`GET, POST, PUT, DELETE, OPTIONS`,"access-control-allow-origin":r,"access-control-max-age":`600`,vary:`Origin`},status:204})}var n=1e4,bodyTimeout=()=>new ApiError(408,`timeout`,`The request body took too long.`);async function readJsonBody(t){if((t.headers.get(`content-type`)??``).split(`;`,1)[0].trim().toLowerCase()!==`application/json`)throw new ApiError(415,`unsupported-media`,`JSON bodies only.`);let r=t.body?.getReader();if(!r)throw badRequest();let i=Date.now(),a,o=[],s=0;try{for(;;){let{done:t,value:c}=await Promise.race([r.read(),new Promise((e,t)=>{a=setTimeout(()=>t(bodyTimeout()),Math.max(0,n-(Date.now()-i)))})]);if(clearTimeout(a),a=void 0,t)break;if(s+=c.byteLength,s>e.ARCHIVE_LIMITS.requestBytes)throw new ApiError(413,`too-large`,`The request is too large.`);o.push(c)}}catch(e){throw r.cancel().catch(()=>void 0),e}finally{a!==void 0&&clearTimeout(a),r.releaseLock()}let c=new Uint8Array(s),l=0;for(let e of o)c.set(e,l),l+=e.byteLength;if(s===0)throw badRequest();try{return JSON.parse(new TextDecoder(`utf-8`,{fatal:!0}).decode(c))}catch{throw badRequest()}}var r=4096;function sessionToken(e){let t=e.headers.get(`cookie`);if(t===null)return null;if(t.length>r)throw badRequest();let n=[];for(let e of t.split(`;`)){let t=e.indexOf(`=`);t!==-1&&e.slice(0,t).trim()===`archive_session`&&n.push(e.slice(t+1).trim())}if(n.length>1)throw badRequest();return n.length===1&&n[0].length>0?n[0]:null}var expiredSessionCookie=t=>e.sessionCookieValue(``,{maxAgeSeconds:0,secure:t});exports.ApiError=ApiError,exports.HTTP_BODY_TIMEOUT_MS=n,exports.badRequest=badRequest,exports.conflict=conflict,exports.corsHeaders=corsHeaders,exports.errorBody=errorBody,exports.expiredSessionCookie=expiredSessionCookie,exports.forbidden=forbidden,exports.invalid=invalid,exports.json=json,exports.noStore=t,exports.notFound=notFound,exports.preflight=preflight,exports.queryParams=queryParams,exports.readJsonBody=readJsonBody,exports.requireMutationGuard=requireMutationGuard,exports.sessionToken=sessionToken,exports.toErrorResponse=toErrorResponse,exports.unauthenticated=unauthenticated;
1
+ const e=require("../contract.cjs");var ApiError=class extends Error{code;retryAfterSeconds;status;diagnostic;constructor(e,t,n,r){super(n,r),this.name=`ApiError`,this.code=t,this.retryAfterSeconds=r?.retryAfterSeconds,this.status=e,this.diagnostic=r?.diagnostic}},badRequest=(e=`The request is not valid.`)=>new ApiError(400,`bad-request`,e),unauthenticated=()=>new ApiError(401,`unauthenticated`,`Sign-in is required.`),forbidden=(e=`That action is not allowed.`)=>new ApiError(403,`forbidden`,e),notFound=()=>new ApiError(404,`not-found`,`No such entry.`),conflict=(e=`The entry changed since you saw it.`)=>new ApiError(409,`conflict`,e),invalid=(e=`The payload is not valid.`)=>new ApiError(422,`invalid-payload`,e),json=(e,t,n={})=>new Response(JSON.stringify(t),{headers:{"content-type":`application/json; charset=utf-8`,...n},status:e}),t={"cache-control":`no-store`},errorBody=(e,n,r,i)=>json(e,{error:{code:n,message:r,requestId:i}},t);function toErrorResponse(e,n){if(e instanceof ApiError){let r=e.retryAfterSeconds===void 0?t:{...t,"retry-after":String(e.retryAfterSeconds)};return json(e.status,{error:{code:e.code,message:e.message,requestId:n}},r)}return errorBody(500,`internal`,`Something went wrong.`,n)}function queryParams(e,t,n=[]){let r=e.searchParams,i=new Set;for(let e of r.keys()){if(!t.includes(e)||i.has(e)&&!n.includes(e))throw badRequest();i.add(e)}return r}function requireMutationGuard(e,t){let n=e.headers.get(`origin`);if(n===null||!t.allowedOrigins.has(n)||e.headers.get(`x-archive-request`)!==`1`)throw forbidden()}function corsHeaders(e,t){let n=e.headers.get(`origin`);return n===null||!t.allowedOrigins.has(n)?{}:{"access-control-allow-credentials":`true`,"access-control-allow-origin":n,vary:`Origin`}}function preflight(t,n){let r=t.headers.get(`origin`);return r===null||!n.allowedOrigins.has(r)?errorBody(403,`forbidden`,`That action is not allowed.`,crypto.randomUUID()):new Response(null,{headers:{"access-control-allow-credentials":`true`,"access-control-allow-headers":`Content-Type, ${e.PUBLISHER_HEADER}, ${e.REQUEST_MARKER_HEADER}`,"access-control-allow-methods":`GET, POST, PUT, DELETE, OPTIONS`,"access-control-allow-origin":r,"access-control-max-age":`600`,vary:`Origin`},status:204})}var n=1e4,bodyTimeout=()=>new ApiError(408,`timeout`,`The request body took too long.`);async function readJsonBody(t){if((t.headers.get(`content-type`)??``).split(`;`,1)[0].trim().toLowerCase()!==`application/json`)throw new ApiError(415,`unsupported-media`,`JSON bodies only.`);let r=t.body?.getReader();if(!r)throw badRequest();let i=Date.now(),a,o=[],s=0;try{for(;;){let{done:t,value:c}=await Promise.race([r.read(),new Promise((e,t)=>{a=setTimeout(()=>t(bodyTimeout()),Math.max(0,n-(Date.now()-i)))})]);if(clearTimeout(a),a=void 0,t)break;if(s+=c.byteLength,s>e.ARCHIVE_LIMITS.requestBytes)throw new ApiError(413,`too-large`,`The request is too large.`);o.push(c)}}catch(e){throw r.cancel().catch(()=>void 0),e}finally{a!==void 0&&clearTimeout(a),r.releaseLock()}let c=new Uint8Array(s),l=0;for(let e of o)c.set(e,l),l+=e.byteLength;if(s===0)throw badRequest();try{return JSON.parse(new TextDecoder(`utf-8`,{fatal:!0}).decode(c))}catch{throw badRequest()}}var r=4096;function sessionToken(e){let t=e.headers.get(`cookie`);if(t===null)return null;if(t.length>r)throw badRequest();let n=[];for(let e of t.split(`;`)){let t=e.indexOf(`=`);t!==-1&&e.slice(0,t).trim()===`archive_session`&&n.push(e.slice(t+1).trim())}if(n.length>1)throw badRequest();return n.length===1&&n[0].length>0?n[0]:null}var expiredSessionCookie=t=>e.sessionCookieValue(``,{maxAgeSeconds:0,secure:t});exports.ApiError=ApiError,exports.HTTP_BODY_TIMEOUT_MS=n,exports.badRequest=badRequest,exports.conflict=conflict,exports.corsHeaders=corsHeaders,exports.errorBody=errorBody,exports.expiredSessionCookie=expiredSessionCookie,exports.forbidden=forbidden,exports.invalid=invalid,exports.json=json,exports.noStore=t,exports.notFound=notFound,exports.preflight=preflight,exports.queryParams=queryParams,exports.readJsonBody=readJsonBody,exports.requireMutationGuard=requireMutationGuard,exports.sessionToken=sessionToken,exports.toErrorResponse=toErrorResponse,exports.unauthenticated=unauthenticated;
2
2
  //# sourceMappingURL=http.cjs.map
@@ -1 +1 @@
1
- {"version":3,"file":"http.cjs","names":[],"sources":["../../src/worker/http.ts"],"sourcesContent":["import {\n ARCHIVE_LIMITS,\n type ArchiveErrorCode,\n PUBLISHER_HEADER,\n REQUEST_MARKER_HEADER,\n SESSION_COOKIE_NAME,\n sessionCookieValue,\n} from '../contract';\nimport type { ApiConfig } from './env';\n\n/**\n * HTTP plumbing shared by every route: the one error shape, bounded body reads, strict\n * query/header guards and the CORS contract. Handlers throw ApiError; the entry point\n * sanitizes — no stack, SQL, body, or PII ever reaches a response. Internal to the\n * Worker: clients parse the wire envelope (`archiveErrorResponseSchema`), never this class.\n */\nexport class ApiError extends Error {\n readonly code: ArchiveErrorCode;\n readonly retryAfterSeconds?: number;\n readonly status: number;\n readonly diagnostic?: ArchiveDiagnostic;\n constructor(\n status: number,\n code: ArchiveErrorCode,\n message: string,\n opts?: ErrorOptions & { retryAfterSeconds?: number; diagnostic?: ArchiveDiagnostic },\n ) {\n super(message, opts);\n this.name = 'ApiError';\n this.code = code;\n this.retryAfterSeconds = opts?.retryAfterSeconds;\n this.status = status;\n this.diagnostic = opts?.diagnostic;\n }\n}\n\nexport type ArchiveDiagnostic =\n | { readonly kind: 'configuration'; readonly field: string }\n | { readonly kind: 'database' | 'record' | 'schema' | 'internal' | 'cache' | 'collection' };\n\nexport const badRequest = (message = 'The request is not valid.'): ApiError =>\n new ApiError(400, 'bad-request', message);\nexport const unauthenticated = (): ApiError => new ApiError(401, 'unauthenticated', 'Sign-in is required.');\nexport const forbidden = (message = 'That action is not allowed.'): ApiError => new ApiError(403, 'forbidden', message);\nexport const notFound = (): ApiError => new ApiError(404, 'not-found', 'No such entry.');\nexport const conflict = (message = 'The entry changed since you saw it.'): ApiError =>\n new ApiError(409, 'conflict', message);\nexport const invalid = (message = 'The payload is not valid.'): ApiError =>\n new ApiError(422, 'invalid-payload', message);\n\nexport const json = (status: number, body: unknown, headers: HeadersInit = {}): Response =>\n new Response(JSON.stringify(body), {\n headers: { 'content-type': 'application/json; charset=utf-8', ...headers },\n status,\n });\n\nexport const noStore = { 'cache-control': 'no-store' } as const;\n\nexport const errorBody = (status: number, code: string, message: string, requestId: string): Response =>\n json(status, { error: { code, message, requestId } }, noStore);\n\n/** Sanitizes any thrown value into the public error shape; unknowns become 500. */\nexport function toErrorResponse(error: unknown, requestId: string): Response {\n if (error instanceof ApiError) {\n const headers: HeadersInit =\n error.retryAfterSeconds === undefined ? noStore : { ...noStore, 'retry-after': String(error.retryAfterSeconds) };\n return json(error.status, { error: { code: error.code, message: error.message, requestId } }, headers);\n }\n return errorBody(500, 'internal', 'Something went wrong.', requestId);\n}\n\n/**\n * Rejects query strings with unknown or repeated parameters. Every accepted route\n * names its full allow-list; anything else is a client bug, not something to ignore.\n */\nexport function queryParams(url: URL, allowed: readonly string[]): URLSearchParams {\n const params = url.searchParams;\n const seen = new Set<string>();\n for (const key of params.keys()) {\n if (!allowed.includes(key) || seen.has(key)) throw badRequest();\n seen.add(key);\n }\n return params;\n}\n\n/** Exact-origin plus request-marker guard for every mutation: no wildcards, no nulls. */\nexport function requireMutationGuard(request: Request, config: ApiConfig): void {\n const origin = request.headers.get('origin');\n if (origin === null || !config.allowedOrigins.has(origin)) throw forbidden();\n if (request.headers.get(REQUEST_MARKER_HEADER) !== '1') throw forbidden();\n}\n\n/** CORS response headers for one already-allowed origin; absent for foreign origins. */\nexport function corsHeaders(request: Request, config: ApiConfig): Record<string, string> {\n const origin = request.headers.get('origin');\n if (origin === null || !config.allowedOrigins.has(origin)) return {};\n return {\n 'access-control-allow-credentials': 'true',\n 'access-control-allow-origin': origin,\n vary: 'Origin',\n };\n}\n\n/** Answers a preflight for an allowed origin; foreign or missing origins get 403. */\nexport function preflight(request: Request, config: ApiConfig): Response {\n const origin = request.headers.get('origin');\n if (origin === null || !config.allowedOrigins.has(origin)) {\n return errorBody(403, 'forbidden', 'That action is not allowed.', crypto.randomUUID());\n }\n return new Response(null, {\n headers: {\n 'access-control-allow-credentials': 'true',\n // Header names are case-insensitive; the values derive from the contract constants.\n 'access-control-allow-headers': `Content-Type, ${PUBLISHER_HEADER}, ${REQUEST_MARKER_HEADER}`,\n 'access-control-allow-methods': 'GET, POST, PUT, DELETE, OPTIONS',\n 'access-control-allow-origin': origin,\n 'access-control-max-age': '600',\n vary: 'Origin',\n },\n status: 204,\n });\n}\n\n/** Hard deadline for reading a request body; slow streams fail as 408. */\nexport const HTTP_BODY_TIMEOUT_MS = 10000;\n\nconst bodyTimeout = (): ApiError => new ApiError(408, 'timeout', 'The request body took too long.');\n\n/**\n * Reads a JSON body with a hard byte ceiling and a wall-clock deadline enforced while\n * streaming — a lying or absent Content-Length and a stalled sender are both cut off.\n * Wrong media types are 415, oversized 413, timed out 408, malformed JSON 400; schema\n * validation happens afterwards at the route boundary.\n */\nexport async function readJsonBody(request: Request): Promise<unknown> {\n // Exactly the application/json media type; parameters (charset etc.) may follow.\n const mediaType = (request.headers.get('content-type') ?? '').split(';', 1)[0].trim().toLowerCase();\n if (mediaType !== 'application/json') {\n throw new ApiError(415, 'unsupported-media', 'JSON bodies only.');\n }\n const reader = request.body?.getReader();\n if (!reader) throw badRequest();\n const startedAt = Date.now();\n let timeoutId: ReturnType<typeof setTimeout> | undefined;\n const chunks: Uint8Array[] = [];\n let total = 0;\n try {\n for (;;) {\n const { done, value } = await Promise.race([\n reader.read(),\n new Promise<never>((_r, reject) => {\n timeoutId = setTimeout(\n () => reject(bodyTimeout()),\n Math.max(0, HTTP_BODY_TIMEOUT_MS - (Date.now() - startedAt)),\n );\n }),\n ]);\n clearTimeout(timeoutId);\n timeoutId = undefined;\n if (done) break;\n total += value.byteLength;\n if (total > ARCHIVE_LIMITS.requestBytes) throw new ApiError(413, 'too-large', 'The request is too large.');\n chunks.push(value);\n }\n } catch (error) {\n // Release the remainder so the failure isn't blocked on a stalled sender.\n reader.cancel().catch(() => undefined);\n throw error;\n } finally {\n if (timeoutId !== undefined) clearTimeout(timeoutId);\n reader.releaseLock();\n }\n const merged = new Uint8Array(total);\n let offset = 0;\n for (const chunk of chunks) {\n merged.set(chunk, offset);\n offset += chunk.byteLength;\n }\n if (total === 0) throw badRequest();\n try {\n return JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(merged));\n } catch {\n throw badRequest();\n }\n}\n\nconst SESSION_COOKIE_MAX = 4096;\n\n/**\n * Reads the session cookie. A duplicate cookie name or an oversized header is a\n * malformed request, not a missing session: callers distinguish null (anonymous)\n * from a token to verify.\n */\nexport function sessionToken(request: Request): string | null {\n const header = request.headers.get('cookie');\n if (header === null) return null;\n if (header.length > SESSION_COOKIE_MAX) throw badRequest();\n const matches: string[] = [];\n for (const part of header.split(';')) {\n const eq = part.indexOf('=');\n if (eq === -1) continue;\n if (part.slice(0, eq).trim() === SESSION_COOKIE_NAME) matches.push(part.slice(eq + 1).trim());\n }\n if (matches.length > 1) throw badRequest();\n return matches.length === 1 && matches[0].length > 0 ? matches[0] : null;\n}\n\n/** The expired-session Set-Cookie value the sign-out route and account erasure send. */\nexport const expiredSessionCookie = (secure: boolean): string => sessionCookieValue('', { maxAgeSeconds: 0, secure });\n"],"mappings":"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"}
1
+ {"version":3,"file":"http.cjs","names":["PUBLISHER_HEADER","REQUEST_MARKER_HEADER","ARCHIVE_LIMITS","sessionCookieValue","require_contract","message","opts","code","status","body","headers","noStore","error","requestId","params","url","seen","key","allowed","repeatable","origin","request","config","HTTP_BODY_TIMEOUT_MS","reader","startedAt","timeoutId","chunks","total","reject","done","value","merged","offset","chunk","SESSION_COOKIE_MAX","header","matches","part","eq"],"sources":["../../src/worker/http.ts"],"sourcesContent":["import {\n ARCHIVE_LIMITS,\n type ArchiveErrorCode,\n PUBLISHER_HEADER,\n REQUEST_MARKER_HEADER,\n SESSION_COOKIE_NAME,\n sessionCookieValue,\n} from '../contract';\nimport type { ApiConfig } from './env';\n\n/**\n * HTTP plumbing shared by every route: the one error shape, bounded body reads, strict\n * query/header guards and the CORS contract. Handlers throw ApiError; the entry point\n * sanitizes — no stack, SQL, body, or PII ever reaches a response. Internal to the\n * Worker: clients parse the wire envelope (`archiveErrorResponseSchema`), never this class.\n */\nexport class ApiError extends Error {\n readonly code: ArchiveErrorCode;\n readonly retryAfterSeconds?: number;\n readonly status: number;\n readonly diagnostic?: ArchiveDiagnostic;\n constructor(\n status: number,\n code: ArchiveErrorCode,\n message: string,\n opts?: ErrorOptions & { retryAfterSeconds?: number; diagnostic?: ArchiveDiagnostic },\n ) {\n super(message, opts);\n this.name = 'ApiError';\n this.code = code;\n this.retryAfterSeconds = opts?.retryAfterSeconds;\n this.status = status;\n this.diagnostic = opts?.diagnostic;\n }\n}\n\nexport type ArchiveDiagnostic =\n | { readonly kind: 'configuration'; readonly field: string }\n | { readonly kind: 'database' | 'record' | 'schema' | 'internal' | 'cache' | 'collection' };\n\nexport const badRequest = (message = 'The request is not valid.'): ApiError =>\n new ApiError(400, 'bad-request', message);\nexport const unauthenticated = (): ApiError => new ApiError(401, 'unauthenticated', 'Sign-in is required.');\nexport const forbidden = (message = 'That action is not allowed.'): ApiError => new ApiError(403, 'forbidden', message);\nexport const notFound = (): ApiError => new ApiError(404, 'not-found', 'No such entry.');\nexport const conflict = (message = 'The entry changed since you saw it.'): ApiError =>\n new ApiError(409, 'conflict', message);\nexport const invalid = (message = 'The payload is not valid.'): ApiError =>\n new ApiError(422, 'invalid-payload', message);\n\nexport const json = (status: number, body: unknown, headers: HeadersInit = {}): Response =>\n new Response(JSON.stringify(body), {\n headers: { 'content-type': 'application/json; charset=utf-8', ...headers },\n status,\n });\n\nexport const noStore = { 'cache-control': 'no-store' } as const;\n\nexport const errorBody = (status: number, code: string, message: string, requestId: string): Response =>\n json(status, { error: { code, message, requestId } }, noStore);\n\n/** Sanitizes any thrown value into the public error shape; unknowns become 500. */\nexport function toErrorResponse(error: unknown, requestId: string): Response {\n if (error instanceof ApiError) {\n const headers: HeadersInit =\n error.retryAfterSeconds === undefined ? noStore : { ...noStore, 'retry-after': String(error.retryAfterSeconds) };\n return json(error.status, { error: { code: error.code, message: error.message, requestId } }, headers);\n }\n return errorBody(500, 'internal', 'Something went wrong.', requestId);\n}\n\n/**\n * Rejects query strings with unknown or repeated parameters. Every accepted route\n * names its full allow-list; anything else is a client bug, not something to ignore.\n * Keys listed in `repeatable` may carry several values (read back with `getAll`);\n * every other key stays single-valued.\n */\nexport function queryParams(url: URL, allowed: readonly string[], repeatable: readonly string[] = []): URLSearchParams {\n const params = url.searchParams;\n const seen = new Set<string>();\n for (const key of params.keys()) {\n if (!allowed.includes(key) || (seen.has(key) && !repeatable.includes(key))) throw badRequest();\n seen.add(key);\n }\n return params;\n}\n\n/** Exact-origin plus request-marker guard for every mutation: no wildcards, no nulls. */\nexport function requireMutationGuard(request: Request, config: ApiConfig): void {\n const origin = request.headers.get('origin');\n if (origin === null || !config.allowedOrigins.has(origin)) throw forbidden();\n if (request.headers.get(REQUEST_MARKER_HEADER) !== '1') throw forbidden();\n}\n\n/** CORS response headers for one already-allowed origin; absent for foreign origins. */\nexport function corsHeaders(request: Request, config: ApiConfig): Record<string, string> {\n const origin = request.headers.get('origin');\n if (origin === null || !config.allowedOrigins.has(origin)) return {};\n return {\n 'access-control-allow-credentials': 'true',\n 'access-control-allow-origin': origin,\n vary: 'Origin',\n };\n}\n\n/** Answers a preflight for an allowed origin; foreign or missing origins get 403. */\nexport function preflight(request: Request, config: ApiConfig): Response {\n const origin = request.headers.get('origin');\n if (origin === null || !config.allowedOrigins.has(origin)) {\n return errorBody(403, 'forbidden', 'That action is not allowed.', crypto.randomUUID());\n }\n return new Response(null, {\n headers: {\n 'access-control-allow-credentials': 'true',\n // Header names are case-insensitive; the values derive from the contract constants.\n 'access-control-allow-headers': `Content-Type, ${PUBLISHER_HEADER}, ${REQUEST_MARKER_HEADER}`,\n 'access-control-allow-methods': 'GET, POST, PUT, DELETE, OPTIONS',\n 'access-control-allow-origin': origin,\n 'access-control-max-age': '600',\n vary: 'Origin',\n },\n status: 204,\n });\n}\n\n/** Hard deadline for reading a request body; slow streams fail as 408. */\nexport const HTTP_BODY_TIMEOUT_MS = 10000;\n\nconst bodyTimeout = (): ApiError => new ApiError(408, 'timeout', 'The request body took too long.');\n\n/**\n * Reads a JSON body with a hard byte ceiling and a wall-clock deadline enforced while\n * streaming — a lying or absent Content-Length and a stalled sender are both cut off.\n * Wrong media types are 415, oversized 413, timed out 408, malformed JSON 400; schema\n * validation happens afterwards at the route boundary.\n */\nexport async function readJsonBody(request: Request): Promise<unknown> {\n // Exactly the application/json media type; parameters (charset etc.) may follow.\n const mediaType = (request.headers.get('content-type') ?? '').split(';', 1)[0].trim().toLowerCase();\n if (mediaType !== 'application/json') {\n throw new ApiError(415, 'unsupported-media', 'JSON bodies only.');\n }\n const reader = request.body?.getReader();\n if (!reader) throw badRequest();\n const startedAt = Date.now();\n let timeoutId: ReturnType<typeof setTimeout> | undefined;\n const chunks: Uint8Array[] = [];\n let total = 0;\n try {\n for (;;) {\n const { done, value } = await Promise.race([\n reader.read(),\n new Promise<never>((_r, reject) => {\n timeoutId = setTimeout(\n () => reject(bodyTimeout()),\n Math.max(0, HTTP_BODY_TIMEOUT_MS - (Date.now() - startedAt)),\n );\n }),\n ]);\n clearTimeout(timeoutId);\n timeoutId = undefined;\n if (done) break;\n total += value.byteLength;\n if (total > ARCHIVE_LIMITS.requestBytes) throw new ApiError(413, 'too-large', 'The request is too large.');\n chunks.push(value);\n }\n } catch (error) {\n // Release the remainder so the failure isn't blocked on a stalled sender.\n reader.cancel().catch(() => undefined);\n throw error;\n } finally {\n if (timeoutId !== undefined) clearTimeout(timeoutId);\n reader.releaseLock();\n }\n const merged = new Uint8Array(total);\n let offset = 0;\n for (const chunk of chunks) {\n merged.set(chunk, offset);\n offset += chunk.byteLength;\n }\n if (total === 0) throw badRequest();\n try {\n return JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(merged));\n } catch {\n throw badRequest();\n }\n}\n\nconst SESSION_COOKIE_MAX = 4096;\n\n/**\n * Reads the session cookie. A duplicate cookie name or an oversized header is a\n * malformed request, not a missing session: callers distinguish null (anonymous)\n * from a token to verify.\n */\nexport function sessionToken(request: Request): string | null {\n const header = request.headers.get('cookie');\n if (header === null) return null;\n if (header.length > SESSION_COOKIE_MAX) throw badRequest();\n const matches: string[] = [];\n for (const part of header.split(';')) {\n const eq = part.indexOf('=');\n if (eq === -1) continue;\n if (part.slice(0, eq).trim() === SESSION_COOKIE_NAME) matches.push(part.slice(eq + 1).trim());\n }\n if (matches.length > 1) throw badRequest();\n return matches.length === 1 && matches[0].length > 0 ? matches[0] : null;\n}\n\n/** The expired-session Set-Cookie value the sign-out route and account erasure send. */\nexport const expiredSessionCookie = (secure: boolean): string => sessionCookieValue('', { maxAgeSeconds: 0, secure });\n"],"mappings":"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"}
@@ -38,8 +38,10 @@ export declare function toErrorResponse(error: unknown, requestId: string): Resp
38
38
  /**
39
39
  * Rejects query strings with unknown or repeated parameters. Every accepted route
40
40
  * names its full allow-list; anything else is a client bug, not something to ignore.
41
+ * Keys listed in `repeatable` may carry several values (read back with `getAll`);
42
+ * every other key stays single-valued.
41
43
  */
42
- export declare function queryParams(url: URL, allowed: readonly string[]): URLSearchParams;
44
+ export declare function queryParams(url: URL, allowed: readonly string[], repeatable?: readonly string[]): URLSearchParams;
43
45
  /** Exact-origin plus request-marker guard for every mutation: no wildcards, no nulls. */
44
46
  export declare function requireMutationGuard(request: Request, config: ApiConfig): void;
45
47
  /** CORS response headers for one already-allowed origin; absent for foreign origins. */
@@ -1 +1 @@
1
- {"version":3,"file":"http.d.ts","sourceRoot":"","sources":["../../src/worker/http.ts"],"names":[],"mappings":"AAAA,OAAO,EAEL,KAAK,gBAAgB,EAKtB,MAAM,aAAa,CAAC;AACrB,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,OAAO,CAAC;AAEvC;;;;;GAKG;AACH,qBAAa,QAAS,SAAQ,KAAK;IACjC,QAAQ,CAAC,IAAI,EAAE,gBAAgB,CAAC;IAChC,QAAQ,CAAC,iBAAiB,CAAC,EAAE,MAAM,CAAC;IACpC,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;IACxB,QAAQ,CAAC,UAAU,CAAC,EAAE,iBAAiB,CAAC;gBAEtC,MAAM,EAAE,MAAM,EACd,IAAI,EAAE,gBAAgB,EACtB,OAAO,EAAE,MAAM,EACf,IAAI,CAAC,EAAE,YAAY,GAAG;QAAE,iBAAiB,CAAC,EAAE,MAAM,CAAC;QAAC,UAAU,CAAC,EAAE,iBAAiB,CAAA;KAAE;CASvF;AAED,MAAM,MAAM,iBAAiB,GACzB;IAAE,QAAQ,CAAC,IAAI,EAAE,eAAe,CAAC;IAAC,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAA;CAAE,GAC1D;IAAE,QAAQ,CAAC,IAAI,EAAE,UAAU,GAAG,QAAQ,GAAG,QAAQ,GAAG,UAAU,GAAG,OAAO,GAAG,YAAY,CAAA;CAAE,CAAC;AAE9F,eAAO,MAAM,UAAU,GAAI,gBAAqC,KAAG,QACxB,CAAC;AAC5C,eAAO,MAAM,eAAe,QAAO,QAAwE,CAAC;AAC5G,eAAO,MAAM,SAAS,GAAI,gBAAuC,KAAG,QAAmD,CAAC;AACxH,eAAO,MAAM,QAAQ,QAAO,QAA4D,CAAC;AACzF,eAAO,MAAM,QAAQ,GAAI,gBAA+C,KAAG,QACnC,CAAC;AACzC,eAAO,MAAM,OAAO,GAAI,gBAAqC,KAAG,QACjB,CAAC;AAEhD,eAAO,MAAM,IAAI,GAAI,QAAQ,MAAM,EAAE,MAAM,OAAO,EAAE,UAAS,WAAgB,KAAG,QAI5E,CAAC;AAEL,eAAO,MAAM,OAAO;;CAA2C,CAAC;AAEhE,eAAO,MAAM,SAAS,GAAI,QAAQ,MAAM,EAAE,MAAM,MAAM,EAAE,SAAS,MAAM,EAAE,WAAW,MAAM,KAAG,QAC7B,CAAC;AAEjE,mFAAmF;AACnF,wBAAgB,eAAe,CAAC,KAAK,EAAE,OAAO,EAAE,SAAS,EAAE,MAAM,GAAG,QAAQ,CAO3E;AAED;;;GAGG;AACH,wBAAgB,WAAW,CAAC,GAAG,EAAE,GAAG,EAAE,OAAO,EAAE,SAAS,MAAM,EAAE,GAAG,eAAe,CAQjF;AAED,yFAAyF;AACzF,wBAAgB,oBAAoB,CAAC,OAAO,EAAE,OAAO,EAAE,MAAM,EAAE,SAAS,GAAG,IAAI,CAI9E;AAED,wFAAwF;AACxF,wBAAgB,WAAW,CAAC,OAAO,EAAE,OAAO,EAAE,MAAM,EAAE,SAAS,GAAG,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAQvF;AAED,qFAAqF;AACrF,wBAAgB,SAAS,CAAC,OAAO,EAAE,OAAO,EAAE,MAAM,EAAE,SAAS,GAAG,QAAQ,CAiBvE;AAED,0EAA0E;AAC1E,eAAO,MAAM,oBAAoB,QAAQ,CAAC;AAI1C;;;;;GAKG;AACH,wBAAsB,YAAY,CAAC,OAAO,EAAE,OAAO,GAAG,OAAO,CAAC,OAAO,CAAC,CAkDrE;AAID;;;;GAIG;AACH,wBAAgB,YAAY,CAAC,OAAO,EAAE,OAAO,GAAG,MAAM,GAAG,IAAI,CAY5D;AAED,wFAAwF;AACxF,eAAO,MAAM,oBAAoB,GAAI,QAAQ,OAAO,KAAG,MAA8D,CAAC"}
1
+ {"version":3,"file":"http.d.ts","sourceRoot":"","sources":["../../src/worker/http.ts"],"names":[],"mappings":"AAAA,OAAO,EAEL,KAAK,gBAAgB,EAKtB,MAAM,aAAa,CAAC;AACrB,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,OAAO,CAAC;AAEvC;;;;;GAKG;AACH,qBAAa,QAAS,SAAQ,KAAK;IACjC,QAAQ,CAAC,IAAI,EAAE,gBAAgB,CAAC;IAChC,QAAQ,CAAC,iBAAiB,CAAC,EAAE,MAAM,CAAC;IACpC,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;IACxB,QAAQ,CAAC,UAAU,CAAC,EAAE,iBAAiB,CAAC;gBAEtC,MAAM,EAAE,MAAM,EACd,IAAI,EAAE,gBAAgB,EACtB,OAAO,EAAE,MAAM,EACf,IAAI,CAAC,EAAE,YAAY,GAAG;QAAE,iBAAiB,CAAC,EAAE,MAAM,CAAC;QAAC,UAAU,CAAC,EAAE,iBAAiB,CAAA;KAAE;CASvF;AAED,MAAM,MAAM,iBAAiB,GACzB;IAAE,QAAQ,CAAC,IAAI,EAAE,eAAe,CAAC;IAAC,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAA;CAAE,GAC1D;IAAE,QAAQ,CAAC,IAAI,EAAE,UAAU,GAAG,QAAQ,GAAG,QAAQ,GAAG,UAAU,GAAG,OAAO,GAAG,YAAY,CAAA;CAAE,CAAC;AAE9F,eAAO,MAAM,UAAU,GAAI,gBAAqC,KAAG,QACxB,CAAC;AAC5C,eAAO,MAAM,eAAe,QAAO,QAAwE,CAAC;AAC5G,eAAO,MAAM,SAAS,GAAI,gBAAuC,KAAG,QAAmD,CAAC;AACxH,eAAO,MAAM,QAAQ,QAAO,QAA4D,CAAC;AACzF,eAAO,MAAM,QAAQ,GAAI,gBAA+C,KAAG,QACnC,CAAC;AACzC,eAAO,MAAM,OAAO,GAAI,gBAAqC,KAAG,QACjB,CAAC;AAEhD,eAAO,MAAM,IAAI,GAAI,QAAQ,MAAM,EAAE,MAAM,OAAO,EAAE,UAAS,WAAgB,KAAG,QAI5E,CAAC;AAEL,eAAO,MAAM,OAAO;;CAA2C,CAAC;AAEhE,eAAO,MAAM,SAAS,GAAI,QAAQ,MAAM,EAAE,MAAM,MAAM,EAAE,SAAS,MAAM,EAAE,WAAW,MAAM,KAAG,QAC7B,CAAC;AAEjE,mFAAmF;AACnF,wBAAgB,eAAe,CAAC,KAAK,EAAE,OAAO,EAAE,SAAS,EAAE,MAAM,GAAG,QAAQ,CAO3E;AAED;;;;;GAKG;AACH,wBAAgB,WAAW,CAAC,GAAG,EAAE,GAAG,EAAE,OAAO,EAAE,SAAS,MAAM,EAAE,EAAE,UAAU,GAAE,SAAS,MAAM,EAAO,GAAG,eAAe,CAQrH;AAED,yFAAyF;AACzF,wBAAgB,oBAAoB,CAAC,OAAO,EAAE,OAAO,EAAE,MAAM,EAAE,SAAS,GAAG,IAAI,CAI9E;AAED,wFAAwF;AACxF,wBAAgB,WAAW,CAAC,OAAO,EAAE,OAAO,EAAE,MAAM,EAAE,SAAS,GAAG,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAQvF;AAED,qFAAqF;AACrF,wBAAgB,SAAS,CAAC,OAAO,EAAE,OAAO,EAAE,MAAM,EAAE,SAAS,GAAG,QAAQ,CAiBvE;AAED,0EAA0E;AAC1E,eAAO,MAAM,oBAAoB,QAAQ,CAAC;AAI1C;;;;;GAKG;AACH,wBAAsB,YAAY,CAAC,OAAO,EAAE,OAAO,GAAG,OAAO,CAAC,OAAO,CAAC,CAkDrE;AAID;;;;GAIG;AACH,wBAAgB,YAAY,CAAC,OAAO,EAAE,OAAO,GAAG,MAAM,GAAG,IAAI,CAY5D;AAED,wFAAwF;AACxF,eAAO,MAAM,oBAAoB,GAAI,QAAQ,OAAO,KAAG,MAA8D,CAAC"}
@@ -1,2 +1,119 @@
1
- import{ARCHIVE_LIMITS as e,PUBLISHER_HEADER as t,REQUEST_MARKER_HEADER as n,sessionCookieValue as r}from"../contract.js";var ApiError=class extends Error{code;retryAfterSeconds;status;diagnostic;constructor(e,t,n,r){super(n,r),this.name=`ApiError`,this.code=t,this.retryAfterSeconds=r?.retryAfterSeconds,this.status=e,this.diagnostic=r?.diagnostic}},badRequest=(e=`The request is not valid.`)=>new ApiError(400,`bad-request`,e),unauthenticated=()=>new ApiError(401,`unauthenticated`,`Sign-in is required.`),forbidden=(e=`That action is not allowed.`)=>new ApiError(403,`forbidden`,e),notFound=()=>new ApiError(404,`not-found`,`No such entry.`),conflict=(e=`The entry changed since you saw it.`)=>new ApiError(409,`conflict`,e),invalid=(e=`The payload is not valid.`)=>new ApiError(422,`invalid-payload`,e),json=(e,t,n={})=>new Response(JSON.stringify(t),{headers:{"content-type":`application/json; charset=utf-8`,...n},status:e}),i={"cache-control":`no-store`},errorBody=(e,t,n,r)=>json(e,{error:{code:t,message:n,requestId:r}},i);function toErrorResponse(e,t){if(e instanceof ApiError){let n=e.retryAfterSeconds===void 0?i:{...i,"retry-after":String(e.retryAfterSeconds)};return json(e.status,{error:{code:e.code,message:e.message,requestId:t}},n)}return errorBody(500,`internal`,`Something went wrong.`,t)}function queryParams(e,t){let n=e.searchParams,r=new Set;for(let e of n.keys()){if(!t.includes(e)||r.has(e))throw badRequest();r.add(e)}return n}function requireMutationGuard(e,t){let n=e.headers.get(`origin`);if(n===null||!t.allowedOrigins.has(n)||e.headers.get(`x-archive-request`)!==`1`)throw forbidden()}function corsHeaders(e,t){let n=e.headers.get(`origin`);return n===null||!t.allowedOrigins.has(n)?{}:{"access-control-allow-credentials":`true`,"access-control-allow-origin":n,vary:`Origin`}}function preflight(e,r){let i=e.headers.get(`origin`);return i===null||!r.allowedOrigins.has(i)?errorBody(403,`forbidden`,`That action is not allowed.`,crypto.randomUUID()):new Response(null,{headers:{"access-control-allow-credentials":`true`,"access-control-allow-headers":`Content-Type, ${t}, ${n}`,"access-control-allow-methods":`GET, POST, PUT, DELETE, OPTIONS`,"access-control-allow-origin":i,"access-control-max-age":`600`,vary:`Origin`},status:204})}var a=1e4,bodyTimeout=()=>new ApiError(408,`timeout`,`The request body took too long.`);async function readJsonBody(t){if((t.headers.get(`content-type`)??``).split(`;`,1)[0].trim().toLowerCase()!==`application/json`)throw new ApiError(415,`unsupported-media`,`JSON bodies only.`);let n=t.body?.getReader();if(!n)throw badRequest();let r=Date.now(),i,o=[],s=0;try{for(;;){let{done:t,value:c}=await Promise.race([n.read(),new Promise((e,t)=>{i=setTimeout(()=>t(bodyTimeout()),Math.max(0,a-(Date.now()-r)))})]);if(clearTimeout(i),i=void 0,t)break;if(s+=c.byteLength,s>e.requestBytes)throw new ApiError(413,`too-large`,`The request is too large.`);o.push(c)}}catch(e){throw n.cancel().catch(()=>void 0),e}finally{i!==void 0&&clearTimeout(i),n.releaseLock()}let c=new Uint8Array(s),l=0;for(let e of o)c.set(e,l),l+=e.byteLength;if(s===0)throw badRequest();try{return JSON.parse(new TextDecoder(`utf-8`,{fatal:!0}).decode(c))}catch{throw badRequest()}}var o=4096;function sessionToken(e){let t=e.headers.get(`cookie`);if(t===null)return null;if(t.length>o)throw badRequest();let n=[];for(let e of t.split(`;`)){let t=e.indexOf(`=`);t!==-1&&e.slice(0,t).trim()===`archive_session`&&n.push(e.slice(t+1).trim())}if(n.length>1)throw badRequest();return n.length===1&&n[0].length>0?n[0]:null}var expiredSessionCookie=e=>r(``,{maxAgeSeconds:0,secure:e});export{ApiError,a as HTTP_BODY_TIMEOUT_MS,badRequest,conflict,corsHeaders,errorBody,expiredSessionCookie,forbidden,invalid,json,i as noStore,notFound,preflight,queryParams,readJsonBody,requireMutationGuard,sessionToken,toErrorResponse,unauthenticated};
1
+ import { ARCHIVE_LIMITS as e, PUBLISHER_HEADER as t, REQUEST_MARKER_HEADER as n, sessionCookieValue as r } from "../contract.js";
2
+ //#region src/worker/http.ts
3
+ var ApiError = class extends Error {
4
+ code;
5
+ retryAfterSeconds;
6
+ status;
7
+ diagnostic;
8
+ constructor(e, t, n, r) {
9
+ super(n, r), this.name = "ApiError", this.code = t, this.retryAfterSeconds = r?.retryAfterSeconds, this.status = e, this.diagnostic = r?.diagnostic;
10
+ }
11
+ }, badRequest = (e = "The request is not valid.") => new ApiError(400, "bad-request", e), unauthenticated = () => new ApiError(401, "unauthenticated", "Sign-in is required."), forbidden = (e = "That action is not allowed.") => new ApiError(403, "forbidden", e), notFound = () => new ApiError(404, "not-found", "No such entry."), conflict = (e = "The entry changed since you saw it.") => new ApiError(409, "conflict", e), invalid = (e = "The payload is not valid.") => new ApiError(422, "invalid-payload", e), json = (e, t, n = {}) => new Response(JSON.stringify(t), {
12
+ headers: {
13
+ "content-type": "application/json; charset=utf-8",
14
+ ...n
15
+ },
16
+ status: e
17
+ }), i = { "cache-control": "no-store" }, errorBody = (e, t, n, r) => json(e, { error: {
18
+ code: t,
19
+ message: n,
20
+ requestId: r
21
+ } }, i);
22
+ function toErrorResponse(e, t) {
23
+ if (e instanceof ApiError) {
24
+ let n = e.retryAfterSeconds === void 0 ? i : {
25
+ ...i,
26
+ "retry-after": String(e.retryAfterSeconds)
27
+ };
28
+ return json(e.status, { error: {
29
+ code: e.code,
30
+ message: e.message,
31
+ requestId: t
32
+ } }, n);
33
+ }
34
+ return errorBody(500, "internal", "Something went wrong.", t);
35
+ }
36
+ function queryParams(e, t, n = []) {
37
+ let r = e.searchParams, i = /* @__PURE__ */ new Set();
38
+ for (let e of r.keys()) {
39
+ if (!t.includes(e) || i.has(e) && !n.includes(e)) throw badRequest();
40
+ i.add(e);
41
+ }
42
+ return r;
43
+ }
44
+ function requireMutationGuard(e, t) {
45
+ let n = e.headers.get("origin");
46
+ if (n === null || !t.allowedOrigins.has(n) || e.headers.get("x-archive-request") !== "1") throw forbidden();
47
+ }
48
+ function corsHeaders(e, t) {
49
+ let n = e.headers.get("origin");
50
+ return n === null || !t.allowedOrigins.has(n) ? {} : {
51
+ "access-control-allow-credentials": "true",
52
+ "access-control-allow-origin": n,
53
+ vary: "Origin"
54
+ };
55
+ }
56
+ function preflight(e, r) {
57
+ let i = e.headers.get("origin");
58
+ return i === null || !r.allowedOrigins.has(i) ? errorBody(403, "forbidden", "That action is not allowed.", crypto.randomUUID()) : new Response(null, {
59
+ headers: {
60
+ "access-control-allow-credentials": "true",
61
+ "access-control-allow-headers": `Content-Type, ${t}, ${n}`,
62
+ "access-control-allow-methods": "GET, POST, PUT, DELETE, OPTIONS",
63
+ "access-control-allow-origin": i,
64
+ "access-control-max-age": "600",
65
+ vary: "Origin"
66
+ },
67
+ status: 204
68
+ });
69
+ }
70
+ var a = 1e4, bodyTimeout = () => new ApiError(408, "timeout", "The request body took too long.");
71
+ async function readJsonBody(t) {
72
+ if ((t.headers.get("content-type") ?? "").split(";", 1)[0].trim().toLowerCase() !== "application/json") throw new ApiError(415, "unsupported-media", "JSON bodies only.");
73
+ let n = t.body?.getReader();
74
+ if (!n) throw badRequest();
75
+ let r = Date.now(), i, o = [], s = 0;
76
+ try {
77
+ for (;;) {
78
+ let { done: t, value: c } = await Promise.race([n.read(), new Promise((e, t) => {
79
+ i = setTimeout(() => t(bodyTimeout()), Math.max(0, a - (Date.now() - r)));
80
+ })]);
81
+ if (clearTimeout(i), i = void 0, t) break;
82
+ if (s += c.byteLength, s > e.requestBytes) throw new ApiError(413, "too-large", "The request is too large.");
83
+ o.push(c);
84
+ }
85
+ } catch (e) {
86
+ throw n.cancel().catch(() => void 0), e;
87
+ } finally {
88
+ i !== void 0 && clearTimeout(i), n.releaseLock();
89
+ }
90
+ let c = new Uint8Array(s), l = 0;
91
+ for (let e of o) c.set(e, l), l += e.byteLength;
92
+ if (s === 0) throw badRequest();
93
+ try {
94
+ return JSON.parse(new TextDecoder("utf-8", { fatal: !0 }).decode(c));
95
+ } catch {
96
+ throw badRequest();
97
+ }
98
+ }
99
+ var o = 4096;
100
+ function sessionToken(e) {
101
+ let t = e.headers.get("cookie");
102
+ if (t === null) return null;
103
+ if (t.length > o) throw badRequest();
104
+ let n = [];
105
+ for (let e of t.split(";")) {
106
+ let t = e.indexOf("=");
107
+ t !== -1 && e.slice(0, t).trim() === "archive_session" && n.push(e.slice(t + 1).trim());
108
+ }
109
+ if (n.length > 1) throw badRequest();
110
+ return n.length === 1 && n[0].length > 0 ? n[0] : null;
111
+ }
112
+ var expiredSessionCookie = (e) => r("", {
113
+ maxAgeSeconds: 0,
114
+ secure: e
115
+ });
116
+ //#endregion
117
+ export { ApiError, a as HTTP_BODY_TIMEOUT_MS, badRequest, conflict, corsHeaders, errorBody, expiredSessionCookie, forbidden, invalid, json, i as noStore, notFound, preflight, queryParams, readJsonBody, requireMutationGuard, sessionToken, toErrorResponse, unauthenticated };
118
+
2
119
  //# sourceMappingURL=http.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"http.js","names":[],"sources":["../../src/worker/http.ts"],"sourcesContent":["import {\n ARCHIVE_LIMITS,\n type ArchiveErrorCode,\n PUBLISHER_HEADER,\n REQUEST_MARKER_HEADER,\n SESSION_COOKIE_NAME,\n sessionCookieValue,\n} from '../contract';\nimport type { ApiConfig } from './env';\n\n/**\n * HTTP plumbing shared by every route: the one error shape, bounded body reads, strict\n * query/header guards and the CORS contract. Handlers throw ApiError; the entry point\n * sanitizes — no stack, SQL, body, or PII ever reaches a response. Internal to the\n * Worker: clients parse the wire envelope (`archiveErrorResponseSchema`), never this class.\n */\nexport class ApiError extends Error {\n readonly code: ArchiveErrorCode;\n readonly retryAfterSeconds?: number;\n readonly status: number;\n readonly diagnostic?: ArchiveDiagnostic;\n constructor(\n status: number,\n code: ArchiveErrorCode,\n message: string,\n opts?: ErrorOptions & { retryAfterSeconds?: number; diagnostic?: ArchiveDiagnostic },\n ) {\n super(message, opts);\n this.name = 'ApiError';\n this.code = code;\n this.retryAfterSeconds = opts?.retryAfterSeconds;\n this.status = status;\n this.diagnostic = opts?.diagnostic;\n }\n}\n\nexport type ArchiveDiagnostic =\n | { readonly kind: 'configuration'; readonly field: string }\n | { readonly kind: 'database' | 'record' | 'schema' | 'internal' | 'cache' | 'collection' };\n\nexport const badRequest = (message = 'The request is not valid.'): ApiError =>\n new ApiError(400, 'bad-request', message);\nexport const unauthenticated = (): ApiError => new ApiError(401, 'unauthenticated', 'Sign-in is required.');\nexport const forbidden = (message = 'That action is not allowed.'): ApiError => new ApiError(403, 'forbidden', message);\nexport const notFound = (): ApiError => new ApiError(404, 'not-found', 'No such entry.');\nexport const conflict = (message = 'The entry changed since you saw it.'): ApiError =>\n new ApiError(409, 'conflict', message);\nexport const invalid = (message = 'The payload is not valid.'): ApiError =>\n new ApiError(422, 'invalid-payload', message);\n\nexport const json = (status: number, body: unknown, headers: HeadersInit = {}): Response =>\n new Response(JSON.stringify(body), {\n headers: { 'content-type': 'application/json; charset=utf-8', ...headers },\n status,\n });\n\nexport const noStore = { 'cache-control': 'no-store' } as const;\n\nexport const errorBody = (status: number, code: string, message: string, requestId: string): Response =>\n json(status, { error: { code, message, requestId } }, noStore);\n\n/** Sanitizes any thrown value into the public error shape; unknowns become 500. */\nexport function toErrorResponse(error: unknown, requestId: string): Response {\n if (error instanceof ApiError) {\n const headers: HeadersInit =\n error.retryAfterSeconds === undefined ? noStore : { ...noStore, 'retry-after': String(error.retryAfterSeconds) };\n return json(error.status, { error: { code: error.code, message: error.message, requestId } }, headers);\n }\n return errorBody(500, 'internal', 'Something went wrong.', requestId);\n}\n\n/**\n * Rejects query strings with unknown or repeated parameters. Every accepted route\n * names its full allow-list; anything else is a client bug, not something to ignore.\n */\nexport function queryParams(url: URL, allowed: readonly string[]): URLSearchParams {\n const params = url.searchParams;\n const seen = new Set<string>();\n for (const key of params.keys()) {\n if (!allowed.includes(key) || seen.has(key)) throw badRequest();\n seen.add(key);\n }\n return params;\n}\n\n/** Exact-origin plus request-marker guard for every mutation: no wildcards, no nulls. */\nexport function requireMutationGuard(request: Request, config: ApiConfig): void {\n const origin = request.headers.get('origin');\n if (origin === null || !config.allowedOrigins.has(origin)) throw forbidden();\n if (request.headers.get(REQUEST_MARKER_HEADER) !== '1') throw forbidden();\n}\n\n/** CORS response headers for one already-allowed origin; absent for foreign origins. */\nexport function corsHeaders(request: Request, config: ApiConfig): Record<string, string> {\n const origin = request.headers.get('origin');\n if (origin === null || !config.allowedOrigins.has(origin)) return {};\n return {\n 'access-control-allow-credentials': 'true',\n 'access-control-allow-origin': origin,\n vary: 'Origin',\n };\n}\n\n/** Answers a preflight for an allowed origin; foreign or missing origins get 403. */\nexport function preflight(request: Request, config: ApiConfig): Response {\n const origin = request.headers.get('origin');\n if (origin === null || !config.allowedOrigins.has(origin)) {\n return errorBody(403, 'forbidden', 'That action is not allowed.', crypto.randomUUID());\n }\n return new Response(null, {\n headers: {\n 'access-control-allow-credentials': 'true',\n // Header names are case-insensitive; the values derive from the contract constants.\n 'access-control-allow-headers': `Content-Type, ${PUBLISHER_HEADER}, ${REQUEST_MARKER_HEADER}`,\n 'access-control-allow-methods': 'GET, POST, PUT, DELETE, OPTIONS',\n 'access-control-allow-origin': origin,\n 'access-control-max-age': '600',\n vary: 'Origin',\n },\n status: 204,\n });\n}\n\n/** Hard deadline for reading a request body; slow streams fail as 408. */\nexport const HTTP_BODY_TIMEOUT_MS = 10000;\n\nconst bodyTimeout = (): ApiError => new ApiError(408, 'timeout', 'The request body took too long.');\n\n/**\n * Reads a JSON body with a hard byte ceiling and a wall-clock deadline enforced while\n * streaming — a lying or absent Content-Length and a stalled sender are both cut off.\n * Wrong media types are 415, oversized 413, timed out 408, malformed JSON 400; schema\n * validation happens afterwards at the route boundary.\n */\nexport async function readJsonBody(request: Request): Promise<unknown> {\n // Exactly the application/json media type; parameters (charset etc.) may follow.\n const mediaType = (request.headers.get('content-type') ?? '').split(';', 1)[0].trim().toLowerCase();\n if (mediaType !== 'application/json') {\n throw new ApiError(415, 'unsupported-media', 'JSON bodies only.');\n }\n const reader = request.body?.getReader();\n if (!reader) throw badRequest();\n const startedAt = Date.now();\n let timeoutId: ReturnType<typeof setTimeout> | undefined;\n const chunks: Uint8Array[] = [];\n let total = 0;\n try {\n for (;;) {\n const { done, value } = await Promise.race([\n reader.read(),\n new Promise<never>((_r, reject) => {\n timeoutId = setTimeout(\n () => reject(bodyTimeout()),\n Math.max(0, HTTP_BODY_TIMEOUT_MS - (Date.now() - startedAt)),\n );\n }),\n ]);\n clearTimeout(timeoutId);\n timeoutId = undefined;\n if (done) break;\n total += value.byteLength;\n if (total > ARCHIVE_LIMITS.requestBytes) throw new ApiError(413, 'too-large', 'The request is too large.');\n chunks.push(value);\n }\n } catch (error) {\n // Release the remainder so the failure isn't blocked on a stalled sender.\n reader.cancel().catch(() => undefined);\n throw error;\n } finally {\n if (timeoutId !== undefined) clearTimeout(timeoutId);\n reader.releaseLock();\n }\n const merged = new Uint8Array(total);\n let offset = 0;\n for (const chunk of chunks) {\n merged.set(chunk, offset);\n offset += chunk.byteLength;\n }\n if (total === 0) throw badRequest();\n try {\n return JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(merged));\n } catch {\n throw badRequest();\n }\n}\n\nconst SESSION_COOKIE_MAX = 4096;\n\n/**\n * Reads the session cookie. A duplicate cookie name or an oversized header is a\n * malformed request, not a missing session: callers distinguish null (anonymous)\n * from a token to verify.\n */\nexport function sessionToken(request: Request): string | null {\n const header = request.headers.get('cookie');\n if (header === null) return null;\n if (header.length > SESSION_COOKIE_MAX) throw badRequest();\n const matches: string[] = [];\n for (const part of header.split(';')) {\n const eq = part.indexOf('=');\n if (eq === -1) continue;\n if (part.slice(0, eq).trim() === SESSION_COOKIE_NAME) matches.push(part.slice(eq + 1).trim());\n }\n if (matches.length > 1) throw badRequest();\n return matches.length === 1 && matches[0].length > 0 ? matches[0] : null;\n}\n\n/** The expired-session Set-Cookie value the sign-out route and account erasure send. */\nexport const expiredSessionCookie = (secure: boolean): string => sessionCookieValue('', { maxAgeSeconds: 0, secure });\n"],"mappings":"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"}
1
+ {"version":3,"file":"http.js","names":["message","opts","code","status","body","headers","noStore","error","requestId","params","url","seen","key","allowed","repeatable","origin","request","config","PUBLISHER_HEADER","REQUEST_MARKER_HEADER","HTTP_BODY_TIMEOUT_MS","reader","startedAt","timeoutId","chunks","total","reject","done","value","ARCHIVE_LIMITS","merged","offset","chunk","SESSION_COOKIE_MAX","header","matches","part","eq","sessionCookieValue"],"sources":["../../src/worker/http.ts"],"sourcesContent":["import {\n ARCHIVE_LIMITS,\n type ArchiveErrorCode,\n PUBLISHER_HEADER,\n REQUEST_MARKER_HEADER,\n SESSION_COOKIE_NAME,\n sessionCookieValue,\n} from '../contract';\nimport type { ApiConfig } from './env';\n\n/**\n * HTTP plumbing shared by every route: the one error shape, bounded body reads, strict\n * query/header guards and the CORS contract. Handlers throw ApiError; the entry point\n * sanitizes — no stack, SQL, body, or PII ever reaches a response. Internal to the\n * Worker: clients parse the wire envelope (`archiveErrorResponseSchema`), never this class.\n */\nexport class ApiError extends Error {\n readonly code: ArchiveErrorCode;\n readonly retryAfterSeconds?: number;\n readonly status: number;\n readonly diagnostic?: ArchiveDiagnostic;\n constructor(\n status: number,\n code: ArchiveErrorCode,\n message: string,\n opts?: ErrorOptions & { retryAfterSeconds?: number; diagnostic?: ArchiveDiagnostic },\n ) {\n super(message, opts);\n this.name = 'ApiError';\n this.code = code;\n this.retryAfterSeconds = opts?.retryAfterSeconds;\n this.status = status;\n this.diagnostic = opts?.diagnostic;\n }\n}\n\nexport type ArchiveDiagnostic =\n | { readonly kind: 'configuration'; readonly field: string }\n | { readonly kind: 'database' | 'record' | 'schema' | 'internal' | 'cache' | 'collection' };\n\nexport const badRequest = (message = 'The request is not valid.'): ApiError =>\n new ApiError(400, 'bad-request', message);\nexport const unauthenticated = (): ApiError => new ApiError(401, 'unauthenticated', 'Sign-in is required.');\nexport const forbidden = (message = 'That action is not allowed.'): ApiError => new ApiError(403, 'forbidden', message);\nexport const notFound = (): ApiError => new ApiError(404, 'not-found', 'No such entry.');\nexport const conflict = (message = 'The entry changed since you saw it.'): ApiError =>\n new ApiError(409, 'conflict', message);\nexport const invalid = (message = 'The payload is not valid.'): ApiError =>\n new ApiError(422, 'invalid-payload', message);\n\nexport const json = (status: number, body: unknown, headers: HeadersInit = {}): Response =>\n new Response(JSON.stringify(body), {\n headers: { 'content-type': 'application/json; charset=utf-8', ...headers },\n status,\n });\n\nexport const noStore = { 'cache-control': 'no-store' } as const;\n\nexport const errorBody = (status: number, code: string, message: string, requestId: string): Response =>\n json(status, { error: { code, message, requestId } }, noStore);\n\n/** Sanitizes any thrown value into the public error shape; unknowns become 500. */\nexport function toErrorResponse(error: unknown, requestId: string): Response {\n if (error instanceof ApiError) {\n const headers: HeadersInit =\n error.retryAfterSeconds === undefined ? noStore : { ...noStore, 'retry-after': String(error.retryAfterSeconds) };\n return json(error.status, { error: { code: error.code, message: error.message, requestId } }, headers);\n }\n return errorBody(500, 'internal', 'Something went wrong.', requestId);\n}\n\n/**\n * Rejects query strings with unknown or repeated parameters. Every accepted route\n * names its full allow-list; anything else is a client bug, not something to ignore.\n * Keys listed in `repeatable` may carry several values (read back with `getAll`);\n * every other key stays single-valued.\n */\nexport function queryParams(url: URL, allowed: readonly string[], repeatable: readonly string[] = []): URLSearchParams {\n const params = url.searchParams;\n const seen = new Set<string>();\n for (const key of params.keys()) {\n if (!allowed.includes(key) || (seen.has(key) && !repeatable.includes(key))) throw badRequest();\n seen.add(key);\n }\n return params;\n}\n\n/** Exact-origin plus request-marker guard for every mutation: no wildcards, no nulls. */\nexport function requireMutationGuard(request: Request, config: ApiConfig): void {\n const origin = request.headers.get('origin');\n if (origin === null || !config.allowedOrigins.has(origin)) throw forbidden();\n if (request.headers.get(REQUEST_MARKER_HEADER) !== '1') throw forbidden();\n}\n\n/** CORS response headers for one already-allowed origin; absent for foreign origins. */\nexport function corsHeaders(request: Request, config: ApiConfig): Record<string, string> {\n const origin = request.headers.get('origin');\n if (origin === null || !config.allowedOrigins.has(origin)) return {};\n return {\n 'access-control-allow-credentials': 'true',\n 'access-control-allow-origin': origin,\n vary: 'Origin',\n };\n}\n\n/** Answers a preflight for an allowed origin; foreign or missing origins get 403. */\nexport function preflight(request: Request, config: ApiConfig): Response {\n const origin = request.headers.get('origin');\n if (origin === null || !config.allowedOrigins.has(origin)) {\n return errorBody(403, 'forbidden', 'That action is not allowed.', crypto.randomUUID());\n }\n return new Response(null, {\n headers: {\n 'access-control-allow-credentials': 'true',\n // Header names are case-insensitive; the values derive from the contract constants.\n 'access-control-allow-headers': `Content-Type, ${PUBLISHER_HEADER}, ${REQUEST_MARKER_HEADER}`,\n 'access-control-allow-methods': 'GET, POST, PUT, DELETE, OPTIONS',\n 'access-control-allow-origin': origin,\n 'access-control-max-age': '600',\n vary: 'Origin',\n },\n status: 204,\n });\n}\n\n/** Hard deadline for reading a request body; slow streams fail as 408. */\nexport const HTTP_BODY_TIMEOUT_MS = 10000;\n\nconst bodyTimeout = (): ApiError => new ApiError(408, 'timeout', 'The request body took too long.');\n\n/**\n * Reads a JSON body with a hard byte ceiling and a wall-clock deadline enforced while\n * streaming — a lying or absent Content-Length and a stalled sender are both cut off.\n * Wrong media types are 415, oversized 413, timed out 408, malformed JSON 400; schema\n * validation happens afterwards at the route boundary.\n */\nexport async function readJsonBody(request: Request): Promise<unknown> {\n // Exactly the application/json media type; parameters (charset etc.) may follow.\n const mediaType = (request.headers.get('content-type') ?? '').split(';', 1)[0].trim().toLowerCase();\n if (mediaType !== 'application/json') {\n throw new ApiError(415, 'unsupported-media', 'JSON bodies only.');\n }\n const reader = request.body?.getReader();\n if (!reader) throw badRequest();\n const startedAt = Date.now();\n let timeoutId: ReturnType<typeof setTimeout> | undefined;\n const chunks: Uint8Array[] = [];\n let total = 0;\n try {\n for (;;) {\n const { done, value } = await Promise.race([\n reader.read(),\n new Promise<never>((_r, reject) => {\n timeoutId = setTimeout(\n () => reject(bodyTimeout()),\n Math.max(0, HTTP_BODY_TIMEOUT_MS - (Date.now() - startedAt)),\n );\n }),\n ]);\n clearTimeout(timeoutId);\n timeoutId = undefined;\n if (done) break;\n total += value.byteLength;\n if (total > ARCHIVE_LIMITS.requestBytes) throw new ApiError(413, 'too-large', 'The request is too large.');\n chunks.push(value);\n }\n } catch (error) {\n // Release the remainder so the failure isn't blocked on a stalled sender.\n reader.cancel().catch(() => undefined);\n throw error;\n } finally {\n if (timeoutId !== undefined) clearTimeout(timeoutId);\n reader.releaseLock();\n }\n const merged = new Uint8Array(total);\n let offset = 0;\n for (const chunk of chunks) {\n merged.set(chunk, offset);\n offset += chunk.byteLength;\n }\n if (total === 0) throw badRequest();\n try {\n return JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(merged));\n } catch {\n throw badRequest();\n }\n}\n\nconst SESSION_COOKIE_MAX = 4096;\n\n/**\n * Reads the session cookie. A duplicate cookie name or an oversized header is a\n * malformed request, not a missing session: callers distinguish null (anonymous)\n * from a token to verify.\n */\nexport function sessionToken(request: Request): string | null {\n const header = request.headers.get('cookie');\n if (header === null) return null;\n if (header.length > SESSION_COOKIE_MAX) throw badRequest();\n const matches: string[] = [];\n for (const part of header.split(';')) {\n const eq = part.indexOf('=');\n if (eq === -1) continue;\n if (part.slice(0, eq).trim() === SESSION_COOKIE_NAME) matches.push(part.slice(eq + 1).trim());\n }\n if (matches.length > 1) throw badRequest();\n return matches.length === 1 && matches[0].length > 0 ? matches[0] : null;\n}\n\n/** The expired-session Set-Cookie value the sign-out route and account erasure send. */\nexport const expiredSessionCookie = (secure: boolean): string => sessionCookieValue('', { maxAgeSeconds: 0, secure });\n"],"mappings":"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"}