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,195 @@
1
+ /**
2
+ * The comparison instrument for #5314 — diffs the routes the server ACTUALLY
3
+ * registers against each surface that claims to describe them.
4
+ *
5
+ * Two surfaces describe this API's endpoints: the root index `endpoints`
6
+ * object (`server.ts`) and the OpenAPI `paths` object (`spec.ts`). Before
7
+ * #5314 both were hand-maintained literals, and both had drifted — 11 and 7
8
+ * entries against 68 registered routes, with `/v1/project-listings/public`
9
+ * missing from both and `/v1/billing/usage` missing from the root index.
10
+ *
11
+ * This module only COMPARES. The derivation that keeps the surfaces honest
12
+ * lives in `spec.ts` beside the shapes it produces; keeping the comparison
13
+ * separate is what lets the gate assert against a surface it did not build.
14
+ *
15
+ * Deliberately free of any `fastify` or `spec.ts` import: it takes plain data,
16
+ * so the gate can feed it a SERVED document (fetched over `inject`) rather
17
+ * than the object the code under test just returned.
18
+ */
19
+
20
+ /** One route as Fastify registered it. `url` is Fastify-shaped (`:param`). */
21
+ export interface RegisteredRoute {
22
+ /** Uppercase HTTP method. HEAD is excluded — Fastify derives it from GET. */
23
+ method: string;
24
+ /** Fastify path, e.g. `/v1/content/:type/:slug`. */
25
+ url: string;
26
+ }
27
+
28
+ /** One surface's disagreement with the registered route table. */
29
+ export interface SurfaceDiff {
30
+ /** Which surface this diff describes. */
31
+ surface: "openapi-paths" | "root-index-endpoints";
32
+ /** `METHOD /path` registered by the server but absent from the surface. */
33
+ missingFromSurface: string[];
34
+ /** `METHOD /path` named by the surface but not registered by the server. */
35
+ orphanedInSurface: string[];
36
+ }
37
+
38
+ /**
39
+ * Fastify path → OpenAPI path template: `/v1/content/:type` becomes
40
+ * `/v1/content/{type}`. OpenAPI 3.1 has no `:param` form, so a document that
41
+ * published the Fastify string would be describing a path no client can build.
42
+ */
43
+ export function toOpenApiPath(fastifyPath: string): string {
44
+ return fastifyPath
45
+ .split("/")
46
+ .map((segment) =>
47
+ segment.startsWith(":") ? `{${segment.slice(1)}}` : segment,
48
+ )
49
+ .join("/");
50
+ }
51
+
52
+ /** The comparison key both sides normalise to: `GET /v1/content/types`. */
53
+ export function routeKey(method: string, path: string): string {
54
+ return `${method.toUpperCase()} ${path}`;
55
+ }
56
+
57
+ /** Registered routes as OpenAPI-shaped comparison keys. */
58
+ function registeredOpenApiKeys(routes: RegisteredRoute[]): Set<string> {
59
+ return new Set(routes.map((r) => routeKey(r.method, toOpenApiPath(r.url))));
60
+ }
61
+
62
+ /** Registered routes as Fastify-shaped comparison keys. */
63
+ function registeredFastifyKeys(routes: RegisteredRoute[]): Set<string> {
64
+ return new Set(routes.map((r) => routeKey(r.method, r.url)));
65
+ }
66
+
67
+ /**
68
+ * The OpenAPI methods a path item declares. Non-method keys an OpenAPI path
69
+ * item may legally carry (`parameters`, `summary`, `description`, `servers`,
70
+ * `$ref`) are not operations and must not be compared as if they were.
71
+ */
72
+ const OPENAPI_METHODS = new Set([
73
+ "get",
74
+ "put",
75
+ "post",
76
+ "delete",
77
+ "options",
78
+ "head",
79
+ "patch",
80
+ "trace",
81
+ ]);
82
+
83
+ /**
84
+ * Comparison keys a served OpenAPI `paths` object claims.
85
+ *
86
+ * Takes the served document's `paths` as plain data so the gate can compare
87
+ * what an agent would actually fetch, not what the builder returned.
88
+ */
89
+ export function openApiSurfaceKeys(
90
+ paths: Record<string, Record<string, unknown>>,
91
+ ): Set<string> {
92
+ const keys = new Set<string>();
93
+ for (const [path, item] of Object.entries(paths ?? {})) {
94
+ for (const method of Object.keys(item ?? {})) {
95
+ if (!OPENAPI_METHODS.has(method.toLowerCase())) continue;
96
+ if (method.toLowerCase() === "head") continue;
97
+ keys.add(routeKey(method, path));
98
+ }
99
+ }
100
+ return keys;
101
+ }
102
+
103
+ /**
104
+ * Comparison keys a root-index `endpoints` object claims.
105
+ *
106
+ * Each value is `"METHOD[/METHOD...] /path"` — the shape the hand-authored
107
+ * object already used (`"GET/PATCH /v1/user/profile"`), preserved so no
108
+ * published entry changes shape. A value that does not parse is reported as
109
+ * an orphan rather than skipped: an unparseable entry is precisely the kind
110
+ * of hand-edit this instrument exists to catch.
111
+ */
112
+ export function rootIndexSurfaceKeys(
113
+ endpoints: Record<string, string>,
114
+ ): Set<string> {
115
+ const keys = new Set<string>();
116
+ for (const value of Object.values(endpoints ?? {})) {
117
+ const match = /^([A-Za-z/]+)\s+(\S+)$/.exec(value ?? "");
118
+ if (!match?.[1] || !match[2]) {
119
+ keys.add(`UNPARSEABLE ${value}`);
120
+ continue;
121
+ }
122
+ for (const method of match[1].split("/")) {
123
+ if (!method) continue;
124
+ if (method.toUpperCase() === "HEAD") continue;
125
+ keys.add(routeKey(method, match[2]));
126
+ }
127
+ }
128
+ return keys;
129
+ }
130
+
131
+ function diff(registered: Set<string>, surface: Set<string>) {
132
+ return {
133
+ missingFromSurface: [...registered].filter((k) => !surface.has(k)).sort(),
134
+ orphanedInSurface: [...surface].filter((k) => !registered.has(k)).sort(),
135
+ };
136
+ }
137
+
138
+ /**
139
+ * Diff the registered route table against both surfaces.
140
+ *
141
+ * `paths` is the served OpenAPI `paths` object (OpenAPI-templated); `endpoints`
142
+ * is the served root-index `endpoints` object (Fastify-shaped, matching the
143
+ * strings that surface has always published). Each is compared in its own
144
+ * vocabulary so neither surface is judged against a shape it never used.
145
+ */
146
+ export function compareSurfaces(
147
+ registered: RegisteredRoute[],
148
+ paths: Record<string, Record<string, unknown>>,
149
+ endpoints: Record<string, string>,
150
+ ): SurfaceDiff[] {
151
+ return [
152
+ {
153
+ surface: "openapi-paths",
154
+ ...diff(registeredOpenApiKeys(registered), openApiSurfaceKeys(paths)),
155
+ },
156
+ {
157
+ surface: "root-index-endpoints",
158
+ ...diff(
159
+ registeredFastifyKeys(registered),
160
+ rootIndexSurfaceKeys(endpoints),
161
+ ),
162
+ },
163
+ ];
164
+ }
165
+
166
+ /** Whether any surface disagrees with the registered route table. */
167
+ export function hasDrift(diffs: SurfaceDiff[]): boolean {
168
+ return diffs.some(
169
+ (d) => d.missingFromSurface.length > 0 || d.orphanedInSurface.length > 0,
170
+ );
171
+ }
172
+
173
+ /**
174
+ * Render the drift for a failing assertion, or `""` when there is none.
175
+ *
176
+ * A surface that agrees contributes nothing, so the empty string means "no
177
+ * drift" and can be asserted against directly. Counts come first so a reader
178
+ * sees the scale before the list, then EVERY entry — a truncated drift report
179
+ * is how a gate teaches people to ignore it.
180
+ */
181
+ export function formatSurfaceDrift(diffs: SurfaceDiff[]): string {
182
+ const lines: string[] = [];
183
+ for (const d of diffs) {
184
+ if (d.missingFromSurface.length === 0 && d.orphanedInSurface.length === 0) {
185
+ continue;
186
+ }
187
+ lines.push(
188
+ `${d.surface}: ${d.missingFromSurface.length} registered route(s) missing from the surface, ` +
189
+ `${d.orphanedInSurface.length} surface entr(ies) with no registered route`,
190
+ );
191
+ for (const k of d.missingFromSurface) lines.push(` missing: ${k}`);
192
+ for (const k of d.orphanedInSurface) lines.push(` orphaned: ${k}`);
193
+ }
194
+ return lines.join("\n");
195
+ }
@@ -61,14 +61,28 @@ const validBody = {
61
61
 
62
62
  describe("POST /api/content/:type", () => {
63
63
  let app: FastifyInstance;
64
+ // The write handlers only register when content-write is enabled
65
+ // (routes/content/index.ts's isModuleEnabled('content-write') gate); an
66
+ // unset AILK_ENABLED_MODULES no longer defaults to every module on
67
+ // (D-44, #5368), so this suite enables it explicitly.
68
+ const priorEnabledModules = process.env.AILK_ENABLED_MODULES;
64
69
 
65
70
  beforeEach(async () => {
71
+ process.env.AILK_ENABLED_MODULES = "content-write";
66
72
  app = require("fastify")();
67
73
  await app.register(contentRoutes);
68
74
  jest.clearAllMocks();
69
75
  mockCheckLicense.mockReset();
70
76
  });
71
77
 
78
+ afterAll(() => {
79
+ if (priorEnabledModules === undefined) {
80
+ delete process.env.AILK_ENABLED_MODULES;
81
+ } else {
82
+ process.env.AILK_ENABLED_MODULES = priorEnabledModules;
83
+ }
84
+ });
85
+
72
86
  afterEach(async () => {
73
87
  await app.close();
74
88
  });
@@ -49,14 +49,28 @@ function freeLicense() {
49
49
 
50
50
  describe("DELETE /api/content/:type/:slug", () => {
51
51
  let app: FastifyInstance;
52
+ // The write handlers only register when content-write is enabled
53
+ // (routes/content/index.ts's isModuleEnabled('content-write') gate); an
54
+ // unset AILK_ENABLED_MODULES no longer defaults to every module on
55
+ // (D-44, #5368), so this suite enables it explicitly.
56
+ const priorEnabledModules = process.env.AILK_ENABLED_MODULES;
52
57
 
53
58
  beforeEach(async () => {
59
+ process.env.AILK_ENABLED_MODULES = "content-write";
54
60
  app = require("fastify")();
55
61
  await app.register(contentRoutes);
56
62
  jest.clearAllMocks();
57
63
  mockCheckLicense.mockReset();
58
64
  });
59
65
 
66
+ afterAll(() => {
67
+ if (priorEnabledModules === undefined) {
68
+ delete process.env.AILK_ENABLED_MODULES;
69
+ } else {
70
+ process.env.AILK_ENABLED_MODULES = priorEnabledModules;
71
+ }
72
+ });
73
+
60
74
  afterEach(async () => {
61
75
  await app.close();
62
76
  });
@@ -61,14 +61,28 @@ const validBody = {
61
61
 
62
62
  describe("PATCH /api/content/:type/:slug", () => {
63
63
  let app: FastifyInstance;
64
+ // The write handlers only register when content-write is enabled
65
+ // (routes/content/index.ts's isModuleEnabled('content-write') gate); an
66
+ // unset AILK_ENABLED_MODULES no longer defaults to every module on
67
+ // (D-44, #5368), so this suite enables it explicitly.
68
+ const priorEnabledModules = process.env.AILK_ENABLED_MODULES;
64
69
 
65
70
  beforeEach(async () => {
71
+ process.env.AILK_ENABLED_MODULES = "content-write";
66
72
  app = require("fastify")();
67
73
  await app.register(contentRoutes);
68
74
  jest.clearAllMocks();
69
75
  mockCheckLicense.mockReset();
70
76
  });
71
77
 
78
+ afterAll(() => {
79
+ if (priorEnabledModules === undefined) {
80
+ delete process.env.AILK_ENABLED_MODULES;
81
+ } else {
82
+ process.env.AILK_ENABLED_MODULES = priorEnabledModules;
83
+ }
84
+ });
85
+
72
86
  afterEach(async () => {
73
87
  await app.close();
74
88
  });
@@ -24,25 +24,52 @@
24
24
  import type { FastifyPluginAsync } from "fastify";
25
25
 
26
26
  import { isModuleEnabled } from "../../config/modules.js";
27
+ import { loadSlice } from "../../lib/slice-load.js";
27
28
 
28
29
  import { getPageRoute } from "./get-page.js";
29
30
  import { listPagesRoute } from "./list-pages.js";
30
31
  import { listTypesRoute } from "./list-types.js";
31
32
 
32
33
  /**
33
- * Content-write slice entry points, as { specifier, exportName }.
34
+ * Content-write slice entry points, as { specifier, exportName, load }.
34
35
  *
35
36
  * What defeats `tsc`'s resolution is the INDIRECTION, not the type: TypeScript
36
37
  * resolves `import("...")` only when the argument is a string-literal
37
- * EXPRESSION written at the call site. Reading the specifier from a variable
38
- * makes it unresolvable regardless of whether that variable's type is `string`
39
- * or a literal (verified both ways). `string` is used here for readability, not
40
- * because a literal type would leak.
38
+ * EXPRESSION written at the call site.
39
+ *
40
+ * WHICH indirection matters, and #5304 is the correction. This table used to
41
+ * hold the specifier as a string and the loop read it out per iteration — a
42
+ * shape `@vercel/nft` cannot evaluate either, so the deploy-time tracer did not
43
+ * emit these files and a deployed site shipped without them. A module-level
44
+ * constant dereferenced into `import()` is unresolvable to `tsc` and traceable
45
+ * to `nft`, so the strip and the deployment are both satisfied. `specifier` is
46
+ * carried for the drift message below and reads the same constant as `load`, so
47
+ * the two cannot disagree.
41
48
  */
42
- const WRITE_ROUTES: ReadonlyArray<{ specifier: string; exportName: string }> = [
43
- { specifier: "./create.js", exportName: "createPageRoute" },
44
- { specifier: "./update.js", exportName: "updatePageRoute" },
45
- { specifier: "./delete.js", exportName: "deletePageRoute" },
49
+ const CREATE_ROUTE = "./create.js";
50
+ const UPDATE_ROUTE = "./update.js";
51
+ const DELETE_ROUTE = "./delete.js";
52
+
53
+ const WRITE_ROUTES: ReadonlyArray<{
54
+ specifier: string;
55
+ exportName: string;
56
+ load: () => Promise<unknown>;
57
+ }> = [
58
+ {
59
+ specifier: CREATE_ROUTE,
60
+ exportName: "createPageRoute",
61
+ load: () => import(CREATE_ROUTE),
62
+ },
63
+ {
64
+ specifier: UPDATE_ROUTE,
65
+ exportName: "updatePageRoute",
66
+ load: () => import(UPDATE_ROUTE),
67
+ },
68
+ {
69
+ specifier: DELETE_ROUTE,
70
+ exportName: "deletePageRoute",
71
+ load: () => import(DELETE_ROUTE),
72
+ },
46
73
  ];
47
74
 
48
75
  export const contentRoutes: FastifyPluginAsync = async (fastify) => {
@@ -51,8 +78,8 @@ export const contentRoutes: FastifyPluginAsync = async (fastify) => {
51
78
  await fastify.register(getPageRoute);
52
79
 
53
80
  if (isModuleEnabled("content-write")) {
54
- for (const { specifier, exportName } of WRITE_ROUTES) {
55
- const mod = (await import(specifier)) as Record<
81
+ for (const { specifier, exportName, load } of WRITE_ROUTES) {
82
+ const mod = (await loadSlice(specifier, load)) as Record<
56
83
  string,
57
84
  FastifyPluginAsync | undefined
58
85
  >;
@@ -1,3 +1,6 @@
1
+ // Not marked oss: false (D-48/#5366 escalation) — tests the
2
+ // always-shipping project-listings anonymous surface; see
3
+ // routes/project-listings/me.ts's header comment.
1
4
  /**
2
5
  * @file configured-application.test.ts
3
6
  *
@@ -43,6 +46,13 @@ jest.mock("../../../lib/prisma.js");
43
46
 
44
47
  jest.mock("@working-theory/email", () => ({
45
48
  sendLeadNotification: jest.fn().mockResolvedValue({ ok: true, messageId: "msg-test" }),
49
+ // #5406 — `start` now reaches the resume email. Declared here for the reason
50
+ // docs/lessons/project-listings/04 records: a mock factory that omits an
51
+ // export the code under test calls leaves it `undefined` at the callsite.
52
+ // These suites configure no `resumeEmail`, so the send short-circuits before
53
+ // this is reached; it is declared so a future fixture that DOES configure
54
+ // one fails on its assertion rather than on a missing mock.
55
+ sendListingResumeEmail: jest.fn().mockResolvedValue({ ok: true, messageId: "msg-resume" }),
46
56
  }));
47
57
 
48
58
  jest.mock("@working-theory/license", () => ({
@@ -190,6 +200,17 @@ function installStore(): void {
190
200
  },
191
201
  );
192
202
 
203
+ // #5275 — the grant write `start` makes after the row. Append-only; it only
204
+ // needs to resolve here (drafts.test.ts asserts on what it is called with).
205
+ (prisma.consentGrant.createMany as jest.Mock).mockImplementation(
206
+ ({ data }: { data: Row[] }) => Promise.resolve({ count: data.length }),
207
+ );
208
+
209
+ // #5275 — `start` writes the row and its grant in one interactive
210
+ // transaction; the mock hands the same store-backed client through.
211
+ (prisma.$transaction as jest.Mock).mockImplementation(
212
+ async (fn: (tx: typeof prisma) => Promise<unknown>) => fn(prisma),
213
+ );
193
214
  (prisma.projectListing.create as jest.Mock).mockImplementation(({ data }: { data: Row }) => {
194
215
  const now = new Date();
195
216
  const row: Row = {
@@ -206,6 +227,13 @@ function installStore(): void {
206
227
  anythingElse: null,
207
228
  address: null,
208
229
  questions: null,
230
+ // #5407 — a row written before the columns existed, and a freshly
231
+ // started draft on a site that asks for one name line: parts null,
232
+ // no phone, no verification stamp.
233
+ founderFirstName: null,
234
+ founderLastName: null,
235
+ founderPhone: null,
236
+ emailVerifiedAt: null,
209
237
  founderCompany: null,
210
238
  leadId: null,
211
239
  headline: null,
@@ -1,3 +1,6 @@
1
+ // Not marked oss: false (D-48/#5366 escalation) — tests the
2
+ // always-shipping project-listings anonymous surface; see
3
+ // routes/project-listings/me.ts's header comment.
1
4
  /**
2
5
  * @file drafts.test.ts
3
6
  *
@@ -40,6 +43,13 @@ jest.mock("../../../lib/prisma.js");
40
43
 
41
44
  jest.mock("@working-theory/email", () => ({
42
45
  sendLeadNotification: jest.fn().mockResolvedValue({ ok: true, messageId: "msg-test" }),
46
+ // #5406 — `start` now reaches the resume email. Declared here for the reason
47
+ // docs/lessons/project-listings/04 records: a mock factory that omits an
48
+ // export the code under test calls leaves it `undefined` at the callsite.
49
+ // These suites configure no `resumeEmail`, so the send short-circuits before
50
+ // this is reached; it is declared so a future fixture that DOES configure
51
+ // one fails on its assertion rather than on a missing mock.
52
+ sendListingResumeEmail: jest.fn().mockResolvedValue({ ok: true, messageId: "msg-resume" }),
43
53
  }));
44
54
 
45
55
  jest.mock("@working-theory/license", () => ({
@@ -52,8 +62,15 @@ jest.mock("@working-theory/license", () => ({
52
62
  }),
53
63
  }));
54
64
 
65
+ import {
66
+ resolveConsentStatement,
67
+ SITE_CONSENT_CONFIG_DEFAULTS,
68
+ } from "@working-theory/validation";
69
+
55
70
  import { prisma } from "../../../lib/prisma.js";
56
71
  import { setupLeadFileEnv } from "../../../__tests__/factories/lead-fixture";
72
+ import {
73
+ MAX_CONSENT_KEYS, UNRECOVERABLE_CONSENT_STATEMENT } from "../../../services/consent.js";
57
74
  import { patchProjectListingDraftRoute } from "../patch-draft.js";
58
75
  import { startProjectListingRoute } from "../start.js";
59
76
  import { submitProjectListingRoute } from "../submit.js";
@@ -164,6 +181,11 @@ function installStore(): void {
164
181
  },
165
182
  );
166
183
 
184
+ // #5275 — `start` writes the row and its grant in one interactive
185
+ // transaction; the mock hands the same store-backed client through.
186
+ (prisma.$transaction as jest.Mock).mockImplementation(
187
+ async (fn: (tx: typeof prisma) => Promise<unknown>) => fn(prisma),
188
+ );
167
189
  (prisma.projectListing.create as jest.Mock).mockImplementation(
168
190
  ({ data }: { data: Row }) => {
169
191
  const now = new Date();
@@ -181,6 +203,13 @@ function installStore(): void {
181
203
  anythingElse: null,
182
204
  address: null,
183
205
  questions: null,
206
+ // #5407 — a row written before the columns existed, and a freshly
207
+ // started draft on a site that asks for one name line: parts null,
208
+ // no phone, no verification stamp.
209
+ founderFirstName: null,
210
+ founderLastName: null,
211
+ founderPhone: null,
212
+ emailVerifiedAt: null,
184
213
  founderCompany: null,
185
214
  leadId: null,
186
215
  headline: null,
@@ -226,6 +255,12 @@ function installStore(): void {
226
255
  return Promise.resolve({ count: matched.length });
227
256
  },
228
257
  );
258
+
259
+ // #5275 — the grant write `start` now makes. Append-only, so the mock only
260
+ // needs to resolve; the start tests below assert on what it was called with.
261
+ (prisma.consentGrant.createMany as jest.Mock).mockImplementation(
262
+ ({ data }: { data: Row[] }) => Promise.resolve({ count: data.length }),
263
+ );
229
264
  }
230
265
 
231
266
  // ─── Helpers ────────────────────────────────────────────────────────────────
@@ -335,6 +370,115 @@ describe("the draft lifecycle (#5036 D1)", () => {
335
370
  expect(rows).toHaveLength(0);
336
371
  });
337
372
 
373
+ // ── start: the consent grant (#5275 D7/D11) ──
374
+
375
+ it.each([
376
+ ["DECLINES the required purpose", { service_terms: false }],
377
+ ["is EMPTY beside the literal", {}],
378
+ ["OMITS the required purpose", { marketing: true }],
379
+ ])(
380
+ "start: a consents map that %s is a 400 and creates no row and no grant",
381
+ async (_label, consents) => {
382
+ const res = await app.inject({
383
+ method: "POST",
384
+ url: "/v1/project-listings",
385
+ payload: { ...START_PAYLOAD, consents, locale: "en" },
386
+ });
387
+ expect(res.statusCode).toBe(400);
388
+ const body = res.json() as { ok: boolean; error: { code: string; message: string } };
389
+ expect(body.ok).toBe(false);
390
+ expect(body.error.code).toBe("invalid_input");
391
+ expect(body.error.message).toMatch(/service_terms/);
392
+ expect(rows).toHaveLength(0);
393
+ expect(prisma.consentGrant.createMany).not.toHaveBeenCalled();
394
+ },
395
+ );
396
+
397
+ it("start: a consents map with more than MAX_CONSENT_KEYS purposes is a 400", async () => {
398
+ const consents: Record<string, boolean> = { service_terms: true };
399
+ for (let i = 0; i < MAX_CONSENT_KEYS; i += 1) consents[`junk_${i}`] = true;
400
+ const res = await app.inject({
401
+ method: "POST",
402
+ url: "/v1/project-listings",
403
+ payload: { ...START_PAYLOAD, consents, locale: "en" },
404
+ });
405
+ expect(res.statusCode).toBe(400);
406
+ expect(rows).toHaveLength(0);
407
+ });
408
+
409
+ it("start: a grant write that fails takes the row with it — one transaction, no stranded listing", async () => {
410
+ (prisma.$transaction as jest.Mock).mockImplementation(
411
+ async (fn: (tx: typeof prisma) => Promise<unknown>) => {
412
+ const before = rows.length;
413
+ try {
414
+ return await fn(prisma);
415
+ } catch (err) {
416
+ rows.splice(before); // roll back the in-memory store
417
+ throw err;
418
+ }
419
+ },
420
+ );
421
+ (prisma.consentGrant.createMany as jest.Mock).mockRejectedValueOnce(new Error("db down"));
422
+ const res = await app.inject({
423
+ method: "POST",
424
+ url: "/v1/project-listings",
425
+ payload: { ...START_PAYLOAD, consents: { service_terms: true }, locale: "en" },
426
+ });
427
+ expect(res.statusCode).toBe(500);
428
+ expect(rows).toHaveLength(0);
429
+ });
430
+
431
+ it("start: a body carrying consents records one grant per purpose with the DEFAULT statement text", async () => {
432
+ const { id } = await startDraft(app, { consents: { service_terms: true }, locale: "en" });
433
+
434
+ // The expected wording is DERIVED from the registry the server resolves
435
+ // from, never retyped — the whole point is that the row carries the string
436
+ // the renderer showed, not a string this test happens to agree with.
437
+ const purpose = SITE_CONSENT_CONFIG_DEFAULTS.purposes[0]!;
438
+ const { statement } = resolveConsentStatement(purpose, "en");
439
+
440
+ expect(prisma.consentGrant.createMany).toHaveBeenCalledTimes(1);
441
+ const { data } = (prisma.consentGrant.createMany as jest.Mock).mock.calls[0]![0] as {
442
+ data: Row[];
443
+ };
444
+ expect(data).toHaveLength(1);
445
+ expect(data[0]).toMatchObject({
446
+ siteId: SITE_ID,
447
+ subjectType: "project_listing",
448
+ subjectId: id,
449
+ purposeId: "service_terms",
450
+ granted: true,
451
+ locale: "en",
452
+ statement,
453
+ statementHash: sha256(statement),
454
+ });
455
+ expect(data[0]!["statement"]).not.toBe(UNRECOVERABLE_CONSENT_STATEMENT);
456
+ // The row's own consentAt is untouched by the grant write (spec §2.3).
457
+ expect(rows[0]!["consentAt"]).toBeInstanceOf(Date);
458
+ });
459
+
460
+ it("start: a body carrying only the legacy literal records ONE grant marked unrecoverable", async () => {
461
+ const { id } = await startDraft(app);
462
+
463
+ expect(prisma.consentGrant.createMany).toHaveBeenCalledTimes(1);
464
+ const { data } = (prisma.consentGrant.createMany as jest.Mock).mock.calls[0]![0] as {
465
+ data: Row[];
466
+ };
467
+ expect(data).toHaveLength(1);
468
+ expect(data[0]).toMatchObject({
469
+ siteId: SITE_ID,
470
+ subjectType: "project_listing",
471
+ subjectId: id,
472
+ purposeId: "service_terms",
473
+ granted: true,
474
+ locale: "und",
475
+ statement: UNRECOVERABLE_CONSENT_STATEMENT,
476
+ statementHash: sha256(UNRECOVERABLE_CONSENT_STATEMENT),
477
+ });
478
+ // Never today's wording for a legacy call — that would fabricate evidence (D11).
479
+ expect(data[0]!["statement"]).not.toContain("You agree to being contacted");
480
+ });
481
+
338
482
  // ── patch ──
339
483
 
340
484
  it("patch: a SINGLE-field patch round-trips", async () => {