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,566 +0,0 @@
1
- // oss: false
2
- /**
3
- * @file tenant-isolation.test.ts
4
- *
5
- * THE ACCEPTANCE GATE for the tenant-scoped project-listings routes (#4998
6
- * AC2, AC3). Registration mirrors server.ts: REAL `authPreHandlerPlugin ->
7
- * tenantPreHandlerPlugin -> projectListingTenantRoutes`, mocked
8
- * `lib/prisma.js`. Fixture sessions for: a site-A owner (sees only site A),
9
- * a site-B admin (sees only site B; 404 on site A's listing id), a site-B
10
- * member (403 on every route, before any read), a bearer token (403), no
11
- * cookie (401). Every success body parses under `projectListingSchema`;
12
- * `waitlistCount` joins on the listing's own `experimentKey` -> `WaitlistScore.count`
13
- * — one hop since #5036 D6d, where it used to translate an `experimentId`
14
- * through `WaitlistExperiment` first. The PATCH transition table is exercised
15
- * exhaustively — every from->to pair (including #4999's one behavior change:
16
- * a decision target on an already-decided listing is now a `200` idempotent
17
- * no-op via the shared `decideListing`, not a `400`).
18
- */
19
- import Fastify from "fastify";
20
- import type { FastifyInstance } from "fastify";
21
- import { projectListingSchema } from "@working-theory/validation";
22
-
23
- // `services/project-listings.ts` imports `routeLead` from `lead-routing.js`
24
- // (used by the draft-submit path, not by these routes) at module scope, which
25
- // transitively imports `@working-theory/email` and `@working-theory/license`
26
- // — mocked here purely so that import chain resolves, mirroring
27
- // `routes/leads/__tests__/index.test.ts`'s discipline.
28
- jest.mock("@working-theory/email", () => ({
29
- sendLeadNotification: jest.fn().mockResolvedValue({ ok: true, messageId: "msg-test" }),
30
- }));
31
- jest.mock("@working-theory/license", () => ({
32
- checkLicense: jest.fn().mockResolvedValue({
33
- tier: "free",
34
- plan: null,
35
- features: [],
36
- expiresAt: null,
37
- revoked: false,
38
- }),
39
- }));
40
-
41
- // eslint-disable-next-line no-restricted-syntax -- jest mock factory must be hoisted
42
- jest.mock("../../../lib/prisma.js", () => ({
43
- prisma: {
44
- session: { findUnique: jest.fn() },
45
- apiToken: {
46
- findUnique: jest.fn(),
47
- update: jest.fn().mockResolvedValue(undefined),
48
- },
49
- workspace: { findUnique: jest.fn(), count: jest.fn() },
50
- membership: { findUnique: jest.fn() },
51
- projectListing: { findFirst: jest.fn(), findMany: jest.fn(), update: jest.fn(), updateMany: jest.fn() },
52
- waitlistExperiment: { findMany: jest.fn() },
53
- waitlistScore: { count: jest.fn() },
54
- },
55
- }));
56
-
57
- import { prisma } from "../../../lib/prisma.js";
58
- import { authPreHandlerPlugin } from "../../../middleware/auth.js";
59
- import { tenantPreHandlerPlugin } from "../../../middleware/tenant.js";
60
- import { projectListingTenantRoutes } from "../index.js";
61
-
62
- const FRONTEND = "https://app.example.com";
63
- const FUTURE = new Date(Date.now() + 60 * 60 * 1000);
64
-
65
- const ORG_A = "org_00000000-0000-7000-8000-000000000001";
66
- const WS_A = "wksp_00000000-0000-7000-8000-000000000001";
67
- const ORG_B = "org_01991234-5678-7000-8000-0000000000bb";
68
- const WS_B = "wksp_00000000-0000-7000-8000-0000000000bb";
69
-
70
- const WORKSPACES = [
71
- { id: WS_A, organizationId: ORG_A },
72
- { id: WS_B, organizationId: ORG_B },
73
- ];
74
-
75
- const MEMBERSHIPS = [
76
- { organizationId: ORG_A, userId: "alice", role: "owner" },
77
- { organizationId: ORG_B, userId: "erin", role: "admin" },
78
- { organizationId: ORG_B, userId: "carol", role: "member" },
79
- ];
80
-
81
- const SESSION_ROWS: Record<string, Record<string, unknown>> = {
82
- cookie_alice: {
83
- id: "sess_alice",
84
- userId: "alice",
85
- expiresAt: FUTURE,
86
- activeOrganizationId: ORG_A,
87
- activeWorkspaceId: WS_A,
88
- },
89
- cookie_erin: {
90
- id: "sess_erin",
91
- userId: "erin",
92
- expiresAt: FUTURE,
93
- activeOrganizationId: ORG_B,
94
- activeWorkspaceId: WS_B,
95
- },
96
- cookie_carol: {
97
- id: "sess_carol",
98
- userId: "carol",
99
- expiresAt: FUTURE,
100
- activeOrganizationId: ORG_B,
101
- activeWorkspaceId: WS_B,
102
- },
103
- };
104
-
105
- const opt = (value: string, label: string) => ({ value, label });
106
- const QUESTIONS = {
107
- q2: { prompt: "x", options: [opt("scale", "Scale"), opt("just-curious", "Just curious")] },
108
- q3: { prompt: "x", options: [opt("a", "A"), opt("b", "B"), opt("c", "C")] },
109
- q4: { prompt: "x", options: [opt("a", "A"), opt("b", "B"), opt("c", "C")] },
110
- q5: { prompt: "x", options: [opt("urgent", "Now"), opt("planning", "Soon"), opt("exploring", "Someday")] },
111
- q6: {
112
- prompt: "x",
113
- options: [opt("low", "Low"), opt("medium", "Medium"), opt("high", "High"), opt("critical", "Critical")],
114
- },
115
- q7: { prompt: "x" },
116
- extras: [],
117
- };
118
-
119
- const LISTING_A = "prjl_00000000-0000-7000-8000-0000000000a1";
120
- const LISTING_B = "prjl_00000000-0000-7000-8000-0000000000b1";
121
- /** The experiment KEY the listing stores since #5036 D6d — not an id. */
122
- const EXP_KEY_A = "exp-a";
123
-
124
- function makeRow(overrides: Partial<Record<string, unknown>> = {}) {
125
- return {
126
- id: LISTING_A,
127
- siteId: WS_A,
128
- leadId: "lead_00000000-0000-7000-8000-000000000001",
129
- slug: "my-project",
130
- name: "My Project",
131
- oneLiner: "A one-liner",
132
- problem: "The problem",
133
- url: null,
134
- // #5036 D2/D5 — `audience` is gone, `stage` is the `product` ladder, and
135
- // these five are what the page and tile render from.
136
- product: "working-prototype",
137
- commitment: "part-time",
138
- teamComposition: "just-me",
139
- faqs: null,
140
- anythingElse: null,
141
- address: "1 Example Street, Exampleton, EX1 2AM",
142
- questions: QUESTIONS,
143
- founderName: "Ada Founder",
144
- founderEmail: "ada@example.com",
145
- founderCompany: null,
146
- headline: null,
147
- logoMarkUrl: null,
148
- logoWordmarkUrl: null,
149
- featured: false,
150
- status: "requested",
151
- // Stamped at `start`, never null (#5036 D1).
152
- consentAt: new Date("2026-09-01T00:00:00.000Z"),
153
- decidedAt: null,
154
- decidedBy: null,
155
- issueUrl: null,
156
- // The experiment's KEY, not its id (#5036 D6d).
157
- experimentKey: null,
158
- windowDays: 21,
159
- windowEndsAt: null,
160
- slackChannel: null,
161
- slackTs: null,
162
- createdAt: new Date("2026-09-01T00:00:00.000Z"),
163
- updatedAt: new Date("2026-09-01T00:00:00.000Z"),
164
- ...overrides,
165
- };
166
- }
167
-
168
- const ROWS = [
169
- makeRow(),
170
- makeRow({ id: LISTING_B, siteId: WS_B, slug: "other-project", founderEmail: "bob@example.com" }),
171
- ];
172
-
173
- function installFixtureMocks(rows = ROWS): void {
174
- (prisma.session.findUnique as jest.Mock).mockImplementation(
175
- ({ where }: { where: { token?: string } }) =>
176
- Promise.resolve(where.token ? (SESSION_ROWS[where.token] ?? null) : null),
177
- );
178
- (prisma.workspace.findUnique as jest.Mock).mockImplementation(
179
- ({ where }: { where: { id?: string } }) =>
180
- Promise.resolve(WORKSPACES.find((w) => w.id === where.id) ?? null),
181
- );
182
- (prisma.workspace.count as jest.Mock).mockResolvedValue(WORKSPACES.length);
183
- (prisma.membership.findUnique as jest.Mock).mockImplementation(
184
- ({ where }: { where: { organizationId_userId?: { organizationId: string; userId: string } } }) => {
185
- const key = where.organizationId_userId;
186
- if (!key) return Promise.resolve(null);
187
- return Promise.resolve(
188
- MEMBERSHIPS.find((m) => m.organizationId === key.organizationId && m.userId === key.userId) ?? null,
189
- );
190
- },
191
- );
192
-
193
- (prisma.projectListing.findMany as jest.Mock).mockImplementation(
194
- ({ where }: { where: { siteId?: string; status?: string; slug?: { startsWith: string } } }) =>
195
- Promise.resolve(
196
- rows.filter(
197
- (r) =>
198
- r.siteId === where.siteId &&
199
- (where.status === undefined || r.status === where.status) &&
200
- (where.slug === undefined || r.slug.startsWith(where.slug.startsWith)),
201
- ),
202
- ),
203
- );
204
- (prisma.projectListing.findFirst as jest.Mock).mockImplementation(
205
- ({ where }: { where: { id?: string; siteId?: string } }) =>
206
- Promise.resolve(rows.find((r) => r.id === where.id && r.siteId === where.siteId) ?? null),
207
- );
208
- (prisma.projectListing.update as jest.Mock).mockImplementation(
209
- ({ where, data }: { where: { id: string }; data: Record<string, unknown> }) => {
210
- const row = rows.find((r) => r.id === where.id);
211
- if (!row) throw new Error("not found");
212
- const updated = { ...row, ...data };
213
- Object.assign(row, updated);
214
- return Promise.resolve(updated);
215
- },
216
- );
217
- // decideListing's guarded compare-and-swap write (#4999 security review) —
218
- // only mutates a row when every `where` key matches (a `status: "requested"`
219
- // mismatch, e.g. already decided, returns `{ count: 0 }` untouched).
220
- (prisma.projectListing.updateMany as jest.Mock).mockImplementation(
221
- ({
222
- where,
223
- data,
224
- }: {
225
- where: { id?: string; siteId?: string; status?: string };
226
- data: Record<string, unknown>;
227
- }) => {
228
- const row = rows.find(
229
- (r) =>
230
- r.id === where.id &&
231
- (where.siteId === undefined || r.siteId === where.siteId) &&
232
- (where.status === undefined || r.status === where.status),
233
- );
234
- if (!row) return Promise.resolve({ count: 0 });
235
- Object.assign(row, data);
236
- return Promise.resolve({ count: 1 });
237
- },
238
- );
239
- (prisma.waitlistExperiment.findMany as jest.Mock).mockResolvedValue([]);
240
- (prisma.waitlistScore.count as jest.Mock).mockResolvedValue(0);
241
- }
242
-
243
- async function buildApp(): Promise<FastifyInstance> {
244
- const app = Fastify();
245
- app.register(authPreHandlerPlugin, { frontendOrigin: FRONTEND });
246
- app.register(async (scope) => {
247
- await scope.register(tenantPreHandlerPlugin);
248
- await scope.register(projectListingTenantRoutes);
249
- });
250
- await app.ready();
251
- return app;
252
- }
253
-
254
- function cookieHeaders(token: string): Record<string, string> {
255
- // `origin` satisfies the same-origin CSRF check the auth preHandler runs
256
- // on non-GET cookie requests (middleware/auth.ts) — harmless on GET.
257
- return { cookie: `better-auth.session_token=${token}`, origin: FRONTEND };
258
- }
259
-
260
- describe("project-listings — tenant isolation", () => {
261
- let app: FastifyInstance;
262
-
263
- beforeEach(async () => {
264
- jest.clearAllMocks();
265
- installFixtureMocks();
266
- app = await buildApp();
267
- });
268
-
269
- afterEach(async () => {
270
- await app.close();
271
- });
272
-
273
- it("a site-A owner sees only site A's listing", async () => {
274
- const res = await app.inject({
275
- method: "GET",
276
- url: "/v1/project-listings",
277
- headers: cookieHeaders("cookie_alice"),
278
- });
279
- expect(res.statusCode).toBe(200);
280
- const body = res.json();
281
- expect(body.data).toHaveLength(1);
282
- expect(body.data[0].id).toBe(LISTING_A);
283
- expect(projectListingSchema.safeParse(body.data[0]).success).toBe(true);
284
- });
285
-
286
- it("a site-B admin sees only site B's listing", async () => {
287
- const res = await app.inject({
288
- method: "GET",
289
- url: "/v1/project-listings",
290
- headers: cookieHeaders("cookie_erin"),
291
- });
292
- expect(res.statusCode).toBe(200);
293
- expect(res.json().data.map((r: { id: string }) => r.id)).toEqual([LISTING_B]);
294
- });
295
-
296
- it("404s on the other site's listing id, indistinguishable from a genuinely-missing one", async () => {
297
- const foreign = await app.inject({
298
- method: "GET",
299
- url: `/v1/project-listings/${LISTING_A}`,
300
- headers: cookieHeaders("cookie_erin"),
301
- });
302
- expect(foreign.statusCode).toBe(404);
303
-
304
- const missing = await app.inject({
305
- method: "GET",
306
- url: "/v1/project-listings/prjl_00000000-0000-7000-8000-00000000dead",
307
- headers: cookieHeaders("cookie_alice"),
308
- });
309
- expect(missing.statusCode).toBe(404);
310
- expect(missing.body).toBe(foreign.body);
311
- });
312
-
313
- it("400s on a malformed :id", async () => {
314
- const res = await app.inject({
315
- method: "GET",
316
- url: "/v1/project-listings/not-a-valid-id",
317
- headers: cookieHeaders("cookie_alice"),
318
- });
319
- expect(res.statusCode).toBe(400);
320
- });
321
-
322
- it("a site-B member gets 403 on every route, before any read", async () => {
323
- const list = await app.inject({
324
- method: "GET",
325
- url: "/v1/project-listings",
326
- headers: cookieHeaders("cookie_carol"),
327
- });
328
- expect(list.statusCode).toBe(403);
329
- expect(prisma.projectListing.findMany).not.toHaveBeenCalled();
330
-
331
- const patchRes = await app.inject({
332
- method: "PATCH",
333
- url: `/v1/project-listings/${LISTING_B}`,
334
- headers: cookieHeaders("cookie_carol"),
335
- payload: { status: "active" },
336
- });
337
- expect(patchRes.statusCode).toBe(403);
338
- });
339
-
340
- it("a bearer token gets 403 (no tenant binding)", async () => {
341
- (prisma.apiToken.findUnique as jest.Mock).mockResolvedValue({
342
- id: "tok1",
343
- userId: "erin",
344
- expiresAt: null,
345
- revokedAt: null,
346
- });
347
- const res = await app.inject({
348
- method: "GET",
349
- url: "/v1/project-listings",
350
- headers: { authorization: "Bearer tok_raw" },
351
- });
352
- expect(res.statusCode).toBe(403);
353
- });
354
-
355
- it("no cookie gets 401", async () => {
356
- const res = await app.inject({ method: "GET", url: "/v1/project-listings" });
357
- expect(res.statusCode).toBe(401);
358
- });
359
-
360
- it("GET :id includes waitlistCount joined on the listing's own experimentKey, in one hop", async () => {
361
- installFixtureMocks([makeRow({ experimentKey: EXP_KEY_A })]);
362
- (prisma.waitlistScore.count as jest.Mock).mockResolvedValue(7);
363
-
364
- const res = await app.inject({
365
- method: "GET",
366
- url: `/v1/project-listings/${LISTING_A}`,
367
- headers: cookieHeaders("cookie_alice"),
368
- });
369
- expect(res.statusCode).toBe(200);
370
- expect(res.json().data.waitlistCount).toBe(7);
371
- expect(prisma.waitlistScore.count).toHaveBeenCalledWith({
372
- where: { siteId: WS_A, experimentKey: EXP_KEY_A },
373
- });
374
- // #5036 D6d removed the id-to-key translation read entirely.
375
- expect(prisma.waitlistExperiment.findMany).not.toHaveBeenCalled();
376
- });
377
-
378
- it("waitlistCount is 0 when experimentKey is unset", async () => {
379
- installFixtureMocks([makeRow()]);
380
- const res = await app.inject({
381
- method: "GET",
382
- url: `/v1/project-listings/${LISTING_A}`,
383
- headers: cookieHeaders("cookie_alice"),
384
- });
385
- expect(res.json().data.waitlistCount).toBe(0);
386
- expect(prisma.waitlistScore.count).not.toHaveBeenCalled();
387
- });
388
-
389
- // ── AC3 — the transition surface, exhaustively ───────────────────────────
390
- //
391
- // #5033 collapsed the lifecycle to five states with `closed` COMPUTED, so
392
- // the transition table this suite used to walk has one row left:
393
- // `requested -> active | rejected`. `approved -> live` and `live -> closed`
394
- // no longer exist as concepts. The idempotent "second click" branch (#4999)
395
- // is unchanged: a decision target on an already-decided row is `200` with
396
- // the row untouched, not a `400`.
397
- //
398
- // The narrowing is deliberate and is what makes "nothing can write
399
- // `closed`" structural: the patch body's enum does not contain it, so the
400
- // request never reaches a service that could.
401
-
402
- const STORED = ["draft", "requested", "rejected", "active"] as const;
403
- const DECISION_TARGETS = ["active", "rejected"] as const;
404
- const DECIDED = new Set<string>(["active", "rejected"]);
405
-
406
- for (const from of STORED) {
407
- for (const to of DECISION_TARGETS) {
408
- if (from === "requested") {
409
- it(`${from} -> ${to} is 200 (the one allowed transition)`, async () => {
410
- installFixtureMocks([makeRow({ status: from })]);
411
- const res = await app.inject({
412
- method: "PATCH",
413
- url: `/v1/project-listings/${LISTING_A}`,
414
- headers: cookieHeaders("cookie_alice"),
415
- payload: { status: to },
416
- });
417
- expect(res.statusCode).toBe(200);
418
- expect(res.json().data.status).toBe(to);
419
- });
420
- } else if (DECIDED.has(from)) {
421
- it(`${from} -> ${to} is 200 idempotent (already decided — #4999 second-click no-op), status stays '${from}'`, async () => {
422
- // An already-`active` row needs the window `decideListing` stamped
423
- // on it: since #5036 D6b a non-featured active row with no window in
424
- // force reads `closed`, which would make this an assertion about the
425
- // fail-closed branch rather than about idempotency.
426
- installFixtureMocks([
427
- makeRow({
428
- status: from,
429
- ...(from === "active" && {
430
- windowEndsAt: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000),
431
- }),
432
- }),
433
- ]);
434
- const res = await app.inject({
435
- method: "PATCH",
436
- url: `/v1/project-listings/${LISTING_A}`,
437
- headers: cookieHeaders("cookie_alice"),
438
- payload: { status: to },
439
- });
440
- expect(res.statusCode).toBe(200);
441
- expect(res.json().data.status).toBe(from);
442
- // decideListing's guarded updateMany (`where` includes
443
- // `status: "requested"`) is attempted but matches 0 rows, so the
444
- // mock's own guard means it never mutated the row either.
445
- expect(prisma.projectListing.updateMany).toHaveBeenCalledWith(
446
- expect.objectContaining({ where: expect.objectContaining({ status: "requested" }) }),
447
- );
448
- });
449
- } else {
450
- it(`${from} -> ${to} is 400 (rejected)`, async () => {
451
- installFixtureMocks([makeRow({ status: from })]);
452
- const res = await app.inject({
453
- method: "PATCH",
454
- url: `/v1/project-listings/${LISTING_A}`,
455
- headers: cookieHeaders("cookie_alice"),
456
- payload: { status: to },
457
- });
458
- expect(res.statusCode).toBe(400);
459
- });
460
- }
461
- }
462
- }
463
-
464
- // The invariant, at the route boundary: no request can store `closed`, and
465
- // no request can reach the retired vocabulary either.
466
- it.each(["closed", "draft", "requested", "applied", "approved", "live"])(
467
- "400s a PATCH asking for status '%s', and writes nothing",
468
- async (status) => {
469
- installFixtureMocks([makeRow({ status: "requested" })]);
470
- const res = await app.inject({
471
- method: "PATCH",
472
- url: `/v1/project-listings/${LISTING_A}`,
473
- headers: cookieHeaders("cookie_alice"),
474
- payload: { status },
475
- });
476
- expect(res.statusCode).toBe(400);
477
- expect(prisma.projectListing.updateMany).not.toHaveBeenCalled();
478
- },
479
- );
480
-
481
- it("stamps decidedAt/decidedBy on requested -> active and requested -> rejected, from request.userId", async () => {
482
- installFixtureMocks([makeRow({ status: "requested" })]);
483
- const res = await app.inject({
484
- method: "PATCH",
485
- url: `/v1/project-listings/${LISTING_A}`,
486
- headers: cookieHeaders("cookie_alice"),
487
- payload: { status: "active" },
488
- });
489
- expect(res.statusCode).toBe(200);
490
- const body = res.json().data;
491
- expect(body.decidedBy).toBe("alice");
492
- expect(body.decidedAt).not.toBeNull();
493
- });
494
-
495
- it("400s on an unknown body field", async () => {
496
- installFixtureMocks([makeRow({ status: "requested" })]);
497
- const res = await app.inject({
498
- method: "PATCH",
499
- url: `/v1/project-listings/${LISTING_A}`,
500
- headers: cookieHeaders("cookie_alice"),
501
- payload: { status: "active", decidedBy: "someone-else" },
502
- });
503
- expect(res.statusCode).toBe(400);
504
- });
505
-
506
- it("400s on an empty body", async () => {
507
- installFixtureMocks([makeRow({ status: "requested" })]);
508
- const res = await app.inject({
509
- method: "PATCH",
510
- url: `/v1/project-listings/${LISTING_A}`,
511
- headers: cookieHeaders("cookie_alice"),
512
- payload: {},
513
- });
514
- expect(res.statusCode).toBe(400);
515
- });
516
-
517
- // ── #5033 — the featured flag, scoped to the tenant ─────────────────────
518
-
519
- it("sets the featured flag on its own, scoped to the caller's site", async () => {
520
- installFixtureMocks([makeRow({ status: "active" })]);
521
- const res = await app.inject({
522
- method: "PATCH",
523
- url: `/v1/project-listings/${LISTING_A}`,
524
- headers: cookieHeaders("cookie_alice"),
525
- payload: { featured: true },
526
- });
527
- expect(res.statusCode).toBe(200);
528
- expect(res.json().data.featured).toBe(true);
529
- expect(prisma.projectListing.updateMany).toHaveBeenCalledWith(
530
- expect.objectContaining({ where: expect.objectContaining({ siteId: WS_A }) }),
531
- );
532
- });
533
-
534
- it("404s a featured write against a foreign listing id", async () => {
535
- const res = await app.inject({
536
- method: "PATCH",
537
- url: `/v1/project-listings/${LISTING_B}`,
538
- headers: cookieHeaders("cookie_alice"),
539
- payload: { featured: true },
540
- });
541
- expect(res.statusCode).toBe(404);
542
- });
543
-
544
- it("400s a PATCH trying to set the site's own config from the request body", async () => {
545
- installFixtureMocks([makeRow({ status: "requested" })]);
546
- for (const key of ["maxWindowDays", "defaultWindowDays", "featuredEnabled", "windowEndsAt"]) {
547
- const res = await app.inject({
548
- method: "PATCH",
549
- url: `/v1/project-listings/${LISTING_A}`,
550
- headers: cookieHeaders("cookie_alice"),
551
- payload: { status: "active", [key]: 3000 },
552
- });
553
- expect(res.statusCode).toBe(400);
554
- }
555
- });
556
-
557
- it("404s a PATCH on a foreign listing id", async () => {
558
- const res = await app.inject({
559
- method: "PATCH",
560
- url: `/v1/project-listings/${LISTING_B}`,
561
- headers: cookieHeaders("cookie_alice"),
562
- payload: { status: "active" },
563
- });
564
- expect(res.statusCode).toBe(404);
565
- });
566
- });
@@ -1,70 +0,0 @@
1
- // oss: false
2
- /**
3
- * GET /v1/project-listings/:id
4
- *
5
- * Tenant-scoped single read (#4998 AC2). `:id` is validated with
6
- * `zProjectListingId` (ADR-0031 rule 5) before any lookup — a malformed id is
7
- * `400`, never reaches the service. A foreign or missing id is `404`,
8
- * indistinguishable (the `waitlist-experiments` no-enumeration posture).
9
- */
10
- import { zProjectListingId } from "@working-theory/ids";
11
- import { projectListingSchema } from "@working-theory/validation";
12
- import type { FastifyReply, FastifyRequest } from "fastify";
13
- import { z } from "zod";
14
-
15
- import { getProjectListing, PROJECT_LISTING_ROLES } from "../../services/project-listings.js";
16
-
17
- import { sendValidated } from "./respond.js";
18
-
19
- const paramsSchema = z.object({ id: zProjectListingId });
20
-
21
- function unauthorized(reply: FastifyReply) {
22
- return reply.status(401).send({
23
- ok: false,
24
- error: { code: "unauthorized", message: "Authentication required" },
25
- });
26
- }
27
-
28
- function forbidden(reply: FastifyReply) {
29
- return reply.status(403).send({
30
- ok: false,
31
- error: { code: "forbidden", message: "Forbidden" },
32
- });
33
- }
34
-
35
- function invalidParams(reply: FastifyReply) {
36
- return reply.status(400).send({
37
- ok: false,
38
- error: { code: "validation_failed", message: "Invalid project listing id" },
39
- });
40
- }
41
-
42
- function notFound(reply: FastifyReply) {
43
- return reply.status(404).send({
44
- ok: false,
45
- error: { code: "not_found", message: "Project listing not found" },
46
- });
47
- }
48
-
49
- export async function getProjectListingHandler(
50
- request: FastifyRequest,
51
- reply: FastifyReply,
52
- ): Promise<void> {
53
- const tenant = request.tenant;
54
- if (!tenant) return unauthorized(reply);
55
- if (!PROJECT_LISTING_ROLES.has(tenant.role)) {
56
- request.log.warn(
57
- { method: request.method, path: request.url, status: 403 },
58
- "[project-listings] get denied; insufficient role",
59
- );
60
- return forbidden(reply);
61
- }
62
-
63
- const parsedParams = paramsSchema.safeParse(request.params);
64
- if (!parsedParams.success) return invalidParams(reply);
65
-
66
- const result = await getProjectListing(tenant.workspaceId, parsedParams.data.id);
67
- if (!result.ok) return notFound(reply);
68
-
69
- return sendValidated(reply, projectListingSchema, result.value, "get");
70
- }
@@ -1,47 +0,0 @@
1
- // oss: false
2
- /**
3
- * GET /v1/project-listings
4
- *
5
- * Tenant-scoped dashboard list (#4998 AC2), the `waitlist-experiments` list
6
- * route's own pattern: scope derived server-side from `request.tenant`
7
- * (never a client-supplied site id), role floor `owner|admin` checked before
8
- * any read, response re-parsed against its own contract schema before send.
9
- */
10
- import { projectListingListResponseSchema } from "@working-theory/validation";
11
- import type { FastifyReply, FastifyRequest } from "fastify";
12
-
13
- import { listProjectListings, PROJECT_LISTING_ROLES } from "../../services/project-listings.js";
14
-
15
- import { sendValidated } from "./respond.js";
16
-
17
- function unauthorized(reply: FastifyReply) {
18
- return reply.status(401).send({
19
- ok: false,
20
- error: { code: "unauthorized", message: "Authentication required" },
21
- });
22
- }
23
-
24
- function forbidden(reply: FastifyReply) {
25
- return reply.status(403).send({
26
- ok: false,
27
- error: { code: "forbidden", message: "Forbidden" },
28
- });
29
- }
30
-
31
- export async function listProjectListingsHandler(
32
- request: FastifyRequest,
33
- reply: FastifyReply,
34
- ): Promise<void> {
35
- const tenant = request.tenant;
36
- if (!tenant) return unauthorized(reply);
37
- if (!PROJECT_LISTING_ROLES.has(tenant.role)) {
38
- request.log.warn(
39
- { method: request.method, path: request.url, status: 403 },
40
- "[project-listings] list denied; insufficient role",
41
- );
42
- return forbidden(reply);
43
- }
44
-
45
- const data = await listProjectListings(tenant.workspaceId);
46
- return sendValidated(reply, projectListingListResponseSchema, data, "list");
47
- }