create-ailk 0.1.1 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (284) hide show
  1. package/component-catalog.md +210 -52
  2. package/dist/cli.js +0 -0
  3. package/dist/lib/apply-module-patches.d.ts +47 -0
  4. package/dist/lib/apply-module-patches.js +370 -0
  5. package/dist/lib/extract-module-bundle.d.ts +12 -3
  6. package/dist/lib/extract-module-bundle.js +236 -24
  7. package/dist/lib/fetch-module.d.ts +3 -38
  8. package/dist/lib/fetch-module.js +26 -8
  9. package/dist/lib/module-license-gate.d.ts +2 -0
  10. package/dist/lib/module-license-gate.js +1 -0
  11. package/dist/lib/paid-paths.d.ts +16 -0
  12. package/dist/lib/paid-paths.js +90 -0
  13. package/dist/module-architecture.d.ts +139 -23
  14. package/dist/module-architecture.js +668 -25
  15. package/dist/parse-args.d.ts +2 -2
  16. package/dist/parse-args.js +3 -1
  17. package/dist/programmatic.d.ts +2 -1
  18. package/dist/programmatic.js +15 -4
  19. package/dist/surfaces.d.ts +12 -36
  20. package/dist/surfaces.js +35 -5
  21. package/dist/sync-routes.js +31 -1
  22. package/package.json +14 -15
  23. package/templates/.claude/agents/web.md +2 -3
  24. package/templates/.claude/rules/architecture.md +5 -5
  25. package/templates/.claude/skills/README.md +2 -1
  26. package/templates/.env.example +21 -9
  27. package/templates/CONVENTIONS.md +6 -7
  28. package/templates/apps/api/.env.example +9 -0
  29. package/templates/apps/api/CLAUDE.md +35 -4
  30. package/templates/apps/api/package.json +0 -1
  31. package/templates/apps/api/src/__tests__/cors.test.ts +195 -0
  32. package/templates/apps/api/src/__tests__/module-exclusion.test.ts +60 -2
  33. package/templates/apps/api/src/__tests__/vercel-handler.test.ts +159 -0
  34. package/templates/apps/api/src/config/__tests__/modules.test.ts +76 -0
  35. package/templates/apps/api/src/config/index.ts +19 -0
  36. package/templates/apps/api/src/config/modules.ts +12 -14
  37. package/templates/apps/api/src/lib/__mocks__/prisma.ts +13 -0
  38. package/templates/apps/api/src/lib/__tests__/slice-load.test.ts +89 -0
  39. package/templates/apps/api/src/lib/slice-load.ts +90 -0
  40. package/templates/apps/api/src/openapi/__tests__/openapi.test.ts +52 -32
  41. package/templates/apps/api/src/openapi/__tests__/spec-identity.test.ts +182 -0
  42. package/templates/apps/api/src/openapi/__tests__/surface-drift.test.ts +293 -0
  43. package/templates/apps/api/src/openapi/index.ts +9 -3
  44. package/templates/apps/api/src/openapi/spec.ts +505 -65
  45. package/templates/apps/api/src/openapi/surface-drift.ts +195 -0
  46. package/templates/apps/api/src/routes/content/__tests__/create.test.ts +14 -0
  47. package/templates/apps/api/src/routes/content/__tests__/delete.test.ts +14 -0
  48. package/templates/apps/api/src/routes/content/__tests__/update.test.ts +14 -0
  49. package/templates/apps/api/src/routes/content/index.ts +38 -11
  50. package/templates/apps/api/src/routes/project-listings/__tests__/configured-application.test.ts +28 -0
  51. package/templates/apps/api/src/routes/project-listings/__tests__/drafts.test.ts +144 -0
  52. package/templates/apps/api/src/routes/project-listings/__tests__/founder-identity.test.ts +796 -0
  53. package/templates/apps/api/src/routes/project-listings/__tests__/me.test.ts +478 -0
  54. package/templates/apps/api/src/routes/project-listings/__tests__/public.test.ts +38 -0
  55. package/templates/apps/api/src/routes/project-listings/__tests__/resume-email.test.ts +814 -0
  56. package/templates/apps/api/src/routes/project-listings/__tests__/site-answers.test.ts +717 -0
  57. package/templates/apps/api/src/routes/project-listings/__tests__/site-key.test.ts +35 -0
  58. package/templates/apps/api/src/routes/project-listings/__tests__/structured-address.test.ts +484 -0
  59. package/templates/apps/api/src/routes/project-listings/index.ts +52 -10
  60. package/templates/apps/api/src/routes/project-listings/me.ts +86 -0
  61. package/templates/apps/api/src/routes/project-listings/patch-draft.ts +5 -0
  62. package/templates/apps/api/src/routes/project-listings/public.ts +5 -0
  63. package/templates/apps/api/src/routes/project-listings/respond.ts +11 -0
  64. package/templates/apps/api/src/routes/project-listings/resume-token.ts +5 -0
  65. package/templates/apps/api/src/routes/project-listings/start.ts +38 -0
  66. package/templates/apps/api/src/routes/project-listings/submit.ts +5 -0
  67. package/templates/apps/api/src/routes/project-listings/verify-email.ts +150 -0
  68. package/templates/apps/api/src/server.ts +471 -133
  69. package/templates/apps/api/src/services/__tests__/consent-migration.test.ts +65 -0
  70. package/templates/apps/api/src/services/__tests__/consent.test.ts +282 -0
  71. package/templates/apps/api/src/services/consent.ts +236 -0
  72. package/templates/apps/api/src/services/deliverable-fulfillment.ts +10 -0
  73. package/templates/apps/api/src/services/listing-config.ts +58 -0
  74. package/templates/apps/api/src/services/project-listing-resume-email.ts +326 -0
  75. package/templates/apps/api/src/services/project-listings.ts +602 -29
  76. package/templates/apps/api/src/vercel-handler.ts +60 -26
  77. package/templates/apps/mcp/.env.example +8 -0
  78. package/templates/apps/mcp/CLAUDE.md +2 -2
  79. package/templates/apps/mcp/__tests__/catalog-drift.test.ts +21 -0
  80. package/templates/apps/mcp/__tests__/config/modules.test.ts +78 -0
  81. package/templates/apps/mcp/__tests__/module-exclusion.test.ts +1 -1
  82. package/templates/apps/mcp/src/config/modules.ts +14 -13
  83. package/templates/apps/mcp/src/server.ts +8 -4
  84. package/templates/apps/mcp/src/tools/index.ts +7 -22
  85. package/templates/apps/web/.env.example +15 -0
  86. package/templates/apps/web/app/[locale]/(authed)/{waitlist/__tests__ → __tests__}/gate.test.tsx +4 -4
  87. package/templates/apps/web/app/[locale]/flows/[slug]/FlowStepperClient.tsx +2 -1
  88. package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/FlowStepperClient.permalink.test.tsx +2 -1
  89. package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/FlowStepperClient.test.tsx +2 -1
  90. package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/flow.actions.test.ts +1 -0
  91. package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/two-route-tool.test.tsx +1 -0
  92. package/templates/apps/web/app/[locale]/flows/[slug]/flow.actions.ts +2 -1
  93. package/templates/apps/web/app/[locale]/flows/[slug]/page.tsx +1 -0
  94. package/templates/apps/web/app/[locale]/flows/[slug]/start/page.tsx +1 -0
  95. package/templates/apps/web/app/[locale]/layout.tsx +13 -1
  96. package/templates/apps/web/app/llms.txt/route.ts +12 -9
  97. package/templates/apps/web/jest.config.cjs +20 -13
  98. package/templates/apps/web/lib/__tests__/site-theme.test.ts +112 -0
  99. package/templates/apps/web/lib/site-brand.tsx +4 -1
  100. package/templates/apps/web/lib/site-theme.ts +74 -0
  101. package/templates/apps/web/next.config.mjs +1 -3
  102. package/templates/apps/web/package.json +1 -6
  103. package/templates/apps/web/public/android-chrome-192x192.png +0 -0
  104. package/templates/apps/web/public/android-chrome-512x512.png +0 -0
  105. package/templates/apps/web/public/apple-touch-icon.png +0 -0
  106. package/templates/apps/web/public/favicon-16x16.png +0 -0
  107. package/templates/apps/web/public/favicon-32x32.png +0 -0
  108. package/templates/apps/web/public/favicon.ico +0 -0
  109. package/templates/apps/web/public/favicon.svg +6 -3
  110. package/templates/apps/web/public/lockup-horizontal.svg +4 -0
  111. package/templates/apps/web/public/logomark.svg +4 -0
  112. package/templates/apps/web/public/site.webmanifest +2 -2
  113. package/templates/apps/web/public/wordmark.svg +4 -0
  114. package/templates/content/_site.mdx +12 -0
  115. package/templates/database/CHANGELOG.md +94 -0
  116. package/templates/database/inbox/schema.prisma +165 -0
  117. package/templates/database/migrations/20260911140000_listing_structured_address/migration.sql +32 -0
  118. package/templates/database/migrations/20260911180000_consent_grants/migration.sql +71 -0
  119. package/templates/database/migrations/20260911200000_listing_site_answers/migration.sql +30 -0
  120. package/templates/database/migrations/20260912120000_listing_owner_link/migration.sql +49 -0
  121. package/templates/database/migrations/20260914120000_listing_founder_identity/migration.sql +123 -0
  122. package/templates/database/package.json +1 -1
  123. package/templates/database/scripts/db-generate-locked.sh +0 -0
  124. package/templates/package.json +1 -1
  125. package/templates/.claude/skills/scaffold-commerce/SKILL.md +0 -807
  126. package/templates/.claude/skills/scaffold-commerce/references/lookup-keys-template.md +0 -71
  127. package/templates/.claude/skills/scaffold-commerce/references/marketplace-brand-pages.md +0 -83
  128. package/templates/.claude/skills/scaffold-commerce/templates/checkout-route.template.ts +0 -370
  129. package/templates/.claude/skills/scaffold-commerce/templates/invoice-route.template.ts +0 -376
  130. package/templates/.claude/skills/scaffold-commerce/templates/marketplace-cta-route.tsx.tmpl +0 -72
  131. package/templates/.claude/skills/scaffold-commerce/templates/payment-link-route.template.ts +0 -471
  132. package/templates/.claude/skills/scaffold-commerce/templates/portal-route.template.ts +0 -365
  133. package/templates/.claude/skills/scaffold-commerce/templates/stripe-config.template.ts +0 -60
  134. package/templates/.claude/skills/scaffold-commerce/templates/subscription-route.template.ts +0 -446
  135. package/templates/apps/api/src/__tests__/server.test.ts +0 -253
  136. package/templates/apps/api/src/bin/deliverable-resend.ts +0 -144
  137. package/templates/apps/api/src/bin/deliverable-upload.ts +0 -114
  138. package/templates/apps/api/src/bin/followup-sweep.ts +0 -44
  139. package/templates/apps/api/src/bin/listing-csv-sweep.ts +0 -46
  140. package/templates/apps/api/src/bin/seed-presets.ts +0 -58
  141. package/templates/apps/api/src/bin/seed-waitlist-experiments.ts +0 -104
  142. package/templates/apps/api/src/bin/session-retention-sweep.ts +0 -46
  143. package/templates/apps/api/src/lib/__tests__/stripe.test.ts +0 -74
  144. package/templates/apps/api/src/lib/stripe.ts +0 -52
  145. package/templates/apps/api/src/lib/tenant-db.ts +0 -225
  146. package/templates/apps/api/src/lib/ws-token.ts +0 -91
  147. package/templates/apps/api/src/middleware/tenant.ts +0 -99
  148. package/templates/apps/api/src/routes/billing/__tests__/portal.test.ts +0 -450
  149. package/templates/apps/api/src/routes/billing/__tests__/read.test.ts +0 -262
  150. package/templates/apps/api/src/routes/billing/__tests__/usage.test.ts +0 -436
  151. package/templates/apps/api/src/routes/billing/index.ts +0 -36
  152. package/templates/apps/api/src/routes/billing/portal.ts +0 -182
  153. package/templates/apps/api/src/routes/billing/read.ts +0 -75
  154. package/templates/apps/api/src/routes/billing/usage.ts +0 -153
  155. package/templates/apps/api/src/routes/checkout/__tests__/sessions.test.ts +0 -687
  156. package/templates/apps/api/src/routes/checkout/index.ts +0 -13
  157. package/templates/apps/api/src/routes/checkout/sessions.ts +0 -197
  158. package/templates/apps/api/src/routes/deliverables/__tests__/index.test.ts +0 -393
  159. package/templates/apps/api/src/routes/deliverables/index.ts +0 -200
  160. package/templates/apps/api/src/routes/flow-checkouts/__tests__/index.test.ts +0 -443
  161. package/templates/apps/api/src/routes/flow-checkouts/index.ts +0 -82
  162. package/templates/apps/api/src/routes/flows/README.md +0 -147
  163. package/templates/apps/api/src/routes/flows/__tests__/index.test.ts +0 -752
  164. package/templates/apps/api/src/routes/flows/__tests__/recommender.test.ts +0 -671
  165. package/templates/apps/api/src/routes/flows/index.ts +0 -202
  166. package/templates/apps/api/src/routes/flows/recommender.ts +0 -443
  167. package/templates/apps/api/src/routes/project-listings/__tests__/copy-edit.test.ts +0 -815
  168. package/templates/apps/api/src/routes/project-listings/__tests__/tenant-isolation.test.ts +0 -566
  169. package/templates/apps/api/src/routes/project-listings/get.ts +0 -70
  170. package/templates/apps/api/src/routes/project-listings/list.ts +0 -47
  171. package/templates/apps/api/src/routes/project-listings/patch.ts +0 -136
  172. package/templates/apps/api/src/routes/schedule/__tests__/index.test.ts +0 -490
  173. package/templates/apps/api/src/routes/schedule/index.ts +0 -249
  174. package/templates/apps/api/src/routes/slack/__tests__/actions.test.ts +0 -385
  175. package/templates/apps/api/src/routes/slack/actions.ts +0 -177
  176. package/templates/apps/api/src/routes/slack/index.ts +0 -38
  177. package/templates/apps/api/src/routes/waitlist-experiments/__tests__/tenant-isolation.test.ts +0 -577
  178. package/templates/apps/api/src/routes/waitlist-experiments/comparison.ts +0 -78
  179. package/templates/apps/api/src/routes/waitlist-experiments/index.ts +0 -41
  180. package/templates/apps/api/src/routes/waitlist-experiments/list.ts +0 -64
  181. package/templates/apps/api/src/routes/waitlist-experiments/respond.ts +0 -42
  182. package/templates/apps/api/src/routes/waitlist-experiments/signup.ts +0 -84
  183. package/templates/apps/api/src/routes/waitlist-experiments/signups.ts +0 -119
  184. package/templates/apps/api/src/routes/waitlist-signups/__tests__/index.test.ts +0 -238
  185. package/templates/apps/api/src/routes/waitlist-signups/index.ts +0 -101
  186. package/templates/apps/api/src/routes/webhooks/README.md +0 -80
  187. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-flow-checkout.test.ts +0 -315
  188. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-idempotency.test.ts +0 -247
  189. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-org-billing.test.ts +0 -270
  190. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-rate-limit.test.ts +0 -198
  191. package/templates/apps/api/src/routes/webhooks/__tests__/stripe.test.ts +0 -633
  192. package/templates/apps/api/src/routes/webhooks/index.ts +0 -41
  193. package/templates/apps/api/src/routes/webhooks/stripe.ts +0 -481
  194. package/templates/apps/api/src/routes/workspaces/__tests__/config-tenant-isolation.test.ts +0 -356
  195. package/templates/apps/api/src/routes/workspaces/__tests__/tenant-isolation.test.ts +0 -671
  196. package/templates/apps/api/src/routes/workspaces/config.ts +0 -125
  197. package/templates/apps/api/src/routes/workspaces/index.ts +0 -194
  198. package/templates/apps/api/src/routes/workspaces/sessions.ts +0 -218
  199. package/templates/apps/api/src/services/__tests__/flow-aggregate.test.ts +0 -425
  200. package/templates/apps/api/src/services/__tests__/flow-engine.test.ts +0 -2840
  201. package/templates/apps/api/src/services/__tests__/lead-promotion.test.ts +0 -393
  202. package/templates/apps/api/src/services/__tests__/listing-csv-sweep.test.ts +0 -560
  203. package/templates/apps/api/src/services/__tests__/playbook-compile.test.ts +0 -406
  204. package/templates/apps/api/src/services/__tests__/playbook-render.test.ts +0 -290
  205. package/templates/apps/api/src/services/__tests__/project-listing-decision.test.ts +0 -736
  206. package/templates/apps/api/src/services/__tests__/project-listing-flow.test.ts +0 -475
  207. package/templates/apps/api/src/services/__tests__/project-listing-issue.test.ts +0 -340
  208. package/templates/apps/api/src/services/__tests__/recommender-capture.test.ts +0 -983
  209. package/templates/apps/api/src/services/__tests__/tenant-context-cascade.test.ts +0 -71
  210. package/templates/apps/api/src/services/__tests__/tenant-context.test.ts +0 -379
  211. package/templates/apps/api/src/services/__tests__/usage-metering.test.ts +0 -768
  212. package/templates/apps/api/src/services/__tests__/waitlist-dashboard.test.ts +0 -1314
  213. package/templates/apps/api/src/services/__tests__/waitlist-experiments.test.ts +0 -341
  214. package/templates/apps/api/src/services/__tests__/waitlist-followup.test.ts +0 -567
  215. package/templates/apps/api/src/services/__tests__/waitlist-scoring.test.ts +0 -474
  216. package/templates/apps/api/src/services/__tests__/waitlist-signups.test.ts +0 -354
  217. package/templates/apps/api/src/services/flow-aggregate.ts +0 -232
  218. package/templates/apps/api/src/services/flow-checkouts.ts +0 -123
  219. package/templates/apps/api/src/services/flow-engine.ts +0 -1278
  220. package/templates/apps/api/src/services/lead-promotion.ts +0 -176
  221. package/templates/apps/api/src/services/listing-csv-sweep.ts +0 -455
  222. package/templates/apps/api/src/services/playbook-compile.ts +0 -398
  223. package/templates/apps/api/src/services/playbook-render.ts +0 -263
  224. package/templates/apps/api/src/services/project-listing-decision.ts +0 -490
  225. package/templates/apps/api/src/services/project-listing-flow.ts +0 -426
  226. package/templates/apps/api/src/services/project-listing-issue.ts +0 -251
  227. package/templates/apps/api/src/services/recommender-capture.ts +0 -835
  228. package/templates/apps/api/src/services/scheduling/cal-provider.ts +0 -392
  229. package/templates/apps/api/src/services/scheduling/index.ts +0 -63
  230. package/templates/apps/api/src/services/scheduling/types.ts +0 -88
  231. package/templates/apps/api/src/services/tenant-context.ts +0 -451
  232. package/templates/apps/api/src/services/usage-metering.ts +0 -699
  233. package/templates/apps/api/src/services/waitlist-dashboard.ts +0 -947
  234. package/templates/apps/api/src/services/waitlist-experiments.ts +0 -213
  235. package/templates/apps/api/src/services/waitlist-followup.ts +0 -486
  236. package/templates/apps/api/src/services/waitlist-scoring.ts +0 -166
  237. package/templates/apps/api/src/services/waitlist-signups.ts +0 -165
  238. package/templates/apps/mcp/__tests__/schedule_tools.test.ts +0 -242
  239. package/templates/apps/mcp/src/tools/create_booking.ts +0 -58
  240. package/templates/apps/mcp/src/tools/get_event_meta.ts +0 -55
  241. package/templates/apps/mcp/src/tools/get_flow.ts +0 -61
  242. package/templates/apps/mcp/src/tools/list_availability.ts +0 -67
  243. package/templates/apps/mcp/src/tools/submit_flow_step.ts +0 -83
  244. package/templates/apps/web/app/[locale]/(authed)/waitlist/[experimentId]/page.tsx +0 -309
  245. package/templates/apps/web/app/[locale]/(authed)/waitlist/[experimentId]/signups/[leadId]/page.tsx +0 -149
  246. package/templates/apps/web/app/[locale]/(authed)/waitlist/__tests__/comparison.test.tsx +0 -273
  247. package/templates/apps/web/app/[locale]/(authed)/waitlist/__tests__/list.test.tsx +0 -171
  248. package/templates/apps/web/app/[locale]/(authed)/waitlist/__tests__/signup.test.tsx +0 -179
  249. package/templates/apps/web/app/[locale]/(authed)/waitlist/components/ExperimentsTable.tsx +0 -116
  250. package/templates/apps/web/app/[locale]/(authed)/waitlist/components/OfferFunnelTable.tsx +0 -66
  251. package/templates/apps/web/app/[locale]/(authed)/waitlist/components/RollupTicker.tsx +0 -61
  252. package/templates/apps/web/app/[locale]/(authed)/waitlist/components/ScoreTrace.tsx +0 -114
  253. package/templates/apps/web/app/[locale]/(authed)/waitlist/components/SignupsTable.tsx +0 -139
  254. package/templates/apps/web/app/[locale]/(authed)/waitlist/components/WaitlistComparisonTabs.tsx +0 -85
  255. package/templates/apps/web/app/[locale]/(authed)/waitlist/components/status-badges.tsx +0 -67
  256. package/templates/apps/web/app/[locale]/(authed)/waitlist/page.tsx +0 -179
  257. package/templates/apps/web/app/[locale]/blog/[[...slug]]/page.tsx +0 -178
  258. package/templates/apps/web/app/[locale]/dev/purchase/__tests__/actions.test.ts +0 -160
  259. package/templates/apps/web/app/[locale]/dev/purchase/error.tsx +0 -25
  260. package/templates/apps/web/app/[locale]/dev/purchase/page.tsx +0 -117
  261. package/templates/apps/web/app/[locale]/dev/purchase/purchase.actions.ts +0 -58
  262. package/templates/apps/web/app/[locale]/docs/[[...slug]]/page.tsx +0 -119
  263. package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/checkout.actions.test.ts +0 -287
  264. package/templates/apps/web/app/[locale]/flows/[slug]/checkout.actions.ts +0 -254
  265. package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/ResultsClient.tsx +0 -532
  266. package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/ResultsEmailStep.tsx +0 -121
  267. package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/__tests__/ResultsClient.checkout.test.tsx +0 -457
  268. package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/__tests__/page.test.tsx +0 -396
  269. package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/page.tsx +0 -114
  270. package/templates/apps/web/app/[locale]/projects/apply/ListingApplicationClient.tsx +0 -110
  271. package/templates/apps/web/app/[locale]/projects/apply/__tests__/ListingApplicationClient.test.tsx +0 -96
  272. package/templates/apps/web/app/[locale]/schedule/__tests__/page.test.tsx +0 -117
  273. package/templates/apps/web/app/[locale]/schedule/confirmed/__tests__/page.test.tsx +0 -96
  274. package/templates/apps/web/app/[locale]/schedule/confirmed/page.tsx +0 -92
  275. package/templates/apps/web/app/[locale]/schedule/page.tsx +0 -82
  276. package/templates/apps/web/content/en/blog/README.txt +0 -25
  277. package/templates/apps/web/content/en/docs/index.mdx +0 -28
  278. package/templates/apps/web/lib/__mocks__/source-server.js +0 -31
  279. package/templates/apps/web/lib/blog-list-data.ts +0 -95
  280. package/templates/apps/web/lib/blog-post-data.ts +0 -79
  281. package/templates/apps/web/lib/source.ts +0 -34
  282. package/templates/apps/web/public/logomark-dark.svg +0 -4
  283. package/templates/apps/web/public/logomark-light.svg +0 -4
  284. package/templates/apps/web/source.config.ts +0 -78
