create-ailk 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (273) hide show
  1. package/component-catalog.md +50 -53
  2. package/dist/lib/apply-module-patches.d.ts +47 -0
  3. package/dist/lib/apply-module-patches.js +370 -0
  4. package/dist/lib/extract-module-bundle.d.ts +12 -3
  5. package/dist/lib/extract-module-bundle.js +236 -24
  6. package/dist/lib/fetch-module.d.ts +3 -38
  7. package/dist/lib/fetch-module.js +26 -8
  8. package/dist/lib/module-license-gate.d.ts +2 -0
  9. package/dist/lib/module-license-gate.js +1 -0
  10. package/dist/lib/paid-paths.d.ts +16 -0
  11. package/dist/lib/paid-paths.js +90 -0
  12. package/dist/module-architecture.d.ts +139 -23
  13. package/dist/module-architecture.js +668 -25
  14. package/dist/parse-args.d.ts +2 -2
  15. package/dist/parse-args.js +3 -1
  16. package/dist/programmatic.d.ts +2 -1
  17. package/dist/programmatic.js +15 -4
  18. package/dist/surfaces.d.ts +12 -36
  19. package/dist/surfaces.js +35 -5
  20. package/dist/sync-routes.js +31 -1
  21. package/package.json +2 -2
  22. package/templates/.claude/agents/web.md +2 -3
  23. package/templates/.claude/rules/architecture.md +5 -5
  24. package/templates/.claude/skills/README.md +2 -1
  25. package/templates/.env.example +21 -9
  26. package/templates/CONVENTIONS.md +6 -7
  27. package/templates/apps/api/.env.example +9 -0
  28. package/templates/apps/api/CLAUDE.md +33 -5
  29. package/templates/apps/api/package.json +0 -1
  30. package/templates/apps/api/src/__tests__/cors.test.ts +195 -0
  31. package/templates/apps/api/src/__tests__/module-exclusion.test.ts +60 -2
  32. package/templates/apps/api/src/__tests__/vercel-handler.test.ts +159 -0
  33. package/templates/apps/api/src/config/__tests__/modules.test.ts +76 -0
  34. package/templates/apps/api/src/config/index.ts +19 -0
  35. package/templates/apps/api/src/config/modules.ts +12 -14
  36. package/templates/apps/api/src/lib/__mocks__/prisma.ts +3 -0
  37. package/templates/apps/api/src/lib/__tests__/slice-load.test.ts +89 -0
  38. package/templates/apps/api/src/lib/slice-load.ts +90 -0
  39. package/templates/apps/api/src/openapi/__tests__/openapi.test.ts +52 -32
  40. package/templates/apps/api/src/openapi/__tests__/spec-identity.test.ts +182 -0
  41. package/templates/apps/api/src/openapi/__tests__/surface-drift.test.ts +293 -0
  42. package/templates/apps/api/src/openapi/index.ts +9 -3
  43. package/templates/apps/api/src/openapi/spec.ts +505 -65
  44. package/templates/apps/api/src/openapi/surface-drift.ts +195 -0
  45. package/templates/apps/api/src/routes/content/__tests__/create.test.ts +14 -0
  46. package/templates/apps/api/src/routes/content/__tests__/delete.test.ts +14 -0
  47. package/templates/apps/api/src/routes/content/__tests__/update.test.ts +14 -0
  48. package/templates/apps/api/src/routes/content/index.ts +38 -11
  49. package/templates/apps/api/src/routes/project-listings/__tests__/configured-application.test.ts +17 -0
  50. package/templates/apps/api/src/routes/project-listings/__tests__/drafts.test.ts +17 -0
  51. package/templates/apps/api/src/routes/project-listings/__tests__/founder-identity.test.ts +796 -0
  52. package/templates/apps/api/src/routes/project-listings/__tests__/me.test.ts +10 -0
  53. package/templates/apps/api/src/routes/project-listings/__tests__/public.test.ts +38 -0
  54. package/templates/apps/api/src/routes/project-listings/__tests__/resume-email.test.ts +814 -0
  55. package/templates/apps/api/src/routes/project-listings/__tests__/site-answers.test.ts +10 -0
  56. package/templates/apps/api/src/routes/project-listings/__tests__/site-key.test.ts +24 -0
  57. package/templates/apps/api/src/routes/project-listings/__tests__/structured-address.test.ts +17 -0
  58. package/templates/apps/api/src/routes/project-listings/index.ts +37 -10
  59. package/templates/apps/api/src/routes/project-listings/me.ts +10 -0
  60. package/templates/apps/api/src/routes/project-listings/patch-draft.ts +5 -0
  61. package/templates/apps/api/src/routes/project-listings/public.ts +5 -0
  62. package/templates/apps/api/src/routes/project-listings/respond.ts +11 -0
  63. package/templates/apps/api/src/routes/project-listings/resume-token.ts +5 -0
  64. package/templates/apps/api/src/routes/project-listings/start.ts +30 -0
  65. package/templates/apps/api/src/routes/project-listings/submit.ts +5 -0
  66. package/templates/apps/api/src/routes/project-listings/verify-email.ts +150 -0
  67. package/templates/apps/api/src/server.ts +459 -135
  68. package/templates/apps/api/src/services/deliverable-fulfillment.ts +10 -0
  69. package/templates/apps/api/src/services/listing-config.ts +58 -0
  70. package/templates/apps/api/src/services/project-listing-resume-email.ts +326 -0
  71. package/templates/apps/api/src/services/project-listings.ts +229 -4
  72. package/templates/apps/api/src/vercel-handler.ts +60 -26
  73. package/templates/apps/mcp/.env.example +8 -0
  74. package/templates/apps/mcp/CLAUDE.md +2 -2
  75. package/templates/apps/mcp/__tests__/catalog-drift.test.ts +21 -0
  76. package/templates/apps/mcp/__tests__/config/modules.test.ts +78 -0
  77. package/templates/apps/mcp/__tests__/module-exclusion.test.ts +1 -1
  78. package/templates/apps/mcp/src/config/modules.ts +14 -13
  79. package/templates/apps/mcp/src/server.ts +8 -4
  80. package/templates/apps/mcp/src/tools/index.ts +7 -22
  81. package/templates/apps/web/.env.example +15 -0
  82. package/templates/apps/web/app/[locale]/(authed)/{waitlist/__tests__ → __tests__}/gate.test.tsx +4 -4
  83. package/templates/apps/web/app/[locale]/flows/[slug]/FlowStepperClient.tsx +2 -1
  84. package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/FlowStepperClient.permalink.test.tsx +2 -1
  85. package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/FlowStepperClient.test.tsx +2 -1
  86. package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/flow.actions.test.ts +1 -0
  87. package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/two-route-tool.test.tsx +1 -0
  88. package/templates/apps/web/app/[locale]/flows/[slug]/flow.actions.ts +2 -1
  89. package/templates/apps/web/app/[locale]/flows/[slug]/page.tsx +1 -0
  90. package/templates/apps/web/app/[locale]/flows/[slug]/start/page.tsx +1 -0
  91. package/templates/apps/web/app/llms.txt/route.ts +12 -9
  92. package/templates/apps/web/jest.config.cjs +14 -13
  93. package/templates/apps/web/next.config.mjs +1 -3
  94. package/templates/apps/web/package.json +0 -6
  95. package/templates/apps/web/public/android-chrome-192x192.png +0 -0
  96. package/templates/apps/web/public/android-chrome-512x512.png +0 -0
  97. package/templates/apps/web/public/apple-touch-icon.png +0 -0
  98. package/templates/apps/web/public/favicon-16x16.png +0 -0
  99. package/templates/apps/web/public/favicon-32x32.png +0 -0
  100. package/templates/apps/web/public/favicon.ico +0 -0
  101. package/templates/apps/web/public/favicon.svg +6 -3
  102. package/templates/apps/web/public/lockup-horizontal.svg +4 -0
  103. package/templates/apps/web/public/logomark.svg +4 -0
  104. package/templates/apps/web/public/site.webmanifest +2 -2
  105. package/templates/apps/web/public/wordmark.svg +4 -0
  106. package/templates/database/CHANGELOG.md +33 -0
  107. package/templates/database/inbox/schema.prisma +67 -0
  108. package/templates/database/migrations/20260914120000_listing_founder_identity/migration.sql +123 -0
  109. package/templates/database/package.json +1 -1
  110. package/templates/package.json +1 -1
  111. package/templates/.claude/skills/scaffold-commerce/SKILL.md +0 -807
  112. package/templates/.claude/skills/scaffold-commerce/references/lookup-keys-template.md +0 -71
  113. package/templates/.claude/skills/scaffold-commerce/references/marketplace-brand-pages.md +0 -83
  114. package/templates/.claude/skills/scaffold-commerce/templates/checkout-route.template.ts +0 -370
  115. package/templates/.claude/skills/scaffold-commerce/templates/invoice-route.template.ts +0 -376
  116. package/templates/.claude/skills/scaffold-commerce/templates/marketplace-cta-route.tsx.tmpl +0 -72
  117. package/templates/.claude/skills/scaffold-commerce/templates/payment-link-route.template.ts +0 -471
  118. package/templates/.claude/skills/scaffold-commerce/templates/portal-route.template.ts +0 -365
  119. package/templates/.claude/skills/scaffold-commerce/templates/stripe-config.template.ts +0 -60
  120. package/templates/.claude/skills/scaffold-commerce/templates/subscription-route.template.ts +0 -446
  121. package/templates/apps/api/src/__tests__/server.test.ts +0 -253
  122. package/templates/apps/api/src/bin/deliverable-resend.ts +0 -144
  123. package/templates/apps/api/src/bin/deliverable-upload.ts +0 -114
  124. package/templates/apps/api/src/bin/followup-sweep.ts +0 -44
  125. package/templates/apps/api/src/bin/listing-csv-sweep.ts +0 -46
  126. package/templates/apps/api/src/bin/seed-presets.ts +0 -58
  127. package/templates/apps/api/src/bin/seed-waitlist-experiments.ts +0 -104
  128. package/templates/apps/api/src/bin/session-retention-sweep.ts +0 -46
  129. package/templates/apps/api/src/lib/__tests__/stripe.test.ts +0 -74
  130. package/templates/apps/api/src/lib/stripe.ts +0 -52
  131. package/templates/apps/api/src/lib/tenant-db.ts +0 -225
  132. package/templates/apps/api/src/lib/ws-token.ts +0 -91
  133. package/templates/apps/api/src/middleware/tenant.ts +0 -99
  134. package/templates/apps/api/src/routes/billing/__tests__/portal.test.ts +0 -450
  135. package/templates/apps/api/src/routes/billing/__tests__/read.test.ts +0 -262
  136. package/templates/apps/api/src/routes/billing/__tests__/usage.test.ts +0 -436
  137. package/templates/apps/api/src/routes/billing/index.ts +0 -36
  138. package/templates/apps/api/src/routes/billing/portal.ts +0 -182
  139. package/templates/apps/api/src/routes/billing/read.ts +0 -75
  140. package/templates/apps/api/src/routes/billing/usage.ts +0 -153
  141. package/templates/apps/api/src/routes/checkout/__tests__/sessions.test.ts +0 -687
  142. package/templates/apps/api/src/routes/checkout/index.ts +0 -13
  143. package/templates/apps/api/src/routes/checkout/sessions.ts +0 -197
  144. package/templates/apps/api/src/routes/deliverables/__tests__/index.test.ts +0 -393
  145. package/templates/apps/api/src/routes/deliverables/index.ts +0 -200
  146. package/templates/apps/api/src/routes/flow-checkouts/__tests__/index.test.ts +0 -443
  147. package/templates/apps/api/src/routes/flow-checkouts/index.ts +0 -82
  148. package/templates/apps/api/src/routes/flows/README.md +0 -147
  149. package/templates/apps/api/src/routes/flows/__tests__/index.test.ts +0 -752
  150. package/templates/apps/api/src/routes/flows/__tests__/recommender.test.ts +0 -671
  151. package/templates/apps/api/src/routes/flows/index.ts +0 -202
  152. package/templates/apps/api/src/routes/flows/recommender.ts +0 -443
  153. package/templates/apps/api/src/routes/project-listings/__tests__/copy-edit.test.ts +0 -815
  154. package/templates/apps/api/src/routes/project-listings/__tests__/me-route-precedence.test.ts +0 -221
  155. package/templates/apps/api/src/routes/project-listings/__tests__/tenant-isolation.test.ts +0 -566
  156. package/templates/apps/api/src/routes/project-listings/get.ts +0 -70
  157. package/templates/apps/api/src/routes/project-listings/list.ts +0 -47
  158. package/templates/apps/api/src/routes/project-listings/patch.ts +0 -136
  159. package/templates/apps/api/src/routes/schedule/__tests__/index.test.ts +0 -490
  160. package/templates/apps/api/src/routes/schedule/index.ts +0 -249
  161. package/templates/apps/api/src/routes/slack/__tests__/actions.test.ts +0 -385
  162. package/templates/apps/api/src/routes/slack/actions.ts +0 -177
  163. package/templates/apps/api/src/routes/slack/index.ts +0 -38
  164. package/templates/apps/api/src/routes/waitlist-experiments/__tests__/tenant-isolation.test.ts +0 -577
  165. package/templates/apps/api/src/routes/waitlist-experiments/comparison.ts +0 -78
  166. package/templates/apps/api/src/routes/waitlist-experiments/index.ts +0 -41
  167. package/templates/apps/api/src/routes/waitlist-experiments/list.ts +0 -64
  168. package/templates/apps/api/src/routes/waitlist-experiments/respond.ts +0 -42
  169. package/templates/apps/api/src/routes/waitlist-experiments/signup.ts +0 -84
  170. package/templates/apps/api/src/routes/waitlist-experiments/signups.ts +0 -119
  171. package/templates/apps/api/src/routes/waitlist-signups/__tests__/index.test.ts +0 -238
  172. package/templates/apps/api/src/routes/waitlist-signups/index.ts +0 -101
  173. package/templates/apps/api/src/routes/webhooks/README.md +0 -80
  174. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-flow-checkout.test.ts +0 -315
  175. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-idempotency.test.ts +0 -247
  176. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-org-billing.test.ts +0 -270
  177. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-rate-limit.test.ts +0 -198
  178. package/templates/apps/api/src/routes/webhooks/__tests__/stripe.test.ts +0 -633
  179. package/templates/apps/api/src/routes/webhooks/index.ts +0 -41
  180. package/templates/apps/api/src/routes/webhooks/stripe.ts +0 -481
  181. package/templates/apps/api/src/routes/workspaces/__tests__/config-tenant-isolation.test.ts +0 -356
  182. package/templates/apps/api/src/routes/workspaces/__tests__/tenant-isolation.test.ts +0 -671
  183. package/templates/apps/api/src/routes/workspaces/config.ts +0 -125
  184. package/templates/apps/api/src/routes/workspaces/index.ts +0 -194
  185. package/templates/apps/api/src/routes/workspaces/sessions.ts +0 -218
  186. package/templates/apps/api/src/services/__tests__/flow-aggregate.test.ts +0 -425
  187. package/templates/apps/api/src/services/__tests__/flow-engine.test.ts +0 -2840
  188. package/templates/apps/api/src/services/__tests__/lead-promotion.test.ts +0 -393
  189. package/templates/apps/api/src/services/__tests__/listing-csv-sweep.test.ts +0 -560
  190. package/templates/apps/api/src/services/__tests__/listing-promotion.test.ts +0 -684
  191. package/templates/apps/api/src/services/__tests__/playbook-compile.test.ts +0 -406
  192. package/templates/apps/api/src/services/__tests__/playbook-render.test.ts +0 -290
  193. package/templates/apps/api/src/services/__tests__/project-listing-decision.test.ts +0 -736
  194. package/templates/apps/api/src/services/__tests__/project-listing-flow.test.ts +0 -475
  195. package/templates/apps/api/src/services/__tests__/project-listing-issue.test.ts +0 -340
  196. package/templates/apps/api/src/services/__tests__/recommender-capture.test.ts +0 -983
  197. package/templates/apps/api/src/services/__tests__/tenant-context-cascade.test.ts +0 -71
  198. package/templates/apps/api/src/services/__tests__/tenant-context.test.ts +0 -379
  199. package/templates/apps/api/src/services/__tests__/usage-metering.test.ts +0 -768
  200. package/templates/apps/api/src/services/__tests__/waitlist-dashboard.test.ts +0 -1314
  201. package/templates/apps/api/src/services/__tests__/waitlist-experiments.test.ts +0 -341
  202. package/templates/apps/api/src/services/__tests__/waitlist-followup.test.ts +0 -567
  203. package/templates/apps/api/src/services/__tests__/waitlist-scoring.test.ts +0 -474
  204. package/templates/apps/api/src/services/__tests__/waitlist-signups.test.ts +0 -354
  205. package/templates/apps/api/src/services/flow-aggregate.ts +0 -232
  206. package/templates/apps/api/src/services/flow-checkouts.ts +0 -123
  207. package/templates/apps/api/src/services/flow-engine.ts +0 -1296
  208. package/templates/apps/api/src/services/lead-promotion.ts +0 -176
  209. package/templates/apps/api/src/services/listing-csv-sweep.ts +0 -455
  210. package/templates/apps/api/src/services/listing-promotion.ts +0 -342
  211. package/templates/apps/api/src/services/playbook-compile.ts +0 -398
  212. package/templates/apps/api/src/services/playbook-render.ts +0 -263
  213. package/templates/apps/api/src/services/project-listing-decision.ts +0 -510
  214. package/templates/apps/api/src/services/project-listing-flow.ts +0 -426
  215. package/templates/apps/api/src/services/project-listing-issue.ts +0 -251
  216. package/templates/apps/api/src/services/recommender-capture.ts +0 -835
  217. package/templates/apps/api/src/services/scheduling/cal-provider.ts +0 -392
  218. package/templates/apps/api/src/services/scheduling/index.ts +0 -63
  219. package/templates/apps/api/src/services/scheduling/types.ts +0 -88
  220. package/templates/apps/api/src/services/tenant-context.ts +0 -451
  221. package/templates/apps/api/src/services/usage-metering.ts +0 -699
  222. package/templates/apps/api/src/services/waitlist-dashboard.ts +0 -947
  223. package/templates/apps/api/src/services/waitlist-experiments.ts +0 -213
  224. package/templates/apps/api/src/services/waitlist-followup.ts +0 -486
  225. package/templates/apps/api/src/services/waitlist-scoring.ts +0 -166
  226. package/templates/apps/api/src/services/waitlist-signups.ts +0 -165
  227. package/templates/apps/mcp/__tests__/schedule_tools.test.ts +0 -242
  228. package/templates/apps/mcp/src/tools/create_booking.ts +0 -58
  229. package/templates/apps/mcp/src/tools/get_event_meta.ts +0 -55
  230. package/templates/apps/mcp/src/tools/get_flow.ts +0 -61
  231. package/templates/apps/mcp/src/tools/list_availability.ts +0 -67
  232. package/templates/apps/mcp/src/tools/submit_flow_step.ts +0 -83
  233. package/templates/apps/web/app/[locale]/(authed)/waitlist/[experimentId]/page.tsx +0 -309
  234. package/templates/apps/web/app/[locale]/(authed)/waitlist/[experimentId]/signups/[leadId]/page.tsx +0 -149
  235. package/templates/apps/web/app/[locale]/(authed)/waitlist/__tests__/comparison.test.tsx +0 -273
  236. package/templates/apps/web/app/[locale]/(authed)/waitlist/__tests__/list.test.tsx +0 -171
  237. package/templates/apps/web/app/[locale]/(authed)/waitlist/__tests__/signup.test.tsx +0 -179
  238. package/templates/apps/web/app/[locale]/(authed)/waitlist/components/ExperimentsTable.tsx +0 -116
  239. package/templates/apps/web/app/[locale]/(authed)/waitlist/components/OfferFunnelTable.tsx +0 -66
  240. package/templates/apps/web/app/[locale]/(authed)/waitlist/components/RollupTicker.tsx +0 -61
  241. package/templates/apps/web/app/[locale]/(authed)/waitlist/components/ScoreTrace.tsx +0 -114
  242. package/templates/apps/web/app/[locale]/(authed)/waitlist/components/SignupsTable.tsx +0 -139
  243. package/templates/apps/web/app/[locale]/(authed)/waitlist/components/WaitlistComparisonTabs.tsx +0 -85
  244. package/templates/apps/web/app/[locale]/(authed)/waitlist/components/status-badges.tsx +0 -67
  245. package/templates/apps/web/app/[locale]/(authed)/waitlist/page.tsx +0 -179
  246. package/templates/apps/web/app/[locale]/blog/[[...slug]]/page.tsx +0 -178
  247. package/templates/apps/web/app/[locale]/dev/purchase/__tests__/actions.test.ts +0 -160
  248. package/templates/apps/web/app/[locale]/dev/purchase/error.tsx +0 -25
  249. package/templates/apps/web/app/[locale]/dev/purchase/page.tsx +0 -117
  250. package/templates/apps/web/app/[locale]/dev/purchase/purchase.actions.ts +0 -58
  251. package/templates/apps/web/app/[locale]/docs/[[...slug]]/page.tsx +0 -119
  252. package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/checkout.actions.test.ts +0 -287
  253. package/templates/apps/web/app/[locale]/flows/[slug]/checkout.actions.ts +0 -254
  254. package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/ResultsClient.tsx +0 -532
  255. package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/ResultsEmailStep.tsx +0 -121
  256. package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/__tests__/ResultsClient.checkout.test.tsx +0 -457
  257. package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/__tests__/page.test.tsx +0 -396
  258. package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/page.tsx +0 -114
  259. package/templates/apps/web/app/[locale]/projects/apply/ListingApplicationClient.tsx +0 -110
  260. package/templates/apps/web/app/[locale]/projects/apply/__tests__/ListingApplicationClient.test.tsx +0 -96
  261. package/templates/apps/web/app/[locale]/schedule/__tests__/page.test.tsx +0 -117
  262. package/templates/apps/web/app/[locale]/schedule/confirmed/__tests__/page.test.tsx +0 -96
  263. package/templates/apps/web/app/[locale]/schedule/confirmed/page.tsx +0 -92
  264. package/templates/apps/web/app/[locale]/schedule/page.tsx +0 -82
  265. package/templates/apps/web/content/en/blog/README.txt +0 -25
  266. package/templates/apps/web/content/en/docs/index.mdx +0 -28
  267. package/templates/apps/web/lib/__mocks__/source-server.js +0 -31
  268. package/templates/apps/web/lib/blog-list-data.ts +0 -95
  269. package/templates/apps/web/lib/blog-post-data.ts +0 -79
  270. package/templates/apps/web/lib/source.ts +0 -34
  271. package/templates/apps/web/public/logomark-dark.svg +0 -4
  272. package/templates/apps/web/public/logomark-light.svg +0 -4
  273. package/templates/apps/web/source.config.ts +0 -78
