create-ailk 0.1.1 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (284) hide show
  1. package/component-catalog.md +210 -52
  2. package/dist/cli.js +0 -0
  3. package/dist/lib/apply-module-patches.d.ts +47 -0
  4. package/dist/lib/apply-module-patches.js +370 -0
  5. package/dist/lib/extract-module-bundle.d.ts +12 -3
  6. package/dist/lib/extract-module-bundle.js +236 -24
  7. package/dist/lib/fetch-module.d.ts +3 -38
  8. package/dist/lib/fetch-module.js +26 -8
  9. package/dist/lib/module-license-gate.d.ts +2 -0
  10. package/dist/lib/module-license-gate.js +1 -0
  11. package/dist/lib/paid-paths.d.ts +16 -0
  12. package/dist/lib/paid-paths.js +90 -0
  13. package/dist/module-architecture.d.ts +139 -23
  14. package/dist/module-architecture.js +668 -25
  15. package/dist/parse-args.d.ts +2 -2
  16. package/dist/parse-args.js +3 -1
  17. package/dist/programmatic.d.ts +2 -1
  18. package/dist/programmatic.js +15 -4
  19. package/dist/surfaces.d.ts +12 -36
  20. package/dist/surfaces.js +35 -5
  21. package/dist/sync-routes.js +31 -1
  22. package/package.json +14 -15
  23. package/templates/.claude/agents/web.md +2 -3
  24. package/templates/.claude/rules/architecture.md +5 -5
  25. package/templates/.claude/skills/README.md +2 -1
  26. package/templates/.env.example +21 -9
  27. package/templates/CONVENTIONS.md +6 -7
  28. package/templates/apps/api/.env.example +9 -0
  29. package/templates/apps/api/CLAUDE.md +35 -4
  30. package/templates/apps/api/package.json +0 -1
  31. package/templates/apps/api/src/__tests__/cors.test.ts +195 -0
  32. package/templates/apps/api/src/__tests__/module-exclusion.test.ts +60 -2
  33. package/templates/apps/api/src/__tests__/vercel-handler.test.ts +159 -0
  34. package/templates/apps/api/src/config/__tests__/modules.test.ts +76 -0
  35. package/templates/apps/api/src/config/index.ts +19 -0
  36. package/templates/apps/api/src/config/modules.ts +12 -14
  37. package/templates/apps/api/src/lib/__mocks__/prisma.ts +13 -0
  38. package/templates/apps/api/src/lib/__tests__/slice-load.test.ts +89 -0
  39. package/templates/apps/api/src/lib/slice-load.ts +90 -0
  40. package/templates/apps/api/src/openapi/__tests__/openapi.test.ts +52 -32
  41. package/templates/apps/api/src/openapi/__tests__/spec-identity.test.ts +182 -0
  42. package/templates/apps/api/src/openapi/__tests__/surface-drift.test.ts +293 -0
  43. package/templates/apps/api/src/openapi/index.ts +9 -3
  44. package/templates/apps/api/src/openapi/spec.ts +505 -65
  45. package/templates/apps/api/src/openapi/surface-drift.ts +195 -0
  46. package/templates/apps/api/src/routes/content/__tests__/create.test.ts +14 -0
  47. package/templates/apps/api/src/routes/content/__tests__/delete.test.ts +14 -0
  48. package/templates/apps/api/src/routes/content/__tests__/update.test.ts +14 -0
  49. package/templates/apps/api/src/routes/content/index.ts +38 -11
  50. package/templates/apps/api/src/routes/project-listings/__tests__/configured-application.test.ts +28 -0
  51. package/templates/apps/api/src/routes/project-listings/__tests__/drafts.test.ts +144 -0
  52. package/templates/apps/api/src/routes/project-listings/__tests__/founder-identity.test.ts +796 -0
  53. package/templates/apps/api/src/routes/project-listings/__tests__/me.test.ts +478 -0
  54. package/templates/apps/api/src/routes/project-listings/__tests__/public.test.ts +38 -0
  55. package/templates/apps/api/src/routes/project-listings/__tests__/resume-email.test.ts +814 -0
  56. package/templates/apps/api/src/routes/project-listings/__tests__/site-answers.test.ts +717 -0
  57. package/templates/apps/api/src/routes/project-listings/__tests__/site-key.test.ts +35 -0
  58. package/templates/apps/api/src/routes/project-listings/__tests__/structured-address.test.ts +484 -0
  59. package/templates/apps/api/src/routes/project-listings/index.ts +52 -10
  60. package/templates/apps/api/src/routes/project-listings/me.ts +86 -0
  61. package/templates/apps/api/src/routes/project-listings/patch-draft.ts +5 -0
  62. package/templates/apps/api/src/routes/project-listings/public.ts +5 -0
  63. package/templates/apps/api/src/routes/project-listings/respond.ts +11 -0
  64. package/templates/apps/api/src/routes/project-listings/resume-token.ts +5 -0
  65. package/templates/apps/api/src/routes/project-listings/start.ts +38 -0
  66. package/templates/apps/api/src/routes/project-listings/submit.ts +5 -0
  67. package/templates/apps/api/src/routes/project-listings/verify-email.ts +150 -0
  68. package/templates/apps/api/src/server.ts +471 -133
  69. package/templates/apps/api/src/services/__tests__/consent-migration.test.ts +65 -0
  70. package/templates/apps/api/src/services/__tests__/consent.test.ts +282 -0
  71. package/templates/apps/api/src/services/consent.ts +236 -0
  72. package/templates/apps/api/src/services/deliverable-fulfillment.ts +10 -0
  73. package/templates/apps/api/src/services/listing-config.ts +58 -0
  74. package/templates/apps/api/src/services/project-listing-resume-email.ts +326 -0
  75. package/templates/apps/api/src/services/project-listings.ts +602 -29
  76. package/templates/apps/api/src/vercel-handler.ts +60 -26
  77. package/templates/apps/mcp/.env.example +8 -0
  78. package/templates/apps/mcp/CLAUDE.md +2 -2
  79. package/templates/apps/mcp/__tests__/catalog-drift.test.ts +21 -0
  80. package/templates/apps/mcp/__tests__/config/modules.test.ts +78 -0
  81. package/templates/apps/mcp/__tests__/module-exclusion.test.ts +1 -1
  82. package/templates/apps/mcp/src/config/modules.ts +14 -13
  83. package/templates/apps/mcp/src/server.ts +8 -4
  84. package/templates/apps/mcp/src/tools/index.ts +7 -22
  85. package/templates/apps/web/.env.example +15 -0
  86. package/templates/apps/web/app/[locale]/(authed)/{waitlist/__tests__ → __tests__}/gate.test.tsx +4 -4
  87. package/templates/apps/web/app/[locale]/flows/[slug]/FlowStepperClient.tsx +2 -1
  88. package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/FlowStepperClient.permalink.test.tsx +2 -1
  89. package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/FlowStepperClient.test.tsx +2 -1
  90. package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/flow.actions.test.ts +1 -0
  91. package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/two-route-tool.test.tsx +1 -0
  92. package/templates/apps/web/app/[locale]/flows/[slug]/flow.actions.ts +2 -1
  93. package/templates/apps/web/app/[locale]/flows/[slug]/page.tsx +1 -0
  94. package/templates/apps/web/app/[locale]/flows/[slug]/start/page.tsx +1 -0
  95. package/templates/apps/web/app/[locale]/layout.tsx +13 -1
  96. package/templates/apps/web/app/llms.txt/route.ts +12 -9
  97. package/templates/apps/web/jest.config.cjs +20 -13
  98. package/templates/apps/web/lib/__tests__/site-theme.test.ts +112 -0
  99. package/templates/apps/web/lib/site-brand.tsx +4 -1
  100. package/templates/apps/web/lib/site-theme.ts +74 -0
  101. package/templates/apps/web/next.config.mjs +1 -3
  102. package/templates/apps/web/package.json +1 -6
  103. package/templates/apps/web/public/android-chrome-192x192.png +0 -0
  104. package/templates/apps/web/public/android-chrome-512x512.png +0 -0
  105. package/templates/apps/web/public/apple-touch-icon.png +0 -0
  106. package/templates/apps/web/public/favicon-16x16.png +0 -0
  107. package/templates/apps/web/public/favicon-32x32.png +0 -0
  108. package/templates/apps/web/public/favicon.ico +0 -0
  109. package/templates/apps/web/public/favicon.svg +6 -3
  110. package/templates/apps/web/public/lockup-horizontal.svg +4 -0
  111. package/templates/apps/web/public/logomark.svg +4 -0
  112. package/templates/apps/web/public/site.webmanifest +2 -2
  113. package/templates/apps/web/public/wordmark.svg +4 -0
  114. package/templates/content/_site.mdx +12 -0
  115. package/templates/database/CHANGELOG.md +94 -0
  116. package/templates/database/inbox/schema.prisma +165 -0
  117. package/templates/database/migrations/20260911140000_listing_structured_address/migration.sql +32 -0
  118. package/templates/database/migrations/20260911180000_consent_grants/migration.sql +71 -0
  119. package/templates/database/migrations/20260911200000_listing_site_answers/migration.sql +30 -0
  120. package/templates/database/migrations/20260912120000_listing_owner_link/migration.sql +49 -0
  121. package/templates/database/migrations/20260914120000_listing_founder_identity/migration.sql +123 -0
  122. package/templates/database/package.json +1 -1
  123. package/templates/database/scripts/db-generate-locked.sh +0 -0
  124. package/templates/package.json +1 -1
  125. package/templates/.claude/skills/scaffold-commerce/SKILL.md +0 -807
  126. package/templates/.claude/skills/scaffold-commerce/references/lookup-keys-template.md +0 -71
  127. package/templates/.claude/skills/scaffold-commerce/references/marketplace-brand-pages.md +0 -83
  128. package/templates/.claude/skills/scaffold-commerce/templates/checkout-route.template.ts +0 -370
  129. package/templates/.claude/skills/scaffold-commerce/templates/invoice-route.template.ts +0 -376
  130. package/templates/.claude/skills/scaffold-commerce/templates/marketplace-cta-route.tsx.tmpl +0 -72
  131. package/templates/.claude/skills/scaffold-commerce/templates/payment-link-route.template.ts +0 -471
  132. package/templates/.claude/skills/scaffold-commerce/templates/portal-route.template.ts +0 -365
  133. package/templates/.claude/skills/scaffold-commerce/templates/stripe-config.template.ts +0 -60
  134. package/templates/.claude/skills/scaffold-commerce/templates/subscription-route.template.ts +0 -446
  135. package/templates/apps/api/src/__tests__/server.test.ts +0 -253
  136. package/templates/apps/api/src/bin/deliverable-resend.ts +0 -144
  137. package/templates/apps/api/src/bin/deliverable-upload.ts +0 -114
  138. package/templates/apps/api/src/bin/followup-sweep.ts +0 -44
  139. package/templates/apps/api/src/bin/listing-csv-sweep.ts +0 -46
  140. package/templates/apps/api/src/bin/seed-presets.ts +0 -58
  141. package/templates/apps/api/src/bin/seed-waitlist-experiments.ts +0 -104
  142. package/templates/apps/api/src/bin/session-retention-sweep.ts +0 -46
  143. package/templates/apps/api/src/lib/__tests__/stripe.test.ts +0 -74
  144. package/templates/apps/api/src/lib/stripe.ts +0 -52
  145. package/templates/apps/api/src/lib/tenant-db.ts +0 -225
  146. package/templates/apps/api/src/lib/ws-token.ts +0 -91
  147. package/templates/apps/api/src/middleware/tenant.ts +0 -99
  148. package/templates/apps/api/src/routes/billing/__tests__/portal.test.ts +0 -450
  149. package/templates/apps/api/src/routes/billing/__tests__/read.test.ts +0 -262
  150. package/templates/apps/api/src/routes/billing/__tests__/usage.test.ts +0 -436
  151. package/templates/apps/api/src/routes/billing/index.ts +0 -36
  152. package/templates/apps/api/src/routes/billing/portal.ts +0 -182
  153. package/templates/apps/api/src/routes/billing/read.ts +0 -75
  154. package/templates/apps/api/src/routes/billing/usage.ts +0 -153
  155. package/templates/apps/api/src/routes/checkout/__tests__/sessions.test.ts +0 -687
  156. package/templates/apps/api/src/routes/checkout/index.ts +0 -13
  157. package/templates/apps/api/src/routes/checkout/sessions.ts +0 -197
  158. package/templates/apps/api/src/routes/deliverables/__tests__/index.test.ts +0 -393
  159. package/templates/apps/api/src/routes/deliverables/index.ts +0 -200
  160. package/templates/apps/api/src/routes/flow-checkouts/__tests__/index.test.ts +0 -443
  161. package/templates/apps/api/src/routes/flow-checkouts/index.ts +0 -82
  162. package/templates/apps/api/src/routes/flows/README.md +0 -147
  163. package/templates/apps/api/src/routes/flows/__tests__/index.test.ts +0 -752
  164. package/templates/apps/api/src/routes/flows/__tests__/recommender.test.ts +0 -671
  165. package/templates/apps/api/src/routes/flows/index.ts +0 -202
  166. package/templates/apps/api/src/routes/flows/recommender.ts +0 -443
  167. package/templates/apps/api/src/routes/project-listings/__tests__/copy-edit.test.ts +0 -815
  168. package/templates/apps/api/src/routes/project-listings/__tests__/tenant-isolation.test.ts +0 -566
  169. package/templates/apps/api/src/routes/project-listings/get.ts +0 -70
  170. package/templates/apps/api/src/routes/project-listings/list.ts +0 -47
  171. package/templates/apps/api/src/routes/project-listings/patch.ts +0 -136
  172. package/templates/apps/api/src/routes/schedule/__tests__/index.test.ts +0 -490
  173. package/templates/apps/api/src/routes/schedule/index.ts +0 -249
  174. package/templates/apps/api/src/routes/slack/__tests__/actions.test.ts +0 -385
  175. package/templates/apps/api/src/routes/slack/actions.ts +0 -177
  176. package/templates/apps/api/src/routes/slack/index.ts +0 -38
  177. package/templates/apps/api/src/routes/waitlist-experiments/__tests__/tenant-isolation.test.ts +0 -577
  178. package/templates/apps/api/src/routes/waitlist-experiments/comparison.ts +0 -78
  179. package/templates/apps/api/src/routes/waitlist-experiments/index.ts +0 -41
  180. package/templates/apps/api/src/routes/waitlist-experiments/list.ts +0 -64
  181. package/templates/apps/api/src/routes/waitlist-experiments/respond.ts +0 -42
  182. package/templates/apps/api/src/routes/waitlist-experiments/signup.ts +0 -84
  183. package/templates/apps/api/src/routes/waitlist-experiments/signups.ts +0 -119
  184. package/templates/apps/api/src/routes/waitlist-signups/__tests__/index.test.ts +0 -238
  185. package/templates/apps/api/src/routes/waitlist-signups/index.ts +0 -101
  186. package/templates/apps/api/src/routes/webhooks/README.md +0 -80
  187. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-flow-checkout.test.ts +0 -315
  188. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-idempotency.test.ts +0 -247
  189. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-org-billing.test.ts +0 -270
  190. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-rate-limit.test.ts +0 -198
  191. package/templates/apps/api/src/routes/webhooks/__tests__/stripe.test.ts +0 -633
  192. package/templates/apps/api/src/routes/webhooks/index.ts +0 -41
  193. package/templates/apps/api/src/routes/webhooks/stripe.ts +0 -481
  194. package/templates/apps/api/src/routes/workspaces/__tests__/config-tenant-isolation.test.ts +0 -356
  195. package/templates/apps/api/src/routes/workspaces/__tests__/tenant-isolation.test.ts +0 -671
  196. package/templates/apps/api/src/routes/workspaces/config.ts +0 -125
  197. package/templates/apps/api/src/routes/workspaces/index.ts +0 -194
  198. package/templates/apps/api/src/routes/workspaces/sessions.ts +0 -218
  199. package/templates/apps/api/src/services/__tests__/flow-aggregate.test.ts +0 -425
  200. package/templates/apps/api/src/services/__tests__/flow-engine.test.ts +0 -2840
  201. package/templates/apps/api/src/services/__tests__/lead-promotion.test.ts +0 -393
  202. package/templates/apps/api/src/services/__tests__/listing-csv-sweep.test.ts +0 -560
  203. package/templates/apps/api/src/services/__tests__/playbook-compile.test.ts +0 -406
  204. package/templates/apps/api/src/services/__tests__/playbook-render.test.ts +0 -290
  205. package/templates/apps/api/src/services/__tests__/project-listing-decision.test.ts +0 -736
  206. package/templates/apps/api/src/services/__tests__/project-listing-flow.test.ts +0 -475
  207. package/templates/apps/api/src/services/__tests__/project-listing-issue.test.ts +0 -340
  208. package/templates/apps/api/src/services/__tests__/recommender-capture.test.ts +0 -983
  209. package/templates/apps/api/src/services/__tests__/tenant-context-cascade.test.ts +0 -71
  210. package/templates/apps/api/src/services/__tests__/tenant-context.test.ts +0 -379
  211. package/templates/apps/api/src/services/__tests__/usage-metering.test.ts +0 -768
  212. package/templates/apps/api/src/services/__tests__/waitlist-dashboard.test.ts +0 -1314
  213. package/templates/apps/api/src/services/__tests__/waitlist-experiments.test.ts +0 -341
  214. package/templates/apps/api/src/services/__tests__/waitlist-followup.test.ts +0 -567
  215. package/templates/apps/api/src/services/__tests__/waitlist-scoring.test.ts +0 -474
  216. package/templates/apps/api/src/services/__tests__/waitlist-signups.test.ts +0 -354
  217. package/templates/apps/api/src/services/flow-aggregate.ts +0 -232
  218. package/templates/apps/api/src/services/flow-checkouts.ts +0 -123
  219. package/templates/apps/api/src/services/flow-engine.ts +0 -1278
  220. package/templates/apps/api/src/services/lead-promotion.ts +0 -176
  221. package/templates/apps/api/src/services/listing-csv-sweep.ts +0 -455
  222. package/templates/apps/api/src/services/playbook-compile.ts +0 -398
  223. package/templates/apps/api/src/services/playbook-render.ts +0 -263
  224. package/templates/apps/api/src/services/project-listing-decision.ts +0 -490
  225. package/templates/apps/api/src/services/project-listing-flow.ts +0 -426
  226. package/templates/apps/api/src/services/project-listing-issue.ts +0 -251
  227. package/templates/apps/api/src/services/recommender-capture.ts +0 -835
  228. package/templates/apps/api/src/services/scheduling/cal-provider.ts +0 -392
  229. package/templates/apps/api/src/services/scheduling/index.ts +0 -63
  230. package/templates/apps/api/src/services/scheduling/types.ts +0 -88
  231. package/templates/apps/api/src/services/tenant-context.ts +0 -451
  232. package/templates/apps/api/src/services/usage-metering.ts +0 -699
  233. package/templates/apps/api/src/services/waitlist-dashboard.ts +0 -947
  234. package/templates/apps/api/src/services/waitlist-experiments.ts +0 -213
  235. package/templates/apps/api/src/services/waitlist-followup.ts +0 -486
  236. package/templates/apps/api/src/services/waitlist-scoring.ts +0 -166
  237. package/templates/apps/api/src/services/waitlist-signups.ts +0 -165
  238. package/templates/apps/mcp/__tests__/schedule_tools.test.ts +0 -242
  239. package/templates/apps/mcp/src/tools/create_booking.ts +0 -58
  240. package/templates/apps/mcp/src/tools/get_event_meta.ts +0 -55
  241. package/templates/apps/mcp/src/tools/get_flow.ts +0 -61
  242. package/templates/apps/mcp/src/tools/list_availability.ts +0 -67
  243. package/templates/apps/mcp/src/tools/submit_flow_step.ts +0 -83
  244. package/templates/apps/web/app/[locale]/(authed)/waitlist/[experimentId]/page.tsx +0 -309
  245. package/templates/apps/web/app/[locale]/(authed)/waitlist/[experimentId]/signups/[leadId]/page.tsx +0 -149
  246. package/templates/apps/web/app/[locale]/(authed)/waitlist/__tests__/comparison.test.tsx +0 -273
  247. package/templates/apps/web/app/[locale]/(authed)/waitlist/__tests__/list.test.tsx +0 -171
  248. package/templates/apps/web/app/[locale]/(authed)/waitlist/__tests__/signup.test.tsx +0 -179
  249. package/templates/apps/web/app/[locale]/(authed)/waitlist/components/ExperimentsTable.tsx +0 -116
  250. package/templates/apps/web/app/[locale]/(authed)/waitlist/components/OfferFunnelTable.tsx +0 -66
  251. package/templates/apps/web/app/[locale]/(authed)/waitlist/components/RollupTicker.tsx +0 -61
  252. package/templates/apps/web/app/[locale]/(authed)/waitlist/components/ScoreTrace.tsx +0 -114
  253. package/templates/apps/web/app/[locale]/(authed)/waitlist/components/SignupsTable.tsx +0 -139
  254. package/templates/apps/web/app/[locale]/(authed)/waitlist/components/WaitlistComparisonTabs.tsx +0 -85
  255. package/templates/apps/web/app/[locale]/(authed)/waitlist/components/status-badges.tsx +0 -67
  256. package/templates/apps/web/app/[locale]/(authed)/waitlist/page.tsx +0 -179
  257. package/templates/apps/web/app/[locale]/blog/[[...slug]]/page.tsx +0 -178
  258. package/templates/apps/web/app/[locale]/dev/purchase/__tests__/actions.test.ts +0 -160
  259. package/templates/apps/web/app/[locale]/dev/purchase/error.tsx +0 -25
  260. package/templates/apps/web/app/[locale]/dev/purchase/page.tsx +0 -117
  261. package/templates/apps/web/app/[locale]/dev/purchase/purchase.actions.ts +0 -58
  262. package/templates/apps/web/app/[locale]/docs/[[...slug]]/page.tsx +0 -119
  263. package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/checkout.actions.test.ts +0 -287
  264. package/templates/apps/web/app/[locale]/flows/[slug]/checkout.actions.ts +0 -254
  265. package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/ResultsClient.tsx +0 -532
  266. package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/ResultsEmailStep.tsx +0 -121
  267. package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/__tests__/ResultsClient.checkout.test.tsx +0 -457
  268. package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/__tests__/page.test.tsx +0 -396
  269. package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/page.tsx +0 -114
  270. package/templates/apps/web/app/[locale]/projects/apply/ListingApplicationClient.tsx +0 -110
  271. package/templates/apps/web/app/[locale]/projects/apply/__tests__/ListingApplicationClient.test.tsx +0 -96
  272. package/templates/apps/web/app/[locale]/schedule/__tests__/page.test.tsx +0 -117
  273. package/templates/apps/web/app/[locale]/schedule/confirmed/__tests__/page.test.tsx +0 -96
  274. package/templates/apps/web/app/[locale]/schedule/confirmed/page.tsx +0 -92
  275. package/templates/apps/web/app/[locale]/schedule/page.tsx +0 -82
  276. package/templates/apps/web/content/en/blog/README.txt +0 -25
  277. package/templates/apps/web/content/en/docs/index.mdx +0 -28
  278. package/templates/apps/web/lib/__mocks__/source-server.js +0 -31
  279. package/templates/apps/web/lib/blog-list-data.ts +0 -95
  280. package/templates/apps/web/lib/blog-post-data.ts +0 -79
  281. package/templates/apps/web/lib/source.ts +0 -34
  282. package/templates/apps/web/public/logomark-dark.svg +0 -4
  283. package/templates/apps/web/public/logomark-light.svg +0 -4
  284. package/templates/apps/web/source.config.ts +0 -78
