create-ailk 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (273) hide show
  1. package/component-catalog.md +50 -53
  2. package/dist/lib/apply-module-patches.d.ts +47 -0
  3. package/dist/lib/apply-module-patches.js +370 -0
  4. package/dist/lib/extract-module-bundle.d.ts +12 -3
  5. package/dist/lib/extract-module-bundle.js +236 -24
  6. package/dist/lib/fetch-module.d.ts +3 -38
  7. package/dist/lib/fetch-module.js +26 -8
  8. package/dist/lib/module-license-gate.d.ts +2 -0
  9. package/dist/lib/module-license-gate.js +1 -0
  10. package/dist/lib/paid-paths.d.ts +16 -0
  11. package/dist/lib/paid-paths.js +90 -0
  12. package/dist/module-architecture.d.ts +139 -23
  13. package/dist/module-architecture.js +668 -25
  14. package/dist/parse-args.d.ts +2 -2
  15. package/dist/parse-args.js +3 -1
  16. package/dist/programmatic.d.ts +2 -1
  17. package/dist/programmatic.js +15 -4
  18. package/dist/surfaces.d.ts +12 -36
  19. package/dist/surfaces.js +35 -5
  20. package/dist/sync-routes.js +31 -1
  21. package/package.json +2 -2
  22. package/templates/.claude/agents/web.md +2 -3
  23. package/templates/.claude/rules/architecture.md +5 -5
  24. package/templates/.claude/skills/README.md +2 -1
  25. package/templates/.env.example +21 -9
  26. package/templates/CONVENTIONS.md +6 -7
  27. package/templates/apps/api/.env.example +9 -0
  28. package/templates/apps/api/CLAUDE.md +33 -5
  29. package/templates/apps/api/package.json +0 -1
  30. package/templates/apps/api/src/__tests__/cors.test.ts +195 -0
  31. package/templates/apps/api/src/__tests__/module-exclusion.test.ts +60 -2
  32. package/templates/apps/api/src/__tests__/vercel-handler.test.ts +159 -0
  33. package/templates/apps/api/src/config/__tests__/modules.test.ts +76 -0
  34. package/templates/apps/api/src/config/index.ts +19 -0
  35. package/templates/apps/api/src/config/modules.ts +12 -14
  36. package/templates/apps/api/src/lib/__mocks__/prisma.ts +3 -0
  37. package/templates/apps/api/src/lib/__tests__/slice-load.test.ts +89 -0
  38. package/templates/apps/api/src/lib/slice-load.ts +90 -0
  39. package/templates/apps/api/src/openapi/__tests__/openapi.test.ts +52 -32
  40. package/templates/apps/api/src/openapi/__tests__/spec-identity.test.ts +182 -0
  41. package/templates/apps/api/src/openapi/__tests__/surface-drift.test.ts +293 -0
  42. package/templates/apps/api/src/openapi/index.ts +9 -3
  43. package/templates/apps/api/src/openapi/spec.ts +505 -65
  44. package/templates/apps/api/src/openapi/surface-drift.ts +195 -0
  45. package/templates/apps/api/src/routes/content/__tests__/create.test.ts +14 -0
  46. package/templates/apps/api/src/routes/content/__tests__/delete.test.ts +14 -0
  47. package/templates/apps/api/src/routes/content/__tests__/update.test.ts +14 -0
  48. package/templates/apps/api/src/routes/content/index.ts +38 -11
  49. package/templates/apps/api/src/routes/project-listings/__tests__/configured-application.test.ts +17 -0
  50. package/templates/apps/api/src/routes/project-listings/__tests__/drafts.test.ts +17 -0
  51. package/templates/apps/api/src/routes/project-listings/__tests__/founder-identity.test.ts +796 -0
  52. package/templates/apps/api/src/routes/project-listings/__tests__/me.test.ts +10 -0
  53. package/templates/apps/api/src/routes/project-listings/__tests__/public.test.ts +38 -0
  54. package/templates/apps/api/src/routes/project-listings/__tests__/resume-email.test.ts +814 -0
  55. package/templates/apps/api/src/routes/project-listings/__tests__/site-answers.test.ts +10 -0
  56. package/templates/apps/api/src/routes/project-listings/__tests__/site-key.test.ts +24 -0
  57. package/templates/apps/api/src/routes/project-listings/__tests__/structured-address.test.ts +17 -0
  58. package/templates/apps/api/src/routes/project-listings/index.ts +37 -10
  59. package/templates/apps/api/src/routes/project-listings/me.ts +10 -0
  60. package/templates/apps/api/src/routes/project-listings/patch-draft.ts +5 -0
  61. package/templates/apps/api/src/routes/project-listings/public.ts +5 -0
  62. package/templates/apps/api/src/routes/project-listings/respond.ts +11 -0
  63. package/templates/apps/api/src/routes/project-listings/resume-token.ts +5 -0
  64. package/templates/apps/api/src/routes/project-listings/start.ts +30 -0
  65. package/templates/apps/api/src/routes/project-listings/submit.ts +5 -0
  66. package/templates/apps/api/src/routes/project-listings/verify-email.ts +150 -0
  67. package/templates/apps/api/src/server.ts +459 -135
  68. package/templates/apps/api/src/services/deliverable-fulfillment.ts +10 -0
  69. package/templates/apps/api/src/services/listing-config.ts +58 -0
  70. package/templates/apps/api/src/services/project-listing-resume-email.ts +326 -0
  71. package/templates/apps/api/src/services/project-listings.ts +229 -4
  72. package/templates/apps/api/src/vercel-handler.ts +60 -26
  73. package/templates/apps/mcp/.env.example +8 -0
  74. package/templates/apps/mcp/CLAUDE.md +2 -2
  75. package/templates/apps/mcp/__tests__/catalog-drift.test.ts +21 -0
  76. package/templates/apps/mcp/__tests__/config/modules.test.ts +78 -0
  77. package/templates/apps/mcp/__tests__/module-exclusion.test.ts +1 -1
  78. package/templates/apps/mcp/src/config/modules.ts +14 -13
  79. package/templates/apps/mcp/src/server.ts +8 -4
  80. package/templates/apps/mcp/src/tools/index.ts +7 -22
  81. package/templates/apps/web/.env.example +15 -0
  82. package/templates/apps/web/app/[locale]/(authed)/{waitlist/__tests__ → __tests__}/gate.test.tsx +4 -4
  83. package/templates/apps/web/app/[locale]/flows/[slug]/FlowStepperClient.tsx +2 -1
  84. package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/FlowStepperClient.permalink.test.tsx +2 -1
  85. package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/FlowStepperClient.test.tsx +2 -1
  86. package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/flow.actions.test.ts +1 -0
  87. package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/two-route-tool.test.tsx +1 -0
  88. package/templates/apps/web/app/[locale]/flows/[slug]/flow.actions.ts +2 -1
  89. package/templates/apps/web/app/[locale]/flows/[slug]/page.tsx +1 -0
  90. package/templates/apps/web/app/[locale]/flows/[slug]/start/page.tsx +1 -0
  91. package/templates/apps/web/app/llms.txt/route.ts +12 -9
  92. package/templates/apps/web/jest.config.cjs +14 -13
  93. package/templates/apps/web/next.config.mjs +1 -3
  94. package/templates/apps/web/package.json +0 -6
  95. package/templates/apps/web/public/android-chrome-192x192.png +0 -0
  96. package/templates/apps/web/public/android-chrome-512x512.png +0 -0
  97. package/templates/apps/web/public/apple-touch-icon.png +0 -0
  98. package/templates/apps/web/public/favicon-16x16.png +0 -0
  99. package/templates/apps/web/public/favicon-32x32.png +0 -0
  100. package/templates/apps/web/public/favicon.ico +0 -0
  101. package/templates/apps/web/public/favicon.svg +6 -3
  102. package/templates/apps/web/public/lockup-horizontal.svg +4 -0
  103. package/templates/apps/web/public/logomark.svg +4 -0
  104. package/templates/apps/web/public/site.webmanifest +2 -2
  105. package/templates/apps/web/public/wordmark.svg +4 -0
  106. package/templates/database/CHANGELOG.md +33 -0
  107. package/templates/database/inbox/schema.prisma +67 -0
  108. package/templates/database/migrations/20260914120000_listing_founder_identity/migration.sql +123 -0
  109. package/templates/database/package.json +1 -1
  110. package/templates/package.json +1 -1
  111. package/templates/.claude/skills/scaffold-commerce/SKILL.md +0 -807
  112. package/templates/.claude/skills/scaffold-commerce/references/lookup-keys-template.md +0 -71
  113. package/templates/.claude/skills/scaffold-commerce/references/marketplace-brand-pages.md +0 -83
  114. package/templates/.claude/skills/scaffold-commerce/templates/checkout-route.template.ts +0 -370
  115. package/templates/.claude/skills/scaffold-commerce/templates/invoice-route.template.ts +0 -376
  116. package/templates/.claude/skills/scaffold-commerce/templates/marketplace-cta-route.tsx.tmpl +0 -72
  117. package/templates/.claude/skills/scaffold-commerce/templates/payment-link-route.template.ts +0 -471
  118. package/templates/.claude/skills/scaffold-commerce/templates/portal-route.template.ts +0 -365
  119. package/templates/.claude/skills/scaffold-commerce/templates/stripe-config.template.ts +0 -60
  120. package/templates/.claude/skills/scaffold-commerce/templates/subscription-route.template.ts +0 -446
  121. package/templates/apps/api/src/__tests__/server.test.ts +0 -253
  122. package/templates/apps/api/src/bin/deliverable-resend.ts +0 -144
  123. package/templates/apps/api/src/bin/deliverable-upload.ts +0 -114
  124. package/templates/apps/api/src/bin/followup-sweep.ts +0 -44
  125. package/templates/apps/api/src/bin/listing-csv-sweep.ts +0 -46
  126. package/templates/apps/api/src/bin/seed-presets.ts +0 -58
  127. package/templates/apps/api/src/bin/seed-waitlist-experiments.ts +0 -104
  128. package/templates/apps/api/src/bin/session-retention-sweep.ts +0 -46
  129. package/templates/apps/api/src/lib/__tests__/stripe.test.ts +0 -74
  130. package/templates/apps/api/src/lib/stripe.ts +0 -52
  131. package/templates/apps/api/src/lib/tenant-db.ts +0 -225
  132. package/templates/apps/api/src/lib/ws-token.ts +0 -91
  133. package/templates/apps/api/src/middleware/tenant.ts +0 -99
  134. package/templates/apps/api/src/routes/billing/__tests__/portal.test.ts +0 -450
  135. package/templates/apps/api/src/routes/billing/__tests__/read.test.ts +0 -262
  136. package/templates/apps/api/src/routes/billing/__tests__/usage.test.ts +0 -436
  137. package/templates/apps/api/src/routes/billing/index.ts +0 -36
  138. package/templates/apps/api/src/routes/billing/portal.ts +0 -182
  139. package/templates/apps/api/src/routes/billing/read.ts +0 -75
  140. package/templates/apps/api/src/routes/billing/usage.ts +0 -153
  141. package/templates/apps/api/src/routes/checkout/__tests__/sessions.test.ts +0 -687
  142. package/templates/apps/api/src/routes/checkout/index.ts +0 -13
  143. package/templates/apps/api/src/routes/checkout/sessions.ts +0 -197
  144. package/templates/apps/api/src/routes/deliverables/__tests__/index.test.ts +0 -393
  145. package/templates/apps/api/src/routes/deliverables/index.ts +0 -200
  146. package/templates/apps/api/src/routes/flow-checkouts/__tests__/index.test.ts +0 -443
  147. package/templates/apps/api/src/routes/flow-checkouts/index.ts +0 -82
  148. package/templates/apps/api/src/routes/flows/README.md +0 -147
  149. package/templates/apps/api/src/routes/flows/__tests__/index.test.ts +0 -752
  150. package/templates/apps/api/src/routes/flows/__tests__/recommender.test.ts +0 -671
  151. package/templates/apps/api/src/routes/flows/index.ts +0 -202
  152. package/templates/apps/api/src/routes/flows/recommender.ts +0 -443
  153. package/templates/apps/api/src/routes/project-listings/__tests__/copy-edit.test.ts +0 -815
  154. package/templates/apps/api/src/routes/project-listings/__tests__/me-route-precedence.test.ts +0 -221
  155. package/templates/apps/api/src/routes/project-listings/__tests__/tenant-isolation.test.ts +0 -566
  156. package/templates/apps/api/src/routes/project-listings/get.ts +0 -70
  157. package/templates/apps/api/src/routes/project-listings/list.ts +0 -47
  158. package/templates/apps/api/src/routes/project-listings/patch.ts +0 -136
  159. package/templates/apps/api/src/routes/schedule/__tests__/index.test.ts +0 -490
  160. package/templates/apps/api/src/routes/schedule/index.ts +0 -249
  161. package/templates/apps/api/src/routes/slack/__tests__/actions.test.ts +0 -385
  162. package/templates/apps/api/src/routes/slack/actions.ts +0 -177
  163. package/templates/apps/api/src/routes/slack/index.ts +0 -38
  164. package/templates/apps/api/src/routes/waitlist-experiments/__tests__/tenant-isolation.test.ts +0 -577
  165. package/templates/apps/api/src/routes/waitlist-experiments/comparison.ts +0 -78
  166. package/templates/apps/api/src/routes/waitlist-experiments/index.ts +0 -41
  167. package/templates/apps/api/src/routes/waitlist-experiments/list.ts +0 -64
  168. package/templates/apps/api/src/routes/waitlist-experiments/respond.ts +0 -42
  169. package/templates/apps/api/src/routes/waitlist-experiments/signup.ts +0 -84
  170. package/templates/apps/api/src/routes/waitlist-experiments/signups.ts +0 -119
  171. package/templates/apps/api/src/routes/waitlist-signups/__tests__/index.test.ts +0 -238
  172. package/templates/apps/api/src/routes/waitlist-signups/index.ts +0 -101
  173. package/templates/apps/api/src/routes/webhooks/README.md +0 -80
  174. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-flow-checkout.test.ts +0 -315
  175. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-idempotency.test.ts +0 -247
  176. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-org-billing.test.ts +0 -270
  177. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-rate-limit.test.ts +0 -198
  178. package/templates/apps/api/src/routes/webhooks/__tests__/stripe.test.ts +0 -633
  179. package/templates/apps/api/src/routes/webhooks/index.ts +0 -41
  180. package/templates/apps/api/src/routes/webhooks/stripe.ts +0 -481
  181. package/templates/apps/api/src/routes/workspaces/__tests__/config-tenant-isolation.test.ts +0 -356
  182. package/templates/apps/api/src/routes/workspaces/__tests__/tenant-isolation.test.ts +0 -671
  183. package/templates/apps/api/src/routes/workspaces/config.ts +0 -125
  184. package/templates/apps/api/src/routes/workspaces/index.ts +0 -194
  185. package/templates/apps/api/src/routes/workspaces/sessions.ts +0 -218
  186. package/templates/apps/api/src/services/__tests__/flow-aggregate.test.ts +0 -425
  187. package/templates/apps/api/src/services/__tests__/flow-engine.test.ts +0 -2840
  188. package/templates/apps/api/src/services/__tests__/lead-promotion.test.ts +0 -393
  189. package/templates/apps/api/src/services/__tests__/listing-csv-sweep.test.ts +0 -560
  190. package/templates/apps/api/src/services/__tests__/listing-promotion.test.ts +0 -684
  191. package/templates/apps/api/src/services/__tests__/playbook-compile.test.ts +0 -406
  192. package/templates/apps/api/src/services/__tests__/playbook-render.test.ts +0 -290
  193. package/templates/apps/api/src/services/__tests__/project-listing-decision.test.ts +0 -736
  194. package/templates/apps/api/src/services/__tests__/project-listing-flow.test.ts +0 -475
  195. package/templates/apps/api/src/services/__tests__/project-listing-issue.test.ts +0 -340
  196. package/templates/apps/api/src/services/__tests__/recommender-capture.test.ts +0 -983
  197. package/templates/apps/api/src/services/__tests__/tenant-context-cascade.test.ts +0 -71
  198. package/templates/apps/api/src/services/__tests__/tenant-context.test.ts +0 -379
  199. package/templates/apps/api/src/services/__tests__/usage-metering.test.ts +0 -768
  200. package/templates/apps/api/src/services/__tests__/waitlist-dashboard.test.ts +0 -1314
  201. package/templates/apps/api/src/services/__tests__/waitlist-experiments.test.ts +0 -341
  202. package/templates/apps/api/src/services/__tests__/waitlist-followup.test.ts +0 -567
  203. package/templates/apps/api/src/services/__tests__/waitlist-scoring.test.ts +0 -474
  204. package/templates/apps/api/src/services/__tests__/waitlist-signups.test.ts +0 -354
  205. package/templates/apps/api/src/services/flow-aggregate.ts +0 -232
  206. package/templates/apps/api/src/services/flow-checkouts.ts +0 -123
  207. package/templates/apps/api/src/services/flow-engine.ts +0 -1296
  208. package/templates/apps/api/src/services/lead-promotion.ts +0 -176
  209. package/templates/apps/api/src/services/listing-csv-sweep.ts +0 -455
  210. package/templates/apps/api/src/services/listing-promotion.ts +0 -342
  211. package/templates/apps/api/src/services/playbook-compile.ts +0 -398
  212. package/templates/apps/api/src/services/playbook-render.ts +0 -263
  213. package/templates/apps/api/src/services/project-listing-decision.ts +0 -510
  214. package/templates/apps/api/src/services/project-listing-flow.ts +0 -426
  215. package/templates/apps/api/src/services/project-listing-issue.ts +0 -251
  216. package/templates/apps/api/src/services/recommender-capture.ts +0 -835
  217. package/templates/apps/api/src/services/scheduling/cal-provider.ts +0 -392
  218. package/templates/apps/api/src/services/scheduling/index.ts +0 -63
  219. package/templates/apps/api/src/services/scheduling/types.ts +0 -88
  220. package/templates/apps/api/src/services/tenant-context.ts +0 -451
  221. package/templates/apps/api/src/services/usage-metering.ts +0 -699
  222. package/templates/apps/api/src/services/waitlist-dashboard.ts +0 -947
  223. package/templates/apps/api/src/services/waitlist-experiments.ts +0 -213
  224. package/templates/apps/api/src/services/waitlist-followup.ts +0 -486
  225. package/templates/apps/api/src/services/waitlist-scoring.ts +0 -166
  226. package/templates/apps/api/src/services/waitlist-signups.ts +0 -165
  227. package/templates/apps/mcp/__tests__/schedule_tools.test.ts +0 -242
  228. package/templates/apps/mcp/src/tools/create_booking.ts +0 -58
  229. package/templates/apps/mcp/src/tools/get_event_meta.ts +0 -55
  230. package/templates/apps/mcp/src/tools/get_flow.ts +0 -61
  231. package/templates/apps/mcp/src/tools/list_availability.ts +0 -67
  232. package/templates/apps/mcp/src/tools/submit_flow_step.ts +0 -83
  233. package/templates/apps/web/app/[locale]/(authed)/waitlist/[experimentId]/page.tsx +0 -309
  234. package/templates/apps/web/app/[locale]/(authed)/waitlist/[experimentId]/signups/[leadId]/page.tsx +0 -149
  235. package/templates/apps/web/app/[locale]/(authed)/waitlist/__tests__/comparison.test.tsx +0 -273
  236. package/templates/apps/web/app/[locale]/(authed)/waitlist/__tests__/list.test.tsx +0 -171
  237. package/templates/apps/web/app/[locale]/(authed)/waitlist/__tests__/signup.test.tsx +0 -179
  238. package/templates/apps/web/app/[locale]/(authed)/waitlist/components/ExperimentsTable.tsx +0 -116
  239. package/templates/apps/web/app/[locale]/(authed)/waitlist/components/OfferFunnelTable.tsx +0 -66
  240. package/templates/apps/web/app/[locale]/(authed)/waitlist/components/RollupTicker.tsx +0 -61
  241. package/templates/apps/web/app/[locale]/(authed)/waitlist/components/ScoreTrace.tsx +0 -114
  242. package/templates/apps/web/app/[locale]/(authed)/waitlist/components/SignupsTable.tsx +0 -139
  243. package/templates/apps/web/app/[locale]/(authed)/waitlist/components/WaitlistComparisonTabs.tsx +0 -85
  244. package/templates/apps/web/app/[locale]/(authed)/waitlist/components/status-badges.tsx +0 -67
  245. package/templates/apps/web/app/[locale]/(authed)/waitlist/page.tsx +0 -179
  246. package/templates/apps/web/app/[locale]/blog/[[...slug]]/page.tsx +0 -178
  247. package/templates/apps/web/app/[locale]/dev/purchase/__tests__/actions.test.ts +0 -160
  248. package/templates/apps/web/app/[locale]/dev/purchase/error.tsx +0 -25
  249. package/templates/apps/web/app/[locale]/dev/purchase/page.tsx +0 -117
  250. package/templates/apps/web/app/[locale]/dev/purchase/purchase.actions.ts +0 -58
  251. package/templates/apps/web/app/[locale]/docs/[[...slug]]/page.tsx +0 -119
  252. package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/checkout.actions.test.ts +0 -287
  253. package/templates/apps/web/app/[locale]/flows/[slug]/checkout.actions.ts +0 -254
  254. package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/ResultsClient.tsx +0 -532
  255. package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/ResultsEmailStep.tsx +0 -121
  256. package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/__tests__/ResultsClient.checkout.test.tsx +0 -457
  257. package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/__tests__/page.test.tsx +0 -396
  258. package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/page.tsx +0 -114
  259. package/templates/apps/web/app/[locale]/projects/apply/ListingApplicationClient.tsx +0 -110
  260. package/templates/apps/web/app/[locale]/projects/apply/__tests__/ListingApplicationClient.test.tsx +0 -96
  261. package/templates/apps/web/app/[locale]/schedule/__tests__/page.test.tsx +0 -117
  262. package/templates/apps/web/app/[locale]/schedule/confirmed/__tests__/page.test.tsx +0 -96
  263. package/templates/apps/web/app/[locale]/schedule/confirmed/page.tsx +0 -92
  264. package/templates/apps/web/app/[locale]/schedule/page.tsx +0 -82
  265. package/templates/apps/web/content/en/blog/README.txt +0 -25
  266. package/templates/apps/web/content/en/docs/index.mdx +0 -28
  267. package/templates/apps/web/lib/__mocks__/source-server.js +0 -31
  268. package/templates/apps/web/lib/blog-list-data.ts +0 -95
  269. package/templates/apps/web/lib/blog-post-data.ts +0 -79
  270. package/templates/apps/web/lib/source.ts +0 -34
  271. package/templates/apps/web/public/logomark-dark.svg +0 -4
  272. package/templates/apps/web/public/logomark-light.svg +0 -4
  273. package/templates/apps/web/source.config.ts +0 -78
