create-ailk 0.1.1 → 0.3.0

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 (284) hide show
  1. package/component-catalog.md +210 -52
  2. package/dist/cli.js +0 -0
  3. package/dist/lib/apply-module-patches.d.ts +47 -0
  4. package/dist/lib/apply-module-patches.js +370 -0
  5. package/dist/lib/extract-module-bundle.d.ts +12 -3
  6. package/dist/lib/extract-module-bundle.js +236 -24
  7. package/dist/lib/fetch-module.d.ts +3 -38
  8. package/dist/lib/fetch-module.js +26 -8
  9. package/dist/lib/module-license-gate.d.ts +2 -0
  10. package/dist/lib/module-license-gate.js +1 -0
  11. package/dist/lib/paid-paths.d.ts +16 -0
  12. package/dist/lib/paid-paths.js +90 -0
  13. package/dist/module-architecture.d.ts +139 -23
  14. package/dist/module-architecture.js +668 -25
  15. package/dist/parse-args.d.ts +2 -2
  16. package/dist/parse-args.js +3 -1
  17. package/dist/programmatic.d.ts +2 -1
  18. package/dist/programmatic.js +15 -4
  19. package/dist/surfaces.d.ts +12 -36
  20. package/dist/surfaces.js +35 -5
  21. package/dist/sync-routes.js +31 -1
  22. package/package.json +14 -15
  23. package/templates/.claude/agents/web.md +2 -3
  24. package/templates/.claude/rules/architecture.md +5 -5
  25. package/templates/.claude/skills/README.md +2 -1
  26. package/templates/.env.example +21 -9
  27. package/templates/CONVENTIONS.md +6 -7
  28. package/templates/apps/api/.env.example +9 -0
  29. package/templates/apps/api/CLAUDE.md +35 -4
  30. package/templates/apps/api/package.json +0 -1
  31. package/templates/apps/api/src/__tests__/cors.test.ts +195 -0
  32. package/templates/apps/api/src/__tests__/module-exclusion.test.ts +60 -2
  33. package/templates/apps/api/src/__tests__/vercel-handler.test.ts +159 -0
  34. package/templates/apps/api/src/config/__tests__/modules.test.ts +76 -0
  35. package/templates/apps/api/src/config/index.ts +19 -0
  36. package/templates/apps/api/src/config/modules.ts +12 -14
  37. package/templates/apps/api/src/lib/__mocks__/prisma.ts +13 -0
  38. package/templates/apps/api/src/lib/__tests__/slice-load.test.ts +89 -0
  39. package/templates/apps/api/src/lib/slice-load.ts +90 -0
  40. package/templates/apps/api/src/openapi/__tests__/openapi.test.ts +52 -32
  41. package/templates/apps/api/src/openapi/__tests__/spec-identity.test.ts +182 -0
  42. package/templates/apps/api/src/openapi/__tests__/surface-drift.test.ts +293 -0
  43. package/templates/apps/api/src/openapi/index.ts +9 -3
  44. package/templates/apps/api/src/openapi/spec.ts +505 -65
  45. package/templates/apps/api/src/openapi/surface-drift.ts +195 -0
  46. package/templates/apps/api/src/routes/content/__tests__/create.test.ts +14 -0
  47. package/templates/apps/api/src/routes/content/__tests__/delete.test.ts +14 -0
  48. package/templates/apps/api/src/routes/content/__tests__/update.test.ts +14 -0
  49. package/templates/apps/api/src/routes/content/index.ts +38 -11
  50. package/templates/apps/api/src/routes/project-listings/__tests__/configured-application.test.ts +28 -0
  51. package/templates/apps/api/src/routes/project-listings/__tests__/drafts.test.ts +144 -0
  52. package/templates/apps/api/src/routes/project-listings/__tests__/founder-identity.test.ts +796 -0
  53. package/templates/apps/api/src/routes/project-listings/__tests__/me.test.ts +478 -0
  54. package/templates/apps/api/src/routes/project-listings/__tests__/public.test.ts +38 -0
  55. package/templates/apps/api/src/routes/project-listings/__tests__/resume-email.test.ts +814 -0
  56. package/templates/apps/api/src/routes/project-listings/__tests__/site-answers.test.ts +717 -0
  57. package/templates/apps/api/src/routes/project-listings/__tests__/site-key.test.ts +35 -0
  58. package/templates/apps/api/src/routes/project-listings/__tests__/structured-address.test.ts +484 -0
  59. package/templates/apps/api/src/routes/project-listings/index.ts +52 -10
  60. package/templates/apps/api/src/routes/project-listings/me.ts +86 -0
  61. package/templates/apps/api/src/routes/project-listings/patch-draft.ts +5 -0
  62. package/templates/apps/api/src/routes/project-listings/public.ts +5 -0
  63. package/templates/apps/api/src/routes/project-listings/respond.ts +11 -0
  64. package/templates/apps/api/src/routes/project-listings/resume-token.ts +5 -0
  65. package/templates/apps/api/src/routes/project-listings/start.ts +38 -0
  66. package/templates/apps/api/src/routes/project-listings/submit.ts +5 -0
  67. package/templates/apps/api/src/routes/project-listings/verify-email.ts +150 -0
  68. package/templates/apps/api/src/server.ts +471 -133
  69. package/templates/apps/api/src/services/__tests__/consent-migration.test.ts +65 -0
  70. package/templates/apps/api/src/services/__tests__/consent.test.ts +282 -0
  71. package/templates/apps/api/src/services/consent.ts +236 -0
  72. package/templates/apps/api/src/services/deliverable-fulfillment.ts +10 -0
  73. package/templates/apps/api/src/services/listing-config.ts +58 -0
  74. package/templates/apps/api/src/services/project-listing-resume-email.ts +326 -0
  75. package/templates/apps/api/src/services/project-listings.ts +602 -29
  76. package/templates/apps/api/src/vercel-handler.ts +60 -26
  77. package/templates/apps/mcp/.env.example +8 -0
  78. package/templates/apps/mcp/CLAUDE.md +2 -2
  79. package/templates/apps/mcp/__tests__/catalog-drift.test.ts +21 -0
  80. package/templates/apps/mcp/__tests__/config/modules.test.ts +78 -0
  81. package/templates/apps/mcp/__tests__/module-exclusion.test.ts +1 -1
  82. package/templates/apps/mcp/src/config/modules.ts +14 -13
  83. package/templates/apps/mcp/src/server.ts +8 -4
  84. package/templates/apps/mcp/src/tools/index.ts +7 -22
  85. package/templates/apps/web/.env.example +15 -0
  86. package/templates/apps/web/app/[locale]/(authed)/{waitlist/__tests__ → __tests__}/gate.test.tsx +4 -4
  87. package/templates/apps/web/app/[locale]/flows/[slug]/FlowStepperClient.tsx +2 -1
  88. package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/FlowStepperClient.permalink.test.tsx +2 -1
  89. package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/FlowStepperClient.test.tsx +2 -1
  90. package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/flow.actions.test.ts +1 -0
  91. package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/two-route-tool.test.tsx +1 -0
  92. package/templates/apps/web/app/[locale]/flows/[slug]/flow.actions.ts +2 -1
  93. package/templates/apps/web/app/[locale]/flows/[slug]/page.tsx +1 -0
  94. package/templates/apps/web/app/[locale]/flows/[slug]/start/page.tsx +1 -0
  95. package/templates/apps/web/app/[locale]/layout.tsx +13 -1
  96. package/templates/apps/web/app/llms.txt/route.ts +12 -9
  97. package/templates/apps/web/jest.config.cjs +20 -13
  98. package/templates/apps/web/lib/__tests__/site-theme.test.ts +112 -0
  99. package/templates/apps/web/lib/site-brand.tsx +4 -1
  100. package/templates/apps/web/lib/site-theme.ts +74 -0
  101. package/templates/apps/web/next.config.mjs +1 -3
  102. package/templates/apps/web/package.json +1 -6
  103. package/templates/apps/web/public/android-chrome-192x192.png +0 -0
  104. package/templates/apps/web/public/android-chrome-512x512.png +0 -0
  105. package/templates/apps/web/public/apple-touch-icon.png +0 -0
  106. package/templates/apps/web/public/favicon-16x16.png +0 -0
  107. package/templates/apps/web/public/favicon-32x32.png +0 -0
  108. package/templates/apps/web/public/favicon.ico +0 -0
  109. package/templates/apps/web/public/favicon.svg +6 -3
  110. package/templates/apps/web/public/lockup-horizontal.svg +4 -0
  111. package/templates/apps/web/public/logomark.svg +4 -0
  112. package/templates/apps/web/public/site.webmanifest +2 -2
  113. package/templates/apps/web/public/wordmark.svg +4 -0
  114. package/templates/content/_site.mdx +12 -0
  115. package/templates/database/CHANGELOG.md +94 -0
  116. package/templates/database/inbox/schema.prisma +165 -0
  117. package/templates/database/migrations/20260911140000_listing_structured_address/migration.sql +32 -0
  118. package/templates/database/migrations/20260911180000_consent_grants/migration.sql +71 -0
  119. package/templates/database/migrations/20260911200000_listing_site_answers/migration.sql +30 -0
  120. package/templates/database/migrations/20260912120000_listing_owner_link/migration.sql +49 -0
  121. package/templates/database/migrations/20260914120000_listing_founder_identity/migration.sql +123 -0
  122. package/templates/database/package.json +1 -1
  123. package/templates/database/scripts/db-generate-locked.sh +0 -0
  124. package/templates/package.json +1 -1
  125. package/templates/.claude/skills/scaffold-commerce/SKILL.md +0 -807
  126. package/templates/.claude/skills/scaffold-commerce/references/lookup-keys-template.md +0 -71
  127. package/templates/.claude/skills/scaffold-commerce/references/marketplace-brand-pages.md +0 -83
  128. package/templates/.claude/skills/scaffold-commerce/templates/checkout-route.template.ts +0 -370
  129. package/templates/.claude/skills/scaffold-commerce/templates/invoice-route.template.ts +0 -376
  130. package/templates/.claude/skills/scaffold-commerce/templates/marketplace-cta-route.tsx.tmpl +0 -72
  131. package/templates/.claude/skills/scaffold-commerce/templates/payment-link-route.template.ts +0 -471
  132. package/templates/.claude/skills/scaffold-commerce/templates/portal-route.template.ts +0 -365
  133. package/templates/.claude/skills/scaffold-commerce/templates/stripe-config.template.ts +0 -60
  134. package/templates/.claude/skills/scaffold-commerce/templates/subscription-route.template.ts +0 -446
  135. package/templates/apps/api/src/__tests__/server.test.ts +0 -253
  136. package/templates/apps/api/src/bin/deliverable-resend.ts +0 -144
  137. package/templates/apps/api/src/bin/deliverable-upload.ts +0 -114
  138. package/templates/apps/api/src/bin/followup-sweep.ts +0 -44
  139. package/templates/apps/api/src/bin/listing-csv-sweep.ts +0 -46
  140. package/templates/apps/api/src/bin/seed-presets.ts +0 -58
  141. package/templates/apps/api/src/bin/seed-waitlist-experiments.ts +0 -104
  142. package/templates/apps/api/src/bin/session-retention-sweep.ts +0 -46
  143. package/templates/apps/api/src/lib/__tests__/stripe.test.ts +0 -74
  144. package/templates/apps/api/src/lib/stripe.ts +0 -52
  145. package/templates/apps/api/src/lib/tenant-db.ts +0 -225
  146. package/templates/apps/api/src/lib/ws-token.ts +0 -91
  147. package/templates/apps/api/src/middleware/tenant.ts +0 -99
  148. package/templates/apps/api/src/routes/billing/__tests__/portal.test.ts +0 -450
  149. package/templates/apps/api/src/routes/billing/__tests__/read.test.ts +0 -262
  150. package/templates/apps/api/src/routes/billing/__tests__/usage.test.ts +0 -436
  151. package/templates/apps/api/src/routes/billing/index.ts +0 -36
  152. package/templates/apps/api/src/routes/billing/portal.ts +0 -182
  153. package/templates/apps/api/src/routes/billing/read.ts +0 -75
  154. package/templates/apps/api/src/routes/billing/usage.ts +0 -153
  155. package/templates/apps/api/src/routes/checkout/__tests__/sessions.test.ts +0 -687
  156. package/templates/apps/api/src/routes/checkout/index.ts +0 -13
  157. package/templates/apps/api/src/routes/checkout/sessions.ts +0 -197
  158. package/templates/apps/api/src/routes/deliverables/__tests__/index.test.ts +0 -393
  159. package/templates/apps/api/src/routes/deliverables/index.ts +0 -200
  160. package/templates/apps/api/src/routes/flow-checkouts/__tests__/index.test.ts +0 -443
  161. package/templates/apps/api/src/routes/flow-checkouts/index.ts +0 -82
  162. package/templates/apps/api/src/routes/flows/README.md +0 -147
  163. package/templates/apps/api/src/routes/flows/__tests__/index.test.ts +0 -752
  164. package/templates/apps/api/src/routes/flows/__tests__/recommender.test.ts +0 -671
  165. package/templates/apps/api/src/routes/flows/index.ts +0 -202
  166. package/templates/apps/api/src/routes/flows/recommender.ts +0 -443
  167. package/templates/apps/api/src/routes/project-listings/__tests__/copy-edit.test.ts +0 -815
  168. package/templates/apps/api/src/routes/project-listings/__tests__/tenant-isolation.test.ts +0 -566
  169. package/templates/apps/api/src/routes/project-listings/get.ts +0 -70
  170. package/templates/apps/api/src/routes/project-listings/list.ts +0 -47
  171. package/templates/apps/api/src/routes/project-listings/patch.ts +0 -136
  172. package/templates/apps/api/src/routes/schedule/__tests__/index.test.ts +0 -490
  173. package/templates/apps/api/src/routes/schedule/index.ts +0 -249
  174. package/templates/apps/api/src/routes/slack/__tests__/actions.test.ts +0 -385
  175. package/templates/apps/api/src/routes/slack/actions.ts +0 -177
  176. package/templates/apps/api/src/routes/slack/index.ts +0 -38
  177. package/templates/apps/api/src/routes/waitlist-experiments/__tests__/tenant-isolation.test.ts +0 -577
  178. package/templates/apps/api/src/routes/waitlist-experiments/comparison.ts +0 -78
  179. package/templates/apps/api/src/routes/waitlist-experiments/index.ts +0 -41
  180. package/templates/apps/api/src/routes/waitlist-experiments/list.ts +0 -64
  181. package/templates/apps/api/src/routes/waitlist-experiments/respond.ts +0 -42
  182. package/templates/apps/api/src/routes/waitlist-experiments/signup.ts +0 -84
  183. package/templates/apps/api/src/routes/waitlist-experiments/signups.ts +0 -119
  184. package/templates/apps/api/src/routes/waitlist-signups/__tests__/index.test.ts +0 -238
  185. package/templates/apps/api/src/routes/waitlist-signups/index.ts +0 -101
  186. package/templates/apps/api/src/routes/webhooks/README.md +0 -80
  187. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-flow-checkout.test.ts +0 -315
  188. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-idempotency.test.ts +0 -247
  189. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-org-billing.test.ts +0 -270
  190. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-rate-limit.test.ts +0 -198
  191. package/templates/apps/api/src/routes/webhooks/__tests__/stripe.test.ts +0 -633
  192. package/templates/apps/api/src/routes/webhooks/index.ts +0 -41
  193. package/templates/apps/api/src/routes/webhooks/stripe.ts +0 -481
  194. package/templates/apps/api/src/routes/workspaces/__tests__/config-tenant-isolation.test.ts +0 -356
  195. package/templates/apps/api/src/routes/workspaces/__tests__/tenant-isolation.test.ts +0 -671
  196. package/templates/apps/api/src/routes/workspaces/config.ts +0 -125
  197. package/templates/apps/api/src/routes/workspaces/index.ts +0 -194
  198. package/templates/apps/api/src/routes/workspaces/sessions.ts +0 -218
  199. package/templates/apps/api/src/services/__tests__/flow-aggregate.test.ts +0 -425
  200. package/templates/apps/api/src/services/__tests__/flow-engine.test.ts +0 -2840
  201. package/templates/apps/api/src/services/__tests__/lead-promotion.test.ts +0 -393
  202. package/templates/apps/api/src/services/__tests__/listing-csv-sweep.test.ts +0 -560
  203. package/templates/apps/api/src/services/__tests__/playbook-compile.test.ts +0 -406
  204. package/templates/apps/api/src/services/__tests__/playbook-render.test.ts +0 -290
  205. package/templates/apps/api/src/services/__tests__/project-listing-decision.test.ts +0 -736
  206. package/templates/apps/api/src/services/__tests__/project-listing-flow.test.ts +0 -475
  207. package/templates/apps/api/src/services/__tests__/project-listing-issue.test.ts +0 -340
  208. package/templates/apps/api/src/services/__tests__/recommender-capture.test.ts +0 -983
  209. package/templates/apps/api/src/services/__tests__/tenant-context-cascade.test.ts +0 -71
  210. package/templates/apps/api/src/services/__tests__/tenant-context.test.ts +0 -379
  211. package/templates/apps/api/src/services/__tests__/usage-metering.test.ts +0 -768
  212. package/templates/apps/api/src/services/__tests__/waitlist-dashboard.test.ts +0 -1314
  213. package/templates/apps/api/src/services/__tests__/waitlist-experiments.test.ts +0 -341
  214. package/templates/apps/api/src/services/__tests__/waitlist-followup.test.ts +0 -567
  215. package/templates/apps/api/src/services/__tests__/waitlist-scoring.test.ts +0 -474
  216. package/templates/apps/api/src/services/__tests__/waitlist-signups.test.ts +0 -354
  217. package/templates/apps/api/src/services/flow-aggregate.ts +0 -232
  218. package/templates/apps/api/src/services/flow-checkouts.ts +0 -123
  219. package/templates/apps/api/src/services/flow-engine.ts +0 -1278
  220. package/templates/apps/api/src/services/lead-promotion.ts +0 -176
  221. package/templates/apps/api/src/services/listing-csv-sweep.ts +0 -455
  222. package/templates/apps/api/src/services/playbook-compile.ts +0 -398
  223. package/templates/apps/api/src/services/playbook-render.ts +0 -263
  224. package/templates/apps/api/src/services/project-listing-decision.ts +0 -490
  225. package/templates/apps/api/src/services/project-listing-flow.ts +0 -426
  226. package/templates/apps/api/src/services/project-listing-issue.ts +0 -251
  227. package/templates/apps/api/src/services/recommender-capture.ts +0 -835
  228. package/templates/apps/api/src/services/scheduling/cal-provider.ts +0 -392
  229. package/templates/apps/api/src/services/scheduling/index.ts +0 -63
  230. package/templates/apps/api/src/services/scheduling/types.ts +0 -88
  231. package/templates/apps/api/src/services/tenant-context.ts +0 -451
  232. package/templates/apps/api/src/services/usage-metering.ts +0 -699
  233. package/templates/apps/api/src/services/waitlist-dashboard.ts +0 -947
  234. package/templates/apps/api/src/services/waitlist-experiments.ts +0 -213
  235. package/templates/apps/api/src/services/waitlist-followup.ts +0 -486
  236. package/templates/apps/api/src/services/waitlist-scoring.ts +0 -166
  237. package/templates/apps/api/src/services/waitlist-signups.ts +0 -165
  238. package/templates/apps/mcp/__tests__/schedule_tools.test.ts +0 -242
  239. package/templates/apps/mcp/src/tools/create_booking.ts +0 -58
  240. package/templates/apps/mcp/src/tools/get_event_meta.ts +0 -55
  241. package/templates/apps/mcp/src/tools/get_flow.ts +0 -61
  242. package/templates/apps/mcp/src/tools/list_availability.ts +0 -67
  243. package/templates/apps/mcp/src/tools/submit_flow_step.ts +0 -83
  244. package/templates/apps/web/app/[locale]/(authed)/waitlist/[experimentId]/page.tsx +0 -309
  245. package/templates/apps/web/app/[locale]/(authed)/waitlist/[experimentId]/signups/[leadId]/page.tsx +0 -149
  246. package/templates/apps/web/app/[locale]/(authed)/waitlist/__tests__/comparison.test.tsx +0 -273
  247. package/templates/apps/web/app/[locale]/(authed)/waitlist/__tests__/list.test.tsx +0 -171
  248. package/templates/apps/web/app/[locale]/(authed)/waitlist/__tests__/signup.test.tsx +0 -179
  249. package/templates/apps/web/app/[locale]/(authed)/waitlist/components/ExperimentsTable.tsx +0 -116
  250. package/templates/apps/web/app/[locale]/(authed)/waitlist/components/OfferFunnelTable.tsx +0 -66
  251. package/templates/apps/web/app/[locale]/(authed)/waitlist/components/RollupTicker.tsx +0 -61
  252. package/templates/apps/web/app/[locale]/(authed)/waitlist/components/ScoreTrace.tsx +0 -114
  253. package/templates/apps/web/app/[locale]/(authed)/waitlist/components/SignupsTable.tsx +0 -139
  254. package/templates/apps/web/app/[locale]/(authed)/waitlist/components/WaitlistComparisonTabs.tsx +0 -85
  255. package/templates/apps/web/app/[locale]/(authed)/waitlist/components/status-badges.tsx +0 -67
  256. package/templates/apps/web/app/[locale]/(authed)/waitlist/page.tsx +0 -179
  257. package/templates/apps/web/app/[locale]/blog/[[...slug]]/page.tsx +0 -178
  258. package/templates/apps/web/app/[locale]/dev/purchase/__tests__/actions.test.ts +0 -160
  259. package/templates/apps/web/app/[locale]/dev/purchase/error.tsx +0 -25
  260. package/templates/apps/web/app/[locale]/dev/purchase/page.tsx +0 -117
  261. package/templates/apps/web/app/[locale]/dev/purchase/purchase.actions.ts +0 -58
  262. package/templates/apps/web/app/[locale]/docs/[[...slug]]/page.tsx +0 -119
  263. package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/checkout.actions.test.ts +0 -287
  264. package/templates/apps/web/app/[locale]/flows/[slug]/checkout.actions.ts +0 -254
  265. package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/ResultsClient.tsx +0 -532
  266. package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/ResultsEmailStep.tsx +0 -121
  267. package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/__tests__/ResultsClient.checkout.test.tsx +0 -457
  268. package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/__tests__/page.test.tsx +0 -396
  269. package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/page.tsx +0 -114
  270. package/templates/apps/web/app/[locale]/projects/apply/ListingApplicationClient.tsx +0 -110
  271. package/templates/apps/web/app/[locale]/projects/apply/__tests__/ListingApplicationClient.test.tsx +0 -96
  272. package/templates/apps/web/app/[locale]/schedule/__tests__/page.test.tsx +0 -117
  273. package/templates/apps/web/app/[locale]/schedule/confirmed/__tests__/page.test.tsx +0 -96
  274. package/templates/apps/web/app/[locale]/schedule/confirmed/page.tsx +0 -92
  275. package/templates/apps/web/app/[locale]/schedule/page.tsx +0 -82
  276. package/templates/apps/web/content/en/blog/README.txt +0 -25
  277. package/templates/apps/web/content/en/docs/index.mdx +0 -28
  278. package/templates/apps/web/lib/__mocks__/source-server.js +0 -31
  279. package/templates/apps/web/lib/blog-list-data.ts +0 -95
  280. package/templates/apps/web/lib/blog-post-data.ts +0 -79
  281. package/templates/apps/web/lib/source.ts +0 -34
  282. package/templates/apps/web/public/logomark-dark.svg +0 -4
  283. package/templates/apps/web/public/logomark-light.svg +0 -4
  284. package/templates/apps/web/source.config.ts +0 -78
