create-ailk 0.2.0 → 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 (273) hide show
  1. package/component-catalog.md +50 -53
  2. package/dist/lib/apply-module-patches.d.ts +47 -0
  3. package/dist/lib/apply-module-patches.js +370 -0
  4. package/dist/lib/extract-module-bundle.d.ts +12 -3
  5. package/dist/lib/extract-module-bundle.js +236 -24
  6. package/dist/lib/fetch-module.d.ts +3 -38
  7. package/dist/lib/fetch-module.js +26 -8
  8. package/dist/lib/module-license-gate.d.ts +2 -0
  9. package/dist/lib/module-license-gate.js +1 -0
  10. package/dist/lib/paid-paths.d.ts +16 -0
  11. package/dist/lib/paid-paths.js +90 -0
  12. package/dist/module-architecture.d.ts +139 -23
  13. package/dist/module-architecture.js +668 -25
  14. package/dist/parse-args.d.ts +2 -2
  15. package/dist/parse-args.js +3 -1
  16. package/dist/programmatic.d.ts +2 -1
  17. package/dist/programmatic.js +15 -4
  18. package/dist/surfaces.d.ts +12 -36
  19. package/dist/surfaces.js +35 -5
  20. package/dist/sync-routes.js +31 -1
  21. package/package.json +2 -2
  22. package/templates/.claude/agents/web.md +2 -3
  23. package/templates/.claude/rules/architecture.md +5 -5
  24. package/templates/.claude/skills/README.md +2 -1
  25. package/templates/.env.example +21 -9
  26. package/templates/CONVENTIONS.md +6 -7
  27. package/templates/apps/api/.env.example +9 -0
  28. package/templates/apps/api/CLAUDE.md +33 -5
  29. package/templates/apps/api/package.json +0 -1
  30. package/templates/apps/api/src/__tests__/cors.test.ts +195 -0
  31. package/templates/apps/api/src/__tests__/module-exclusion.test.ts +60 -2
  32. package/templates/apps/api/src/__tests__/vercel-handler.test.ts +159 -0
  33. package/templates/apps/api/src/config/__tests__/modules.test.ts +76 -0
  34. package/templates/apps/api/src/config/index.ts +19 -0
  35. package/templates/apps/api/src/config/modules.ts +12 -14
  36. package/templates/apps/api/src/lib/__mocks__/prisma.ts +3 -0
  37. package/templates/apps/api/src/lib/__tests__/slice-load.test.ts +89 -0
  38. package/templates/apps/api/src/lib/slice-load.ts +90 -0
  39. package/templates/apps/api/src/openapi/__tests__/openapi.test.ts +52 -32
  40. package/templates/apps/api/src/openapi/__tests__/spec-identity.test.ts +182 -0
  41. package/templates/apps/api/src/openapi/__tests__/surface-drift.test.ts +293 -0
  42. package/templates/apps/api/src/openapi/index.ts +9 -3
  43. package/templates/apps/api/src/openapi/spec.ts +505 -65
  44. package/templates/apps/api/src/openapi/surface-drift.ts +195 -0
  45. package/templates/apps/api/src/routes/content/__tests__/create.test.ts +14 -0
  46. package/templates/apps/api/src/routes/content/__tests__/delete.test.ts +14 -0
  47. package/templates/apps/api/src/routes/content/__tests__/update.test.ts +14 -0
  48. package/templates/apps/api/src/routes/content/index.ts +38 -11
  49. package/templates/apps/api/src/routes/project-listings/__tests__/configured-application.test.ts +17 -0
  50. package/templates/apps/api/src/routes/project-listings/__tests__/drafts.test.ts +17 -0
  51. package/templates/apps/api/src/routes/project-listings/__tests__/founder-identity.test.ts +796 -0
  52. package/templates/apps/api/src/routes/project-listings/__tests__/me.test.ts +10 -0
  53. package/templates/apps/api/src/routes/project-listings/__tests__/public.test.ts +38 -0
  54. package/templates/apps/api/src/routes/project-listings/__tests__/resume-email.test.ts +814 -0
  55. package/templates/apps/api/src/routes/project-listings/__tests__/site-answers.test.ts +10 -0
  56. package/templates/apps/api/src/routes/project-listings/__tests__/site-key.test.ts +24 -0
  57. package/templates/apps/api/src/routes/project-listings/__tests__/structured-address.test.ts +17 -0
  58. package/templates/apps/api/src/routes/project-listings/index.ts +37 -10
  59. package/templates/apps/api/src/routes/project-listings/me.ts +10 -0
  60. package/templates/apps/api/src/routes/project-listings/patch-draft.ts +5 -0
  61. package/templates/apps/api/src/routes/project-listings/public.ts +5 -0
  62. package/templates/apps/api/src/routes/project-listings/respond.ts +11 -0
  63. package/templates/apps/api/src/routes/project-listings/resume-token.ts +5 -0
  64. package/templates/apps/api/src/routes/project-listings/start.ts +30 -0
  65. package/templates/apps/api/src/routes/project-listings/submit.ts +5 -0
  66. package/templates/apps/api/src/routes/project-listings/verify-email.ts +150 -0
  67. package/templates/apps/api/src/server.ts +459 -135
  68. package/templates/apps/api/src/services/deliverable-fulfillment.ts +10 -0
  69. package/templates/apps/api/src/services/listing-config.ts +58 -0
  70. package/templates/apps/api/src/services/project-listing-resume-email.ts +326 -0
  71. package/templates/apps/api/src/services/project-listings.ts +229 -4
  72. package/templates/apps/api/src/vercel-handler.ts +60 -26
  73. package/templates/apps/mcp/.env.example +8 -0
  74. package/templates/apps/mcp/CLAUDE.md +2 -2
  75. package/templates/apps/mcp/__tests__/catalog-drift.test.ts +21 -0
  76. package/templates/apps/mcp/__tests__/config/modules.test.ts +78 -0
  77. package/templates/apps/mcp/__tests__/module-exclusion.test.ts +1 -1
  78. package/templates/apps/mcp/src/config/modules.ts +14 -13
  79. package/templates/apps/mcp/src/server.ts +8 -4
  80. package/templates/apps/mcp/src/tools/index.ts +7 -22
  81. package/templates/apps/web/.env.example +15 -0
  82. package/templates/apps/web/app/[locale]/(authed)/{waitlist/__tests__ → __tests__}/gate.test.tsx +4 -4
  83. package/templates/apps/web/app/[locale]/flows/[slug]/FlowStepperClient.tsx +2 -1
  84. package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/FlowStepperClient.permalink.test.tsx +2 -1
  85. package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/FlowStepperClient.test.tsx +2 -1
  86. package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/flow.actions.test.ts +1 -0
  87. package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/two-route-tool.test.tsx +1 -0
  88. package/templates/apps/web/app/[locale]/flows/[slug]/flow.actions.ts +2 -1
  89. package/templates/apps/web/app/[locale]/flows/[slug]/page.tsx +1 -0
  90. package/templates/apps/web/app/[locale]/flows/[slug]/start/page.tsx +1 -0
  91. package/templates/apps/web/app/llms.txt/route.ts +12 -9
  92. package/templates/apps/web/jest.config.cjs +14 -13
  93. package/templates/apps/web/next.config.mjs +1 -3
  94. package/templates/apps/web/package.json +0 -6
  95. package/templates/apps/web/public/android-chrome-192x192.png +0 -0
  96. package/templates/apps/web/public/android-chrome-512x512.png +0 -0
  97. package/templates/apps/web/public/apple-touch-icon.png +0 -0
  98. package/templates/apps/web/public/favicon-16x16.png +0 -0
  99. package/templates/apps/web/public/favicon-32x32.png +0 -0
  100. package/templates/apps/web/public/favicon.ico +0 -0
  101. package/templates/apps/web/public/favicon.svg +6 -3
  102. package/templates/apps/web/public/lockup-horizontal.svg +4 -0
  103. package/templates/apps/web/public/logomark.svg +4 -0
  104. package/templates/apps/web/public/site.webmanifest +2 -2
  105. package/templates/apps/web/public/wordmark.svg +4 -0
  106. package/templates/database/CHANGELOG.md +33 -0
  107. package/templates/database/inbox/schema.prisma +67 -0
  108. package/templates/database/migrations/20260914120000_listing_founder_identity/migration.sql +123 -0
  109. package/templates/database/package.json +1 -1
  110. package/templates/package.json +1 -1
  111. package/templates/.claude/skills/scaffold-commerce/SKILL.md +0 -807
  112. package/templates/.claude/skills/scaffold-commerce/references/lookup-keys-template.md +0 -71
  113. package/templates/.claude/skills/scaffold-commerce/references/marketplace-brand-pages.md +0 -83
  114. package/templates/.claude/skills/scaffold-commerce/templates/checkout-route.template.ts +0 -370
  115. package/templates/.claude/skills/scaffold-commerce/templates/invoice-route.template.ts +0 -376
  116. package/templates/.claude/skills/scaffold-commerce/templates/marketplace-cta-route.tsx.tmpl +0 -72
  117. package/templates/.claude/skills/scaffold-commerce/templates/payment-link-route.template.ts +0 -471
  118. package/templates/.claude/skills/scaffold-commerce/templates/portal-route.template.ts +0 -365
  119. package/templates/.claude/skills/scaffold-commerce/templates/stripe-config.template.ts +0 -60
  120. package/templates/.claude/skills/scaffold-commerce/templates/subscription-route.template.ts +0 -446
  121. package/templates/apps/api/src/__tests__/server.test.ts +0 -253
  122. package/templates/apps/api/src/bin/deliverable-resend.ts +0 -144
  123. package/templates/apps/api/src/bin/deliverable-upload.ts +0 -114
  124. package/templates/apps/api/src/bin/followup-sweep.ts +0 -44
  125. package/templates/apps/api/src/bin/listing-csv-sweep.ts +0 -46
  126. package/templates/apps/api/src/bin/seed-presets.ts +0 -58
  127. package/templates/apps/api/src/bin/seed-waitlist-experiments.ts +0 -104
  128. package/templates/apps/api/src/bin/session-retention-sweep.ts +0 -46
  129. package/templates/apps/api/src/lib/__tests__/stripe.test.ts +0 -74
  130. package/templates/apps/api/src/lib/stripe.ts +0 -52
  131. package/templates/apps/api/src/lib/tenant-db.ts +0 -225
  132. package/templates/apps/api/src/lib/ws-token.ts +0 -91
  133. package/templates/apps/api/src/middleware/tenant.ts +0 -99
  134. package/templates/apps/api/src/routes/billing/__tests__/portal.test.ts +0 -450
  135. package/templates/apps/api/src/routes/billing/__tests__/read.test.ts +0 -262
  136. package/templates/apps/api/src/routes/billing/__tests__/usage.test.ts +0 -436
  137. package/templates/apps/api/src/routes/billing/index.ts +0 -36
  138. package/templates/apps/api/src/routes/billing/portal.ts +0 -182
  139. package/templates/apps/api/src/routes/billing/read.ts +0 -75
  140. package/templates/apps/api/src/routes/billing/usage.ts +0 -153
  141. package/templates/apps/api/src/routes/checkout/__tests__/sessions.test.ts +0 -687
  142. package/templates/apps/api/src/routes/checkout/index.ts +0 -13
  143. package/templates/apps/api/src/routes/checkout/sessions.ts +0 -197
  144. package/templates/apps/api/src/routes/deliverables/__tests__/index.test.ts +0 -393
  145. package/templates/apps/api/src/routes/deliverables/index.ts +0 -200
  146. package/templates/apps/api/src/routes/flow-checkouts/__tests__/index.test.ts +0 -443
  147. package/templates/apps/api/src/routes/flow-checkouts/index.ts +0 -82
  148. package/templates/apps/api/src/routes/flows/README.md +0 -147
  149. package/templates/apps/api/src/routes/flows/__tests__/index.test.ts +0 -752
  150. package/templates/apps/api/src/routes/flows/__tests__/recommender.test.ts +0 -671
  151. package/templates/apps/api/src/routes/flows/index.ts +0 -202
  152. package/templates/apps/api/src/routes/flows/recommender.ts +0 -443
  153. package/templates/apps/api/src/routes/project-listings/__tests__/copy-edit.test.ts +0 -815
  154. package/templates/apps/api/src/routes/project-listings/__tests__/me-route-precedence.test.ts +0 -221
  155. package/templates/apps/api/src/routes/project-listings/__tests__/tenant-isolation.test.ts +0 -566
  156. package/templates/apps/api/src/routes/project-listings/get.ts +0 -70
  157. package/templates/apps/api/src/routes/project-listings/list.ts +0 -47
  158. package/templates/apps/api/src/routes/project-listings/patch.ts +0 -136
  159. package/templates/apps/api/src/routes/schedule/__tests__/index.test.ts +0 -490
  160. package/templates/apps/api/src/routes/schedule/index.ts +0 -249
  161. package/templates/apps/api/src/routes/slack/__tests__/actions.test.ts +0 -385
  162. package/templates/apps/api/src/routes/slack/actions.ts +0 -177
  163. package/templates/apps/api/src/routes/slack/index.ts +0 -38
  164. package/templates/apps/api/src/routes/waitlist-experiments/__tests__/tenant-isolation.test.ts +0 -577
  165. package/templates/apps/api/src/routes/waitlist-experiments/comparison.ts +0 -78
  166. package/templates/apps/api/src/routes/waitlist-experiments/index.ts +0 -41
  167. package/templates/apps/api/src/routes/waitlist-experiments/list.ts +0 -64
  168. package/templates/apps/api/src/routes/waitlist-experiments/respond.ts +0 -42
  169. package/templates/apps/api/src/routes/waitlist-experiments/signup.ts +0 -84
  170. package/templates/apps/api/src/routes/waitlist-experiments/signups.ts +0 -119
  171. package/templates/apps/api/src/routes/waitlist-signups/__tests__/index.test.ts +0 -238
  172. package/templates/apps/api/src/routes/waitlist-signups/index.ts +0 -101
  173. package/templates/apps/api/src/routes/webhooks/README.md +0 -80
  174. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-flow-checkout.test.ts +0 -315
  175. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-idempotency.test.ts +0 -247
  176. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-org-billing.test.ts +0 -270
  177. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-rate-limit.test.ts +0 -198
  178. package/templates/apps/api/src/routes/webhooks/__tests__/stripe.test.ts +0 -633
  179. package/templates/apps/api/src/routes/webhooks/index.ts +0 -41
  180. package/templates/apps/api/src/routes/webhooks/stripe.ts +0 -481
  181. package/templates/apps/api/src/routes/workspaces/__tests__/config-tenant-isolation.test.ts +0 -356
  182. package/templates/apps/api/src/routes/workspaces/__tests__/tenant-isolation.test.ts +0 -671
  183. package/templates/apps/api/src/routes/workspaces/config.ts +0 -125
  184. package/templates/apps/api/src/routes/workspaces/index.ts +0 -194
  185. package/templates/apps/api/src/routes/workspaces/sessions.ts +0 -218
  186. package/templates/apps/api/src/services/__tests__/flow-aggregate.test.ts +0 -425
  187. package/templates/apps/api/src/services/__tests__/flow-engine.test.ts +0 -2840
  188. package/templates/apps/api/src/services/__tests__/lead-promotion.test.ts +0 -393
  189. package/templates/apps/api/src/services/__tests__/listing-csv-sweep.test.ts +0 -560
  190. package/templates/apps/api/src/services/__tests__/listing-promotion.test.ts +0 -684
  191. package/templates/apps/api/src/services/__tests__/playbook-compile.test.ts +0 -406
  192. package/templates/apps/api/src/services/__tests__/playbook-render.test.ts +0 -290
  193. package/templates/apps/api/src/services/__tests__/project-listing-decision.test.ts +0 -736
  194. package/templates/apps/api/src/services/__tests__/project-listing-flow.test.ts +0 -475
  195. package/templates/apps/api/src/services/__tests__/project-listing-issue.test.ts +0 -340
  196. package/templates/apps/api/src/services/__tests__/recommender-capture.test.ts +0 -983
  197. package/templates/apps/api/src/services/__tests__/tenant-context-cascade.test.ts +0 -71
  198. package/templates/apps/api/src/services/__tests__/tenant-context.test.ts +0 -379
  199. package/templates/apps/api/src/services/__tests__/usage-metering.test.ts +0 -768
  200. package/templates/apps/api/src/services/__tests__/waitlist-dashboard.test.ts +0 -1314
  201. package/templates/apps/api/src/services/__tests__/waitlist-experiments.test.ts +0 -341
  202. package/templates/apps/api/src/services/__tests__/waitlist-followup.test.ts +0 -567
  203. package/templates/apps/api/src/services/__tests__/waitlist-scoring.test.ts +0 -474
  204. package/templates/apps/api/src/services/__tests__/waitlist-signups.test.ts +0 -354
  205. package/templates/apps/api/src/services/flow-aggregate.ts +0 -232
  206. package/templates/apps/api/src/services/flow-checkouts.ts +0 -123
  207. package/templates/apps/api/src/services/flow-engine.ts +0 -1296
  208. package/templates/apps/api/src/services/lead-promotion.ts +0 -176
  209. package/templates/apps/api/src/services/listing-csv-sweep.ts +0 -455
  210. package/templates/apps/api/src/services/listing-promotion.ts +0 -342
  211. package/templates/apps/api/src/services/playbook-compile.ts +0 -398
  212. package/templates/apps/api/src/services/playbook-render.ts +0 -263
  213. package/templates/apps/api/src/services/project-listing-decision.ts +0 -510
  214. package/templates/apps/api/src/services/project-listing-flow.ts +0 -426
  215. package/templates/apps/api/src/services/project-listing-issue.ts +0 -251
  216. package/templates/apps/api/src/services/recommender-capture.ts +0 -835
  217. package/templates/apps/api/src/services/scheduling/cal-provider.ts +0 -392
  218. package/templates/apps/api/src/services/scheduling/index.ts +0 -63
  219. package/templates/apps/api/src/services/scheduling/types.ts +0 -88
  220. package/templates/apps/api/src/services/tenant-context.ts +0 -451
  221. package/templates/apps/api/src/services/usage-metering.ts +0 -699
  222. package/templates/apps/api/src/services/waitlist-dashboard.ts +0 -947
  223. package/templates/apps/api/src/services/waitlist-experiments.ts +0 -213
  224. package/templates/apps/api/src/services/waitlist-followup.ts +0 -486
  225. package/templates/apps/api/src/services/waitlist-scoring.ts +0 -166
  226. package/templates/apps/api/src/services/waitlist-signups.ts +0 -165
  227. package/templates/apps/mcp/__tests__/schedule_tools.test.ts +0 -242
  228. package/templates/apps/mcp/src/tools/create_booking.ts +0 -58
  229. package/templates/apps/mcp/src/tools/get_event_meta.ts +0 -55
  230. package/templates/apps/mcp/src/tools/get_flow.ts +0 -61
  231. package/templates/apps/mcp/src/tools/list_availability.ts +0 -67
  232. package/templates/apps/mcp/src/tools/submit_flow_step.ts +0 -83
  233. package/templates/apps/web/app/[locale]/(authed)/waitlist/[experimentId]/page.tsx +0 -309
  234. package/templates/apps/web/app/[locale]/(authed)/waitlist/[experimentId]/signups/[leadId]/page.tsx +0 -149
  235. package/templates/apps/web/app/[locale]/(authed)/waitlist/__tests__/comparison.test.tsx +0 -273
  236. package/templates/apps/web/app/[locale]/(authed)/waitlist/__tests__/list.test.tsx +0 -171
  237. package/templates/apps/web/app/[locale]/(authed)/waitlist/__tests__/signup.test.tsx +0 -179
  238. package/templates/apps/web/app/[locale]/(authed)/waitlist/components/ExperimentsTable.tsx +0 -116
  239. package/templates/apps/web/app/[locale]/(authed)/waitlist/components/OfferFunnelTable.tsx +0 -66
  240. package/templates/apps/web/app/[locale]/(authed)/waitlist/components/RollupTicker.tsx +0 -61
  241. package/templates/apps/web/app/[locale]/(authed)/waitlist/components/ScoreTrace.tsx +0 -114
  242. package/templates/apps/web/app/[locale]/(authed)/waitlist/components/SignupsTable.tsx +0 -139
  243. package/templates/apps/web/app/[locale]/(authed)/waitlist/components/WaitlistComparisonTabs.tsx +0 -85
  244. package/templates/apps/web/app/[locale]/(authed)/waitlist/components/status-badges.tsx +0 -67
  245. package/templates/apps/web/app/[locale]/(authed)/waitlist/page.tsx +0 -179
  246. package/templates/apps/web/app/[locale]/blog/[[...slug]]/page.tsx +0 -178
  247. package/templates/apps/web/app/[locale]/dev/purchase/__tests__/actions.test.ts +0 -160
  248. package/templates/apps/web/app/[locale]/dev/purchase/error.tsx +0 -25
  249. package/templates/apps/web/app/[locale]/dev/purchase/page.tsx +0 -117
  250. package/templates/apps/web/app/[locale]/dev/purchase/purchase.actions.ts +0 -58
  251. package/templates/apps/web/app/[locale]/docs/[[...slug]]/page.tsx +0 -119
  252. package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/checkout.actions.test.ts +0 -287
  253. package/templates/apps/web/app/[locale]/flows/[slug]/checkout.actions.ts +0 -254
  254. package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/ResultsClient.tsx +0 -532
  255. package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/ResultsEmailStep.tsx +0 -121
  256. package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/__tests__/ResultsClient.checkout.test.tsx +0 -457
  257. package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/__tests__/page.test.tsx +0 -396
  258. package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/page.tsx +0 -114
  259. package/templates/apps/web/app/[locale]/projects/apply/ListingApplicationClient.tsx +0 -110
  260. package/templates/apps/web/app/[locale]/projects/apply/__tests__/ListingApplicationClient.test.tsx +0 -96
  261. package/templates/apps/web/app/[locale]/schedule/__tests__/page.test.tsx +0 -117
  262. package/templates/apps/web/app/[locale]/schedule/confirmed/__tests__/page.test.tsx +0 -96
  263. package/templates/apps/web/app/[locale]/schedule/confirmed/page.tsx +0 -92
  264. package/templates/apps/web/app/[locale]/schedule/page.tsx +0 -82
  265. package/templates/apps/web/content/en/blog/README.txt +0 -25
  266. package/templates/apps/web/content/en/docs/index.mdx +0 -28
  267. package/templates/apps/web/lib/__mocks__/source-server.js +0 -31
  268. package/templates/apps/web/lib/blog-list-data.ts +0 -95
  269. package/templates/apps/web/lib/blog-post-data.ts +0 -79
  270. package/templates/apps/web/lib/source.ts +0 -34
  271. package/templates/apps/web/public/logomark-dark.svg +0 -4
  272. package/templates/apps/web/public/logomark-light.svg +0 -4
  273. package/templates/apps/web/source.config.ts +0 -78
