create-ailk 0.22.0 → 0.24.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 (287) hide show
  1. package/README.md +27 -14
  2. package/component-catalog.json +149 -5
  3. package/component-catalog.md +7 -6
  4. package/dist/add-module.js +17 -10
  5. package/dist/add-route.d.ts +55 -1
  6. package/dist/add-route.js +301 -13
  7. package/dist/cli.js +27 -10
  8. package/dist/derive-architecture.d.ts +7 -5
  9. package/dist/derive-architecture.js +26 -32
  10. package/dist/lib/design-checks.js +4 -2
  11. package/dist/lib/env-catalog.js +0 -4
  12. package/dist/lib/npm-auth.d.ts +9 -0
  13. package/dist/lib/npm-auth.js +48 -0
  14. package/dist/lib/scaffold-render-sweep.d.ts +36 -2
  15. package/dist/lib/scaffold-render-sweep.js +50 -8
  16. package/dist/module-architecture.js +115 -10
  17. package/dist/paid-sections.d.ts +21 -2
  18. package/dist/paid-sections.js +69 -7
  19. package/dist/seed-flows.d.ts +4 -1
  20. package/dist/seed-flows.js +50 -4
  21. package/dist/spoke-checkout.d.ts +38 -35
  22. package/dist/spoke-checkout.js +66 -229
  23. package/dist/starter-grid.d.ts +0 -3
  24. package/dist/starter-grid.js +24 -14
  25. package/dist/surfaces.js +33 -12
  26. package/dist/sync-routes.d.ts +68 -5
  27. package/dist/sync-routes.js +455 -29
  28. package/dist/template-record.d.ts +1 -1
  29. package/dist/template-record.js +3 -2
  30. package/dist/update.js +10 -0
  31. package/package.json +3 -3
  32. package/site-templates/_base/content/en/NewsletterSignup/newsletter.mdx +4 -49
  33. package/site-templates/_base/content/en/Pricing/pricing.mdx +3 -21
  34. package/site-templates/marketing-site/TEMPLATE-MANIFEST.json +23 -20
  35. package/site-templates/marketing-site/TEMPLATE-MANIFEST.md +28 -14
  36. package/site-templates/marketing-site-free/TEMPLATE-MANIFEST.json +15 -39
  37. package/site-templates/marketing-site-free/TEMPLATE-MANIFEST.md +38 -34
  38. package/site-templates/marketing-site-free/content/en/NewsletterSignup/newsletter.mdx +6 -43
  39. package/site-templates/shapes/local-business-service/TEMPLATE-MANIFEST.json +14 -31
  40. package/site-templates/shapes/local-business-service/TEMPLATE-MANIFEST.md +26 -25
  41. package/site-templates/shapes/local-business-service/content/en/NewsletterSignup/newsletter.mdx +6 -43
  42. package/site-templates/shapes/organization-service/TEMPLATE-MANIFEST.json +14 -31
  43. package/site-templates/shapes/organization-service/TEMPLATE-MANIFEST.md +26 -25
  44. package/site-templates/shapes/organization-service/content/en/NewsletterSignup/newsletter.mdx +6 -43
  45. package/site-templates/shapes/person-service/TEMPLATE-MANIFEST.json +14 -31
  46. package/site-templates/shapes/person-service/TEMPLATE-MANIFEST.md +26 -25
  47. package/site-templates/shapes/person-service/content/en/NewsletterSignup/newsletter.mdx +6 -43
  48. package/templates/.env.example +13 -7
  49. package/templates/.github/workflows/ci.yml +13 -0
  50. package/templates/CLAUDE.md +4 -2
  51. package/templates/README.md +1 -0
  52. package/templates/_gitignore +4 -0
  53. package/templates/apps/api/.env.example +34 -0
  54. package/templates/apps/api/CLAUDE.md +94 -19
  55. package/templates/apps/api/package.json +1 -0
  56. package/templates/apps/api/src/__tests__/factories/in-memory-prisma.ts +2 -0
  57. package/templates/apps/api/src/bin/stripe-connections-rekey.ts +45 -0
  58. package/templates/apps/api/src/config/__tests__/stripe-connection-key.test.ts +185 -0
  59. package/templates/apps/api/src/config/index.ts +109 -0
  60. package/templates/apps/api/src/lib/__mocks__/prisma.ts +7 -1
  61. package/templates/apps/api/src/lib/__tests__/flow-rename-race.db.test.ts +116 -0
  62. package/templates/apps/api/src/lib/__tests__/site.test.ts +16 -0
  63. package/templates/apps/api/src/lib/error-serializer.ts +74 -7
  64. package/templates/apps/api/src/lib/site.ts +4 -1
  65. package/templates/apps/api/src/lib/stripe.ts +77 -4
  66. package/templates/apps/api/src/lib/webhook-idempotency.ts +66 -2
  67. package/templates/apps/api/src/middleware/__tests__/rate-limit.test.ts +218 -0
  68. package/templates/apps/api/src/middleware/__tests__/write-budget.test.ts +174 -0
  69. package/templates/apps/api/src/middleware/rate-limit.ts +226 -4
  70. package/templates/apps/api/src/openapi/__tests__/openapi.test.ts +127 -0
  71. package/templates/apps/api/src/openapi/spec.ts +343 -7
  72. package/templates/apps/api/src/routes/billing/plan-checkout.ts +332 -0
  73. package/templates/apps/api/src/routes/checkout/__tests__/live-key.test.ts +11 -0
  74. package/templates/apps/api/src/routes/checkout/__tests__/offerings-authorization.test.ts +1426 -0
  75. package/templates/apps/api/src/routes/checkout/__tests__/session.test.ts +11 -0
  76. package/templates/apps/api/src/routes/checkout/__tests__/sessions.test.ts +49 -0
  77. package/templates/apps/api/src/routes/checkout/session.ts +57 -25
  78. package/templates/apps/api/src/routes/checkout/sessions.ts +232 -82
  79. package/templates/apps/api/src/routes/consent/__tests__/account.test.ts +5 -5
  80. package/templates/apps/api/src/routes/consent/__tests__/sign-up-rate-limit.test.ts +160 -0
  81. package/templates/apps/api/src/routes/consent/account.ts +4 -2
  82. package/templates/apps/api/src/routes/content/__tests__/get-page.test.ts +2 -2
  83. package/templates/apps/api/src/routes/content/__tests__/offerings.test.ts +731 -0
  84. package/templates/apps/api/src/routes/content/__tests__/pricing.test.ts +753 -0
  85. package/templates/apps/api/src/routes/content/__tests__/shown-offerings.test.ts +762 -0
  86. package/templates/apps/api/src/routes/content/get-page.ts +34 -119
  87. package/templates/apps/api/src/routes/content/index.ts +16 -2
  88. package/templates/apps/api/src/routes/content/list-pages.ts +15 -36
  89. package/templates/apps/api/src/routes/content/offerings.ts +184 -0
  90. package/templates/apps/api/src/routes/content/pricing.ts +176 -0
  91. package/templates/apps/api/src/routes/content/search.ts +16 -45
  92. package/templates/apps/api/src/routes/content/shown-offerings.ts +229 -0
  93. package/templates/apps/api/src/routes/leads/index.ts +377 -308
  94. package/templates/apps/api/src/routes/project-listings/__tests__/me.test.ts +20 -9
  95. package/templates/apps/api/src/routes/project-listings/__tests__/public.test.ts +17 -6
  96. package/templates/apps/api/src/routes/project-listings/__tests__/site-answers.test.ts +1 -1
  97. package/templates/apps/api/src/routes/webhooks/README.md +66 -0
  98. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-flow-checkout.test.ts +1 -0
  99. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-offerings.test.ts +814 -0
  100. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-org-billing.test.ts +80 -68
  101. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-site-filter.test.ts +6 -5
  102. package/templates/apps/api/src/routes/webhooks/__tests__/stripe.test.ts +8 -2
  103. package/templates/apps/api/src/routes/webhooks/index.ts +23 -0
  104. package/templates/apps/api/src/routes/webhooks/stripe-offerings.ts +384 -0
  105. package/templates/apps/api/src/routes/webhooks/stripe.ts +93 -17
  106. package/templates/apps/api/src/server.ts +165 -51
  107. package/templates/apps/api/src/services/__tests__/abandon-email.test.ts +121 -4
  108. package/templates/apps/api/src/services/__tests__/checkout-plan.test.ts +313 -0
  109. package/templates/apps/api/src/services/__tests__/deliverable-fulfillment.test.ts +61 -2
  110. package/templates/apps/api/src/services/__tests__/keyless-workspace-predicate.test.ts +234 -0
  111. package/templates/apps/api/src/services/__tests__/lead-routing.test.ts +374 -44
  112. package/templates/apps/api/src/services/__tests__/person.test.ts +260 -1
  113. package/templates/apps/api/src/services/__tests__/plan-offerings.test.ts +450 -0
  114. package/templates/apps/api/src/services/__tests__/project-listings.counts.test.ts +202 -0
  115. package/templates/apps/api/src/services/__tests__/project-listings.fence.test.ts +15 -18
  116. package/templates/apps/api/src/services/abandon-email.ts +28 -5
  117. package/templates/apps/api/src/services/deliverable-fulfillment.ts +15 -12
  118. package/templates/apps/api/src/services/entitlements.ts +157 -1
  119. package/templates/apps/api/src/services/lead-routing.ts +113 -58
  120. package/templates/apps/api/src/services/offerings/__tests__/catalog.test.ts +348 -0
  121. package/templates/apps/api/src/services/offerings/__tests__/checkout-session-record.test.ts +268 -0
  122. package/templates/apps/api/src/services/offerings/__tests__/connection.test.ts +975 -0
  123. package/templates/apps/api/src/services/offerings/__tests__/fixtures.ts +416 -0
  124. package/templates/apps/api/src/services/offerings/__tests__/org-products.test.ts +400 -0
  125. package/templates/apps/api/src/services/offerings/__tests__/readers.test.ts +685 -0
  126. package/templates/apps/api/src/services/offerings/__tests__/source.test.ts +231 -0
  127. package/templates/apps/api/src/services/offerings/catalog.ts +318 -0
  128. package/templates/apps/api/src/services/offerings/checkout-authorization.ts +403 -0
  129. package/templates/apps/api/src/services/offerings/checkout-session-record.ts +192 -0
  130. package/templates/apps/api/src/services/offerings/connection.ts +1083 -0
  131. package/templates/apps/api/src/services/offerings/index.ts +614 -0
  132. package/templates/apps/api/src/services/offerings/source.ts +140 -0
  133. package/templates/apps/api/src/services/offerings/store.ts +150 -0
  134. package/templates/apps/api/src/services/person.ts +147 -9
  135. package/templates/apps/api/src/services/plan-offerings.ts +96 -30
  136. package/templates/apps/api/src/services/project-listings.ts +43 -31
  137. package/templates/apps/api/src/services/public-site-resolver.ts +19 -14
  138. package/templates/apps/mcp/CLAUDE.md +32 -18
  139. package/templates/apps/mcp/__tests__/app-entry.test.ts +2 -2
  140. package/templates/apps/mcp/__tests__/catalog-drift.test.ts +9 -4
  141. package/templates/apps/mcp/__tests__/get_offerings.test.ts +358 -0
  142. package/templates/apps/mcp/__tests__/get_pricing.test.ts +364 -0
  143. package/templates/apps/mcp/__tests__/http-transport.test.ts +95 -14
  144. package/templates/apps/mcp/__tests__/lib/content-adapter-parity.test.ts +8 -3
  145. package/templates/apps/mcp/__tests__/lib/content-adapter.test.ts +0 -25
  146. package/templates/apps/mcp/__tests__/lib/fail-closed-tools.test.ts +22 -7
  147. package/templates/apps/mcp/__tests__/list_pages.test.ts +84 -44
  148. package/templates/apps/mcp/__tests__/locale.test.ts +41 -56
  149. package/templates/apps/mcp/__tests__/module-exclusion.test.ts +2 -2
  150. package/templates/apps/mcp/__tests__/rate-limit-write.test.ts +189 -0
  151. package/templates/apps/mcp/__tests__/read_content.test.ts +115 -78
  152. package/templates/apps/mcp/__tests__/request_callback.test.ts +63 -128
  153. package/templates/apps/mcp/__tests__/search_content.test.ts +76 -61
  154. package/templates/apps/mcp/__tests__/server.test.ts +13 -11
  155. package/templates/apps/mcp/__tests__/start_purchase.test.ts +208 -12
  156. package/templates/apps/mcp/__tests__/submit_lead.test.ts +82 -167
  157. package/templates/apps/mcp/__tests__/subscribe_newsletter.test.ts +59 -130
  158. package/templates/apps/mcp/__tests__/tools/purchase.test.ts +11 -11
  159. package/templates/apps/mcp/package.json +1 -0
  160. package/templates/apps/mcp/src/abuse/index.ts +18 -11
  161. package/templates/apps/mcp/src/http-transport.ts +47 -9
  162. package/templates/apps/mcp/src/lib/content-adapter.ts +0 -26
  163. package/templates/apps/mcp/src/lib/empty-content-adapter.ts +5 -4
  164. package/templates/apps/mcp/src/lib/shown-offerings.ts +105 -0
  165. package/templates/apps/mcp/src/lib/site-config.ts +53 -0
  166. package/templates/apps/mcp/src/server.ts +5 -0
  167. package/templates/apps/mcp/src/tools/capture_lead.ts +30 -28
  168. package/templates/apps/mcp/src/tools/get_offerings.ts +47 -30
  169. package/templates/apps/mcp/src/tools/get_pricing.ts +40 -40
  170. package/templates/apps/mcp/src/tools/index.ts +22 -21
  171. package/templates/apps/mcp/src/tools/list_pages.ts +27 -33
  172. package/templates/apps/mcp/src/tools/purchase.ts +30 -2
  173. package/templates/apps/mcp/src/tools/read_content.ts +46 -40
  174. package/templates/apps/mcp/src/tools/request_callback.ts +21 -28
  175. package/templates/apps/mcp/src/tools/search_content.ts +20 -24
  176. package/templates/apps/mcp/src/tools/subscribe_newsletter.ts +23 -26
  177. package/templates/apps/mcp/src/workspace-server.ts +15 -0
  178. package/templates/apps/web/.env.example +10 -31
  179. package/templates/apps/web/CLAUDE.md +324 -42
  180. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/agreements-prompt.test.tsx +3 -3
  181. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/layout.test.tsx +3 -3
  182. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/privacy.test.tsx +3 -3
  183. package/templates/apps/web/app/[locale]/careers/[slug]/[action]/page.tsx +189 -0
  184. package/templates/apps/web/app/[locale]/careers/[slug]/apply.actions.ts +4 -2
  185. package/templates/apps/web/app/[locale]/careers/[slug]/job-application-form.ts +8 -3
  186. package/templates/apps/web/app/[locale]/careers/[slug]/page.tsx +22 -144
  187. package/templates/apps/web/app/[locale]/careers/__tests__/apply.actions.test.ts +9 -0
  188. package/templates/apps/web/app/[locale]/careers/__tests__/careers-hub.test.tsx +172 -0
  189. package/templates/apps/web/app/[locale]/careers/__tests__/careers-set.test.tsx +149 -0
  190. package/templates/apps/web/app/[locale]/careers/__tests__/job-application-form.test.ts +22 -24
  191. package/templates/apps/web/app/[locale]/careers/__tests__/job-apply-page.test.tsx +140 -66
  192. package/templates/apps/web/app/[locale]/careers/__tests__/page-renderer-migration.test.tsx +57 -22
  193. package/templates/apps/web/app/[locale]/careers/page.tsx +129 -5
  194. package/templates/apps/web/app/[locale]/contact/__tests__/ConversationalIntakeWrapper.test.tsx +1 -1
  195. package/templates/apps/web/app/[locale]/contact/__tests__/contact-magnet-page.test.tsx +119 -11
  196. package/templates/apps/web/app/[locale]/contact/__tests__/contact.actions.test.ts +28 -5
  197. package/templates/apps/web/app/[locale]/contact/contact.actions.ts +20 -6
  198. package/templates/apps/web/app/[locale]/contact/intake-script.ts +1 -1
  199. package/templates/apps/web/app/[locale]/contact/page.tsx +99 -92
  200. package/templates/apps/web/app/[locale]/flows/[slug]/FlowStepperClient.tsx +237 -5
  201. package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/FlowStepperClient.count.test.tsx +43 -0
  202. package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/FlowStepperClient.deliverable.test.tsx +200 -0
  203. package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/FlowStepperClient.ending.test.tsx +197 -0
  204. package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/magnet-page.test.tsx +430 -0
  205. package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/two-route-tool.test.tsx +21 -9
  206. package/templates/apps/web/app/[locale]/flows/[slug]/magnet-page.tsx +369 -0
  207. package/templates/apps/web/app/[locale]/flows/[slug]/page.tsx +12 -73
  208. package/templates/apps/web/app/[locale]/flows/[slug]/start/page.tsx +10 -31
  209. package/templates/apps/web/app/[locale]/layout.tsx +55 -4
  210. package/templates/apps/web/app/[locale]/newsletter/__tests__/page.test.tsx +174 -104
  211. package/templates/apps/web/app/[locale]/newsletter/page.tsx +95 -58
  212. package/templates/apps/web/app/[locale]/pricing/__tests__/__snapshots__/unconnected.test.tsx.snap +7 -0
  213. package/templates/apps/web/app/[locale]/pricing/__tests__/offerings.test.tsx +599 -0
  214. package/templates/apps/web/app/[locale]/pricing/__tests__/page.test.tsx +216 -0
  215. package/templates/apps/web/app/[locale]/pricing/__tests__/unconnected.test.tsx +236 -0
  216. package/templates/apps/web/app/[locale]/pricing/page.tsx +367 -6
  217. package/templates/apps/web/app/[locale]/team/__tests__/team-routes.test.tsx +4 -1
  218. package/templates/apps/web/app/[locale]/unsubscribe/__tests__/unsubscribe-rsc-boundary.test.tsx +17 -0
  219. package/templates/apps/web/app/[locale]/unsubscribe/__tests__/unsubscribe.test.tsx +62 -3
  220. package/templates/apps/web/app/[locale]/unsubscribe/page.tsx +70 -18
  221. package/templates/apps/web/app/[locale]/unsubscribe/unsubscribe-button.tsx +30 -9
  222. package/templates/apps/web/app/api/offerings/checkout/[lookupKey]/route.ts +316 -0
  223. package/templates/apps/web/app/api/offerings/checkout/__tests__/route.test.ts +810 -0
  224. package/templates/apps/web/lib/__tests__/auth-routes.test.ts +27 -1
  225. package/templates/apps/web/lib/__tests__/deck-page.test.ts +2 -1
  226. package/templates/apps/web/lib/__tests__/in-page-magnet.test.ts +265 -0
  227. package/templates/apps/web/lib/__tests__/magnet-routes.test.ts +64 -0
  228. package/templates/apps/web/lib/__tests__/newsletter.actions.test.ts +293 -0
  229. package/templates/apps/web/lib/__tests__/offerings-pricing.test.ts +601 -0
  230. package/templates/apps/web/lib/__tests__/structured-data.test.tsx +67 -8
  231. package/templates/apps/web/lib/auth-routes.ts +36 -2
  232. package/templates/apps/web/lib/checkout-config.ts +9 -20
  233. package/templates/apps/web/lib/deck-page.ts +1 -0
  234. package/templates/apps/web/lib/deliverable-grant.ts +18 -0
  235. package/templates/apps/web/lib/in-page-magnet.ts +416 -0
  236. package/templates/apps/web/lib/magnet-routes.ts +44 -0
  237. package/templates/apps/web/lib/modules.generated.ts +9 -0
  238. package/templates/apps/web/lib/newsletter.actions.ts +205 -0
  239. package/templates/apps/web/lib/offerings-pricing.ts +326 -0
  240. package/templates/apps/web/lib/posting-title.ts +17 -0
  241. package/templates/apps/web/lib/sign-up-agreements.ts +4 -2
  242. package/templates/apps/web/messages/en.json +49 -15
  243. package/templates/apps/web/middleware.ts +12 -2
  244. package/templates/apps/web/security-headers.config.ts +17 -0
  245. package/templates/content/en/BlogList/blog.mdx +4 -71
  246. package/templates/content/en/BookCall/book-a-call.mdx +1 -41
  247. package/templates/content/en/BookCall/onsite-estimate.mdx +1 -17
  248. package/templates/content/en/BookCall/onsite-service.mdx +1 -17
  249. package/templates/content/en/BookCall/start-project.mdx +1 -17
  250. package/templates/content/en/ContactPage/contact.mdx +6 -63
  251. package/templates/content/en/HomePage/home.mdx +3 -21
  252. package/templates/database/CHANGELOG.md +51 -0
  253. package/templates/database/__tests__/in-page-magnets-migration.test.ts +161 -0
  254. package/templates/database/__tests__/offerings-checkout-session.db.test.ts +270 -0
  255. package/templates/database/__tests__/offerings-migration.test.ts +197 -0
  256. package/templates/database/__tests__/offerings-shown-price-ids-migration.test.ts +86 -0
  257. package/templates/database/__tests__/offerings.db.test.ts +303 -0
  258. package/templates/database/__tests__/seed.test.ts +63 -1
  259. package/templates/database/__tests__/trial-magnet-migration.test.ts +186 -0
  260. package/templates/database/auth/schema.prisma +4 -0
  261. package/templates/database/config/schema.prisma +91 -0
  262. package/templates/database/migrations/20261020120000_offerings_data_model/migration.sql +146 -0
  263. package/templates/database/migrations/20261021120000_offerings_checkout_session/migration.sql +77 -0
  264. package/templates/database/migrations/20261022120000_in_page_magnets/migration.sql +45 -0
  265. package/templates/database/migrations/20261023120000_offerings_shown_price_ids/migration.sql +47 -0
  266. package/templates/database/migrations/20261024120000_organization_plan_checkout_session/migration.sql +16 -0
  267. package/templates/database/migrations/20261025120000_trial_magnet/migration.sql +28 -0
  268. package/templates/database/migrations/20261026120000_trial_magnet_proof_line/migration.sql +23 -0
  269. package/templates/database/package.json +1 -1
  270. package/templates/database/seed.ts +57 -2
  271. package/templates/tools/lint-architecture.ts +205 -1
  272. package/versions.json +35 -35
  273. package/site-templates/marketing-site-free/content/en/Pricing/pricing.mdx +0 -199
  274. package/templates/apps/api/src/routes/content/slug.ts +0 -9
  275. package/templates/apps/web/app/[locale]/downloads/__tests__/page-renderer-migration.test.tsx +0 -93
  276. package/templates/apps/web/app/[locale]/downloads/page.tsx +0 -37
  277. package/templates/apps/web/app/[locale]/newsletter/__tests__/newsletter.actions.test.ts +0 -139
  278. package/templates/apps/web/app/[locale]/newsletter/newsletter.actions.ts +0 -93
  279. package/templates/apps/web/app/api/checkout/[offering]/route.ts +0 -48
  280. package/templates/apps/web/app/api/checkout/__tests__/route.test.ts +0 -120
  281. package/templates/apps/web/app/api/checkout/__tests__/success-path.test.ts +0 -86
  282. package/templates/apps/web/app/api/webhooks/stripe/__tests__/route.test.ts +0 -92
  283. package/templates/apps/web/app/api/webhooks/stripe/route.ts +0 -31
  284. package/templates/apps/web/lib/__tests__/checkout.test.ts +0 -243
  285. package/templates/apps/web/lib/__tests__/meeting-slugs.test.ts +0 -120
  286. package/templates/apps/web/lib/checkout.ts +0 -93
  287. package/templates/apps/web/lib/meeting-slugs.ts +0 -63