@@ -1,342 +0,0 @@
1
- /**
2
- * @file listing-promotion.ts
3
- * @description ProjectListing → owner-user linking across the inbox/auth
4
- * namespace boundary (#5295; ADR 0009 D3 service-layer pattern).
5
- *
6
- * The sibling of `lead-promotion.ts`, for the same shape of problem. A
7
- * `ProjectListing` is written on an ANONYMOUS route: `founderEmail` is a string
8
- * a stranger typed into a form, and nothing about typing an address proves you
9
- * own it. So `founderEmail` can never by itself say whose row it is — two
10
- * people who type the same address are two people — and this service
11
- * materializes the ownership claim that a read may actually join on.
12
- *
13
- * The link is stamped only when ALL THREE hold:
14
- *
15
- * 1. the reconciling user's OWN email is VERIFIED (`User.emailVerified`) —
16
- * the consent basis, inherited from `lead-promotion.ts` D2, and a
17
- * service-level invariant rather than a caller obligation, so a wrong
18
- * call-site cannot bypass it;
19
- * 2. that verified address matches the listing's `founderEmail`;
20
- * 3. the listing's `siteId` is one of that user's OWN tenant workspaces —
21
- * the tenant-safety invariant (`lead-promotion.ts` D3), which is what
22
- * makes a row captured under another organization's workspace unmatchable
23
- * by construction rather than by a later filter.
24
- *
25
- * Any one failing is a NO-OP returning `count: 0`, never a throw — the lead
26
- * service's posture, for the same reason: this runs on a reconciliation path
27
- * (an email-verification callback, a sign-in), where a throw would fail an
28
- * unrelated request that had nothing to do with listings.
29
- *
30
- * And one condition the lead service does NOT have: the match must be
31
- * UNAMBIGUOUS. Two strangers can type the same address into the anonymous form,
32
- * and when they do nothing says which of the two rows is whose — so a row is
33
- * claimed only when it is the only un-linked row carrying that address at its
34
- * site. See the refusal block in the body; this is the difference between
35
- * mirroring the lead service's mechanism and inheriting a disclosure from it.
36
- *
37
- * Workspace resolution is delegated to `tenant-context.ts`'s `listTenancy` —
38
- * the sole cross-namespace tenancy assembler — so this file never issues a raw
39
- * membership or workspace model query of its own (`architecture.yaml`'s
40
- * tenant-scope forbidden pattern reserves those to
41
- * `tenant-context.ts` / `tenant-db.ts`). That delegation is module-conditional;
42
- * see the note above TENANT_CONTEXT_SPECIFIER.
43
- *
44
- * PII boundary: this module never logs or returns `founderEmail`,
45
- * `founderName`, or any application answer. The result carries ids only — a
46
- * listing id and the workspace id it sits in.
47
- *
48
- * NOTE for whoever wires this to a verification hook: `internal_error` carries
49
- * the raw `Error.message`, which can be a database error string. It is for the
50
- * caller's log, not for a response body. Same shape as `lead-promotion.ts`.
51
- */
52
-
53
- import type { Id } from "@working-theory/ids";
54
- import { logger } from "@working-theory/observability";
55
- import { PROJECT_LISTING_STORED_STATUSES } from "@working-theory/validation";
56
-
57
- import { isModuleEnabled } from "../config/modules.js";
58
- import { prisma } from "../lib/prisma.js";
59
-
60
- /**
61
- * `tenant-context.ts` is a multi-tenant SLICE file the OSS strip removes
62
- * (#5182), and `listTenancy` is a VALUE — a runtime call, not an erasable type
63
- * — so neither a static import nor a literal `await import("./tenant-context.js")`
64
- * survives the strip: `tsc` resolves a string-literal specifier whether or not
65
- * the branch containing it can ever run, and either form is TS2307 on the
66
- * public tree.
67
- *
68
- * The remedy is the one `lead-promotion.ts`, `server.ts` and
69
- * `routes/content/index.ts` already use: gate the call on
70
- * `isModuleEnabled('multi-tenant')` and read the specifier from this constant
71
- * so it is not a literal written at the call site. What defeats resolution is
72
- * the INDIRECTION, not the type of the value.
73
- *
74
- * Restated here rather than shared with `lead-promotion.ts`: the mechanism is
75
- * three lines and a constant, and extracting it would mean editing the Lead
76
- * promotion path this file deliberately only mirrors.
77
- *
78
- * Semantics on a build without the module: there is no workspace concept at
79
- * all, so a user has no workspaces, so no listing can match
80
- * `siteId ∈ workspaceIds` — which is exactly the zero-workspace early return
81
- * below. The no-op is correct rather than degraded.
82
- */
83
- const TENANT_CONTEXT_SPECIFIER = "./tenant-context.js";
84
-
85
- /**
86
- * The statuses a row must be in to be claimable: every stored status EXCEPT
87
- * `draft`.
88
- *
89
- * Two reasons, and the second is a security one.
90
- *
91
- * A `draft` is addressed by its RESUME TOKEN, which is that draft's whole
92
- * credential, and `resumeTokenHash` is cleared at submit — so the token and the
93
- * owner link hand off to each other cleanly. Before submit the token is the
94
- * access path; after it, the link is. A draft needs no link and gains nothing
95
- * from one.
96
- *
97
- * And `POST /v1/project-listings` is ANONYMOUS with the concurrent-listing
98
- * quota enforced at SUBMIT rather than at start, so anyone holding a site's
99
- * public key — it ships in browser JavaScript by design — can create unbounded
100
- * `draft` rows carrying any address they choose. If drafts were claimable, one
101
- * such row would be enough either to hand a stranger's text to whoever verifies
102
- * that address, or (under the ambiguity refusal below) to block that person
103
- * from ever claiming their own listing. Excluding drafts raises the cost of
104
- * that from one anonymous request to a COMPLETE application that passes submit
105
- * validation, counts against the two-row quota for that address at that site,
106
- * and sends an operator notification to the address itself.
107
- *
108
- * Derived from the exported vocabulary rather than restated, so a new stored
109
- * status is a deliberate decision here rather than a silent inclusion.
110
- */
111
- const CLAIMABLE_STATUSES: readonly string[] = PROJECT_LISTING_STORED_STATUSES.filter(
112
- (status) => status !== "draft",
113
- );
114
-
115
- /** The one export this file needs from the multi-tenant slice. */
116
- interface TenantContextSlice {
117
- listTenancy: (params: {
118
- userId: Id<"user">;
119
- sessionTenant: undefined;
120
- }) => Promise<
121
- | {
122
- ok: true;
123
- value: { organizations: Array<{ workspaces: Array<{ id: string }> }> };
124
- }
125
- | { ok: false; error: { message: string } }
126
- >;
127
- }
128
-
129
- // ─── Types ────────────────────────────────────────────────────────────────────
130
-
131
- export type PromotedListing = {
132
- listingId: string;
133
- siteId: string;
134
- };
135
-
136
- export type PromoteListingsResult =
137
- | { ok: true; count: number; promoted: PromotedListing[] }
138
- | {
139
- ok: false;
140
- error: { kind: "user_not_found" | "internal_error"; message: string };
141
- };
142
-
143
- // ─── Main export ──────────────────────────────────────────────────────────────
144
-
145
- /**
146
- * Link every un-linked `ProjectListing` whose `founderEmail` is the verifying
147
- * user's own verified address, within that user's own tenant workspaces.
148
- *
149
- * No-ops (`count: 0`) — never throws — when: the user's email is unverified,
150
- * the user belongs to no workspace, no matching un-linked listing exists, or
151
- * the match is ambiguous (more than one un-linked listing carries the address
152
- * at one site).
153
- *
154
- * Idempotent, and proved so on both sides of the write: a listing already
155
- * carrying `ownerUserId` is excluded from the candidate read AND from the
156
- * update's own `where`, so a re-invocation links nothing and a concurrent run
157
- * cannot transfer a row away from the user who already claimed it.
158
- */
159
- export async function promoteListingsForVerifiedUser(params: {
160
- userId: string;
161
- }): Promise<PromoteListingsResult> {
162
- const { userId } = params;
163
-
164
- try {
165
- const user = await prisma.user.findUnique({
166
- where: { id: userId },
167
- select: { id: true, email: true, emailVerified: true },
168
- });
169
-
170
- if (!user) {
171
- return {
172
- ok: false,
173
- error: { kind: "user_not_found", message: "User not found" },
174
- };
175
- }
176
-
177
- // Condition 1 — the verified-email gate. A service-level invariant, not a
178
- // caller obligation: it cannot be bypassed by a wrong call-site.
179
- if (!user.emailVerified) {
180
- return { ok: true, count: 0, promoted: [] };
181
- }
182
-
183
- // Condition 3 — resolve the verifying user's OWN workspaces via the sole
184
- // tenant-context assembler (never a raw membership or workspace model query
185
- // in this file). Module-conditional per the note on
186
- // TENANT_CONTEXT_SPECIFIER above: no multi-tenant module, no workspaces.
187
- let workspaceIds: string[] = [];
188
- if (isModuleEnabled("multi-tenant")) {
189
- const specifier: string = TENANT_CONTEXT_SPECIFIER;
190
- const { listTenancy } = (await import(specifier)) as TenantContextSlice;
191
- const tenancy = await listTenancy({
192
- userId: user.id as Id<"user">,
193
- sessionTenant: undefined,
194
- });
195
- if (!tenancy.ok) {
196
- return {
197
- ok: false,
198
- error: { kind: "internal_error", message: tenancy.error.message },
199
- };
200
- }
201
-
202
- workspaceIds = tenancy.value.organizations.flatMap((org) =>
203
- org.workspaces.map((workspace) => workspace.id as string),
204
- );
205
- }
206
-
207
- if (workspaceIds.length === 0) {
208
- return { ok: true, count: 0, promoted: [] };
209
- }
210
-
211
- /**
212
- * Conditions 2 + 3, both in the WHERE clause so neither is a post-filter.
213
- *
214
- * The email comparison is EXACT equality against the same normalization
215
- * `startProjectListingDraft` applies when it writes the column
216
- * (`normalizeFounderEmail` — trim + lower-case), so the two sides of the
217
- * match are normalized the same way by construction.
218
- *
219
- * Deliberately NOT `{ equals, mode: "insensitive" }`, the form
220
- * `waitlist-signups.ts` uses on `Lead.email`: Prisma compiles an
221
- * insensitive `equals` to `founder_email ILIKE $1`, which makes `_` and
222
- * `%` in the CALLER'S own verified address LIKE wildcards. A user whose
223
- * real address is `a_c@example.com` then matches a different founder's
224
- * `abc@example.com` in the same workspace — verified in this repo against
225
- * a live Postgres, two rows in, two rows out. That is precisely the
226
- * cross-founder capture this link exists to remove, so the stricter
227
- * comparison wins. The cost is a listing whose stored `founderEmail`
228
- * predates `normalizeFounderEmail` and carries upper case: it stays
229
- * un-linked, which is the safe direction (a missing link is a no-op; a
230
- * wrong one hands over a stranger's submission) and the same resting
231
- * state every un-promoted row already sits in.
232
- *
233
- * `ownerUserId: null` is what makes this idempotent.
234
- */
235
- const candidates = await prisma.projectListing.findMany({
236
- where: {
237
- founderEmail: user.email.trim().toLowerCase(),
238
- siteId: { in: workspaceIds },
239
- ownerUserId: null,
240
- // See CLAIMABLE_STATUSES: a draft is token-addressed, and an anonymous
241
- // caller can create them without passing the quota.
242
- status: { in: [...CLAIMABLE_STATUSES] },
243
- },
244
- select: { id: true, siteId: true },
245
- });
246
-
247
- if (candidates.length === 0) {
248
- return { ok: true, count: 0, promoted: [] };
249
- }
250
-
251
- /**
252
- * AMBIGUITY REFUSAL — the case a verified email cannot resolve.
253
- *
254
- * Two strangers can both type the same address into the anonymous form, and
255
- * when they do there are two un-linked rows carrying it and NOTHING that
256
- * says which is whose. Linking both to whoever verifies the address hands
257
- * one person the other's whole application — the exact disclosure this
258
- * column exists to remove, arriving by the back door. `lead-promotion.ts`
259
- * links every match and therefore has this shape; it is out of scope to
260
- * change there, and it is not inherited here.
261
- *
262
- * So the rule is: within ONE site, a verified address claims a row only
263
- * when it is the ONLY un-linked row carrying that address. Otherwise every
264
- * row in that ambiguous group stays null. The grain is the site because
265
- * that is where the collision happens and where the concurrent-listing
266
- * limit is already counted (`countOtherNonTerminalListings`); a second site
267
- * of the same user is judged on its own.
268
- *
269
- * The cost is a founder who legitimately submitted TWO applications at one
270
- * site (`PROJECT_LISTING_CONCURRENT_LIMIT` is 2): neither is linked, and
271
- * they reach those rows the way they do today. That is the deliberate
272
- * trade. Nothing in the data distinguishes their second application from a
273
- * stranger's first, so the honest answer is to claim neither — and an
274
- * unclaimed row is the resting state this column already treats as
275
- * legitimate, not a failure.
276
- *
277
- * `users.email` is UNIQUE, so there is never a SECOND verifier competing
278
- * for the refused rows: refusing costs no other claimant anything.
279
- */
280
- const perSite = new Map<string, number>();
281
- for (const listing of candidates) {
282
- perSite.set(listing.siteId, (perSite.get(listing.siteId) ?? 0) + 1);
283
- }
284
- const attributable = candidates.filter(
285
- (listing) => perSite.get(listing.siteId) === 1,
286
- );
287
-
288
- const ambiguousRows = candidates.length - attributable.length;
289
- if (ambiguousRows > 0) {
290
- // PII-safe: counts and the user id only — never the address, the
291
- // founder's name, or a listing id that would identify whose row it is.
292
- logger.warn(
293
- {
294
- kind: "ambiguous_listing_owner",
295
- userId: user.id,
296
- ambiguousRows,
297
- attributableRows: attributable.length,
298
- },
299
- "[listing-promotion] more than one un-linked listing shares this address at one site; leaving those rows unlinked",
300
- );
301
- }
302
-
303
- if (attributable.length === 0) {
304
- return { ok: true, count: 0, promoted: [] };
305
- }
306
-
307
- /**
308
- * `ownerUserId: null` is repeated on the WRITE, not only on the read above.
309
- * Without it the update is a blind write over whatever the row holds now,
310
- * and two reconciliation runs that both saw the row un-linked would let the
311
- * second one TRANSFER ownership away from the first — which contradicts the
312
- * idempotency this service documents. With it the write is a
313
- * compare-and-swap: the loser of the race matches 0 rows.
314
- *
315
- * `count` therefore reports rows this call actually claimed, not rows it
316
- * intended to.
317
- */
318
- const written = await prisma.projectListing.updateMany({
319
- where: {
320
- id: { in: attributable.map((listing) => listing.id) },
321
- ownerUserId: null,
322
- },
323
- data: { ownerUserId: user.id, ownerLinkedAt: new Date() },
324
- });
325
-
326
- if (written.count === 0) {
327
- return { ok: true, count: 0, promoted: [] };
328
- }
329
-
330
- return {
331
- ok: true,
332
- count: written.count,
333
- promoted: attributable.map((listing) => ({
334
- listingId: listing.id,
335
- siteId: listing.siteId,
336
- })),
337
- };
338
- } catch (err) {
339
- const message = err instanceof Error ? err.message : String(err);
340
- return { ok: false, error: { kind: "internal_error", message } };
341
- }
342
- }