create-ailk 0.18.0 → 0.19.1

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 (218) hide show
  1. package/LICENSE +6 -5
  2. package/NOTICE +2 -0
  3. package/dist/add-module.js +11 -3
  4. package/dist/category-profile.d.ts +10 -0
  5. package/dist/category-profile.js +21 -0
  6. package/dist/derive-architecture.d.ts +5 -1
  7. package/dist/derive-architecture.js +8 -3
  8. package/dist/index.d.ts +1 -1
  9. package/dist/index.js +1 -1
  10. package/dist/lib/license-key-files.d.ts +6 -3
  11. package/dist/lib/license-key-files.js +8 -2
  12. package/dist/lib/scaffold-render-sweep.d.ts +32 -0
  13. package/dist/lib/scaffold-render-sweep.js +76 -3
  14. package/dist/license-set.d.ts +1 -1
  15. package/dist/license-set.js +13 -8
  16. package/dist/module-architecture.js +128 -3
  17. package/dist/programmatic.js +11 -1
  18. package/dist/site-roster.js +21 -4
  19. package/dist/spoke-checkout.d.ts +11 -3
  20. package/dist/spoke-checkout.js +62 -12
  21. package/dist/starter-grid.d.ts +1 -1
  22. package/dist/surfaces.d.ts +14 -0
  23. package/dist/surfaces.js +170 -1
  24. package/dist/template-manifest.d.ts +4 -3
  25. package/dist/topology.d.ts +7 -2
  26. package/dist/topology.js +88 -3
  27. package/package.json +5 -4
  28. package/templates/.env.example +3 -3
  29. package/templates/.github/workflows/ci.yml +5 -0
  30. package/templates/apps/api/.env.example +17 -1
  31. package/templates/apps/api/CLAUDE.md +31 -7
  32. package/templates/apps/api/LICENSE +6 -5
  33. package/templates/apps/api/NOTICE +2 -0
  34. package/templates/apps/api/jest.config.cjs +4 -29
  35. package/templates/apps/api/package.json +1 -0
  36. package/templates/apps/api/src/__tests__/factories/in-memory-prisma.ts +19 -0
  37. package/templates/apps/api/src/__tests__/stripe-boot.test.ts +132 -0
  38. package/templates/apps/api/src/lib/__mocks__/prisma.ts +17 -0
  39. package/templates/apps/api/src/lib/__tests__/consent-ledger-double.ts +237 -0
  40. package/templates/apps/api/src/lib/__tests__/consent-store.test.ts +334 -0
  41. package/templates/apps/api/src/lib/__tests__/deliverable-asset-store.test.ts +38 -0
  42. package/templates/apps/api/src/lib/__tests__/stripe-admission.test.ts +217 -0
  43. package/templates/apps/api/src/lib/__tests__/stripe.test.ts +7 -0
  44. package/templates/apps/api/src/lib/consent-store.ts +335 -0
  45. package/templates/apps/api/src/lib/deliverable-asset-store.ts +5 -4
  46. package/templates/apps/api/src/lib/stripe.ts +176 -3
  47. package/templates/apps/api/src/middleware/__tests__/auth.hub-session-path.test.ts +272 -39
  48. package/templates/apps/api/src/middleware/__tests__/rate-limit.test.ts +51 -0
  49. package/templates/apps/api/src/middleware/auth.ts +214 -46
  50. package/templates/apps/api/src/middleware/rate-limit.ts +5 -0
  51. package/templates/apps/api/src/routes/checkout/__tests__/live-key.test.ts +167 -0
  52. package/templates/apps/api/src/routes/checkout/session.ts +3 -3
  53. package/templates/apps/api/src/routes/checkout/sessions.ts +6 -4
  54. package/templates/apps/api/src/routes/consent/cookie.ts +90 -0
  55. package/templates/apps/api/src/routes/consent/index.ts +18 -0
  56. package/templates/apps/api/src/routes/consent/unsubscribe.ts +127 -0
  57. package/templates/apps/api/src/routes/leads/index.ts +19 -0
  58. package/templates/apps/api/src/routes/project-listings/__tests__/drafts.test.ts +5 -0
  59. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-flow-checkout.test.ts +17 -4
  60. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-idempotency.test.ts +2 -0
  61. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-livemode-live.test.ts +159 -0
  62. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-livemode.test.ts +158 -0
  63. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-rate-limit.test.ts +2 -0
  64. package/templates/apps/api/src/routes/webhooks/stripe.ts +17 -0
  65. package/templates/apps/api/src/server.ts +218 -16
  66. package/templates/apps/api/src/services/__tests__/consent-ledger.test.ts +503 -0
  67. package/templates/apps/api/src/services/__tests__/consent.test.ts +18 -3
  68. package/templates/apps/api/src/services/__tests__/lead-routing.test.ts +173 -4
  69. package/templates/apps/api/src/services/__tests__/person.test.ts +392 -0
  70. package/templates/apps/api/src/services/__tests__/request-confirm.test.ts +87 -0
  71. package/templates/apps/api/src/services/__tests__/revalidation.test.ts +57 -17
  72. package/templates/apps/api/src/services/consent.ts +488 -3
  73. package/templates/apps/api/src/services/lead-routing.ts +135 -14
  74. package/templates/apps/api/src/services/person.ts +501 -1
  75. package/templates/apps/api/src/services/project-listings.ts +7 -2
  76. package/templates/apps/api/src/services/request-confirm.ts +52 -0
  77. package/templates/apps/api/src/services/revalidation.ts +11 -1
  78. package/templates/apps/api/vercel.json +2 -2
  79. package/templates/apps/mcp/.env.example +10 -0
  80. package/templates/apps/mcp/CLAUDE.md +9 -4
  81. package/templates/apps/mcp/LICENSE +6 -5
  82. package/templates/apps/mcp/NOTICE +2 -0
  83. package/templates/apps/mcp/__tests__/abuse/guard.test.ts +198 -0
  84. package/templates/apps/mcp/__tests__/audit-single-page.test.ts +15 -3
  85. package/templates/apps/mcp/__tests__/boot.test.ts +78 -1
  86. package/templates/apps/mcp/__tests__/http-transport.test.ts +291 -2
  87. package/templates/apps/mcp/__tests__/request_callback.test.ts +69 -7
  88. package/templates/apps/mcp/__tests__/submit_lead.test.ts +69 -8
  89. package/templates/apps/mcp/__tests__/subscribe_newsletter.test.ts +69 -7
  90. package/templates/apps/mcp/jest.config.cjs +3 -44
  91. package/templates/apps/mcp/src/abuse/index.ts +117 -21
  92. package/templates/apps/mcp/src/boot.ts +5 -23
  93. package/templates/apps/mcp/src/http-transport.ts +93 -73
  94. package/templates/apps/mcp/src/index.ts +2 -0
  95. package/templates/apps/mcp/src/request-context.ts +52 -0
  96. package/templates/apps/mcp/src/tools/capture_lead.ts +9 -1
  97. package/templates/apps/mcp/src/tools/request_callback.ts +9 -1
  98. package/templates/apps/mcp/src/tools/subscribe_newsletter.ts +9 -1
  99. package/templates/apps/web/LICENSE +6 -5
  100. package/templates/apps/web/NOTICE +2 -0
  101. package/templates/apps/web/app/.well-known/ai-plugin.json/route.ts +3 -3
  102. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/account-frame-sign-out.test.tsx +175 -0
  103. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/billing.test.tsx +302 -0
  104. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/general.test.tsx +724 -0
  105. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/hub-mode.test.tsx +346 -41
  106. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/item.test.tsx +11 -13
  107. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/layout-sign-out.test.tsx +146 -0
  108. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/layout.test.tsx +232 -29
  109. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/new-menu.test.tsx +305 -0
  110. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/new-popups.test.tsx +82 -0
  111. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/notifications.test.tsx +140 -0
  112. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/page.test.tsx +163 -72
  113. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/popups.test.tsx +451 -0
  114. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/security.test.tsx +409 -0
  115. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/settings.test.tsx +65 -380
  116. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/support.test.tsx +16 -11
  117. package/templates/apps/web/app/[locale]/(authed)/account/account-data.ts +1 -1
  118. package/templates/apps/web/app/[locale]/(authed)/account/account-frame.tsx +188 -54
  119. package/templates/apps/web/app/[locale]/(authed)/account/actions.ts +24 -45
  120. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/[pieceId]/page.tsx +253 -0
  121. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/[pieceId]/review-form.tsx +145 -0
  122. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/[pieceId]/versions/[version]/file/route.ts +100 -0
  123. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/__tests__/file-route.test.ts +179 -0
  124. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/__tests__/page.test.tsx +514 -0
  125. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/__tests__/review.test.tsx +288 -0
  126. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/artifact-actions.ts +123 -0
  127. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/artifact-data.ts +82 -0
  128. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/page.tsx +174 -0
  129. package/templates/apps/web/app/[locale]/(authed)/account/billing/page.tsx +172 -0
  130. package/templates/apps/web/app/[locale]/(authed)/account/billing-source.ts +147 -0
  131. package/templates/apps/web/app/[locale]/(authed)/account/components/request-quote.tsx +229 -0
  132. package/templates/apps/web/app/[locale]/(authed)/account/components/schedule-service.tsx +276 -0
  133. package/templates/apps/web/app/[locale]/(authed)/account/components/send-message.tsx +128 -0
  134. package/templates/apps/web/app/[locale]/(authed)/account/general/billing-anchor.tsx +27 -0
  135. package/templates/apps/web/app/[locale]/(authed)/account/general/contact-actions.ts +81 -0
  136. package/templates/apps/web/app/[locale]/(authed)/account/general/deletion-request.tsx +132 -0
  137. package/templates/apps/web/app/[locale]/(authed)/account/general/page.tsx +193 -0
  138. package/templates/apps/web/app/[locale]/(authed)/account/general/profile-form.tsx +104 -0
  139. package/templates/apps/web/app/[locale]/(authed)/account/general/profile-source.ts +73 -0
  140. package/templates/apps/web/app/[locale]/(authed)/account/inbox/message/[id]/file/__tests__/route.test.ts +18 -8
  141. package/templates/apps/web/app/[locale]/(authed)/account/inbox/message/[id]/file/route.ts +4 -6
  142. package/templates/apps/web/app/[locale]/(authed)/account/layout.tsx +30 -4
  143. package/templates/apps/web/app/[locale]/(authed)/account/new-actions.ts +219 -0
  144. package/templates/apps/web/app/[locale]/(authed)/account/new-menu.tsx +340 -0
  145. package/templates/apps/web/app/[locale]/(authed)/account/new-popups.tsx +107 -0
  146. package/templates/apps/web/app/[locale]/(authed)/account/notifications/page.tsx +57 -0
  147. package/templates/apps/web/app/[locale]/(authed)/account/page.tsx +85 -26
  148. package/templates/apps/web/app/[locale]/(authed)/account/profile-actions.ts +116 -0
  149. package/templates/apps/web/app/[locale]/(authed)/account/security/page.tsx +132 -0
  150. package/templates/apps/web/app/[locale]/(authed)/account/security/security-panel.tsx +102 -0
  151. package/templates/apps/web/app/[locale]/(authed)/account/security/security-source.ts +62 -0
  152. package/templates/apps/web/app/[locale]/(authed)/account/settings/page.tsx +13 -354
  153. package/templates/apps/web/app/[locale]/(authed)/account/settings-frame.tsx +116 -0
  154. package/templates/apps/web/app/[locale]/(authed)/account/sign-in-method.ts +37 -0
  155. package/templates/apps/web/app/[locale]/(authed)/account/support-actions.ts +9 -10
  156. package/templates/apps/web/app/[locale]/layout.tsx +23 -4
  157. package/templates/apps/web/app/api/auth/hub/__tests__/continuation.test.ts +143 -0
  158. package/templates/apps/web/app/api/auth/hub/authorize/__tests__/route.test.ts +62 -10
  159. package/templates/apps/web/app/api/auth/hub/authorize/route.ts +22 -7
  160. package/templates/apps/web/app/api/auth/hub/continuation.ts +73 -0
  161. package/templates/apps/web/app/api/auth/hub/continue/[ref]/__tests__/route.test.ts +188 -0
  162. package/templates/apps/web/app/api/auth/hub/continue/[ref]/route.ts +53 -0
  163. package/templates/apps/web/app/robots.txt/route.ts +10 -4
  164. package/templates/apps/web/auth.ts +25 -2
  165. package/templates/apps/web/lib/__tests__/account-config.test.ts +258 -46
  166. package/templates/apps/web/lib/__tests__/auth-email.test.ts +84 -0
  167. package/templates/apps/web/lib/account-config.ts +188 -52
  168. package/templates/apps/web/lib/auth-email.ts +45 -10
  169. package/templates/apps/web/lib/hub-session/__tests__/hub-session.test.ts +143 -0
  170. package/templates/apps/web/lib/hub-session/index.ts +1 -0
  171. package/templates/apps/web/lib/hub-session/reseal.ts +90 -0
  172. package/templates/apps/web/lib/hub-session/seal.ts +13 -9
  173. package/templates/apps/web/lib/topology.generated.ts +33 -0
  174. package/templates/apps/web/messages/en.json +194 -33
  175. package/templates/database/CHANGELOG.md +37 -0
  176. package/templates/database/LICENSE +6 -5
  177. package/templates/database/NOTICE +2 -0
  178. package/templates/database/README.md +1 -1
  179. package/templates/database/__tests__/consent-ledger.db.test.ts +345 -0
  180. package/templates/database/__tests__/fence-fan-out.db.test.ts +6 -2
  181. package/templates/database/__tests__/fixtures/person-contact-backfill.json +182 -0
  182. package/templates/database/__tests__/member-area.db.test.ts +15 -3
  183. package/templates/database/__tests__/member-contract-migration.test.ts +85 -0
  184. package/templates/database/__tests__/member-contract.db.test.ts +413 -0
  185. package/templates/database/__tests__/person-contact.db.test.ts +563 -0
  186. package/templates/database/__tests__/person.db.test.ts +3 -1
  187. package/templates/database/__tests__/seed.test.ts +54 -2
  188. package/templates/database/inbox/schema.prisma +166 -25
  189. package/templates/database/migrations/20261012120000_member_contract/migration.sql +192 -0
  190. package/templates/database/migrations/20261013120000_lead_spoke_checkout/migration.sql +20 -0
  191. package/templates/database/migrations/20261014120000_consent_ledger/migration.sql +122 -0
  192. package/templates/database/migrations/20261014120000_person_contact/migration.sql +231 -0
  193. package/templates/database/package.json +3 -2
  194. package/templates/database/seed.ts +34 -2
  195. package/versions.json +36 -36
  196. package/templates/apps/api/src/__tests__/content-write-production-boot.test.ts +0 -104
  197. package/templates/apps/api/src/__tests__/cors.test.ts +0 -276
  198. package/templates/apps/api/src/__tests__/module-exclusion.test.ts +0 -279
  199. package/templates/apps/api/src/__tests__/openapi-keyless-hub.test.ts +0 -122
  200. package/templates/apps/api/src/__tests__/review-surface-registration.test.ts +0 -167
  201. package/templates/apps/api/src/__tests__/root-modules.test.ts +0 -175
  202. package/templates/apps/api/src/__tests__/schedule-forwarded-rate-limit.test.ts +0 -117
  203. package/templates/apps/api/src/__tests__/server.test.ts +0 -591
  204. package/templates/apps/api/src/__tests__/workspace-module-registration.test.ts +0 -175
  205. package/templates/apps/api/src/openapi/__tests__/surface-drift.test.ts +0 -293
  206. package/templates/apps/api/src/routes/leads/__tests__/index.test.ts +0 -1620
  207. package/templates/apps/api/src/routes/project-listings/__tests__/directory.test.ts +0 -528
  208. package/templates/apps/api/src/routes/workspaces/__tests__/inbox-wire-schemas.test.ts +0 -198
  209. package/templates/apps/api/src/services/__tests__/aeo-score.test.ts +0 -652
  210. package/templates/apps/api/src/services/__tests__/lead-promotion.test.ts +0 -408
  211. package/templates/apps/api/src/services/__tests__/leads-inbox.test.ts +0 -476
  212. package/templates/apps/mcp/__tests__/audit.test.ts +0 -346
  213. package/templates/apps/mcp/__tests__/parity.test.ts +0 -325
  214. package/templates/apps/mcp/__tests__/workspace-surface.test.ts +0 -364
  215. package/templates/apps/web/app/[locale]/(authed)/account/settings/color-mode.tsx +0 -68
  216. package/templates/apps/web/app/[locale]/(authed)/account/settings/deletion-request.tsx +0 -55
  217. package/templates/apps/web/app/[locale]/(authed)/account/settings/name-form.tsx +0 -118
  218. package/templates/database/__tests__/build.test.ts +0 -131