@@ -1,3 +1,6 @@
1
+ // Not marked oss: false (D-48/#5366 escalation) — tests the
2
+ // always-shipping project-listings anonymous surface; see
3
+ // routes/project-listings/me.ts's header comment.
1
4
  /**
2
5
  * @file site-key.test.ts
3
6
  *
@@ -28,6 +31,13 @@ jest.mock("../../../lib/prisma.js");
28
31
 
29
32
  jest.mock("@working-theory/email", () => ({
30
33
  sendLeadNotification: jest.fn().mockResolvedValue({ ok: true, messageId: "msg-test" }),
34
+ // #5406 — `start` now reaches the resume email. Declared here for the reason
35
+ // docs/lessons/project-listings/04 records: a mock factory that omits an
36
+ // export the code under test calls leaves it `undefined` at the callsite.
37
+ // These suites configure no `resumeEmail`, so the send short-circuits before
38
+ // this is reached; it is declared so a future fixture that DOES configure
39
+ // one fails on its assertion rather than on a missing mock.
40
+ sendListingResumeEmail: jest.fn().mockResolvedValue({ ok: true, messageId: "msg-resume" }),
31
41
  }));
32
42
 
33
43
  jest.mock("@working-theory/license", () => ({
@@ -109,6 +119,17 @@ function installStore(): void {
109
119
  },
110
120
  );
111
121
 
122
+ // #5275 — the grant write `start` makes after the row. Append-only; it only
123
+ // needs to resolve here (drafts.test.ts asserts on what it is called with).
124
+ (prisma.consentGrant.createMany as jest.Mock).mockImplementation(
125
+ ({ data }: { data: Row[] }) => Promise.resolve({ count: data.length }),
126
+ );
127
+
128
+ // #5275 — `start` writes the row and its grant in one interactive
129
+ // transaction; the mock hands the same store-backed client through.
130
+ (prisma.$transaction as jest.Mock).mockImplementation(
131
+ async (fn: (tx: typeof prisma) => Promise<unknown>) => fn(prisma),
132
+ );
112
133
  (prisma.projectListing.create as jest.Mock).mockImplementation(({ data }: { data: Row }) => {
113
134
  const now = new Date();
114
135
  const row: Row = {
@@ -125,6 +146,13 @@ function installStore(): void {
125
146
  anythingElse: null,
126
147
  address: null,
127
148
  questions: null,
149
+ // #5407 — a row written before the columns existed, and a freshly
150
+ // started draft on a site that asks for one name line: parts null,
151
+ // no phone, no verification stamp.
152
+ founderFirstName: null,
153
+ founderLastName: null,
154
+ founderPhone: null,
155
+ emailVerifiedAt: null,
128
156
  founderCompany: null,
129
157
  leadId: null,
130
158
  headline: null,
@@ -175,6 +203,13 @@ function seedActiveListing(siteId: string, slug: string): void {
175
203
  address: "1 Founder Way",
176
204
  founderName: "Some Founder",
177
205
  founderEmail: "founder@example.com",
206
+ // #5407 — a row written before the columns existed, and a freshly
207
+ // started draft on a site that asks for one name line: parts null,
208
+ // no phone, no verification stamp.
209
+ founderFirstName: null,
210
+ founderLastName: null,
211
+ founderPhone: null,
212
+ emailVerifiedAt: null,
178
213
  founderCompany: null,
179
214
  questions: null,
180
215
  logoMarkUrl: null,
@@ -0,0 +1,484 @@
1
+ // Not marked oss: false (D-48/#5366 escalation) — tests the
2
+ // always-shipping project-listings anonymous surface; see
3
+ // routes/project-listings/me.ts's header comment.
4
+ /**
5
+ * @file structured-address.test.ts
6
+ *
7
+ * THE ACCEPTANCE GATE for the structured founder address (#5279).
8
+ *
9
+ * The application collected a whole business address in ONE free-text box.
10
+ * It now renders a fieldset, and the payload field `address` became a UNION:
11
+ * the legacy display STRING or the structured PARTS. The row keeps `address`
12
+ * as the single-line DISPLAY string and gains six nullable part columns
13
+ * beside it, which the server writes — and composes the display line from —
14
+ * in `draftUpdateData`.
15
+ *
16
+ * Two properties carry this change, and both look fine when broken, which is
17
+ * why they each get a test that fails when they are:
18
+ *
19
+ * 1. The parts arrive as PARTS. A patch carrying the structured object must
20
+ * land six DISTINCT column values, not one concatenated string in
21
+ * `address` with the columns left null. A service that quietly stringified
22
+ * the object would produce a row that reads correctly on the listing page
23
+ * and has silently thrown away the split the fieldset exists to capture.
24
+ *
25
+ * 2. A legacy free-text address round-trips CHARACTER-FOR-CHARACTER with its
26
+ * parts null. Nothing may ever split an existing `address` into parts:
27
+ * address parsing guesses, and a wrong guess silently corrupts the party
28
+ * identification the listing's privacy notice points at (#5036 D6), with
29
+ * nothing on the row to mark it as invented. A null part is honest; a
30
+ * guessed one is not.
31
+ *
32
+ * Same harness as `configured-application.test.ts` and `drafts.test.ts`: real
33
+ * routes, real services, mocked `lib/prisma.js` over a small in-memory row
34
+ * store, `routeLead` running for real against a per-test temp path.
35
+ */
36
+
37
+ // eslint-disable-next-line no-restricted-syntax -- prisma is a DB boundary; __mocks__/prisma.ts activates the auto-mock
38
+ jest.mock("../../../lib/prisma.js");
39
+
40
+ jest.mock("@working-theory/email", () => ({
41
+ sendLeadNotification: jest.fn().mockResolvedValue({ ok: true, messageId: "msg-test" }),
42
+ // #5406 — `start` now reaches the resume email. Declared here for the reason
43
+ // docs/lessons/project-listings/04 records: a mock factory that omits an
44
+ // export the code under test calls leaves it `undefined` at the callsite.
45
+ // These suites configure no `resumeEmail`, so the send short-circuits before
46
+ // this is reached; it is declared so a future fixture that DOES configure
47
+ // one fails on its assertion rather than on a missing mock.
48
+ sendListingResumeEmail: jest.fn().mockResolvedValue({ ok: true, messageId: "msg-resume" }),
49
+ }));
50
+
51
+ jest.mock("@working-theory/license", () => ({
52
+ checkLicense: jest.fn().mockResolvedValue({
53
+ tier: "free",
54
+ plan: null,
55
+ features: [],
56
+ expiresAt: null,
57
+ revoked: false,
58
+ }),
59
+ }));
60
+
61
+ import Fastify from "fastify";
62
+ import type { FastifyInstance } from "fastify";
63
+
64
+ import { setupLeadFileEnv } from "../../../__tests__/factories/lead-fixture";
65
+ import { prisma } from "../../../lib/prisma.js";
66
+ import { patchProjectListingDraftRoute } from "../patch-draft.js";
67
+ import { startProjectListingRoute } from "../start.js";
68
+ import { submitProjectListingRoute } from "../submit.js";
69
+
70
+ // ─── Fixtures ───────────────────────────────────────────────────────────────
71
+
72
+ const SITE_ID = "site-1";
73
+ const SITE_KEY = "pk-site-1";
74
+
75
+ const opt = (value: string, label: string) => ({ value, label });
76
+
77
+ const VALID_QUESTIONS = {
78
+ q2: {
79
+ prompt: "Why are you here?",
80
+ options: [opt("scale", "Scaling my team"), opt("just-curious", "Just curious")],
81
+ },
82
+ q3: { prompt: "Stage?", options: [opt("a", "A"), opt("b", "B"), opt("c", "C")] },
83
+ q4: { prompt: "Team size?", options: [opt("a", "A"), opt("b", "B"), opt("c", "C")] },
84
+ q5: {
85
+ prompt: "Urgency?",
86
+ options: [opt("urgent", "Now"), opt("planning", "Soon"), opt("exploring", "Someday")],
87
+ },
88
+ q6: {
89
+ prompt: "Pain?",
90
+ options: [
91
+ opt("low", "Low"),
92
+ opt("medium", "Medium"),
93
+ opt("high", "High"),
94
+ opt("critical", "Critical"),
95
+ ],
96
+ },
97
+ q7: { prompt: "Anything else?" },
98
+ extras: [],
99
+ };
100
+
101
+ const START_PAYLOAD = {
102
+ siteKey: SITE_KEY,
103
+ founderName: "Ada Founder",
104
+ founderEmail: "ada@example.com",
105
+ consent: true,
106
+ };
107
+
108
+ /** Everything the ten steps after step one collect, minus the address. */
109
+ const APPLICATION_WITHOUT_ADDRESS = {
110
+ name: "My Project",
111
+ headline: "Ten words or fewer for the tile title here",
112
+ oneLiner: "A short sentence describing what the project does for its people.",
113
+ problem: "The problem is real and this describes it.",
114
+ product: "wireframes",
115
+ commitment: "part-time",
116
+ teamComposition: "co-founders",
117
+ faqs: [
118
+ { question: "Q one?", answer: "A one." },
119
+ { question: "Q two?", answer: "A two." },
120
+ { question: "Q three?", answer: "A three." },
121
+ ],
122
+ questions: VALID_QUESTIONS,
123
+ };
124
+
125
+ /** What `AddressFieldset` posts — six controls under six fixed keys. */
126
+ const ADDRESS_PARTS = {
127
+ addressLine1: "221B Baker Street",
128
+ addressLine2: "Flat B",
129
+ city: "London",
130
+ region: "Greater London",
131
+ postalCode: "NW1 6XE",
132
+ country: "GB",
133
+ };
134
+
135
+ /**
136
+ * The display line the SERVER must compose from `ADDRESS_PARTS` — the parts
137
+ * in reading order, joined with ", ". Written out literally rather than by
138
+ * calling `composeAddressLine`, so a change to the joiner fails this test
139
+ * instead of travelling through it.
140
+ */
141
+ const COMPOSED_ADDRESS_LINE =
142
+ "221B Baker Street, Flat B, London, Greater London, NW1 6XE, GB";
143
+
144
+ /**
145
+ * An address a founder typed into the ONE free-text box, before #5279. It is
146
+ * not in the composed order, its parts are not separable by any rule, and
147
+ * nothing may ever try: it is here to be returned unchanged.
148
+ */
149
+ const LEGACY_ADDRESS_LINE =
150
+ "221B Baker Street, Marylebone, London NW1 6XE, United Kingdom";
151
+
152
+ // ─── The in-memory row store the Prisma mock is backed by ───────────────────
153
+
154
+ type Row = Record<string, unknown>;
155
+
156
+ let rows: Row[];
157
+ let nextId: number;
158
+
159
+ function matches(row: Row, where: Record<string, unknown>): boolean {
160
+ return Object.entries(where).every(([key, value]) => {
161
+ if (key === "OR") {
162
+ return (value as Record<string, unknown>[]).some((clause) => matches(row, clause));
163
+ }
164
+ if (value !== null && typeof value === "object" && !(value instanceof Date)) {
165
+ const clause = value as Record<string, unknown>;
166
+ if ("in" in clause) return (clause["in"] as unknown[]).includes(row[key]);
167
+ if ("not" in clause) return row[key] !== clause["not"];
168
+ if ("gt" in clause) return (row[key] as Date) > (clause["gt"] as Date);
169
+ if ("startsWith" in clause) {
170
+ return (
171
+ typeof row[key] === "string" &&
172
+ (row[key] as string).startsWith(clause["startsWith"] as string)
173
+ );
174
+ }
175
+ }
176
+ return row[key] === value;
177
+ });
178
+ }
179
+
180
+ function installStore(): void {
181
+ rows = [];
182
+ nextId = 1;
183
+
184
+ (prisma.lead.create as jest.Mock).mockImplementation(() =>
185
+ Promise.resolve({ id: `lead_00000000-0000-7000-8000-00000000000${nextId}` }),
186
+ );
187
+
188
+ (prisma.siteListingConfig.findUnique as jest.Mock).mockImplementation(
189
+ ({ where }: { where: { siteId?: string; publicKey?: string } }) => {
190
+ if (where.publicKey !== undefined) {
191
+ return Promise.resolve(where.publicKey === SITE_KEY ? { siteId: SITE_ID } : null);
192
+ }
193
+ // No config row — the platform defaults, which collect `address`.
194
+ return Promise.resolve(null);
195
+ },
196
+ );
197
+
198
+ // #5275 — the grant write `start` makes after the row. Append-only; it only
199
+ // needs to resolve here (drafts.test.ts asserts on what it is called with).
200
+ (prisma.consentGrant.createMany as jest.Mock).mockImplementation(
201
+ ({ data }: { data: Row[] }) => Promise.resolve({ count: data.length }),
202
+ );
203
+
204
+ // #5275 — `start` writes the row and its grant in one interactive
205
+ // transaction; the mock hands the same store-backed client through.
206
+ (prisma.$transaction as jest.Mock).mockImplementation(
207
+ async (fn: (tx: typeof prisma) => Promise<unknown>) => fn(prisma),
208
+ );
209
+
210
+ (prisma.projectListing.create as jest.Mock).mockImplementation(({ data }: { data: Row }) => {
211
+ const now = new Date();
212
+ const row: Row = {
213
+ id: `prjl_00000000-0000-7000-8000-00000000000${nextId++}`,
214
+ slug: null,
215
+ name: null,
216
+ oneLiner: null,
217
+ problem: null,
218
+ url: null,
219
+ product: null,
220
+ commitment: null,
221
+ teamComposition: null,
222
+ faqs: null,
223
+ anythingElse: null,
224
+ address: null,
225
+ // #5279 — a freshly-started draft has no parts, exactly like every row
226
+ // written before the columns existed.
227
+ addressLine1: null,
228
+ addressLine2: null,
229
+ addressCity: null,
230
+ addressRegion: null,
231
+ addressPostalCode: null,
232
+ addressCountry: null,
233
+ questions: null,
234
+ // #5407 — a row written before the columns existed, and a freshly
235
+ // started draft on a site that asks for one name line: parts null,
236
+ // no phone, no verification stamp.
237
+ founderFirstName: null,
238
+ founderLastName: null,
239
+ founderPhone: null,
240
+ emailVerifiedAt: null,
241
+ founderCompany: null,
242
+ leadId: null,
243
+ headline: null,
244
+ logoMarkUrl: null,
245
+ logoWordmarkUrl: null,
246
+ featured: false,
247
+ decidedAt: null,
248
+ decidedBy: null,
249
+ issueUrl: null,
250
+ experimentKey: null,
251
+ windowEndsAt: null,
252
+ createdAt: now,
253
+ updatedAt: now,
254
+ ...data,
255
+ };
256
+ rows.push(row);
257
+ return Promise.resolve(row);
258
+ });
259
+
260
+ (prisma.projectListing.findFirst as jest.Mock).mockImplementation(
261
+ ({ where }: { where: Record<string, unknown> }) =>
262
+ Promise.resolve(rows.find((row) => matches(row, where)) ?? null),
263
+ );
264
+
265
+ (prisma.projectListing.findMany as jest.Mock).mockImplementation(
266
+ ({ where }: { where: Record<string, unknown> }) =>
267
+ Promise.resolve(rows.filter((row) => matches(row, where))),
268
+ );
269
+
270
+ (prisma.projectListing.update as jest.Mock).mockImplementation(
271
+ ({ where, data }: { where: Record<string, unknown>; data: Row }) => {
272
+ const row = rows.find((r) => matches(r, where));
273
+ Object.assign(row!, data, { updatedAt: new Date() });
274
+ return Promise.resolve(row);
275
+ },
276
+ );
277
+
278
+ (prisma.projectListing.updateMany as jest.Mock).mockImplementation(
279
+ ({ where, data }: { where: Record<string, unknown>; data: Row }) => {
280
+ const matched = rows.filter((r) => matches(r, where));
281
+ matched.forEach((r) => Object.assign(r, data, { updatedAt: new Date() }));
282
+ return Promise.resolve({ count: matched.length });
283
+ },
284
+ );
285
+ }
286
+
287
+ // ─── Helpers ────────────────────────────────────────────────────────────────
288
+
289
+ const bearer = (token: string) => ({ authorization: `Bearer ${token}` });
290
+
291
+ async function buildApp(): Promise<FastifyInstance> {
292
+ const app = Fastify();
293
+ await app.register(startProjectListingRoute);
294
+ await app.register(patchProjectListingDraftRoute);
295
+ await app.register(submitProjectListingRoute);
296
+ await app.ready();
297
+ return app;
298
+ }
299
+
300
+ async function startDraft(
301
+ app: FastifyInstance,
302
+ ): Promise<{ id: string; resumeToken: string }> {
303
+ const res = await app.inject({
304
+ method: "POST",
305
+ url: "/v1/project-listings",
306
+ payload: START_PAYLOAD,
307
+ });
308
+ expect(res.statusCode).toBe(201);
309
+ return (res.json() as { data: { id: string; resumeToken: string } }).data;
310
+ }
311
+
312
+ const patch = (
313
+ app: FastifyInstance,
314
+ draft: { id: string; resumeToken: string },
315
+ payload: Record<string, unknown>,
316
+ ) =>
317
+ app.inject({
318
+ method: "PATCH",
319
+ url: `/v1/project-listings/drafts/${draft.id}`,
320
+ headers: bearer(draft.resumeToken),
321
+ payload,
322
+ });
323
+
324
+ const submit = (app: FastifyInstance, draft: { id: string; resumeToken: string }) =>
325
+ app.inject({
326
+ method: "POST",
327
+ url: `/v1/project-listings/drafts/${draft.id}/submit`,
328
+ headers: bearer(draft.resumeToken),
329
+ });
330
+
331
+ const storedRow = (id: string): Row => {
332
+ const row = rows.find((r) => r["id"] === id);
333
+ expect(row).toBeDefined();
334
+ return row!;
335
+ };
336
+
337
+ const draftBody = (res: { json: () => unknown }) =>
338
+ (res.json() as { data: Record<string, unknown> }).data;
339
+
340
+ // ─── Tests ──────────────────────────────────────────────────────────────────
341
+
342
+ describe("the structured founder address (#5279)", () => {
343
+ setupLeadFileEnv("project-listing-structured-address");
344
+ let app: FastifyInstance;
345
+
346
+ beforeEach(async () => {
347
+ jest.clearAllMocks();
348
+ installStore();
349
+ app = await buildApp();
350
+ });
351
+
352
+ afterEach(async () => {
353
+ await app.close();
354
+ });
355
+
356
+ describe("the parts arrive as PARTS, and the server composes the display line", () => {
357
+ it("stores six DISTINCT column values, not one concatenated string", async () => {
358
+ const draft = await startDraft(app);
359
+
360
+ const res = await patch(app, draft, { address: ADDRESS_PARTS });
361
+
362
+ expect(res.statusCode).toBe(200);
363
+
364
+ // The column-level half. Each part is in its OWN column — a service that
365
+ // stringified the object would leave every one of these null and this
366
+ // test is the thing that notices.
367
+ const row = storedRow(draft.id);
368
+ expect(row["addressLine1"]).toBe("221B Baker Street");
369
+ expect(row["addressLine2"]).toBe("Flat B");
370
+ expect(row["addressCity"]).toBe("London");
371
+ expect(row["addressRegion"]).toBe("Greater London");
372
+ expect(row["addressPostalCode"]).toBe("NW1 6XE");
373
+ expect(row["addressCountry"]).toBe("GB");
374
+
375
+ // The display half. `address` is DERIVED here and only here — the parts
376
+ // in reading order, joined with ", ".
377
+ expect(row["address"]).toBe(COMPOSED_ADDRESS_LINE);
378
+ });
379
+
380
+ it("answers the save with the composed line AND the parts, separably", async () => {
381
+ const draft = await startDraft(app);
382
+
383
+ const body = draftBody(await patch(app, draft, { address: ADDRESS_PARTS }));
384
+
385
+ expect(body["address"]).toBe(COMPOSED_ADDRESS_LINE);
386
+ expect(body["addressParts"]).toEqual(ADDRESS_PARTS);
387
+ });
388
+
389
+ it("omits the blank addressLine2 from both the columns and the composed line", async () => {
390
+ const draft = await startDraft(app);
391
+ const { addressLine2: _line2, ...withoutLine2 } = ADDRESS_PARTS;
392
+
393
+ const body = draftBody(await patch(app, draft, { address: withoutLine2 }));
394
+
395
+ expect(storedRow(draft.id)["addressLine2"]).toBeNull();
396
+ expect(body["address"]).toBe("221B Baker Street, London, Greater London, NW1 6XE, GB");
397
+ expect(body["addressParts"]).toEqual(withoutLine2);
398
+ });
399
+
400
+ it("a composed address submits, and reaches the row as the display line", async () => {
401
+ const draft = await startDraft(app);
402
+ await patch(app, draft, { ...APPLICATION_WITHOUT_ADDRESS, address: ADDRESS_PARTS });
403
+
404
+ const res = await submit(app, draft);
405
+
406
+ expect(res.statusCode).toBe(200);
407
+ expect(storedRow(draft.id)["address"]).toBe(COMPOSED_ADDRESS_LINE);
408
+ expect(storedRow(draft.id)["addressCountry"]).toBe("GB");
409
+ });
410
+
411
+ it("refuses parts that are not a whole address, rather than storing half of one", async () => {
412
+ const draft = await startDraft(app);
413
+ const { city: _city, ...incomplete } = ADDRESS_PARTS;
414
+
415
+ const res = await patch(app, draft, { address: incomplete });
416
+
417
+ expect(res.statusCode).toBe(400);
418
+ expect(storedRow(draft.id)["addressLine1"]).toBeNull();
419
+ expect(storedRow(draft.id)["address"]).toBeNull();
420
+ });
421
+ });
422
+
423
+ describe("a legacy free-text address round-trips unchanged, with its parts null", () => {
424
+ // The load-bearing guard. Nothing may split an existing `address` into
425
+ // parts — a wrong split silently corrupts the party identification the
426
+ // listing's privacy notice points at, and a null part is honest where a
427
+ // guessed one is not.
428
+ it("comes back CHARACTER-FOR-CHARACTER from a save, with addressParts null", async () => {
429
+ const draft = await startDraft(app);
430
+
431
+ const body = draftBody(await patch(app, draft, { address: LEGACY_ADDRESS_LINE }));
432
+
433
+ expect(body["address"]).toBe(LEGACY_ADDRESS_LINE);
434
+ expect(body["addressParts"]).toBeNull();
435
+ });
436
+
437
+ it("leaves EVERY part column untouched — a plain string writes none of them", async () => {
438
+ const draft = await startDraft(app);
439
+
440
+ await patch(app, draft, { address: LEGACY_ADDRESS_LINE });
441
+
442
+ const row = storedRow(draft.id);
443
+ expect(row["address"]).toBe(LEGACY_ADDRESS_LINE);
444
+ for (const column of [
445
+ "addressLine1",
446
+ "addressLine2",
447
+ "addressCity",
448
+ "addressRegion",
449
+ "addressPostalCode",
450
+ "addressCountry",
451
+ ]) {
452
+ expect(row[column]).toBeNull();
453
+ }
454
+ });
455
+
456
+ it("submits exactly as it did before #5279, and the stored line is unchanged", async () => {
457
+ const draft = await startDraft(app);
458
+ await patch(app, draft, {
459
+ ...APPLICATION_WITHOUT_ADDRESS,
460
+ address: LEGACY_ADDRESS_LINE,
461
+ });
462
+
463
+ const res = await submit(app, draft);
464
+
465
+ expect(res.statusCode).toBe(200);
466
+ expect(storedRow(draft.id)["address"]).toBe(LEGACY_ADDRESS_LINE);
467
+ expect(storedRow(draft.id)["addressLine1"]).toBeNull();
468
+ });
469
+
470
+ // A row written before #5279 holds a line and six nulls. Overwriting the
471
+ // line with a NEW line must not start inventing parts for it either.
472
+ it("re-patching a legacy line with another legacy line still writes no parts", async () => {
473
+ const draft = await startDraft(app);
474
+ await patch(app, draft, { address: LEGACY_ADDRESS_LINE });
475
+
476
+ await patch(app, draft, { address: "1 Example Street, Exampleton, EX1 2AM" });
477
+
478
+ const row = storedRow(draft.id);
479
+ expect(row["address"]).toBe("1 Example Street, Exampleton, EX1 2AM");
480
+ expect(row["addressCity"]).toBeNull();
481
+ expect(row["addressCountry"]).toBeNull();
482
+ });
483
+ });
484
+ });
@@ -6,7 +6,10 @@
6
6
  * POST /v1/project-listings — anonymous start (leadsScope)