@@ -0,0 +1,313 @@
1
+ /**
2
+ * @file checkout-plan.test.ts
3
+ * @description The hub's checkout plan rule end to end (#8793, spec
4
+ * docs/specs/8793-the-hub-s-checkout-plan-spec.md D1–D3; AC-3, AC-4, AC-5):
5
+ * a spoke's marked checkout lead never writes a Person's plan — it holds it —
6
+ * and the held plan is released only when `resolveMemberPersonId` first links
7
+ * the Person to the signed-in user whose VERIFIED email is the Person's own.
8
+ * A checkout that types another Person's email cannot change that Person's
9
+ * plan, and the lead, the purchase message, the purchase email and the
10
+ * consent grant are written exactly as before.
11
+ *
12
+ * Runs `routeLead` and `resolveMemberPersonId` with the real `person.ts`
13
+ * over `createInMemoryPrisma()`, which enforces the Person's
14
+ * `(workspaceId, email)` and `(workspaceId, userId)` keys.
15
+ *
16
+ * External dependency boundaries mocked:
17
+ * - apps/api/src/lib/prisma — the in-memory, where-clause-honouring double.
18
+ * Its `lead.count` is arranged per test: the purchase email's cap reads
19
+ * with `mode: "insensitive"`, a filter the double does not model.
20
+ * - @working-theory/email — the operator notification and purchase email.
21
+ * - ../plan-offerings.js — the workspace's content-adapter read.
22
+ * - consent.recordCheckoutConsent — spied; the ledger's own transaction is
23
+ * `consent.test.ts`'s, and here only its call is the claim.
24
+ */
25
+
26
+ // eslint-disable-next-line no-restricted-syntax -- lib/prisma.ts wraps the external DB boundary (PrismaClient requires a live connection); the in-memory double is the repo-standard where-honouring test double for it.
27
+ jest.mock("../../lib/prisma.js", () =>
28
+ jest
29
+ .requireActual<typeof import("../../__tests__/factories/in-memory-prisma")>(
30
+ "../../__tests__/factories/in-memory-prisma",
31
+ )
32
+ .createInMemoryPrisma(),
33
+ );
34
+
35
+ const mockSendLeadNotification = jest.fn();
36
+ const mockSendPurchaseConfirmationEmail = jest.fn();
37
+ jest.mock("@working-theory/email", () => ({
38
+ sendLeadNotification: (...args: unknown[]) =>
39
+ mockSendLeadNotification(...args),
40
+ sendPurchaseConfirmationEmail: (...args: unknown[]) =>
41
+ mockSendPurchaseConfirmationEmail(...args),
42
+ }));
43
+
44
+ const mockListPlanOfferings = jest.fn();
45
+ // eslint-disable-next-line no-restricted-syntax -- plan-offerings.ts is the workspace's content-adapter read (the database adapter, an external boundary), the same seam lead-routing.test.ts mocks.
46
+ jest.mock("../plan-offerings.js", () => ({
47
+ listPlanOfferings: (...args: unknown[]) => mockListPlanOfferings(...args),
48
+ }));
49
+
50
+ import { logger } from "@working-theory/observability";
51
+
52
+ import { prisma } from "../../lib/prisma.js";
53
+ import * as consent from "../consent";
54
+ import { routeLead } from "../lead-routing";
55
+ import { resolveMemberPersonId } from "../person";
56
+
57
+ type Row = Record<string, unknown>;
58
+ // The in-memory double's own surface (`__db`, `__calls`) is not on the
59
+ // PrismaClient type the import carries, so it is named here once.
60
+ // eslint-disable-next-line no-restricted-syntax -- justified above
61
+ const store = prisma as unknown as {
62
+ __db: Record<string, Row[]>;
63
+ lead: Record<string, jest.Mock>;
64
+ };
65
+
66
+ const WS = "wksp_00000000-0000-7000-8000-000000008793";
67
+ const KEY = "cs_live_h8793Hold"; // gitleaks:allow -- a synthetic Checkout Session id shape (#8793 AC-3), not a credential
68
+ const KEY_2 = "cs_live_h8793Hold2"; // gitleaks:allow -- a second synthetic Checkout Session id shape (#8793 AC-4), not a credential
69
+ const VICTIM = "victim@x.io";
70
+ const U = "user_00000000-0000-7000-8000-000000008793";
71
+ const ENV_KEYS = [
72
+ "DATABASE_URL",
73
+ "NODE_ENV",
74
+ "AILK_LEAD_NOTIFICATION_TO",
75
+ "AILK_LEAD_NOTIFICATION_FROM",
76
+ "AILK_DELIVERABLE_EMAIL_FROM",
77
+ ] as const;
78
+
79
+ const sourceMeta = {
80
+ ip: "127.0.0.1",
81
+ userAgent: "jest",
82
+ receivedAt: new Date().toISOString(),
83
+ viaSpoke: true,
84
+ };
85
+
86
+ const people = () => (store.__db["person"] ??= []);
87
+ const users = () => (store.__db["user"] ??= []);
88
+ const leads = () => (store.__db["lead"] ??= []);
89
+ const interactions = () => (store.__db["interaction"] ??= []);
90
+ const victim = () => people().find((p) => p["email"] === VICTIM);
91
+
92
+ function checkout(email: string, offering: string, extra: Row = {}) {
93
+ return {
94
+ kind: "submit" as const,
95
+ siteId: WS,
96
+ source: "web",
97
+ email,
98
+ payload: {
99
+ site: "wendi",
100
+ page: "checkout",
101
+ offering,
102
+ amountTotal: 900,
103
+ currency: "usd",
104
+ ...extra,
105
+ } as Record<string, unknown>,
106
+ };
107
+ }
108
+
109
+ /** A Person of `WS`, optionally linked to a verified user. */
110
+ function seedVictim(row: { userId?: string; planOffering?: string | null }): void {
111
+ people().push({
112
+ id: "person_00000000-0000-7000-8000-000000008793",
113
+ workspaceId: WS,
114
+ email: VICTIM,
115
+ name: null,
116
+ userId: null,
117
+ billingProvider: null,
118
+ billingCustomerId: null,
119
+ planOffering: null,
120
+ contactConfirmedAt: null,
121
+ contactConfirmedBy: null,
122
+ createdAt: new Date(Date.UTC(2026, 0, 1)),
123
+ ...row,
124
+ });
125
+ }
126
+
127
+ const verifiedUser = (id: string, email: string) =>
128
+ users().push({ id, email, emailVerified: true });
129
+
130
+ let saved: Partial<Record<(typeof ENV_KEYS)[number], string | undefined>>;
131
+ let info: jest.SpyInstance;
132
+ let recordGrant: jest.SpyInstance;
133
+
134
+ const planLines = () =>
135
+ info.mock.calls
136
+ .map(([fields]) => fields as Row)
137
+ .filter((fields) => fields?.["event"] === "checkout-plan");
138
+
139
+ /** Let the fired (void) purchase email run to its end. */
140
+ const settle = async () => {
141
+ for (let i = 0; i < 5; i += 1)
142
+ await new Promise((resolve) => setImmediate(resolve));
143
+ };
144
+
145
+ beforeEach(() => {
146
+ for (const key of Object.keys(store.__db)) delete store.__db[key];
147
+ saved = {};
148
+ for (const k of ENV_KEYS) saved[k] = process.env[k];
149
+ process.env.DATABASE_URL = "postgres://stub";
150
+ delete process.env.NODE_ENV;
151
+ process.env.AILK_LEAD_NOTIFICATION_TO = "owner@example.com";
152
+ process.env.AILK_LEAD_NOTIFICATION_FROM = "noreply@example.com";
153
+ process.env.AILK_DELIVERABLE_EMAIL_FROM = "orders@example.com";
154
+ mockSendLeadNotification.mockReset().mockResolvedValue({ ok: true, messageId: "m" });
155
+ mockSendPurchaseConfirmationEmail
156
+ .mockReset()
157
+ .mockResolvedValue({ ok: true, messageId: "m2" });
158
+ mockListPlanOfferings.mockReset().mockResolvedValue([]);
159
+ store.lead["count"]!.mockReset().mockResolvedValue(1);
160
+ info = jest.spyOn(logger, "info").mockImplementation(() => undefined as never);
161
+ recordGrant = jest
162
+ .spyOn(consent, "recordCheckoutConsent")
163
+ .mockResolvedValue("recorded");
164
+ });
165
+
166
+ afterEach(() => {
167
+ for (const k of ENV_KEYS) {
168
+ if (saved[k] === undefined) delete process.env[k];
169
+ else process.env[k] = saved[k];
170
+ }
171
+ info.mockRestore();
172
+ recordGrant.mockRestore();
173
+ });
174
+
175
+ describe("a checkout that types another Person's email cannot change that Person's plan (#8793 AC-3)", () => {
176
+ it("a linked victim's set plan stays, and the checkout's line is held", async () => {
177
+ verifiedUser(U, VICTIM);
178
+ seedVictim({ userId: U, planOffering: "premium" });
179
+ await routeLead(checkout("VICTIM@x.io", "wendi_basic"), sourceMeta, {
180
+ idempotencyKey: KEY,
181
+ });
182
+ expect(victim()!["planOffering"]).toBe("premium");
183
+ expect(planLines()).toEqual([
184
+ { event: "checkout-plan", outcome: "held", siteId: WS },
185
+ ]);
186
+ });
187
+
188
+ it("a linked victim's blank plan stays blank, through the victim's next member read", async () => {
189
+ verifiedUser(U, VICTIM);
190
+ seedVictim({ userId: U });
191
+ await routeLead(checkout("VICTIM@x.io", "wendi_basic"), sourceMeta, {
192
+ idempotencyKey: KEY,
193
+ });
194
+ expect(victim()!["planOffering"]).toBeNull();
195
+ expect(await resolveMemberPersonId(WS, U, VICTIM)).toBe(victim()!["id"]);
196
+ expect(victim()!["planOffering"]).toBeNull();
197
+ });
198
+
199
+ it("a stranger's later purchase cannot decide an unlinked victim's held plan", async () => {
200
+ seedVictim({});
201
+ leads().push({
202
+ id: "lead_00000000-0000-7000-8000-000000000001",
203
+ workspaceId: WS,
204
+ siteId: WS,
205
+ personId: victim()!["id"],
206
+ email: VICTIM,
207
+ source: "web",
208
+ spokeCheckout: true,
209
+ payload: { site: "wendi", page: "checkout", offering: "wendi_premium" },
210
+ createdAt: new Date(Date.UTC(2026, 0, 2)),
211
+ });
212
+ await routeLead(checkout("VICTIM@x.io", "wendi_basic"), sourceMeta, {
213
+ idempotencyKey: KEY,
214
+ });
215
+ verifiedUser(U, VICTIM);
216
+ const log = { warn: jest.fn() };
217
+ expect(await resolveMemberPersonId(WS, U, VICTIM, log)).toBe(victim()!["id"]);
218
+ expect(victim()!["planOffering"]).toBeNull();
219
+ // The release ran and refused: the purchases disagree.
220
+ expect(log.warn).toHaveBeenCalledWith(
221
+ { event: "checkout-plan-release", outcome: "ambiguous" },
222
+ expect.any(String),
223
+ );
224
+ });
225
+
226
+ it("an unlinked victim's operator-set plan stays through the first link", async () => {
227
+ seedVictim({ planOffering: "premium" });
228
+ await routeLead(checkout("VICTIM@x.io", "wendi_basic"), sourceMeta, {
229
+ idempotencyKey: KEY,
230
+ });
231
+ verifiedUser(U, VICTIM);
232
+ expect(await resolveMemberPersonId(WS, U, VICTIM)).toBe(victim()!["id"]);
233
+ expect(victim()!["planOffering"]).toBe("premium");
234
+ });
235
+ });
236
+
237
+ describe("a real buyer still gets the plan at their verified first link (#8793 AC-4)", () => {
238
+ const BUYER = "new@x.io";
239
+ const buyer = () => people().find((p) => p["email"] === BUYER)!;
240
+ // A Person the capture created carries no `planOffering` key in the double
241
+ // (Postgres reads the unwritten column back as `null`).
242
+ const buyerPlan = () => buyer()["planOffering"] ?? null;
243
+
244
+ it("the checkout holds, and the buyer's first member read releases it", async () => {
245
+ await routeLead(checkout(BUYER, "wendi_retainer"), sourceMeta, {
246
+ idempotencyKey: KEY,
247
+ });
248
+ expect(buyerPlan()).toBeNull();
249
+ expect(planLines()).toEqual([
250
+ { event: "checkout-plan", outcome: "held", siteId: WS },
251
+ ]);
252
+ verifiedUser(U, BUYER);
253
+ expect(await resolveMemberPersonId(WS, U, BUYER)).toBe(buyer()["id"]);
254
+ expect(buyerPlan()).toBe("retainer");
255
+ });
256
+
257
+ it("a second purchase of the same offering under a new key still releases it", async () => {
258
+ await routeLead(checkout(BUYER, "wendi_retainer"), sourceMeta, {
259
+ idempotencyKey: KEY,
260
+ });
261
+ await routeLead(checkout(BUYER, "wendi_retainer"), sourceMeta, {
262
+ idempotencyKey: KEY_2,
263
+ });
264
+ expect(leads()).toHaveLength(2);
265
+ verifiedUser(U, BUYER);
266
+ expect(await resolveMemberPersonId(WS, U, BUYER)).toBe(buyer()["id"]);
267
+ expect(buyerPlan()).toBe("retainer");
268
+ });
269
+ });
270
+
271
+ describe("the lead, the purchase message, the purchase email and the consent grant are unchanged (#8793 AC-5)", () => {
272
+ it.each([
273
+ ["linked with a plan", { userId: U, planOffering: "premium" }],
274
+ ["linked with no plan", { userId: U }],
275
+ ["unlinked", {}],
276
+ ] as const)("victim %s", async (_label, victimRow) => {
277
+ verifiedUser(U, VICTIM);
278
+ seedVictim(victimRow);
279
+ const terms = consent.currentAccountTermsStamp()!;
280
+ const result = await routeLead(
281
+ checkout("VICTIM@x.io", "wendi_basic", { termsAccepted: terms.stamp }),
282
+ sourceMeta,
283
+ { idempotencyKey: KEY },
284
+ );
285
+ await settle();
286
+ const personId = victim()!["id"];
287
+ expect(result).toEqual({
288
+ ok: true,
289
+ id: leads()[0]!["id"],
290
+ personId,
291
+ persisted: "db",
292
+ email: expect.anything(),
293
+ });
294
+ expect(leads()).toHaveLength(1);
295
+ expect(leads()[0]).toMatchObject({ spokeCheckout: true, personId });
296
+ expect(interactions()).toHaveLength(1);
297
+ expect(interactions()[0]).toMatchObject({
298
+ personId,
299
+ channel: "member",
300
+ direction: "outbound",
301
+ subject: "Your purchase",
302
+ createdBy: "system:checkout",
303
+ });
304
+ expect(mockSendPurchaseConfirmationEmail).toHaveBeenCalledTimes(1);
305
+ expect(recordGrant).toHaveBeenCalledTimes(1);
306
+ expect(recordGrant).toHaveBeenCalledWith({
307
+ workspaceId: WS,
308
+ personId,
309
+ sessionId: KEY,
310
+ policyVersion: terms.policyVersion,
311
+ });
312
+ });
313
+ });
@@ -144,6 +144,16 @@ describe("fulfillDeliverable", () => {
144
144
  );
145
145
  });
