create-ailk 0.2.0 → 0.4.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 (292) hide show
  1. package/component-catalog.md +50 -53
  2. package/dist/cli.js +19 -9
  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 +51 -3
  6. package/dist/lib/extract-module-bundle.js +327 -28
  7. package/dist/lib/fetch-module.d.ts +3 -38
  8. package/dist/lib/fetch-module.js +26 -8
  9. package/dist/lib/fetch-template.d.ts +20 -0
  10. package/dist/lib/fetch-template.js +129 -0
  11. package/dist/lib/module-license-gate.d.ts +10 -0
  12. package/dist/lib/module-license-gate.js +13 -4
  13. package/dist/lib/paid-paths.d.ts +16 -0
  14. package/dist/lib/paid-paths.js +90 -0
  15. package/dist/module-architecture.d.ts +197 -23
  16. package/dist/module-architecture.js +820 -25
  17. package/dist/parse-args.d.ts +9 -2
  18. package/dist/parse-args.js +46 -1
  19. package/dist/programmatic.d.ts +28 -2
  20. package/dist/programmatic.js +115 -8
  21. package/dist/surfaces.d.ts +12 -36
  22. package/dist/surfaces.js +77 -5
  23. package/dist/sync-routes.js +31 -1
  24. package/dist/template-manifest.d.ts +92 -0
  25. package/dist/template-manifest.js +168 -0
  26. package/dist/template-names.d.ts +41 -0
  27. package/dist/template-names.js +44 -0
  28. package/package.json +3 -2
  29. package/site-templates/marketing-site-free/ailk-template.json +8 -0
  30. package/site-templates/marketing-site-free/content/en/Pricing/pricing.mdx +103 -0
  31. package/site-templates/marketing-site-free/content/en/PrivacyPage/privacy.mdx +79 -0
  32. package/site-templates/marketing-site-free/content/en/TermsPage/terms.mdx +80 -0
  33. package/site-templates/marketing-site-free/provenance.json +4 -0
  34. package/templates/.claude/agents/web.md +2 -3
  35. package/templates/.claude/rules/architecture.md +5 -5
  36. package/templates/.claude/skills/README.md +2 -1
  37. package/templates/.env.example +21 -9
  38. package/templates/CONVENTIONS.md +6 -7
  39. package/templates/apps/api/.env.example +9 -0
  40. package/templates/apps/api/CLAUDE.md +33 -5
  41. package/templates/apps/api/package.json +0 -1
  42. package/templates/apps/api/src/__tests__/cors.test.ts +195 -0
  43. package/templates/apps/api/src/__tests__/module-exclusion.test.ts +60 -2
  44. package/templates/apps/api/src/__tests__/vercel-handler.test.ts +159 -0
  45. package/templates/apps/api/src/__tests__/workspace-module-registration.test.ts +166 -0
  46. package/templates/apps/api/src/config/__tests__/modules.test.ts +76 -0
  47. package/templates/apps/api/src/config/index.ts +19 -0
  48. package/templates/apps/api/src/config/modules.ts +12 -14
  49. package/templates/apps/api/src/lib/__mocks__/prisma.ts +3 -0
  50. package/templates/apps/api/src/lib/__tests__/slice-load.test.ts +89 -0
  51. package/templates/apps/api/src/lib/slice-load.ts +90 -0
  52. package/templates/apps/api/src/openapi/__tests__/openapi.test.ts +52 -32
  53. package/templates/apps/api/src/openapi/__tests__/spec-identity.test.ts +182 -0
  54. package/templates/apps/api/src/openapi/__tests__/surface-drift.test.ts +293 -0
  55. package/templates/apps/api/src/openapi/index.ts +9 -3
  56. package/templates/apps/api/src/openapi/spec.ts +505 -65
  57. package/templates/apps/api/src/openapi/surface-drift.ts +195 -0
  58. package/templates/apps/api/src/routes/content/__tests__/create.test.ts +14 -0
  59. package/templates/apps/api/src/routes/content/__tests__/delete.test.ts +14 -0
  60. package/templates/apps/api/src/routes/content/__tests__/update.test.ts +14 -0
  61. package/templates/apps/api/src/routes/content/index.ts +38 -11
  62. package/templates/apps/api/src/routes/project-listings/__tests__/configured-application.test.ts +17 -0
  63. package/templates/apps/api/src/routes/project-listings/__tests__/drafts.test.ts +17 -0
  64. package/templates/apps/api/src/routes/project-listings/__tests__/founder-identity.test.ts +796 -0
  65. package/templates/apps/api/src/routes/project-listings/__tests__/me.test.ts +10 -0
  66. package/templates/apps/api/src/routes/project-listings/__tests__/public.test.ts +38 -0
  67. package/templates/apps/api/src/routes/project-listings/__tests__/resume-email.test.ts +814 -0
  68. package/templates/apps/api/src/routes/project-listings/__tests__/site-answers.test.ts +10 -0
  69. package/templates/apps/api/src/routes/project-listings/__tests__/site-key.test.ts +24 -0
  70. package/templates/apps/api/src/routes/project-listings/__tests__/structured-address.test.ts +17 -0
  71. package/templates/apps/api/src/routes/project-listings/index.ts +37 -10
  72. package/templates/apps/api/src/routes/project-listings/me.ts +10 -0
  73. package/templates/apps/api/src/routes/project-listings/patch-draft.ts +5 -0
  74. package/templates/apps/api/src/routes/project-listings/public.ts +5 -0
  75. package/templates/apps/api/src/routes/project-listings/respond.ts +11 -0
  76. package/templates/apps/api/src/routes/project-listings/resume-token.ts +5 -0
  77. package/templates/apps/api/src/routes/project-listings/start.ts +30 -0
  78. package/templates/apps/api/src/routes/project-listings/submit.ts +5 -0
  79. package/templates/apps/api/src/routes/project-listings/verify-email.ts +150 -0
  80. package/templates/apps/api/src/server.ts +504 -137
  81. package/templates/apps/api/src/services/deliverable-fulfillment.ts +10 -0
  82. package/templates/apps/api/src/services/listing-config.ts +58 -0
  83. package/templates/apps/api/src/services/project-listing-resume-email.ts +326 -0
  84. package/templates/apps/api/src/services/project-listings.ts +229 -4
  85. package/templates/apps/api/src/vercel-handler.ts +60 -26
  86. package/templates/apps/mcp/.env.example +8 -0
  87. package/templates/apps/mcp/CLAUDE.md +2 -2
  88. package/templates/apps/mcp/__tests__/catalog-drift.test.ts +21 -0
  89. package/templates/apps/mcp/__tests__/config/modules.test.ts +78 -0
  90. package/templates/apps/mcp/__tests__/module-exclusion.test.ts +1 -1
  91. package/templates/apps/mcp/src/config/modules.ts +14 -13
  92. package/templates/apps/mcp/src/server.ts +8 -4
  93. package/templates/apps/mcp/src/tools/index.ts +7 -22
  94. package/templates/apps/web/.env.example +15 -0
  95. package/templates/apps/web/app/[locale]/(authed)/{waitlist/__tests__ → __tests__}/gate.test.tsx +4 -4
  96. package/templates/apps/web/app/[locale]/flows/[slug]/FlowStepperClient.tsx +2 -1
  97. package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/FlowStepperClient.permalink.test.tsx +2 -1
  98. package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/FlowStepperClient.test.tsx +2 -1
  99. package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/flow.actions.test.ts +1 -0
  100. package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/two-route-tool.test.tsx +1 -0
  101. package/templates/apps/web/app/[locale]/flows/[slug]/flow.actions.ts +2 -1
  102. package/templates/apps/web/app/[locale]/flows/[slug]/page.tsx +1 -0
  103. package/templates/apps/web/app/[locale]/flows/[slug]/start/page.tsx +1 -0
  104. package/templates/apps/web/app/[locale]/pricing/page.tsx +40 -0
  105. package/templates/apps/web/app/[locale]/privacy/page.tsx +40 -0
  106. package/templates/apps/web/app/[locale]/terms/page.tsx +40 -0
  107. package/templates/apps/web/app/llms.txt/route.ts +12 -9
  108. package/templates/apps/web/jest.config.cjs +19 -13
  109. package/templates/apps/web/lib/__tests__/server-api.test.ts +61 -0
  110. package/templates/apps/web/lib/server-api.ts +28 -12
  111. package/templates/apps/web/messages/en.json +58 -0
  112. package/templates/apps/web/next.config.mjs +1 -3
  113. package/templates/apps/web/package.json +0 -6
  114. package/templates/apps/web/public/android-chrome-192x192.png +0 -0
  115. package/templates/apps/web/public/android-chrome-512x512.png +0 -0
  116. package/templates/apps/web/public/apple-touch-icon.png +0 -0
  117. package/templates/apps/web/public/favicon-16x16.png +0 -0
  118. package/templates/apps/web/public/favicon-32x32.png +0 -0
  119. package/templates/apps/web/public/favicon.ico +0 -0
  120. package/templates/apps/web/public/favicon.svg +6 -3
  121. package/templates/apps/web/public/lockup-horizontal.svg +4 -0
  122. package/templates/apps/web/public/logomark.svg +4 -0
  123. package/templates/apps/web/public/site.webmanifest +2 -2
  124. package/templates/apps/web/public/wordmark.svg +4 -0
  125. package/templates/database/CHANGELOG.md +47 -0
  126. package/templates/database/inbox/schema.prisma +67 -0
  127. package/templates/database/migrations/20260914120000_listing_founder_identity/migration.sql +123 -0
  128. package/templates/database/package.json +1 -1
  129. package/templates/package.json +1 -1
  130. package/templates/.claude/skills/scaffold-commerce/SKILL.md +0 -807
  131. package/templates/.claude/skills/scaffold-commerce/references/lookup-keys-template.md +0 -71
  132. package/templates/.claude/skills/scaffold-commerce/references/marketplace-brand-pages.md +0 -83
  133. package/templates/.claude/skills/scaffold-commerce/templates/checkout-route.template.ts +0 -370
  134. package/templates/.claude/skills/scaffold-commerce/templates/invoice-route.template.ts +0 -376
  135. package/templates/.claude/skills/scaffold-commerce/templates/marketplace-cta-route.tsx.tmpl +0 -72
  136. package/templates/.claude/skills/scaffold-commerce/templates/payment-link-route.template.ts +0 -471
  137. package/templates/.claude/skills/scaffold-commerce/templates/portal-route.template.ts +0 -365
  138. package/templates/.claude/skills/scaffold-commerce/templates/stripe-config.template.ts +0 -60
  139. package/templates/.claude/skills/scaffold-commerce/templates/subscription-route.template.ts +0 -446
  140. package/templates/apps/api/src/__tests__/server.test.ts +0 -253
  141. package/templates/apps/api/src/bin/deliverable-resend.ts +0 -144
  142. package/templates/apps/api/src/bin/deliverable-upload.ts +0 -114
  143. package/templates/apps/api/src/bin/followup-sweep.ts +0 -44
  144. package/templates/apps/api/src/bin/listing-csv-sweep.ts +0 -46
  145. package/templates/apps/api/src/bin/seed-presets.ts +0 -58
  146. package/templates/apps/api/src/bin/seed-waitlist-experiments.ts +0 -104
  147. package/templates/apps/api/src/bin/session-retention-sweep.ts +0 -46
  148. package/templates/apps/api/src/lib/__tests__/stripe.test.ts +0 -74
  149. package/templates/apps/api/src/lib/stripe.ts +0 -52
  150. package/templates/apps/api/src/lib/tenant-db.ts +0 -225
  151. package/templates/apps/api/src/lib/ws-token.ts +0 -91
  152. package/templates/apps/api/src/middleware/tenant.ts +0 -99
  153. package/templates/apps/api/src/routes/billing/__tests__/portal.test.ts +0 -450
  154. package/templates/apps/api/src/routes/billing/__tests__/read.test.ts +0 -262
  155. package/templates/apps/api/src/routes/billing/__tests__/usage.test.ts +0 -436
  156. package/templates/apps/api/src/routes/billing/index.ts +0 -36
  157. package/templates/apps/api/src/routes/billing/portal.ts +0 -182
  158. package/templates/apps/api/src/routes/billing/read.ts +0 -75
  159. package/templates/apps/api/src/routes/billing/usage.ts +0 -153
  160. package/templates/apps/api/src/routes/checkout/__tests__/sessions.test.ts +0 -687
  161. package/templates/apps/api/src/routes/checkout/index.ts +0 -13
  162. package/templates/apps/api/src/routes/checkout/sessions.ts +0 -197
  163. package/templates/apps/api/src/routes/deliverables/__tests__/index.test.ts +0 -393
  164. package/templates/apps/api/src/routes/deliverables/index.ts +0 -200
  165. package/templates/apps/api/src/routes/flow-checkouts/__tests__/index.test.ts +0 -443
  166. package/templates/apps/api/src/routes/flow-checkouts/index.ts +0 -82
  167. package/templates/apps/api/src/routes/flows/README.md +0 -147
  168. package/templates/apps/api/src/routes/flows/__tests__/index.test.ts +0 -752
  169. package/templates/apps/api/src/routes/flows/__tests__/recommender.test.ts +0 -671
  170. package/templates/apps/api/src/routes/flows/index.ts +0 -202
  171. package/templates/apps/api/src/routes/flows/recommender.ts +0 -443
  172. package/templates/apps/api/src/routes/project-listings/__tests__/copy-edit.test.ts +0 -815
  173. package/templates/apps/api/src/routes/project-listings/__tests__/me-route-precedence.test.ts +0 -221
  174. package/templates/apps/api/src/routes/project-listings/__tests__/tenant-isolation.test.ts +0 -566
  175. package/templates/apps/api/src/routes/project-listings/get.ts +0 -70
  176. package/templates/apps/api/src/routes/project-listings/list.ts +0 -47
  177. package/templates/apps/api/src/routes/project-listings/patch.ts +0 -136
  178. package/templates/apps/api/src/routes/schedule/__tests__/index.test.ts +0 -490
  179. package/templates/apps/api/src/routes/schedule/index.ts +0 -249
  180. package/templates/apps/api/src/routes/slack/__tests__/actions.test.ts +0 -385
  181. package/templates/apps/api/src/routes/slack/actions.ts +0 -177
  182. package/templates/apps/api/src/routes/slack/index.ts +0 -38
  183. package/templates/apps/api/src/routes/waitlist-experiments/__tests__/tenant-isolation.test.ts +0 -577
  184. package/templates/apps/api/src/routes/waitlist-experiments/comparison.ts +0 -78
  185. package/templates/apps/api/src/routes/waitlist-experiments/index.ts +0 -41
  186. package/templates/apps/api/src/routes/waitlist-experiments/list.ts +0 -64
  187. package/templates/apps/api/src/routes/waitlist-experiments/respond.ts +0 -42
  188. package/templates/apps/api/src/routes/waitlist-experiments/signup.ts +0 -84
  189. package/templates/apps/api/src/routes/waitlist-experiments/signups.ts +0 -119
  190. package/templates/apps/api/src/routes/waitlist-signups/__tests__/index.test.ts +0 -238
  191. package/templates/apps/api/src/routes/waitlist-signups/index.ts +0 -101
  192. package/templates/apps/api/src/routes/webhooks/README.md +0 -80
  193. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-flow-checkout.test.ts +0 -315
  194. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-idempotency.test.ts +0 -247
  195. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-org-billing.test.ts +0 -270
  196. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-rate-limit.test.ts +0 -198
  197. package/templates/apps/api/src/routes/webhooks/__tests__/stripe.test.ts +0 -633
  198. package/templates/apps/api/src/routes/webhooks/index.ts +0 -41
  199. package/templates/apps/api/src/routes/webhooks/stripe.ts +0 -481
  200. package/templates/apps/api/src/routes/workspaces/__tests__/config-tenant-isolation.test.ts +0 -356
  201. package/templates/apps/api/src/routes/workspaces/__tests__/tenant-isolation.test.ts +0 -671
  202. package/templates/apps/api/src/routes/workspaces/config.ts +0 -125
  203. package/templates/apps/api/src/routes/workspaces/index.ts +0 -194
  204. package/templates/apps/api/src/routes/workspaces/sessions.ts +0 -218
  205. package/templates/apps/api/src/services/__tests__/flow-aggregate.test.ts +0 -425
  206. package/templates/apps/api/src/services/__tests__/flow-engine.test.ts +0 -2840
  207. package/templates/apps/api/src/services/__tests__/lead-promotion.test.ts +0 -393
  208. package/templates/apps/api/src/services/__tests__/listing-csv-sweep.test.ts +0 -560
  209. package/templates/apps/api/src/services/__tests__/listing-promotion.test.ts +0 -684
  210. package/templates/apps/api/src/services/__tests__/playbook-compile.test.ts +0 -406
  211. package/templates/apps/api/src/services/__tests__/playbook-render.test.ts +0 -290
  212. package/templates/apps/api/src/services/__tests__/project-listing-decision.test.ts +0 -736
  213. package/templates/apps/api/src/services/__tests__/project-listing-flow.test.ts +0 -475
  214. package/templates/apps/api/src/services/__tests__/project-listing-issue.test.ts +0 -340
  215. package/templates/apps/api/src/services/__tests__/recommender-capture.test.ts +0 -983
  216. package/templates/apps/api/src/services/__tests__/tenant-context-cascade.test.ts +0 -71
  217. package/templates/apps/api/src/services/__tests__/tenant-context.test.ts +0 -379
  218. package/templates/apps/api/src/services/__tests__/usage-metering.test.ts +0 -768
  219. package/templates/apps/api/src/services/__tests__/waitlist-dashboard.test.ts +0 -1314
  220. package/templates/apps/api/src/services/__tests__/waitlist-experiments.test.ts +0 -341
  221. package/templates/apps/api/src/services/__tests__/waitlist-followup.test.ts +0 -567
  222. package/templates/apps/api/src/services/__tests__/waitlist-scoring.test.ts +0 -474
  223. package/templates/apps/api/src/services/__tests__/waitlist-signups.test.ts +0 -354
  224. package/templates/apps/api/src/services/flow-aggregate.ts +0 -232
  225. package/templates/apps/api/src/services/flow-checkouts.ts +0 -123
  226. package/templates/apps/api/src/services/flow-engine.ts +0 -1296
  227. package/templates/apps/api/src/services/lead-promotion.ts +0 -176
  228. package/templates/apps/api/src/services/listing-csv-sweep.ts +0 -455
  229. package/templates/apps/api/src/services/listing-promotion.ts +0 -342
  230. package/templates/apps/api/src/services/playbook-compile.ts +0 -398
  231. package/templates/apps/api/src/services/playbook-render.ts +0 -263
  232. package/templates/apps/api/src/services/project-listing-decision.ts +0 -510
  233. package/templates/apps/api/src/services/project-listing-flow.ts +0 -426
  234. package/templates/apps/api/src/services/project-listing-issue.ts +0 -251
  235. package/templates/apps/api/src/services/recommender-capture.ts +0 -835
  236. package/templates/apps/api/src/services/scheduling/cal-provider.ts +0 -392
  237. package/templates/apps/api/src/services/scheduling/index.ts +0 -63
  238. package/templates/apps/api/src/services/scheduling/types.ts +0 -88
  239. package/templates/apps/api/src/services/tenant-context.ts +0 -451
  240. package/templates/apps/api/src/services/usage-metering.ts +0 -699
  241. package/templates/apps/api/src/services/waitlist-dashboard.ts +0 -947
  242. package/templates/apps/api/src/services/waitlist-experiments.ts +0 -213
  243. package/templates/apps/api/src/services/waitlist-followup.ts +0 -486
  244. package/templates/apps/api/src/services/waitlist-scoring.ts +0 -166
  245. package/templates/apps/api/src/services/waitlist-signups.ts +0 -165
  246. package/templates/apps/mcp/__tests__/schedule_tools.test.ts +0 -242
  247. package/templates/apps/mcp/src/tools/create_booking.ts +0 -58
  248. package/templates/apps/mcp/src/tools/get_event_meta.ts +0 -55
  249. package/templates/apps/mcp/src/tools/get_flow.ts +0 -61
  250. package/templates/apps/mcp/src/tools/list_availability.ts +0 -67
  251. package/templates/apps/mcp/src/tools/submit_flow_step.ts +0 -83
  252. package/templates/apps/web/app/[locale]/(authed)/waitlist/[experimentId]/page.tsx +0 -309
  253. package/templates/apps/web/app/[locale]/(authed)/waitlist/[experimentId]/signups/[leadId]/page.tsx +0 -149
  254. package/templates/apps/web/app/[locale]/(authed)/waitlist/__tests__/comparison.test.tsx +0 -273
  255. package/templates/apps/web/app/[locale]/(authed)/waitlist/__tests__/list.test.tsx +0 -171
  256. package/templates/apps/web/app/[locale]/(authed)/waitlist/__tests__/signup.test.tsx +0 -179
  257. package/templates/apps/web/app/[locale]/(authed)/waitlist/components/ExperimentsTable.tsx +0 -116
  258. package/templates/apps/web/app/[locale]/(authed)/waitlist/components/OfferFunnelTable.tsx +0 -66
  259. package/templates/apps/web/app/[locale]/(authed)/waitlist/components/RollupTicker.tsx +0 -61
  260. package/templates/apps/web/app/[locale]/(authed)/waitlist/components/ScoreTrace.tsx +0 -114
  261. package/templates/apps/web/app/[locale]/(authed)/waitlist/components/SignupsTable.tsx +0 -139
  262. package/templates/apps/web/app/[locale]/(authed)/waitlist/components/WaitlistComparisonTabs.tsx +0 -85
  263. package/templates/apps/web/app/[locale]/(authed)/waitlist/components/status-badges.tsx +0 -67
  264. package/templates/apps/web/app/[locale]/(authed)/waitlist/page.tsx +0 -179
  265. package/templates/apps/web/app/[locale]/blog/[[...slug]]/page.tsx +0 -178
  266. package/templates/apps/web/app/[locale]/dev/purchase/__tests__/actions.test.ts +0 -160
  267. package/templates/apps/web/app/[locale]/dev/purchase/error.tsx +0 -25
  268. package/templates/apps/web/app/[locale]/dev/purchase/page.tsx +0 -117
  269. package/templates/apps/web/app/[locale]/dev/purchase/purchase.actions.ts +0 -58
  270. package/templates/apps/web/app/[locale]/docs/[[...slug]]/page.tsx +0 -119
  271. package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/checkout.actions.test.ts +0 -287
  272. package/templates/apps/web/app/[locale]/flows/[slug]/checkout.actions.ts +0 -254
  273. package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/ResultsClient.tsx +0 -532
  274. package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/ResultsEmailStep.tsx +0 -121
  275. package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/__tests__/ResultsClient.checkout.test.tsx +0 -457
  276. package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/__tests__/page.test.tsx +0 -396
  277. package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/page.tsx +0 -114
  278. package/templates/apps/web/app/[locale]/projects/apply/ListingApplicationClient.tsx +0 -110
  279. package/templates/apps/web/app/[locale]/projects/apply/__tests__/ListingApplicationClient.test.tsx +0 -96
  280. package/templates/apps/web/app/[locale]/schedule/__tests__/page.test.tsx +0 -117
  281. package/templates/apps/web/app/[locale]/schedule/confirmed/__tests__/page.test.tsx +0 -96
  282. package/templates/apps/web/app/[locale]/schedule/confirmed/page.tsx +0 -92
  283. package/templates/apps/web/app/[locale]/schedule/page.tsx +0 -82
  284. package/templates/apps/web/content/en/blog/README.txt +0 -25
  285. package/templates/apps/web/content/en/docs/index.mdx +0 -28
  286. package/templates/apps/web/lib/__mocks__/source-server.js +0 -31
  287. package/templates/apps/web/lib/blog-list-data.ts +0 -95
  288. package/templates/apps/web/lib/blog-post-data.ts +0 -79
  289. package/templates/apps/web/lib/source.ts +0 -34
  290. package/templates/apps/web/public/logomark-dark.svg +0 -4
  291. package/templates/apps/web/public/logomark-light.svg +0 -4
  292. 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
- }