7
7
  * PATCH /v1/project-listings/drafts/:id — anonymous save-as-you-go, token-gated
8
8
  * POST /v1/project-listings/drafts/:id/submit — anonymous submit, token-gated
9
+ * POST /v1/project-listings/drafts/:id/verify-email — anonymous, token- AND
10
+ * nonce-gated (#5407)
9
11
  * GET /v1/project-listings/public — anonymous page-render read (leadsScope)
12
+ * GET /v1/project-listings/me — the founder's OWN listings (authScope)
10
13
  * GET /v1/project-listings — tenant-scoped dashboard list
11
14
  * GET /v1/project-listings/:id — tenant-scoped single read
12
15
  * PATCH /v1/project-listings/:id — tenant-scoped decision + featured flag
@@ -19,6 +22,13 @@
19
22
  * anonymous token-gated PATCH and the operator's tenant-guarded PATCH apart
20
23
  * at the router — they differ in segment count, so neither shadows the other.
21
24
  *
25
+ * `/me` (#5295) sits between the two scopes: `authScope` only, no tenant guard.
26
+ * A founder is not a workspace member, so the guard's default-deny answers 403
27
+ * for their own submission — and the fix is this ADDITIVE route, joined on the
28
+ * owner link, rather than a relaxed `PROJECT_LISTING_ROLES`. Its static `/me`
29
+ * segment takes router precedence over the parametric `/:id` below, exactly as
30
+ * the already-shipped static `/public` does.
31
+ *
22
32
  * API<->MCP parity: no MCP twin. The anonymous set mirrors the leads routes'
23
33
  * own identity-blind posture (an applying founder has no account, and the
24
34
  * resume token is a per-draft capability rather than an identity); the
@@ -27,15 +37,30 @@
27
37
  */
28
38
  import type { FastifyPluginAsync } from "fastify";
29
39
 
40
+ import { loadSlice } from "../../lib/slice-load.js";
41
+
42
+ import { projectListingMeRoute as meRoute } from "./me.js";
30
43
  import { patchProjectListingDraftRoute } from "./patch-draft.js";
31
44
  import { publicProjectListingsRoute } from "./public.js";
32
45
  import { startProjectListingRoute } from "./start.js";
33
46
  import { submitProjectListingRoute } from "./submit.js";
47
+ import { verifyProjectListingDraftEmailRoute } from "./verify-email.js";
34
48
 
35
49
  export const projectListingStartRoute = startProjectListingRoute;
36
50
  export const projectListingDraftPatchRoute = patchProjectListingDraftRoute;
37
51
  export const projectListingSubmitRoute = submitProjectListingRoute;
38
52
  export const projectListingPublicRoute = publicProjectListingsRoute;
53
+ /**
54
+ * The resume link's verification write (#5407). Static, not a slice: it is
55
+ * part of the free anonymous surface, exactly like the three routes above.
56
+ */
57
+ export const projectListingVerifyEmailRoute = verifyProjectListingDraftEmailRoute;
58
+ /**
59
+ * The founder's own listings read (#5295). Static, not a slice: it depends on
60
+ * the owner link alone, so it is registered — and works — on a build with no
61
+ * multi-tenant module, the same as `flowCheckoutRoutes`.
62
+ */
63
+ export const projectListingMeRoute = meRoute;
39
64
 
40
65
  /**
41
66
  * Tenant-scoped handlers, as { specifier, exportName }, reached at REGISTRATION
@@ -47,50 +72,67 @@ export const projectListingPublicRoute = publicProjectListingsRoute;
47
72
  * purpose, which is why the boundary is drawn here at the registration
48
73
  * call-site rather than by splitting the directory.
49
74
  *
50
- * The specifier is read from this table rather than written inline because
51
- * `tsc` resolves a string-literal `import("...")` EXPRESSION whether or not the
52
- * branch runs, and would report TS2307 on the stripped tree. The plugin body
75
+ * The specifier is kept off the `import()` call site because `tsc` resolves a
76
+ * string-literal `import("...")` EXPRESSION whether or not the branch runs, and
77
+ * would report TS2307 on the stripped tree. It is held in a module-level
78
+ * constant rather than read out of the table per iteration (#5304): the table
79
+ * form was invisible to the deploy-time file tracer too, so these files were
80
+ * compiled and never shipped, and the constant form is the one shape `tsc`
81
+ * refuses to resolve and `@vercel/nft` still traces. `specifier` stays on each
82
+ * row for the drift message and reads the same constant as `load`. The plugin body
53
83
  * only executes when `server.ts` registers it, which it does inside
54
84
  * `isModuleEnabled('multi-tenant')` — so nothing here runs on an OSS build.
55
85
  *
56
86
  * The export name is unchanged so `server.ts` needs no edit: it still reaches
57
87
  * `projectListingTenantRoutes` from this module.
58
88
  */
89
+ const LIST_HANDLER = "./list.js";
90
+ const GET_HANDLER = "./get.js";
91
+ const PATCH_HANDLER = "./patch.js";
92
+ const PATCH_COPY_HANDLER = "./patch-copy.js";
93
+
59
94
  const TENANT_HANDLERS: ReadonlyArray<{
60
95
  method: "get" | "patch";
61
96
  url: string;
62
97
  specifier: string;
63
98
  exportName: string;
99
+ load: () => Promise<unknown>;
64
100
  }> = [
65
101
  {
66
102
  method: "get",
67
103
  url: "/v1/project-listings",
68
- specifier: "./list.js",
104
+ specifier: LIST_HANDLER,
69
105
  exportName: "listProjectListingsHandler",
106
+ load: () => import(LIST_HANDLER),
70
107
  },
71
108
  {
72
109
  method: "get",
73
110
  url: "/v1/project-listings/:id",
74
- specifier: "./get.js",
111
+ specifier: GET_HANDLER,
75
112
  exportName: "getProjectListingHandler",
113
+ load: () => import(GET_HANDLER),
76
114
  },
77
115
  {
78
116
  method: "patch",
79
117
  url: "/v1/project-listings/:id",
80
- specifier: "./patch.js",
118
+ specifier: PATCH_HANDLER,
81
119
  exportName: "patchProjectListingHandler",
120
+ load: () => import(PATCH_HANDLER),
82
121
  },
83
122
  {
84
123
  method: "patch",
85
124
  url: "/v1/project-listings/:id/copy",
86
- specifier: "./patch-copy.js",
125
+ specifier: PATCH_COPY_HANDLER,
87
126
  exportName: "patchProjectListingCopyHandler",
127
+ load: () => import(PATCH_COPY_HANDLER),
88
128
  },
89
129
  ];
90
130
 
91
- export const projectListingTenantRoutes: FastifyPluginAsync = async (fastify) => {
92
- for (const { method, url, specifier, exportName } of TENANT_HANDLERS) {
93
- const mod = (await import(specifier)) as Record<
131
+ export const projectListingTenantRoutes: FastifyPluginAsync = async (
132
+ fastify,
133
+ ) => {
134
+ for (const { method, url, specifier, exportName, load } of TENANT_HANDLERS) {
135
+ const mod = (await loadSlice(specifier, load)) as Record<
94
136
  string,
95
137
  Parameters<typeof fastify.get>[1] | undefined
96
138
  >;