@vielzeug/archive 0.0.0-stage → 26.10.7

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 (118) hide show
  1. package/README.md +81 -2
  2. package/dist/_crypto.cjs +2 -0
  3. package/dist/_crypto.cjs.map +1 -0
  4. package/dist/_crypto.d.ts +7 -0
  5. package/dist/_crypto.d.ts.map +1 -0
  6. package/dist/_crypto.js +2 -0
  7. package/dist/_crypto.js.map +1 -0
  8. package/dist/_json.cjs +2 -0
  9. package/dist/_json.cjs.map +1 -0
  10. package/dist/_json.d.ts +4 -0
  11. package/dist/_json.d.ts.map +1 -0
  12. package/dist/_json.js +2 -0
  13. package/dist/_json.js.map +1 -0
  14. package/dist/auth/config.cjs +2 -0
  15. package/dist/auth/config.cjs.map +1 -0
  16. package/dist/auth/config.d.ts +88 -0
  17. package/dist/auth/config.d.ts.map +1 -0
  18. package/dist/auth/config.js +2 -0
  19. package/dist/auth/config.js.map +1 -0
  20. package/dist/auth/errors.cjs +2 -0
  21. package/dist/auth/errors.cjs.map +1 -0
  22. package/dist/auth/errors.d.ts +10 -0
  23. package/dist/auth/errors.d.ts.map +1 -0
  24. package/dist/auth/errors.js +2 -0
  25. package/dist/auth/errors.js.map +1 -0
  26. package/dist/auth/store.cjs +19 -0
  27. package/dist/auth/store.cjs.map +1 -0
  28. package/dist/auth/store.d.ts +45 -0
  29. package/dist/auth/store.d.ts.map +1 -0
  30. package/dist/auth/store.js +19 -0
  31. package/dist/auth/store.js.map +1 -0
  32. package/dist/auth/worker.cjs +16 -0
  33. package/dist/auth/worker.cjs.map +1 -0
  34. package/dist/auth/worker.d.ts +47 -0
  35. package/dist/auth/worker.d.ts.map +1 -0
  36. package/dist/auth/worker.js +16 -0
  37. package/dist/auth/worker.js.map +1 -0
  38. package/dist/auth.cjs +1 -0
  39. package/dist/auth.d.ts +5 -0
  40. package/dist/auth.d.ts.map +1 -0
  41. package/dist/auth.js +1 -0
  42. package/dist/collection.cjs +2 -0
  43. package/dist/collection.cjs.map +1 -0
  44. package/dist/collection.d.ts +139 -0
  45. package/dist/collection.d.ts.map +1 -0
  46. package/dist/collection.js +2 -0
  47. package/dist/collection.js.map +1 -0
  48. package/dist/contract.cjs +2 -0
  49. package/dist/contract.cjs.map +1 -0
  50. package/dist/contract.d.ts +228 -0
  51. package/dist/contract.d.ts.map +1 -0
  52. package/dist/contract.js +2 -0
  53. package/dist/contract.js.map +1 -0
  54. package/dist/errors.cjs +2 -0
  55. package/dist/errors.cjs.map +1 -0
  56. package/dist/errors.d.ts +12 -0
  57. package/dist/errors.d.ts.map +1 -0
  58. package/dist/errors.js +2 -0
  59. package/dist/errors.js.map +1 -0
  60. package/dist/index.cjs +1 -0
  61. package/dist/index.d.ts +4 -0
  62. package/dist/index.d.ts.map +1 -0
  63. package/dist/index.js +1 -0
  64. package/dist/worker/_pagination.cjs +2 -0
  65. package/dist/worker/_pagination.cjs.map +1 -0
  66. package/dist/worker/_pagination.d.ts +31 -0
  67. package/dist/worker/_pagination.d.ts.map +1 -0
  68. package/dist/worker/_pagination.js +2 -0
  69. package/dist/worker/_pagination.js.map +1 -0
  70. package/dist/worker/_service.cjs +2 -0
  71. package/dist/worker/_service.cjs.map +1 -0
  72. package/dist/worker/_service.d.ts +5 -0
  73. package/dist/worker/_service.d.ts.map +1 -0
  74. package/dist/worker/_service.js +2 -0
  75. package/dist/worker/_service.js.map +1 -0
  76. package/dist/worker/auth.cjs +2 -0
  77. package/dist/worker/auth.cjs.map +1 -0
  78. package/dist/worker/auth.d.ts +34 -0
  79. package/dist/worker/auth.d.ts.map +1 -0
  80. package/dist/worker/auth.js +2 -0
  81. package/dist/worker/auth.js.map +1 -0
  82. package/dist/worker/env.cjs +2 -0
  83. package/dist/worker/env.cjs.map +1 -0
  84. package/dist/worker/env.d.ts +73 -0
  85. package/dist/worker/env.d.ts.map +1 -0
  86. package/dist/worker/env.js +2 -0
  87. package/dist/worker/env.js.map +1 -0
  88. package/dist/worker/http.cjs +2 -0
  89. package/dist/worker/http.cjs.map +1 -0
  90. package/dist/worker/http.d.ts +66 -0
  91. package/dist/worker/http.d.ts.map +1 -0
  92. package/dist/worker/http.js +2 -0
  93. package/dist/worker/http.js.map +1 -0
  94. package/dist/worker/publications.cjs +2 -0
  95. package/dist/worker/publications.cjs.map +1 -0
  96. package/dist/worker/publications.d.ts +60 -0
  97. package/dist/worker/publications.d.ts.map +1 -0
  98. package/dist/worker/publications.js +2 -0
  99. package/dist/worker/publications.js.map +1 -0
  100. package/dist/worker/sql.cjs +60 -0
  101. package/dist/worker/sql.cjs.map +1 -0
  102. package/dist/worker/sql.d.ts +87 -0
  103. package/dist/worker/sql.d.ts.map +1 -0
  104. package/dist/worker/sql.js +60 -0
  105. package/dist/worker/sql.js.map +1 -0
  106. package/dist/worker/worker.cjs +2 -0
  107. package/dist/worker/worker.cjs.map +1 -0
  108. package/dist/worker/worker.d.ts +72 -0
  109. package/dist/worker/worker.d.ts.map +1 -0
  110. package/dist/worker/worker.js +2 -0
  111. package/dist/worker/worker.js.map +1 -0
  112. package/dist/worker.cjs +1 -0
  113. package/dist/worker.d.ts +4 -0
  114. package/dist/worker.d.ts.map +1 -0
  115. package/dist/worker.js +1 -0
  116. package/migrations/0001_community_archive.sql +173 -0
  117. package/migrations/auth/0001_auth.sql +65 -0
  118. package/package.json +72 -4
