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,4 +1,11 @@
1
1
  /**
2
+ * NOT marked `oss: false` (D-48/#5366 escalation — see
3
+ * `routes/project-listings/me.ts`'s header comment): imported by
4
+ * `routes/project-listings/{start,patch-draft,submit,public,me}.ts`, each of
5
+ * which ships unconditionally because `routes/project-listings/index.ts`'s
6
+ * multi-tenant literal `import()` forces it. Registration of the routes that
7
+ * call into this service stays gated (`isModuleEnabled("signal-collectors")`).
8
+ *
2
9
  * @file project-listings.ts
3
10
  * @description The founder's-application-row service (#4998, reshaped by
4
11
  * #5036) — writes, reads, and transitions `ProjectListing` (ADR 0009 D3
@@ -42,10 +49,15 @@ import {
42
49
  clampWindowDays,
43
50
  buildProjectListingDraftPatchSchema,
44
51
  buildProjectListingSubmitSchema,
52
+ composeAddressLine,
53
+ composeFounderName,
54
+ flowAddressValueSchema,
55
+ publicSiteAnswers,
45
56
  PROJECT_LISTING_CONCURRENT_LIMIT,
46
57
  PROJECT_LISTING_NON_TERMINAL_STATUSES,
47
58
  } from "@working-theory/validation";
48
59
  import type {
60
+ FlowAddressValue,
49
61
  ProjectListing,
50
62
  ProjectListingDraftPatchInput,
51
63
  ProjectListingFaqs,
@@ -56,6 +68,13 @@ import type {
56
68
 
57
69
  import { prisma } from "../lib/prisma.js";
58
70
 
71
+ import {
72
+ consentConfigForSite,
73
+ MAX_CONSENT_KEYS,
74
+ recordConsent,
75
+ recordLegacyConsent,
76
+ requiredConsentUnmet,
77
+ } from "./consent.js";
59
78
  import { routeLead, type SourceMeta } from "./lead-routing.js";
60
79
  import { listingConfigFor } from "./listing-config.js";
61
80
 
@@ -205,9 +224,27 @@ const PROJECT_LISTING_SELECT = {
205
224
  faqs: true,
206
225
  anythingElse: true,
207
226
  address: true,
227
+ // #5279 — the structured parts beside the display string.
228
+ addressLine1: true,
229
+ addressLine2: true,
230
+ addressCity: true,
231
+ addressRegion: true,
232
+ addressPostalCode: true,
233
+ addressCountry: true,
208
234
  questions: true,
235
+ // #5285 — the site's own answers, and the window the applicant asked for.
236
+ // `windowDays` rides the select because `submit` re-validates the assembled
237
+ // application, and `requestedWindowDays` is one of its fields now.
238
+ siteAnswers: true,
239
+ windowDays: true,
209
240
  founderName: true,
241
+ // #5407 — the name parts beside the composed display string, the phone, and
242
+ // the resume-link verification stamp.
243
+ founderFirstName: true,
244
+ founderLastName: true,
210
245
  founderEmail: true,
246
+ founderPhone: true,
247
+ emailVerifiedAt: true,
211
248
  founderCompany: true,
212
249
  headline: true,
213
250
  logoMarkUrl: true,
@@ -228,6 +265,81 @@ type ProjectListingRow = Prisma.ProjectListingGetPayload<{
228
265
  select: typeof PROJECT_LISTING_SELECT;
229
266
  }>;
230
267
 
268
+ /**
269
+ * The structured address parts of a row, or null (#5279).
270
+ *
271
+ * Exported because `project-listing-decision.ts` renders the same row shape
272
+ * and must answer the same way — two readers guessing separately at what
273
+ * "has parts" means is exactly how one of them starts emitting a half-filled
274
+ * object.
275
+ *
276
+ * The rule is the composite's REQUIRED set: the five non-optional parts are
277
+ * all present, or there are no parts. `addressLine2` is optional in the
278
+ * fieldset, so it rides along when present and is simply absent when not. A
279
+ * row written before #5279 has all six columns null and answers null here —
280
+ * never a partial object reconstructed from `address`, which nothing in this
281
+ * repo parses.
282
+ *
283
+ * `.safeParse` rather than a cast: these are six nullable TEXT columns, and
284
+ * the emitted object must satisfy `flowAddressValueSchema` or the row fails
285
+ * its own response schema downstream. A shape that somehow does not (a
286
+ * `country` that is not an alpha-2 code, say) answers null rather than
287
+ * putting an invalid address on the PUBLIC read.
288
+ */
289
+ export function addressPartsFromRow(row: {
290
+ addressLine1: string | null;
291
+ addressLine2: string | null;
292
+ addressCity: string | null;
293
+ addressRegion: string | null;
294
+ addressPostalCode: string | null;
295
+ addressCountry: string | null;
296
+ }): FlowAddressValue | null {
297
+ if (
298
+ row.addressLine1 === null ||
299
+ row.addressCity === null ||
300
+ row.addressRegion === null ||
301
+ row.addressPostalCode === null ||
302
+ row.addressCountry === null
303
+ ) {
304
+ return null;
305
+ }
306
+ const parsed = flowAddressValueSchema.safeParse({
307
+ addressLine1: row.addressLine1,
308
+ ...(row.addressLine2 !== null && { addressLine2: row.addressLine2 }),
309
+ city: row.addressCity,
310
+ region: row.addressRegion,
311
+ postalCode: row.addressPostalCode,
312
+ country: row.addressCountry,
313
+ });
314
+ return parsed.success ? parsed.data : null;
315
+ }
316
+
317
+ /**
318
+ * The site-answers object of a row, or null (#5285).
319
+ *
320
+ * Exported for the same reason `addressPartsFromRow` is: the operator's
321
+ * decision payload renders the same row shape and must answer the same way.
322
+ *
323
+ * The column is `Json?`, so Prisma types it as `JsonValue` — an array, a
324
+ * number and a string all satisfy it. Only ever written by
325
+ * `draftUpdateData` from a body the site's own declarations validated, so a
326
+ * row holding anything else is a row somebody wrote around this service.
327
+ * A non-object answers null rather than being cast: a value of the wrong
328
+ * SHAPE reaching the response schema would fail the whole read, and one
329
+ * malformed row must not take a listing page down.
330
+ *
331
+ * Non-string members are dropped for the same reason — the store is
332
+ * `Record<string, string>`, and what is handed back has to actually be one.
333
+ */
334
+ export function siteAnswersFromRow(value: Prisma.JsonValue | null): Record<string, string> | null {
335
+ if (value === null || typeof value !== "object" || Array.isArray(value)) return null;
336
+ return Object.fromEntries(
337
+ Object.entries(value).filter(
338
+ (entry): entry is [string, string] => typeof entry[1] === "string",
339
+ ),
340
+ );
341
+ }
342
+
231
343
  /**
232
344
  * `now` is passed rather than read inside so one read renders every row of a
233
345
  * list against a single instant. `featuredEnabled` is the site's config
@@ -257,12 +369,25 @@ function toResponseRow(
257
369
  faqs: row.faqs as unknown as ProjectListingFaqs | null,
258
370
  anythingElse: row.anythingElse,
259
371
  address: row.address,
372
+ addressParts: addressPartsFromRow(row),
260
373
  founderName: row.founderName,
374
+ founderFirstName: row.founderFirstName,
375
+ founderLastName: row.founderLastName,
261
376
  founderEmail: row.founderEmail,
377
+ founderPhone: row.founderPhone,
378
+ // #5407 — the operator's verification filter at review. Null means "no
379
+ // opening of the resume link recorded", never "verification failed".
380
+ emailVerifiedAt: row.emailVerifiedAt ? row.emailVerifiedAt.toISOString() : null,
262
381
  founderCompany: row.founderCompany,
263
382
  questions: row.questions as unknown as ProjectListingQuestions | null,
264
383
  logoMarkUrl: row.logoMarkUrl,
265
384
  logoWordmarkUrl: row.logoWordmarkUrl,
385
+ // #5285 — UNFILTERED here, deliberately. This row shape serves the tenant
386
+ // dashboard and the founder's own draft read, both of which are entitled
387
+ // to every answer. The PUBLIC read is the one that must not carry an
388
+ // `operator-only` answer, and it narrows this through `publicSiteAnswers`
389
+ // in `publicProjectListings` — one filter, on the one read that publishes.
390
+ siteAnswers: siteAnswersFromRow(row.siteAnswers),
266
391
  featured: featuredEnabled && row.featured,
267
392
  // The row's OWN flag decides the closed exemption (#5036 D6b), not the
268
393
  // site's `featuredEnabled` switch: that switch governs whether the flag
@@ -384,6 +509,42 @@ function hashResumeToken(token: string): string {
384
509
  return createHash("sha256").update(token).digest("hex");
385
510
  }
386
511
 
512
+ // ─── The email-verification nonce (#5407) ──────────────────────────────────
513
+
514
+ /**
515
+ * Mint the nonce that PROVES the founder read mail sent to `founderEmail`.
516
+ *
517
+ * WHY A SECOND CREDENTIAL EXISTS AT ALL, since the obvious design is to reuse
518
+ * the resume token the emailed link already carries. That design cannot work:
519
+ * `start` returns the resume token in its own 201 body, to whoever posted the
520
+ * form. The emailed link is a SECOND COPY of a credential the applicant
521
+ * already holds, so keying verification on it would let two HTTP calls — open
522
+ * a draft under a stranger's address, take the token out of the response,
523
+ * present it back — forge the stamp with no mailbox involved. Found by this
524
+ * change's own security review, before it shipped.
525
+ *
526
+ * This nonce closes that because of where it does NOT go. It is minted here,
527
+ * digested into `emailVerifyNonceHash`, and returned to exactly one caller —
528
+ * `start`, which hands it to `sendListingResumeLink` and to nothing else. It
529
+ * is in no response body, no log line, and no browser that filled the form.
530
+ * The only way to hold it is to have read the email.
531
+ *
532
+ * Same 256 bits as the resume token, for the same reason.
533
+ */
534
+ function mintVerifyNonce(): string {
535
+ return randomBytes(RESUME_TOKEN_BYTES).toString("base64url");
536
+ }
537
+
538
+ /**
539
+ * What `emailVerifyNonceHash` holds. The same plain sha-256 as
540
+ * `hashResumeToken`, for the same reason — the input is CSPRNG output, not a
541
+ * human-chosen secret — and kept as its own function rather than aliased so
542
+ * no call site has to read "resume token" while meaning the nonce.
543
+ */
544
+ function hashVerifyNonce(nonce: string): string {
545
+ return createHash("sha256").update(nonce).digest("hex");
546
+ }
547
+
387
548
  /**
388
549
  * Resolve the ONE draft a token grants access to (#5036 D1).
389
550
  *
@@ -410,17 +571,38 @@ async function findDraftByToken(
410
571
  // ─── start (POST, anonymous) ────────────────────────────────────────────────
411
572
 
412
573
  /**
413
- * `start` has no failure mode of its own to report. It reads the site's config
414
- * (which falls back to the platform defaults rather than erroring) and writes
415
- * one row; a Prisma failure is a 500 through the framework, as everywhere else.
416
- * The Lead write the one step that used to give this operation a
417
- * `persist_failed`/`config_missing` surface moved to `submit`.
574
+ * `start` has one failure mode of its own to report (#5275): a `consents`
575
+ * map that does not GRANT every purpose the site's registry marks required —
576
+ * declined, or simply absent. The legacy literal says the founder consented;
577
+ * a `false` (or nothing) beside it for the required purpose says they did
578
+ * notthe body contradicts itself, and the row must not be created on it
579
+ * (it would go on to publish the founder's address against a recorded
580
+ * decline, or with no evidence row at all). An oversized map is the same
581
+ * 400. Everything else is as before: the site's config falls back to the
582
+ * platform defaults rather than erroring, and a Prisma failure is a 500
583
+ * through the framework. The Lead write — the step that used to give this
584
+ * operation a `persist_failed`/`config_missing` surface — moved to `submit`.
418
585
  */