@@ -0,0 +1,478 @@
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 me.test.ts
6
+ *
7
+ * THE ACCEPTANCE GATE for the founder's own listings read (#5295).
8
+ * Registration mirrors server.ts: REAL `authPreHandlerPlugin ->
9
+ * projectListingMeRoute` (auth only, NO tenant guard), mocked `lib/prisma.js`.
10
+ *
11
+ * Proves the security properties the owner link exists for:
12
+ * - two users sharing a `founderEmail` value never see each other's rows;
13
+ * - a row whose owner link was never set is invisible to the person whose
14
+ * address it carries — the read never falls back to an email comparison;
15
+ * - the `where` clause carries `ownerUserId` and nothing resembling an email
16
+ * (a clause carrying one is treated as a contract violation and returns
17
+ * nothing, the `flow-checkouts` test's own discipline);
18
+ * - a signed-in caller who owns nothing gets `200 []`, never a 403;
19
+ * - `PROJECT_LISTING_ROLES` is untouched and this route never consults it.
20
+ */
21
+ import Fastify from "fastify";
22
+ import type { FastifyInstance } from "fastify";
23
+ import { projectListingMeResponseSchema } from "@working-theory/validation";
24
+
25
+ // `services/project-listings.ts` imports `routeLead` from `lead-routing.js` at
26
+ // module scope, which transitively imports `@working-theory/email` and
27
+ // `@working-theory/license` — mocked here purely so that import chain resolves,
28
+ // mirroring `tenant-isolation.test.ts`'s discipline.
29
+ jest.mock("@working-theory/email", () => ({
30
+ sendLeadNotification: jest.fn().mockResolvedValue({ ok: true, messageId: "msg-test" }),
31
+ }));
32
+ jest.mock("@working-theory/license", () => ({
33
+ checkLicense: jest.fn().mockResolvedValue({
34
+ tier: "free",
35
+ plan: null,
36
+ features: [],
37
+ expiresAt: null,
38
+ revoked: false,
39
+ }),
40
+ }));
41
+
42
+ // eslint-disable-next-line no-restricted-syntax -- jest mock factory must be hoisted
43
+ jest.mock("../../../lib/prisma.js", () => ({
44
+ prisma: {
45
+ session: { findUnique: jest.fn() },
46
+ apiToken: {
47
+ findUnique: jest.fn(),
48
+ update: jest.fn().mockResolvedValue(undefined),
49
+ },
50
+ user: { findUnique: jest.fn() },
51
+ projectListing: { findMany: jest.fn() },
52
+ waitlistScore: { count: jest.fn() },
53
+ },
54
+ }));
55
+
56
+ import { prisma } from "../../../lib/prisma.js";
57
+ import { authPreHandlerPlugin } from "../../../middleware/auth.js";
58
+ import { PROJECT_LISTING_ROLES } from "../../../services/project-listings.js";
59
+ import { projectListingMeRoute } from "../index.js";
60
+
61
+ const FRONTEND = "https://app.example.com";
62
+ const FUTURE = new Date(Date.now() + 60 * 60 * 1000);
63
+
64
+ const SITE_A = "wksp_00000000-0000-7000-8000-000000000001";
65
+ const SITE_B = "wksp_00000000-0000-7000-8000-000000000002";
66
+
67
+ /**
68
+ * `ada` and `bruno` are TWO DIFFERENT PEOPLE who typed the SAME address into
69
+ * the anonymous application form. That is the whole point: `founderEmail` is
70
+ * not an identity, and nothing may derive ownership from it.
71
+ */
72
+ const SHARED_EMAIL = "founder@example.com";
73
+
74
+ const USERS: Record<string, { id: string; email: string; emailVerified: boolean }> = {
75
+ ada: { id: "ada", email: SHARED_EMAIL, emailVerified: true },
76
+ bruno: { id: "bruno", email: SHARED_EMAIL, emailVerified: true },
77
+ // Signed in, verified, owns nothing at all.
78
+ cleo: { id: "cleo", email: "cleo@example.com", emailVerified: true },
79
+ // Signed in with an address that was never verified, and whose own listing
80
+ // WAS linked earlier. See the test that covers this case.
81
+ dane: { id: "dane", email: "dane@example.com", emailVerified: false },
82
+ };
83
+
84
+ const SESSION_ROWS: Record<string, Record<string, unknown>> = {
85
+ cookie_ada: { id: "sess_ada", userId: "ada", expiresAt: FUTURE, activeOrganizationId: null, activeWorkspaceId: null },
86
+ cookie_bruno: { id: "sess_bruno", userId: "bruno", expiresAt: FUTURE, activeOrganizationId: null, activeWorkspaceId: null },
87
+ cookie_cleo: { id: "sess_cleo", userId: "cleo", expiresAt: FUTURE, activeOrganizationId: null, activeWorkspaceId: null },
88
+ cookie_dane: { id: "sess_dane", userId: "dane", expiresAt: FUTURE, activeOrganizationId: null, activeWorkspaceId: null },
89
+ };
90
+
91
+ type ListingFixture = {
92
+ id: string;
93
+ siteId: string;
94
+ founderEmail: string;
95
+ ownerUserId: string | null;
96
+ experimentKey: string | null;
97
+ createdAt: Date;
98
+ };
99
+
100
+ /** A complete stored row at `PROJECT_LISTING_SELECT`'s shape. */
101
+ function row(fixture: ListingFixture) {
102
+ return {
103
+ id: fixture.id,
104
+ siteId: fixture.siteId,
105
+ leadId: `lead_${fixture.id}`,
106
+ slug: fixture.id.replace("prjl_", ""),
107
+ name: "Atlas",
108
+ oneLiner: "A one-liner.",
109
+ problem: "A problem.",
110
+ url: "https://atlas.example.com",
111
+ product: "live-some-users",
112
+ commitment: "full-time",
113
+ teamComposition: "just-me",
114
+ faqs: null,
115
+ anythingElse: null,
116
+ address: "1 Main St",
117
+ addressLine1: null,
118
+ addressLine2: null,
119
+ addressCity: null,
120
+ addressRegion: null,
121
+ addressPostalCode: null,
122
+ addressCountry: null,
123
+ questions: null,
124
+ siteAnswers: null,
125
+ windowDays: 30,
126
+ founderName: "A Founder",
127
+ founderEmail: fixture.founderEmail,
128
+ // #5407 — a row written before the columns existed, and a freshly
129
+ // started draft on a site that asks for one name line: parts null,
130
+ // no phone, no verification stamp.
131
+ founderFirstName: null,
132
+ founderLastName: null,
133
+ founderPhone: null,
134
+ emailVerifiedAt: null,
135
+ founderCompany: null,
136
+ headline: "Atlas",
137
+ logoMarkUrl: null,
138
+ logoWordmarkUrl: null,
139
+ featured: false,
140
+ status: "requested",
141
+ consentAt: fixture.createdAt,
142
+ decidedAt: null,
143
+ decidedBy: "operator@example.com",
144
+ issueUrl: "https://github.com/example/repo/issues/1",
145
+ experimentKey: fixture.experimentKey,
146
+ windowEndsAt: null,
147
+ createdAt: fixture.createdAt,
148
+ updatedAt: fixture.createdAt,
149
+ };
150
+ }
151
+
152
+ const LISTINGS: ListingFixture[] = [
153
+ // ada's own, linked to ada.
154
+ {
155
+ id: "prjl_ada_one",
156
+ siteId: SITE_A,
157
+ founderEmail: SHARED_EMAIL,
158
+ ownerUserId: "ada",
159
+ experimentKey: "atlas-waitlist",
160
+ createdAt: new Date("2026-09-01T00:00:00.000Z"),
161
+ },
162
+ // ada again, NEWER, and in a SECOND workspace of ada's — proves the per-site
163
+ // waitlistCount resolution and the newest-first ordering.
164
+ {
165
+ id: "prjl_ada_two",
166
+ siteId: SITE_B,
167
+ founderEmail: SHARED_EMAIL,
168
+ ownerUserId: "ada",
169
+ experimentKey: "atlas-two-waitlist",
170
+ createdAt: new Date("2026-09-05T00:00:00.000Z"),
171
+ },
172
+ // bruno's own — SAME founderEmail as ada's rows, different person.
173
+ {
174
+ id: "prjl_bruno_one",
175
+ siteId: SITE_A,
176
+ founderEmail: SHARED_EMAIL,
177
+ ownerUserId: "bruno",
178
+ experimentKey: null,
179
+ createdAt: new Date("2026-09-03T00:00:00.000Z"),
180
+ },
181
+ // Carries the shared address and was NEVER linked. Invisible to everyone.
182
+ {
183
+ id: "prjl_unlinked",
184
+ siteId: SITE_A,
185
+ founderEmail: SHARED_EMAIL,
186
+ ownerUserId: null,
187
+ experimentKey: null,
188
+ createdAt: new Date("2026-09-04T00:00:00.000Z"),
189
+ },
190
+ // dane's own, linked while dane's address was still verified.
191
+ {
192
+ id: "prjl_dane_one",
193
+ siteId: SITE_A,
194
+ founderEmail: "dane@example.com",
195
+ ownerUserId: "dane",
196
+ experimentKey: null,
197
+ createdAt: new Date("2026-09-02T00:00:00.000Z"),
198
+ },
199
+ ];
200
+
201
+ type ListingWhere = Record<string, unknown> & { ownerUserId?: unknown };
202
+
203
+ const WAITLIST_COUNTS: Record<string, Record<string, number>> = {
204
+ [SITE_A]: { "atlas-waitlist": 7 },
205
+ [SITE_B]: { "atlas-two-waitlist": 3 },
206
+ };
207
+
208
+ function installFixtureMocks(): void {
209
+ (prisma.session.findUnique as jest.Mock).mockImplementation(
210
+ ({ where }: { where: { token?: string } }) =>
211
+ Promise.resolve(where.token ? (SESSION_ROWS[where.token] ?? null) : null),
212
+ );
213
+ (prisma.apiToken.findUnique as jest.Mock).mockResolvedValue(null);
214
+ (prisma.user.findUnique as jest.Mock).mockImplementation(({ where }: { where: { id: string } }) =>
215
+ Promise.resolve(USERS[where.id] ?? null),
216
+ );
217
+
218
+ /**
219
+ * The owner link is the ONLY join the service may use, and this mock is
220
+ * written to FAIL on a regression rather than to absorb one.
221
+ *
222
+ * Two earlier, weaker forms were rejected during review, both of which let a
223
+ * regression keep the security tests green:
224
+ *
225
+ * - `expect(where).not.toHaveProperty("founderEmail")` inspects TOP-LEVEL
226
+ * keys only, so an `OR`-wrapped email fallback
227
+ * (`{ OR: [{ ownerUserId }, { founderEmail }] }`) sails straight past it —
228
+ * and a mock that then filtered on `where.ownerUserId` returns `[]` for
229
+ * that shape, which looks like "nothing leaked".
230
+ * - Filtering on `where.ownerUserId` when it is `undefined` also returns
231
+ * `[]`, while Prisma compiles an `undefined` filter to no filter at all
232
+ * (`WHERE 1=1`) and the REAL read would be unscoped.
233
+ *
234
+ * So: any key other than `ownerUserId` THROWS, an `ownerUserId` that is not a
235
+ * string THROWS, and the filter is exact equality on it. A widened read now
236
+ * fails as a thrown error rather than as a quietly empty result.
237
+ */
238
+ (prisma.projectListing.findMany as jest.Mock).mockImplementation(
239
+ ({ where }: { where: ListingWhere }) => {
240
+ const keys = Object.keys(where);
241
+ if (keys.length !== 1 || keys[0] !== "ownerUserId") {
242
+ throw new Error(
243
+ `the /me read must scope on ownerUserId and nothing else; got where keys [${keys.join(", ")}]`,
244
+ );
245
+ }
246
+ if (typeof where.ownerUserId !== "string") {
247
+ throw new Error(
248
+ "ownerUserId must be a concrete string; Prisma compiles an undefined filter to no filter, leaving the read unscoped",
249
+ );
250
+ }
251
+ const ownerUserId = where.ownerUserId;
252
+ return Promise.resolve(
253
+ LISTINGS.filter((l) => l.ownerUserId === ownerUserId)
254
+ .sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime())
255
+ .map(row),
256
+ );
257
+ },
258
+ );
259
+
260
+ (prisma.waitlistScore.count as jest.Mock).mockImplementation(
261
+ ({ where }: { where: { siteId: string; experimentKey: string } }) =>
262
+ Promise.resolve(WAITLIST_COUNTS[where.siteId]?.[where.experimentKey] ?? 0),
263
+ );
264
+ }
265
+
266
+ async function buildApp(): Promise<FastifyInstance> {
267
+ const app = Fastify();
268
+ await app.register(async (authScope) => {
269
+ await authScope.register(authPreHandlerPlugin, { frontendOrigin: FRONTEND });
270
+ await authScope.register(projectListingMeRoute);
271
+ });
272
+ await app.ready();
273
+ return app;
274
+ }
275
+
276
+ const cookie = (token: string) => ({ cookie: `better-auth.session_token=${token}` });
277
+ const URL_ME = "/v1/project-listings/me";
278
+
279
+ const listingWhere = (call = 0) =>
280
+ (prisma.projectListing.findMany as jest.Mock).mock.calls[call][0].where as ListingWhere;
281
+
282
+ let app: FastifyInstance;
283
+
284
+ beforeEach(async () => {
285
+ jest.clearAllMocks();
286
+ installFixtureMocks();
287
+ app = await buildApp();
288
+ });
289
+
290
+ afterEach(async () => {
291
+ await app.close();
292
+ });
293
+
294
+ describe("GET /v1/project-listings/me — auth", () => {
295
+ it("no session cookie and no bearer → the preHandler's existing 401; no listing read", async () => {
296
+ const res = await app.inject({ method: "GET", url: URL_ME });
297
+
298
+ expect(res.statusCode).toBe(401);
299
+ expect(prisma.projectListing.findMany).not.toHaveBeenCalled();
300
+ });
301
+
302
+ it("an expired / unknown cookie → 401", async () => {
303
+ const res = await app.inject({ method: "GET", url: URL_ME, headers: cookie("cookie_nobody") });
304
+
305
+ expect(res.statusCode).toBe(401);
306
+ expect(prisma.projectListing.findMany).not.toHaveBeenCalled();
307
+ });
308
+ });
309
+
310
+ describe("GET /v1/project-listings/me — the owner link is the only join", () => {
311
+ /** SECURITY PROPERTY: two users sharing a founderEmail value. */
312
+ it("two users sharing a founderEmail never see each other's listings", async () => {
313
+ const adaRes = await app.inject({ method: "GET", url: URL_ME, headers: cookie("cookie_ada") });
314
+ const brunoRes = await app.inject({ method: "GET", url: URL_ME, headers: cookie("cookie_bruno") });
315
+
316
+ expect(adaRes.statusCode).toBe(200);
317
+ expect(brunoRes.statusCode).toBe(200);
318
+
319
+ const adaIds = (adaRes.json().data as Array<{ id: string }>).map((r) => r.id);
320
+ const brunoIds = (brunoRes.json().data as Array<{ id: string }>).map((r) => r.id);
321
+
322
+ // ada sees her two rows, newest first, and NOT bruno's.
323
+ expect(adaIds).toEqual(["prjl_ada_two", "prjl_ada_one"]);
324
+ // bruno sees his one row, and NOT ada's — though every row in play carries
325
+ // the identical founderEmail.
326
+ expect(brunoIds).toEqual(["prjl_bruno_one"]);
327
+
328
+ expect(adaIds).not.toContain("prjl_bruno_one");
329
+ expect(brunoIds).not.toContain("prjl_ada_one");
330
+ expect(brunoIds).not.toContain("prjl_ada_two");
331
+ // And neither of them reaches the un-linked row carrying the same address.
332
+ expect([...adaIds, ...brunoIds]).not.toContain("prjl_unlinked");
333
+ });
334
+
335
+ /** SECURITY PROPERTY: no read-time email fallback. */
336
+ it("a listing carrying the caller's own address but NO owner link is never returned", async () => {
337
+ const res = await app.inject({ method: "GET", url: URL_ME, headers: cookie("cookie_ada") });
338
+
339
+ const ids = (res.json().data as Array<{ id: string }>).map((r) => r.id);
340
+ expect(ids).not.toContain("prjl_unlinked");
341
+ });
342
+
343
+ it("the where clause is the owner link and carries nothing email-shaped", async () => {
344
+ await app.inject({ method: "GET", url: URL_ME, headers: cookie("cookie_ada") });
345
+
346
+ expect(listingWhere()).toEqual({ ownerUserId: "ada" });
347
+ });
348
+
349
+ it("a query parameter is never read — ?email= and ?ownerUserId= change nothing", async () => {
350
+ const res = await app.inject({
351
+ method: "GET",
352
+ url: `${URL_ME}?email=${encodeURIComponent(SHARED_EMAIL)}&ownerUserId=bruno`,
353
+ headers: cookie("cookie_ada"),
354
+ });
355
+
356
+ expect(res.statusCode).toBe(200);
357
+ // Still ada's own rows, scoped by the SESSION's user id.
358
+ expect(listingWhere()).toEqual({ ownerUserId: "ada" });
359
+ expect((res.json().data as Array<{ id: string }>).map((r) => r.id)).toEqual([
360
+ "prjl_ada_two",
361
+ "prjl_ada_one",
362
+ ]);
363
+ });
364
+ });
365
+
366
+ describe("GET /v1/project-listings/me — the empty case is not a refusal", () => {
367
+ /** SECURITY / UX PROPERTY: owning nothing is an empty list, never a 403. */
368
+ it("a signed-in caller who owns no listing gets 200 with an empty list, not a 403", async () => {
369
+ const res = await app.inject({ method: "GET", url: URL_ME, headers: cookie("cookie_cleo") });
370
+
371
+ expect(res.statusCode).toBe(200);
372
+ expect(res.json()).toEqual({ ok: true, data: [] });
373
+ // Specifically NOT the tenant routes' 403 — cleo is legitimately signed in.
374
+ expect(res.statusCode).not.toBe(403);
375
+ });
376
+
377
+ it("no waitlist count is read when the caller owns nothing", async () => {
378
+ await app.inject({ method: "GET", url: URL_ME, headers: cookie("cookie_cleo") });
379
+
380
+ expect(prisma.waitlistScore.count).not.toHaveBeenCalled();
381
+ });
382
+ });
383
+
384
+ describe("GET /v1/project-listings/me — the response shape", () => {
385
+ it("every row parses under projectListingMeResponseSchema", async () => {
386
+ const res = await app.inject({ method: "GET", url: URL_ME, headers: cookie("cookie_ada") });
387
+
388
+ expect(res.statusCode).toBe(200);
389
+ const parsed = projectListingMeResponseSchema.safeParse(res.json().data);
390
+ expect(parsed.success).toBe(true);
391
+ });
392
+
393
+ it("the five internal references never reach the founder", async () => {
394
+ const res = await app.inject({ method: "GET", url: URL_ME, headers: cookie("cookie_ada") });
395
+
396
+ for (const listing of res.json().data as Array<Record<string, unknown>>) {
397
+ expect(listing).not.toHaveProperty("siteId");
398
+ expect(listing).not.toHaveProperty("leadId");
399
+ expect(listing).not.toHaveProperty("decidedBy");
400
+ expect(listing).not.toHaveProperty("issueUrl");
401
+ expect(listing).not.toHaveProperty("experimentKey");
402
+ }
403
+ // No workspace id reaches the wire anywhere in the body.
404
+ expect(res.body).not.toContain(SITE_A);
405
+ expect(res.body).not.toContain(SITE_B);
406
+ });
407
+
408
+ it("waitlistCount is resolved per site, not all under one site id", async () => {
409
+ const res = await app.inject({ method: "GET", url: URL_ME, headers: cookie("cookie_ada") });
410
+
411
+ const byId = Object.fromEntries(
412
+ (res.json().data as Array<{ id: string; waitlistCount: number }>).map((r) => [
413
+ r.id,
414
+ r.waitlistCount,
415
+ ]),
416
+ );
417
+ // prjl_ada_one's experiment lives at SITE_A (7); prjl_ada_two's at SITE_B (3).
418
+ // One shared site id would have reported 0 for one of them.
419
+ expect(byId["prjl_ada_one"]).toBe(7);
420
+ expect(byId["prjl_ada_two"]).toBe(3);
421
+ expect(prisma.waitlistScore.count).toHaveBeenCalledWith({
422
+ where: { siteId: SITE_A, experimentKey: "atlas-waitlist" },
423
+ });
424
+ expect(prisma.waitlistScore.count).toHaveBeenCalledWith({
425
+ where: { siteId: SITE_B, experimentKey: "atlas-two-waitlist" },
426
+ });
427
+ });
428
+
429
+ it("the founder's own email is echoed back to the founder who wrote it", async () => {
430
+ const res = await app.inject({ method: "GET", url: URL_ME, headers: cookie("cookie_bruno") });
431
+
432
+ expect((res.json().data as Array<{ founderEmail: string }>)[0]?.founderEmail).toBe(
433
+ SHARED_EMAIL,
434
+ );
435
+ });
436
+ });
437
+
438
+ describe("GET /v1/project-listings/me — the read-time verification posture", () => {
439
+ /**
440
+ * A caller whose address is no longer verified still reads the rows their
441
+ * link already covers. This is deliberate, and it is the one place this route
442
+ * diverges from `/v1/flow-checkouts/me`, which re-checks `emailVerified`.
443
+ *
444
+ * The re-check is load-bearing on `/v1/waitlist-signups/me`, where the match
445
+ * IS an email, and it is defence in depth on `/v1/flow-checkouts/me`. Here it
446
+ * would be neither: the link is the proof, it was only ever issuable under
447
+ * verification, and refusing on a later unverified address would hide rows
448
+ * the caller already proved they own (changing your email address is not a
449
+ * reason to lose your own submissions). An unverified address still cannot
450
+ * CREATE a link — `listing-promotion.ts` refuses before any listing read.
451
+ */
452
+ it("a caller whose address is no longer verified still reads the rows their link already covers", async () => {
453
+ const res = await app.inject({ method: "GET", url: URL_ME, headers: cookie("cookie_dane") });
454
+
455
+ expect(res.statusCode).toBe(200);
456
+ expect((res.json().data as Array<{ id: string }>).map((r) => r.id)).toEqual([
457
+ "prjl_dane_one",
458
+ ]);
459
+ expect(listingWhere()).toEqual({ ownerUserId: "dane" });
460
+ });
461
+ });
462
+
463
+ describe("the existing operator role floor is untouched", () => {
464
+ it("PROJECT_LISTING_ROLES is still exactly owner + admin", () => {
465
+ expect([...PROJECT_LISTING_ROLES].sort()).toEqual(["admin", "owner"]);
466
+ expect(PROJECT_LISTING_ROLES.size).toBe(2);
467
+ });
468
+
469
+ it("the /me route never consults the tenant context or the role floor", async () => {
470
+ // The app this suite builds registers NO tenant preHandler at all, so
471
+ // `request.tenant` is undefined for every request above. That every test in
472
+ // this file answers 200 is the proof: a route that consulted the floor
473
+ // would have answered 401/403 instead.
474
+ const res = await app.inject({ method: "GET", url: URL_ME, headers: cookie("cookie_ada") });
475
+
476
+ expect(res.statusCode).toBe(200);
477
+ });
478
+ });
@@ -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 public.test.ts
3
6
  *
