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,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
- }