@@ -0,0 +1,65 @@
1
+ /**
2
+ * @file consent-migration.test.ts
3
+ * @description Drift guard between the consent_grants migration's BACKFILL
4
+ * and the consent service (#5275, spec §5.1 last bullet + §5.3 second
5
+ * boundary): every pre-existing listing gets one grant marked
6
+ * wording-UNRECOVERABLE — the exact `UNRECOVERABLE_CONSENT_STATEMENT`
7
+ * literal, so a later reader can match it — stamped at the listing's own
8
+ * consent_at, and project_listings itself is never touched.
9
+ */
10
+ import { readFileSync } from "node:fs";
11
+ import { join, resolve } from "node:path";
12
+
13
+ import { SITE_CONSENT_CONFIG_DEFAULTS } from "@working-theory/validation";
14
+
15
+ import { UNRECOVERABLE_CONSENT_STATEMENT } from "../consent";
16
+
17
+ const MIGRATION = join(
18
+ resolve(__dirname, "..", "..", "..", "..", ".."),
19
+ "database",
20
+ "migrations",
21
+ "20260911180000_consent_grants",
22
+ "migration.sql",
23
+ );
24
+
25
+ describe("consent_grants migration backfill (#5275 D11)", () => {
26
+ const sql = readFileSync(MIGRATION, "utf-8");
27
+
28
+ it("creates the table with its two indexes", () => {
29
+ expect(sql).toContain('CREATE TABLE "consent_grants"');
30
+ expect(sql).toContain('("site_id", "subject_type", "subject_id")');
31
+ expect(sql).toContain('("site_id", "purpose_id")');
32
+ });
33
+
34
+ it("inserts one row per pre-existing project_listings row", () => {
35
+ expect(sql).toContain('INSERT INTO "consent_grants"');
36
+ expect(sql).toMatch(/FROM "project_listings"/);
37
+ expect(sql).toContain("'project_listing'");
38
+ expect(sql).toContain("'service_terms'");
39
+ });
40
+
41
+ it("marks every backfilled row with the exact unrecoverable sentinel and a COMPUTED hash", () => {
42
+ expect(sql).toContain(`'${UNRECOVERABLE_CONSENT_STATEMENT}'`);
43
+ expect(sql).toContain(
44
+ `encode(sha256(convert_to('${UNRECOVERABLE_CONSENT_STATEMENT}', 'UTF8')), 'hex')`,
45
+ );
46
+ expect(sql).toContain("'und'");
47
+ });
48
+
49
+ it("sources created_at from the listing's own consent_at", () => {
50
+ const insert = sql.slice(sql.indexOf('INSERT INTO "consent_grants"'));
51
+ expect(insert).toContain('"consent_at"');
52
+ expect(insert).toContain('"created_at"');
53
+ });
54
+
55
+ it("never UPDATEs or ALTERs project_listings — consent_at stays as it was", () => {
56
+ expect(sql).not.toMatch(/UPDATE\s+"project_listings"/i);
57
+ expect(sql).not.toMatch(/ALTER\s+TABLE\s+"project_listings"/i);
58
+ });
59
+
60
+ it("never stamps today's default wording onto a grant nobody read", () => {
61
+ expect(sql).not.toContain("You agree to being contacted");
62
+ const defaultStatement = SITE_CONSENT_CONFIG_DEFAULTS.purposes[0]!.label["en"]!;
63
+ expect(sql).not.toContain(defaultStatement);
64
+ });
65
+ });
@@ -0,0 +1,282 @@
1
+ /**
2
+ * @file consent.test.ts
3
+ * @description Unit tests for the consent-grant writer (#5275 Part B) —
4
+ * `recordConsent`, `recordLegacyConsent`, `consentStatementHash`,
5
+ * `consentConfigForSite`.
6
+ *
7
+ * External dependency boundaries mocked:
8
+ * - @working-theory/database (via moduleNameMapper -> prisma-client.js mock; prisma singleton via __mocks__/prisma.ts)
9
+ *
10
+ * The evidence rule under test: a row carries the statement the registry
11
+ * RESOLVED (the same function the renderer used), in the locale it was
12
+ * actually served, or the unrecoverable sentinel — never anything a request
13
+ * body said and never today's wording for a grant nobody read.
14
+ */
15
+
16
+ // eslint-disable-next-line no-restricted-syntax -- lib/prisma.ts wraps the external DB boundary (PrismaClient requires a live connection); __mocks__/prisma.ts is the repo-standard test double for this boundary.
17
+ jest.mock("../../lib/prisma.js");
18
+
19
+ import { createHash } from "node:crypto";
20
+
21
+ import {
22
+ DEFAULT_CONSENT_PURPOSE_ID,
23
+ resolveConsentStatement,
24
+ SITE_CONSENT_CONFIG_DEFAULTS,
25
+ siteConsentConfigSchema,
26
+ } from "@working-theory/validation";
27
+
28
+ import { prisma } from "../../lib/prisma.js";
29
+
30
+ import {
31
+ consentConfigForSite,
32
+ consentStatementHash,
33
+ recordConsent,
34
+ recordLegacyConsent,
35
+ UNRECOVERABLE_CONSENT_LOCALE,
36
+ UNRECOVERABLE_CONSENT_STATEMENT,
37
+ requiredConsentUnmet,
38
+ } from "../consent";
39
+
40
+ const SITE_ID = "site-1";
41
+ const SUBJECT = { subjectType: "project_listing", subjectId: "prjl_00000000-0000-7000-8000-000000000001" };
42
+
43
+ /** Two purposes, one of them bilingual — the fixture every locale case reads from. */
44
+ const CONFIG = siteConsentConfigSchema.parse({
45
+ policies: { terms: "/terms", privacy: "/privacy" },
46
+ purposes: [
47
+ {
48
+ id: "service_terms",
49
+ required: true,
50
+ label: {
51
+ en: "You agree to our [Terms](terms).",
52
+ fr: "Vous acceptez nos [Conditions](terms).",
53
+ },
54
+ },
55
+ {
56
+ id: "marketing_email",
57
+ required: false,
58
+ label: { en: "Send me occasional product news." },
59
+ },
60
+ ],
61
+ });
62
+
63
+ type Row = Record<string, unknown>;
64
+
65
+ function writtenRows(call = 0): Row[] {
66
+ const arg = (prisma.consentGrant.createMany as jest.Mock).mock.calls[call]![0] as { data: Row[] };
67
+ return arg.data;
68
+ }
69
+
70
+ describe("recordConsent (#5275)", () => {
71
+ beforeEach(() => {
72
+ jest.clearAllMocks();
73
+ (prisma.consentGrant.createMany as jest.Mock).mockImplementation(
74
+ ({ data }: { data: Row[] }) => Promise.resolve({ count: data.length }),
75
+ );
76
+ });
77
+
78
+ it("one granted + one declined purpose → ONE createMany with two rows, one granted:true and one granted:false (D8)", async () => {
79
+ const written = await recordConsent({
80
+ siteId: SITE_ID,
81
+ ...SUBJECT,
82
+ locale: "en",
83
+ config: CONFIG,
84
+ consents: { service_terms: true, marketing_email: false },
85
+ });
86
+
87
+ expect(written).toBe(2);
88
+ expect(prisma.consentGrant.createMany).toHaveBeenCalledTimes(1);
89
+ const rows = writtenRows();
90
+ expect(rows).toHaveLength(2);
91
+ expect(rows.find((r) => r["purposeId"] === "service_terms")).toMatchObject({
92
+ siteId: SITE_ID,
93
+ ...SUBJECT,
94
+ granted: true,
95
+ });
96
+ expect(rows.find((r) => r["purposeId"] === "marketing_email")).toMatchObject({
97
+ siteId: SITE_ID,
98
+ ...SUBJECT,
99
+ granted: false,
100
+ });
101
+ });
102
+
103
+ it("each row carries the RESOLVED statement text and the locale it was actually served in (D7)", async () => {
104
+ // `fr-CA` is asked for; the bilingual purpose serves `fr` (language-subtag
105
+ // fallback), the English-only purpose serves `en` (default-locale
106
+ // fallback). The row records the locale READ, not the one requested.
107
+ await recordConsent({
108
+ siteId: SITE_ID,
109
+ ...SUBJECT,
110
+ locale: "fr-CA",
111
+ config: CONFIG,
112
+ consents: { service_terms: true, marketing_email: true },
113
+ });
114
+
115
+ const rows = writtenRows();
116
+ const terms = rows.find((r) => r["purposeId"] === "service_terms")!;
117
+ const marketing = rows.find((r) => r["purposeId"] === "marketing_email")!;
118
+
119
+ const expectedTerms = resolveConsentStatement(CONFIG.purposes[0]!, "fr-CA");
120
+ expect(expectedTerms.locale).toBe("fr");
121
+ expect(terms["statement"]).toBe("Vous acceptez nos [Conditions](terms).");
122
+ expect(terms["statement"]).toBe(expectedTerms.statement);
123
+ expect(terms["locale"]).toBe("fr");
124
+ expect(terms["statementHash"]).toBe(consentStatementHash(expectedTerms.statement));
125
+
126
+ expect(marketing["statement"]).toBe("Send me occasional product news.");
127
+ expect(marketing["locale"]).toBe("en");
128
+ expect(marketing["locale"]).not.toBe("fr-CA");
129
+ });
130
+
131
+ it("a second recordConsent for the same subject + purpose APPENDS — createMany again, never update/upsert (D9)", async () => {
132
+ const input = {
133
+ siteId: SITE_ID,
134
+ ...SUBJECT,
135
+ locale: "en",
136
+ config: CONFIG,
137
+ consents: { marketing_email: true },
138
+ };
139
+ await recordConsent(input);
140
+ await recordConsent({ ...input, consents: { marketing_email: false } });
141
+
142
+ expect(prisma.consentGrant.createMany).toHaveBeenCalledTimes(2);
143
+ expect(writtenRows(0)[0]).toMatchObject({ purposeId: "marketing_email", granted: true });
144
+ expect(writtenRows(1)[0]).toMatchObject({ purposeId: "marketing_email", granted: false });
145
+ // The mock delegate deliberately exposes no mutating method: the writer
146
+ // has nothing to update or upsert WITH, so append is structural.
147
+ const delegate = prisma.consentGrant as Record<string, unknown>;
148
+ expect(delegate["update"]).toBeUndefined();
149
+ expect(delegate["upsert"]).toBeUndefined();
150
+ expect(delegate["updateMany"]).toBeUndefined();
151
+ });
152
+
153
+ it("an unknown purpose id writes NOTHING for it — no statement was rendered (evidence rule)", async () => {
154
+ const written = await recordConsent({
155
+ siteId: SITE_ID,
156
+ ...SUBJECT,
157
+ locale: "en",
158
+ config: CONFIG,
159
+ consents: { service_terms: true, not_a_purpose: true },
160
+ });
161
+
162
+ expect(written).toBe(1);
163
+ const rows = writtenRows();
164
+ expect(rows).toHaveLength(1);
165
+ expect(rows[0]!["purposeId"]).toBe("service_terms");
166
+ });
167
+
168
+ it("only unknown purpose ids → no createMany at all, returns 0", async () => {
169
+ const written = await recordConsent({
170
+ siteId: SITE_ID,
171
+ ...SUBJECT,
172
+ locale: "en",
173
+ config: CONFIG,
174
+ consents: { not_a_purpose: true },
175
+ });
176
+ expect(written).toBe(0);
177
+ expect(prisma.consentGrant.createMany).not.toHaveBeenCalled();
178
+ });
179
+
180
+ it("never writes the unrecoverable sentinel when a statement was resolved", async () => {
181
+ await recordConsent({
182
+ siteId: SITE_ID,
183
+ ...SUBJECT,
184
+ locale: "en",
185
+ config: SITE_CONSENT_CONFIG_DEFAULTS,
186
+ consents: { [DEFAULT_CONSENT_PURPOSE_ID]: true },
187
+ });
188
+ const [row] = writtenRows();
189
+ expect(row!["statement"]).not.toBe(UNRECOVERABLE_CONSENT_STATEMENT);
190
+ expect(row!["locale"]).not.toBe(UNRECOVERABLE_CONSENT_LOCALE);
191
+ });
192
+ });
193
+
194
+ describe("recordLegacyConsent (#5275 D11)", () => {
195
+ beforeEach(() => {
196
+ jest.clearAllMocks();
197
+ (prisma.consentGrant.createMany as jest.Mock).mockImplementation(
198
+ ({ data }: { data: Row[] }) => Promise.resolve({ count: data.length }),
199
+ );
200
+ });
201
+
202
+ it("writes exactly one row: sentinel statement, locale 'und', purpose service_terms under the default config", async () => {
203
+ const written = await recordLegacyConsent({
204
+ siteId: SITE_ID,
205
+ ...SUBJECT,
206
+ config: SITE_CONSENT_CONFIG_DEFAULTS,
207
+ });
208
+
209
+ expect(written).toBe(1);
210
+ expect(prisma.consentGrant.createMany).toHaveBeenCalledTimes(1);
211
+ const rows = writtenRows();
212
+ expect(rows).toHaveLength(1);
213
+ expect(rows[0]).toEqual({
214
+ siteId: SITE_ID,
215
+ ...SUBJECT,
216
+ purposeId: DEFAULT_CONSENT_PURPOSE_ID,
217
+ granted: true,
218
+ locale: UNRECOVERABLE_CONSENT_LOCALE,
219
+ statement: UNRECOVERABLE_CONSENT_STATEMENT,
220
+ statementHash: consentStatementHash(UNRECOVERABLE_CONSENT_STATEMENT),
221
+ });
222
+ expect(rows[0]!["purposeId"]).toBe("service_terms");
223
+ expect(rows[0]!["locale"]).toBe("und");
224
+ // Never today's wording for a grant nobody read.
225
+ expect(rows[0]!["statement"]).not.toContain("You agree to being contacted");
226
+ });
227
+
228
+ it("targets the first REQUIRED purpose, not merely the first purpose", async () => {
229
+ const config = siteConsentConfigSchema.parse({
230
+ purposes: [
231
+ { id: "marketing_email", required: false, label: { en: "News." } },
232
+ { id: "service_terms", required: true, label: { en: "Terms." } },
233
+ ],
234
+ });
235
+ await recordLegacyConsent({ siteId: SITE_ID, ...SUBJECT, config });
236
+ expect(writtenRows()[0]!["purposeId"]).toBe("service_terms");
237
+ });
238
+
239
+ it("falls back to the first purpose when none is required", async () => {
240
+ const config = siteConsentConfigSchema.parse({
241
+ purposes: [{ id: "marketing_email", required: false, label: { en: "News." } }],
242
+ });
243
+ await recordLegacyConsent({ siteId: SITE_ID, ...SUBJECT, config });
244
+ expect(writtenRows()[0]!["purposeId"]).toBe("marketing_email");
245
+ });
246
+ });
247
+
248
+ describe("consentStatementHash", () => {
249
+ it("is sha256 hex", () => {
250
+ expect(consentStatementHash("abc")).toBe(
251
+ "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad",
252
+ );
253
+ expect(consentStatementHash(UNRECOVERABLE_CONSENT_STATEMENT)).toBe(
254
+ createHash("sha256").update(UNRECOVERABLE_CONSENT_STATEMENT).digest("hex"),
255
+ );
256
+ expect(consentStatementHash(UNRECOVERABLE_CONSENT_STATEMENT)).toMatch(/^[0-9a-f]{64}$/);
257
+ });
258
+ });
259
+
260
+ describe("consentConfigForSite", () => {
261
+ it("resolves every site to the platform defaults until a config row exists", async () => {
262
+ await expect(consentConfigForSite(SITE_ID)).resolves.toBe(SITE_CONSENT_CONFIG_DEFAULTS);
263
+ });
264
+ });
265
+
266
+ describe("requiredConsentUnmet (#5275, security review Medium 1)", () => {
267
+ const config = {
268
+ policies: {},
269
+ purposes: [
270
+ { id: "service_terms", required: true, label: { en: "a" } },
271
+ { id: "marketing", required: false, label: { en: "b" } },
272
+ ],
273
+ };
274
+
275
+ it("names a required purpose that is declined OR absent, and passes when every required one is true", () => {
276
+ expect(requiredConsentUnmet(config, { service_terms: false })).toBe("service_terms");
277
+ expect(requiredConsentUnmet(config, {})).toBe("service_terms");
278
+ expect(requiredConsentUnmet(config, { marketing: true })).toBe("service_terms");
279
+ expect(requiredConsentUnmet(config, { service_terms: true })).toBeUndefined();
280
+ expect(requiredConsentUnmet(config, { service_terms: true, marketing: false })).toBeUndefined();
281
+ });
282
+ });
@@ -0,0 +1,236 @@
1
+ /**
2
+ * @file consent.ts
3
+ * @description The consent-grant writer (#5275 Part B) — `recordConsent`,
4
+ * the one function that turns a person's answers to a site's consent
5
+ * purposes into `ConsentGrant` rows, plus `recordLegacyConsent`, the D11
6
+ * path for a caller that could only say `consent: true`.
7
+ *
8
+ * SURFACE-AGNOSTIC BY DESIGN (D10). A grant's subject is a `subjectType` +
9
+ * `subjectId` pair of plain scalars with no relation into any other table
10
+ * (ADR 0009 invariant 1), so a new surface — the newsletter subscribe path
11
+ * is the named follow-up — adopts consent by adding ONE call here and NO
12
+ * migration. Nothing in this module knows what a project listing is.
13
+ *
14
+ * THE EVIDENCE RULE (spec §5.2): no row is ever written with statement text
15
+ * the system did not actually render to that person. Two consequences:
16
+ *
17
+ * - `recordConsent` resolves each statement from the SAME registry and the
18
+ * SAME `resolveConsentStatement` the renderer used (D7), so the recorded
19
+ * wording and the rendered wording are one string by construction, and a
20
+ * request body never carries wording at all. A purpose id the registry
21
+ * does not know is skipped, not written: no statement was rendered for it.
22
+ * - A grant whose wording cannot be proven — a legacy `consent: true` body,
23
+ * or the migration backfill — carries `UNRECOVERABLE_CONSENT_STATEMENT`,
24
+ * never today's default text. Stamping current wording onto a grant
25
+ * nobody read would fabricate evidence (D11).
26
+ *
27
+ * APPEND-ONLY (D9): every write is a `createMany`; this module never
28
+ * updates or upserts, so a later withdrawal sits beside the earlier grant
29
+ * rather than replacing it. Declines are written as `granted: false` (D8).
30
+ */
31
+
32
+ import { createHash } from "node:crypto";
33
+
34
+ import { logger } from "@working-theory/observability";
35
+ import {
36
+ consentPurposeById,
37
+ resolveConsentStatement,
38
+ SITE_CONSENT_CONFIG_DEFAULTS,
39
+ type SiteConsentConfig,
40
+ } from "@working-theory/validation";
41
+
42
+ import { prisma } from "../lib/prisma.js";
43
+
44
+ /**
45
+ * The one delegate this module writes through — the shape of `prisma` AND of
46
+ * the `tx` client an interactive `$transaction` hands out, so a caller that
47
+ * creates the subject row can write the grant in the SAME transaction (the
48
+ * listing's `start` does): either the row and its evidence both land, or
49
+ * neither does.
50
+ */
51
+ export interface ConsentWriteClient {
52
+ consentGrant: {
53
+ createMany(args: { data: ConsentGrantRow[] }): Promise<{ count: number }>;
54
+ };
55
+ }
56
+
57
+ interface ConsentGrantRow {
58
+ siteId: string;
59
+ subjectType: string;
60
+ subjectId: string;
61
+ purposeId: string;
62
+ granted: boolean;
63
+ locale: string;
64
+ statement: string;
65
+ statementHash: string;
66
+ }
67
+
68
+ /**
69
+ * The most keys a `consents` map may carry on an anonymous request. Any real
70
+ * registry is a handful of purposes; the cap bounds the per-request work an
71
+ * unbounded map could buy (security review, Low 3).
72
+ */
73
+ export const MAX_CONSENT_KEYS = 64;
74
+
75
+ /**
76
+ * The statement sentinel for a grant whose wording cannot be proven — a row
77
+ * carrying it records THAT a person consented (and when), not WHAT they read.
78
+ * Written on exactly two paths: `recordLegacyConsent` (a body carrying only
79
+ * the legacy `consent: true` literal, D11) and the consent_grants migration's
80
+ * backfill of pre-existing listings, which must carry this exact literal.
81
+ * It is never a substitute for real wording: a caller that rendered a
82
+ * statement records it through `recordConsent`.
83
+ */
84
+ export const UNRECOVERABLE_CONSENT_STATEMENT =
85
+ "[unrecoverable: no statement was rendered by this system for this grant]";
86
+
87
+ /** BCP-47 `und` — the locale of a statement nobody read. */
88
+ export const UNRECOVERABLE_CONSENT_LOCALE = "und";
89
+
90
+ /** sha256 hex of a statement — the `statementHash` that groups identical wordings. */
91
+ export function consentStatementHash(statement: string): string {
92
+ return createHash("sha256").update(statement).digest("hex");
93
+ }
94
+
95
+ /**
96
+ * The site's consent registry.
97
+ *
98
+ * There is no per-site consent config ROW yet (spec §4 deliberately adds
99
+ * none), so every site resolves to `SITE_CONSENT_CONFIG_DEFAULTS`. This
100
+ * function is the single seam a `SiteConsentConfig` row wires into later
101
+ * (the `listingConfigFor` shape: read the blob, `consentConfigFor(blob)`).
102
+ * The RENDERER (`packages/ui`) resolves its statements from the same
103
+ * `SITE_CONSENT_CONFIG_DEFAULTS`, which is what keeps the recorded statement
104
+ * identical to the rendered one until a config row exists — at which point
105
+ * both sides must read the same row.
106
+ */
107
+ export async function consentConfigForSite(
108
+ _siteId: string,
109
+ ): Promise<SiteConsentConfig> {
110
+ return SITE_CONSENT_CONFIG_DEFAULTS;
111
+ }
112
+
113
+ /**
114
+ * The id of the first REQUIRED registry purpose `consents` does not GRANT —
115
+ * declined (`false`) or simply absent — or `undefined` when every required
116
+ * purpose is `true`. Absence counts as unmet on purpose (security review,
117
+ * Medium 1): a body carrying `consents: {}` beside `consent: true` would
118
+ * otherwise create the subject with no grant row at all, which is a worse
119
+ * record than the legacy sentinel. A surface whose subject cannot exist
120
+ * without the required purposes (the listing: the row goes on to publish the
121
+ * founder's address) checks this before creating the subject.
122
+ */
123
+ export function requiredConsentUnmet(
124
+ config: SiteConsentConfig,
125
+ consents: Record<string, boolean>,
126
+ ): string | undefined {
127
+ for (const purpose of config.purposes) {
128
+ if (purpose.required && consents[purpose.id] !== true) return purpose.id;
129
+ }
130
+ return undefined;
131
+ }
132
+
133
+ export interface RecordConsentInput {
134
+ siteId: string;
135
+ /** e.g. `project_listing`, `subscriber` — a plain scalar, no relation (D10). */
136
+ subjectType: string;
137
+ subjectId: string;
138
+ /** The locale the statements were RENDERED in; the served locale is what gets recorded. */
139
+ locale: string;
140
+ config: SiteConsentConfig;
141
+ /** purposeId → the person's answer. `false` is a stored decline (D8). */
142
+ consents: Record<string, boolean>;
143
+ }
144
+
145
+ /**
146
+ * Write one `ConsentGrant` row per answered purpose in one `createMany`.
147
+ * Returns the number of rows written. Append-only — never update/upsert.
148
+ * `db` defaults to the shared client; pass a `$transaction` client to land
149
+ * the grant atomically with the subject row it belongs to.
150
+ */
151
+ export async function recordConsent(
152
+ input: RecordConsentInput,
153
+ db: ConsentWriteClient = prisma,
154
+ ): Promise<number> {
155
+ const rows: ConsentGrantRow[] = [];
156
+ const unknown: string[] = [];
157
+
158
+ for (const [purposeId, granted] of Object.entries(input.consents)) {
159
+ const purpose = consentPurposeById(input.config, purposeId);
160
+ if (!purpose) {
161
+ // Not in the registry ⇒ no statement was rendered for it ⇒ nothing to
162
+ // record (the evidence rule). Collected and logged ONCE per call below,
163
+ // because a client naming a purpose the site does not declare is a
164
+ // drift someone should see — and a map full of junk keys must not buy
165
+ // a log line each.
166
+ unknown.push(purposeId);
167
+ continue;
168
+ }
169
+ const { statement, locale } = resolveConsentStatement(purpose, input.locale);
170
+ rows.push({
171
+ siteId: input.siteId,
172
+ subjectType: input.subjectType,
173
+ subjectId: input.subjectId,
174
+ purposeId,
175
+ granted,
176
+ locale,
177
+ statement,
178
+ statementHash: consentStatementHash(statement),
179
+ });
180
+ }
181
+
182
+ if (unknown.length > 0) {
183
+ logger.warn(
184
+ {
185
+ siteId: input.siteId,
186
+ subjectType: input.subjectType,
187
+ unknownCount: unknown.length,
188
+ sample: unknown.slice(0, 5),
189
+ },
190
+ "[consent] skipping unknown purpose ids — not in the site's consent registry, no statement was rendered for them",
191
+ );
192
+ }
193
+
194
+ if (rows.length === 0) return 0;
195
+ const result = await db.consentGrant.createMany({ data: rows });
196
+ return result.count;
197
+ }
198
+
199
+ export interface RecordLegacyConsentInput {
200
+ siteId: string;
201
+ subjectType: string;
202
+ subjectId: string;
203
+ config: SiteConsentConfig;
204
+ }
205
+
206
+ /**
207
+ * The D11 path: a body that carried only the legacy `consent: true` literal.
208
+ * Writes ONE grant against the site's first `required` purpose (falling back
209
+ * to its first purpose), `granted: true`, with the statement marked
210
+ * unrecoverable — the caller affirmed, but this system rendered it no
211
+ * wording to affirm.
212
+ */
213
+ export async function recordLegacyConsent(
214
+ input: RecordLegacyConsentInput,
215
+ db: ConsentWriteClient = prisma,
216
+ ): Promise<number> {
217
+ const purpose =
218
+ input.config.purposes.find((p) => p.required) ?? input.config.purposes[0];
219
+ if (!purpose) return 0;
220
+
221
+ const result = await db.consentGrant.createMany({
222
+ data: [
223
+ {
224
+ siteId: input.siteId,
225
+ subjectType: input.subjectType,
226
+ subjectId: input.subjectId,
227
+ purposeId: purpose.id,
228
+ granted: true,
229
+ locale: UNRECOVERABLE_CONSENT_LOCALE,
230
+ statement: UNRECOVERABLE_CONSENT_STATEMENT,
231
+ statementHash: consentStatementHash(UNRECOVERABLE_CONSENT_STATEMENT),
232
+ },
233
+ ],
234
+ });
235
+ return result.count;
236
+ }
@@ -3,6 +3,16 @@
3
3
  * @description The deliverable-fulfillment service (#3961 D3/D4/D5/D7;
4
4
  * extended #3998 D1/D3/D5; extended FEAT-074 U4b, #4718 D4).
5
5
  *
6
+ * NOT marked `oss: false` and NOT in signal-collectors's `ownedFiles`
7
+ * (module-architecture.ts), despite D-48/D6 placing this file with
8
+ * signal-collectors — `services/recommender-capture.ts` imports it directly
9
+ * and is itself reached from `server.ts` by a plain, unconditional static
10
+ * import today (commerce/#5364 has not yet landed the gate around it).
11
+ * Marking or excluding this file broke both the OSS-strip and the create-ailk
12
+ * starter snapshot for EVERY module, not just signal-collectors (spec §5.4
13
+ * escalation boundary; see module-architecture.ts's comment on this file).
14
+ * Revert once #5364 lands.
15
+ *
6
16
  * `fulfillDeliverable` is the one entry point, called from flow-engine's
7
17
  * completion branch after `routeLead` succeeds and only when the session's
8
18
  * `configSnapshot` carries a `deliverables` block (D4 — the block is opt-in
@@ -1,4 +1,10 @@
1
1
  /**
2
+ * NOT marked `oss: false` (D-48/#5366 escalation — see
3
+ * `routes/project-listings/me.ts`'s header comment): imported by
4
+ * `routes/project-listings/{start,public}.ts`, each of which ships
5
+ * unconditionally because `routes/project-listings/index.ts`'s multi-tenant
6
+ * literal `import()` forces it. Registration stays gated.
7
+ *
2
8
  * @file listing-config.ts
3
9
  * @description The per-site project-listings config read (#5033) — one
4
10
  * function, `listingConfigFor`, that resolves a site's window bounds and
@@ -53,9 +59,61 @@ export async function listingConfigFor(siteId: string): Promise<SiteListingConfi
53
59
  );
54
60
  return SITE_LISTING_CONFIG_DEFAULTS;
55
61
  }
62
+ warnOnDroppedField(siteId, row.data, parsed.data);
56
63
  return parsed.data;
57
64
  }
58
65
 
66
+ /**
67
+ * The two `.catch(undefined)` members of `siteListingConfigSchema` are silent
68
+ * by construction (#5406): a malformed `resumeEmail` or `siteName` is DROPPED
69
+ * rather than failing the whole blob, so the site keeps its own window bounds
70
+ * and collected fields instead of silently reverting to the platform's more
71
+ * permissive defaults.
72
+ *
73
+ * Silent is the right BEHAVIOUR and the wrong OBSERVABILITY. An operator who
74
+ * typos a subject key gets a site that quietly stops sending the resume email
75
+ * with nothing anywhere saying why. This closes that: when the stored blob has
76
+ * the key and the parsed config does not, the drop is logged.
77
+ *
78
+ * Field NAMES only, never values — `resumeEmail` carries the site's addresses
79
+ * and the founder-facing copy, and this module sits on the anonymous path.
80
+ */
81
+ function warnOnDroppedField(
82
+ siteId: string,
83
+ stored: unknown,
84
+ parsed: SiteListingConfig,
85
+ ): void {
86
+ if (typeof stored !== "object" || stored === null) return;
87
+ const raw = stored as Record<string, unknown>;
88
+ for (const field of ["resumeEmail", "siteName"] as const) {
89
+ if (raw[field] !== undefined && parsed[field] === undefined) {
90
+ logger.warn(
91
+ { siteId, field },
92
+ "[listing-config] stored field failed its schema and was DROPPED — the rest of this site's config still applies, but this field is not in effect",
93
+ );
94
+ }
95
+ }
96
+
97
+ // `resumeEmail.maxSendsPerHour` carries its OWN `.catch(undefined)` (#5414),
98
+ // so a bad cap is dropped while the block around it survives — which the
99
+ // loop above, comparing whole members, cannot see. Checked only when the
100
+ // block itself parsed: a dropped block is already reported once above, and
101
+ // reporting it twice would make the narrower line the less trustworthy one.
102
+ const storedEmail = raw["resumeEmail"];
103
+ if (
104
+ typeof storedEmail === "object" &&
105
+ storedEmail !== null &&
106
+ (storedEmail as Record<string, unknown>)["maxSendsPerHour"] !== undefined &&
107
+ parsed.resumeEmail !== undefined &&
108
+ parsed.resumeEmail.maxSendsPerHour === undefined
109
+ ) {
110
+ logger.warn(
111
+ { siteId, field: "resumeEmail.maxSendsPerHour" },
112
+ "[listing-config] stored field failed its schema and was DROPPED — the rest of this site's config still applies, but this field is not in effect",
113
+ );
114
+ }
115
+ }
116
+
59
117
  /**
60
118
  * Resolve a site from its PUBLIC SITE KEY (#5048 D2).
61
119
  *