@@ -34,6 +37,13 @@ import { projectListingPublicSchema } from "@working-theory/validation";
34
37
  // `routes/leads/__tests__/index.test.ts`'s discipline.
35
38
  jest.mock("@working-theory/email", () => ({
36
39
  sendLeadNotification: jest.fn().mockResolvedValue({ ok: true, messageId: "msg-test" }),
40
+ // #5406 — `start` now reaches the resume email. Declared here for the reason
41
+ // docs/lessons/project-listings/04 records: a mock factory that omits an
42
+ // export the code under test calls leaves it `undefined` at the callsite.
43
+ // These suites configure no `resumeEmail`, so the send short-circuits before
44
+ // this is reached; it is declared so a future fixture that DOES configure
45
+ // one fails on its assertion rather than on a missing mock.
46
+ sendListingResumeEmail: jest.fn().mockResolvedValue({ ok: true, messageId: "msg-resume" }),
37
47
  }));
38
48
  jest.mock("@working-theory/license", () => ({
39
49
  checkLicense: jest.fn().mockResolvedValue({
@@ -108,6 +118,13 @@ function makeRow(overrides: Partial<Record<string, unknown>>) {
108
118
  questions: QUESTIONS,
109
119
  founderName: "Some Founder",
110
120
  founderEmail: "founder@example.com",
121
+ // #5407 — a row that filled in EVERY new column, which is the shape that
122
+ // can actually leak. A null phone would pass the public-read tests below
123
+ // whether or not the strip existed.
124
+ founderFirstName: "Some",
125
+ founderLastName: "Founder",
126
+ founderPhone: "+12015550123",
127
+ emailVerifiedAt: new Date("2026-08-02T00:00:00.000Z"),
111
128
  founderCompany: null,
112
129
  headline: null,
113
130
  logoMarkUrl: null,
@@ -268,6 +285,27 @@ describe("GET /v1/project-listings/public", () => {
268
285
  }
269
286
  });
270
287
 
288
+ // #5407 — the phone and the verification stamp join `founderEmail` on the
289
+ // never-public list, and the fixture above carries a real value for each so
290
+ // this test can actually fail. Asserted on the SERIALIZED body, not on the
291
+ // parsed rows: `.strict()` would reject an extra key, but only a string
292
+ // search proves the VALUE is not riding some other field.
293
+ it("never carries founderPhone or emailVerifiedAt on any row (#5407)", async () => {
294
+ const res = await app.inject({
295
+ method: "GET",
296
+ url: `/v1/project-listings/public?siteKey=${SITE_KEY}`,
297
+ });
298
+
299
+ const body = JSON.stringify(res.json());
300
+ expect(body).not.toContain("founderPhone");
301
+ expect(body).not.toContain("+12015550123");
302
+ expect(body).not.toContain("emailVerifiedAt");
303
+ expect(body).not.toContain("2026-08-02");
304
+ // The two name PARTS do ride the public read, deliberately: they are the
305
+ // same information as `founderName`, which has always been public.
306
+ expect(body).toContain("founderFirstName");
307
+ });
308
+
271
309
  it("400s on a malformed siteKey", async () => {
272
310
  const res = await app.inject({ method: "GET", url: "/v1/project-listings/public?siteKey=" });
273
311
  expect(res.statusCode).toBe(400);