@@ -1,671 +0,0 @@
1
- // oss: false
2
- /**
3
- * @file tenant-isolation.test.ts
4
- *
5
- * THE ACCEPTANCE GATE for the multi-tenant module (FEAT-042 / #3585):
6
- * a member of Org A must not reach Org B's data by ANY route — read or write.
7
- *
8
- * Fixture: two orgs (org_00000000-0000-7000-8000-000000000aaa/org_00000000-0000-7000-8000-000000000bbb), alice = owner of org_00000000-0000-7000-8000-000000000aaa, bob = owner of org_00000000-0000-7000-8000-000000000bbb,
9
- * carol = plain member of org_00000000-0000-7000-8000-000000000aaa, dave = cookie session pointing at wksp_00000000-0000-7000-8000-0000000000aa with NO
10
- * membership (revoked). One workspace + one agent session + one artifact per
11
- * org. The prisma mock HONORS where-clauses (id + workspaceId, membership
12
- * compound key), so scoping is exercised behaviorally — a foreign-tenant
13
- * lookup genuinely returns null — and cross-tenant denials are additionally
14
- * asserted by "domain query never ran".
15
- *
16
- * Registration mirrors server.ts exactly: auth preHandler → context routes →
17
- * nested scope { tenant guard → workspace/session routes }.
18
- */
19
- import Fastify from "fastify";
20
- import type { FastifyInstance } from "fastify";
21
-
22
- // eslint-disable-next-line no-restricted-syntax -- jest mock factory must be hoisted
23
- jest.mock("../../../lib/prisma.js", () => ({
24
- prisma: {
25
- session: { findUnique: jest.fn(), update: jest.fn() },
26
- apiToken: {
27
- findUnique: jest.fn(),
28
- update: jest.fn().mockResolvedValue(undefined),
29
- },
30
- workspace: { findUnique: jest.fn(), create: jest.fn(), count: jest.fn() },
31
- membership: { findUnique: jest.fn(), findMany: jest.fn() },
32
- agentSession: {
33
- findMany: jest.fn(),
34
- findFirst: jest.fn(),
35
- create: jest.fn(),
36
- updateMany: jest.fn(),
37
- },
38
- conversationEvent: { findMany: jest.fn() },
39
- sessionReadReceipt: { findMany: jest.fn(), upsert: jest.fn() },
40
- artifact: { findMany: jest.fn(), findFirst: jest.fn() },
41
- artifactVersion: { findMany: jest.fn() },
42
- },
43
- }));
44
-
45
- import { prisma } from "../../../lib/prisma.js";
46
- import { authPreHandlerPlugin } from "../../../middleware/auth.js";
47
- import { tenantPreHandlerPlugin } from "../../../middleware/tenant.js";
48
- import { workspaceContextRoutes, workspaceSessionRoutes } from "../index.js";
49
- import { sessionRoutes } from "../sessions.js";
50
-
51
- const FRONTEND = "https://app.example.com";
52
- const FUTURE = new Date(Date.now() + 60 * 60 * 1000);
53
-
54
- // ── Fixture data ────────────────────────────────────────────────────────────
55
-
56
- const WORKSPACES = [
57
- { id: "wksp_00000000-0000-7000-8000-0000000000aa", organizationId: "org_00000000-0000-7000-8000-000000000aaa", name: "A Main", slug: "a-main" },
58
- { id: "wksp_00000000-0000-7000-8000-0000000000a2", organizationId: "org_00000000-0000-7000-8000-000000000aaa", name: "A Second", slug: "a-second" },
59
- { id: "wksp_00000000-0000-7000-8000-0000000000bb", organizationId: "org_00000000-0000-7000-8000-000000000bbb", name: "B Main", slug: "b-main" },
60
- ];
61
-
62
- const MEMBERSHIPS = [
63
- { organizationId: "org_00000000-0000-7000-8000-000000000aaa", userId: "alice", role: "owner" },
64
- { organizationId: "org_00000000-0000-7000-8000-000000000aaa", userId: "carol", role: "member" },
65
- { organizationId: "org_00000000-0000-7000-8000-000000000bbb", userId: "bob", role: "owner" },
66
- ];
67
-
68
- const AGENT_SESSIONS = [
69
- {
70
- id: "agsess_00000000-0000-7000-8000-0000000000aa",
71
- workspaceId: "wksp_00000000-0000-7000-8000-0000000000aa",
72
- title: "Org A session",
73
- status: "idle",
74
- lastSeq: 7,
75
- lastMessageAt: new Date("2026-07-01T00:00:00Z"),
76
- draftMessage: null,
77
- createdAt: new Date("2026-06-30T00:00:00Z"),
78
- updatedAt: new Date("2026-07-01T00:00:00Z"),
79
- },
80
- {
81
- id: "agsess_00000000-0000-7000-8000-0000000000bb",
82
- workspaceId: "wksp_00000000-0000-7000-8000-0000000000bb",
83
- title: "Org B session",
84
- status: "idle",
85
- lastSeq: 3,
86
- lastMessageAt: new Date("2026-07-02T00:00:00Z"),
87
- draftMessage: null,
88
- createdAt: new Date("2026-06-29T00:00:00Z"),
89
- updatedAt: new Date("2026-07-02T00:00:00Z"),
90
- },
91
- ];
92
-
93
- const EVENTS = [
94
- {
95
- id: "evA1",
96
- sessionId: "agsess_00000000-0000-7000-8000-0000000000aa",
97
- workspaceId: "wksp_00000000-0000-7000-8000-0000000000aa",
98
- agentId: "agent-1",
99
- seq: 1,
100
- type: "user_message",
101
- turnId: "t1",
102
- payload: { content: "hi" },
103
- createdAt: new Date("2026-07-01T00:00:01Z"),
104
- },
105
- {
106
- id: "evB1",
107
- sessionId: "agsess_00000000-0000-7000-8000-0000000000bb",
108
- workspaceId: "wksp_00000000-0000-7000-8000-0000000000bb",
109
- agentId: "agent-1",
110
- seq: 1,
111
- type: "user_message",
112
- turnId: "t1",
113
- payload: { content: "org B secret" },
114
- createdAt: new Date("2026-07-02T00:00:01Z"),
115
- },
116
- ];
117
-
118
- const ARTIFACTS = [
119
- {
120
- id: "artif_00000000-0000-7000-8000-0000000000aa",
121
- sessionId: "agsess_00000000-0000-7000-8000-0000000000aa",
122
- workspaceId: "wksp_00000000-0000-7000-8000-0000000000aa",
123
- type: "text/document",
124
- title: "A doc",
125
- createdAt: new Date(),
126
- updatedAt: new Date(),
127
- },
128
- {
129
- id: "artif_00000000-0000-7000-8000-0000000000bb",
130
- sessionId: "agsess_00000000-0000-7000-8000-0000000000bb",
131
- workspaceId: "wksp_00000000-0000-7000-8000-0000000000bb",
132
- type: "text/document",
133
- title: "B doc",
134
- createdAt: new Date(),
135
- updatedAt: new Date(),
136
- },
137
- ];
138
-
139
- const ARTIFACT_VERSIONS = [
140
- { id: "artiv_00000000-0000-7000-8000-0000000000a1", artifactId: "artif_00000000-0000-7000-8000-0000000000aa", workspaceId: "wksp_00000000-0000-7000-8000-0000000000aa", isCurrent: true, savedAt: new Date() },
141
- { id: "artiv_00000000-0000-7000-8000-0000000000b1", artifactId: "artif_00000000-0000-7000-8000-0000000000bb", workspaceId: "wksp_00000000-0000-7000-8000-0000000000bb", isCurrent: true, savedAt: new Date() },
142
- ];
143
-
144
- /** Cookie token → Session row. Covers healthy, no-active, stale, revoked. */
145
- const SESSION_ROWS: Record<string, Record<string, unknown>> = {
146
- cookie_alice: {
147
- id: "sess_alice",
148
- userId: "alice",
149
- expiresAt: FUTURE,
150
- activeOrganizationId: "org_00000000-0000-7000-8000-000000000aaa",
151
- activeWorkspaceId: "wksp_00000000-0000-7000-8000-0000000000aa",
152
- },
153
- cookie_bob: {
154
- id: "sess_bob",
155
- userId: "bob",
156
- expiresAt: FUTURE,
157
- activeOrganizationId: "org_00000000-0000-7000-8000-000000000bbb",
158
- activeWorkspaceId: "wksp_00000000-0000-7000-8000-0000000000bb",
159
- },
160
- cookie_carol: {
161
- id: "sess_carol",
162
- userId: "carol",
163
- expiresAt: FUTURE,
164
- activeOrganizationId: "org_00000000-0000-7000-8000-000000000aaa",
165
- activeWorkspaceId: "wksp_00000000-0000-7000-8000-0000000000aa",
166
- },
167
- cookie_alice_no_active: {
168
- id: "sess_alice2",
169
- userId: "alice",
170
- expiresAt: FUTURE,
171
- activeOrganizationId: null,
172
- activeWorkspaceId: null,
173
- },
174
- cookie_alice_stale: {
175
- id: "sess_alice3",
176
- userId: "alice",
177
- expiresAt: FUTURE,
178
- // Workspace of org A but session claims org B — must be denied.
179
- activeOrganizationId: "org_00000000-0000-7000-8000-000000000bbb",
180
- activeWorkspaceId: "wksp_00000000-0000-7000-8000-0000000000aa",
181
- },
182
- cookie_alice_ws2: {
183
- id: "sess_alice_ws2",
184
- userId: "alice",
185
- expiresAt: FUTURE,
186
- activeOrganizationId: "org_00000000-0000-7000-8000-000000000aaa",
187
- activeWorkspaceId: "wksp_00000000-0000-7000-8000-0000000000a2",
188
- },
189
- cookie_dave_revoked: {
190
- id: "sess_dave",
191
- userId: "dave",
192
- expiresAt: FUTURE,
193
- activeOrganizationId: "org_00000000-0000-7000-8000-000000000aaa",
194
- activeWorkspaceId: "wksp_00000000-0000-7000-8000-0000000000aa",
195
- },
196
- };
197
-
198
- // ── Where-clause-honoring mock wiring ──────────────────────────────────────
199
-
200
- type Where = Record<string, unknown>;
201
-
202
- function installFixtureMocks(): void {
203
- (prisma.session.findUnique as jest.Mock).mockImplementation(
204
- ({ where }: { where: { token?: string } }) =>
205
- Promise.resolve(where.token ? (SESSION_ROWS[where.token] ?? null) : null),
206
- );
207
- (prisma.session.update as jest.Mock).mockResolvedValue({});
208
-
209
- (prisma.workspace.findUnique as jest.Mock).mockImplementation(
210
- ({ where }: { where: { id?: string } }) =>
211
- Promise.resolve(WORKSPACES.find((w) => w.id === where.id) ?? null),
212
- );
213
-
214
- // This fixture is genuinely multi-tenant (3 workspaces across 2 orgs) — the
215
- // #3627 single-tenant default-resolution branch (`count() === 1`) must
216
- // never fire here, so an unbound session keeps denying (default-deny).
217
- (prisma.workspace.count as jest.Mock).mockResolvedValue(WORKSPACES.length);
218
-
219
- (prisma.membership.findUnique as jest.Mock).mockImplementation(
220
- ({
221
- where,
222
- }: {
223
- where: { organizationId_userId?: { organizationId: string; userId: string } };
224
- }) => {
225
- const key = where.organizationId_userId;
226
- if (!key) return Promise.resolve(null);
227
- return Promise.resolve(
228
- MEMBERSHIPS.find(
229
- (m) =>
230
- m.organizationId === key.organizationId && m.userId === key.userId,
231
- ) ?? null,
232
- );
233
- },
234
- );
235
-
236
- (prisma.membership.findMany as jest.Mock).mockImplementation(
237
- ({ where }: { where: { userId?: string } }) => {
238
- const rows = MEMBERSHIPS.filter((m) => m.userId === where.userId).map(
239
- (m) => {
240
- const org = { id: m.organizationId, name: m.organizationId, slug: m.organizationId };
241
- return {
242
- role: m.role,
243
- createdAt: new Date(),
244
- organization: {
245
- ...org,
246
- workspaces: WORKSPACES.filter(
247
- (w) => w.organizationId === m.organizationId,
248
- ).map((w) => ({ id: w.id, name: w.name, slug: w.slug })),
249
- },
250
- };
251
- },
252
- );
253
- return Promise.resolve(rows);
254
- },
255
- );
256
-
257
- (prisma.agentSession.findFirst as jest.Mock).mockImplementation(
258
- ({ where }: { where: Where }) =>
259
- Promise.resolve(
260
- AGENT_SESSIONS.find(
261
- (s) => s.id === where.id && s.workspaceId === where.workspaceId,
262
- ) ?? null,
263
- ),
264
- );
265
- (prisma.agentSession.findMany as jest.Mock).mockImplementation(
266
- ({ where }: { where: Where }) =>
267
- Promise.resolve(
268
- AGENT_SESSIONS.filter((s) => s.workspaceId === where.workspaceId),
269
- ),
270
- );
271
- (prisma.agentSession.create as jest.Mock).mockImplementation(
272
- ({ data }: { data: Where }) =>
273
- Promise.resolve({ id: "ses_new", ...data }),
274
- );
275
-
276
- (prisma.conversationEvent.findMany as jest.Mock).mockImplementation(
277
- ({ where }: { where: Where }) =>
278
- Promise.resolve(
279
- EVENTS.filter(
280
- (e) =>
281
- e.sessionId === where.sessionId &&
282
- e.workspaceId === where.workspaceId,
283
- ),
284
- ),
285
- );
286
-
287
- (prisma.sessionReadReceipt.findMany as jest.Mock).mockImplementation(
288
- ({ where }: { where: Where }) =>
289
- Promise.resolve(
290
- [] as Array<{ sessionId: string; lastReadSeq: number }>,
291
- ),
292
- );
293
- (prisma.sessionReadReceipt.upsert as jest.Mock).mockImplementation(
294
- ({ create }: { create: Where }) => Promise.resolve(create),
295
- );
296
-
297
- (prisma.artifact.findFirst as jest.Mock).mockImplementation(
298
- ({ where }: { where: Where }) =>
299
- Promise.resolve(
300
- ARTIFACTS.find(
301
- (a) => a.id === where.id && a.workspaceId === where.workspaceId,
302
- ) ?? null,
303
- ),
304
- );
305
- (prisma.artifact.findMany as jest.Mock).mockImplementation(
306
- ({ where }: { where: Where }) =>
307
- Promise.resolve(
308
- ARTIFACTS.filter(
309
- (a) =>
310
- a.sessionId === where.sessionId &&
311
- a.workspaceId === where.workspaceId,
312
- ),
313
- ),
314
- );
315
- (prisma.artifactVersion.findMany as jest.Mock).mockImplementation(
316
- ({ where }: { where: Where }) =>
317
- Promise.resolve(
318
- ARTIFACT_VERSIONS.filter(
319
- (v) =>
320
- v.artifactId === where.artifactId &&
321
- v.workspaceId === where.workspaceId,
322
- ),
323
- ),
324
- );
325
- }
326
-
327
- // ── App assembly (mirrors server.ts registration order) ────────────────────
328
-
329
- async function buildApp(): Promise<FastifyInstance> {
330
- const app = Fastify();
331
- app.register(authPreHandlerPlugin, { frontendOrigin: FRONTEND });
332
- app.register(workspaceContextRoutes);
333
- app.register(async (scope) => {
334
- await scope.register(tenantPreHandlerPlugin);
335
- await scope.register(workspaceSessionRoutes);
336
- await scope.register(sessionRoutes);
337
- });
338
- await app.ready();
339
- return app;
340
- }
341
-
342
- function cookieHeaders(token: string, mutating = false): Record<string, string> {
343
- return {
344
- cookie: `better-auth.session_token=${token}`,
345
- ...(mutating ? { origin: FRONTEND } : {}),
346
- };
347
- }
348
-
349
- describe("tenant isolation — cross-org access denied on every route", () => {
350
- let app: FastifyInstance;
351
-
352
- beforeEach(async () => {
353
- jest.clearAllMocks();
354
- installFixtureMocks();
355
- app = await buildApp();
356
- });
357
-
358
- afterEach(async () => {
359
- await app.close();
360
- });
361
-
362
- // ── Cross-org READS ──────────────────────────────────────────────────────
363
-
364
- it("GET a foreign workspace's session list → 403, no domain query runs", async () => {
365
- const res = await app.inject({
366
- method: "GET",
367
- url: "/v1/workspaces/wksp_00000000-0000-7000-8000-0000000000bb/sessions",
368
- headers: cookieHeaders("cookie_alice"),
369
- });
370
- expect(res.statusCode).toBe(403);
371
- expect(prisma.agentSession.findMany).not.toHaveBeenCalled();
372
- });
373
-
374
- it("GET own workspace's session list → 200, scoped to own workspace only", async () => {
375
- const res = await app.inject({
376
- method: "GET",
377
- url: "/v1/workspaces/wksp_00000000-0000-7000-8000-0000000000aa/sessions",
378
- headers: cookieHeaders("cookie_alice"),
379
- });
380
- expect(res.statusCode).toBe(200);
381
- const body = res.json();
382
- expect(body.ok).toBe(true);
383
- expect(body.data.sessions).toHaveLength(1);
384
- expect(body.data.sessions[0].id).toBe("agsess_00000000-0000-7000-8000-0000000000aa");
385
- expect(prisma.agentSession.findMany).toHaveBeenCalledWith(
386
- expect.objectContaining({
387
- where: expect.objectContaining({ workspaceId: "wksp_00000000-0000-7000-8000-0000000000aa" }),
388
- }),
389
- );
390
- });
391
-
392
- it("replay a foreign session's conversation events → 404, events never queried", async () => {
393
- const res = await app.inject({
394
- method: "GET",
395
- url: "/v1/sessions/agsess_00000000-0000-7000-8000-0000000000bb/conversation-events?sinceSeq=0",
396
- headers: cookieHeaders("cookie_alice"),
397
- });
398
- expect(res.statusCode).toBe(404);
399
- expect(prisma.conversationEvent.findMany).not.toHaveBeenCalled();
400
- });
401
-
402
- it("replay own session's events → 200 with events + lastSeq", async () => {
403
- const res = await app.inject({
404
- method: "GET",
405
- url: "/v1/sessions/agsess_00000000-0000-7000-8000-0000000000aa/conversation-events?sinceSeq=0",
406
- headers: cookieHeaders("cookie_alice"),
407
- });
408
- expect(res.statusCode).toBe(200);
409
- const body = res.json();
410
- expect(body.data.lastSeq).toBe(7);
411
- expect(body.data.events).toHaveLength(1);
412
- expect(body.data.events[0].payload.content).toBe("hi");
413
- });
414
-
415
- it("mint a ws-token for a foreign session → 404", async () => {
416
- const res = await app.inject({
417
- method: "GET",
418
- url: "/v1/sessions/agsess_00000000-0000-7000-8000-0000000000bb/ws-token",
419
- headers: cookieHeaders("cookie_alice"),
420
- });
421
- expect(res.statusCode).toBe(404);
422
- });
423
-
424
- it("mint a ws-token for an own session → 200 with short-lived token", async () => {
425
- const res = await app.inject({
426
- method: "GET",
427
- url: "/v1/sessions/agsess_00000000-0000-7000-8000-0000000000aa/ws-token",
428
- headers: cookieHeaders("cookie_alice"),
429
- });
430
- expect(res.statusCode).toBe(200);
431
- expect(res.json().data.token).toMatch(/^[\w-]+\.[\w-]+\.[\w-]+$/);
432
- });
433
-
434
- it("list a foreign session's artifacts → 404", async () => {
435
- const res = await app.inject({
436
- method: "GET",
437
- url: "/v1/sessions/agsess_00000000-0000-7000-8000-0000000000bb/artifacts",
438
- headers: cookieHeaders("cookie_alice"),
439
- });
440
- expect(res.statusCode).toBe(404);
441
- expect(prisma.artifact.findMany).not.toHaveBeenCalled();
442
- });
443
-
444
- it("list a foreign artifact's versions → 404, versions never queried", async () => {
445
- const res = await app.inject({
446
- method: "GET",
447
- url: "/v1/artifacts/artif_00000000-0000-7000-8000-0000000000bb/versions",
448
- headers: cookieHeaders("cookie_alice"),
449
- });
450
- expect(res.statusCode).toBe(404);
451
- expect(prisma.artifactVersion.findMany).not.toHaveBeenCalled();
452
- });
453
-
454
- it("list an own artifact's versions → 200", async () => {
455
- const res = await app.inject({
456
- method: "GET",
457
- url: "/v1/artifacts/artif_00000000-0000-7000-8000-0000000000aa/versions",
458
- headers: cookieHeaders("cookie_alice"),
459
- });
460
- expect(res.statusCode).toBe(200);
461
- expect(res.json().data.versions).toHaveLength(1);
462
- });
463
-
464
- it("the switcher listing never contains foreign orgs", async () => {
465
- const res = await app.inject({
466
- method: "GET",
467
- url: "/v1/workspaces",
468
- headers: cookieHeaders("cookie_alice"),
469
- });
470
- expect(res.statusCode).toBe(200);
471
- const orgs = res.json().data.organizations;
472
- expect(orgs.map((o: { id: string }) => o.id)).toEqual(["org_00000000-0000-7000-8000-000000000aaa"]);
473
- });
474
-
475
- // ── Cross-org WRITES ─────────────────────────────────────────────────────
476
-
477
- it("create a session in a foreign workspace → 403, create never runs", async () => {
478
- const res = await app.inject({
479
- method: "POST",
480
- url: "/v1/workspaces/wksp_00000000-0000-7000-8000-0000000000bb/sessions",
481
- headers: cookieHeaders("cookie_alice", true),
482
- payload: { title: "intrusion" },
483
- });
484
- expect(res.statusCode).toBe(403);
485
- expect(prisma.agentSession.create).not.toHaveBeenCalled();
486
- });
487
-
488
- it("create a session in own workspace → 201 scoped to own workspace", async () => {
489
- const res = await app.inject({
490
- method: "POST",
491
- url: "/v1/workspaces/wksp_00000000-0000-7000-8000-0000000000aa/sessions",
492
- headers: cookieHeaders("cookie_alice", true),
493
- payload: { title: "mine" },
494
- });
495
- expect(res.statusCode).toBe(201);
496
- expect(prisma.agentSession.create).toHaveBeenCalledWith(
497
- expect.objectContaining({
498
- data: expect.objectContaining({ workspaceId: "wksp_00000000-0000-7000-8000-0000000000aa" }),
499
- }),
500
- );
501
- });
502
-
503
- it("mark-read on a foreign session → 404, upsert never runs", async () => {
504
- const res = await app.inject({
505
- method: "POST",
506
- url: "/v1/sessions/agsess_00000000-0000-7000-8000-0000000000bb/mark-read",
507
- headers: cookieHeaders("cookie_alice", true),
508
- payload: {},
509
- });
510
- expect(res.statusCode).toBe(404);
511
- expect(prisma.sessionReadReceipt.upsert).not.toHaveBeenCalled();
512
- });
513
-
514
- it("activate a workspace in an org the caller is not a member of → 404, session untouched", async () => {
515
- const res = await app.inject({
516
- method: "POST",
517
- url: "/v1/workspaces/wksp_00000000-0000-7000-8000-0000000000bb/activate",
518
- headers: cookieHeaders("cookie_alice", true),
519
- });
520
- expect(res.statusCode).toBe(404);
521
- expect(prisma.session.update).not.toHaveBeenCalled();
522
- });
523
-
524
- it("activate an own-org workspace → 200 and updates the session row", async () => {
525
- const res = await app.inject({
526
- method: "POST",
527
- url: "/v1/workspaces/wksp_00000000-0000-7000-8000-0000000000aa/activate",
528
- headers: cookieHeaders("cookie_alice", true),
529
- });
530
- expect(res.statusCode).toBe(200);
531
- expect(prisma.session.update).toHaveBeenCalledWith(
532
- expect.objectContaining({
533
- where: { id: "sess_alice" },
534
- data: { activeWorkspaceId: "wksp_00000000-0000-7000-8000-0000000000aa", activeOrganizationId: "org_00000000-0000-7000-8000-000000000aaa" },
535
- }),
536
- );
537
- });
538
-
539
- it("create a workspace in a foreign org → 404, create never runs", async () => {
540
- const res = await app.inject({
541
- method: "POST",
542
- url: "/v1/workspaces",
543
- headers: cookieHeaders("cookie_alice", true),
544
- payload: { organizationId: "org_00000000-0000-7000-8000-000000000bbb", name: "Intrusion" },
545
- });
546
- expect(res.statusCode).toBe(404);
547
- expect(prisma.workspace.create).not.toHaveBeenCalled();
548
- });
549
-
550
- it("create a workspace below the admin role floor → 403 (carol is member)", async () => {
551
- const res = await app.inject({
552
- method: "POST",
553
- url: "/v1/workspaces",
554
- headers: cookieHeaders("cookie_carol", true),
555
- payload: { organizationId: "org_00000000-0000-7000-8000-000000000aaa", name: "Nope" },
556
- });
557
- expect(res.statusCode).toBe(403);
558
- expect(prisma.workspace.create).not.toHaveBeenCalled();
559
- });
560
-
561
- // ── Default-deny tenant context ──────────────────────────────────────────
562
-
563
- it("bearer-authenticated request (no workspace binding, #3591) → 403", async () => {
564
- (prisma.apiToken.findUnique as jest.Mock).mockResolvedValue({
565
- id: "tok1",
566
- userId: "alice",
567
- expiresAt: null,
568
- revokedAt: null,
569
- });
570
- const res = await app.inject({
571
- method: "GET",
572
- url: "/v1/workspaces/wksp_00000000-0000-7000-8000-0000000000aa/sessions",
573
- headers: { authorization: "Bearer tok_raw" },
574
- });
575
- expect(res.statusCode).toBe(403);
576
- expect(prisma.agentSession.findMany).not.toHaveBeenCalled();
577
- });
578
-
579
- it("cookie session with no active workspace → 403", async () => {
580
- const res = await app.inject({
581
- method: "GET",
582
- url: "/v1/workspaces/wksp_00000000-0000-7000-8000-0000000000aa/sessions",
583
- headers: cookieHeaders("cookie_alice_no_active"),
584
- });
585
- expect(res.statusCode).toBe(403);
586
- });
587
-
588
- it("stale session context (active org ≠ workspace's org) → 403", async () => {
589
- const res = await app.inject({
590
- method: "GET",
591
- url: "/v1/workspaces/wksp_00000000-0000-7000-8000-0000000000aa/sessions",
592
- headers: cookieHeaders("cookie_alice_stale"),
593
- });
594
- expect(res.statusCode).toBe(403);
595
- });
596
-
597
- it("revoked membership with a still-active session context → 403", async () => {
598
- const res = await app.inject({
599
- method: "GET",
600
- url: "/v1/workspaces/wksp_00000000-0000-7000-8000-0000000000aa/sessions",
601
- headers: cookieHeaders("cookie_dave_revoked"),
602
- });
603
- expect(res.statusCode).toBe(403);
604
- expect(prisma.agentSession.findMany).not.toHaveBeenCalled();
605
- });
606
-
607
- it("after switching to another own-org workspace, the PREVIOUS workspace is unreachable", async () => {
608
- // alice's session now points at wksp_00000000-0000-7000-8000-0000000000a2 (same org) — wksp_00000000-0000-7000-8000-0000000000aa, her previous
609
- // active workspace, must 403 like any non-active target (I5b).
610
- const res = await app.inject({
611
- method: "GET",
612
- url: "/v1/workspaces/wksp_00000000-0000-7000-8000-0000000000aa/sessions",
613
- headers: cookieHeaders("cookie_alice_ws2"),
614
- });
615
- expect(res.statusCode).toBe(403);
616
- expect(prisma.agentSession.findMany).not.toHaveBeenCalled();
617
-
618
- const own = await app.inject({
619
- method: "GET",
620
- url: "/v1/workspaces/wksp_00000000-0000-7000-8000-0000000000a2/sessions",
621
- headers: cookieHeaders("cookie_alice_ws2"),
622
- });
623
- expect(own.statusCode).toBe(200);
624
- });
625
-
626
- it("activate is not an existence oracle: foreign and nonexistent ids are identical 404s", async () => {
627
- // carol (org A member) probing org B's workspace vs a made-up id (I5c).
628
- const foreign = await app.inject({
629
- method: "POST",
630
- url: "/v1/workspaces/wksp_00000000-0000-7000-8000-0000000000bb/activate",
631
- headers: cookieHeaders("cookie_carol", true),
632
- });
633
- const missing = await app.inject({
634
- method: "POST",
635
- url: "/v1/workspaces/wksp_00000000-0000-7000-8000-00000000dead/activate",
636
- headers: cookieHeaders("cookie_carol", true),
637
- });
638
- expect(foreign.statusCode).toBe(404);
639
- expect(missing.statusCode).toBe(404);
640
- expect(foreign.body).toBe(missing.body);
641
- expect(prisma.session.update).not.toHaveBeenCalled();
642
-
643
- // A malformed id never reaches the service — rejected at the boundary by
644
- // the zWorkspaceId schema (ADR-0031 rule 5), before any lookup runs.
645
- const malformed = await app.inject({
646
- method: "POST",
647
- url: "/v1/workspaces/ws_does_not_exist/activate",
648
- headers: cookieHeaders("cookie_carol", true),
649
- });
650
- expect(malformed.statusCode).toBe(400);
651
- });
652
-
653
- it("unauthenticated → 401", async () => {
654
- const res = await app.inject({
655
- method: "GET",
656
- url: "/v1/workspaces/wksp_00000000-0000-7000-8000-0000000000aa/sessions",
657
- });
658
- expect(res.statusCode).toBe(401);
659
- });
660
-
661
- it("bob CAN read his own org's data (the grant path works for both tenants)", async () => {
662
- const res = await app.inject({
663
- method: "GET",
664
- url: "/v1/workspaces/wksp_00000000-0000-7000-8000-0000000000bb/sessions",
665
- headers: cookieHeaders("cookie_bob"),
666
- });
667
- expect(res.statusCode).toBe(200);
668
- const body = res.json();
669
- expect(body.data.sessions.map((s: { id: string }) => s.id)).toEqual(["agsess_00000000-0000-7000-8000-0000000000bb"]);
670
- });
671
- });