create-ailk 0.2.0 → 0.4.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 (292) hide show
  1. package/component-catalog.md +50 -53
  2. package/dist/cli.js +19 -9
  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 +51 -3
  6. package/dist/lib/extract-module-bundle.js +327 -28
  7. package/dist/lib/fetch-module.d.ts +3 -38
  8. package/dist/lib/fetch-module.js +26 -8
  9. package/dist/lib/fetch-template.d.ts +20 -0
  10. package/dist/lib/fetch-template.js +129 -0
  11. package/dist/lib/module-license-gate.d.ts +10 -0
  12. package/dist/lib/module-license-gate.js +13 -4
  13. package/dist/lib/paid-paths.d.ts +16 -0
  14. package/dist/lib/paid-paths.js +90 -0
  15. package/dist/module-architecture.d.ts +197 -23
  16. package/dist/module-architecture.js +820 -25
  17. package/dist/parse-args.d.ts +9 -2
  18. package/dist/parse-args.js +46 -1
  19. package/dist/programmatic.d.ts +28 -2
  20. package/dist/programmatic.js +115 -8
  21. package/dist/surfaces.d.ts +12 -36
  22. package/dist/surfaces.js +77 -5
  23. package/dist/sync-routes.js +31 -1
  24. package/dist/template-manifest.d.ts +92 -0
  25. package/dist/template-manifest.js +168 -0
  26. package/dist/template-names.d.ts +41 -0
  27. package/dist/template-names.js +44 -0
  28. package/package.json +3 -2
  29. package/site-templates/marketing-site-free/ailk-template.json +8 -0
  30. package/site-templates/marketing-site-free/content/en/Pricing/pricing.mdx +103 -0
  31. package/site-templates/marketing-site-free/content/en/PrivacyPage/privacy.mdx +79 -0
  32. package/site-templates/marketing-site-free/content/en/TermsPage/terms.mdx +80 -0
  33. package/site-templates/marketing-site-free/provenance.json +4 -0
  34. package/templates/.claude/agents/web.md +2 -3
  35. package/templates/.claude/rules/architecture.md +5 -5
  36. package/templates/.claude/skills/README.md +2 -1
  37. package/templates/.env.example +21 -9
  38. package/templates/CONVENTIONS.md +6 -7
  39. package/templates/apps/api/.env.example +9 -0
  40. package/templates/apps/api/CLAUDE.md +33 -5
  41. package/templates/apps/api/package.json +0 -1
  42. package/templates/apps/api/src/__tests__/cors.test.ts +195 -0
  43. package/templates/apps/api/src/__tests__/module-exclusion.test.ts +60 -2
  44. package/templates/apps/api/src/__tests__/vercel-handler.test.ts +159 -0
  45. package/templates/apps/api/src/__tests__/workspace-module-registration.test.ts +166 -0
  46. package/templates/apps/api/src/config/__tests__/modules.test.ts +76 -0
  47. package/templates/apps/api/src/config/index.ts +19 -0
  48. package/templates/apps/api/src/config/modules.ts +12 -14
  49. package/templates/apps/api/src/lib/__mocks__/prisma.ts +3 -0
  50. package/templates/apps/api/src/lib/__tests__/slice-load.test.ts +89 -0
  51. package/templates/apps/api/src/lib/slice-load.ts +90 -0
  52. package/templates/apps/api/src/openapi/__tests__/openapi.test.ts +52 -32
  53. package/templates/apps/api/src/openapi/__tests__/spec-identity.test.ts +182 -0
  54. package/templates/apps/api/src/openapi/__tests__/surface-drift.test.ts +293 -0
  55. package/templates/apps/api/src/openapi/index.ts +9 -3
  56. package/templates/apps/api/src/openapi/spec.ts +505 -65
  57. package/templates/apps/api/src/openapi/surface-drift.ts +195 -0
  58. package/templates/apps/api/src/routes/content/__tests__/create.test.ts +14 -0
  59. package/templates/apps/api/src/routes/content/__tests__/delete.test.ts +14 -0
  60. package/templates/apps/api/src/routes/content/__tests__/update.test.ts +14 -0
  61. package/templates/apps/api/src/routes/content/index.ts +38 -11
  62. package/templates/apps/api/src/routes/project-listings/__tests__/configured-application.test.ts +17 -0
  63. package/templates/apps/api/src/routes/project-listings/__tests__/drafts.test.ts +17 -0
  64. package/templates/apps/api/src/routes/project-listings/__tests__/founder-identity.test.ts +796 -0
  65. package/templates/apps/api/src/routes/project-listings/__tests__/me.test.ts +10 -0
  66. package/templates/apps/api/src/routes/project-listings/__tests__/public.test.ts +38 -0
  67. package/templates/apps/api/src/routes/project-listings/__tests__/resume-email.test.ts +814 -0
  68. package/templates/apps/api/src/routes/project-listings/__tests__/site-answers.test.ts +10 -0
  69. package/templates/apps/api/src/routes/project-listings/__tests__/site-key.test.ts +24 -0
  70. package/templates/apps/api/src/routes/project-listings/__tests__/structured-address.test.ts +17 -0
  71. package/templates/apps/api/src/routes/project-listings/index.ts +37 -10
  72. package/templates/apps/api/src/routes/project-listings/me.ts +10 -0
  73. package/templates/apps/api/src/routes/project-listings/patch-draft.ts +5 -0
  74. package/templates/apps/api/src/routes/project-listings/public.ts +5 -0
  75. package/templates/apps/api/src/routes/project-listings/respond.ts +11 -0
  76. package/templates/apps/api/src/routes/project-listings/resume-token.ts +5 -0
  77. package/templates/apps/api/src/routes/project-listings/start.ts +30 -0
  78. package/templates/apps/api/src/routes/project-listings/submit.ts +5 -0
  79. package/templates/apps/api/src/routes/project-listings/verify-email.ts +150 -0
  80. package/templates/apps/api/src/server.ts +504 -137
  81. package/templates/apps/api/src/services/deliverable-fulfillment.ts +10 -0
  82. package/templates/apps/api/src/services/listing-config.ts +58 -0
  83. package/templates/apps/api/src/services/project-listing-resume-email.ts +326 -0
  84. package/templates/apps/api/src/services/project-listings.ts +229 -4
  85. package/templates/apps/api/src/vercel-handler.ts +60 -26
  86. package/templates/apps/mcp/.env.example +8 -0
  87. package/templates/apps/mcp/CLAUDE.md +2 -2
  88. package/templates/apps/mcp/__tests__/catalog-drift.test.ts +21 -0
  89. package/templates/apps/mcp/__tests__/config/modules.test.ts +78 -0
  90. package/templates/apps/mcp/__tests__/module-exclusion.test.ts +1 -1
  91. package/templates/apps/mcp/src/config/modules.ts +14 -13
  92. package/templates/apps/mcp/src/server.ts +8 -4
  93. package/templates/apps/mcp/src/tools/index.ts +7 -22
  94. package/templates/apps/web/.env.example +15 -0
  95. package/templates/apps/web/app/[locale]/(authed)/{waitlist/__tests__ → __tests__}/gate.test.tsx +4 -4
  96. package/templates/apps/web/app/[locale]/flows/[slug]/FlowStepperClient.tsx +2 -1
  97. package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/FlowStepperClient.permalink.test.tsx +2 -1
  98. package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/FlowStepperClient.test.tsx +2 -1
  99. package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/flow.actions.test.ts +1 -0
  100. package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/two-route-tool.test.tsx +1 -0
  101. package/templates/apps/web/app/[locale]/flows/[slug]/flow.actions.ts +2 -1
  102. package/templates/apps/web/app/[locale]/flows/[slug]/page.tsx +1 -0
  103. package/templates/apps/web/app/[locale]/flows/[slug]/start/page.tsx +1 -0
  104. package/templates/apps/web/app/[locale]/pricing/page.tsx +40 -0
  105. package/templates/apps/web/app/[locale]/privacy/page.tsx +40 -0
  106. package/templates/apps/web/app/[locale]/terms/page.tsx +40 -0
  107. package/templates/apps/web/app/llms.txt/route.ts +12 -9
  108. package/templates/apps/web/jest.config.cjs +19 -13
  109. package/templates/apps/web/lib/__tests__/server-api.test.ts +61 -0
  110. package/templates/apps/web/lib/server-api.ts +28 -12
  111. package/templates/apps/web/messages/en.json +58 -0
  112. package/templates/apps/web/next.config.mjs +1 -3
  113. package/templates/apps/web/package.json +0 -6
  114. package/templates/apps/web/public/android-chrome-192x192.png +0 -0
  115. package/templates/apps/web/public/android-chrome-512x512.png +0 -0
  116. package/templates/apps/web/public/apple-touch-icon.png +0 -0
  117. package/templates/apps/web/public/favicon-16x16.png +0 -0
  118. package/templates/apps/web/public/favicon-32x32.png +0 -0
  119. package/templates/apps/web/public/favicon.ico +0 -0
  120. package/templates/apps/web/public/favicon.svg +6 -3
  121. package/templates/apps/web/public/lockup-horizontal.svg +4 -0
  122. package/templates/apps/web/public/logomark.svg +4 -0
  123. package/templates/apps/web/public/site.webmanifest +2 -2
  124. package/templates/apps/web/public/wordmark.svg +4 -0
  125. package/templates/database/CHANGELOG.md +47 -0
  126. package/templates/database/inbox/schema.prisma +67 -0
  127. package/templates/database/migrations/20260914120000_listing_founder_identity/migration.sql +123 -0
  128. package/templates/database/package.json +1 -1
  129. package/templates/package.json +1 -1
  130. package/templates/.claude/skills/scaffold-commerce/SKILL.md +0 -807
  131. package/templates/.claude/skills/scaffold-commerce/references/lookup-keys-template.md +0 -71
  132. package/templates/.claude/skills/scaffold-commerce/references/marketplace-brand-pages.md +0 -83
  133. package/templates/.claude/skills/scaffold-commerce/templates/checkout-route.template.ts +0 -370
  134. package/templates/.claude/skills/scaffold-commerce/templates/invoice-route.template.ts +0 -376
  135. package/templates/.claude/skills/scaffold-commerce/templates/marketplace-cta-route.tsx.tmpl +0 -72
  136. package/templates/.claude/skills/scaffold-commerce/templates/payment-link-route.template.ts +0 -471
  137. package/templates/.claude/skills/scaffold-commerce/templates/portal-route.template.ts +0 -365
  138. package/templates/.claude/skills/scaffold-commerce/templates/stripe-config.template.ts +0 -60
  139. package/templates/.claude/skills/scaffold-commerce/templates/subscription-route.template.ts +0 -446
  140. package/templates/apps/api/src/__tests__/server.test.ts +0 -253
  141. package/templates/apps/api/src/bin/deliverable-resend.ts +0 -144
  142. package/templates/apps/api/src/bin/deliverable-upload.ts +0 -114
  143. package/templates/apps/api/src/bin/followup-sweep.ts +0 -44
  144. package/templates/apps/api/src/bin/listing-csv-sweep.ts +0 -46
  145. package/templates/apps/api/src/bin/seed-presets.ts +0 -58
  146. package/templates/apps/api/src/bin/seed-waitlist-experiments.ts +0 -104
  147. package/templates/apps/api/src/bin/session-retention-sweep.ts +0 -46
  148. package/templates/apps/api/src/lib/__tests__/stripe.test.ts +0 -74
  149. package/templates/apps/api/src/lib/stripe.ts +0 -52
  150. package/templates/apps/api/src/lib/tenant-db.ts +0 -225
  151. package/templates/apps/api/src/lib/ws-token.ts +0 -91
  152. package/templates/apps/api/src/middleware/tenant.ts +0 -99
  153. package/templates/apps/api/src/routes/billing/__tests__/portal.test.ts +0 -450
  154. package/templates/apps/api/src/routes/billing/__tests__/read.test.ts +0 -262
  155. package/templates/apps/api/src/routes/billing/__tests__/usage.test.ts +0 -436
  156. package/templates/apps/api/src/routes/billing/index.ts +0 -36
  157. package/templates/apps/api/src/routes/billing/portal.ts +0 -182
  158. package/templates/apps/api/src/routes/billing/read.ts +0 -75
  159. package/templates/apps/api/src/routes/billing/usage.ts +0 -153
  160. package/templates/apps/api/src/routes/checkout/__tests__/sessions.test.ts +0 -687
  161. package/templates/apps/api/src/routes/checkout/index.ts +0 -13
  162. package/templates/apps/api/src/routes/checkout/sessions.ts +0 -197
  163. package/templates/apps/api/src/routes/deliverables/__tests__/index.test.ts +0 -393
  164. package/templates/apps/api/src/routes/deliverables/index.ts +0 -200
  165. package/templates/apps/api/src/routes/flow-checkouts/__tests__/index.test.ts +0 -443
  166. package/templates/apps/api/src/routes/flow-checkouts/index.ts +0 -82
  167. package/templates/apps/api/src/routes/flows/README.md +0 -147
  168. package/templates/apps/api/src/routes/flows/__tests__/index.test.ts +0 -752
  169. package/templates/apps/api/src/routes/flows/__tests__/recommender.test.ts +0 -671
  170. package/templates/apps/api/src/routes/flows/index.ts +0 -202
  171. package/templates/apps/api/src/routes/flows/recommender.ts +0 -443
  172. package/templates/apps/api/src/routes/project-listings/__tests__/copy-edit.test.ts +0 -815
  173. package/templates/apps/api/src/routes/project-listings/__tests__/me-route-precedence.test.ts +0 -221
  174. package/templates/apps/api/src/routes/project-listings/__tests__/tenant-isolation.test.ts +0 -566
  175. package/templates/apps/api/src/routes/project-listings/get.ts +0 -70
  176. package/templates/apps/api/src/routes/project-listings/list.ts +0 -47
  177. package/templates/apps/api/src/routes/project-listings/patch.ts +0 -136
  178. package/templates/apps/api/src/routes/schedule/__tests__/index.test.ts +0 -490
  179. package/templates/apps/api/src/routes/schedule/index.ts +0 -249
  180. package/templates/apps/api/src/routes/slack/__tests__/actions.test.ts +0 -385
  181. package/templates/apps/api/src/routes/slack/actions.ts +0 -177
  182. package/templates/apps/api/src/routes/slack/index.ts +0 -38
  183. package/templates/apps/api/src/routes/waitlist-experiments/__tests__/tenant-isolation.test.ts +0 -577
  184. package/templates/apps/api/src/routes/waitlist-experiments/comparison.ts +0 -78
  185. package/templates/apps/api/src/routes/waitlist-experiments/index.ts +0 -41
  186. package/templates/apps/api/src/routes/waitlist-experiments/list.ts +0 -64
  187. package/templates/apps/api/src/routes/waitlist-experiments/respond.ts +0 -42
  188. package/templates/apps/api/src/routes/waitlist-experiments/signup.ts +0 -84
  189. package/templates/apps/api/src/routes/waitlist-experiments/signups.ts +0 -119
  190. package/templates/apps/api/src/routes/waitlist-signups/__tests__/index.test.ts +0 -238
  191. package/templates/apps/api/src/routes/waitlist-signups/index.ts +0 -101
  192. package/templates/apps/api/src/routes/webhooks/README.md +0 -80
  193. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-flow-checkout.test.ts +0 -315
  194. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-idempotency.test.ts +0 -247
  195. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-org-billing.test.ts +0 -270
  196. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-rate-limit.test.ts +0 -198
  197. package/templates/apps/api/src/routes/webhooks/__tests__/stripe.test.ts +0 -633
  198. package/templates/apps/api/src/routes/webhooks/index.ts +0 -41
  199. package/templates/apps/api/src/routes/webhooks/stripe.ts +0 -481
  200. package/templates/apps/api/src/routes/workspaces/__tests__/config-tenant-isolation.test.ts +0 -356
  201. package/templates/apps/api/src/routes/workspaces/__tests__/tenant-isolation.test.ts +0 -671
  202. package/templates/apps/api/src/routes/workspaces/config.ts +0 -125
  203. package/templates/apps/api/src/routes/workspaces/index.ts +0 -194
  204. package/templates/apps/api/src/routes/workspaces/sessions.ts +0 -218
  205. package/templates/apps/api/src/services/__tests__/flow-aggregate.test.ts +0 -425
  206. package/templates/apps/api/src/services/__tests__/flow-engine.test.ts +0 -2840
  207. package/templates/apps/api/src/services/__tests__/lead-promotion.test.ts +0 -393
  208. package/templates/apps/api/src/services/__tests__/listing-csv-sweep.test.ts +0 -560
  209. package/templates/apps/api/src/services/__tests__/listing-promotion.test.ts +0 -684
  210. package/templates/apps/api/src/services/__tests__/playbook-compile.test.ts +0 -406
  211. package/templates/apps/api/src/services/__tests__/playbook-render.test.ts +0 -290
  212. package/templates/apps/api/src/services/__tests__/project-listing-decision.test.ts +0 -736
  213. package/templates/apps/api/src/services/__tests__/project-listing-flow.test.ts +0 -475
  214. package/templates/apps/api/src/services/__tests__/project-listing-issue.test.ts +0 -340
  215. package/templates/apps/api/src/services/__tests__/recommender-capture.test.ts +0 -983
  216. package/templates/apps/api/src/services/__tests__/tenant-context-cascade.test.ts +0 -71
  217. package/templates/apps/api/src/services/__tests__/tenant-context.test.ts +0 -379
  218. package/templates/apps/api/src/services/__tests__/usage-metering.test.ts +0 -768
  219. package/templates/apps/api/src/services/__tests__/waitlist-dashboard.test.ts +0 -1314
  220. package/templates/apps/api/src/services/__tests__/waitlist-experiments.test.ts +0 -341
  221. package/templates/apps/api/src/services/__tests__/waitlist-followup.test.ts +0 -567
  222. package/templates/apps/api/src/services/__tests__/waitlist-scoring.test.ts +0 -474
  223. package/templates/apps/api/src/services/__tests__/waitlist-signups.test.ts +0 -354
  224. package/templates/apps/api/src/services/flow-aggregate.ts +0 -232
  225. package/templates/apps/api/src/services/flow-checkouts.ts +0 -123
  226. package/templates/apps/api/src/services/flow-engine.ts +0 -1296
  227. package/templates/apps/api/src/services/lead-promotion.ts +0 -176
  228. package/templates/apps/api/src/services/listing-csv-sweep.ts +0 -455
  229. package/templates/apps/api/src/services/listing-promotion.ts +0 -342
  230. package/templates/apps/api/src/services/playbook-compile.ts +0 -398
  231. package/templates/apps/api/src/services/playbook-render.ts +0 -263
  232. package/templates/apps/api/src/services/project-listing-decision.ts +0 -510
  233. package/templates/apps/api/src/services/project-listing-flow.ts +0 -426
  234. package/templates/apps/api/src/services/project-listing-issue.ts +0 -251
  235. package/templates/apps/api/src/services/recommender-capture.ts +0 -835
  236. package/templates/apps/api/src/services/scheduling/cal-provider.ts +0 -392
  237. package/templates/apps/api/src/services/scheduling/index.ts +0 -63
  238. package/templates/apps/api/src/services/scheduling/types.ts +0 -88
  239. package/templates/apps/api/src/services/tenant-context.ts +0 -451
  240. package/templates/apps/api/src/services/usage-metering.ts +0 -699
  241. package/templates/apps/api/src/services/waitlist-dashboard.ts +0 -947
  242. package/templates/apps/api/src/services/waitlist-experiments.ts +0 -213
  243. package/templates/apps/api/src/services/waitlist-followup.ts +0 -486
  244. package/templates/apps/api/src/services/waitlist-scoring.ts +0 -166
  245. package/templates/apps/api/src/services/waitlist-signups.ts +0 -165
  246. package/templates/apps/mcp/__tests__/schedule_tools.test.ts +0 -242
  247. package/templates/apps/mcp/src/tools/create_booking.ts +0 -58
  248. package/templates/apps/mcp/src/tools/get_event_meta.ts +0 -55
  249. package/templates/apps/mcp/src/tools/get_flow.ts +0 -61
  250. package/templates/apps/mcp/src/tools/list_availability.ts +0 -67
  251. package/templates/apps/mcp/src/tools/submit_flow_step.ts +0 -83
  252. package/templates/apps/web/app/[locale]/(authed)/waitlist/[experimentId]/page.tsx +0 -309
  253. package/templates/apps/web/app/[locale]/(authed)/waitlist/[experimentId]/signups/[leadId]/page.tsx +0 -149
  254. package/templates/apps/web/app/[locale]/(authed)/waitlist/__tests__/comparison.test.tsx +0 -273
  255. package/templates/apps/web/app/[locale]/(authed)/waitlist/__tests__/list.test.tsx +0 -171
  256. package/templates/apps/web/app/[locale]/(authed)/waitlist/__tests__/signup.test.tsx +0 -179
  257. package/templates/apps/web/app/[locale]/(authed)/waitlist/components/ExperimentsTable.tsx +0 -116
  258. package/templates/apps/web/app/[locale]/(authed)/waitlist/components/OfferFunnelTable.tsx +0 -66
  259. package/templates/apps/web/app/[locale]/(authed)/waitlist/components/RollupTicker.tsx +0 -61
  260. package/templates/apps/web/app/[locale]/(authed)/waitlist/components/ScoreTrace.tsx +0 -114
  261. package/templates/apps/web/app/[locale]/(authed)/waitlist/components/SignupsTable.tsx +0 -139
  262. package/templates/apps/web/app/[locale]/(authed)/waitlist/components/WaitlistComparisonTabs.tsx +0 -85
  263. package/templates/apps/web/app/[locale]/(authed)/waitlist/components/status-badges.tsx +0 -67
  264. package/templates/apps/web/app/[locale]/(authed)/waitlist/page.tsx +0 -179
  265. package/templates/apps/web/app/[locale]/blog/[[...slug]]/page.tsx +0 -178
  266. package/templates/apps/web/app/[locale]/dev/purchase/__tests__/actions.test.ts +0 -160
  267. package/templates/apps/web/app/[locale]/dev/purchase/error.tsx +0 -25
  268. package/templates/apps/web/app/[locale]/dev/purchase/page.tsx +0 -117
  269. package/templates/apps/web/app/[locale]/dev/purchase/purchase.actions.ts +0 -58
  270. package/templates/apps/web/app/[locale]/docs/[[...slug]]/page.tsx +0 -119
  271. package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/checkout.actions.test.ts +0 -287
  272. package/templates/apps/web/app/[locale]/flows/[slug]/checkout.actions.ts +0 -254
  273. package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/ResultsClient.tsx +0 -532
  274. package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/ResultsEmailStep.tsx +0 -121
  275. package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/__tests__/ResultsClient.checkout.test.tsx +0 -457
  276. package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/__tests__/page.test.tsx +0 -396
  277. package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/page.tsx +0 -114
  278. package/templates/apps/web/app/[locale]/projects/apply/ListingApplicationClient.tsx +0 -110
  279. package/templates/apps/web/app/[locale]/projects/apply/__tests__/ListingApplicationClient.test.tsx +0 -96
  280. package/templates/apps/web/app/[locale]/schedule/__tests__/page.test.tsx +0 -117
  281. package/templates/apps/web/app/[locale]/schedule/confirmed/__tests__/page.test.tsx +0 -96
  282. package/templates/apps/web/app/[locale]/schedule/confirmed/page.tsx +0 -92
  283. package/templates/apps/web/app/[locale]/schedule/page.tsx +0 -82
  284. package/templates/apps/web/content/en/blog/README.txt +0 -25
  285. package/templates/apps/web/content/en/docs/index.mdx +0 -28
  286. package/templates/apps/web/lib/__mocks__/source-server.js +0 -31
  287. package/templates/apps/web/lib/blog-list-data.ts +0 -95
  288. package/templates/apps/web/lib/blog-post-data.ts +0 -79
  289. package/templates/apps/web/lib/source.ts +0 -34
  290. package/templates/apps/web/public/logomark-dark.svg +0 -4
  291. package/templates/apps/web/public/logomark-light.svg +0 -4
  292. 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
+ });