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,577 +0,0 @@
1
- // oss: false
2
- /**
3
- * @file tenant-isolation.test.ts
4
- *
5
- * THE ACCEPTANCE GATE for the operator dashboard read API (#4665 D3/D7,
6
- * #4671 AC1/AC3/AC4). Registration mirrors server.ts exactly: REAL
7
- * `authPreHandlerPlugin -> tenantPreHandlerPlugin -> waitlistExperimentRoutes`,
8
- * mocked `lib/prisma.js`, fixture sessions for: a sentinel-org owner (sees
9
- * two sites), a second-org admin (sees only its site; 404 on the other
10
- * site's experiment id and lead id), a second-org member (403), a bearer
11
- * token (403), no cookie (401). Every success body parses under the D6
12
- * schema.
13
- */
14
- import Fastify from "fastify";
15
- import type { FastifyInstance } from "fastify";
16
- import {
17
- waitlistDashboardListSchema,
18
- waitlistExperimentComparisonSchema,
19
- waitlistSignupDetailSchema,
20
- waitlistSignupListSchema,
21
- } from "@working-theory/validation";
22
-
23
- // eslint-disable-next-line no-restricted-syntax -- jest mock factory must be hoisted
24
- jest.mock("../../../lib/prisma.js", () => ({
25
- prisma: {
26
- session: { findUnique: jest.fn() },
27
- apiToken: {
28
- findUnique: jest.fn(),
29
- update: jest.fn().mockResolvedValue(undefined),
30
- },
31
- workspace: { findUnique: jest.fn(), count: jest.fn() },
32
- membership: { findUnique: jest.fn() },
33
- waitlistExperiment: { findMany: jest.fn(), findFirst: jest.fn() },
34
- lead: { findMany: jest.fn(), findFirst: jest.fn() },
35
- waitlistScore: { findMany: jest.fn(), findFirst: jest.fn(), findUnique: jest.fn() },
36
- },
37
- }));
38
-
39
- import { prisma } from "../../../lib/prisma.js";
40
- import { authPreHandlerPlugin } from "../../../middleware/auth.js";
41
- import { tenantPreHandlerPlugin } from "../../../middleware/tenant.js";
42
- import { waitlistExperimentRoutes } from "../index.js";
43
-
44
- const FRONTEND = "https://app.example.com";
45
- const FUTURE = new Date(Date.now() + 60 * 60 * 1000);
46
-
47
- // ── Fixture ids (DEFAULT_ORG_ID / DEFAULT_WORKSPACE_ID from database/tenancy.ts) ──
48
-
49
- const ORG_SENTINEL = "org_00000000-0000-7000-8000-000000000001";
50
- const WS_A = "wksp_00000000-0000-7000-8000-000000000001";
51
- // A genuinely non-sentinel org — a real (non-zero-timestamp) v7 uuid.
52
- const ORG_B = "org_01991234-5678-7000-8000-0000000000bb";
53
- const WS_B = "wksp_00000000-0000-7000-8000-0000000000bb";
54
-
55
- const WORKSPACES = [
56
- { id: WS_A, organizationId: ORG_SENTINEL },
57
- { id: WS_B, organizationId: ORG_B },
58
- ];
59
-
60
- const MEMBERSHIPS = [
61
- { organizationId: ORG_SENTINEL, userId: "alice", role: "owner" },
62
- { organizationId: ORG_B, userId: "bob", role: "owner" },
63
- { organizationId: ORG_B, userId: "erin", role: "admin" },
64
- { organizationId: ORG_B, userId: "carol", role: "member" },
65
- ];
66
-
67
- const SESSION_ROWS: Record<string, Record<string, unknown>> = {
68
- cookie_alice: {
69
- id: "sess_alice",
70
- userId: "alice",
71
- expiresAt: FUTURE,
72
- activeOrganizationId: ORG_SENTINEL,
73
- activeWorkspaceId: WS_A,
74
- },
75
- cookie_bob: {
76
- id: "sess_bob",
77
- userId: "bob",
78
- expiresAt: FUTURE,
79
- activeOrganizationId: ORG_B,
80
- activeWorkspaceId: WS_B,
81
- },
82
- cookie_erin: {
83
- id: "sess_erin",
84
- userId: "erin",
85
- expiresAt: FUTURE,
86
- activeOrganizationId: ORG_B,
87
- activeWorkspaceId: WS_B,
88
- },
89
- cookie_carol: {
90
- id: "sess_carol",
91
- userId: "carol",
92
- expiresAt: FUTURE,
93
- activeOrganizationId: ORG_B,
94
- activeWorkspaceId: WS_B,
95
- },
96
- };
97
-
98
- // ── Waitlist-domain fixtures ──────────────────────────────────────────────────
99
-
100
- const en = (s: string) => ({ en: s });
101
-
102
- const EXP_A = "wlexp_00000000-0000-7000-8000-0000000000aa"; // site A (sentinel), scored
103
- const EXP_B = "wlexp_00000000-0000-7000-8000-0000000000bb"; // site B (org B), unscored
104
-
105
- const CONFIG_A = {
106
- key: "exp-a",
107
- mode: "qualifying" as const,
108
- status: "live" as const,
109
- waitlists: [{ key: "wl1", flowSlug: "exp-a-wl1" }],
110
- questionSet: {
111
- version: 1 as const,
112
- questions: [
113
- {
114
- id: "role",
115
- prompt: en("Role?"),
116
- answerType: "single" as const,
117
- options: [
118
- { value: "founder", label: en("Founder") },
119
- { value: "other", label: en("Other") },
120
- ],
121
- },
122
- ],
123
- },
124
- scoring: {
125
- version: 1 as const,
126
- points: [
127
- { name: "role_pt", question: "role", kind: "optionWeights" as const, weights: { founder: 5, other: 0 } },
128
- ],
129
- scores: { total: { sum: ["role_pt"] } },
130
- tiers: [
131
- { key: "hot", when: { score: "total", gte: 5 } },
132
- { key: "cold", when: { score: "total", gte: 0 } },
133
- ],
134
- default: "cold",
135
- serviceableExcludes: ["cold"],
136
- },
137
- tracking: {
138
- version: 1 as const,
139
- utm: { source: "utm_source", medium: "utm_medium", campaign: "utm_campaign" },
140
- waitlists: { wl1: { campaign: "a-campaign", channels: ["organic"] } },
141
- },
142
- stripeConfigRef: null,
143
- };
144
-
145
- const CONFIG_B = {
146
- key: "exp-b",
147
- mode: "lead-capture" as const,
148
- status: "draft" as const,
149
- waitlists: [{ key: "wl2", flowSlug: "exp-b-wl2" }],
150
- questionSet: {
151
- version: 1 as const,
152
- questions: [{ id: "role", prompt: en("Role?"), answerType: "text" as const, minLength: 1 }],
153
- },
154
- tracking: {
155
- version: 1 as const,
156
- utm: { source: "utm_source", medium: "utm_medium", campaign: "utm_campaign" },
157
- waitlists: { wl2: { campaign: "b-campaign", channels: ["organic"] } },
158
- },
159
- stripeConfigRef: null,
160
- };
161
-
162
- const EXPERIMENTS = [
163
- {
164
- id: EXP_A,
165
- siteId: WS_A,
166
- key: CONFIG_A.key,
167
- mode: CONFIG_A.mode,
168
- status: CONFIG_A.status,
169
- config: CONFIG_A,
170
- createdAt: new Date("2026-08-01T00:00:00.000Z"),
171
- updatedAt: new Date("2026-08-01T00:00:00.000Z"),
172
- },
173
- {
174
- id: EXP_B,
175
- siteId: WS_B,
176
- key: CONFIG_B.key,
177
- mode: CONFIG_B.mode,
178
- status: CONFIG_B.status,
179
- config: CONFIG_B,
180
- createdAt: new Date("2026-08-02T00:00:00.000Z"),
181
- updatedAt: new Date("2026-08-02T00:00:00.000Z"),
182
- },
183
- ];
184
-
185
- const LEAD_A1 = "lead_00000000-0000-7000-8000-0000000000a1"; // site A, scored hot
186
- const LEAD_A2 = "lead_00000000-0000-7000-8000-0000000000a2"; // site A, unscored
187
- const LEAD_B1 = "lead_00000000-0000-7000-8000-0000000000b1"; // site B
188
-
189
- const LEADS = [
190
- {
191
- id: LEAD_A1,
192
- siteId: WS_A,
193
- email: "a1@example.com",
194
- source: "flow:exp-a-wl1",
195
- payload: { experimentKey: "exp-a", waitlistKey: "wl1", role: "founder" },
196
- createdAt: new Date("2026-08-10T00:00:00.000Z"),
197
- },
198
- {
199
- id: LEAD_A2,
200
- siteId: WS_A,
201
- email: "a2@example.com",
202
- source: "flow:exp-a-wl1",
203
- payload: { experimentKey: "exp-a", waitlistKey: "wl1", role: "other" },
204
- createdAt: new Date("2026-08-11T00:00:00.000Z"),
205
- },
206
- {
207
- id: LEAD_B1,
208
- siteId: WS_B,
209
- email: "b1@example.com",
210
- source: "flow:exp-b-wl2",
211
- payload: { experimentKey: "exp-b", waitlistKey: "wl2", role: "member-org" },
212
- createdAt: new Date("2026-08-12T00:00:00.000Z"),
213
- },
214
- ];
215
-
216
- const SCORES = [
217
- {
218
- leadId: LEAD_A1,
219
- siteId: WS_A,
220
- experimentKey: "exp-a",
221
- tier: "hot",
222
- score: 5,
223
- categoryScores: { total: 5 },
224
- ruleTrace: { fired: ["role_pt"] },
225
- updatedAt: new Date("2026-08-10T01:00:00.000Z"),
226
- },
227
- ];
228
-
229
- // ── A generic, where-clause-honoring in-memory matcher (mirrors
230
- // services/__tests__/waitlist-dashboard.test.ts's discipline) ────────────────
231
-
232
- type Row = Record<string, unknown>;
233
- type Where = Record<string, unknown>;
234
-
235
- function valuesEqual(a: unknown, b: unknown): boolean {
236
- if (a instanceof Date && b instanceof Date) return a.getTime() === b.getTime();
237
- return a === b;
238
- }
239
- function lessThan(a: unknown, b: unknown): boolean {
240
- if (a instanceof Date && b instanceof Date) return a.getTime() < b.getTime();
241
- return (a as never) < (b as never);
242
- }
243
-
244
- function matchesWhere(row: Row, where: Where): boolean {
245
- for (const [key, cond] of Object.entries(where)) {
246
- if (key === "OR") {
247
- if (!(cond as Where[]).some((w) => matchesWhere(row, w))) return false;
248
- continue;
249
- }
250
- if (key === "AND") {
251
- if (!(cond as Where[]).every((w) => matchesWhere(row, w))) return false;
252
- continue;
253
- }
254
- if (key === "payload") {
255
- const { path, equals } = cond as { path: string[]; equals: unknown };
256
- const payload = row.payload as Record<string, unknown> | undefined;
257
- const value = payload ? payload[path[0] as string] : undefined;
258
- if (!valuesEqual(value, equals)) return false;
259
- continue;
260
- }
261
- const rowValue = row[key];
262
- if (cond !== null && typeof cond === "object" && !Array.isArray(cond) && !(cond instanceof Date)) {
263
- const condObj = cond as Record<string, unknown>;
264
- if ("in" in condObj) {
265
- if (!(condObj.in as unknown[]).some((v) => valuesEqual(v, rowValue))) return false;
266
- continue;
267
- }
268
- if ("lt" in condObj) {
269
- if (!lessThan(rowValue, condObj.lt)) return false;
270
- continue;
271
- }
272
- }
273
- if (!valuesEqual(rowValue, cond)) return false;
274
- }
275
- return true;
276
- }
277
-
278
- function sortRows(rows: Row[], orderBy: unknown): Row[] {
279
- if (!orderBy) return rows;
280
- const orders = Array.isArray(orderBy) ? orderBy : [orderBy];
281
- return [...rows].sort((a, b) => {
282
- for (const ord of orders as Array<Record<string, "asc" | "desc">>) {
283
- const [field, dir] = Object.entries(ord)[0] as [string, "asc" | "desc"];
284
- const av = a[field];
285
- const bv = b[field];
286
- let cmp: number;
287
- if (av instanceof Date && bv instanceof Date) cmp = av.getTime() - bv.getTime();
288
- else if ((av as never) < (bv as never)) cmp = -1;
289
- else if ((av as never) > (bv as never)) cmp = 1;
290
- else cmp = 0;
291
- if (dir === "desc") cmp = -cmp;
292
- if (cmp !== 0) return cmp;
293
- }
294
- return 0;
295
- });
296
- }
297
-
298
- function installFixtureMocks(): void {
299
- (prisma.session.findUnique as jest.Mock).mockImplementation(
300
- ({ where }: { where: { token?: string } }) =>
301
- Promise.resolve(where.token ? (SESSION_ROWS[where.token] ?? null) : null),
302
- );
303
- (prisma.workspace.findUnique as jest.Mock).mockImplementation(
304
- ({ where }: { where: { id?: string } }) =>
305
- Promise.resolve(WORKSPACES.find((w) => w.id === where.id) ?? null),
306
- );
307
- // Genuinely multi-tenant (2 workspaces across 2 orgs) — the single-tenant
308
- // default-resolution branch (count() === 1) must never fire here.
309
- (prisma.workspace.count as jest.Mock).mockResolvedValue(WORKSPACES.length);
310
- (prisma.membership.findUnique as jest.Mock).mockImplementation(
311
- ({ where }: { where: { organizationId_userId?: { organizationId: string; userId: string } } }) => {
312
- const key = where.organizationId_userId;
313
- if (!key) return Promise.resolve(null);
314
- return Promise.resolve(
315
- MEMBERSHIPS.find((m) => m.organizationId === key.organizationId && m.userId === key.userId) ?? null,
316
- );
317
- },
318
- );
319
-
320
- (prisma.waitlistExperiment.findMany as jest.Mock).mockImplementation(
321
- ({ where }: { where?: Where }) =>
322
- Promise.resolve(sortRows(EXPERIMENTS.filter((e) => matchesWhere(e, where ?? {})), { createdAt: "desc" })),
323
- );
324
- (prisma.waitlistExperiment.findFirst as jest.Mock).mockImplementation(
325
- ({ where }: { where?: Where }) => Promise.resolve(EXPERIMENTS.find((e) => matchesWhere(e, where ?? {})) ?? null),
326
- );
327
- (prisma.lead.findMany as jest.Mock).mockImplementation(
328
- ({ where, orderBy, take }: { where?: Where; orderBy?: unknown; take?: number }) => {
329
- let rows = LEADS.filter((l) => matchesWhere(l, where ?? {}));
330
- rows = sortRows(rows, orderBy);
331
- if (typeof take === "number") rows = rows.slice(0, take);
332
- return Promise.resolve(rows);
333
- },
334
- );
335
- (prisma.lead.findFirst as jest.Mock).mockImplementation(({ where }: { where?: Where }) =>
336
- Promise.resolve(LEADS.find((l) => matchesWhere(l, where ?? {})) ?? null),
337
- );
338
- (prisma.waitlistScore.findMany as jest.Mock).mockImplementation(({ where }: { where?: Where }) =>
339
- Promise.resolve(SCORES.filter((s) => matchesWhere(s, where ?? {}))),
340
- );
341
- (prisma.waitlistScore.findFirst as jest.Mock).mockImplementation(({ where }: { where?: Where }) =>
342
- Promise.resolve(SCORES.find((s) => matchesWhere(s, where ?? {})) ?? null),
343
- );
344
- (prisma.waitlistScore.findUnique as jest.Mock).mockImplementation(
345
- ({ where }: { where: { leadId: string } }) => Promise.resolve(SCORES.find((s) => s.leadId === where.leadId) ?? null),
346
- );
347
- }
348
-
349
- // ── App assembly (mirrors server.ts registration order) ─────────────────────
350
-
351
- async function buildApp(): Promise<FastifyInstance> {
352
- const app = Fastify();
353
- app.register(authPreHandlerPlugin, { frontendOrigin: FRONTEND });
354
- app.register(async (scope) => {
355
- await scope.register(tenantPreHandlerPlugin);
356
- await scope.register(waitlistExperimentRoutes);
357
- });
358
- await app.ready();
359
- return app;
360
- }
361
-
362
- function cookieHeaders(token: string): Record<string, string> {
363
- return { cookie: `better-auth.session_token=${token}` };
364
- }
365
-
366
- const ORIGINAL_ENV = process.env;
367
-
368
- describe("waitlist-experiments dashboard — tenant isolation", () => {
369
- let app: FastifyInstance;
370
-
371
- beforeEach(async () => {
372
- jest.clearAllMocks();
373
- installFixtureMocks();
374
- // This suite's premise is a deployment where the platform-operator pin
375
- // is already set (alice, the sentinel-org owner, IS the platform
376
- // operator throughout) — the pin's own set/unset behavior is
377
- // `services/__tests__/waitlist-dashboard.test.ts`'s "isPlatformOperator /
378
- // resolveWaitlistReadScope" describe block's job, not this file's
379
- // (Sam-disposed 2026-09-02, PR #4687 security review, High finding).
380
- process.env = { ...ORIGINAL_ENV, AILK_OPERATOR_EMAIL: "op@example.com" };
381
- app = await buildApp();
382
- });
383
-
384
- afterEach(async () => {
385
- await app.close();
386
- });
387
-
388
- afterAll(() => {
389
- process.env = ORIGINAL_ENV;
390
- });
391
-
392
- // ── AC4 — the sentinel-org owner is the platform operator (sees both sites) ──
393
-
394
- it("a sentinel-org owner sees both sites' experiments (scope: 'all', rollup.sites = 2)", async () => {
395
- const res = await app.inject({
396
- method: "GET",
397
- url: "/v1/waitlist-experiments",
398
- headers: cookieHeaders("cookie_alice"),
399
- });
400
- expect(res.statusCode).toBe(200);
401
- const body = res.json();
402
- expect(waitlistDashboardListSchema.safeParse(body.data).success).toBe(true);
403
- expect(body.data.scope).toBe("all");
404
- expect(body.data.experiments.map((e: { id: string }) => e.id).sort()).toEqual([EXP_A, EXP_B].sort());
405
- expect(body.data.rollup.sites).toBe(2);
406
- });
407
-
408
- // ── AC4 — a second-org admin sees only its own site's experiment ────────────
409
-
410
- it("a second-org admin sees only its own site's experiment (scope: 'site')", async () => {
411
- const res = await app.inject({
412
- method: "GET",
413
- url: "/v1/waitlist-experiments",
414
- headers: cookieHeaders("cookie_erin"),
415
- });
416
- expect(res.statusCode).toBe(200);
417
- const body = res.json();
418
- expect(body.data.scope).toBe("site");
419
- expect(body.data.experiments.map((e: { id: string }) => e.id)).toEqual([EXP_B]);
420
- expect(body.data.rollup.sites).toBe(1);
421
- });
422
-
423
- // ── AC4 — a foreign experiment/lead id is 404, indistinguishable from missing ──
424
-
425
- it("404s on the other site's experiment id, and on a foreign lead id", async () => {
426
- const foreignExperiment = await app.inject({
427
- method: "GET",
428
- url: `/v1/waitlist-experiments/${EXP_A}`,
429
- headers: cookieHeaders("cookie_erin"),
430
- });
431
- expect(foreignExperiment.statusCode).toBe(404);
432
-
433
- const foreignLead = await app.inject({
434
- method: "GET",
435
- url: `/v1/waitlist-experiments/${EXP_B}/signups/${LEAD_A1}`,
436
- headers: cookieHeaders("cookie_erin"),
437
- });
438
- expect(foreignLead.statusCode).toBe(404);
439
-
440
- const missing = await app.inject({
441
- method: "GET",
442
- url: "/v1/waitlist-experiments/wlexp_00000000-0000-7000-8000-00000000dead",
443
- headers: cookieHeaders("cookie_alice"),
444
- });
445
- expect(missing.statusCode).toBe(404);
446
- // A foreign id and a genuinely-missing id are indistinguishable (the
447
- // no-enumeration posture, D3) — same status, same error shape.
448
- expect(missing.json().error.code).toBe(foreignExperiment.json().error.code);
449
- expect(missing.body).toBe(foreignExperiment.body);
450
- });
451
-
452
- // ── AC4 — a second-org member is refused before any read ─────────────────────
453
-
454
- it("a second-org member gets 403 on every route, before any read", async () => {
455
- const list = await app.inject({
456
- method: "GET",
457
- url: "/v1/waitlist-experiments",
458
- headers: cookieHeaders("cookie_carol"),
459
- });
460
- expect(list.statusCode).toBe(403);
461
- expect(prisma.waitlistExperiment.findMany).not.toHaveBeenCalled();
462
-
463
- const comparison = await app.inject({
464
- method: "GET",
465
- url: `/v1/waitlist-experiments/${EXP_B}`,
466
- headers: cookieHeaders("cookie_carol"),
467
- });
468
- expect(comparison.statusCode).toBe(403);
469
- });
470
-
471
- // ── AC4 — a bearer token gets 403 (no workspace binding, default-deny) ───────
472
-
473
- it("a bearer token gets 403 (no tenant binding)", async () => {
474
- (prisma.apiToken.findUnique as jest.Mock).mockResolvedValue({
475
- id: "tok1",
476
- userId: "bob",
477
- expiresAt: null,
478
- revokedAt: null,
479
- });
480
- const res = await app.inject({
481
- method: "GET",
482
- url: "/v1/waitlist-experiments",
483
- headers: { authorization: "Bearer tok_raw" },
484
- });
485
- expect(res.statusCode).toBe(403);
486
- expect(prisma.waitlistExperiment.findMany).not.toHaveBeenCalled();
487
- });
488
-
489
- // ── AC4 — no cookie gets 401 ───────────────────────────────────────────────
490
-
491
- it("no cookie gets 401", async () => {
492
- const res = await app.inject({ method: "GET", url: "/v1/waitlist-experiments" });
493
- expect(res.statusCode).toBe(401);
494
- });
495
-
496
- // ── AC2 — comparison, schema-valid ────────────────────────────────────────
497
-
498
- it("GET the comparison for an owned experiment — 200, schema-valid", async () => {
499
- const res = await app.inject({
500
- method: "GET",
501
- url: `/v1/waitlist-experiments/${EXP_A}`,
502
- headers: cookieHeaders("cookie_alice"),
503
- });
504
- expect(res.statusCode).toBe(200);
505
- const body = res.json();
506
- expect(waitlistExperimentComparisonSchema.safeParse(body.data).success).toBe(true);
507
- expect(body.data.waitlists[0].raw).toBe(2);
508
- expect(body.data.waitlists[0].qualified).toBe(1); // only LEAD_A1 (hot) qualifies
509
- });
510
-
511
- // ── AC1 — signups list, schema-valid, cursor pagination ──────────────────
512
-
513
- it("GET signups — 200, schema-valid, tier/score joined from WaitlistScore", async () => {
514
- const res = await app.inject({
515
- method: "GET",
516
- url: `/v1/waitlist-experiments/${EXP_A}/signups?limit=1`,
517
- headers: cookieHeaders("cookie_alice"),
518
- });
519
- expect(res.statusCode).toBe(200);
520
- const body = res.json();
521
- expect(waitlistSignupListSchema.safeParse(body.data).success).toBe(true);
522
- expect(body.data.items).toHaveLength(1);
523
- expect(body.data.items[0].leadId).toBe(LEAD_A2); // newest first
524
- expect(body.data.nextCursor).toBe(LEAD_A2);
525
- });
526
-
527
- it("400s on a malformed :experimentId", async () => {
528
- const res = await app.inject({
529
- method: "GET",
530
- url: "/v1/waitlist-experiments/not-a-valid-id/signups",
531
- headers: cookieHeaders("cookie_alice"),
532
- });
533
- expect(res.statusCode).toBe(400);
534
- });
535
-
536
- // ── AC3 — signup drill-down, scored and unscored ──────────────────────────
537
-
538
- it("GET a scored signup detail — schema-valid, carries tier/score/ruleTrace", async () => {
539
- const res = await app.inject({
540
- method: "GET",
541
- url: `/v1/waitlist-experiments/${EXP_A}/signups/${LEAD_A1}`,
542
- headers: cookieHeaders("cookie_alice"),
543
- });
544
- expect(res.statusCode).toBe(200);
545
- const body = res.json();
546
- expect(waitlistSignupDetailSchema.safeParse(body.data).success).toBe(true);
547
- expect(body.data.score).toEqual({
548
- tier: "hot",
549
- score: 5,
550
- categoryScores: { total: 5 },
551
- ruleTrace: { fired: ["role_pt"] },
552
- updatedAt: "2026-08-10T01:00:00.000Z",
553
- });
554
- });
555
-
556
- it("GET an unscored signup detail — score: null", async () => {
557
- const res = await app.inject({
558
- method: "GET",
559
- url: `/v1/waitlist-experiments/${EXP_A}/signups/${LEAD_A2}`,
560
- headers: cookieHeaders("cookie_alice"),
561
- });
562
- expect(res.statusCode).toBe(200);
563
- expect(res.json().data.score).toBeNull();
564
- });
565
-
566
- // bob (org B owner) can read his own org's data — the grant path works for
567
- // both tenants, mirroring usage.test.ts's equivalent assertion.
568
- it("bob (org B owner) can read his own org's data", async () => {
569
- const res = await app.inject({
570
- method: "GET",
571
- url: "/v1/waitlist-experiments",
572
- headers: cookieHeaders("cookie_bob"),
573
- });
574
- expect(res.statusCode).toBe(200);
575
- expect(res.json().data.experiments.map((e: { id: string }) => e.id)).toEqual([EXP_B]);
576
- });
577
- });
@@ -1,78 +0,0 @@
1
- // oss: false
2
- /**
3
- * GET /v1/waitlist-experiments/:experimentId
4
- *
5
- * Per-waitlist comparison + total (#4665 D7, B0). `:experimentId` is
6
- * validated with `zWaitlistExperimentId` (ADR-0031 rule 5) before any
7
- * lookup runs — a malformed id is `400`, never reaches the service. A
8
- * foreign or missing experiment id is `404`, indistinguishable (D3
9
- * no-enumeration posture).
10
- *
11
- * Role floor + tenant guard — see `list.ts`'s header; identical contract.
12
- */
13
- import { zWaitlistExperimentId } from "@working-theory/ids";
14
- import { waitlistExperimentComparisonSchema } from "@working-theory/validation";
15
- import type { FastifyReply, FastifyRequest } from "fastify";
16
- import { z } from "zod";
17
-
18
- import {
19
- getExperimentComparison,
20
- resolveWaitlistReadScope,
21
- WAITLIST_DASHBOARD_ROLES,
22
- } from "../../services/waitlist-dashboard.js";
23
-
24
- import { sendValidated } from "./respond.js";
25
-
26
- const paramsSchema = z.object({ experimentId: zWaitlistExperimentId });
27
-
28
- function unauthorized(reply: FastifyReply) {
29
- return reply.status(401).send({
30
- ok: false,
31
- error: { code: "unauthorized", message: "Authentication required" },
32
- });
33
- }
34
-
35
- function forbidden(reply: FastifyReply) {
36
- return reply.status(403).send({
37
- ok: false,
38
- error: { code: "forbidden", message: "Forbidden" },
39
- });
40
- }
41
-
42
- function invalidParams(reply: FastifyReply) {
43
- return reply.status(400).send({
44
- ok: false,
45
- error: { code: "validation_failed", message: "Invalid experiment id" },
46
- });
47
- }
48
-
49
- function notFound(reply: FastifyReply) {
50
- return reply.status(404).send({
51
- ok: false,
52
- error: { code: "not_found", message: "Experiment not found" },
53
- });
54
- }
55
-
56
- export async function getWaitlistExperimentComparisonHandler(
57
- request: FastifyRequest,
58
- reply: FastifyReply,
59
- ): Promise<void> {
60
- const tenant = request.tenant;
61
- if (!tenant) return unauthorized(reply);
62
- if (!WAITLIST_DASHBOARD_ROLES.has(tenant.role)) {
63
- request.log.warn(
64
- { method: request.method, path: request.url, status: 403 },
65
- "[waitlist-experiments] comparison denied; insufficient role",
66
- );
67
- return forbidden(reply);
68
- }
69
-
70
- const parsedParams = paramsSchema.safeParse(request.params);
71
- if (!parsedParams.success) return invalidParams(reply);
72
-
73
- const scope = resolveWaitlistReadScope(tenant);
74
- const result = await getExperimentComparison(scope, parsedParams.data.experimentId);
75
- if (!result.ok) return notFound(reply);
76
-
77
- return sendValidated(reply, waitlistExperimentComparisonSchema, result.value, "comparison");
78
- }
@@ -1,41 +0,0 @@
1
- // oss: false
2
- /**
3
- * Waitlist-experiments dashboard route group (#4665 D7, B0 — the API half of
4
- * the operator dashboard; #4671).
5
- *
6
- * Routes:
7
- * GET /v1/waitlist-experiments — list + rollup
8
- * GET /v1/waitlist-experiments/:experimentId — per-waitlist comparison + total
9
- * GET /v1/waitlist-experiments/:experimentId/signups — paginated signups
10
- * GET /v1/waitlist-experiments/:experimentId/signups/:leadId — one signup's answers + score
11
- *
12
- * Registered INSIDE server.ts's `tenantScope` (D7) so `request.tenant` is
13
- * populated by `tenantPreHandlerPlugin` before any handler runs — NOT at the
14
- * public/checkout registration site.
15
- *
16
- * API↔MCP parity: identity-bound tenant routes (owner/admin-facing, exposes
17
- * signup PII) — no MCP twin (#3591 exception, the same shape as the sibling
18
- * `/v1/billing/*` and `/v1/workspaces/*` routes).
19
- */
20
- import type { FastifyPluginAsync } from "fastify";
21
-
22
- import { getWaitlistExperimentComparisonHandler } from "./comparison.js";
23
- import { listWaitlistExperimentsHandler } from "./list.js";
24
- import { getWaitlistSignupHandler } from "./signup.js";
25
- import { listWaitlistSignupsHandler } from "./signups.js";
26
-
27
- export const waitlistExperimentRoutes: FastifyPluginAsync = async (fastify) => {
28
- fastify.get("/v1/waitlist-experiments", listWaitlistExperimentsHandler);
29
- fastify.get(
30
- "/v1/waitlist-experiments/:experimentId",
31
- getWaitlistExperimentComparisonHandler,
32
- );
33
- fastify.get(
34
- "/v1/waitlist-experiments/:experimentId/signups",
35
- listWaitlistSignupsHandler,
36
- );
37
- fastify.get(
38
- "/v1/waitlist-experiments/:experimentId/signups/:leadId",
39
- getWaitlistSignupHandler,
40
- );
41
- };