419
- export type StartProjectListingDraftResult = {
420
- ok: true;
421
- id: string;
422
- resumeToken: string;
423
- };
586
+ export type StartProjectListingDraftResult =
587
+ | {
588
+ ok: true;
589
+ id: string;
590
+ resumeToken: string;
591
+ /**
592
+ * #5407 — the email-verification nonce, for the RESUME-EMAIL BUILDER
593
+ * and for nothing else.
594
+ *
595
+ * A sibling of the result rather than a field of anything the route
596
+ * hands to a response schema, deliberately (lesson 05): `start`'s 201
597
+ * body is `projectListingStartResponseSchema`, which is `.strict()` and
598
+ * carries `id` and `resumeToken` only. That route's request and
599
+ * response shapes are a declared hard floor and are unchanged — this
600
+ * value reaches `sendListingResumeLink` from the route's local
601
+ * variable and never touches the wire.
602
+ */
603
+ verifyNonce: string;
604
+ }
605
+ | { ok: false; error: { kind: "invalid_input"; message: string } };
424
606
 
425
607
  /**
426
608
  * Create a `draft` from step one — the founder's name, email and consent.
@@ -444,27 +626,211 @@ export async function startProjectListingDraft(
444
626
  siteId: string,
445
627
  input: ProjectListingStartInput,
446
628
  ): Promise<StartProjectListingDraftResult> {
447
- const { founderName, requestedWindowDays } = input;
629
+ const { requestedWindowDays } = input;
630
+ // #5407 — the NOT NULL display column. `composeFounderName` builds it from
631
+ // the PARTS wherever both are written (even beside a `founderName` the body
632
+ // also sent, so the stored line and the stored parts can never disagree),
633
+ // and stores a line sent on its own verbatim. ONE WAY ONLY: nothing here or
634
+ // anywhere else derives parts back out of a line. The schema has already
635
+ // refused a body carrying neither shape, so this always produces a string.
636
+ const founderName = composeFounderName(input);
448
637
  const founderEmail = normalizeFounderEmail(input.founderEmail);
449
638
 
450
639
  const listingConfig = await listingConfigFor(siteId);
451
640
  const windowDays = clampWindowDays(requestedWindowDays, listingConfig);
452
641
 
642
+ // #5275 — refuse BEFORE the row exists: a required purpose not granted
643
+ // (declined or absent) is a body that contradicts its own `consent: true`,
644
+ // and an oversized map is not a form's answer.
645
+ const consentConfig = await consentConfigForSite(siteId);
646
+ if (input.consents) {
647
+ if (Object.keys(input.consents).length > MAX_CONSENT_KEYS) {
648
+ return {
649
+ ok: false,
650
+ error: {
651
+ kind: "invalid_input",
652
+ message: `consents carries more than ${MAX_CONSENT_KEYS} purposes`,
653
+ },
654
+ };
655
+ }
656
+ const unmet = requiredConsentUnmet(consentConfig, input.consents);
657
+ if (unmet !== undefined) {
658
+ return {
659
+ ok: false,
660
+ error: {
661
+ kind: "invalid_input",
662
+ message: `required consent purpose "${unmet}" was not granted`,
663
+ },
664
+ };
665
+ }
666
+ }
667
+
453
668
  const resumeToken = mintResumeToken();
454
- const row = await prisma.projectListing.create({
455
- data: {
456
- siteId,
457
- founderName,
458
- founderEmail,
459
- status: "draft",
460
- consentAt: new Date(),
461
- resumeTokenHash: hashResumeToken(resumeToken),
462
- windowDays,
463
- },
464
- select: { id: true },
669
+ // #5407 minted on EVERY draft, whether or not the site sends the resume
670
+ // email. `start` cannot know: the send is decided asynchronously inside
671
+ // `sendListingResumeLink`, against the site's own config. A site running no
672
+ // resume email therefore stores a nonce nobody is ever handed, and nothing
673
+ // on that row can ever verify — which is the correct outcome rather than a
674
+ // gap, because no one has read anything at that address.
675
+ const verifyNonce = mintVerifyNonce();
676
+ // #5275 — the row and its grant record land in ONE transaction: from here
677
+ // on `consentAt` and the ConsentGrant row are stamped by the SAME
678
+ // affirmative act, and a grant write that fails takes the row with it
679
+ // rather than stranding a "consented" listing with no evidence (security
680
+ // review, Medium 2). A body carrying `consents` records the wording the
681
+ // founder read (resolved server-side from the same registry the renderer
682
+ // used, D7); a body carrying only the legacy `consent: true` literal
683
+ // records one grant marked unrecoverable (D11) rather than today's
684
+ // wording, which nobody was shown.
685
+ const consents = input.consents;
686
+ const locale = input.locale ?? "en";
687
+ const row = await prisma.$transaction(async (tx) => {
688
+ const created = await tx.projectListing.create({
689
+ data: {
690
+ siteId,
691
+ founderName,
692
+ // #5407 — the parts as sent, beside the composed line. Absent where a
693
+ // caller sent only a display line, which is the shape every row
694
+ // written before #5407 carries; nothing here ever splits that line.
695
+ ...(input.founderFirstName !== undefined && {
696
+ founderFirstName: input.founderFirstName,
697
+ }),
698
+ ...(input.founderLastName !== undefined && {
699
+ founderLastName: input.founderLastName,
700
+ }),
701
+ founderEmail,
702
+ ...(input.founderPhone !== undefined && { founderPhone: input.founderPhone }),
703
+ status: "draft",
704
+ consentAt: new Date(),
705
+ resumeTokenHash: hashResumeToken(resumeToken),
706
+ emailVerifyNonceHash: hashVerifyNonce(verifyNonce),
707
+ windowDays,
708
+ },
709
+ select: { id: true },
710
+ });
711
+ const subject = { siteId, subjectType: "project_listing", subjectId: created.id };
712
+ if (consents) {
713
+ await recordConsent({ ...subject, locale, config: consentConfig, consents }, tx);
714
+ } else {
715
+ await recordLegacyConsent({ ...subject, config: consentConfig }, tx);
716
+ }
717
+ return created;
718
+ });
719
+
720
+ return { ok: true, id: row.id, resumeToken, verifyNonce };
721
+ }
722
+
723
+ // ─── verify-email (POST, anonymous, nonce-gated) ───────────────────────────
724
+
725
+ /**
726
+ * THE NONCE HAS NO EXPIRY, and shares the resume token's lifetime exactly: it
727
+ * lives until `submit` clears both digests, and an unsubmitted draft is never
728
+ * swept (`DRAFT_HOLDS_A_SLOT_DAYS` bounds the QUOTA a draft occupies, not the
729
+ * row). A resume email sitting in a mailbox for a year still verifies that
730
+ * draft a year later.
731
+ *
732
+ * That is acceptable rather than overlooked, and for one reason: the same
733
+ * message carries the resume token, so anyone who can read it can already
734
+ * resume and submit the draft outright. A TTL would have to cover both
735
+ * credentials to mean anything, and that is a change to #5048's resume
736
+ * contract rather than to this column.
737
+ *
738
+ * It is also NOT cleared on a successful stamp — the fallback read below
739
+ * needs it to answer a repeat call idempotently.
740
+ */
741
+ export type VerifyProjectListingDraftEmailResult =
742
+ | { ok: true; value: { id: string; emailVerifiedAt: Date } }
743
+ | { ok: false; error: { kind: "not_found"; message: string } };
744
+
745
+ /**
746
+ * Record that somebody who can read mail at `founderEmail` opened this
747
+ * draft's emailed link (#5407).
748
+ *
749
+ * THE PRECISION IN THAT SENTENCE IS DELIBERATE. It is evidence about the
750
+ * ADDRESS, not about the person driving the draft. Whoever posted `start`
751
+ * chose the address and holds the resume token, so an attacker can open a
752
+ * draft under a stranger's address and the platform will mail that stranger a
753
+ * link; if they click it, the attacker's draft is stamped. Binding the stamp
754
+ * to the application's CONTENT, or to `ownerUserId`, is what would close that,
755
+ * and neither is in this issue. What the stamp rules out is the case it was
756
+ * built for: a stamp obtained with no mailbox involved at all.
757
+ *
758
+ * WHAT IT PROVES, and why it takes TWO credentials. The caller must present
759
+ * both the draft's resume token and its verification NONCE, and only the
760
+ * second one carries the proof:
761
+ *
762
+ * - the RESUME TOKEN says "you opened this draft". `start` returns it in
763
+ * its own 201 body, so anyone who posted the form has it. On its own it
764
+ * proves nothing about an address, and a design that stamped on it alone
765
+ * would be forgeable in two HTTP calls — open a draft under a stranger's
766
+ * address, take the token out of the response, present it back. That is
767
+ * the design this one replaced, caught by its own security review.
768
+ * - the NONCE says "you read mail sent to `founderEmail`". It is minted at
769
+ * `start`, digested onto the row, and handed to nothing but
770
+ * `sendListingResumeLink`. It is in no response body and no log line, and
771
+ * the only place it exists outside this server is the `?verify=`
772
+ * parameter of the link inside the email.
773
+ *
774
+ * The token stays in the guard anyway: it costs one term in a `where` and it
775
+ * means a nonce that somehow leaked alone still opens nothing.
776
+ *
777
+ * What it does NOT say is who the founder is. `ownerUserId` (#5295) is the
778
+ * column that carries an identity claim and still needs a signed-in user
779
+ * whose own address the auth system verified.
780
+ *
781
+ * WRITE-ONCE, BY COMPARE-AND-SWAP. `emailVerifiedAt: null` is in the `where`,
782
+ * so the stamp is the atomic transition and two concurrent openings of the
783
+ * same link produce one stamp between them rather than a last-writer-wins
784
+ * race. A repeat opening reads back the FIRST stamp, so the call is
785
+ * idempotent in its effect and in its answer. It is not indistinguishable —
786
+ * a repeat answers an older timestamp than "now" — and does not need to be:
787
+ * only a holder of both credentials can make the call at all, and they are
788
+ * the one person entitled to know when their own link was first opened.
789
+ *
790
+ * ONE ANSWER FOR EVERY FAILURE. A wrong token, a wrong nonce, a credential
791
+ * bound to a different listing, an unknown id and an already-submitted row
792
+ * are all `not_found` — exactly what `patch` and `submit` answer. The
793
+ * anonymous surface must not distinguish "exists but not yours" from "does
794
+ * not exist", and a verification route least of all: its whole input is a
795
+ * guessable-in-principle id and two secrets, so a distinguishable response
796
+ * would turn it into an oracle for which drafts exist and, worse, into a
797
+ * nonce-guessing feedback channel.
798
+ *
799
+ * Neither credential is ever logged, returned, or compared in application
800
+ * code — only their sha-256 digests reach the query.
801
+ */
802
+ export async function verifyProjectListingDraftEmail(
803
+ id: string,
804
+ resumeToken: string,
805
+ verifyNonce: string,
806
+ ): Promise<VerifyProjectListingDraftEmailResult> {
807
+ const guard = {
808
+ id,
809
+ status: "draft",
810
+ resumeTokenHash: hashResumeToken(resumeToken),
811
+ emailVerifyNonceHash: hashVerifyNonce(verifyNonce),
812
+ };
813
+ const emailVerifiedAt = new Date();
814
+
815
+ const stamped = await prisma.projectListing.updateMany({
816
+ where: { ...guard, emailVerifiedAt: null },
817
+ data: { emailVerifiedAt },
465
818
  });
819
+ if (stamped.count > 0) return { ok: true, value: { id, emailVerifiedAt } };
466
820
 
467
- return { ok: true, id: row.id, resumeToken };
821
+ // Zero rows means one of two things, and they answer differently: the draft
822
+ // is already stamped (idempotent success, carrying the FIRST stamp), or the
823
+ // credentials do not open this draft (`not_found`). This read is what tells
824
+ // them apart, and it is gated by the same four terms the write was — so it
825
+ // cannot report a draft the caller's credentials do not open.
826
+ const row = await prisma.projectListing.findFirst({
827
+ where: guard,
828
+ select: { id: true, emailVerifiedAt: true },
829
+ });
830
+ if (row?.emailVerifiedAt) {
831
+ return { ok: true, value: { id: row.id, emailVerifiedAt: row.emailVerifiedAt } };
832
+ }
833
+ return { ok: false, error: { kind: "not_found", message: "Draft not found" } };
468
834
  }