@@ -33,10 +33,19 @@
33
33
  * (`lib/hub-sign-in-store.ts`). It is admitted only beside the SAME
34
34
  * spoke's key (`x-ailk-spoke-key`), only while its grant is unrevoked and
35
35
  * unexpired, its client enabled and its person's email verified, and only
36
- * on `HUB_SESSION_ROUTES` — an exact list of four reads; any other route
37
- * is 404 (never 403, so the account area's `read()` loads "nothing for
38
- * this caller"). It acts as its person (`request.userId`) and carries
39
- * `request.hubSession` — never `request.sessionTenant`.
36
+ * on `HUB_SESSION_ROUTES` — an exact list of METHOD + registered URL
37
+ * pairs — the credential's own `me` and the member routes, 20 in all
38
+ * (#8220); any other route is 404 (never 403, so the account area's
39
+ * `read()` loads "nothing for this caller"). It acts as its person
40
+ * (`request.userId`) and carries `request.hubSession` — never
41
+ * `request.sessionTenant` — and its request logger names the spoke
42
+ * (`spokeId`). Each person is limited, at each spoke, to 60 requests and
43
+ * 10 writes a minute and 30 writes an hour — a fixed hour window from
44
+ * the bucket's first admitted write (#8220 D2, SEC-8169-4): the bucket is
45
+ * keyed on the VERIFIED spoke and person, never a header, and charged
46
+ * only after every check above passes, so a refused credential or an
47
+ * off-list route never gets one. The write limits refuse only writes,
48
+ * and a request the budget refuses is not counted.
40
49
  *
41
50
  * Every authenticated request carries `request.actor` — the person, or the
42
51
  * agent and the person it acts for — and its request/reply loggers are
@@ -141,21 +150,47 @@ export type HubSession = {
141
150
  };
142
151
 
143
152
  /**
144
- * The only routes a hub-issued credential reaches (#7894 D6): an EXACT list of
145
- * method + registered URL, never a prefix, so a route added later joins only
146
- * by a reviewed edit of this list (SEC-7894-3). The first is the credential's
147
- * own `me`; the other three are #7877's member reads. Not admitted,
148
- * deliberately: `GET /v1/waitlist-signups/me`, `GET /v1/flow-checkouts/me`,
149
- * `GET /v1/subscription/me` and `POST /v1/subscription/portal-session` (they
150
- * answer for the HUB's site and the person's platform billing, SEC-7894-1,
151
- * -2), and the token and profile routes.
153
+ * The only routes a hub-issued credential reaches (#7894 D6, #8220 D1): an
154
+ * EXACT list of method + registered URL, never a prefix and never computed,
155
+ * so a route added later joins only by a reviewed edit of this list
156
+ * (SEC-7894-3). Four groups, in this order: the credential's own `me` and
157
+ * #7877's three member reads; #8169's eight member reads; #8170's six member
158
+ * writes (roadmap §7.2 row order); #8438's member consents read and write.
159
+ * `GET /v1/consent/platform` is not admitted — it is the Admin owner's own
160
+ * consent to Working Theory, on the hub's cookie session. The member profile write IS admitted — it
161
+ * is the person's own account, which roadmap D9 saves through it. Not
162
+ * admitted, deliberately: `GET /v1/waitlist-signups/me`,
163
+ * `GET /v1/flow-checkouts/me`, `GET /v1/subscription/me` and
164
+ * `POST /v1/subscription/portal-session` (they answer for the HUB's site and
165
+ * the person's platform billing, SEC-7894-1, -2), the token routes and the
166
+ * hub's own-account `/v1/user/*` routes, `PATCH /v1/user/profile` among them.
152
167
  */
153
- export const HUB_SESSION_ROUTES: readonly string[] = [
168
+ export const HUB_SESSION_ROUTES: readonly string[] = Object.freeze([
154
169
  "GET /v1/hub-sign-in/me",
155
170
  "GET /v1/member/pieces",
156
171
  "GET /v1/member/messages",
157
172
  "GET /v1/member/messages/:id/file",
158
- ];
173
+ "GET /v1/member/purchases",
174
+ "GET /v1/member/appointments",
175
+ "GET /v1/member/artifacts",
176
+ "GET /v1/member/artifacts/:pieceId/versions/:version/file",
177
+ "GET /v1/member/meeting-types",
178
+ "GET /v1/member/availability",
179
+ "GET /v1/member/request-form",
180
+ "GET /v1/member/billing",
181
+ "POST /v1/member/artifacts/:pieceId/review",
182
+ "POST /v1/member/bookings",
183
+ "POST /v1/member/requests",
184
+ "POST /v1/member/messages",
185
+ "PATCH /v1/member/profile",
186
+ "GET /v1/member/profile/contact",
187
+ "PATCH /v1/member/profile/contact",
188
+ "POST /v1/member/deletion-request",
189
+ // #8438 D6 — the member's own consents. `GET /v1/consent/platform` is NOT
190
+ // admitted: those are the Admin owner's, on the hub's own cookie session.
191
+ "GET /v1/member/consents",
192
+ "POST /v1/member/consents",
193
+ ]);
159
194
 
160
195
  /**
161
196
  * Who made a request (#3591 D9). A cookie session and a personal `ApiToken`
@@ -311,12 +346,13 @@ function bindActor(
311
346
  reply.log = request.log;
312
347
  }
313
348
 
314
- // ── The per-agent limit (#3591 D4 step 5, SEC-3591-04/-14) ─────────────────
349
+ // ── The per-caller limits (#3591 D4 step 5, SEC-3591-04/-14; #8220 D2) ──────
315
350
  //
316
351
  // In the handler, not `onRequest`: the shared limiter runs before any
317
- // identity exists, and a bucket must only ever be keyed on a VERIFIED agent
318
- // id, so an invented key never gets one. The shape of
319
- // `routes/subscription/portal.ts`'s per-user budget.
352
+ // identity exists, and a bucket must only ever be keyed on a VERIFIED id, so
353
+ // an invented key or credential never gets one. The shape of
354
+ // `routes/subscription/portal.ts`'s per-user budget. An agent and a person
355
+ // never share a map.
320
356
 
321
357
  const AGENT_WINDOW_MS = 60_000;
322
358
  const AGENT_REQUESTS_PER_WINDOW = 60;
@@ -324,53 +360,159 @@ const AGENT_WRITES_PER_WINDOW = 10;
324
360
  /** Cap on distinct buckets — the oldest is evicted at capacity (DEF-011's shape). */
325
361
  const MAX_AGENT_BUCKETS = 5_000;
326
362
 
327
- type AgentBucket = { windowStartMs: number; requests: number; writes: number };
328
- const agentBuckets = new Map<string, AgentBucket>();
363
+ const HUB_SESSION_WINDOW_MS = 60_000;
364
+ const HUB_SESSION_REQUESTS_PER_WINDOW = 60;
365
+ const HUB_SESSION_WRITES_PER_WINDOW = 10;
366
+ const HUB_SESSION_WRITE_HOUR_MS = 3_600_000;
367
+ const HUB_SESSION_WRITES_PER_HOUR = 30;
368
+ const MAX_HUB_SESSION_BUCKETS = 5_000;
369
+
370
+ type CallerBucket = {
371
+ windowStartMs: number;
372
+ requests: number;
373
+ writes: number;
374
+ /** The hourly write window — set by the bucket's first admitted write. */
375
+ hourStartMs: number | null;
376
+ hourWrites: number;
377
+ };
378
+
379
+ type CallerLimits = {
380
+ windowMs: number;
381
+ maxBuckets: number;
382
+ requests: number;
383
+ writes: number;
384
+ /** An hourly write ceiling — the hub-credential path's alone. */
385
+ writesPerHour?: { limit: number; windowMs: number };
386
+ /** When true, the write limits refuse only writes; a read meets only `requests`. */
387
+ refuseOnlyWrites: boolean;
388
+ /** When true, a refused request still counts toward every limit. */
389
+ countRefused: boolean;
390
+ };
391
+
392
+ const AGENT_LIMITS: CallerLimits = {
393
+ windowMs: AGENT_WINDOW_MS,
394
+ maxBuckets: MAX_AGENT_BUCKETS,
395
+ requests: AGENT_REQUESTS_PER_WINDOW,
396
+ writes: AGENT_WRITES_PER_WINDOW,
397
+ refuseOnlyWrites: false,
398
+ countRefused: true,
399
+ };
400
+
401
+ const HUB_SESSION_LIMITS: CallerLimits = {
402
+ windowMs: HUB_SESSION_WINDOW_MS,
403
+ maxBuckets: MAX_HUB_SESSION_BUCKETS,
404
+ requests: HUB_SESSION_REQUESTS_PER_WINDOW,
405
+ writes: HUB_SESSION_WRITES_PER_WINDOW,
406
+ writesPerHour: {
407
+ limit: HUB_SESSION_WRITES_PER_HOUR,
408
+ windowMs: HUB_SESSION_WRITE_HOUR_MS,
409
+ },
410
+ refuseOnlyWrites: true,
411
+ countRefused: false,
412
+ };
413
+
414
+ const agentBuckets = new Map<string, CallerBucket>();
415
+ const hubSessionBuckets = new Map<string, CallerBucket>();
329
416
 
330
417
  /** Test-only reset. Not intended for production use. */
331
418
  export function __resetAgentRateBuckets(): void {
332
419
  agentBuckets.clear();
333
420
  }
334
421
 
422
+ /** Test-only reset. Not intended for production use. */
423
+ export function __resetHubSessionRateBuckets(): void {
424
+ hubSessionBuckets.clear();
425
+ }
426
+
335
427
  function isWrite(method: string): boolean {
336
428
  const m = method.toUpperCase();
337
429
  return m !== "GET" && m !== "HEAD";
338
430
  }
339
431
 
340
- /** `undefined` when admitted; the seconds to wait when the agent is over budget. */
341
- function chargeAgentRateLimit(
342
- agentId: string,
432
+ /** A bucket no window still holds — the eviction pass may drop it. */
433
+ function isStale(
434
+ bucket: CallerBucket,
435
+ now: number,
436
+ limits: CallerLimits,
437
+ ): boolean {
438
+ if (now - bucket.windowStartMs < limits.windowMs) return false;
439
+ return (
440
+ bucket.hourStartMs === null ||
441
+ limits.writesPerHour === undefined ||
442
+ now - bucket.hourStartMs >= limits.writesPerHour.windowMs
443
+ );
444
+ }
445
+
446
+ /**
447
+ * Charge one request to `key`'s bucket. `undefined` when admitted; the
448
+ * seconds to wait — to the end of the latest window that refused, at least
449
+ * 1 — when over budget.
450
+ */
451
+ function chargeCallerBucket(
452
+ buckets: Map<string, CallerBucket>,
453
+ key: string,
343
454
  method: string,
344
455
  now: number,
456
+ limits: CallerLimits,
345
457
  ): number | undefined {
346
- let bucket = agentBuckets.get(agentId);
347
- if (!bucket || now - bucket.windowStartMs >= AGENT_WINDOW_MS) {
348
- if (!agentBuckets.has(agentId) && agentBuckets.size >= MAX_AGENT_BUCKETS) {
349
- for (const [id, b] of agentBuckets) {
350
- if (now - b.windowStartMs >= AGENT_WINDOW_MS) agentBuckets.delete(id);
458
+ let bucket = buckets.get(key);
459
+ if (!bucket || now - bucket.windowStartMs >= limits.windowMs) {
460
+ if (!bucket && buckets.size >= limits.maxBuckets) {
461
+ for (const [k, b] of buckets) {
462
+ if (isStale(b, now, limits)) buckets.delete(k);
351
463
  }
352
- if (agentBuckets.size >= MAX_AGENT_BUCKETS) {
353
- const oldest = agentBuckets.keys().next().value;
354
- if (oldest !== undefined) agentBuckets.delete(oldest);
464
+ if (buckets.size >= limits.maxBuckets) {
465
+ const oldest = buckets.keys().next().value;
466
+ if (oldest !== undefined) buckets.delete(oldest);
355
467
  }
356
468
  }
357
- // Re-insert, so Map order stays oldest-window-first for the eviction above.
358
- agentBuckets.delete(agentId);
359
- bucket = { windowStartMs: now, requests: 0, writes: 0 };
360
- agentBuckets.set(agentId, bucket);
469
+ // Re-insert, so Map order stays oldest-window-first for the eviction
470
+ // above. A new minute keeps the hour's start and count.
471
+ buckets.delete(key);
472
+ bucket = {
473
+ windowStartMs: now,
474
+ requests: 0,
475
+ writes: 0,
476
+ hourStartMs: bucket?.hourStartMs ?? null,
477
+ hourWrites: bucket?.hourWrites ?? 0,
478
+ };
479
+ buckets.set(key, bucket);
361
480
  }
362
- bucket.requests += 1;
363
- if (isWrite(method)) bucket.writes += 1;
481
+ const hour = limits.writesPerHour;
364
482
  if (
365
- bucket.requests > AGENT_REQUESTS_PER_WINDOW ||
366
- bucket.writes > AGENT_WRITES_PER_WINDOW
483
+ hour !== undefined &&
484
+ bucket.hourStartMs !== null &&
485
+ now - bucket.hourStartMs >= hour.windowMs
367
486
  ) {
368
- return Math.max(
369
- 1,
370
- Math.ceil((bucket.windowStartMs + AGENT_WINDOW_MS - now) / 1_000),
371
- );
487
+ bucket.hourStartMs = null;
488
+ bucket.hourWrites = 0;
372
489
  }
373
- return undefined;
490
+
491
+ const write = isWrite(method);
492
+ const requests = bucket.requests + 1;
493
+ const writes = bucket.writes + (write ? 1 : 0);
494
+ const hourWrites = bucket.hourWrites + (write ? 1 : 0);
495
+ const minuteEnd = bucket.windowStartMs + limits.windowMs;
496
+ const refusedUntil: number[] = [];
497
+ if (requests > limits.requests) refusedUntil.push(minuteEnd);
498
+ if (write || !limits.refuseOnlyWrites) {
499
+ if (writes > limits.writes) refusedUntil.push(minuteEnd);
500
+ if (write && hour !== undefined && hourWrites > hour.limit) {
501
+ refusedUntil.push((bucket.hourStartMs ?? now) + hour.windowMs);
502
+ }
503
+ }
504
+
505
+ const refused = refusedUntil.length > 0;
506
+ if (!refused || limits.countRefused) {
507
+ bucket.requests = requests;
508
+ bucket.writes = writes;
509
+ if (write && hour !== undefined) {
510
+ bucket.hourWrites = hourWrites;
511
+ bucket.hourStartMs ??= now;
512
+ }
513
+ }
514
+ if (!refused) return undefined;
515
+ return Math.max(1, Math.ceil((Math.max(...refusedUntil) - now) / 1_000));
374
516
  }
375
517
 
376
518
  /**
@@ -412,7 +554,13 @@ async function authenticateAgent(
412
554
  onBehalfOfUserId: personId,
413
555
  });
414
556
 
415
- const retryAfter = chargeAgentRateLimit(agentId, request.method, now);
557
+ const retryAfter = chargeCallerBucket(
558
+ agentBuckets,
559
+ agentId,
560
+ request.method,
561
+ now,
562
+ AGENT_LIMITS,
563
+ );
416
564
  if (retryAfter !== undefined) {
417
565
  reply.code(429).header("Retry-After", String(retryAfter)).send(RATE_LIMITED);
418
566
  return;
@@ -440,7 +588,9 @@ async function authenticateAgent(
440
588
  /**
441
589
  * The hub-credential path (#7894 D6). The credential is never logged and
442
590
  * never echoed: every refusal is a fixed body. (1)-(3) failing is the one 401;
443
- * (4), the route not being on `HUB_SESSION_ROUTES`, is 404 with no handler run.
591
+ * (4), the route not being on `HUB_SESSION_ROUTES`, is 404 with no handler run;
592
+ * (5), the person over budget at this spoke (#8220 D2), is 429 with no
593
+ * handler run.
444
594
  */
445
595
  async function authenticateHubSession(
446
596
  request: FastifyRequest,
@@ -490,6 +640,24 @@ async function authenticateHubSession(
490
640
  workspaceId: spoke.workspaceId as Id<"workspace">,
491
641
  };
492
642
  bindActor(request, reply, { type: "user", userId: request.userId });
643
+ // #8220 D10 — every line the request logger writes after admission names
644
+ // the spoke that acted: its id, never its key or the credential. (Lines a
645
+ // helper writes through the module-level `logger` carry no request binding.)
646
+ request.log = request.log.child({ spokeId: spoke.id });
647
+ reply.log = request.log;
648
+
649
+ // (5) The verified (spoke, person) is within its budget.
650
+ const retryAfter = chargeCallerBucket(
651
+ hubSessionBuckets,
652
+ `${spoke.id}:${grant.userId}`,
653
+ request.method,
654
+ now,
655
+ HUB_SESSION_LIMITS,
656
+ );
657
+ if (retryAfter !== undefined) {
658
+ reply.code(429).header("Retry-After", String(retryAfter)).send(RATE_LIMITED);
659
+ return;
660
+ }
493
661
  }
494
662
 
495
663
  export type AuthPreHandlerOptions = {
@@ -170,6 +170,11 @@ export const FORWARDED_VISITOR_ROUTES: readonly Readonly<{
170
170
  { method: "POST", url: "/v1/leads/callback" },
171
171
  { method: "GET", url: "/v1/schedule/availability" },
172
172
  { method: "POST", url: "/v1/schedule/book" },
173
+ // #8438 D5/D9 — the cookie banner's log and the unsubscribe landing: a
174
+ // site's server forwards each visitor's IP, so one spoke's visitors are
175
+ // counted one by one rather than in one bucket keyed on its egress IP.
176
+ { method: "POST", url: "/v1/consent/cookie" },
177
+ { method: "POST", url: "/v1/consent/unsubscribe" },
173
178
  ];
174
179
 
175
180
  const VISITOR_IP_HEADER = "x-ailk-visitor-ip";
@@ -0,0 +1,167 @@
1
+ /**
2
+ * @file live-key.test.ts
3
+ *
4
+ * The hub checkout with a live key (#8450 AC 1, AC 2): `POST
5
+ * /v1/checkout/sessions` and the return page's `GET
6
+ * /v1/checkout/sessions/:id`. Unlike `sessions.test.ts` and
7
+ * `session.test.ts`, BOTH guards here are real: `lib/stripe.ts` runs as
8
+ * itself (only the two Stripe calls are mocked, so nothing leaves the
9
+ * process), and `@working-theory/stripe-client` runs as itself with
10
+ * `assertSandboxKey` wrapped in a spy, so the test can see whether a route
11
+ * still calls the sandbox-only guard.
12
+ *
13
+ * The process admits a restricted live key once, in `beforeAll`, exactly as
14
+ * `build()` does at boot. Every live-shaped key is composed at runtime from
15
+ * fragments — never a literal (architecture.yaml's live-key pattern).
16
+ */
17
+ import type { FastifyInstance } from "fastify";
18
+
19
+ const mockCreateCheckoutSession = jest.fn();
20
+ const mockRetrieveCheckoutSession = jest.fn();
21
+
22
+ jest.mock("@working-theory/stripe-client", () => {
23
+ const actual = jest.requireActual("@working-theory/stripe-client");
24
+ return { ...actual, assertSandboxKey: jest.fn(actual.assertSandboxKey) };
25
+ });
26
+
27
+ // eslint-disable-next-line no-restricted-syntax -- jest mock factory must be hoisted
28
+ jest.mock("../../../lib/stripe.js", () => ({
29
+ ...jest.requireActual("../../../lib/stripe.js"),
30
+ createCheckoutSession: (...args: unknown[]) => mockCreateCheckoutSession(...args),
31
+ retrieveCheckoutSession: (...args: unknown[]) => mockRetrieveCheckoutSession(...args),
32
+ }));
33
+
34
+ // eslint-disable-next-line no-restricted-syntax -- jest mock factory must be hoisted; the DB boundary, mirrors sessions.test.ts
35
+ jest.mock("../../../lib/prisma.js", () => ({
36
+ prisma: {
37
+ session: { findUnique: jest.fn() },
38
+ user: { findUnique: jest.fn() },
39
+ apiToken: { findUnique: jest.fn(), update: jest.fn() },
40
+ },
41
+ }));
42
+
43
+ import { assertSandboxKey } from "@working-theory/stripe-client";
44
+
45
+ import { config } from "../../../config/index.js";
46
+ import { admitApiStripeKey, stripeKeyMode } from "../../../lib/stripe.js";
47
+ import { __resetBuckets } from "../../../middleware/rate-limit.js";
48
+ import { checkoutRoutes } from "../index";
49
+
50
+ config.FRONTEND_URL = "https://example.com";
51
+ config.TRUSTED_ORIGINS = ["https://example.com"];
52
+
53
+ const TAIL = "FixtureOnlyCheckout";
54
+ const restrictedLiveKey = ["rk", "live", ""].join("_") + TAIL;
55
+ const secretLiveKey = ["sk", "live", ""].join("_") + TAIL;
56
+ const publishableLiveKey = ["pk", "live", ""].join("_") + TAIL;
57
+
58
+ const sandboxSpy = assertSandboxKey as jest.MockedFunction<typeof assertSandboxKey>;
59
+
60
+ const ENV_KEYS = ["STRIPE_SECRET_KEY", "STRIPE_MODE", "VERCEL_ENV", "OSS_API_INTERNAL_SECRET"] as const;
61
+ const savedEnv = Object.fromEntries(ENV_KEYS.map((k) => [k, process.env[k]]));
62
+
63
+ const VALID_BODY = {
64
+ productCode: "ailk-pro",
65
+ successUrl: "https://example.com/success",
66
+ cancelUrl: "https://example.com/cancel",
67
+ };
68
+
69
+ const SESSION_ID = "cs_live_fixture12345";
70
+
71
+ function buildApp(): FastifyInstance {
72
+ const fastify = require("fastify")({ logger: false });
73
+ fastify.register(checkoutRoutes);
74
+ return fastify;
75
+ }
76
+
77
+ describe("the hub checkout — an admitted live key (#8450)", () => {
78
+ let app: FastifyInstance;
79
+
80
+ beforeAll(() => {
81
+ delete process.env["OSS_API_INTERNAL_SECRET"];
82
+ process.env["STRIPE_SECRET_KEY"] = restrictedLiveKey;
83
+ process.env["STRIPE_MODE"] = "live";
84
+ process.env["VERCEL_ENV"] = "production";
85
+ expect(admitApiStripeKey()).toBe("live");
86
+ expect(stripeKeyMode()).toBe("live");
87
+ });
88
+
89
+ afterAll(() => {
90
+ for (const k of ENV_KEYS) {
91
+ if (savedEnv[k] === undefined) delete process.env[k];
92
+ else process.env[k] = savedEnv[k];
93
+ }
94
+ });
95
+
96
+ beforeEach(async () => {
97
+ jest.clearAllMocks();
98
+ __resetBuckets();
99
+ process.env["STRIPE_SECRET_KEY"] = restrictedLiveKey;
100
+ mockCreateCheckoutSession.mockResolvedValue({
101
+ sessionId: "cs_live_fixture12345",
102
+ url: "https://checkout.stripe.com/c/pay/fixture",
103
+ });
104
+ mockRetrieveCheckoutSession.mockResolvedValue({
105
+ id: SESSION_ID,
106
+ status: "complete",
107
+ paymentStatus: "paid",
108
+ currency: "usd",
109
+ amountTotal: 4800,
110
+ lineItems: [],
111
+ });
112
+ app = buildApp();
113
+ await app.ready();
114
+ });
115
+
116
+ afterEach(async () => {
117
+ await app.close();
118
+ });
119
+
120
+ describe("POST /v1/checkout/sessions", () => {
121
+ it("gets past the key check on the restricted live key, without the sandbox-only guard", async () => {
122
+ const res = await app.inject({ method: "POST", url: "/v1/checkout/sessions", payload: VALID_BODY });
123
+
124
+ expect(res.statusCode).toBe(201);
125
+ expect(mockCreateCheckoutSession).toHaveBeenCalledTimes(1);
126
+ expect(sandboxSpy).not.toHaveBeenCalled();
127
+ });
128
+
129
+ it.each([
130
+ ["an unrestricted live key", secretLiveKey],
131
+ ["a publishable live key", publishableLiveKey],
132
+ ])("still refuses %s with 500 live-mode-forbidden and no Stripe call", async (_label, key) => {
133
+ process.env["STRIPE_SECRET_KEY"] = key;
134
+
135
+ const res = await app.inject({ method: "POST", url: "/v1/checkout/sessions", payload: VALID_BODY });
136
+
137
+ expect(res.statusCode).toBe(500);
138
+ expect(res.json()).toEqual({ error: "live-mode-forbidden" });
139
+ expect(res.body).not.toContain(TAIL);
140
+ expect(mockCreateCheckoutSession).not.toHaveBeenCalled();
141
+ });
142
+ });
143
+
144
+ describe("GET /v1/checkout/sessions/:id", () => {
145
+ it("gets past the key check on the restricted live key, without the sandbox-only guard", async () => {
146
+ const res = await app.inject({ method: "GET", url: `/v1/checkout/sessions/${SESSION_ID}` });
147
+
148
+ expect(res.statusCode).toBe(200);
149
+ expect(mockRetrieveCheckoutSession).toHaveBeenCalledTimes(1);
150
+ expect(sandboxSpy).not.toHaveBeenCalled();
151
+ });
152
+
153
+ it.each([
154
+ ["an unrestricted live key", secretLiveKey],
155
+ ["a publishable live key", publishableLiveKey],
156
+ ])("still refuses %s with 500 live-mode-forbidden and no Stripe call", async (_label, key) => {
157
+ process.env["STRIPE_SECRET_KEY"] = key;
158
+
159
+ const res = await app.inject({ method: "GET", url: `/v1/checkout/sessions/${SESSION_ID}` });
160
+
161
+ expect(res.statusCode).toBe(500);
162
+ expect(res.json()).toEqual({ error: "live-mode-forbidden" });
163
+ expect(res.body).not.toContain(TAIL);
164
+ expect(mockRetrieveCheckoutSession).not.toHaveBeenCalled();
165
+ });
166
+ });
167
+ });
@@ -27,7 +27,7 @@
27
27
  * 400 validation-error — `:id` fails the shape check
28
28
  * 404 unknown-session — Stripe raised `resource_missing`
29
29
  * 500 stripe-not-configured — STRIPE_SECRET_KEY is missing/empty
30
- * 500 live-mode-forbidden — STRIPE_SECRET_KEY is a live key
30
+ * 500 live-mode-forbidden — STRIPE_SECRET_KEY is a live key the process did not admit
31
31
  * 500 config-error — STRIPE_SECRET_KEY invalid configuration
32
32
  * 502 checkout-unavailable — Stripe SDK threw an unexpected error
33
33
  */
@@ -35,7 +35,7 @@ import type { FastifyReply, FastifyRequest } from "fastify";
35
35
  import { z } from "zod";
36
36
 
37
37
  import {
38
- assertSandboxKey,
38
+ assertAdmittedStripeKey,
39
39
  retrieveCheckoutSession,
40
40
  StripeConfigError,
41
41
  UnknownSessionError,
@@ -80,7 +80,7 @@ export async function checkoutSessionReadHandler(
80
80
  return reply.status(500).send({ error: "stripe-not-configured" });
81
81
  }
82
82
  try {
83
- assertSandboxKey(stripeSecretKey);
83
+ assertAdmittedStripeKey(stripeSecretKey);
84
84
  } catch {
85
85
  return reply.status(500).send({ error: "live-mode-forbidden" });
86
86
  }
@@ -7,7 +7,9 @@
7
7
  * price opens a subscription session), so this route serves both (#4398).
8
8
  *
9
9
  * Security invariants:
10
- * - Sandbox-only: live Stripe keys are rejected at runtime before any Stripe call.
10
+ * - Admitted key only (#8450): before any Stripe call, a test-mode key or
11
+ * the restricted live key the process admitted at boot passes
12
+ * (`assertAdmittedStripeKey`); every other live key is refused.
11
13
  * - No PII in logs: successUrl/cancelUrl and full session IDs are never logged.
12
14
  * Only the first 8 chars of the session ID appear in log lines.
13
15
  * - Error responses never echo user input (productCode, URLs).
@@ -48,7 +50,7 @@
48
50
  * unauthenticated redirect is off the site origin (#6877)
49
51
  * 404 unknown-product-code — no active Stripe price for the given productCode
50
52
  * 500 stripe-not-configured — STRIPE_SECRET_KEY is missing/empty (no Stripe call)
51
- * 500 live-mode-forbidden — STRIPE_SECRET_KEY is a live key
53
+ * 500 live-mode-forbidden — STRIPE_SECRET_KEY is a live key the process did not admit
52
54
  * 500 config-error — STRIPE_SECRET_KEY invalid configuration (from the SDK wrapper),
53
55
  * or no trusted browser origin is configured when the origin
54
56
  * lock applies (#6877, #7395)
@@ -62,7 +64,7 @@ import { z } from "zod";
62
64
  import { config } from "../../config/index.js";
63
65
  import { siteStamp } from "../../lib/site.js";
64
66
  import {
65
- assertSandboxKey,
67
+ assertAdmittedStripeKey,
66
68
  createCheckoutSession,
67
69
  StripeConfigError,
68
70
  UnknownCustomerError,
@@ -248,7 +250,7 @@ export async function checkoutSessionsHandler(
248
250
  return reply.status(500).send({ error: "stripe-not-configured" });
249
251
  }
250
252
  try {
251
- assertSandboxKey(stripeSecretKey);
253
+ assertAdmittedStripeKey(stripeSecretKey);
252
254
  } catch {
253
255
  return reply.status(500).send({ error: "live-mode-forbidden" });
254
256
  }
@@ -0,0 +1,90 @@
1
+ /**
2
+ * The cookie banner's log (#8438, spec
3
+ * docs/specs/8438-consent-registry-and-ledger-step-spec.md D5).
4
+ *
5
+ * POST /v1/consent/cookie
6
+ * `{ visitorId, choice: "accepted" | "declined", locale }` — one row
7
+ * in the consent ledger against the visitor id, source
8
+ * `cookie_banner`. Answers `201 { recorded: true }`, or
9
+ * `200 { recorded: false }` when the site declares no cookie purpose
10
+ * or the visitor's newest row already records the same choice.
11
+ *
12
+ * The workspace comes from `resolvePublicWorkspace(request)` — the spoke
13
+ * key, else the one-workspace deployment — and NEVER from the body, which is
14
+ * strict and names no site, workspace, person or user. An unresolved
15
+ * workspace is `503 tenant_unresolved`; no database `503
16
+ * consent_store_unavailable`; a failed write the same.
17
+ *
18
+ * Registered at the top level in its own rate-limited scope, outside every
19
+ * module gate: consent logging is part of the never-gated privacy floor.
20
+ * The browser cookie `ailk-consent` stays the operative switch (C3) — this
21
+ * route only records the choice.
22
+ *
23
+ * Logs carry `{ event, outcome }` only: never a visitor id or a statement.
24
+ *
25
+ * MCP parity note: no MCP twin, a recorded exception — it logs a browser's
26
+ * banner choice against a browser-held id, and an agent holds neither
27
+ * (tests/parity/README.md).
28
+ */
29
+ import { consentCookieBodySchema } from "@working-theory/validation";
30
+ import type { FastifyPluginAsync } from "fastify";
31
+
32
+ import { recordCookieChoice } from "../../services/consent.js";
33
+ import { resolvePublicWorkspace } from "../../services/public-site-resolver.js";
34
+
35
+ const EVENT = "consent-cookie";
36
+
37
+ export const consentCookieRoute: FastifyPluginAsync = async (fastify) => {
38
+ fastify.post("/v1/consent/cookie", async (request, reply) => {
39
+ const body = consentCookieBodySchema.safeParse(request.body);
40
+ if (!body.success) {
41
+ return reply.status(400).send({
42
+ error: "invalid_input",
43
+ message: "The request was not valid.",
44
+ });
45
+ }
46
+ if (!process.env["DATABASE_URL"]) {
47
+ return reply.status(503).send({
48
+ error: "consent_store_unavailable",
49
+ message: "Consent cannot be recorded on this site.",
50
+ });
51
+ }
52
+
53
+ const site = await resolvePublicWorkspace(request);
54
+ if (site.kind !== "resolved") {
55
+ request.log.warn(
56
+ { event: EVENT, outcome: "tenant_unresolved" },
57
+ "[consent] refused",
58
+ );
59
+ return reply.status(503).send({
60
+ error: "tenant_unresolved",
61
+ message: "This site is not configured to record consent.",
62
+ });
63
+ }
64
+
65
+ try {
66
+ const result = await recordCookieChoice({
67
+ workspaceId: site.workspaceId,
68
+ visitorId: body.data.visitorId,
69
+ choice: body.data.choice,
70
+ locale: body.data.locale,
71
+ });
72
+ request.log.info(
73
+ { event: EVENT, outcome: result.recorded ? "recorded" : "unchanged" },
74
+ "[consent] cookie choice",
75
+ );
76
+ return reply
77
+ .status(result.recorded ? 201 : 200)
78
+ .send({ recorded: result.recorded });
79
+ } catch {
80
+ request.log.error(
81
+ { event: EVENT, outcome: "failed" },
82
+ "[consent] cookie choice failed",
83
+ );
84
+ return reply.status(503).send({
85
+ error: "consent_store_unavailable",
86
+ message: "Consent cannot be recorded right now.",
87
+ });
88
+ }
89
+ });
90
+ };