@@ -0,0 +1 @@
1
+ {"version":3,"file":"auth.cjs","names":[],"sources":["../../src/worker/auth.ts"],"sourcesContent":["import { errors, type JWTPayload, jwtVerify } from 'jose';\nimport {\n ARCHIVE_OPERATOR_ROLE,\n archiveErrorResponseSchema,\n archiveOkSchema,\n isWellFormedText,\n OPERATOR_SESSION_MAX_AGE_SECONDS,\n SESSION_MAX_AGE_SECONDS,\n} from '../contract';\nimport { serviceRequest } from './_service';\nimport type { ApiConfig, ArchiveWorkerEnv } from './env';\nimport { ApiError, sessionToken, unauthenticated } from './http';\nimport { SQL } from './sql';\n\n/**\n * Session authentication. The Worker trusts exactly one source: the archive_session\n * HttpOnly cookie holding an RS256 JWT from the configured issuer. Tokens are verified\n * before any database read and are never echoed into DTOs; the publisher row maps the\n * opaque (issuer, subject) pair to the public publisher id. An optional `roles` claim\n * from the same trusted issuer grants operator authority (moderation routes).\n */\nexport interface VerifiedSubject {\n issuer: string;\n subject: string;\n}\n\nexport interface Publisher {\n displayName: string;\n id: string;\n}\n\nexport interface Session {\n /** True only when the verified token's `roles` claim carries the operator role. */\n operator: boolean;\n /** The registered publisher row; null while the verified user has not registered. */\n publisher: Publisher | null;\n subject: VerifiedSubject;\n}\n\nconst JWT_MAX_AGE_SECONDS = SESSION_MAX_AGE_SECONDS;\nexport const JWT_CLOCK_TOLERANCE_SECONDS = 5;\nconst SUBJECT_MAX_CHARS = 256;\n\nconst authUnavailable = (): ApiError => new ApiError(503, 'auth-unavailable', 'Sign-in is currently unavailable.');\n\n/**\n * Verifies the session cookie; returns null for anonymous requests.\n * Token/signature/claim problems are 401; JWKS provider faults (fetch failure,\n * timeout, undecodable document) are 503 `auth-unavailable` — an outage is not a\n * reason to treat the caller as signed out.\n */\nexport async function verifySession(\n request: Request,\n config: ApiConfig,\n env: ArchiveWorkerEnv,\n allowErasureRetry = false,\n): Promise<Session | null> {\n const token = sessionToken(request);\n if (token === null) return null;\n\n let payload: JWTPayload;\n try {\n const result = await jwtVerify(token, config.jwks, {\n algorithms: ['RS256'],\n audience: config.audience,\n clockTolerance: JWT_CLOCK_TOLERANCE_SECONDS,\n issuer: config.issuer,\n maxTokenAge: JWT_MAX_AGE_SECONDS,\n requiredClaims: ['sub', 'iss', 'aud', 'iat', 'exp', 'sessionVersion'],\n });\n payload = result.payload;\n } catch (error) {\n // Provider faults are 503: a JWKS outage must never read as a signed-out user.\n if (\n error instanceof errors.JWKSTimeout ||\n error instanceof errors.JWKSInvalid ||\n error instanceof errors.JWKSMultipleMatchingKeys\n ) {\n throw authUnavailable();\n }\n // Everything else in the JOSE family — malformed, expired, wrong alg/aud/iss,\n // bad signature, no matching key — is a token problem.\n if (error instanceof errors.JOSEError) throw unauthenticated();\n // Network fetch failures surface outside the JOSE error family.\n throw authUnavailable();\n }\n const subject = payload.sub;\n const issuer = payload.iss;\n const iat = payload.iat;\n const roles = payload.roles;\n if (!Number.isSafeInteger(payload.sessionVersion) || (payload.sessionVersion as number) < 0) throw unauthenticated();\n // Least authority: a malformed roles claim grants nothing, and operator authority\n // only ever comes from this verified, audience- and issuer-checked token.\n const operator = Array.isArray(roles) && roles.includes(ARCHIVE_OPERATOR_ROLE);\n // Malformed subjects must reject outright: lone surrogates/control characters\n // could otherwise collapse distinct claims into the same stored identity.\n if (\n typeof subject !== 'string' ||\n subject.length === 0 ||\n subject.length > SUBJECT_MAX_CHARS ||\n !isWellFormedText(subject)\n ) {\n throw unauthenticated();\n }\n if (typeof issuer !== 'string') throw unauthenticated();\n // A token claiming to be issued in the future beyond clock tolerance is a claims failure.\n if (typeof iat === 'number' && iat * 1000 > Date.now() + JWT_CLOCK_TOLERANCE_SECONDS * 1000) {\n throw unauthenticated();\n }\n if (\n operator &&\n (typeof iat !== 'number' ||\n typeof payload.exp !== 'number' ||\n payload.exp - iat > OPERATOR_SESSION_MAX_AGE_SECONDS ||\n Date.now() / 1000 - iat > OPERATOR_SESSION_MAX_AGE_SECONDS + JWT_CLOCK_TOLERANCE_SECONDS)\n )\n throw unauthenticated();\n\n // Only a previously tombstoned principal can bypass live validation to finish\n // partial erasure. Its profile cannot be re-created while the tombstone lives.\n const erasureRetry =\n allowErasureRetry && (await env.DB.prepare(SQL.revokedSubject).bind(issuer, subject, Date.now()).first()) !== null;\n if (!erasureRetry) {\n try {\n await serviceRequest(\n env.AUTH_ISSUER_SERVICE,\n new Request(new URL('/api/v1/auth/session', config.issuer), {\n headers: { cookie: `archive_session=${token}` },\n redirect: 'manual',\n }),\n async (response) => {\n const body: unknown = await response.json();\n if (response.status === 200 && archiveOkSchema.safeParse(body).success) return;\n const failure = archiveErrorResponseSchema.safeParse(body);\n if (response.status === 401 && failure.success && failure.data.error.code === 'unauthenticated')\n throw unauthenticated();\n throw authUnavailable();\n },\n );\n } catch (error) {\n if (error instanceof ApiError && error.status === 401) throw error;\n throw authUnavailable();\n }\n }\n if (!allowErasureRetry) {\n const revoked = await env.DB.prepare(SQL.revokedSubject).bind(issuer, subject, Date.now()).first();\n if (revoked !== null) throw unauthenticated();\n }\n const row = await env.DB.prepare(SQL.publisher).bind(issuer, subject).first<{\n display_name: string;\n id: string;\n }>();\n return {\n operator,\n publisher: row === null ? null : { displayName: row.display_name, id: row.id },\n subject: { issuer, subject },\n };\n}\n\n/** Session required for private routes; anonymous gets 401. */\nexport function requireSession(session: Session | null): Session {\n if (session === null) throw unauthenticated();\n return session;\n}\n"],"mappings":"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"}
@@ -0,0 +1,34 @@
1
+ import type { ApiConfig, ArchiveWorkerEnv } from './env';
2
+ /**
3
+ * Session authentication. The Worker trusts exactly one source: the archive_session
4
+ * HttpOnly cookie holding an RS256 JWT from the configured issuer. Tokens are verified
5
+ * before any database read and are never echoed into DTOs; the publisher row maps the
6
+ * opaque (issuer, subject) pair to the public publisher id. An optional `roles` claim
7
+ * from the same trusted issuer grants operator authority (moderation routes).
8
+ */
9
+ export interface VerifiedSubject {
10
+ issuer: string;
11
+ subject: string;
12
+ }
13
+ export interface Publisher {
14
+ displayName: string;
15
+ id: string;
16
+ }
17
+ export interface Session {
18
+ /** True only when the verified token's `roles` claim carries the operator role. */
19
+ operator: boolean;
20
+ /** The registered publisher row; null while the verified user has not registered. */
21
+ publisher: Publisher | null;
22
+ subject: VerifiedSubject;
23
+ }
24
+ export declare const JWT_CLOCK_TOLERANCE_SECONDS = 5;
25
+ /**
26
+ * Verifies the session cookie; returns null for anonymous requests.
27
+ * Token/signature/claim problems are 401; JWKS provider faults (fetch failure,
28
+ * timeout, undecodable document) are 503 `auth-unavailable` — an outage is not a
29
+ * reason to treat the caller as signed out.
30
+ */
31
+ export declare function verifySession(request: Request, config: ApiConfig, env: ArchiveWorkerEnv, allowErasureRetry?: boolean): Promise<Session | null>;
32
+ /** Session required for private routes; anonymous gets 401. */
33
+ export declare function requireSession(session: Session | null): Session;
34
+ //# sourceMappingURL=auth.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"auth.d.ts","sourceRoot":"","sources":["../../src/worker/auth.ts"],"names":[],"mappings":"AAUA,OAAO,KAAK,EAAE,SAAS,EAAE,gBAAgB,EAAE,MAAM,OAAO,CAAC;AAIzD;;;;;;GAMG;AACH,MAAM,WAAW,eAAe;IAC9B,MAAM,EAAE,MAAM,CAAC;IACf,OAAO,EAAE,MAAM,CAAC;CACjB;AAED,MAAM,WAAW,SAAS;IACxB,WAAW,EAAE,MAAM,CAAC;IACpB,EAAE,EAAE,MAAM,CAAC;CACZ;AAED,MAAM,WAAW,OAAO;IACtB,mFAAmF;IACnF,QAAQ,EAAE,OAAO,CAAC;IAClB,qFAAqF;IACrF,SAAS,EAAE,SAAS,GAAG,IAAI,CAAC;IAC5B,OAAO,EAAE,eAAe,CAAC;CAC1B;AAGD,eAAO,MAAM,2BAA2B,IAAI,CAAC;AAK7C;;;;;GAKG;AACH,wBAAsB,aAAa,CACjC,OAAO,EAAE,OAAO,EAChB,MAAM,EAAE,SAAS,EACjB,GAAG,EAAE,gBAAgB,EACrB,iBAAiB,UAAQ,GACxB,OAAO,CAAC,OAAO,GAAG,IAAI,CAAC,CAqGzB;AAED,+DAA+D;AAC/D,wBAAgB,cAAc,CAAC,OAAO,EAAE,OAAO,GAAG,IAAI,GAAG,OAAO,CAG/D"}
@@ -0,0 +1,2 @@
1
+ import{SESSION_MAX_AGE_SECONDS as e,archiveErrorResponseSchema as t,archiveOkSchema as n,isWellFormedText as r}from"../contract.js";import{serviceRequest as i}from"./_service.js";import{ApiError as a,sessionToken as o,unauthenticated as s}from"./http.js";import{SQL as c}from"./sql.js";import{errors as l,jwtVerify as u}from"jose";var d=e,f=256,authUnavailable=()=>new a(503,`auth-unavailable`,`Sign-in is currently unavailable.`);async function verifySession(e,p,m,h=!1){let g=o(e);if(g===null)return null;let _;try{_=(await u(g,p.jwks,{algorithms:[`RS256`],audience:p.audience,clockTolerance:5,issuer:p.issuer,maxTokenAge:d,requiredClaims:[`sub`,`iss`,`aud`,`iat`,`exp`,`sessionVersion`]})).payload}catch(e){throw e instanceof l.JWKSTimeout||e instanceof l.JWKSInvalid||e instanceof l.JWKSMultipleMatchingKeys?authUnavailable():e instanceof l.JOSEError?s():authUnavailable()}let v=_.sub,y=_.iss,b=_.iat,x=_.roles;if(!Number.isSafeInteger(_.sessionVersion)||_.sessionVersion<0)throw s();let S=Array.isArray(x)&&x.includes(`archive-operator`);if(typeof v!=`string`||v.length===0||v.length>f||!r(v)||typeof y!=`string`||typeof b==`number`&&b*1e3>Date.now()+5e3||S&&(typeof b!=`number`||typeof _.exp!=`number`||_.exp-b>900||Date.now()/1e3-b>905))throw s();if(!(h&&await m.DB.prepare(c.revokedSubject).bind(y,v,Date.now()).first()!==null))try{await i(m.AUTH_ISSUER_SERVICE,new Request(new URL(`/api/v1/auth/session`,p.issuer),{headers:{cookie:`archive_session=${g}`},redirect:`manual`}),async e=>{let r=await e.json();if(e.status===200&&n.safeParse(r).success)return;let i=t.safeParse(r);throw e.status===401&&i.success&&i.data.error.code===`unauthenticated`?s():authUnavailable()})}catch(e){throw e instanceof a&&e.status===401?e:authUnavailable()}if(!h&&await m.DB.prepare(c.revokedSubject).bind(y,v,Date.now()).first()!==null)throw s();let C=await m.DB.prepare(c.publisher).bind(y,v).first();return{operator:S,publisher:C===null?null:{displayName:C.display_name,id:C.id},subject:{issuer:y,subject:v}}}function requireSession(e){if(e===null)throw s();return e}export{requireSession,verifySession};
2
+ //# sourceMappingURL=auth.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"auth.js","names":[],"sources":["../../src/worker/auth.ts"],"sourcesContent":["import { errors, type JWTPayload, jwtVerify } from 'jose';\nimport {\n ARCHIVE_OPERATOR_ROLE,\n archiveErrorResponseSchema,\n archiveOkSchema,\n isWellFormedText,\n OPERATOR_SESSION_MAX_AGE_SECONDS,\n SESSION_MAX_AGE_SECONDS,\n} from '../contract';\nimport { serviceRequest } from './_service';\nimport type { ApiConfig, ArchiveWorkerEnv } from './env';\nimport { ApiError, sessionToken, unauthenticated } from './http';\nimport { SQL } from './sql';\n\n/**\n * Session authentication. The Worker trusts exactly one source: the archive_session\n * HttpOnly cookie holding an RS256 JWT from the configured issuer. Tokens are verified\n * before any database read and are never echoed into DTOs; the publisher row maps the\n * opaque (issuer, subject) pair to the public publisher id. An optional `roles` claim\n * from the same trusted issuer grants operator authority (moderation routes).\n */\nexport interface VerifiedSubject {\n issuer: string;\n subject: string;\n}\n\nexport interface Publisher {\n displayName: string;\n id: string;\n}\n\nexport interface Session {\n /** True only when the verified token's `roles` claim carries the operator role. */\n operator: boolean;\n /** The registered publisher row; null while the verified user has not registered. */\n publisher: Publisher | null;\n subject: VerifiedSubject;\n}\n\nconst JWT_MAX_AGE_SECONDS = SESSION_MAX_AGE_SECONDS;\nexport const JWT_CLOCK_TOLERANCE_SECONDS = 5;\nconst SUBJECT_MAX_CHARS = 256;\n\nconst authUnavailable = (): ApiError => new ApiError(503, 'auth-unavailable', 'Sign-in is currently unavailable.');\n\n/**\n * Verifies the session cookie; returns null for anonymous requests.\n * Token/signature/claim problems are 401; JWKS provider faults (fetch failure,\n * timeout, undecodable document) are 503 `auth-unavailable` — an outage is not a\n * reason to treat the caller as signed out.\n */\nexport async function verifySession(\n request: Request,\n config: ApiConfig,\n env: ArchiveWorkerEnv,\n allowErasureRetry = false,\n): Promise<Session | null> {\n const token = sessionToken(request);\n if (token === null) return null;\n\n let payload: JWTPayload;\n try {\n const result = await jwtVerify(token, config.jwks, {\n algorithms: ['RS256'],\n audience: config.audience,\n clockTolerance: JWT_CLOCK_TOLERANCE_SECONDS,\n issuer: config.issuer,\n maxTokenAge: JWT_MAX_AGE_SECONDS,\n requiredClaims: ['sub', 'iss', 'aud', 'iat', 'exp', 'sessionVersion'],\n });\n payload = result.payload;\n } catch (error) {\n // Provider faults are 503: a JWKS outage must never read as a signed-out user.\n if (\n error instanceof errors.JWKSTimeout ||\n error instanceof errors.JWKSInvalid ||\n error instanceof errors.JWKSMultipleMatchingKeys\n ) {\n throw authUnavailable();\n }\n // Everything else in the JOSE family — malformed, expired, wrong alg/aud/iss,\n // bad signature, no matching key — is a token problem.\n if (error instanceof errors.JOSEError) throw unauthenticated();\n // Network fetch failures surface outside the JOSE error family.\n throw authUnavailable();\n }\n const subject = payload.sub;\n const issuer = payload.iss;\n const iat = payload.iat;\n const roles = payload.roles;\n if (!Number.isSafeInteger(payload.sessionVersion) || (payload.sessionVersion as number) < 0) throw unauthenticated();\n // Least authority: a malformed roles claim grants nothing, and operator authority\n // only ever comes from this verified, audience- and issuer-checked token.\n const operator = Array.isArray(roles) && roles.includes(ARCHIVE_OPERATOR_ROLE);\n // Malformed subjects must reject outright: lone surrogates/control characters\n // could otherwise collapse distinct claims into the same stored identity.\n if (\n typeof subject !== 'string' ||\n subject.length === 0 ||\n subject.length > SUBJECT_MAX_CHARS ||\n !isWellFormedText(subject)\n ) {\n throw unauthenticated();\n }\n if (typeof issuer !== 'string') throw unauthenticated();\n // A token claiming to be issued in the future beyond clock tolerance is a claims failure.\n if (typeof iat === 'number' && iat * 1000 > Date.now() + JWT_CLOCK_TOLERANCE_SECONDS * 1000) {\n throw unauthenticated();\n }\n if (\n operator &&\n (typeof iat !== 'number' ||\n typeof payload.exp !== 'number' ||\n payload.exp - iat > OPERATOR_SESSION_MAX_AGE_SECONDS ||\n Date.now() / 1000 - iat > OPERATOR_SESSION_MAX_AGE_SECONDS + JWT_CLOCK_TOLERANCE_SECONDS)\n )\n throw unauthenticated();\n\n // Only a previously tombstoned principal can bypass live validation to finish\n // partial erasure. Its profile cannot be re-created while the tombstone lives.\n const erasureRetry =\n allowErasureRetry && (await env.DB.prepare(SQL.revokedSubject).bind(issuer, subject, Date.now()).first()) !== null;\n if (!erasureRetry) {\n try {\n await serviceRequest(\n env.AUTH_ISSUER_SERVICE,\n new Request(new URL('/api/v1/auth/session', config.issuer), {\n headers: { cookie: `archive_session=${token}` },\n redirect: 'manual',\n }),\n async (response) => {\n const body: unknown = await response.json();\n if (response.status === 200 && archiveOkSchema.safeParse(body).success) return;\n const failure = archiveErrorResponseSchema.safeParse(body);\n if (response.status === 401 && failure.success && failure.data.error.code === 'unauthenticated')\n throw unauthenticated();\n throw authUnavailable();\n },\n );\n } catch (error) {\n if (error instanceof ApiError && error.status === 401) throw error;\n throw authUnavailable();\n }\n }\n if (!allowErasureRetry) {\n const revoked = await env.DB.prepare(SQL.revokedSubject).bind(issuer, subject, Date.now()).first();\n if (revoked !== null) throw unauthenticated();\n }\n const row = await env.DB.prepare(SQL.publisher).bind(issuer, subject).first<{\n display_name: string;\n id: string;\n }>();\n return {\n operator,\n publisher: row === null ? null : { displayName: row.display_name, id: row.id },\n subject: { issuer, subject },\n };\n}\n\n/** Session required for private routes; anonymous gets 401. */\nexport function requireSession(session: Session | null): Session {\n if (session === null) throw unauthenticated();\n return session;\n}\n"],"mappings":"2UAuCA,IAAM,EAAsB,EAEtB,EAAoB,IAEpB,oBAAkC,IAAI,EAAS,IAAK,mBAAoB,mCAAmC,EAQjH,eAAsB,cACpB,EACA,EACA,EACA,EAAoB,GACK,CACzB,IAAM,EAAQ,EAAa,CAAO,EAClC,GAAI,IAAU,KAAM,OAAO,KAE3B,IAAI,EACJ,GAAI,CASF,GAAU,MARW,EAAU,EAAO,EAAO,KAAM,CACjD,WAAY,CAAC,OAAO,EACpB,SAAU,EAAO,SACjB,eAAA,EACA,OAAQ,EAAO,OACf,YAAa,EACb,eAAgB,CAAC,MAAO,MAAO,MAAO,MAAO,MAAO,gBAAgB,CACtE,CAAC,EAAA,CACgB,OACnB,OAAS,EAAO,CAad,MAVE,aAAiB,EAAO,aACxB,aAAiB,EAAO,aACxB,aAAiB,EAAO,yBAElB,gBAAgB,EAIpB,aAAiB,EAAO,UAAiB,EAAgB,EAEvD,gBAAgB,CACxB,CACA,IAAM,EAAU,EAAQ,IAClB,EAAS,EAAQ,IACjB,EAAM,EAAQ,IACd,EAAQ,EAAQ,MACtB,GAAI,CAAC,OAAO,cAAc,EAAQ,cAAc,GAAM,EAAQ,eAA4B,EAAG,MAAM,EAAgB,EAGnH,IAAM,EAAW,MAAM,QAAQ,CAAK,GAAK,EAAM,SAAA,kBAA8B,EAgB7E,GAZE,OAAO,GAAY,UACnB,EAAQ,SAAW,GACnB,EAAQ,OAAS,GACjB,CAAC,EAAiB,CAAO,GAIvB,OAAO,GAAW,UAElB,OAAO,GAAQ,UAAY,EAAM,IAAO,KAAK,IAAI,EAAA,KAInD,IACC,OAAO,GAAQ,UACd,OAAO,EAAQ,KAAQ,UACvB,EAAQ,IAAM,EAAA,KACd,KAAK,IAAI,EAAI,IAAO,EAAA,KAEtB,MAAM,EAAgB,EAMxB,GAAI,EADF,GAAsB,MAAM,EAAI,GAAG,QAAQ,EAAI,cAAc,CAAC,CAAC,KAAK,EAAQ,EAAS,KAAK,IAAI,CAAC,CAAC,CAAC,MAAM,IAAO,MAE9G,GAAI,CACF,MAAM,EACJ,EAAI,oBACJ,IAAI,QAAQ,IAAI,IAAI,uBAAwB,EAAO,MAAM,EAAG,CAC1D,QAAS,CAAE,OAAQ,mBAAmB,GAAQ,EAC9C,SAAU,QACZ,CAAC,EACD,KAAO,IAAa,CAClB,IAAM,EAAgB,MAAM,EAAS,KAAK,EAC1C,GAAI,EAAS,SAAW,KAAO,EAAgB,UAAU,CAAI,CAAC,CAAC,QAAS,OACxE,IAAM,EAAU,EAA2B,UAAU,CAAI,EAGzD,MAFI,EAAS,SAAW,KAAO,EAAQ,SAAW,EAAQ,KAAK,MAAM,OAAS,kBACtE,EAAgB,EAClB,gBAAgB,CACxB,CACF,CACF,OAAS,EAAO,CAEd,MADI,aAAiB,GAAY,EAAM,SAAW,IAAW,EACvD,gBAAgB,CACxB,CAEF,GAAI,CAAC,GAEC,MADkB,EAAI,GAAG,QAAQ,EAAI,cAAc,CAAC,CAAC,KAAK,EAAQ,EAAS,KAAK,IAAI,CAAC,CAAC,CAAC,MAAM,IACjF,KAAM,MAAM,EAAgB,EAE9C,IAAM,EAAM,MAAM,EAAI,GAAG,QAAQ,EAAI,SAAS,CAAC,CAAC,KAAK,EAAQ,CAAO,CAAC,CAAC,MAGnE,EACH,MAAO,CACL,WACA,UAAW,IAAQ,KAAO,KAAO,CAAE,YAAa,EAAI,aAAc,GAAI,EAAI,EAAG,EAC7E,QAAS,CAAE,SAAQ,SAAQ,CAC7B,CACF,CAGA,SAAgB,eAAe,EAAkC,CAC/D,GAAI,IAAY,KAAM,MAAM,EAAgB,EAC5C,OAAO,CACT"}
@@ -0,0 +1,2 @@
1
+ const e=require("./http.cjs");let t=require("jose");var n=new Set([`127.0.0.1`,`localhost`,`[::1]`]),isLoopback=e=>n.has(e.hostname),configError=t=>new e.ApiError(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)},r=`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 r;throw configError(`RATE_LIMIT_KEY_SECRET`)}if(t===`production`&&n.length<32)throw configError(`RATE_LIMIT_KEY_SECRET`);return n},i=new WeakMap;function apiConfig(e){let n=i.get(e);if(n)return n;let r=requireVar(e,`APP_ENV`);if(r!==`local`&&r!==`production`)throw configError(`APP_ENV`);let a=r;validateUrl(requireUrl(requireVar(e,`JWT_ISSUER`),`JWT_ISSUER`),a,`JWT_ISSUER`);let o=validateUrl(requireUrl(requireVar(e,`JWT_JWKS_URL`),`JWT_JWKS_URL`),a,`JWT_JWKS_URL`),s=requireVar(e,`JWT_AUDIENCE`),c=new Set(requireVar(e,`ALLOWED_ORIGINS`).split(`,`).map(e=>e.trim()).filter(e=>e.length>0).map(e=>validateOrigin(e,a,`ALLOWED_ORIGINS`)));if(c.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 l={allowedOrigins:c,appEnv:a,audience:s,issuer:requireVar(e,`JWT_ISSUER`),jwks:(0,t.createRemoteJWKSet)(o),likesEnabled:flag(requireVar(e,`LIKES_ENABLED`),`LIKES_ENABLED`),publishingEnabled:flag(requireVar(e,`PUBLISHING_ENABLED`),`PUBLISHING_ENABLED`),rateLimitPepper:rateLimitPepperFor(e,a)};return i.set(e,l),l}exports.apiConfig=apiConfig;
2
+ //# sourceMappingURL=env.cjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"env.cjs","names":[],"sources":["../../src/worker/env.ts"],"sourcesContent":["import { createRemoteJWKSet } 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 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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"}
@@ -0,0 +1,73 @@
1
+ import { createRemoteJWKSet } from 'jose';
2
+ /**
3
+ * Worker configuration. Every value the worker trusts comes from the validated Env —
4
+ * never from request-supplied URLs or headers. Anything missing or malformed fails
5
+ * closed (503) rather than falling back to a permissive default.
6
+ */
7
+ export type AppEnv = 'local' | 'production';
8
+ export interface ApiConfig {
9
+ allowedOrigins: ReadonlySet<string>;
10
+ appEnv: AppEnv;
11
+ audience: string;
12
+ issuer: string;
13
+ jwks: ReturnType<typeof createRemoteJWKSet>;
14
+ likesEnabled: boolean;
15
+ publishingEnabled: boolean;
16
+ rateLimitPepper: string;
17
+ }
18
+ /** The rate-limit binding subset the core calls; the runtime's RateLimit satisfies it. */
19
+ export interface RateLimiterBinding {
20
+ limit(input: {
21
+ key: string;
22
+ }): Promise<{
23
+ success: boolean;
24
+ }>;
25
+ }
26
+ /** The D1 statement subset the core binds; null params model nullable columns. */
27
+ export interface D1Statement {
28
+ all<T>(): Promise<{
29
+ results?: T[];
30
+ }>;
31
+ bind(...params: (string | number | null)[]): D1Statement;
32
+ first<T>(): Promise<T | null>;
33
+ run(): Promise<{
34
+ meta?: {
35
+ changes?: number;
36
+ };
37
+ }>;
38
+ }
39
+ /** The D1 database subset the core uses; a deployment's generated D1Database satisfies it. */
40
+ export interface D1Like {
41
+ batch(statements: D1Statement[]): Promise<{
42
+ meta?: {
43
+ changes?: number;
44
+ };
45
+ }[]>;
46
+ prepare(sql: string): D1Statement;
47
+ }
48
+ /**
49
+ * The bindings the archive Worker reads. The package declares its own minimal
50
+ * structural types instead of `@cloudflare/workers-types`: a deployment's generated
51
+ * Env (with real D1Database/RateLimit bindings) satisfies this shape structurally.
52
+ */
53
+ export interface ArchiveWorkerEnv {
54
+ ALLOWED_ORIGINS: string;
55
+ APP_ENV: string;
56
+ /** Required issuer session-validation service; every private request checks live authority. */
57
+ AUTH_ISSUER_SERVICE: AuthIssuerServiceBinding;
58
+ DB: D1Like;
59
+ JWT_AUDIENCE: string;
60
+ JWT_ISSUER: string;
61
+ JWT_JWKS_URL: string;
62
+ LIKES_ENABLED: string;
63
+ PUBLISHING_ENABLED: string;
64
+ RATE_LIMIT_KEY_SECRET: string;
65
+ READ_LIMIT: RateLimiterBinding;
66
+ WRITE_LIMIT: RateLimiterBinding;
67
+ }
68
+ export interface AuthIssuerServiceBinding {
69
+ fetch(request: Request): Promise<Response>;
70
+ }
71
+ /** Parses and validates the env once per bindings object; throws ApiError(503). */
72
+ export declare function apiConfig(env: ArchiveWorkerEnv): ApiConfig;
73
+ //# sourceMappingURL=env.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"env.d.ts","sourceRoot":"","sources":["../../src/worker/env.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,kBAAkB,EAAE,MAAM,MAAM,CAAC;AAG1C;;;;GAIG;AACH,MAAM,MAAM,MAAM,GAAG,OAAO,GAAG,YAAY,CAAC;AAE5C,MAAM,WAAW,SAAS;IACxB,cAAc,EAAE,WAAW,CAAC,MAAM,CAAC,CAAC;IACpC,MAAM,EAAE,MAAM,CAAC;IACf,QAAQ,EAAE,MAAM,CAAC;IACjB,MAAM,EAAE,MAAM,CAAC;IACf,IAAI,EAAE,UAAU,CAAC,OAAO,kBAAkB,CAAC,CAAC;IAC5C,YAAY,EAAE,OAAO,CAAC;IACtB,iBAAiB,EAAE,OAAO,CAAC;IAC3B,eAAe,EAAE,MAAM,CAAC;CACzB;AAED,0FAA0F;AAC1F,MAAM,WAAW,kBAAkB;IACjC,KAAK,CAAC,KAAK,EAAE;QAAE,GAAG,EAAE,MAAM,CAAA;KAAE,GAAG,OAAO,CAAC;QAAE,OAAO,EAAE,OAAO,CAAA;KAAE,CAAC,CAAC;CAC9D;AAED,kFAAkF;AAClF,MAAM,WAAW,WAAW;IAC1B,GAAG,CAAC,CAAC,KAAK,OAAO,CAAC;QAAE,OAAO,CAAC,EAAE,CAAC,EAAE,CAAA;KAAE,CAAC,CAAC;IACrC,IAAI,CAAC,GAAG,MAAM,EAAE,CAAC,MAAM,GAAG,MAAM,GAAG,IAAI,CAAC,EAAE,GAAG,WAAW,CAAC;IACzD,KAAK,CAAC,CAAC,KAAK,OAAO,CAAC,CAAC,GAAG,IAAI,CAAC,CAAC;IAC9B,GAAG,IAAI,OAAO,CAAC;QAAE,IAAI,CAAC,EAAE;YAAE,OAAO,CAAC,EAAE,MAAM,CAAA;SAAE,CAAA;KAAE,CAAC,CAAC;CACjD;AAED,8FAA8F;AAC9F,MAAM,WAAW,MAAM;IACrB,KAAK,CAAC,UAAU,EAAE,WAAW,EAAE,GAAG,OAAO,CAAC;QAAE,IAAI,CAAC,EAAE;YAAE,OAAO,CAAC,EAAE,MAAM,CAAA;SAAE,CAAA;KAAE,EAAE,CAAC,CAAC;IAC7E,OAAO,CAAC,GAAG,EAAE,MAAM,GAAG,WAAW,CAAC;CACnC;AAED;;;;GAIG;AACH,MAAM,WAAW,gBAAgB;IAC/B,eAAe,EAAE,MAAM,CAAC;IACxB,OAAO,EAAE,MAAM,CAAC;IAChB,+FAA+F;IAC/F,mBAAmB,EAAE,wBAAwB,CAAC;IAC9C,EAAE,EAAE,MAAM,CAAC;IACX,YAAY,EAAE,MAAM,CAAC;IACrB,UAAU,EAAE,MAAM,CAAC;IACnB,YAAY,EAAE,MAAM,CAAC;IACrB,aAAa,EAAE,MAAM,CAAC;IACtB,kBAAkB,EAAE,MAAM,CAAC;IAC3B,qBAAqB,EAAE,MAAM,CAAC;IAC9B,UAAU,EAAE,kBAAkB,CAAC;IAC/B,WAAW,EAAE,kBAAkB,CAAC;CACjC;AAED,MAAM,WAAW,wBAAwB;IACvC,KAAK,CAAC,OAAO,EAAE,OAAO,GAAG,OAAO,CAAC,QAAQ,CAAC,CAAC;CAC5C;AAiFD,mFAAmF;AACnF,wBAAgB,SAAS,CAAC,GAAG,EAAE,gBAAgB,GAAG,SAAS,CA0C1D"}
@@ -0,0 +1,2 @@
1
+ import{ApiError as e}from"./http.js";import{createRemoteJWKSet as t}from"jose";var n=new Set([`127.0.0.1`,`localhost`,`[::1]`]),isLoopback=e=>n.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)},r=`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 r;throw configError(`RATE_LIMIT_KEY_SECRET`)}if(t===`production`&&n.length<32)throw configError(`RATE_LIMIT_KEY_SECRET`);return n},i=new WeakMap;function apiConfig(e){let n=i.get(e);if(n)return n;let r=requireVar(e,`APP_ENV`);if(r!==`local`&&r!==`production`)throw configError(`APP_ENV`);let a=r;validateUrl(requireUrl(requireVar(e,`JWT_ISSUER`),`JWT_ISSUER`),a,`JWT_ISSUER`);let o=validateUrl(requireUrl(requireVar(e,`JWT_JWKS_URL`),`JWT_JWKS_URL`),a,`JWT_JWKS_URL`),s=requireVar(e,`JWT_AUDIENCE`),c=new Set(requireVar(e,`ALLOWED_ORIGINS`).split(`,`).map(e=>e.trim()).filter(e=>e.length>0).map(e=>validateOrigin(e,a,`ALLOWED_ORIGINS`)));if(c.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 l={allowedOrigins:c,appEnv:a,audience:s,issuer:requireVar(e,`JWT_ISSUER`),jwks:t(o),likesEnabled:flag(requireVar(e,`LIKES_ENABLED`),`LIKES_ENABLED`),publishingEnabled:flag(requireVar(e,`PUBLISHING_ENABLED`),`PUBLISHING_ENABLED`),rateLimitPepper:rateLimitPepperFor(e,a)};return i.set(e,l),l}export{apiConfig};
2
+ //# sourceMappingURL=env.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"env.js","names":[],"sources":["../../src/worker/env.ts"],"sourcesContent":["import { createRemoteJWKSet } 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 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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"}
@@ -0,0 +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;
2
+ //# sourceMappingURL=http.cjs.map
@@ -0,0 +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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"}
@@ -0,0 +1,66 @@
1
+ import { type ArchiveErrorCode } from '../contract';
2
+ import type { ApiConfig } from './env';
3
+ /**
4
+ * HTTP plumbing shared by every route: the one error shape, bounded body reads, strict
5
+ * query/header guards and the CORS contract. Handlers throw ApiError; the entry point
6
+ * sanitizes — no stack, SQL, body, or PII ever reaches a response. Internal to the
7
+ * Worker: clients parse the wire envelope (`archiveErrorResponseSchema`), never this class.
8
+ */
9
+ export declare class ApiError extends Error {
10
+ readonly code: ArchiveErrorCode;
11
+ readonly retryAfterSeconds?: number;
12
+ readonly status: number;
13
+ readonly diagnostic?: ArchiveDiagnostic;
14
+ constructor(status: number, code: ArchiveErrorCode, message: string, opts?: ErrorOptions & {
15
+ retryAfterSeconds?: number;
16
+ diagnostic?: ArchiveDiagnostic;
17
+ });
18
+ }
19
+ export type ArchiveDiagnostic = {
20
+ readonly kind: 'configuration';
21
+ readonly field: string;
22
+ } | {
23
+ readonly kind: 'database' | 'record' | 'schema' | 'internal' | 'cache' | 'collection';
24
+ };
25
+ export declare const badRequest: (message?: string) => ApiError;
26
+ export declare const unauthenticated: () => ApiError;
27
+ export declare const forbidden: (message?: string) => ApiError;
28
+ export declare const notFound: () => ApiError;
29
+ export declare const conflict: (message?: string) => ApiError;
30
+ export declare const invalid: (message?: string) => ApiError;
31
+ export declare const json: (status: number, body: unknown, headers?: HeadersInit) => Response;
32
+ export declare const noStore: {
33
+ readonly 'cache-control': "no-store";
34
+ };
35
+ export declare const errorBody: (status: number, code: string, message: string, requestId: string) => Response;
36
+ /** Sanitizes any thrown value into the public error shape; unknowns become 500. */
37
+ export declare function toErrorResponse(error: unknown, requestId: string): Response;
38
+ /**
39
+ * Rejects query strings with unknown or repeated parameters. Every accepted route
40
+ * names its full allow-list; anything else is a client bug, not something to ignore.
41
+ */
42
+ export declare function queryParams(url: URL, allowed: readonly string[]): URLSearchParams;
43
+ /** Exact-origin plus request-marker guard for every mutation: no wildcards, no nulls. */
44
+ export declare function requireMutationGuard(request: Request, config: ApiConfig): void;
45
+ /** CORS response headers for one already-allowed origin; absent for foreign origins. */
46
+ export declare function corsHeaders(request: Request, config: ApiConfig): Record<string, string>;
47
+ /** Answers a preflight for an allowed origin; foreign or missing origins get 403. */
48
+ export declare function preflight(request: Request, config: ApiConfig): Response;
49
+ /** Hard deadline for reading a request body; slow streams fail as 408. */
50
+ export declare const HTTP_BODY_TIMEOUT_MS = 10000;
51
+ /**
52
+ * Reads a JSON body with a hard byte ceiling and a wall-clock deadline enforced while
53
+ * streaming — a lying or absent Content-Length and a stalled sender are both cut off.
54
+ * Wrong media types are 415, oversized 413, timed out 408, malformed JSON 400; schema
55
+ * validation happens afterwards at the route boundary.
56
+ */
57
+ export declare function readJsonBody(request: Request): Promise<unknown>;
58
+ /**
59
+ * Reads the session cookie. A duplicate cookie name or an oversized header is a
60
+ * malformed request, not a missing session: callers distinguish null (anonymous)
61
+ * from a token to verify.
62
+ */
63
+ export declare function sessionToken(request: Request): string | null;
64
+ /** The expired-session Set-Cookie value the sign-out route and account erasure send. */
65
+ export declare const expiredSessionCookie: (secure: boolean) => string;
66
+ //# sourceMappingURL=http.d.ts.map
@@ -0,0 +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"}
@@ -0,0 +1,2 @@
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};
2
+ //# sourceMappingURL=http.js.map
@@ -0,0 +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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"}
@@ -0,0 +1,2 @@
1
+ const e=require("../contract.cjs"),t=require("./http.cjs"),n=require("./sql.cjs"),r=require("./_pagination.cjs");let i=require("@vielzeug/spell");async function databaseCall(e){try{return await e()}catch(e){throw new t.ApiError(500,`internal`,`The service is not available.`,{cause:e,diagnostic:{kind:`database`}})}}var first=(e,t,...n)=>databaseCall(()=>e.DB.prepare(t).bind(...n).first()),all=(e,t,...n)=>databaseCall(async()=>{let r=await e.DB.prepare(t).bind(...n).all();if(!Array.isArray(r.results))throw Error(`Missing database query results`);return r.results}),run=(e,t,...n)=>databaseCall(()=>e.DB.prepare(t).bind(...n).run()),uuid=()=>crypto.randomUUID(),now=()=>new Date().toISOString(),recordUnavailable=()=>new t.ApiError(503,`record-unavailable`,`The record is not available.`,{diagnostic:{kind:`record`}}),a=/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i,rowToEntry=(e,t)=>{let n;try{n=JSON.parse(t.payload_json)}catch{throw recordUnavailable()}let r=e.contract.entry.safeParse({author:{id:t.owner_id,name:t.author_name},contentHash:t.content_hash,contentVersion:t.content_version,id:t.id,likeCount:t.like_count,payload:n,publishedAt:t.published_at,revision:t.revision,schemaVersion:t.schema_version,updatedAt:t.updated_at});if(!r.success)throw recordUnavailable();return r.data},rowToOwnedEntry=(t,n)=>{let r=rowToEntry(t,n);if(!e.uuidSchema.safeParse(n.client_key).success)throw recordUnavailable();return{...r,clientKey:n.client_key,hidden:n.hidden_at!==null}},entryById=async(e,t,r)=>{let i=await first(t,n.SQL.publication,r,e.name);return i===null?null:rowToEntry(e,i)},searchMatchFor=n=>{if(n===null)return;if(n.length>e.ARCHIVE_LIMITS.searchChars)throw t.badRequest();let r=(n.match(/[\p{L}\p{N}]+/gu)??[]).slice(0,e.ARCHIVE_LIMITS.searchTerms);if(r.length!==0)return r.map(e=>`"${e}"*`).join(` `)},entryCursorContext=e=>JSON.stringify([e.collection,e.sort,e.author??``,e.filter??``,e.tag??``,e.searchMatch??``]),cursorFromRow=e=>({createdAt:e.published_at,id:e.id,likeCount:e.like_count});async function listEntries(i,o,s){let c=new URL(o.url),l=t.queryParams(c,[`author`,`cursor`,`filter`,`limit`,`search`,`sort`,`tag`]),u=l.get(`sort`)??`recent`,d=e.archiveSortSchema.safeParse(u);if(!d.success)throw t.badRequest();let f=l.get(`filter`);if(f!==null&&!i.filters.includes(f))throw t.badRequest();let p=l.get(`tag`);if(p!==null&&!i.tags.includes(p))throw t.badRequest();let m=l.get(`author`);if(m!==null&&!a.test(m))throw t.badRequest();let h=searchMatchFor(l.get(`search`)),g=entryCursorContext({author:m??void 0,collection:i.name,filter:f??void 0,searchMatch:h,sort:d.data,tag:p??void 0}),{cursor:_,limit:v}=r.forwardPageParams(l,g),y={author:m??void 0,collection:i.name,cursor:_===null?void 0:{createdAt:_.createdAt,id:_.id,likeCount:_.likeCount??0},filter:f??void 0,limit:v+1,searchMatch:h,sort:d.data,tag:p??void 0},b=n.listPublicationsSql(y),x=await all(s,b.sql,...b.parameters),S=x.slice(0,v),C=S.at(-1),w={cursor:x.length>v&&C!==void 0?r.nextCursor(cursorFromRow(C),g):null,entries:S.map(e=>rowToEntry(i,e))};return t.json(200,w,t.noStore)}function collectionMeta(n){let r=e.archiveCollectionMetaSchema.safeParse({filters:[...n.filters].sort(),name:n.name,tags:[...n.tags].sort()});if(!r.success)throw recordUnavailable();return t.json(200,r.data,t.noStore)}async function getEntry(e,n,r){let i=await entryById(e,n,r);if(i===null)throw t.notFound();return t.json(200,i,t.noStore)}async function healthJson(r,i){let a=null,o=null;try{a=(await first(i,n.SQL.schemaVersion))?.applied??0}catch{o=`database-unavailable`}o===null&&a!==1&&(o=`schema-mismatch`);let s=e.archiveHealthSchema.safeParse({dbSchemaVersion:a,features:{likes:r.likesEnabled,publishing:r.publishingEnabled},limits:e.ARCHIVE_LIMITS,reason:o,schemaVersion:1,status:o===null?`ok`:`degraded`});if(!s.success)throw recordUnavailable();return t.json(o===null?200:503,s.data,t.noStore)}var identityJson=(n,r)=>{let i=e.archiveIdentitySchema.safeParse(r);if(!i.success)throw recordUnavailable();return t.json(n,i.data,t.noStore)};function getMe(e){return identityJson(200,e?.publisher?{id:e.publisher.id,name:e.publisher.displayName,operator:e.operator}:null)}async function registerMe(e,r,i){let a=await t.readJsonBody(e),s=o.safeParse(a);if(!s.success)throw t.invalid();let c=s.data.name??``,l=await first(r,n.SQL.publisher,i.subject.issuer,i.subject.subject);if(l!==null)return identityJson(200,{id:l.id,name:l.display_name,operator:i.operator});let u=uuid();await run(r,n.SQL.insertPublisher,u,i.subject.issuer,i.subject.subject,c,now(),i.subject.issuer,i.subject.subject,Date.now());let d=await first(r,n.SQL.publisher,i.subject.issuer,i.subject.subject);if(d===null)throw await first(r,n.SQL.revokedSubject,i.subject.issuer,i.subject.subject,Date.now())===null?t.conflict():new t.ApiError(401,`unauthenticated`,`Sign-in is required.`);return identityJson(201,{id:d.id,name:d.display_name,operator:i.operator})}async function renameMe(e,r,i){let a=requirePublisherRow(i),o=await t.readJsonBody(e),c=s.safeParse(o);if(!c.success)throw t.invalid();return c.data.name!==a.displayName&&await run(r,n.SQL.renamePublisher,c.data.name,a.id,c.data.name),identityJson(200,{id:a.id,name:c.data.name,operator:i.operator})}async function deleteProfile(e,t){return t.publisher!==null&&await run(e,n.SQL.deletePublisher,t.publisher.id),new Response(null,{headers:{"cache-control":`no-store`},status:204})}async function deleteAccountProfile(r,i){let{issuer:a,subject:o}=i.subject;return await databaseCall(()=>r.DB.batch([r.DB.prepare(n.SQL.revokeSubject).bind(a,o,Date.now()+(e.SESSION_MAX_AGE_SECONDS+10)*1e3),r.DB.prepare(n.SQL.deleteSubjectPublisher).bind(a,o)])),new Response(null,{headers:t.noStore,status:204})}async function erasePublisher(e,r,i){let a=requireOperatorRow(r);return await databaseCall(()=>e.DB.batch([e.DB.prepare(n.SQL.auditErasure).bind(uuid(),a.id,now(),i),e.DB.prepare(n.SQL.deletePublisher).bind(i)])),new Response(null,{headers:t.noStore,status:204})}async function listModerationLog(i,a,o){requireOperatorRow(o);let s=`moderation-log`,{cursor:c,limit:l}=r.forwardPage(i,s),u=n.moderationLogSql({cursor:c,limit:l+1}),d=await all(a,u.sql,...u.parameters),f=d.slice(0,l),p=f.at(-1),m=e.archiveModerationLogSchema.safeParse({actions:f.map(e=>({action:e.action,collection:e.collection,createdAt:e.created_at,id:e.id,operatorId:e.operator_id,targetId:e.target_id})),cursor:d.length>l&&p!==void 0?r.nextCursor({createdAt:p.created_at,id:p.id},s):null});if(!m.success)throw recordUnavailable();return t.json(200,m.data,t.noStore)}var requirePublisherRow=e=>{if(e.publisher===null)throw t.forbidden(`Register before publishing.`);return e.publisher},requireOperatorRow=e=>{if(!e.operator)throw t.forbidden(`Operator access is required.`);if(e.publisher===null)throw t.forbidden(`Register before moderating.`);return e.publisher},o=i.s.object({name:e.archiveAliasSchema.optional()}),s=i.s.object({name:e.archiveAliasSchema});async function myPublications(r,i,a){if(a.publisher===null)return t.json(200,{entries:[]},t.noStore);let o=await all(i,n.SQL.ownPublications,a.publisher.id,r.name,e.ARCHIVE_LIMITS.maxPublications);return t.json(200,{entries:o.map(e=>rowToOwnedEntry(r,e))},t.noStore)}async function myLikes(i,o,s){let c=new URL(i.url).searchParams.get(`ids`);if(c===null){let a=`likes:${s.publisher?.id??`unregistered`}`,{cursor:c,limit:l}=r.forwardPage(i,a);if(s.publisher===null)return t.json(200,{cursor:null,ids:[]},t.noStore);let u=n.likesPageSql(s.publisher.id,{cursor:c,limit:l+1}),d=await all(o,u.sql,...u.parameters),f=d.slice(0,l),p=f.at(-1),m=e.archiveLikesPageSchema.safeParse({cursor:d.length>l&&p!==void 0?r.nextCursor({createdAt:p.created_at,id:p.publication_id},a):null,ids:f.map(e=>e.publication_id)});if(!m.success)throw recordUnavailable();return t.json(200,m.data,t.noStore)}t.queryParams(new URL(i.url),[`ids`]);let l=[...new Set(c.split(`,`))];if(l.length>e.ARCHIVE_LIMITS.likeStateIds||l.some(e=>!a.test(e)))throw t.badRequest();if(s.publisher===null)return t.json(200,{ids:[]},t.noStore);let u=n.likedPublicationsSql(s.publisher.id,l),d=await all(o,u.sql,...u.parameters),f=new Set(d.map(e=>e.publication_id)),p={ids:l.filter(e=>f.has(e))},m=e.archiveLikedIdsSchema.safeParse(p);if(!m.success)throw recordUnavailable();return t.json(200,m.data,t.noStore)}var c=i.s.object({clientKey:e.uuidSchema,payload:i.s.unknown()}),l=i.s.object({expectedRevision:i.s.number().int().min(1).max(2**53-1),payload:i.s.unknown()});async function resolveRetryRecord(e,n,r,i){if(r===null||r.expires_at<=Date.now())return null;if(r.payload_hash!==i)throw t.conflict(`That key was used for a different payload.`);if(r.publication_id===null)throw new t.ApiError(410,`gone`,`That publication was removed.`);let a=await entryById(e,n,r.publication_id);return a===null?`vanished`:t.json(200,a,t.noStore)}async function publishEntry(r,i,a,o,s){if(!o.publishingEnabled)throw new t.ApiError(503,`publishing-disabled`,`Publishing is currently disabled.`);let l=requirePublisherRow(s),u=await t.readJsonBody(i),d=c.safeParse(u);if(!d.success)throw t.invalid();let{clientKey:f}=d.data,p=await r.derive(d.data.payload);if(p===null)throw t.invalid();let m=p.canonicalJson,h=p.contentHash,g=l.id,_=r.name,v=await resolveRetryRecord(r,a,await first(a,n.SQL.request,g,_,f),h);if(v!==null&&v!==`vanished`)return v;let y=await first(a,n.SQL.publicationByKey,g,_,f);if(y!==null){if(y.hidden_at!==null)throw t.forbidden(`This entry is under moderation review.`);if(y.content_hash!==h)throw t.conflict(`That key was used for a different payload.`);return t.json(200,rowToEntry(r,y),t.noStore)}let b=now(),x=Date.now()+e.ARCHIVE_LIMITS.idempotencyRetentionMs,S=JSON.stringify(p.tags);if(((await databaseCall(()=>a.DB.batch([a.DB.prepare(n.SQL.expireRequests).bind(Date.now()),a.DB.prepare(n.SQL.insertPublication).bind(uuid(),g,_,f,1,p.contentVersion,m,h,p.filter,p.title,S,b,b,g,_,e.ARCHIVE_LIMITS.maxPublications,g,_,f,Date.now()),a.DB.prepare(n.SQL.insertRequest).bind(g,_,f,h,b,x,g,_,f,h)])))[1]?.meta?.changes??0)===0){let e=await resolveRetryRecord(r,a,await first(a,n.SQL.request,g,_,f),h);if(e===`vanished`)throw new t.ApiError(410,`gone`,`That publication was removed.`);if(e!==null)return e;let i=await first(a,n.SQL.publicationByKey,g,_,f);if(i!==null&&i.hidden_at!==null)throw t.forbidden(`This entry is under moderation review.`);if(i!==null&&i.content_hash===h)return t.json(200,rowToEntry(r,i),t.noStore);throw i===null?new t.ApiError(429,`quota`,`Publication quota reached.`):t.conflict(`That key was used for a different payload.`)}let C=await first(a,n.SQL.publicationByKey,g,_,f);if(C===null)throw t.conflict();return t.json(201,rowToEntry(r,C),t.noStore)}async function updateEntry(e,r,i,a,o,s){if(!a.publishingEnabled)throw new t.ApiError(503,`publishing-disabled`,`Publishing is currently disabled.`);let c=requirePublisherRow(o),u=await first(i,n.SQL.publicationAny,s,e.name);if(u===null||u.owner_id!==c.id)throw t.notFound();if(u.hidden_at!==null)throw t.forbidden(`This entry is under moderation review.`);let d=await t.readJsonBody(r),f=l.safeParse(d);if(!f.success)throw t.invalid();let{expectedRevision:p}=f.data;if(p!==u.revision)throw t.conflict();let m=await e.derive(f.data.payload);if(m===null)throw t.invalid();let h=m.canonicalJson,g=m.contentHash;if(g===u.content_hash){let r=await first(i,n.SQL.publicationAny,s,e.name);if(r===null||r.owner_id!==c.id)throw t.notFound();if(r.hidden_at!==null)throw t.forbidden(`This entry is under moderation review.`);if(r.revision!==p)throw t.conflict();return t.json(200,rowToEntry(e,r),t.noStore)}let _=JSON.stringify(m.tags);if(((await run(i,n.SQL.updatePublication,1,m.contentVersion,h,g,m.filter,m.title,_,now(),s,c.id,p,g)).meta?.changes??0)===0){let r=await first(i,n.SQL.publicationAny,s,e.name);throw r===null?t.notFound():r.hidden_at===null?t.conflict():t.forbidden(`This entry is under moderation review.`)}let v=await entryById(e,i,s);if(v===null)throw t.notFound();return t.json(200,v,t.noStore)}async function deleteEntry(e,r,i,a){if(i.operator){let t=requireOperatorRow(i);await databaseCall(()=>r.DB.batch([r.DB.prepare(n.SQL.auditRemoval).bind(uuid(),t.id,now(),a,e.name),r.DB.prepare(n.SQL.removePublicationAny).bind(a,e.name)]))}else{let o=requirePublisherRow(i),s=await first(r,n.SQL.publicationAny,a,e.name);if(s===null)return new Response(null,{headers:t.noStore,status:204});if(s.owner_id!==o.id)throw t.notFound();await run(r,n.SQL.removePublication,a,o.id)}return new Response(null,{headers:t.noStore,status:204})}async function setLike(r,i,a,o,s,c){let l=requirePublisherRow(o);if(c&&!a.likesEnabled)throw new t.ApiError(503,`likes-disabled`,`Likes are currently disabled.`);if(await first(i,n.SQL.publication,s,r.name)===null)throw t.notFound();c?await run(i,n.SQL.insertLike,l.id,now(),s):await run(i,n.SQL.removeLike,s,l.id);let u=await first(i,n.SQL.likeState,l.id,s);if(u===null)throw t.notFound();let d={id:u.id,likeCount:u.like_count,liked:u.liked===1},f=e.archiveLikeStateSchema.safeParse(d);if(!f.success)throw recordUnavailable();return t.json(200,f.data,t.noStore)}var u=i.s.object({reason:i.s.string().check(e.isWellFormedText).trim().min(1).max(e.ARCHIVE_LIMITS.reportReasonChars)});async function reportEntry(e,r,i,a,o){let s=requirePublisherRow(a),c=await t.readJsonBody(r),l=u.safeParse(c);if(!l.success)throw t.invalid();if(await first(i,n.SQL.publication,o,e.name)===null)throw t.notFound();return await run(i,n.SQL.insertReport,uuid(),s.id,l.data.reason,now(),o,e.name),new Response(null,{headers:t.noStore,status:204})}async function setHidden(e,r,i,a,o){let s=requireOperatorRow(i);if(await first(r,n.SQL.publicationAny,a,e.name)===null)throw t.notFound();let c=o?r.DB.prepare(n.SQL.hidePublication).bind(now(),a,e.name):r.DB.prepare(n.SQL.unhidePublication).bind(a,e.name);return await databaseCall(()=>r.DB.batch([r.DB.prepare(n.SQL.auditHide).bind(uuid(),s.id,o?`hide`:`unhide`,now(),a,e.name,Number(o)),c])),new Response(null,{headers:t.noStore,status:204})}async function listReports(i,a,o,s){requireOperatorRow(s);let c=new URL(a.url).searchParams.get(`status`)??`unresolved`;if(c!==`unresolved`&&c!==`resolved`)throw t.badRequest();let l=`reports:${i.name}:${c}`,{cursor:u,limit:d}=r.forwardPage(a,l,[`status`]),f=n.reportsPageSql({collection:i.name,resolved:c===`resolved`},{cursor:u,limit:d+1}),p=await all(o,f.sql,...f.parameters),m=p.slice(0,d),h=m.at(-1),g=e.archiveReportsSchema.safeParse({cursor:p.length>d&&h!==void 0?r.nextCursor({createdAt:h.created_at,id:h.id},l):null,reports:m.map(e=>({createdAt:e.created_at,entryId:e.publication_id,entryTitle:e.entry_title,id:e.id,reason:e.reason,reporter:{id:e.reporter_id,name:e.reporter_name},resolvedAt:e.resolved_at}))});if(!g.success)throw recordUnavailable();return t.json(200,g.data,t.noStore)}async function resolveReport(e,r,i,a){if(requireOperatorRow(i),await first(r,n.SQL.reportById,a,e.name)===null)throw t.notFound();return await run(r,n.SQL.resolveReport,now(),a,e.name),new Response(null,{headers:t.noStore,status:204})}async function myReports(i,a,o){let s=`own-reports:${o.publisher?.id??`unregistered`}`,{cursor:c,limit:l}=r.forwardPage(i,s);if(o.publisher===null)return t.json(200,{cursor:null,reports:[]},t.noStore);let u=n.reportsPageSql({publisherId:o.publisher.id},{cursor:c,limit:l+1}),d=await all(a,u.sql,...u.parameters),f=d.slice(0,l),p=f.at(-1),m=e.archiveOwnReportsSchema.safeParse({cursor:d.length>l&&p!==void 0?r.nextCursor({createdAt:p.created_at,id:p.id},s):null,reports:f.map(e=>({collection:e.collection,createdAt:e.created_at,entryId:e.publication_id,id:e.id,reason:e.reason,resolvedAt:e.resolved_at}))});if(!m.success)throw recordUnavailable();return t.json(200,m.data,t.noStore)}exports.collectionMeta=collectionMeta,exports.deleteAccountProfile=deleteAccountProfile,exports.deleteEntry=deleteEntry,exports.deleteProfile=deleteProfile,exports.erasePublisher=erasePublisher,exports.getEntry=getEntry,exports.getMe=getMe,exports.healthJson=healthJson,exports.listEntries=listEntries,exports.listModerationLog=listModerationLog,exports.listReports=listReports,exports.myLikes=myLikes,exports.myPublications=myPublications,exports.myReports=myReports,exports.publishEntry=publishEntry,exports.registerMe=registerMe,exports.renameMe=renameMe,exports.reportEntry=reportEntry,exports.requireOperatorRow=requireOperatorRow,exports.requirePublisherRow=requirePublisherRow,exports.resolveReport=resolveReport,exports.setHidden=setHidden,exports.setLike=setLike,exports.updateEntry=updateEntry;
2
+ //# sourceMappingURL=publications.cjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"publications.cjs","names":[],"sources":["../../src/worker/publications.ts"],"sourcesContent":["import { s } from '@vielzeug/spell';\nimport type { ArchiveCollection } from '../collection';\nimport {\n ARCHIVE_CONTRACT_VERSION,\n ARCHIVE_LIMITS,\n ARCHIVE_SCHEMA_VERSION,\n type ArchiveModerationAction,\n archiveAliasSchema,\n archiveCollectionMetaSchema,\n archiveHealthSchema,\n archiveIdentitySchema,\n archiveLikedIdsSchema,\n archiveLikeStateSchema,\n archiveLikesPageSchema,\n archiveModerationLogSchema,\n archiveOwnReportsSchema,\n archiveReportsSchema,\n archiveSortSchema,\n isWellFormedText,\n SESSION_MAX_AGE_SECONDS,\n uuidSchema,\n} from '../contract';\nimport { forwardPage, forwardPageParams, nextCursor } from './_pagination';\nimport { JWT_CLOCK_TOLERANCE_SECONDS, type Publisher, type Session } from './auth';\nimport type { ApiConfig, ArchiveWorkerEnv } from './env';\nimport {\n ApiError,\n badRequest,\n conflict,\n forbidden,\n invalid,\n json,\n noStore,\n notFound,\n queryParams,\n readJsonBody,\n} from './http';\nimport {\n likedPublicationsSql,\n likesPageSql,\n listPublicationsSql,\n moderationLogSql,\n type PublicationCursor,\n type PublicationFilter,\n reportsPageSql,\n SQL,\n} from './sql';\n\n/**\n * Route handlers for /api/v1, parameterized by the registered collection. All SQL lives\n * in sql.ts and is bound positionally here in the authored order; the DTO boundary\n * re-validates every stored row before it leaves, content validation lives in the\n * collection's derive step, and moderation state (hidden entries) is visible only\n * through the owner and operator surfaces.\n */\n\ninterface PublicationRow {\n author_name: string;\n client_key: string;\n collection: string;\n content_hash: string;\n content_version: number;\n filter_value: string | null;\n /** Null while visible; set while an operator has removed the entry from public view. */\n hidden_at: string | null;\n id: string;\n like_count: number;\n owner_id: string;\n payload_json: string;\n published_at: string;\n revision: number;\n schema_version: number;\n tags_json: string;\n title: string;\n updated_at: string;\n}\n\nasync function databaseCall<T>(operation: () => Promise<T>): Promise<T> {\n try {\n return await operation();\n } catch (cause) {\n throw new ApiError(500, 'internal', 'The service is not available.', { cause, diagnostic: { kind: 'database' } });\n }\n}\nconst first = <T>(env: ArchiveWorkerEnv, sql: string, ...params: (string | number | null)[]) =>\n databaseCall(() =>\n env.DB.prepare(sql)\n .bind(...params)\n .first<T>(),\n );\nconst all = <T>(env: ArchiveWorkerEnv, sql: string, ...params: (string | number | null)[]) =>\n databaseCall(async () => {\n const result = await env.DB.prepare(sql)\n .bind(...params)\n .all<T>();\n if (!Array.isArray(result.results)) throw new Error('Missing database query results');\n return result.results;\n });\nconst run = (env: ArchiveWorkerEnv, sql: string, ...params: (string | number | null)[]) =>\n databaseCall(() =>\n env.DB.prepare(sql)\n .bind(...params)\n .run(),\n );\n\nconst uuid = (): string => crypto.randomUUID();\nconst now = (): string => new Date().toISOString();\n\n/** A stored row that fails the emitted-DTO contract is unavailable, not malformed data. */\nconst recordUnavailable = (): ApiError =>\n new ApiError(503, 'record-unavailable', 'The record is not available.', { diagnostic: { kind: 'record' } });\n\nconst UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;\n\n/**\n * Maps a joined row to the public DTO through the collection's strict entry schema:\n * stored JSON is parsed and re-validated, stored versions are emitted as-is, and any\n * drift — unknown fields, corrupt payload, mismatched versions — surfaces as 503\n * `record-unavailable` rather than leaking or fabricating data.\n */\nconst rowToEntry = (collection: ArchiveCollection, row: PublicationRow) => {\n let payloadJson: unknown;\n try {\n payloadJson = JSON.parse(row.payload_json);\n } catch {\n throw recordUnavailable();\n }\n const parsed = collection.contract.entry.safeParse({\n author: { id: row.owner_id, name: row.author_name },\n contentHash: row.content_hash,\n contentVersion: row.content_version,\n id: row.id,\n likeCount: row.like_count,\n payload: payloadJson,\n publishedAt: row.published_at,\n revision: row.revision,\n schemaVersion: row.schema_version,\n updatedAt: row.updated_at,\n });\n if (!parsed.success) throw recordUnavailable();\n return parsed.data;\n};\n\nconst rowToOwnedEntry = (collection: ArchiveCollection, row: PublicationRow) => {\n const entry = rowToEntry(collection, row);\n // The private owner DTO adds the create retry key and the moderation state;\n // public routes never emit either.\n const key = uuidSchema.safeParse(row.client_key);\n if (!key.success) throw recordUnavailable();\n return { ...entry, clientKey: row.client_key, hidden: row.hidden_at !== null };\n};\n\nconst entryById = async (collection: ArchiveCollection, env: ArchiveWorkerEnv, id: string) => {\n const row = await first<PublicationRow>(env, SQL.publication, id, collection.name);\n return row === null ? null : rowToEntry(collection, row);\n};\n\n/* ----------------------------------- list --------------------------------- */\n\n/** Normalizes free text into the bounded FTS prefix-match expression the SQL expects. */\nconst searchMatchFor = (search: string | null): string | undefined => {\n if (search === null) return undefined;\n if (search.length > ARCHIVE_LIMITS.searchChars) throw badRequest();\n // Unicode word extraction: punctuation splits terms rather than poisoning them,\n // so \"Hunter's\" and \"fire-ice\" match the words their indexed titles carry.\n const terms = (search.match(/[\\p{L}\\p{N}]+/gu) ?? []).slice(0, ARCHIVE_LIMITS.searchTerms);\n if (terms.length === 0) return undefined;\n return terms.map((t) => `\"${t}\"*`).join(' ');\n};\n\n/**\n * The entry-list cursor context: every query dimension the page's boundary depends\n * on, so a cursor minted under one filter/sort/search and replayed under another is\n * a 400 — the same forward-cursor guard every other list in the package uses.\n */\nconst entryCursorContext = (query: {\n author?: string;\n collection: string;\n filter?: string;\n searchMatch?: string;\n sort: 'liked' | 'recent';\n tag?: string;\n}): string =>\n JSON.stringify([\n query.collection,\n query.sort,\n query.author ?? '',\n query.filter ?? '',\n query.tag ?? '',\n query.searchMatch ?? '',\n ]);\n\nconst cursorFromRow = (row: PublicationRow): PublicationCursor => ({\n createdAt: row.published_at,\n id: row.id,\n likeCount: row.like_count,\n});\n\nexport async function listEntries(\n collection: ArchiveCollection,\n request: Request,\n env: ArchiveWorkerEnv,\n): Promise<Response> {\n const url = new URL(request.url);\n const params = queryParams(url, ['author', 'cursor', 'filter', 'limit', 'search', 'sort', 'tag']);\n\n const sortRaw = params.get('sort') ?? 'recent';\n const sortParsed = archiveSortSchema.safeParse(sortRaw);\n if (!sortParsed.success) throw badRequest();\n\n const filter = params.get('filter');\n if (filter !== null && !collection.filters.includes(filter)) throw badRequest();\n const tag = params.get('tag');\n if (tag !== null && !collection.tags.includes(tag)) throw badRequest();\n const author = params.get('author');\n if (author !== null && !UUID_RE.test(author)) throw badRequest();\n\n const searchMatch = searchMatchFor(params.get('search'));\n const context = entryCursorContext({\n author: author ?? undefined,\n collection: collection.name,\n filter: filter ?? undefined,\n searchMatch,\n sort: sortParsed.data,\n tag: tag ?? undefined,\n });\n const { cursor, limit } = forwardPageParams(params, context);\n\n const query: PublicationFilter = {\n author: author ?? undefined,\n collection: collection.name,\n cursor:\n cursor === null ? undefined : { createdAt: cursor.createdAt, id: cursor.id, likeCount: cursor.likeCount ?? 0 },\n filter: filter ?? undefined,\n limit: limit + 1,\n searchMatch,\n sort: sortParsed.data,\n tag: tag ?? undefined,\n };\n\n // One bounded query — never a count: the over-read row is the next page's proof.\n const bound = listPublicationsSql(query);\n const fetched = await all<PublicationRow>(env, bound.sql, ...bound.parameters);\n const page = fetched.slice(0, limit);\n const last = page.at(-1);\n\n // Entries are validated per row through the collection's entry schema; the cursor\n // is internally generated, so the page composes validated parts directly.\n const pageDto = {\n cursor: fetched.length > limit && last !== undefined ? nextCursor(cursorFromRow(last), context) : null,\n entries: page.map((row) => rowToEntry(collection, row)),\n };\n return json(200, pageDto, noStore);\n}\n\n/** The collection's public self-description: name plus the declared query vocabularies. */\nexport function collectionMeta(collection: ArchiveCollection): Response {\n const parsed = archiveCollectionMetaSchema.safeParse({\n filters: [...collection.filters].sort(),\n name: collection.name,\n tags: [...collection.tags].sort(),\n });\n if (!parsed.success) throw recordUnavailable();\n return json(200, parsed.data, noStore);\n}\n\nexport async function getEntry(collection: ArchiveCollection, env: ArchiveWorkerEnv, id: string): Promise<Response> {\n const entry = await entryById(collection, env, id);\n if (entry === null) throw notFound();\n return json(200, entry, noStore);\n}\n\n/* ---------------------------------- health --------------------------------- */\n\n/**\n * Service health: the feature flags, the wire limits, and the storage schema stamp\n * actually present in the database. A database whose migrations lag the package\n * answers `degraded` instead of pretending to be fine — the most likely deployment\n * fault becomes a one-glance diagnosis.\n */\nexport async function healthJson(config: ApiConfig, env: ArchiveWorkerEnv): Promise<Response> {\n let dbSchemaVersion: number | null = null;\n let reason: 'database-unavailable' | 'schema-mismatch' | null = null;\n try {\n const row = await first<{ applied: number }>(env, SQL.schemaVersion);\n dbSchemaVersion = row?.applied ?? 0;\n } catch {\n reason = 'database-unavailable';\n }\n if (reason === null && dbSchemaVersion !== ARCHIVE_SCHEMA_VERSION) reason = 'schema-mismatch';\n const parsed = archiveHealthSchema.safeParse({\n dbSchemaVersion,\n features: { likes: config.likesEnabled, publishing: config.publishingEnabled },\n limits: ARCHIVE_LIMITS,\n reason,\n schemaVersion: ARCHIVE_CONTRACT_VERSION,\n status: reason === null ? 'ok' : 'degraded',\n });\n if (!parsed.success) throw recordUnavailable();\n return json(reason === null ? 200 : 503, parsed.data, noStore);\n}\n\n/* --------------------------------- identity -------------------------------- */\n\n/** Emits the private identity DTO through the strict shared schema. */\nconst identityJson = (status: number, identity: unknown): Response => {\n const parsed = archiveIdentitySchema.safeParse(identity);\n if (!parsed.success) throw recordUnavailable();\n return json(status, parsed.data, noStore);\n};\n\nexport function getMe(session: Session | null): Response {\n return identityJson(\n 200,\n session?.publisher\n ? { id: session.publisher.id, name: session.publisher.displayName, operator: session.operator }\n : null,\n );\n}\n\nexport async function registerMe(request: Request, env: ArchiveWorkerEnv, session: Session): Promise<Response> {\n const body = await readJsonBody(request);\n const parsed = registerBodySchema.safeParse(body);\n if (!parsed.success) throw invalid();\n const name = parsed.data.name ?? '';\n\n const existing = await first<{ display_name: string; id: string }>(\n env,\n SQL.publisher,\n session.subject.issuer,\n session.subject.subject,\n );\n if (existing !== null) {\n return identityJson(200, { id: existing.id, name: existing.display_name, operator: session.operator });\n }\n const id = uuid();\n await run(\n env,\n SQL.insertPublisher,\n id,\n session.subject.issuer,\n session.subject.subject,\n name,\n now(),\n session.subject.issuer,\n session.subject.subject,\n Date.now(),\n );\n const row = await first<{ display_name: string; id: string }>(\n env,\n SQL.publisher,\n session.subject.issuer,\n session.subject.subject,\n );\n if (row === null) {\n if ((await first(env, SQL.revokedSubject, session.subject.issuer, session.subject.subject, Date.now())) !== null) {\n throw new ApiError(401, 'unauthenticated', 'Sign-in is required.');\n }\n throw conflict();\n }\n return identityJson(201, { id: row.id, name: row.display_name, operator: session.operator });\n}\n\nexport async function renameMe(request: Request, env: ArchiveWorkerEnv, session: Session): Promise<Response> {\n const publisher = requirePublisherRow(session);\n const body = await readJsonBody(request);\n const parsed = renameBodySchema.safeParse(body);\n if (!parsed.success) throw invalid();\n if (parsed.data.name !== publisher.displayName) {\n await run(env, SQL.renamePublisher, parsed.data.name, publisher.id, parsed.data.name);\n }\n return identityJson(200, { id: publisher.id, name: parsed.data.name, operator: session.operator });\n}\n\n/**\n * Archive-profile erasure, independent of the identity provider's account.\n * The credential survives so an issuer can retry coordinated account erasure.\n */\nexport async function deleteProfile(env: ArchiveWorkerEnv, session: Session): Promise<Response> {\n if (session.publisher !== null) await run(env, SQL.deletePublisher, session.publisher.id);\n return new Response(null, {\n headers: {\n 'cache-control': 'no-store',\n },\n status: 204,\n });\n}\n\n/** Revocation and erasure share a transaction, preventing a stale session from recreating the profile. */\nexport async function deleteAccountProfile(env: ArchiveWorkerEnv, session: Session): Promise<Response> {\n const { issuer, subject } = session.subject;\n // Issuance may be one tolerance ahead; expiry verification permits another.\n await databaseCall(() =>\n env.DB.batch([\n env.DB.prepare(SQL.revokeSubject).bind(\n issuer,\n subject,\n Date.now() + (SESSION_MAX_AGE_SECONDS + 2 * JWT_CLOCK_TOLERANCE_SECONDS) * 1000,\n ),\n env.DB.prepare(SQL.deleteSubjectPublisher).bind(issuer, subject),\n ]),\n );\n return new Response(null, { headers: noStore, status: 204 });\n}\n\n/**\n * Operator-side GDPR erasure: removes any publisher's row; FK cascades take its\n * publications, likes and retry keys. Idempotent for the same reason the self-erase\n * is: an already-erased publisher is a 204 with nothing left to remove.\n */\nexport async function erasePublisher(env: ArchiveWorkerEnv, session: Session, publisherId: string): Promise<Response> {\n const operator = requireOperatorRow(session);\n // The terminal moderation action: erasing a publisher removes their row, and FK\n // cascades take the publications, likes and retry keys with it — the same cascade a\n // self-erase walks, applied to any publisher the operator names. The guarded audit\n // and deletion share a transaction, so retries never record fictitious actions.\n await databaseCall(() =>\n env.DB.batch([\n env.DB.prepare(SQL.auditErasure).bind(uuid(), operator.id, now(), publisherId),\n env.DB.prepare(SQL.deletePublisher).bind(publisherId),\n ]),\n );\n return new Response(null, { headers: noStore, status: 204 });\n}\n\n/** The operator's accountability log: newest actions first, bounded, ids only. */\nexport async function listModerationLog(request: Request, env: ArchiveWorkerEnv, session: Session): Promise<Response> {\n requireOperatorRow(session);\n const context = 'moderation-log';\n const { cursor, limit } = forwardPage(request, context);\n const bound = moderationLogSql({ cursor, limit: limit + 1 });\n const rows = await all<{\n id: string;\n action: ArchiveModerationAction;\n collection: string | null;\n created_at: string;\n operator_id: string;\n target_id: string;\n }>(env, bound.sql, ...bound.parameters);\n const page = rows.slice(0, limit);\n const last = page.at(-1);\n const parsed = archiveModerationLogSchema.safeParse({\n actions: page.map((row) => ({\n action: row.action,\n collection: row.collection,\n createdAt: row.created_at,\n id: row.id,\n operatorId: row.operator_id,\n targetId: row.target_id,\n })),\n cursor:\n rows.length > limit && last !== undefined\n ? nextCursor({ createdAt: last.created_at, id: last.id }, context)\n : null,\n });\n if (!parsed.success) throw recordUnavailable();\n return json(200, parsed.data, noStore);\n}\n\nexport const requirePublisherRow = (session: Session): Publisher => {\n if (session.publisher === null) throw forbidden('Register before publishing.');\n return session.publisher;\n};\n\n/** Operator authority plus a registered publisher row: moderation routes need both,\n * the role claim for authority and the row for attribution. */\nexport const requireOperatorRow = (session: Session): Publisher => {\n if (!session.operator) throw forbidden('Operator access is required.');\n if (session.publisher === null) throw forbidden('Register before moderating.');\n return session.publisher;\n};\n\n// Private identity payloads share the one alias rule with the emitted DTOs.\nconst registerBodySchema = s.object({ name: archiveAliasSchema.optional() });\nconst renameBodySchema = s.object({ name: archiveAliasSchema });\n\nexport async function myPublications(\n collection: ArchiveCollection,\n env: ArchiveWorkerEnv,\n session: Session,\n): Promise<Response> {\n if (session.publisher === null) return json(200, { entries: [] }, noStore);\n const rows = await all<PublicationRow>(\n env,\n SQL.ownPublications,\n session.publisher.id,\n collection.name,\n ARCHIVE_LIMITS.maxPublications,\n );\n // The private owner DTO adds the create retry key; public routes never emit it.\n return json(200, { entries: rows.map((row) => rowToOwnedEntry(collection, row)) }, noStore);\n}\n\nexport async function myLikes(request: Request, env: ArchiveWorkerEnv, session: Session): Promise<Response> {\n const params = new URL(request.url).searchParams;\n const raw = params.get('ids');\n if (raw === null) {\n const context = `likes:${session.publisher?.id ?? 'unregistered'}`;\n const { cursor, limit } = forwardPage(request, context);\n if (session.publisher === null) return json(200, { cursor: null, ids: [] }, noStore);\n const bound = likesPageSql(session.publisher.id, { cursor, limit: limit + 1 });\n const rows = await all<{ created_at: string; publication_id: string }>(env, bound.sql, ...bound.parameters);\n const page = rows.slice(0, limit);\n const last = page.at(-1);\n const parsed = archiveLikesPageSchema.safeParse({\n cursor:\n rows.length > limit && last !== undefined\n ? nextCursor({ createdAt: last.created_at, id: last.publication_id }, context)\n : null,\n ids: page.map((row) => row.publication_id),\n });\n if (!parsed.success) throw recordUnavailable();\n return json(200, parsed.data, noStore);\n }\n queryParams(new URL(request.url), ['ids']);\n const ids = [...new Set(raw.split(','))];\n if (ids.length > ARCHIVE_LIMITS.likeStateIds || ids.some((id) => !UUID_RE.test(id))) throw badRequest();\n if (session.publisher === null) return json(200, { ids: [] }, noStore);\n const bound = likedPublicationsSql(session.publisher.id, ids);\n const rows = await all<{ publication_id: string }>(env, bound.sql, ...bound.parameters);\n const liked = new Set(rows.map((r) => r.publication_id));\n const dto = { ids: ids.filter((id) => liked.has(id)) };\n const validated = archiveLikedIdsSchema.safeParse(dto);\n if (!validated.success) throw recordUnavailable();\n return json(200, validated.data, noStore);\n}\n\n/* --------------------------------- mutations ------------------------------- */\n\n// The core validates the request envelope; the collection's derive step validates the payload.\nconst publishBodySchema = s.object({ clientKey: uuidSchema, payload: s.unknown() });\nconst updateBodySchema = s.object({\n expectedRevision: s.number().int().min(1).max(Number.MAX_SAFE_INTEGER),\n payload: s.unknown(),\n});\n\ninterface RetryRecord {\n expires_at: number;\n payload_hash: string;\n publication_id: string | null;\n}\n\n/**\n * The one replay/conflict/gone decision for a create retry key, shared by the\n * pre-read and the post-insert race re-read: a live record for a different payload\n * is a 409, a tombstone (null publication id) is a 410, and a live record for the\n * same payload replays the entry. `'vanished'` means the recorded publication is\n * no longer readable — hidden or raced-deleted — and only the caller knows which:\n * the pre-read falls through to the publication-by-key lookup (a hidden entry is\n * 403 there), the post-insert re-read answers 410.\n */\nasync function resolveRetryRecord(\n collection: ArchiveCollection,\n env: ArchiveWorkerEnv,\n record: RetryRecord | null,\n hash: string,\n): Promise<Response | 'vanished' | null> {\n if (record === null || record.expires_at <= Date.now()) return null;\n if (record.payload_hash !== hash) throw conflict('That key was used for a different payload.');\n if (record.publication_id === null) throw new ApiError(410, 'gone', 'That publication was removed.');\n const entry = await entryById(collection, env, record.publication_id);\n if (entry === null) return 'vanished';\n return json(200, entry, noStore);\n}\n\nexport async function publishEntry(\n collection: ArchiveCollection,\n request: Request,\n env: ArchiveWorkerEnv,\n config: ApiConfig,\n session: Session,\n): Promise<Response> {\n if (!config.publishingEnabled) throw new ApiError(503, 'publishing-disabled', 'Publishing is currently disabled.');\n const publisher = requirePublisherRow(session);\n const body = await readJsonBody(request);\n const parsed = publishBodySchema.safeParse(body);\n if (!parsed.success) throw invalid();\n const { clientKey } = parsed.data;\n // The derive step owns every content rejection: schema, vocabulary, title, byte bound.\n const derivation = await collection.derive(parsed.data.payload);\n if (derivation === null) throw invalid();\n\n const payloadJson = derivation.canonicalJson;\n const hash = derivation.contentHash;\n const owner = publisher.id;\n const name = collection.name;\n\n // Retry-key inspection first: the request record decides replay vs conflict vs gone.\n // A logically expired record is ignored entirely — retention is enforced by time,\n // not just by the periodic sweep.\n const retry = await first<RetryRecord>(env, SQL.request, owner, name, clientKey);\n const replay = await resolveRetryRecord(collection, env, retry, hash);\n if (replay !== null && replay !== 'vanished') return replay;\n\n const existing = await first<PublicationRow>(env, SQL.publicationByKey, owner, name, clientKey);\n if (existing !== null) {\n // A hidden entry is frozen for its owner too: replaying the create key must not\n // read as a live publication.\n if (existing.hidden_at !== null) throw forbidden('This entry is under moderation review.');\n if (existing.content_hash !== hash) throw conflict('That key was used for a different payload.');\n return json(200, rowToEntry(collection, existing), noStore);\n }\n\n const timestamp = now();\n const expiresAt = Date.now() + ARCHIVE_LIMITS.idempotencyRetentionMs;\n const tagsJson = JSON.stringify(derivation.tags);\n const results = await databaseCall(() =>\n env.DB.batch([\n env.DB.prepare(SQL.expireRequests).bind(Date.now()),\n env.DB.prepare(SQL.insertPublication).bind(\n uuid(),\n owner,\n name,\n clientKey,\n ARCHIVE_CONTRACT_VERSION,\n derivation.contentVersion,\n payloadJson,\n hash,\n derivation.filter,\n derivation.title,\n tagsJson,\n timestamp,\n timestamp,\n owner,\n name,\n ARCHIVE_LIMITS.maxPublications,\n owner,\n name,\n clientKey,\n Date.now(),\n ),\n env.DB.prepare(SQL.insertRequest).bind(\n owner,\n name,\n clientKey,\n hash,\n timestamp,\n expiresAt,\n owner,\n name,\n clientKey,\n hash,\n ),\n ]),\n );\n if ((results[1]?.meta?.changes ?? 0) === 0) {\n // The guarded insert was skipped: a race already wrote the key, the key's\n // publication was removed, or the owner's live-publication quota is full.\n // Re-reading the request record first distinguishes a tombstone from a race.\n const racedRecord = await first<RetryRecord>(env, SQL.request, owner, name, clientKey);\n const racedReplay = await resolveRetryRecord(collection, env, racedRecord, hash);\n if (racedReplay === 'vanished') throw new ApiError(410, 'gone', 'That publication was removed.');\n if (racedReplay !== null) return racedReplay;\n const raced = await first<PublicationRow>(env, SQL.publicationByKey, owner, name, clientKey);\n if (raced !== null && raced.hidden_at !== null) throw forbidden('This entry is under moderation review.');\n if (raced !== null && raced.content_hash === hash) return json(200, rowToEntry(collection, raced), noStore);\n if (raced !== null) throw conflict('That key was used for a different payload.');\n throw new ApiError(429, 'quota', 'Publication quota reached.');\n }\n\n const created = await first<PublicationRow>(env, SQL.publicationByKey, owner, name, clientKey);\n if (created === null) throw conflict();\n return json(201, rowToEntry(collection, created), noStore);\n}\n\nexport async function updateEntry(\n collection: ArchiveCollection,\n request: Request,\n env: ArchiveWorkerEnv,\n config: ApiConfig,\n session: Session,\n id: string,\n): Promise<Response> {\n if (!config.publishingEnabled) throw new ApiError(503, 'publishing-disabled', 'Publishing is currently disabled.');\n const publisher = requirePublisherRow(session);\n // The owner's row regardless of moderation state: the author must be told their\n // entry is frozen, not that it vanished.\n const row = await first<PublicationRow>(env, SQL.publicationAny, id, collection.name);\n // Replacing an entry is the author's alone; anyone else sees the entry as absent.\n if (row === null || row.owner_id !== publisher.id) throw notFound();\n // Hidden entries are frozen: an edit must not bypass the operator's decision.\n if (row.hidden_at !== null) throw forbidden('This entry is under moderation review.');\n\n const body = await readJsonBody(request);\n const parsed = updateBodySchema.safeParse(body);\n if (!parsed.success) throw invalid();\n const { expectedRevision } = parsed.data;\n\n if (expectedRevision !== row.revision) throw conflict();\n\n const derivation = await collection.derive(parsed.data.payload);\n if (derivation === null) throw invalid();\n const payloadJson = derivation.canonicalJson;\n const hash = derivation.contentHash;\n if (hash === row.content_hash) {\n const current = await first<PublicationRow>(env, SQL.publicationAny, id, collection.name);\n if (current === null || current.owner_id !== publisher.id) throw notFound();\n if (current.hidden_at !== null) throw forbidden('This entry is under moderation review.');\n if (current.revision !== expectedRevision) throw conflict();\n return json(200, rowToEntry(collection, current), noStore);\n }\n\n const tagsJson = JSON.stringify(derivation.tags);\n const result = await run(\n env,\n SQL.updatePublication,\n ARCHIVE_CONTRACT_VERSION,\n derivation.contentVersion,\n payloadJson,\n hash,\n derivation.filter,\n derivation.title,\n tagsJson,\n now(),\n id,\n publisher.id,\n expectedRevision,\n hash,\n );\n if ((result.meta?.changes ?? 0) === 0) {\n const current = await first<PublicationRow>(env, SQL.publicationAny, id, collection.name);\n if (current === null) throw notFound();\n if (current.hidden_at !== null) throw forbidden('This entry is under moderation review.');\n throw conflict();\n }\n const updated = await entryById(collection, env, id);\n if (updated === null) throw notFound();\n return json(200, updated, noStore);\n}\n\nexport async function deleteEntry(\n collection: ArchiveCollection,\n env: ArchiveWorkerEnv,\n session: Session,\n id: string,\n): Promise<Response> {\n // The owner's row regardless of moderation state: the author may still remove a\n // hidden entry (their content, their call) — hiding freezes edits, not removal.\n // Removing an entry is the author's alone; an operator may remove ANY entry — the\n // terminal moderation action for abusive content. The operator path carries the\n // same registered-publisher-row requirement every moderation route shares: one\n // accountable identity per action. Everyone else sees the entry as absent.\n if (session.operator) {\n const operator = requireOperatorRow(session);\n await databaseCall(() =>\n env.DB.batch([\n env.DB.prepare(SQL.auditRemoval).bind(uuid(), operator.id, now(), id, collection.name),\n env.DB.prepare(SQL.removePublicationAny).bind(id, collection.name),\n ]),\n );\n } else {\n const publisher = requirePublisherRow(session);\n const row = await first<PublicationRow>(env, SQL.publicationAny, id, collection.name);\n if (row === null) return new Response(null, { headers: noStore, status: 204 });\n if (row.owner_id !== publisher.id) throw notFound();\n await run(env, SQL.removePublication, id, publisher.id);\n }\n return new Response(null, { headers: noStore, status: 204 });\n}\n\nexport async function setLike(\n collection: ArchiveCollection,\n env: ArchiveWorkerEnv,\n config: ApiConfig,\n session: Session,\n id: string,\n liked: boolean,\n): Promise<Response> {\n const publisher = requirePublisherRow(session);\n if (liked && !config.likesEnabled) throw new ApiError(503, 'likes-disabled', 'Likes are currently disabled.');\n const row = await first<PublicationRow>(env, SQL.publication, id, collection.name);\n if (row === null) throw notFound();\n if (liked) {\n await run(env, SQL.insertLike, publisher.id, now(), id);\n } else {\n await run(env, SQL.removeLike, id, publisher.id);\n }\n // One consistent snapshot: the count and the viewer's flag read together.\n const state = await first<{ id: string; like_count: number; liked: number }>(env, SQL.likeState, publisher.id, id);\n if (state === null) throw notFound();\n const dto = { id: state.id, likeCount: state.like_count, liked: state.liked === 1 };\n const validated = archiveLikeStateSchema.safeParse(dto);\n if (!validated.success) throw recordUnavailable();\n return json(200, validated.data, noStore);\n}\n\n/* -------------------------------- moderation ------------------------------- */\n\n// One free-text report reason: trimmed, non-empty, well-formed, bounded.\nconst reportBodySchema = s.object({\n reason: s.string().check(isWellFormedText).trim().min(1).max(ARCHIVE_LIMITS.reportReasonChars),\n});\n\n/**\n * A reader's abuse report against one visible entry. Idempotent per (entry, reporter):\n * reporting again is the same 204, never a duplicate row. Hidden or missing entries\n * are 404 — they cannot be reported, they are already actioned or gone.\n */\nexport async function reportEntry(\n collection: ArchiveCollection,\n request: Request,\n env: ArchiveWorkerEnv,\n session: Session,\n id: string,\n): Promise<Response> {\n const reporter = requirePublisherRow(session);\n const body = await readJsonBody(request);\n const parsed = reportBodySchema.safeParse(body);\n if (!parsed.success) throw invalid();\n const row = await first<PublicationRow>(env, SQL.publication, id, collection.name);\n if (row === null) throw notFound();\n // Bound in the SQL's authored placeholder order: reporter, reason, created_at,\n // then the WHERE's entry id and collection.\n await run(env, SQL.insertReport, uuid(), reporter.id, parsed.data.reason, now(), id, collection.name);\n return new Response(null, { headers: noStore, status: 204 });\n}\n\n/**\n * The operator soft-hide: removes an entry from every public surface while keeping\n * the row, its likes and its retry key intact. Idempotent — already in the requested\n * state answers the same 204. Operator authority is the session's verified role\n * claim; a registered publisher row is still required (one accountable identity).\n */\nexport async function setHidden(\n collection: ArchiveCollection,\n env: ArchiveWorkerEnv,\n session: Session,\n id: string,\n hidden: boolean,\n): Promise<Response> {\n const operator = requireOperatorRow(session);\n // Any collection row, visible or hidden: the operator acts on the storage row. The\n // pre-read establishes existence only. The transactional audit guard decides\n // whether the requested transition is an effective action.\n const row = await first<PublicationRow>(env, SQL.publicationAny, id, collection.name);\n if (row === null) throw notFound();\n const statement = hidden\n ? env.DB.prepare(SQL.hidePublication).bind(now(), id, collection.name)\n : env.DB.prepare(SQL.unhidePublication).bind(id, collection.name);\n await databaseCall(() =>\n env.DB.batch([\n env.DB.prepare(SQL.auditHide).bind(\n uuid(),\n operator.id,\n hidden ? 'hide' : 'unhide',\n now(),\n id,\n collection.name,\n Number(hidden),\n ),\n statement,\n ]),\n );\n return new Response(null, { headers: noStore, status: 204 });\n}\n\n/** The operator's report queue for one collection: newest first, bounded. */\nexport async function listReports(\n collection: ArchiveCollection,\n request: Request,\n env: ArchiveWorkerEnv,\n session: Session,\n): Promise<Response> {\n requireOperatorRow(session);\n const status = new URL(request.url).searchParams.get('status') ?? 'unresolved';\n if (status !== 'unresolved' && status !== 'resolved') throw badRequest();\n const context = `reports:${collection.name}:${status}`;\n const { cursor, limit } = forwardPage(request, context, ['status']);\n const bound = reportsPageSql(\n { collection: collection.name, resolved: status === 'resolved' },\n { cursor, limit: limit + 1 },\n );\n const rows = await all<ReportRow>(env, bound.sql, ...bound.parameters);\n const page = rows.slice(0, limit);\n const last = page.at(-1);\n const parsed = archiveReportsSchema.safeParse({\n cursor:\n rows.length > limit && last !== undefined\n ? nextCursor({ createdAt: last.created_at, id: last.id }, context)\n : null,\n reports: page.map((row) => ({\n createdAt: row.created_at,\n entryId: row.publication_id,\n entryTitle: row.entry_title,\n id: row.id,\n reason: row.reason,\n reporter: { id: row.reporter_id, name: row.reporter_name },\n resolvedAt: row.resolved_at,\n })),\n });\n if (!parsed.success) throw recordUnavailable();\n return json(200, parsed.data, noStore);\n}\n\ninterface ReportRow {\n collection: string;\n created_at: string;\n entry_title: string;\n id: string;\n publication_id: string;\n reason: string;\n reporter_id: string;\n reporter_name: string;\n resolved_at: string | null;\n}\n\nexport async function resolveReport(\n collection: ArchiveCollection,\n env: ArchiveWorkerEnv,\n session: Session,\n id: string,\n): Promise<Response> {\n requireOperatorRow(session);\n const row = await first<{ id: string }>(env, SQL.reportById, id, collection.name);\n if (row === null) throw notFound();\n await run(env, SQL.resolveReport, now(), id, collection.name);\n return new Response(null, { headers: noStore, status: 204 });\n}\n\nexport async function myReports(request: Request, env: ArchiveWorkerEnv, session: Session): Promise<Response> {\n const context = `own-reports:${session.publisher?.id ?? 'unregistered'}`;\n const { cursor, limit } = forwardPage(request, context);\n if (session.publisher === null) return json(200, { cursor: null, reports: [] }, noStore);\n const bound = reportsPageSql({ publisherId: session.publisher.id }, { cursor, limit: limit + 1 });\n const rows = await all<ReportRow>(env, bound.sql, ...bound.parameters);\n const page = rows.slice(0, limit);\n const last = page.at(-1);\n const parsed = archiveOwnReportsSchema.safeParse({\n cursor:\n rows.length > limit && last !== undefined\n ? nextCursor({ createdAt: last.created_at, id: last.id }, context)\n : null,\n reports: page.map((row) => ({\n collection: row.collection,\n createdAt: row.created_at,\n entryId: row.publication_id,\n id: row.id,\n reason: row.reason,\n resolvedAt: row.resolved_at,\n })),\n });\n if (!parsed.success) throw recordUnavailable();\n return json(200, parsed.data, noStore);\n}\n"],"mappings":"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"}