469
835
 
470
836
  // ─── patch (PATCH, anonymous, token-gated) ──────────────────────────────────
@@ -478,15 +844,47 @@ export type PatchProjectListingDraftResult =
478
844
  | { kind: "invalid_input"; message: string };
479
845
  };
480
846
 
847
+ /**
848
+ * What `draftUpdateData` cannot read off the patch body (#5285).
849
+ *
850
+ * Both members are the SERVER's, and that is why they are a second argument
851
+ * rather than two more payload fields: `windowDays` is the applicant's
852
+ * request already clamped against the site's own maximum, and
853
+ * `storedSiteAnswers` is what is on the row, which the merge needs and a
854
+ * request body must never be able to supply.
855
+ */
856
+ interface DraftUpdateContext {
857
+ /** The clamped window, when the patch asked for one. */
858
+ windowDays?: number;
859
+ /** The answers already stored, for the merge. */
860
+ storedSiteAnswers?: Record<string, string>;
861
+ }
862
+
481
863
  /**
482
864
  * Translate a validated partial application into a Prisma update.
483
865
  *
484
866
  * Only the keys the caller actually sent are written: an absent key is
485
867
  * `undefined`, which Prisma omits from the UPDATE, so patching one field
486
868
  * cannot blank the nine the founder filled in on earlier steps.
869
+ *
870
+ * #5279 — THIS IS WHERE THE DISPLAY ADDRESS IS COMPOSED, and the only place.
871
+ * `address` is a union of the legacy display STRING and the structured PARTS
872
+ * (`flowAddressValueSchema`), and the two branches write different columns:
873
+ *
874
+ * string → `address` verbatim, no part column touched. A founder who typed
875
+ * a line gets that line stored exactly as typed, and a draft
876
+ * written before #5279 keeps patching as it always did.
877
+ * parts → the six part columns, PLUS `address` composed from them by
878
+ * `composeAddressLine`.
879
+ *
880
+ * The derivation runs ONE WAY ONLY. Nothing anywhere derives parts from a
881
+ * line: address parsing guesses, and a wrong guess silently corrupts the
882
+ * party identification a listing's privacy notice points at (#5036 D6), with
883
+ * nothing on the row to mark it as invented.
487
884
  */
