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,200 +0,0 @@
1
- /**
2
- * Deliverables route group (#3961 D5; extended FEAT-074 U4b, #4718 D4).
3
- *
4
- * Routes:
5
- * GET /v1/deliverables/:grantId — stream the grant's asset bytes
6
- *
7
- * The capability-token download route — a lead-facing URL (an email-click
8
- * download), not an agent-facing business-data endpoint. No MCP twin (the
9
- * same no-twin class as the identity-bound `/v1/workspaces/*` and
10
- * `/v1/billing/*` routes — apps/api/CLAUDE.md § Endpoint conventions). No
11
- * siteId in the URL: the grant id alone resolves both the asset and its
12
- * site (a stable-id ref, ADR 0009 invariant 1 posture — never a
13
- * cross-namespace `@relation`).
14
- *
15
- * A `grant.assetId` prefixed `playbook:` (FEAT-074 U4b, #4718 D4) is the
16
- * VIRTUAL asset a `deliverable-fulfillment.ts` playbook grant mints — it
17
- * skips the `DeliverableAsset` lookup entirely, loads the lead's NEWEST
18
- * `PlaybookInstance` for `(grant.siteId, grant.leadId, experimentKey)`, and
19
- * renders it to a PDF on request (`renderPlaybookPdf`, D1 — never a stored
20
- * blob). Every other `assetId` follows the unchanged operator-uploaded path
21
- * below.
22
- *
23
- * Every miss — unknown id, malformed id, grant-without-asset, a `playbook:`
24
- * grant with no matching `PlaybookInstance` — is a UNIFORM 404
25
- * (`{ ok:false, error:{ code:'not_found' } }`): no route distinguishes
26
- * "wrong token" from "no such asset," so nothing here is an enumeration
27
- * oracle. Repeated downloads on one grant are allowed — a lead re-opening
28
- * their email is the normal case, so the grant is never consumed on read.
29
- *
30
- * A render failure (`PlaybookRenderError` — the vendored library no longer
31
- * declares a play the stored instance names, D1's "library defect, not a
32
- * render branch" posture) is deliberately NOT folded into the uniform-404
33
- * contract above — it is a genuine internal error, not a token/asset miss.
34
- * Caught, logged with `{grantId, experimentKey}` (never PII) so an on-call
35
- * engineer can diagnose a library-drift incident from logs alone, and
36
- * reported as a `500` (mirrors `waitlist-experiments/respond.ts`'s
37
- * contract-drift posture).
38
- */
39
- import { logger } from "@working-theory/observability";
40
- import type { FastifyPluginAsync } from "fastify";
41
-
42
- import { resolveLocale } from "../../lib/locales.js";
43
- import { prisma } from "../../lib/prisma.js";
44
- import type { PlaybookSource } from "../../services/playbook-compile.js";
45
- import { PlaybookRenderError, renderPlaybookPdf } from "../../services/playbook-render.js";
46
-
47
- type DeliverableGrantRow = {
48
- id: string;
49
- siteId: string;
50
- assetId: string;
51
- leadId: string;
52
- };
53
-
54
- type DeliverableAssetRow = {
55
- id: string;
56
- siteId: string;
57
- filename: string;
58
- contentType: string;
59
- bytes: Buffer;
60
- };
61
-
62
- type PlaybookInstanceRow = {
63
- level: number;
64
- source: unknown;
65
- markdown: string;
66
- };
67
-
68
- const PLAYBOOK_ASSET_PREFIX = "playbook:";
69
-
70
- const NOT_FOUND = {
71
- ok: false as const,
72
- error: { code: "not_found", message: "Not found." },
73
- };
74
-
75
- const DELIVERABLES_NOT_CONFIGURED = {
76
- ok: false as const,
77
- error: {
78
- code: "deliverables_not_configured",
79
- message: "Deliverables are not configured.",
80
- },
81
- };
82
-
83
- const RENDER_FAILED = {
84
- ok: false as const,
85
- error: { code: "render_failed", message: "Failed to render the document." },
86
- };
87
-
88
- /**
89
- * Strip CR/LF (header injection — the same class of guard
90
- * @working-theory/email's `hasCRLF` applies to header-bound strings) and
91
- * collapse anything outside a safe charset to `_`. `filename` is
92
- * operator-authored (the `deliverable:upload` CLI's `--filename`, or the
93
- * uploaded file's own basename) — it MUST NOT reach the
94
- * `Content-Disposition` header un-sanitized.
95
- */
96
- function sanitizeFilename(filename: string): string {
97
- const stripped = filename.replace(/[\r\n]/g, "");
98
- const safe = stripped.replace(/[^a-zA-Z0-9._\- ]/g, "_");
99
- return safe.length > 0 ? safe : "download";
100
- }
101
-
102
- /**
103
- * `playbook-level-<N>.pdf`, `playbook-gate.pdf` for Level 0 (spec §2.1's
104
- * never-say-Level-0 rule — a download filename is customer-visible; mirrors
105
- * `renderPlaybookPdf`'s own Gate-vs-Level chrome-label split). Passed
106
- * through `sanitizeFilename` like every other filename this route serves.
107
- */
108
- function playbookFilename(level: number): string {
109
- return level === 0 ? "playbook-gate.pdf" : `playbook-level-${level}.pdf`;
110
- }
111
-
112
- export const deliverableDownloadRoute: FastifyPluginAsync = async (fastify) => {
113
- fastify.get<{ Params: { grantId: string }; Querystring: { locale?: string } }>(
114
- "/v1/deliverables/:grantId",
115
- async (request, reply) => {
116
- if (!process.env.DATABASE_URL) {
117
- // Constant across every grantId (not attacker-distinguishing) —
118
- // this guard sits OUTSIDE the uniform-404 contract, which covers
119
- // only per-token misses.
120
- return reply.status(503).send(DELIVERABLES_NOT_CONFIGURED);
121
- }
122
-
123
- const { grantId } = request.params;
124
-
125
- const grant = (await prisma.deliverableGrant.findUnique({
126
- where: { id: grantId },
127
- })) as DeliverableGrantRow | null;
128
-
129
- if (!grant) {
130
- return reply.status(404).send(NOT_FOUND);
131
- }
132
-
133
- // FEAT-074 U4b (#4718) D4 — the `playbook:` virtual asset: skip the
134
- // DeliverableAsset lookup entirely, resolve the lead's NEWEST
135
- // PlaybookInstance for (siteId, leadId, experimentKey), and render it
136
- // on request. Tenancy: scoped by grant.siteId + grant.leadId ALONE —
137
- // never a request-supplied id (spec §2.2 Tenancy).
138
- if (grant.assetId.startsWith(PLAYBOOK_ASSET_PREFIX)) {
139
- const experimentKey = grant.assetId.slice(PLAYBOOK_ASSET_PREFIX.length);
140
-
141
- const instance = (await prisma.playbookInstance.findFirst({
142
- where: { siteId: grant.siteId, leadId: grant.leadId, experimentKey },
143
- orderBy: { createdAt: "desc" },
144
- select: { level: true, source: true, markdown: true },
145
- })) as PlaybookInstanceRow | null;
146
-
147
- if (!instance) {
148
- return reply.status(404).send(NOT_FOUND);
149
- }
150
-
151
- const locale = resolveLocale(request.query.locale);
152
-
153
- let bytes: Uint8Array;
154
- try {
155
- bytes = await renderPlaybookPdf({
156
- source: instance.source as PlaybookSource,
157
- markdown: instance.markdown,
158
- locale,
159
- });
160
- } catch (err) {
161
- if (!(err instanceof PlaybookRenderError)) throw err;
162
- logger.error(
163
- { grantId, experimentKey, err: err.message },
164
- "[deliverables] playbook render failed — the vendored library no longer declares a play this instance names",
165
- );
166
- return reply.status(500).send(RENDER_FAILED);
167
- }
168
-
169
- const filename = sanitizeFilename(playbookFilename(instance.level));
170
-
171
- return reply
172
- .header("Content-Type", "application/pdf")
173
- .header("Content-Disposition", `attachment; filename="${filename}"`)
174
- .header("X-Content-Type-Options", "nosniff")
175
- .send(Buffer.from(bytes));
176
- }
177
-
178
- const asset = (await prisma.deliverableAsset.findUnique({
179
- where: { id: grant.assetId },
180
- })) as DeliverableAssetRow | null;
181
-
182
- if (!asset || asset.siteId !== grant.siteId) {
183
- return reply.status(404).send(NOT_FOUND);
184
- }
185
-
186
- const filename = sanitizeFilename(asset.filename);
187
-
188
- return reply
189
- .header("Content-Type", asset.contentType)
190
- .header("Content-Disposition", `attachment; filename="${filename}"`)
191
- // The operator-supplied contentType is echoed verbatim above; this
192
- // origin also serves /v1/auth/* cookies, so nosniff stops a
193
- // mis-typed or malicious contentType from being MIME-sniffed by
194
- // the browser into an executable context (#4002, #3961
195
- // security-review — Medium/Low, non-blocking on #3961's Pass).
196
- .header("X-Content-Type-Options", "nosniff")
197
- .send(asset.bytes);
198
- },
199
- );
200
- };
@@ -1,443 +0,0 @@
1
- /**
2
- * @file index.test.ts
3
- *
4
- * THE ACCEPTANCE GATE for the respondent checkouts route (#4984).
5
- * Registration mirrors server.ts: REAL `authPreHandlerPlugin ->
6
- * flowCheckoutRoutes` (auth only, no tenant guard), mocked `lib/prisma.js`.
7
- * Proves: no session → the preHandler's own 401, untouched; an unverified
8
- * user → 403 `email_unverified` with NO Lead / FlowCheckout read; a verified
9
- * respondent → 200 with exactly their promoted leads' checkouts, newest
10
- * first, ten keys per row, parsing under the contract schema; a foreign
11
- * user's checkout and an UN-promoted lead carrying the caller's own email
12
- * never appear (the promotion link is the only join — email never matches);
13
- * an `email` query parameter is never read; every where clause carries the
14
- * current siteId.
15
- */
16
- import Fastify from "fastify";
17
- import type { FastifyInstance } from "fastify";
18
- import { flowCheckoutMeResponseSchema } from "@working-theory/validation";
19
-
20
- // eslint-disable-next-line no-restricted-syntax -- jest mock factory must be hoisted
21
- jest.mock("../../../lib/prisma.js", () => ({
22
- prisma: {
23
- session: { findUnique: jest.fn() },
24
- apiToken: {
25
- findUnique: jest.fn(),
26
- update: jest.fn().mockResolvedValue(undefined),
27
- },
28
- user: { findUnique: jest.fn() },
29
- lead: { findMany: jest.fn() },
30
- flowCheckout: { findMany: jest.fn() },
31
- },
32
- }));
33
-
34
- import { prisma } from "../../../lib/prisma.js";
35
- import { authPreHandlerPlugin } from "../../../middleware/auth.js";
36
- import { flowCheckoutRoutes } from "../index.js";
37
-
38
- const FRONTEND = "https://app.example.com";
39
- const FUTURE = new Date(Date.now() + 60 * 60 * 1000);
40
- const SITE = "wksp_00000000-0000-7000-8000-000000000001";
41
- const OTHER_SITE = "wksp_00000000-0000-7000-8000-000000000002";
42
-
43
- const USERS: Record<string, { id: string; email: string; emailVerified: boolean }> = {
44
- alice: { id: "alice", email: "alice@example.com", emailVerified: true },
45
- // Email+password account whose address was never verified.
46
- mallory: { id: "mallory", email: "mallory@example.com", emailVerified: false },
47
- bob: { id: "bob", email: "bob@example.com", emailVerified: true },
48
- carol: { id: "carol", email: "carol@example.com", emailVerified: true },
49
- };
50
-
51
- const SESSION_ROWS: Record<string, Record<string, unknown>> = {
52
- cookie_alice: { id: "sess_alice", userId: "alice", expiresAt: FUTURE, activeOrganizationId: null, activeWorkspaceId: null },
53
- cookie_mallory: { id: "sess_mallory", userId: "mallory", expiresAt: FUTURE, activeOrganizationId: null, activeWorkspaceId: null },
54
- cookie_bob: { id: "sess_bob", userId: "bob", expiresAt: FUTURE, activeOrganizationId: null, activeWorkspaceId: null },
55
- };
56
-
57
- type LeadRow = { id: string; siteId: string; email: string; promotedUserId: string | null };
58
-
59
- /**
60
- * alice's lead is promoted to her; carol's lead is promoted to carol; bob's
61
- * lead carries HIS email but was never promoted — so bob owns nothing at
62
- * read time, whatever his address says.
63
- */
64
- const LEADS: LeadRow[] = [
65
- { id: "lead_a1", siteId: SITE, email: "alice@example.com", promotedUserId: "alice" },
66
- { id: "lead_c1", siteId: SITE, email: "carol@example.com", promotedUserId: "carol" },
67
- { id: "lead_b1", siteId: SITE, email: "bob@example.com", promotedUserId: null },
68
- // alice again, at ANOTHER site — must never be read under SITE.
69
- { id: "lead_a2", siteId: OTHER_SITE, email: "alice@example.com", promotedUserId: "alice" },
70
- ];
71
-
72
- const CHECKOUTS = [
73
- {
74
- id: "flchk_alice_created",
75
- siteId: SITE,
76
- leadId: "lead_a1",
77
- sessionId: "flsess_a1",
78
- flowSlug: "ailk-reco",
79
- experimentKey: "ailk-reco",
80
- offerKey: "pro",
81
- seats: 1,
82
- period: "monthly",
83
- creditStop: {},
84
- amountMinor: 4900,
85
- currency: "usd",
86
- quote: { total: 4900 },
87
- permalink: "/r/abc",
88
- stripeSessionId: null,
89
- status: "created",
90
- completedAt: null,
91
- createdAt: new Date("2026-09-05T00:00:00.000Z"),
92
- },
93
- {
94
- id: "flchk_alice_completed",
95
- siteId: SITE,
96
- leadId: "lead_a1",
97
- sessionId: "flsess_a1",
98
- flowSlug: "ailk-reco",
99
- experimentKey: "ailk-reco",
100
- offerKey: "pro_plus",
101
- seats: 2,
102
- period: "annual",
103
- creditStop: {},
104
- amountMinor: 99000,
105
- currency: "usd",
106
- quote: { total: 99000 },
107
- permalink: "/r/def",
108
- stripeSessionId: "cs_test_alice",
109
- status: "completed",
110
- completedAt: new Date("2026-09-03T00:00:05.000Z"),
111
- createdAt: new Date("2026-09-03T00:00:00.000Z"),
112
- },
113
- {
114
- id: "flchk_foreign",
115
- siteId: SITE,
116
- leadId: "lead_c1",
117
- sessionId: "flsess_c1",
118
- flowSlug: "ailk-reco",
119
- experimentKey: "ailk-reco",
120
- offerKey: "pro",
121
- seats: 1,
122
- period: "monthly",
123
- creditStop: {},
124
- amountMinor: 4900,
125
- currency: "usd",
126
- quote: { total: 4900 },
127
- permalink: "/r/ghi",
128
- stripeSessionId: "cs_test_carol",
129
- status: "completed",
130
- completedAt: new Date("2026-09-04T00:00:05.000Z"),
131
- createdAt: new Date("2026-09-04T00:00:00.000Z"),
132
- },
133
- {
134
- id: "flchk_unpromoted",
135
- siteId: SITE,
136
- leadId: "lead_b1",
137
- sessionId: "flsess_b1",
138
- flowSlug: "ailk-reco",
139
- experimentKey: "ailk-reco",
140
- offerKey: "starter",
141
- seats: 1,
142
- period: "monthly",
143
- creditStop: {},
144
- amountMinor: 1900,
145
- currency: "usd",
146
- quote: { total: 1900 },
147
- permalink: "/r/jkl",
148
- stripeSessionId: "cs_test_bob",
149
- status: "completed",
150
- completedAt: new Date("2026-09-04T00:00:05.000Z"),
151
- createdAt: new Date("2026-09-04T00:00:00.000Z"),
152
- },
153
- {
154
- id: "flchk_other_site",
155
- siteId: OTHER_SITE,
156
- leadId: "lead_a2",
157
- sessionId: "flsess_a2",
158
- flowSlug: "ailk-reco",
159
- experimentKey: "ailk-reco",
160
- offerKey: "pro",
161
- seats: 1,
162
- period: "monthly",
163
- creditStop: {},
164
- amountMinor: 4900,
165
- currency: "usd",
166
- quote: { total: 4900 },
167
- permalink: "/r/mno",
168
- stripeSessionId: "cs_test_other",
169
- status: "completed",
170
- completedAt: new Date("2026-09-06T00:00:05.000Z"),
171
- createdAt: new Date("2026-09-06T00:00:00.000Z"),
172
- },
173
- ];
174
-
175
- type LeadWhere = { siteId: string; promotedUserId?: string; email?: unknown };
176
- type CheckoutWhere = { siteId: string; leadId: { in: string[] } };
177
-
178
- function installFixtureMocks(): void {
179
- (prisma.session.findUnique as jest.Mock).mockImplementation(
180
- ({ where }: { where: { token?: string } }) =>
181
- Promise.resolve(where.token ? (SESSION_ROWS[where.token] ?? null) : null),
182
- );
183
- (prisma.apiToken.findUnique as jest.Mock).mockResolvedValue(null);
184
- (prisma.user.findUnique as jest.Mock).mockImplementation(({ where }: { where: { id: string } }) =>
185
- Promise.resolve(USERS[where.id] ?? null),
186
- );
187
- // The promotion link is the ONLY join the SERVICE may use: a where clause
188
- // carrying `email` is a contract violation and returns nothing.
189
- (prisma.lead.findMany as jest.Mock).mockImplementation(({ where }: { where: LeadWhere }) => {
190
- expect(where).not.toHaveProperty("email");
191
- if ("email" in where) return Promise.resolve([]);
192
- return Promise.resolve(
193
- LEADS.filter((lead) => lead.siteId === where.siteId && lead.promotedUserId === where.promotedUserId).map(
194
- (lead) => ({ id: lead.id }),
195
- ),
196
- );
197
- });
198
- (prisma.flowCheckout.findMany as jest.Mock).mockImplementation(({ where }: { where: CheckoutWhere }) =>
199
- Promise.resolve(
200
- CHECKOUTS.filter((row) => row.siteId === where.siteId && where.leadId.in.includes(row.leadId)).sort(
201
- (a, b) => b.createdAt.getTime() - a.createdAt.getTime(),
202
- ),
203
- ),
204
- );
205
- }
206
-
207
- async function buildApp(): Promise<FastifyInstance> {
208
- const app = Fastify();
209
- await app.register(async (authScope) => {
210
- await authScope.register(authPreHandlerPlugin, { frontendOrigin: FRONTEND });
211
- await authScope.register(flowCheckoutRoutes);
212
- });
213
- await app.ready();
214
- return app;
215
- }
216
-
217
- const cookie = (token: string) => ({ cookie: `better-auth.session_token=${token}` });
218
- const URL_ME = "/v1/flow-checkouts/me";
219
- const CONTRACT_KEYS = [
220
- "id",
221
- "flowSlug",
222
- "experimentKey",
223
- "offerKey",
224
- "status",
225
- "amountMinor",
226
- "currency",
227
- "period",
228
- "completedAt",
229
- "createdAt",
230
- ].sort();
231
-
232
- const leadWhere = (call = 0) => (prisma.lead.findMany as jest.Mock).mock.calls[call][0].where as LeadWhere;
233
- const checkoutWhere = (call = 0) =>
234
- (prisma.flowCheckout.findMany as jest.Mock).mock.calls[call][0].where as CheckoutWhere;
235
-
236
- let app: FastifyInstance;
237
-
238
- beforeEach(async () => {
239
- jest.clearAllMocks();
240
- process.env.AILK_SITE_ID = SITE;
241
- installFixtureMocks();
242
- app = await buildApp();
243
- });
244
-
245
- afterEach(async () => {
246
- await app.close();
247
- });
248
-
249
- afterAll(() => {
250
- delete process.env.AILK_SITE_ID;
251
- });
252
-
253
- describe("GET /v1/flow-checkouts/me — auth", () => {
254
- it("no session cookie and no bearer → the preHandler's existing 401, untouched; no User/Lead read", async () => {
255
- const res = await app.inject({ method: "GET", url: URL_ME });
256
-
257
- expect(res.statusCode).toBe(401);
258
- expect(res.json()).toEqual({ error: "unauthorized" });
259
- expect(prisma.user.findUnique).not.toHaveBeenCalled();
260
- expect(prisma.lead.findMany).not.toHaveBeenCalled();
261
- expect(prisma.flowCheckout.findMany).not.toHaveBeenCalled();
262
- });
263
-
264
- it("an expired / unknown cookie → 401", async () => {
265
- const res = await app.inject({ method: "GET", url: URL_ME, headers: cookie("cookie_nobody") });
266
-
267
- expect(res.statusCode).toBe(401);
268
- expect(prisma.lead.findMany).not.toHaveBeenCalled();
269
- expect(prisma.flowCheckout.findMany).not.toHaveBeenCalled();
270
- });
271
-
272
- it("unverified email → 403 email_unverified, BEFORE any Lead / FlowCheckout read", async () => {
273
- const res = await app.inject({ method: "GET", url: URL_ME, headers: cookie("cookie_mallory") });
274
-
275
- expect(res.statusCode).toBe(403);
276
- expect(res.json()).toEqual({
277
- ok: false,
278
- error: { code: "email_unverified", message: expect.any(String) },
279
- });
280
- expect(prisma.lead.findMany).not.toHaveBeenCalled();
281
- expect(prisma.flowCheckout.findMany).not.toHaveBeenCalled();
282
- });
283
- });
284
-
285
- describe("GET /v1/flow-checkouts/me — the read", () => {
286
- it("verified respondent → 200 with exactly her two rows, newest first, ten keys each, parsing under flowCheckoutMeResponseSchema", async () => {
287
- const res = await app.inject({ method: "GET", url: URL_ME, headers: cookie("cookie_alice") });
288
-
289
- expect(res.statusCode).toBe(200);
290
- const body = res.json() as { ok: true; data: Record<string, unknown>[] };
291
- expect(body.ok).toBe(true);
292
- expect(Array.isArray(body.data)).toBe(true);
293
- expect(body.data).toHaveLength(2);
294
- for (const row of body.data) expect(Object.keys(row).sort()).toEqual(CONTRACT_KEYS);
295
- expect(flowCheckoutMeResponseSchema.parse(body.data)).toEqual([
296
- {
297
- id: "flchk_alice_created",
298
- flowSlug: "ailk-reco",
299
- experimentKey: "ailk-reco",
300
- offerKey: "pro",
301
- status: "created",
302
- amountMinor: 4900,
303
- currency: "usd",
304
- period: "monthly",
305
- completedAt: null,
306
- createdAt: "2026-09-05T00:00:00.000Z",
307
- },
308
- {
309
- id: "flchk_alice_completed",
310
- flowSlug: "ailk-reco",
311
- experimentKey: "ailk-reco",
312
- offerKey: "pro_plus",
313
- status: "completed",
314
- amountMinor: 99000,
315
- currency: "usd",
316
- period: "annual",
317
- completedAt: "2026-09-03T00:00:05.000Z",
318
- createdAt: "2026-09-03T00:00:00.000Z",
319
- },
320
- ]);
321
- // Nobody else's row, no un-promoted row, no other site's row, and no
322
- // Stripe id / permalink / email leaves the process.
323
- expect(res.body).not.toContain("flchk_foreign");
324
- expect(res.body).not.toContain("flchk_unpromoted");
325
- expect(res.body).not.toContain("flchk_other_site");
326
- expect(res.body).not.toContain("cs_test");
327
- expect(res.body).not.toContain("/r/");
328
- expect(res.body).not.toContain("lead_");
329
- expect(res.body).not.toContain("flsess_");
330
- expect(res.body).not.toContain("@example.com");
331
- });
332
-
333
- it("verified user whose only lead carries his email but was never promoted → 200 [] (email never matches)", async () => {
334
- const res = await app.inject({ method: "GET", url: URL_ME, headers: cookie("cookie_bob") });
335
-
336
- expect(res.statusCode).toBe(200);
337
- expect(res.json()).toEqual({ ok: true, data: [] });
338
- expect(leadWhere()).toEqual({ siteId: SITE, promotedUserId: "bob" });
339
- // No lead → no FlowCheckout read at all.
340
- expect(prisma.flowCheckout.findMany).not.toHaveBeenCalled();
341
- });
342
-
343
- it("an `email` query parameter is never read — the SESSION user's rows resolve, never the parameter's", async () => {
344
- // bob is verified and owns nothing; naming alice's email must not read alice's rows.
345
- const smuggled = await app.inject({
346
- method: "GET",
347
- url: `${URL_ME}?email=alice%40example.com`,
348
- headers: cookie("cookie_bob"),
349
- });
350
- expect(smuggled.statusCode).toBe(200);
351
- expect((smuggled.json() as { data: unknown[] }).data).toEqual([]);
352
- expect(leadWhere().promotedUserId).toBe("bob");
353
- expect(leadWhere()).not.toHaveProperty("email");
354
-
355
- // …and alice naming someone else's email still gets HER OWN rows.
356
- jest.clearAllMocks();
357
- installFixtureMocks();
358
- const own = await app.inject({
359
- method: "GET",
360
- url: `${URL_ME}?email=bob%40example.com`,
361
- headers: cookie("cookie_alice"),
362
- });
363
- expect(own.statusCode).toBe(200);
364
- const rows = (own.json() as { data: { id: string }[] }).data;
365
- expect(rows.map((row) => row.id)).toEqual(["flchk_alice_created", "flchk_alice_completed"]);
366
- expect(leadWhere().promotedUserId).toBe("alice");
367
- expect(leadWhere()).not.toHaveProperty("email");
368
- });
369
-
370
- it("every where clause carries the current siteId (AILK_SITE_ID) — never a post-filter", async () => {
371
- const res = await app.inject({ method: "GET", url: URL_ME, headers: cookie("cookie_alice") });
372
-
373
- expect(res.statusCode).toBe(200);
374
- expect(leadWhere().siteId).toBe(SITE);
375
- expect(checkoutWhere().siteId).toBe(SITE);
376
- // Only the leads promoted to alice AT THIS SITE feed the checkout read.
377
- expect(checkoutWhere().leadId.in).toEqual(["lead_a1"]);
378
- });
379
-
380
- it("a bearer API token resolves the SAME userId path — alice's rows, the same promotion-link where clause, no email", async () => {
381
- // The preHandler's second mode: cookie absent, Authorization: Bearer <token>.
382
- // The token row's userId is the identity — the service's gates apply unchanged.
383
- (prisma.apiToken.findUnique as jest.Mock).mockResolvedValue({
384
- id: "tok_alice",
385
- userId: "alice",
386
- expiresAt: null,
387
- revokedAt: null,
388
- });
389
- const res = await app.inject({
390
- method: "GET",
391
- url: URL_ME,
392
- headers: { authorization: "Bearer alice-opaque-token" },
393
- });
394
-
395
- expect(res.statusCode).toBe(200);
396
- const body = res.json() as { ok: true; data: Array<{ id: string }> };
397
- expect(body.data.map((row) => row.id)).toEqual(["flchk_alice_created", "flchk_alice_completed"]);
398
- expect(leadWhere()).toEqual({ siteId: SITE, promotedUserId: "alice" });
399
- });
400
-
401
- it("a bearer token for an unverified user → 403 email_unverified, same as the cookie path", async () => {
402
- (prisma.apiToken.findUnique as jest.Mock).mockResolvedValue({
403
- id: "tok_mallory",
404
- userId: "mallory",
405
- expiresAt: null,
406
- revokedAt: null,
407
- });
408
- const res = await app.inject({
409
- method: "GET",
410
- url: URL_ME,
411
- headers: { authorization: "Bearer mallory-opaque-token" },
412
- });
413
-
414
- expect(res.statusCode).toBe(403);
415
- expect(prisma.lead.findMany).not.toHaveBeenCalled();
416
- });
417
-
418
- it("a row that drifts out of the contract (a 4-letter currency) → 500 internal_error, and no row content leaves the process", async () => {
419
- (prisma.flowCheckout.findMany as jest.Mock).mockResolvedValue([
420
- {
421
- id: "flchk_drift",
422
- flowSlug: "ailk-reco",
423
- experimentKey: "ailk-reco",
424
- offerKey: "pro",
425
- status: "created",
426
- amountMinor: 4900,
427
- currency: "usdx",
428
- period: "monthly",
429
- completedAt: null,
430
- createdAt: new Date("2026-09-05T00:00:00.000Z"),
431
- },
432
- ]);
433
- const res = await app.inject({ method: "GET", url: URL_ME, headers: cookie("cookie_alice") });
434
-
435
- expect(res.statusCode).toBe(500);
436
- expect(res.json()).toEqual({
437
- ok: false,
438
- error: { code: "internal_error", message: expect.any(String) },
439
- });
440
- expect(res.body).not.toContain("flchk_drift");
441
- expect(res.body).not.toContain("usdx");
442
- });
443
- });