146
146
 
147
+ it("selects only the id on the legacy DeliverableAsset lookup, never the bytes blob (#9248)", async () => {
148
+ await fulfillDeliverable(baseInput({ segment: "small" }));
149
+
150
+ // `DeliverableAsset.bytes` is the file itself; this read exists only to
151
+ // learn the row's id, so the query must name that one column.
152
+ const [[args]] = (prisma.deliverableAsset.findUnique as jest.Mock).mock
153
+ .calls as [[{ select?: Record<string, unknown> }]];
154
+ expect(args.select).toEqual({ id: true });
155
+ });
156
+
147
157
  it("resolves the OTHER segment's entry when segment differs (two-segment divergence, AC1)", async () => {
148
158
  (prisma.deliverableAsset.findUnique as jest.Mock).mockResolvedValue({
149
159
  id: "dasset_00000000-0000-7000-8000-0000000000a2",
@@ -471,6 +481,55 @@ describe("fulfillDeliverable", () => {
471
481
  );
472
482
  });
473
483
 
484
+ // ── #8848 D3 — the minted grant id, and a deliverable flow's Asset-only store ──
485
+
486
+ it("resolves { grantId } equal to the created row's id on a successful mint", async () => {
487
+ await expect(fulfillDeliverable(baseInput())).resolves.toEqual({ grantId: GRANT_ID });
488
+ });
489
+
490
+ it("resolves undefined on the asset_missing path", async () => {
491
+ (prisma.flowDefinition.findUnique as jest.Mock).mockResolvedValue({ workspaceId: WORKSPACE_ID });
492
+ (prisma.asset.findFirst as jest.Mock).mockResolvedValue(null);
493
+ (prisma.deliverableAsset.findUnique as jest.Mock).mockResolvedValue(null);
494
+
495
+ await expect(fulfillDeliverable(baseInput())).resolves.toBeUndefined();
496
+ });
497
+
498
+ it("a gated file snapshot whose key names only a legacy DeliverableAsset mints no grant (SEC-8848-B)", async () => {
499
+ (prisma.flowDefinition.findUnique as jest.Mock).mockResolvedValue({ workspaceId: WORKSPACE_ID });
500
+ (prisma.asset.findFirst as jest.Mock).mockResolvedValue(null);
501
+ const configSnapshot = {
502
+ ...baseConfigSnapshot(),
503
+ deliverables: { ...deliverablesBlock(), assets: [{ assetKey: "listing-acme-2026-10-12" }] },
504
+ deliverable: { kind: "file", gated: true, title: en("Q3"), geometry: "letter", pages: 3 },
505
+ };
506
+
507
+ await expect(
508
+ fulfillDeliverable(baseInput({ segment: undefined, configSnapshot })),
509
+ ).resolves.toBeUndefined();
510
+
511
+ expect(prisma.deliverableAsset.findUnique).not.toHaveBeenCalled();
512
+ expect(prisma.deliverableGrant.create).not.toHaveBeenCalled();
513
+ expect(mockLoggerInfo).toHaveBeenCalledWith(
514
+ expect.objectContaining({ outcome: "asset_missing" }),
515
+ expect.any(String),
516
+ );
517
+ });
518
+
519
+ it("the same legacy-only key on a flow with no deliverable block still mints (NFR1)", async () => {
520
+ (prisma.flowDefinition.findUnique as jest.Mock).mockResolvedValue({ workspaceId: WORKSPACE_ID });
521
+ (prisma.asset.findFirst as jest.Mock).mockResolvedValue(null);
522
+ const configSnapshot = {
523
+ ...baseConfigSnapshot(),
524
+ deliverables: { ...deliverablesBlock(), assets: [{ assetKey: "listing-acme-2026-10-12" }] },
525
+ };
526
+
527
+ await expect(
528
+ fulfillDeliverable(baseInput({ segment: undefined, configSnapshot })),
529
+ ).resolves.toEqual({ grantId: GRANT_ID });
530
+ expect(prisma.deliverableAsset.findUnique).toHaveBeenCalled();
531
+ });
532
+
474
533
  it("never reaches a key that exists only in ANOTHER workspace (NFR3) — the lookup is scoped to this flow's own workspaceId alone", async () => {
475
534
  (prisma.flowDefinition.findUnique as jest.Mock).mockResolvedValue({
476
535
  workspaceId: WORKSPACE_ID,
@@ -692,7 +751,7 @@ describe("fulfillDeliverable", () => {
692
751
  error: { kind: "send_failed", message: "Resend down" },
693
752
  });
694
753
 
695
- await expect(fulfillDeliverable(baseInput())).resolves.toBeUndefined();
754
+ await expect(fulfillDeliverable(baseInput())).resolves.toEqual({ grantId: GRANT_ID });
696
755
  expect(mockLoggerInfo).toHaveBeenCalled();
697
756
  // AC4: send_failed is a post-mint outcome — it stamps too.
698
757
  expect(prisma.deliverableGrant.update).toHaveBeenCalledWith({
@@ -708,7 +767,7 @@ describe("fulfillDeliverable", () => {
708
767
  new Error("connection reset"),
709
768
  );
710
769
 
711
- await expect(fulfillDeliverable(baseInput())).resolves.toBeUndefined();
770
+ await expect(fulfillDeliverable(baseInput())).resolves.toEqual({ grantId: GRANT_ID });
712
771
  expect(mockSendDeliverableEmail).toHaveBeenCalled();
713
772
  expect(mockLoggerWarn).toHaveBeenCalledWith(
714
773
  expect.objectContaining({ grantId: GRANT_ID, outcome: "sent" }),
@@ -0,0 +1,234 @@
1
+ /**
2
+ * @file keyless-workspace-predicate.test.ts
3
+ *
4
+ * One keyless-workspace predicate (#9145). `resolvePublicWorkspace` (content,
5
+ * pricing, schedule, the consent cookie) and `resolveCallerWorkspace` (the
6
+ * `/v1/flows` routes and the open deliverable) answer a keyless caller through
7
+ * the SAME rule — `services/default-tenant.ts#resolveDefaultWorkspaceId`: one
8
+ * workspace that is the seeded default resolves to it, anything else is
9
+ * unresolved. The `AILK_SITE_ID` alias is never consulted for a keyless
10
+ * caller, so a multi-workspace deployment that is not a hub answers every
11
+ * anonymous route the same way instead of 503 on `/pricing` and the alias
12
+ * workspace on `/flows/*`.
13
+ *
14
+ * The spoke-key arm and every refusal are unchanged; the hub cases here pin
15
+ * that a keyless call on a hub is refused by both resolvers alike.
16
+ *
17
+ * External dependency boundaries mocked:
18
+ * - @working-theory/database (via the prisma singleton; __mocks__/prisma.ts)
19
+ */
20
+ import { createHash } from "node:crypto";
21
+
22
+ // eslint-disable-next-line no-restricted-syntax -- lib/prisma.ts wraps the external DB boundary (PrismaClient requires a live connection); __mocks__/prisma.ts is the repo-standard test double for this boundary.
23
+ jest.mock("../../lib/prisma.js");
24
+
25
+ import { DEFAULT_WORKSPACE_ID } from "@working-theory/database/tenancy";
26
+ import type { FastifyRequest } from "fastify";
27
+
28
+ import * as configModule from "../../config/index.js";
29
+ import type { HubSpokeKeyMode } from "../../config/index.js";
30
+ import { __resetSiteWorkspaceCacheForTests } from "../../lib/site.js";
31
+ import {
32
+ mockSentinelWorkspaceExists,
33
+ mockWorkspaceExists,
34
+ } from "../../lib/__mocks__/prisma.js";
35
+ import { prisma } from "../../lib/prisma.js";
36
+ import {
37
+ __resetHubStatusForTests,
38
+ resolveCallerWorkspace,
39
+ resolvePublicWorkspace,
40
+ } from "../public-site-resolver.js";
41
+
42
+ const db = jest.mocked(prisma);
43
+
44
+ const W1 = "wksp_01920000-0000-7000-8000-0000000000b1";
45
+ const W2 = "wksp_01920000-0000-7000-8000-0000000000b2";
46
+ const S1 = "spoke_01920000-0000-7000-8000-0000000000c1";
47
+ const KEY_S1 = `ailk_spk_S1${"x".repeat(41)}`;
48
+
49
+ const digest = (key: string) => createHash("sha256").update(key).digest("hex");
50
+
51
+ /** A request double carrying only what the resolver may read. */
52
+ function requestWith(headers: Record<string, string> = {}): FastifyRequest {
53
+ // eslint-disable-next-line no-restricted-syntax -- a FastifyRequest double carrying only the two members the resolver reads (headers, log)
54
+ return {
55
+ headers,
56
+ log: { warn: jest.fn(), info: jest.fn(), error: jest.fn() },
57
+ } as unknown as FastifyRequest;
58
+ }
59
+
60
+ function setMode(mode: HubSpokeKeyMode): void {
61
+ jest.replaceProperty(configModule, "config", {
62
+ ...configModule.config,
63
+ AILK_HUB_REQUIRE_SPOKE_KEY: mode,
64
+ });
65
+ }
66
+
67
+ /** A deployment with no spoke registered: not a hub (#7633 D1). */
68
+ function notAHub(): void {
69
+ db.spoke.findFirst.mockResolvedValue(null);
70
+ }
71
+
72
+ /** A hub: its registry holds `S1` → `W1`. */
73
+ function aHub(): void {
74
+ db.spoke.findFirst.mockResolvedValue({ id: S1 } as never);
75
+ db.spoke.findUnique.mockImplementation((async ({
76
+ where,
77
+ }: {
78
+ where: { credentialHash: string };
79
+ }) =>
80
+ where.credentialHash === digest(KEY_S1)
81
+ ? { id: S1, workspaceId: W1, revokedAt: null }
82
+ : null) as never);
83
+ }
84
+
85
+ /** Exactly one workspace, the seeded default on the sentinel Organization. */
86
+ function oneWorkspaceDeployment(): void {
87
+ db.workspace.count.mockResolvedValue(1);
88
+ mockSentinelWorkspaceExists(db);
89
+ }
90
+
91
+ /** Three workspaces — the seeded default beside `W1` and `W2`. */
92
+ function multiWorkspaceDeployment(): void {
93
+ db.workspace.count.mockResolvedValue(3);
94
+ mockSentinelWorkspaceExists(db, W1, W2);
95
+ }
96
+
97
+ const saved = {
98
+ adapter: process.env.CONTENT_ADAPTER,
99
+ databaseUrl: process.env.DATABASE_URL,
100
+ siteId: process.env.AILK_SITE_ID,
101
+ };
102
+
103
+ function restore(name: string, value: string | undefined): void {
104
+ if (value === undefined) delete process.env[name];
105
+ else process.env[name] = value;
106
+ }
107
+
108
+ beforeEach(() => {
109
+ jest.resetAllMocks();
110
+ process.env.DATABASE_URL = "postgres://stub";
111
+ process.env.CONTENT_ADAPTER = "database";
112
+ delete process.env.AILK_SITE_ID;
113
+ setMode("auto");
114
+ __resetHubStatusForTests();
115
+ __resetSiteWorkspaceCacheForTests();
116
+ });
117
+
118
+ afterEach(() => {
119
+ jest.restoreAllMocks();
120
+ restore("CONTENT_ADAPTER", saved.adapter);
121
+ restore("DATABASE_URL", saved.databaseUrl);
122
+ restore("AILK_SITE_ID", saved.siteId);
123
+ });
124
+
125
+ describe("a one-workspace deployment that is not a hub (#9145)", () => {
126
+ beforeEach(() => {
127
+ notAHub();
128
+ oneWorkspaceDeployment();
129
+ });
130
+
131
+ it("serves the same workspace through both resolvers", async () => {
132
+ const pub = await resolvePublicWorkspace(requestWith());
133
+ const caller = await resolveCallerWorkspace(requestWith());
134
+
135
+ expect(pub).toEqual({ kind: "resolved", workspaceId: DEFAULT_WORKSPACE_ID });
136
+ expect(caller).toEqual({
137
+ kind: "resolved",
138
+ workspaceId: DEFAULT_WORKSPACE_ID,
139
+ viaSpoke: false,
140
+ });
141
+ });
142
+
143
+ it("resolveCallerWorkspace answers the predicate on any content adapter — flows are database rows", async () => {
144
+ process.env.CONTENT_ADAPTER = "mdx";
145
+
146
+ await expect(resolveCallerWorkspace(requestWith())).resolves.toEqual({
147
+ kind: "resolved",
148
+ workspaceId: DEFAULT_WORKSPACE_ID,
149
+ viaSpoke: false,
150
+ });
151
+ });
152
+ });
153
+
154
+ describe("a multi-workspace deployment that is not a hub (#9145)", () => {
155
+ beforeEach(() => {
156
+ notAHub();
157
+ multiWorkspaceDeployment();
158
+ });
159
+
160
+ it("answers unresolved through both resolvers, though the default alias resolves", async () => {
161
+ const pub = await resolvePublicWorkspace(requestWith());
162
+ const caller = await resolveCallerWorkspace(requestWith());
163
+
164
+ expect(pub).toEqual({ kind: "unresolved", reason: "ambiguous" });
165
+ expect(caller).toEqual({ kind: "unresolved", reason: "ambiguous" });
166
+ });
167
+
168
+ it("answers unresolved through both, though AILK_SITE_ID names a real workspace", async () => {
169
+ process.env.AILK_SITE_ID = W1;
170
+
171
+ await expect(resolvePublicWorkspace(requestWith())).resolves.toEqual({
172
+ kind: "unresolved",
173
+ reason: "ambiguous",
174
+ });
175
+ await expect(resolveCallerWorkspace(requestWith())).resolves.toEqual({
176
+ kind: "unresolved",
177
+ reason: "ambiguous",
178
+ });
179
+ });
180
+ });
181
+
182
+ describe("a sole workspace that is not the seeded default (#9145)", () => {
183
+ it("answers unresolved through both, though AILK_SITE_ID names it", async () => {
184
+ notAHub();
185
+ process.env.AILK_SITE_ID = W1;
186
+ db.workspace.count.mockResolvedValue(1);
187
+ mockWorkspaceExists(db, W1);
188
+
189
+ await expect(resolvePublicWorkspace(requestWith())).resolves.toEqual({
190
+ kind: "unresolved",
191
+ reason: "ambiguous",
192
+ });
193
+ await expect(resolveCallerWorkspace(requestWith())).resolves.toEqual({
194
+ kind: "unresolved",
195
+ reason: "ambiguous",
196
+ });
197
+ });
198
+ });
199
+
200
+ describe("a hub (#9145 — the refusals are unchanged)", () => {
201
+ it("AILK_HUB_REQUIRE_SPOKE_KEY on refuses a keyless call through both, on a one-workspace deployment", async () => {
202
+ setMode("on");
203
+ notAHub();
204
+ oneWorkspaceDeployment();
205
+
206
+ await expect(resolvePublicWorkspace(requestWith())).resolves.toEqual({
207
+ kind: "unresolved",
208
+ reason: "spoke-refused",
209
+ });
210
+ await expect(resolveCallerWorkspace(requestWith())).resolves.toEqual({
211
+ kind: "unresolved",
212
+ reason: "spoke-refused",
213
+ });
214
+ expect(db.workspace.count).not.toHaveBeenCalled();
215
+ });
216
+
217
+ it("a registered spoke still resolves to its own workspace through both", async () => {
218
+ aHub();
219
+ multiWorkspaceDeployment();
220
+ const headers = { "x-ailk-spoke-key": KEY_S1 };
221
+
222
+ await expect(resolvePublicWorkspace(requestWith(headers))).resolves.toEqual({
223
+ kind: "resolved",
224
+ workspaceId: W1,
225
+ });
226
+ await expect(resolveCallerWorkspace(requestWith(headers))).resolves.toEqual({
227
+ kind: "resolved",
228
+ workspaceId: W1,
229
+ viaSpoke: true,
230
+ spokeId: S1,
231
+ });
232
+ expect(db.workspace.count).not.toHaveBeenCalled();
233
+ });
234
+ });