488
885
  function draftUpdateData(
489
886
  patch: ProjectListingDraftPatchInput,
887
+ context: DraftUpdateContext,
490
888
  ): Prisma.ProjectListingUpdateInput {
491
889
  return {
492
890
  ...(patch.name !== undefined && { name: patch.name }),
@@ -499,11 +897,45 @@ function draftUpdateData(
499
897
  ...(patch.teamComposition !== undefined && { teamComposition: patch.teamComposition }),
500
898
  ...(patch.faqs !== undefined && { faqs: patch.faqs as unknown as Prisma.InputJsonValue }),
501
899
  ...(patch.anythingElse !== undefined && { anythingElse: patch.anythingElse }),
502
- ...(patch.address !== undefined && { address: patch.address }),
900
+ ...(patch.address !== undefined &&
901
+ (typeof patch.address === "string"
902
+ ? { address: patch.address }
903
+ : {
904
+ address: composeAddressLine(patch.address),
905
+ addressLine1: patch.address.addressLine1,
906
+ addressLine2: patch.address.addressLine2 ?? null,
907
+ addressCity: patch.address.city,
908
+ addressRegion: patch.address.region,
909
+ addressPostalCode: patch.address.postalCode,
910
+ addressCountry: patch.address.country,
911
+ })),
503
912
  ...(patch.founderCompany !== undefined && { founderCompany: patch.founderCompany }),
504
913
  ...(patch.questions !== undefined && {
505
914
  questions: patch.questions as unknown as Prisma.InputJsonValue,
506
915
  }),
916
+ ...(patch.logoMarkUrl !== undefined && { logoMarkUrl: patch.logoMarkUrl }),
917
+ ...(patch.logoWordmarkUrl !== undefined && {
918
+ logoWordmarkUrl: patch.logoWordmarkUrl,
919
+ }),
920
+ // #5285 — the applicant's REQUEST, already clamped by the caller against
921
+ // the site's own `maxWindowDays`. It is written to `windowDays`, the same
922
+ // column `start` writes, because there is one window per listing and the
923
+ // screen that asks for it is the site's choice of screen, not a second
924
+ // field. Clamped and not refused, for the reason `startProjectListingDraft`
925
+ // gives: a founder who could post their own bound could post an unbounded
926
+ // one.
927
+ ...(context.windowDays !== undefined && { windowDays: context.windowDays }),
928
+ // #5285 — MERGED, never replaced. A founder patches one screen at a time,
929
+ // so a body carrying screen four's two answers must not blank screen
930
+ // two's. The incoming keys win; everything already stored is carried.
931
+ // Every incoming id was checked against the site's declarations by the
932
+ // compiled patch schema, so nothing undeclared can enter here.
933
+ ...(patch.siteAnswers !== undefined && {
934
+ siteAnswers: {
935
+ ...context.storedSiteAnswers,
936
+ ...patch.siteAnswers,
937
+ } as Prisma.InputJsonValue,
938
+ }),
507
939
  };
508
940
  }
509
941
 
@@ -526,7 +958,13 @@ export async function patchProjectListingDraft(
526
958
  // still answers `not_found` before any body is inspected.
527
959
  const owner = await prisma.projectListing.findFirst({
528
960
  where: { id, status: "draft", resumeTokenHash: hashResumeToken(resumeToken) },
529
- select: { siteId: true },
961
+ // #5285 `siteAnswers` rides this read because the write MERGES into it
962
+ // (a founder patches one screen at a time). It is still not the write's
963
+ // guard: the compare-and-swap below is, and a concurrent patch of a
964
+ // DIFFERENT screen can still lose its answers to this one, exactly as it
965
+ // can already lose a column. The store is one row's worth of one
966
+ // founder's own form, not a shared document.
967
+ select: { siteId: true, siteAnswers: true },
530
968
  });
531
969
  if (!owner) {
532
970
  return { ok: false, error: { kind: "not_found", message: "Draft not found" } };
@@ -536,6 +974,7 @@ export async function patchProjectListingDraft(
536
974
  const parsed = buildProjectListingDraftPatchSchema(
537
975
  listingConfig.wordCaps,
538
976
  listingConfig.collectedFields,
977
+ listingConfig.siteAnswers,
539
978
  ).safeParse(body);
540
979
  if (!parsed.success) {
541
980
  return {
@@ -548,6 +987,21 @@ export async function patchProjectListingDraft(
548
987
  }
549
988
  const patch: ProjectListingDraftPatchInput = parsed.data;
550
989
 
990
+ const stored = siteAnswersFromRow(owner.siteAnswers);
991
+ const context: DraftUpdateContext = {
992
+ // THE CLAMP, and the reason `requestedWindowDays` is safe to accept from
993
+ // an anonymous body at all (#5285). The site's `maxWindowDays` is read
994
+ // server-side and caps everything; the payload carries a REQUEST. A
995
+ // founder who could post their own bound could post an unbounded one, and
996
+ // this is the only place a patched window reaches a column.
997
+ ...(patch.requestedWindowDays !== undefined && {
998
+ windowDays: clampWindowDays(patch.requestedWindowDays, listingConfig),
999
+ }),
1000
+ // What is on the row already, so the merge below adds to it rather than
1001
+ // replacing it. It comes from the ROW and never from the body.
1002
+ ...(stored !== null && { storedSiteAnswers: stored }),
1003
+ };
1004
+
551
1005
  // A GUARDED `updateMany`, not a read-then-write: the token check and the
552
1006
  // write are one statement, so a concurrent `submit` cannot flip the row to
553
1007
  // `requested` in between and let this patch land on an application the
@@ -555,7 +1009,7 @@ export async function patchProjectListingDraft(
555
1009
  // same compare-and-swap shape `submit` and `decideListing` already use.
556
1010
  const updated = await prisma.projectListing.updateMany({
557
1011
  where: { id, status: "draft", resumeTokenHash: hashResumeToken(resumeToken) },
558
- data: draftUpdateData(patch),
1012
+ data: draftUpdateData(patch, context),
559
1013
  });
560
1014
  if (updated.count === 0) {
561
1015
  return { ok: false, error: { kind: "not_found", message: "Draft not found" } };
@@ -613,6 +1067,15 @@ function applicationFromRow(
613
1067
  address: row.address,
614
1068
  ...(row.founderCompany !== null && { founderCompany: row.founderCompany }),
615
1069
  questions: row.questions,
1070
+ // #5285 — the three fields that became writable. Each is optional in the
1071
+ // application schema, so a row that never carried one simply omits it and
1072
+ // `submit` is unaffected. `requestedWindowDays` reads back the CLAMPED
1073
+ // `windowDays` the row actually holds, not what was asked for: the
1074
+ // request is not stored anywhere, and re-asserting it would be asserting
1075
+ // a number nothing ever agreed to.
1076
+ ...(row.logoMarkUrl !== null && { logoMarkUrl: row.logoMarkUrl }),
1077
+ ...(row.logoWordmarkUrl !== null && { logoWordmarkUrl: row.logoWordmarkUrl }),
1078
+ ...(row.windowDays !== null && { requestedWindowDays: row.windowDays }),
616
1079
  };
617
1080
  return Object.fromEntries(
618
1081
  Object.entries(all).filter(([key]) => collected.has(key)),
@@ -754,7 +1217,15 @@ export async function submitProjectListingDraft(
754
1217
  try {
755
1218
  const updated = await prisma.projectListing.updateMany({
756
1219
  where: { id, status: "draft" },
757
- data: { status: "requested", slug: candidate, resumeTokenHash: null },
1220
+ // #5407 the verification nonce is cleared here too, for the reason
1221
+ // the resume-token digest is: a credential that leaks after the draft
1222
+ // stops being a draft is inert twice over.
1223
+ data: {
1224
+ status: "requested",
1225
+ slug: candidate,
1226
+ resumeTokenHash: null,
1227
+ emailVerifyNonceHash: null,
1228
+ },
758
1229
  });
759
1230
  return updated.count;
760
1231
  } catch (err) {
@@ -879,6 +1350,88 @@ export async function getProjectListing(siteId: string, id: string): Promise<Get
879
1350
  return { ok: true, value: toResponseRow(row, waitlistCountFor(row, counts), new Date(), true) };
880
1351
  }
881
1352
 
1353
+ // ─── The founder's own listings (auth scope; #5295) ─────────────────────────
1354
+
1355
+ /**
1356
+ * The listings the CALLING USER owns, newest first.
1357
+ *
1358
+ * The join is the owner link and ONLY the owner link:
1359
+ * `ownerUserId === userId`, the scalar `listing-promotion.ts` stamps once that
1360
+ * user's own email is verified, matches the row's `founderEmail`, and the row's
1361
+ * site resolves to one of the user's own workspaces. `founderEmail` is NEVER
1362
+ * compared here, at any stage — an un-linked listing that happens to carry the
1363
+ * caller's address is not theirs until a promotion said so, and a read-time
1364
+ * email match would reintroduce exactly the cross-founder capture the link
1365
+ * exists to remove. (`flow-checkouts.ts` draws the same line for `Lead`.)
1366
+ *
1367
+ * No `siteId` parameter and no `currentSiteId()` scope, deliberately — and the
1368
+ * reason is NOT that the two can never coincide. `siteIdForPublicKey`
1369
+ * (`listing-config.ts`) does fall back to `currentSiteId()`, so a listing's
1370
+ * `siteId` CAN be `AILK_SITE_ID` or the literal `"default"` on a single-tenant
1371
+ * deployment. The reason is that the owner link is already the narrower scope:
1372
+ * it is only ever stamped for a site inside the user's own tenancy
1373
+ * (`listing-promotion.ts`, condition 3), so adding a site term could only
1374
+ * remove rows the caller owns — and on the multi-tenant shape, where a
1375
+ * listing's `siteId` is a WORKSPACE id rather than `AILK_SITE_ID`, it would
1376
+ * remove all of them. The response omits `siteId` regardless, so no workspace
1377
+ * id leaves the read.
1378
+ *
1379
+ * The one thing this gives up against the sibling `/me` routes: on a database
1380
+ * shared by more than one site, a caller's rows from every such site come back
1381
+ * together. They are all the caller's own rows, which is why that is acceptable
1382
+ * here and not a disclosure.
1383
+ *
1384
+ * No verified-email re-check either. `flow-checkouts.ts` performs one as
1385
+ * defence in depth beside its email-matching sibling
1386
+ * (`waitlist-signups.ts`, where the gate is load-bearing because the match IS
1387
+ * an email). Here the link is the proof, and it was only issuable under
1388
+ * verification — so a re-check adds no safety, while failing a caller whose
1389
+ * address later changed to an unverified one would wrongly hide the rows they
1390
+ * already proved they own.
1391
+ *
1392
+ * `waitlistCount` is resolved PER SITE: the count is keyed by
1393
+ * `(siteId, experimentKey)`, and a founder may own listings in more than one of
1394
+ * their own workspaces, so counting them all under one site id would report
1395
+ * another site's number.
1396
+ *
1397
+ * An empty array for a caller who owns nothing is the normal answer, not an
1398
+ * error.
1399
+ */
1400
+ export async function listMyProjectListings(userId: string): Promise<ProjectListing[]> {
1401
+ const rows = await prisma.projectListing.findMany({
1402
+ where: { ownerUserId: userId },
1403
+ orderBy: { createdAt: "desc" },
1404
+ select: PROJECT_LISTING_SELECT,
1405
+ });
1406
+ if (rows.length === 0) return [];
1407
+
1408
+ const rowsBySite = new Map<string, ProjectListingRow[]>();
1409
+ for (const row of rows) {
1410
+ const forSite = rowsBySite.get(row.siteId);
1411
+ if (forSite) forSite.push(row);
1412
+ else rowsBySite.set(row.siteId, [row]);
1413
+ }
1414
+ const countsBySite = new Map<string, Map<string, number>>();
1415
+ await Promise.all(
1416
+ [...rowsBySite].map(async ([siteId, siteRows]) => {
1417
+ countsBySite.set(siteId, await attachWaitlistCounts(siteId, siteRows));
1418
+ }),
1419
+ );
1420
+
1421
+ const now = new Date();
1422
+ // `featuredEnabled: true` — the founder's own row, like the operator's
1423
+ // dashboard row, reports the flag as stored. It is the PUBLIC shelf the
1424
+ // site's config switch governs, not what the row's owner may see.
1425
+ return rows.map((row) =>
1426
+ toResponseRow(
1427
+ row,
1428
+ waitlistCountFor(row, countsBySite.get(row.siteId) ?? new Map()),
1429
+ now,
1430
+ true,
1431
+ ),
1432
+ );
1433
+ }
1434
+
882
1435
  // ─── Patch (tenant scope; the featured flag) ───────────────────────────────
883
1436
 
884
1437
  export type SetListingFeaturedResult =
@@ -1113,12 +1666,32 @@ export async function publicProjectListings(siteId: string): Promise<ProjectList
1113
1666
 
1114
1667
  const now = new Date();
1115
1668
  return [...top3, ...rest].map(({ row, waitlistCount }) => {
1116
- const { founderEmail: _founderEmail, ...publicRow } = toResponseRow(
1669
+ // #5407 `founderPhone` and `emailVerifiedAt` leave here with
1670
+ // `founderEmail`. `projectListingPublicSchema` omits all three and
1671
+ // `sendValidated` re-parses `.strict()`, so a key that stopped being
1672
+ // dropped here would 500 the public read rather than quietly publishing a
1673
+ // founder's phone number — which is the failure mode worth having.
1674
+ const {
1675
+ founderEmail: _founderEmail,
1676
+ founderPhone: _founderPhone,
1677
+ emailVerifiedAt: _emailVerifiedAt,
1678
+ ...publicRow
1679
+ } = toResponseRow(
1117
1680
  row,
1118
1681
  waitlistCount,
1119
1682
  now,
1120
1683
  listingConfig.featuredEnabled,
1121
1684
  );
1122
- return publicRow;
1685
+ // #5285 — the `public`-visibility answers ONLY. An `operator-only` answer
1686
+ // is something a site asked for its own triage, and a founder who was
1687
+ // told it would not be published has to be right about that; an answer
1688
+ // whose id the site no longer declares is dropped for the same reason,
1689
+ // because "no declaration" cannot be read as "public" on the read that
1690
+ // publishes. The tenant dashboard and the review payload are the audience
1691
+ // that sees the rest, and they do not come through here.
1692
+ return {
1693
+ ...publicRow,
1694
+ siteAnswers: publicSiteAnswers(publicRow.siteAnswers, listingConfig.siteAnswers),
1695
+ };
1123
1696
  });
1124
1697
  }