@@ -0,0 +1,796 @@
1
+ /**
2
+ * @file founder-identity.test.ts
3
+ *
4
+ * THE ACCEPTANCE GATE for the founder's identity in parts and the resume
5
+ * link's email verification (#5407).
6
+ *
7
+ * Two properties carry this change, and both look fine when broken:
8
+ *
9
+ * 1. `founder_name` STILL HOLDS A DISPLAY NAME, composed server-side from
10
+ * the parts wherever they are written. Every existing reader of it — the
11
+ * submit-time slug fallback, the approve/reject email's greeting, the
12
+ * Slack review card, the build issue, the `routeLead` identity — reads
13
+ * that column and nothing else, so a `start` that stored the parts and
14
+ * left the display line to the client would break all of them at once,
15
+ * silently, one release later. The stored line and the stored parts must
16
+ * also never DISAGREE, which is why the parts win over a `founderName`
17
+ * sent beside them.
18
+ *
19
+ * 2. VERIFICATION IS WRITE-ONCE AND TOKEN-GATED. The stamp says the resume
20
+ * token reached `founder_email` and came back; a second opening must not
21
+ * move it, and a wrong token must change nothing and be indistinguishable
22
+ * from an unknown draft. An anonymous route whose entire input is an id
23
+ * and a token is an oracle for which drafts exist the moment its answers
24
+ * differ.
25
+ *
26
+ * Same harness as `structured-address.test.ts`: real routes, real services,
27
+ * mocked `lib/prisma.js` over a small in-memory row store, `routeLead`
28
+ * running for real against a per-test temp path.
29
+ */
30
+
31
+ // eslint-disable-next-line no-restricted-syntax -- prisma is a DB boundary; __mocks__/prisma.ts activates the auto-mock
32
+ jest.mock("../../../lib/prisma.js");
33
+
34
+ jest.mock("@working-theory/email", () => ({
35
+ sendLeadNotification: jest.fn().mockResolvedValue({ ok: true, messageId: "msg-test" }),
36
+ // #5406/#5407 — the resume email is the ONLY carrier of the verification
37
+ // nonce, so this mock is not a stub to keep the suite quiet: it is the
38
+ // founder's mailbox. `nonceFromEmail` below reads the nonce back out of the
39
+ // link this was asked to send, which is the only way any test here can
40
+ // obtain one.
41
+ sendListingResumeEmail: jest.fn().mockResolvedValue({ ok: true, messageId: "msg-resume" }),
42
+ }));
43
+
44
+ jest.mock("@working-theory/license", () => ({
45
+ checkLicense: jest.fn().mockResolvedValue({
46
+ tier: "free",
47
+ plan: null,
48
+ features: [],
49
+ expiresAt: null,
50
+ revoked: false,
51
+ }),
52
+ }));
53
+
54
+ import { sendListingResumeEmail } from "@working-theory/email";
55
+ import Fastify from "fastify";
56
+ import type { FastifyInstance } from "fastify";
57
+
58
+ import { setupLeadFileEnv } from "../../../__tests__/factories/lead-fixture";
59
+ import { prisma } from "../../../lib/prisma.js";
60
+ import { patchProjectListingDraftRoute } from "../patch-draft.js";
61
+ import { startProjectListingRoute } from "../start.js";
62
+ import { submitProjectListingRoute } from "../submit.js";
63
+ import { verifyProjectListingDraftEmailRoute } from "../verify-email.js";
64
+
65
+ // ─── Fixtures ───────────────────────────────────────────────────────────────
66
+
67
+ const SITE_ID = "site-1";
68
+ const SITE_KEY = "pk-site-1";
69
+
70
+ const opt = (value: string, label: string) => ({ value, label });
71
+
72
+ const VALID_QUESTIONS = {
73
+ q2: {
74
+ prompt: "Why are you here?",
75
+ options: [opt("scale", "Scaling my team"), opt("just-curious", "Just curious")],
76
+ },
77
+ q3: { prompt: "Stage?", options: [opt("a", "A"), opt("b", "B"), opt("c", "C")] },
78
+ q4: { prompt: "Team size?", options: [opt("a", "A"), opt("b", "B"), opt("c", "C")] },
79
+ q5: {
80
+ prompt: "Urgency?",
81
+ options: [opt("urgent", "Now"), opt("planning", "Soon"), opt("exploring", "Someday")],
82
+ },
83
+ q6: {
84
+ prompt: "Pain?",
85
+ options: [
86
+ opt("low", "Low"),
87
+ opt("medium", "Medium"),
88
+ opt("high", "High"),
89
+ opt("critical", "Critical"),
90
+ ],
91
+ },
92
+ q7: { prompt: "Anything else?" },
93
+ extras: [],
94
+ };
95
+
96
+ /** The #5407 contact screen's body: the name in PARTS, plus a phone. */
97
+ const START_PAYLOAD = {
98
+ siteKey: SITE_KEY,
99
+ founderFirstName: "Ada",
100
+ founderLastName: "Lovelace",
101
+ founderEmail: "ada@example.com",
102
+ founderPhone: "+44 20 7946 0958",
103
+ consent: true,
104
+ };
105
+
106
+ /** What every caller that predates #5407 sends, and must go on sending. */
107
+ const LEGACY_START_PAYLOAD = {
108
+ siteKey: SITE_KEY,
109
+ founderName: "Ada van der Berg",
110
+ founderEmail: "ada@example.com",
111
+ consent: true,
112
+ };
113
+
114
+ /** Everything the steps after step one collect, minus the address. */
115
+ const APPLICATION_WITHOUT_ADDRESS = {
116
+ name: "My Project",
117
+ headline: "Ten words or fewer for the tile title here",
118
+ oneLiner: "A short sentence describing what the project does for its people.",
119
+ problem: "The problem is real and this describes it.",
120
+ product: "wireframes",
121
+ commitment: "part-time",
122
+ teamComposition: "co-founders",
123
+ faqs: [
124
+ { question: "Q one?", answer: "A one." },
125
+ { question: "Q two?", answer: "A two." },
126
+ { question: "Q three?", answer: "A three." },
127
+ ],
128
+ questions: VALID_QUESTIONS,
129
+ };
130
+
131
+ /** A complete address, so a draft can be driven all the way to `submit`. */
132
+ const ADDRESS_PARTS = {
133
+ addressLine1: "221B Baker Street",
134
+ city: "London",
135
+ region: "Greater London",
136
+ postalCode: "NW1 6XE",
137
+ country: "GB",
138
+ };
139
+
140
+ // ─── The in-memory row store the Prisma mock is backed by ───────────────────
141
+
142
+ type Row = Record<string, unknown>;
143
+
144
+ let rows: Row[];
145
+ let nextId: number;
146
+ /** Whether the site has switched the #5406 resume email on for this test. */
147
+ let resumeEmailEnabled = true;
148
+
149
+ /**
150
+ * The site's `SiteListingConfig` row, carrying the two members #5406 requires
151
+ * before it will send anything. Everything else falls back to the platform
152
+ * defaults, exactly as it does for a site that configures only these.
153
+ */
154
+ const SITE_CONFIG_ROW = {
155
+ // `listingConfigFor` selects `data` and parses that blob — the config is one
156
+ // JSON column, not a row of columns.
157
+ data: {
158
+ resumeEmail: {
159
+ fromAddress: "hello@site.example",
160
+ baseUrl: "https://site.example/apply",
161
+ },
162
+ },
163
+ };
164
+
165
+ function matches(row: Row, where: Record<string, unknown>): boolean {
166
+ return Object.entries(where).every(([key, value]) => {
167
+ if (key === "OR") {
168
+ return (value as Record<string, unknown>[]).some((clause) => matches(row, clause));
169
+ }
170
+ if (value !== null && typeof value === "object" && !(value instanceof Date)) {
171
+ const clause = value as Record<string, unknown>;
172
+ if ("in" in clause) return (clause["in"] as unknown[]).includes(row[key]);
173
+ if ("not" in clause) return row[key] !== clause["not"];
174
+ if ("gt" in clause) return (row[key] as Date) > (clause["gt"] as Date);
175
+ if ("startsWith" in clause) {
176
+ return (
177
+ typeof row[key] === "string" &&
178
+ (row[key] as string).startsWith(clause["startsWith"] as string)
179
+ );
180
+ }
181
+ }
182
+ return row[key] === value;
183
+ });
184
+ }
185
+
186
+ function installStore(): void {
187
+ rows = [];
188
+ nextId = 1;
189
+ resumeEmailEnabled = true;
190
+
191
+ (prisma.lead.create as jest.Mock).mockImplementation(() =>
192
+ Promise.resolve({ id: `lead_00000000-0000-7000-8000-00000000000${nextId}` }),
193
+ );
194
+
195
+ (prisma.siteListingConfig.findUnique as jest.Mock).mockImplementation(
196
+ ({ where }: { where: { siteId?: string; publicKey?: string } }) => {
197
+ if (where.publicKey !== undefined) {
198
+ return Promise.resolve(where.publicKey === SITE_KEY ? { siteId: SITE_ID } : null);
199
+ }
200
+ // #5406 — the resume email is OPT-IN, and this suite opts in: without
201
+ // `fromAddress` and `baseUrl` no message is sent, so no nonce is ever
202
+ // delivered and nothing here could verify. The `resume-email-off` block
203
+ // at the end of the file asserts exactly that state, with this returning
204
+ // null instead.
205
+ return Promise.resolve(resumeEmailEnabled ? SITE_CONFIG_ROW : null);
206
+ },
207
+ );
208
+
209
+ // #5275 — the grant write `start` makes after the row. Append-only; it only
210
+ // needs to resolve here (drafts.test.ts asserts on what it is called with).
211
+ (prisma.consentGrant.createMany as jest.Mock).mockImplementation(
212
+ ({ data }: { data: Row[] }) => Promise.resolve({ count: data.length }),
213
+ );
214
+
215
+ // #5275 — `start` writes the row and its grant in one interactive
216
+ // transaction; the mock hands the same store-backed client through.
217
+ (prisma.$transaction as jest.Mock).mockImplementation(
218
+ async (fn: (tx: typeof prisma) => Promise<unknown>) => fn(prisma),
219
+ );
220
+
221
+ (prisma.projectListing.create as jest.Mock).mockImplementation(({ data }: { data: Row }) => {
222
+ const now = new Date();
223
+ const row: Row = {
224
+ id: `prjl_00000000-0000-7000-8000-00000000000${nextId++}`,
225
+ slug: null,
226
+ name: null,
227
+ oneLiner: null,
228
+ problem: null,
229
+ url: null,
230
+ product: null,
231
+ commitment: null,
232
+ teamComposition: null,
233
+ faqs: null,
234
+ anythingElse: null,
235
+ address: null,
236
+ // #5279 — a freshly-started draft has no parts, exactly like every row
237
+ // written before the columns existed.
238
+ addressLine1: null,
239
+ addressLine2: null,
240
+ addressCity: null,
241
+ addressRegion: null,
242
+ addressPostalCode: null,
243
+ addressCountry: null,
244
+ questions: null,
245
+ // #5407 — a row written before the columns existed, and a freshly
246
+ // started draft on a site that asks for one name line: parts null,
247
+ // no phone, no verification stamp.
248
+ founderFirstName: null,
249
+ founderLastName: null,
250
+ founderPhone: null,
251
+ emailVerifiedAt: null,
252
+ founderCompany: null,
253
+ leadId: null,
254
+ headline: null,
255
+ logoMarkUrl: null,
256
+ logoWordmarkUrl: null,
257
+ featured: false,
258
+ decidedAt: null,
259
+ decidedBy: null,
260
+ issueUrl: null,
261
+ experimentKey: null,
262
+ windowEndsAt: null,
263
+ createdAt: now,
264
+ updatedAt: now,
265
+ ...data,
266
+ };
267
+ rows.push(row);
268
+ return Promise.resolve(row);
269
+ });
270
+
271
+ (prisma.projectListing.findFirst as jest.Mock).mockImplementation(
272
+ ({ where }: { where: Record<string, unknown> }) =>
273
+ Promise.resolve(rows.find((row) => matches(row, where)) ?? null),
274
+ );
275
+
276
+ (prisma.projectListing.findMany as jest.Mock).mockImplementation(
277
+ ({ where }: { where: Record<string, unknown> }) =>
278
+ Promise.resolve(rows.filter((row) => matches(row, where))),
279
+ );
280
+
281
+ (prisma.projectListing.update as jest.Mock).mockImplementation(
282
+ ({ where, data }: { where: Record<string, unknown>; data: Row }) => {
283
+ const row = rows.find((r) => matches(r, where));
284
+ Object.assign(row!, data, { updatedAt: new Date() });
285
+ return Promise.resolve(row);
286
+ },
287
+ );
288
+
289
+ (prisma.projectListing.updateMany as jest.Mock).mockImplementation(
290
+ ({ where, data }: { where: Record<string, unknown>; data: Row }) => {
291
+ const matched = rows.filter((r) => matches(r, where));
292
+ matched.forEach((r) => Object.assign(r, data, { updatedAt: new Date() }));
293
+ return Promise.resolve({ count: matched.length });
294
+ },
295
+ );
296
+ }
297
+
298
+ // ─── Helpers ────────────────────────────────────────────────────────────────
299
+
300
+ const bearer = (token: string) => ({ authorization: `Bearer ${token}` });
301
+
302
+ async function buildApp(): Promise<FastifyInstance> {
303
+ const app = Fastify();
304
+ await app.register(startProjectListingRoute);
305
+ await app.register(patchProjectListingDraftRoute);
306
+ await app.register(submitProjectListingRoute);
307
+ await app.register(verifyProjectListingDraftEmailRoute);
308
+ await app.ready();
309
+ return app;
310
+ }
311
+
312
+ async function startDraft(
313
+ app: FastifyInstance,
314
+ payload: Record<string, unknown> = START_PAYLOAD,
315
+ ): Promise<{ id: string; resumeToken: string }> {
316
+ const res = await app.inject({
317
+ method: "POST",
318
+ url: "/v1/project-listings",
319
+ payload,
320
+ });
321
+ expect(res.statusCode).toBe(201);
322
+ return (res.json() as { data: { id: string; resumeToken: string } }).data;
323
+ }
324
+
325
+ /**
326
+ * Call the verify route the way a founder's browser does when it lands on the
327
+ * emailed link: the resume token in the header, the nonce in the body.
328
+ *
329
+ * `nonce` is deliberately a separate argument rather than something derived
330
+ * from `draft`, because the WHOLE POINT of #5407's fix is that the nonce is
331
+ * not derivable from anything `start` hands back. A test that could reach it
332
+ * without going through the email would be testing the forgeable design.
333
+ */
334
+ const verifyEmail = (
335
+ app: FastifyInstance,
336
+ id: string,
337
+ token?: string,
338
+ nonce?: string,
339
+ ) =>
340
+ app.inject({
341
+ method: "POST",
342
+ url: `/v1/project-listings/drafts/${id}/verify-email`,
343
+ ...(token === undefined ? {} : { headers: bearer(token) }),
344
+ ...(nonce === undefined ? {} : { payload: { verifyNonce: nonce } }),
345
+ });
346
+
347
+ /**
348
+ * Wait for the fire-and-forget resume email to have been dispatched.
349
+ *
350
+ * `start` returns its 201 without awaiting the send, so the mock is called a
351
+ * microtask or two later. Polling the mock is what makes every test below
352
+ * deterministic without reaching into the service's internals.
353
+ */
354
+ async function waitForEmail(listingId: string): Promise<void> {
355
+ for (let i = 0; i < 50; i += 1) {
356
+ const sent = (sendListingResumeEmail as jest.Mock).mock.calls.some(([arg]) =>
357
+ String((arg as { resumeLink: string }).resumeLink).includes(listingId),
358
+ );
359
+ if (sent) return;
360
+ await new Promise((resolve) => setTimeout(resolve, 2));
361
+ }
362
+ throw new Error(`no resume email dispatched for ${listingId}`);
363
+ }
364
+
365
+ /**
366
+ * The nonce as it reached the FOUNDER — read out of the link the resume email
367
+ * was asked to send, which is the only place it exists outside the server.
368
+ *
369
+ * `sendListingResumeEmail` is mocked at the top of this file, so this reads
370
+ * the argument `project-listing-resume-email.ts` passed it. That indirection
371
+ * is the test's whole value: it proves the nonce travels by mail and by
372
+ * nothing else.
373
+ */
374
+ function nonceFromEmail(listingId: string): string {
375
+ const calls = (sendListingResumeEmail as jest.Mock).mock.calls;
376
+ const call = calls.find(([arg]) =>
377
+ String((arg as { resumeLink: string }).resumeLink).includes(listingId),
378
+ );
379
+ if (!call) throw new Error(`no resume email sent for ${listingId}`);
380
+ const link = new URL((call[0] as { resumeLink: string }).resumeLink);
381
+ const nonce = link.searchParams.get("verify");
382
+ if (!nonce) throw new Error("the emailed link carried no verify nonce");
383
+ return nonce;
384
+ }
385
+
386
+ const patch = (
387
+ app: FastifyInstance,
388
+ draft: { id: string; resumeToken: string },
389
+ payload: Record<string, unknown>,
390
+ ) =>
391
+ app.inject({
392
+ method: "PATCH",
393
+ url: `/v1/project-listings/drafts/${draft.id}`,
394
+ headers: bearer(draft.resumeToken),
395
+ payload,
396
+ });
397
+
398
+ const submit = (app: FastifyInstance, draft: { id: string; resumeToken: string }) =>
399
+ app.inject({
400
+ method: "POST",
401
+ url: `/v1/project-listings/drafts/${draft.id}/submit`,
402
+ headers: bearer(draft.resumeToken),
403
+ });
404
+
405
+ const storedRow = (id: string): Row => {
406
+ const row = rows.find((r) => r["id"] === id);
407
+ expect(row).toBeDefined();
408
+ return row!;
409
+ };
410
+
411
+ const draftBody = (res: { json: () => unknown }) =>
412
+ (res.json() as { data: Record<string, unknown> }).data;
413
+
414
+ // ─── Tests ──────────────────────────────────────────────────────────────────
415
+
416
+ describe("the founder's identity in parts (#5407)", () => {
417
+ setupLeadFileEnv("project-listing-founder-identity");
418
+ let app: FastifyInstance;
419
+
420
+ beforeEach(async () => {
421
+ jest.clearAllMocks();
422
+ installStore();
423
+ app = await buildApp();
424
+ });
425
+
426
+ afterEach(async () => {
427
+ await app.close();
428
+ });
429
+
430
+ describe("`start` stores the parts AND composes the display name", () => {
431
+ it("writes first name, last name and phone to their own columns", async () => {
432
+ const draft = await startDraft(app);
433
+
434
+ const row = storedRow(draft.id);
435
+ expect(row["founderFirstName"]).toBe("Ada");
436
+ expect(row["founderLastName"]).toBe("Lovelace");
437
+ expect(row["founderPhone"]).toBe("+44 20 7946 0958");
438
+ });
439
+
440
+ it("writes `founder_name` as the composed display string", async () => {
441
+ // The column every existing reader reads. Written out literally rather
442
+ // than by calling `composeFounderName`, so a change to the joiner fails
443
+ // this test instead of travelling through it.
444
+ const draft = await startDraft(app);
445
+ expect(storedRow(draft.id)["founderName"]).toBe("Ada Lovelace");
446
+ });
447
+
448
+ it("composes from the PARTS even when the body also sends a display line", async () => {
449
+ // The stored line and the stored parts must never disagree — a row
450
+ // saying "A. Lovelace" beside parts saying "Ada" + "Lovelace" has no
451
+ // reader that can tell which is right.
452
+ const draft = await startDraft(app, {
453
+ ...START_PAYLOAD,
454
+ founderName: "A. Lovelace",
455
+ });
456
+
457
+ const row = storedRow(draft.id);
458
+ expect(row["founderName"]).toBe("Ada Lovelace");
459
+ expect(row["founderFirstName"]).toBe("Ada");
460
+ expect(row["founderLastName"]).toBe("Lovelace");
461
+ });
462
+
463
+ it("carries the composed name through SUBMIT, which is where every reader picks it up", async () => {
464
+ // `submit` is the moment the slug is derived, the Lead is written and
465
+ // the Slack card is posted — all three read `founderName` off the row
466
+ // and none of them knows the parts exist.
467
+ const draft = await startDraft(app);
468
+ await patch(app, draft, { ...APPLICATION_WITHOUT_ADDRESS, address: ADDRESS_PARTS });
469
+
470
+ const res = await submit(app, draft);
471
+
472
+ expect(res.statusCode).toBe(200);
473
+ expect(storedRow(draft.id)["founderName"]).toBe("Ada Lovelace");
474
+ // The parts survive the transition beside it, unaltered.
475
+ expect(storedRow(draft.id)["founderFirstName"]).toBe("Ada");
476
+ expect(storedRow(draft.id)["founderLastName"]).toBe("Lovelace");
477
+ });
478
+ });
479
+
480
+ describe("a caller that predates #5407 is untouched", () => {
481
+ it("stores a `founderName` line VERBATIM, with both parts null", async () => {
482
+ const draft = await startDraft(app, LEGACY_START_PAYLOAD);
483
+
484
+ const row = storedRow(draft.id);
485
+ // Nothing splits it. "Ada van der Berg" has no last space that means
486
+ // anything, and a wrong split would put an invented surname on the row
487
+ // the operator reviews with nothing to mark it as invented.
488
+ expect(row["founderName"]).toBe("Ada van der Berg");
489
+ expect(row["founderFirstName"]).toBeNull();
490
+ expect(row["founderLastName"]).toBeNull();
491
+ expect(row["founderPhone"]).toBeNull();
492
+ });
493
+
494
+ it("refuses a body carrying no name at all, rather than writing a blank one", async () => {
495
+ const { founderName: _line, ...nameless } = LEGACY_START_PAYLOAD;
496
+
497
+ const res = await app.inject({
498
+ method: "POST",
499
+ url: "/v1/project-listings",
500
+ payload: nameless,
501
+ });
502
+
503
+ expect(res.statusCode).toBe(400);
504
+ expect(rows).toHaveLength(0);
505
+ });
506
+
507
+ it("refuses whitespace-only parts, rather than storing a blank-looking name", async () => {
508
+ const res = await app.inject({
509
+ method: "POST",
510
+ url: "/v1/project-listings",
511
+ payload: { ...START_PAYLOAD, founderFirstName: " ", founderLastName: " " },
512
+ });
513
+
514
+ expect(res.statusCode).toBe(400);
515
+ expect(rows).toHaveLength(0);
516
+ });
517
+ });
518
+
519
+ describe("the founder's own draft read echoes their own input back", () => {
520
+ it("carries the phone and an unverified stamp", async () => {
521
+ // The founder's own echo DOES carry the phone — it is their own step-one
522
+ // input, returned to the person who typed it. What must never carry it
523
+ // is the PUBLIC read, which `public.test.ts` asserts against a row with
524
+ // a real phone on it rather than a null one.
525
+ const draft = await startDraft(app);
526
+ const body = draftBody(await patch(app, draft, { headline: "A headline here" }));
527
+ expect(body["founderPhone"]).toBe("+44 20 7946 0958");
528
+ expect(body["emailVerifiedAt"]).toBeNull();
529
+ });
530
+ });
531
+ });
532
+
533
+ describe("the resume link's email verification (#5407)", () => {
534
+ setupLeadFileEnv("project-listing-verify-email");
535
+ let app: FastifyInstance;
536
+
537
+ beforeEach(async () => {
538
+ jest.clearAllMocks();
539
+ installStore();
540
+ app = await buildApp();
541
+ });
542
+
543
+ afterEach(async () => {
544
+ await app.close();
545
+ });
546
+
547
+ /** Start a draft and wait for its resume email to have been dispatched. */
548
+ async function startAndMail(): Promise<{
549
+ id: string;
550
+ resumeToken: string;
551
+ nonce: string;
552
+ }> {
553
+ const draft = await startDraft(app);
554
+ // The send is fire-and-forget, so it lands a microtask or two after the
555
+ // 201. Waiting on the mock is what makes this deterministic.
556
+ await waitForEmail(draft.id);
557
+ return { ...draft, nonce: nonceFromEmail(draft.id) };
558
+ }
559
+
560
+ // ── THE LOAD-BEARING TEST ────────────────────────────────────────────────
561
+ //
562
+ // The first design of this route stamped on the resume token alone, and the
563
+ // resume token is in `start`'s own 201 body. So the forge was two calls:
564
+ // open a draft under a stranger's address, take the token out of the
565
+ // response, present it back. Everything else in this describe block can
566
+ // pass with that design; only this fails.
567
+ describe("the forge path — `start`'s own response is not enough", () => {
568
+ it("REFUSES a caller holding only what `start` returned", async () => {
569
+ const draft = await startDraft(app, {
570
+ ...START_PAYLOAD,
571
+ founderEmail: "victim@example.com",
572
+ });
573
+
574
+ // Everything an attacker has after two HTTP calls: the id and the
575
+ // resume token, straight out of the 201.
576
+ const res = await verifyEmail(app, draft.id, draft.resumeToken, draft.resumeToken);
577
+
578
+ expect(res.statusCode).toBe(404);
579
+ expect(storedRow(draft.id)["emailVerifiedAt"]).toBeNull();
580
+ });
581
+
582
+ it("never puts the nonce in `start`'s response, where the forge would find it", async () => {
583
+ const res = await app.inject({
584
+ method: "POST",
585
+ url: "/v1/project-listings",
586
+ payload: START_PAYLOAD,
587
+ });
588
+
589
+ expect(res.statusCode).toBe(201);
590
+ // The response is the id and the token, and nothing else — the route's
591
+ // hard-floor contract, unchanged by #5407.
592
+ const data = (res.json() as { data: Record<string, unknown> }).data;
593
+ expect(Object.keys(data).sort()).toEqual(["id", "resumeToken"]);
594
+
595
+ // And the nonce is nowhere in the body at all, under any key.
596
+ await waitForEmail(String(data["id"]));
597
+ expect(res.body).not.toContain(nonceFromEmail(String(data["id"])));
598
+ });
599
+
600
+ it("stores only a DIGEST — the nonce is not readable off the row", async () => {
601
+ const { id, nonce } = await startAndMail();
602
+
603
+ const row = storedRow(id);
604
+ expect(row["emailVerifyNonceHash"]).toEqual(expect.any(String));
605
+ expect(row["emailVerifyNonceHash"]).not.toBe(nonce);
606
+ // sha-256, hex.
607
+ expect(row["emailVerifyNonceHash"]).toMatch(/^[0-9a-f]{64}$/);
608
+ });
609
+ });
610
+
611
+ describe("the emailed nonce is what verifies", () => {
612
+ it("stamps `email_verified_at` when the link's own nonce is presented", async () => {
613
+ const { id, resumeToken, nonce } = await startAndMail();
614
+ expect(storedRow(id)["emailVerifiedAt"]).toBeNull();
615
+
616
+ const res = await verifyEmail(app, id, resumeToken, nonce);
617
+
618
+ expect(res.statusCode).toBe(200);
619
+ const stamped = storedRow(id)["emailVerifiedAt"];
620
+ expect(stamped).toBeInstanceOf(Date);
621
+ expect((res.json() as { data: { emailVerifiedAt: string } }).data.emailVerifiedAt).toBe(
622
+ (stamped as Date).toISOString(),
623
+ );
624
+ });
625
+
626
+ it("is WRITE-ONCE — a second call answers the FIRST stamp and moves nothing", async () => {
627
+ const { id, resumeToken, nonce } = await startAndMail();
628
+
629
+ const first = await verifyEmail(app, id, resumeToken, nonce);
630
+ const firstStamp = storedRow(id)["emailVerifiedAt"] as Date;
631
+
632
+ const second = await verifyEmail(app, id, resumeToken, nonce);
633
+
634
+ expect(second.statusCode).toBe(200);
635
+ expect((storedRow(id)["emailVerifiedAt"] as Date).getTime()).toBe(firstStamp.getTime());
636
+ expect(second.json()).toEqual(first.json());
637
+ });
638
+
639
+ it("refuses a WRONG nonce, with the same 404 as everything else", async () => {
640
+ const { id, resumeToken } = await startAndMail();
641
+
642
+ const res = await verifyEmail(app, id, resumeToken, "not-the-nonce");
643
+
644
+ expect(res.statusCode).toBe(404);
645
+ expect(res.json()).toEqual({
646
+ ok: false,
647
+ error: { code: "not_found", message: "Draft not found" },
648
+ });
649
+ expect(storedRow(id)["emailVerifiedAt"]).toBeNull();
650
+ });
651
+
652
+ it("refuses another listing's nonce", async () => {
653
+ const mine = await startAndMail();
654
+ const theirs = await startDraft(app, {
655
+ ...START_PAYLOAD,
656
+ founderEmail: "grace@example.com",
657
+ });
658
+ await waitForEmail(theirs.id);
659
+
660
+ const res = await verifyEmail(
661
+ app,
662
+ theirs.id,
663
+ theirs.resumeToken,
664
+ nonceFromEmail(mine.id),
665
+ );
666
+
667
+ expect(res.statusCode).toBe(404);
668
+ expect(storedRow(theirs.id)["emailVerifiedAt"]).toBeNull();
669
+ expect(storedRow(mine.id)["emailVerifiedAt"]).toBeNull();
670
+ });
671
+
672
+ it("refuses a WRONG resume token even with the right nonce", async () => {
673
+ // The nonce carries the proof, but the token stays in the guard — a
674
+ // nonce that leaked on its own still opens nothing.
675
+ const { id, nonce } = await startAndMail();
676
+
677
+ const res = await verifyEmail(app, id, "not-the-token", nonce);
678
+
679
+ expect(res.statusCode).toBe(404);
680
+ expect(storedRow(id)["emailVerifiedAt"]).toBeNull();
681
+ });
682
+ });
683
+
684
+ describe("the error surface is `patch`'s and `submit`'s", () => {
685
+ it("gives an UNKNOWN id the identical answer to a wrong nonce", async () => {
686
+ const { id, resumeToken, nonce } = await startAndMail();
687
+ const wrongNonce = await verifyEmail(app, id, resumeToken, "not-the-nonce");
688
+ const unknownId = await verifyEmail(
689
+ app,
690
+ "prjl_00000000-0000-7000-8000-0000000000ff",
691
+ resumeToken,
692
+ nonce,
693
+ );
694
+
695
+ expect(unknownId.statusCode).toBe(wrongNonce.statusCode);
696
+ expect(unknownId.json()).toEqual(wrongNonce.json());
697
+ });
698
+
699
+ it("refuses a SUBMITTED row — submit clears both digests", async () => {
700
+ const { id, resumeToken, nonce } = await startAndMail();
701
+ const draft = { id, resumeToken };
702
+ await patch(app, draft, { ...APPLICATION_WITHOUT_ADDRESS, address: ADDRESS_PARTS });
703
+ expect((await submit(app, draft)).statusCode).toBe(200);
704
+
705
+ expect(storedRow(id)["emailVerifyNonceHash"]).toBeNull();
706
+ const res = await verifyEmail(app, id, resumeToken, nonce);
707
+
708
+ expect(res.statusCode).toBe(404);
709
+ expect(storedRow(id)["emailVerifiedAt"]).toBeNull();
710
+ });
711
+
712
+ it("answers 401 when no token is presented at all", async () => {
713
+ const { id, nonce } = await startAndMail();
714
+
715
+ const res = await verifyEmail(app, id, undefined, nonce);
716
+
717
+ expect(res.statusCode).toBe(401);
718
+ expect(res.json()).toEqual({
719
+ ok: false,
720
+ error: { code: "unauthorized", message: "A resume token is required" },
721
+ });
722
+ });
723
+
724
+ it("answers 400 when the body carries no nonce", async () => {
725
+ const { id, resumeToken } = await startAndMail();
726
+
727
+ const res = await verifyEmail(app, id, resumeToken);
728
+
729
+ expect(res.statusCode).toBe(400);
730
+ expect((res.json() as { error: { code: string } }).error.code).toBe("validation_failed");
731
+ expect(storedRow(id)["emailVerifiedAt"]).toBeNull();
732
+ });
733
+
734
+ it("answers 400 on a malformed id, before any credential is consulted", async () => {
735
+ const res = await app.inject({
736
+ method: "POST",
737
+ url: "/v1/project-listings/drafts/not-an-id/verify-email",
738
+ headers: bearer("anything"),
739
+ payload: { verifyNonce: "anything" },
740
+ });
741
+
742
+ expect(res.statusCode).toBe(400);
743
+ expect((res.json() as { error: { code: string } }).error.code).toBe("validation_failed");
744
+ });
745
+
746
+ it("never returns either credential, on success or failure", async () => {
747
+ const { id, resumeToken, nonce } = await startAndMail();
748
+ const ok = await verifyEmail(app, id, resumeToken, nonce);
749
+ const bad = await verifyEmail(app, id, resumeToken, "not-the-nonce");
750
+
751
+ for (const res of [ok, bad]) {
752
+ expect(res.body).not.toContain(resumeToken);
753
+ expect(res.body).not.toContain(nonce);
754
+ }
755
+ // And nothing but the id and the stamp rides the success body.
756
+ expect(Object.keys((ok.json() as { data: Record<string, unknown> }).data).sort()).toEqual([
757
+ "emailVerifiedAt",
758
+ "id",
759
+ ]);
760
+ });
761
+
762
+ it("surfaces the stamp on the founder's own draft read", async () => {
763
+ const { id, resumeToken, nonce } = await startAndMail();
764
+ await verifyEmail(app, id, resumeToken, nonce);
765
+
766
+ const body = draftBody(
767
+ await patch(app, { id, resumeToken }, { headline: "A headline here" }),
768
+ );
769
+
770
+ expect(body["emailVerifiedAt"]).toBe(
771
+ (storedRow(id)["emailVerifiedAt"] as Date).toISOString(),
772
+ );
773
+ // The nonce digest is an internal column and never rides a response.
774
+ expect(body["emailVerifyNonceHash"]).toBeUndefined();
775
+ });
776
+ });
777
+
778
+ describe("a site that sends no resume email can never verify", () => {
779
+ it("mints a nonce, delivers none, and leaves the row unverifiable", async () => {
780
+ resumeEmailEnabled = false;
781
+
782
+ const draft = await startDraft(app);
783
+ await Promise.resolve();
784
+
785
+ // The nonce exists — `start` cannot know whether the send will happen.
786
+ expect(storedRow(draft.id)["emailVerifyNonceHash"]).toEqual(expect.any(String));
787
+ // But nothing was mailed, so nothing outside the server holds it.
788
+ expect(sendListingResumeEmail).not.toHaveBeenCalled();
789
+ // And the only credentials anyone has do not verify. That is the
790
+ // correct outcome, not a gap: nobody has read anything at that address.
791
+ const res = await verifyEmail(app, draft.id, draft.resumeToken, draft.resumeToken);
792
+ expect(res.statusCode).toBe(404);
793
+ expect(storedRow(draft.id)["emailVerifiedAt"]).toBeNull();
794
+ });
795
+ });
796
+ });