create-ailk 0.22.0 → 0.23.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 (238) hide show
  1. package/component-catalog.json +149 -5
  2. package/component-catalog.md +7 -6
  3. package/dist/add-module.js +11 -9
  4. package/dist/add-route.d.ts +55 -1
  5. package/dist/add-route.js +294 -12
  6. package/dist/cli.js +27 -10
  7. package/dist/derive-architecture.d.ts +7 -5
  8. package/dist/derive-architecture.js +9 -32
  9. package/dist/lib/design-checks.js +4 -2
  10. package/dist/lib/env-catalog.js +0 -4
  11. package/dist/lib/npm-auth.d.ts +9 -0
  12. package/dist/lib/npm-auth.js +48 -0
  13. package/dist/lib/scaffold-render-sweep.d.ts +3 -2
  14. package/dist/lib/scaffold-render-sweep.js +5 -8
  15. package/dist/module-architecture.js +98 -10
  16. package/dist/paid-sections.d.ts +21 -2
  17. package/dist/paid-sections.js +69 -7
  18. package/dist/seed-flows.d.ts +4 -1
  19. package/dist/seed-flows.js +50 -4
  20. package/dist/spoke-checkout.d.ts +38 -35
  21. package/dist/spoke-checkout.js +66 -229
  22. package/dist/starter-grid.d.ts +0 -3
  23. package/dist/starter-grid.js +24 -14
  24. package/dist/surfaces.js +33 -12
  25. package/dist/sync-routes.d.ts +38 -1
  26. package/dist/sync-routes.js +184 -11
  27. package/dist/template-record.d.ts +1 -1
  28. package/dist/template-record.js +3 -2
  29. package/dist/update.js +10 -0
  30. package/package.json +3 -3
  31. package/site-templates/_base/content/en/NewsletterSignup/newsletter.mdx +4 -49
  32. package/site-templates/_base/content/en/Pricing/pricing.mdx +3 -21
  33. package/site-templates/marketing-site/TEMPLATE-MANIFEST.json +23 -20
  34. package/site-templates/marketing-site/TEMPLATE-MANIFEST.md +28 -14
  35. package/site-templates/marketing-site-free/TEMPLATE-MANIFEST.json +15 -39
  36. package/site-templates/marketing-site-free/TEMPLATE-MANIFEST.md +38 -34
  37. package/site-templates/marketing-site-free/content/en/NewsletterSignup/newsletter.mdx +6 -43
  38. package/site-templates/shapes/local-business-service/TEMPLATE-MANIFEST.json +14 -31
  39. package/site-templates/shapes/local-business-service/TEMPLATE-MANIFEST.md +26 -25
  40. package/site-templates/shapes/local-business-service/content/en/NewsletterSignup/newsletter.mdx +6 -43
  41. package/site-templates/shapes/organization-service/TEMPLATE-MANIFEST.json +14 -31
  42. package/site-templates/shapes/organization-service/TEMPLATE-MANIFEST.md +26 -25
  43. package/site-templates/shapes/organization-service/content/en/NewsletterSignup/newsletter.mdx +6 -43
  44. package/site-templates/shapes/person-service/TEMPLATE-MANIFEST.json +14 -31
  45. package/site-templates/shapes/person-service/TEMPLATE-MANIFEST.md +26 -25
  46. package/site-templates/shapes/person-service/content/en/NewsletterSignup/newsletter.mdx +6 -43
  47. package/templates/.env.example +9 -7
  48. package/templates/.github/workflows/ci.yml +13 -0
  49. package/templates/README.md +1 -0
  50. package/templates/_gitignore +4 -0
  51. package/templates/apps/api/.env.example +27 -0
  52. package/templates/apps/api/CLAUDE.md +77 -16
  53. package/templates/apps/api/package.json +1 -0
  54. package/templates/apps/api/src/__tests__/factories/in-memory-prisma.ts +2 -0
  55. package/templates/apps/api/src/bin/stripe-connections-rekey.ts +45 -0
  56. package/templates/apps/api/src/config/__tests__/stripe-connection-key.test.ts +185 -0
  57. package/templates/apps/api/src/config/index.ts +109 -0
  58. package/templates/apps/api/src/lib/__mocks__/prisma.ts +3 -0
  59. package/templates/apps/api/src/lib/__tests__/flow-rename-race.db.test.ts +116 -0
  60. package/templates/apps/api/src/lib/error-serializer.ts +74 -7
  61. package/templates/apps/api/src/lib/stripe.ts +74 -4
  62. package/templates/apps/api/src/lib/webhook-idempotency.ts +66 -2
  63. package/templates/apps/api/src/middleware/__tests__/rate-limit.test.ts +218 -0
  64. package/templates/apps/api/src/middleware/__tests__/write-budget.test.ts +174 -0
  65. package/templates/apps/api/src/middleware/rate-limit.ts +226 -4
  66. package/templates/apps/api/src/openapi/spec.ts +10 -7
  67. package/templates/apps/api/src/routes/billing/plan-checkout.ts +325 -0
  68. package/templates/apps/api/src/routes/checkout/__tests__/live-key.test.ts +11 -0
  69. package/templates/apps/api/src/routes/checkout/__tests__/offerings-authorization.test.ts +1300 -0
  70. package/templates/apps/api/src/routes/checkout/__tests__/session.test.ts +11 -0
  71. package/templates/apps/api/src/routes/checkout/__tests__/sessions.test.ts +49 -0
  72. package/templates/apps/api/src/routes/checkout/session.ts +57 -25
  73. package/templates/apps/api/src/routes/checkout/sessions.ts +232 -82
  74. package/templates/apps/api/src/routes/consent/__tests__/account.test.ts +5 -5
  75. package/templates/apps/api/src/routes/consent/__tests__/sign-up-rate-limit.test.ts +160 -0
  76. package/templates/apps/api/src/routes/consent/account.ts +4 -2
  77. package/templates/apps/api/src/routes/content/__tests__/get-page.test.ts +2 -2
  78. package/templates/apps/api/src/routes/content/__tests__/offerings.test.ts +731 -0
  79. package/templates/apps/api/src/routes/content/__tests__/pricing.test.ts +753 -0
  80. package/templates/apps/api/src/routes/content/__tests__/shown-offerings.test.ts +762 -0
  81. package/templates/apps/api/src/routes/content/index.ts +16 -2
  82. package/templates/apps/api/src/routes/content/offerings.ts +184 -0
  83. package/templates/apps/api/src/routes/content/pricing.ts +176 -0
  84. package/templates/apps/api/src/routes/content/shown-offerings.ts +229 -0
  85. package/templates/apps/api/src/routes/leads/index.ts +17 -14
  86. package/templates/apps/api/src/routes/webhooks/README.md +66 -0
  87. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-offerings.test.ts +814 -0
  88. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-org-billing.test.ts +80 -68
  89. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-site-filter.test.ts +5 -5
  90. package/templates/apps/api/src/routes/webhooks/__tests__/stripe.test.ts +8 -2
  91. package/templates/apps/api/src/routes/webhooks/index.ts +23 -0
  92. package/templates/apps/api/src/routes/webhooks/stripe-offerings.ts +384 -0
  93. package/templates/apps/api/src/routes/webhooks/stripe.ts +91 -17
  94. package/templates/apps/api/src/server.ts +140 -51
  95. package/templates/apps/api/src/services/__tests__/checkout-plan.test.ts +313 -0
  96. package/templates/apps/api/src/services/__tests__/deliverable-fulfillment.test.ts +51 -2
  97. package/templates/apps/api/src/services/__tests__/keyless-workspace-predicate.test.ts +234 -0
  98. package/templates/apps/api/src/services/__tests__/lead-routing.test.ts +374 -44
  99. package/templates/apps/api/src/services/__tests__/person.test.ts +232 -1
  100. package/templates/apps/api/src/services/__tests__/plan-offerings.test.ts +444 -0
  101. package/templates/apps/api/src/services/deliverable-fulfillment.ts +13 -5
  102. package/templates/apps/api/src/services/entitlements.ts +157 -1
  103. package/templates/apps/api/src/services/lead-routing.ts +113 -58
  104. package/templates/apps/api/src/services/offerings/__tests__/catalog.test.ts +295 -0
  105. package/templates/apps/api/src/services/offerings/__tests__/checkout-session-record.test.ts +268 -0
  106. package/templates/apps/api/src/services/offerings/__tests__/connection.test.ts +868 -0
  107. package/templates/apps/api/src/services/offerings/__tests__/fixtures.ts +416 -0
  108. package/templates/apps/api/src/services/offerings/__tests__/org-products.test.ts +396 -0
  109. package/templates/apps/api/src/services/offerings/__tests__/readers.test.ts +681 -0
  110. package/templates/apps/api/src/services/offerings/__tests__/source.test.ts +231 -0
  111. package/templates/apps/api/src/services/offerings/catalog.ts +308 -0
  112. package/templates/apps/api/src/services/offerings/checkout-authorization.ts +385 -0
  113. package/templates/apps/api/src/services/offerings/checkout-session-record.ts +192 -0
  114. package/templates/apps/api/src/services/offerings/connection.ts +1003 -0
  115. package/templates/apps/api/src/services/offerings/index.ts +614 -0
  116. package/templates/apps/api/src/services/offerings/source.ts +140 -0
  117. package/templates/apps/api/src/services/offerings/store.ts +150 -0
  118. package/templates/apps/api/src/services/person.ts +124 -9
  119. package/templates/apps/api/src/services/plan-offerings.ts +96 -30
  120. package/templates/apps/api/src/services/public-site-resolver.ts +19 -14
  121. package/templates/apps/mcp/CLAUDE.md +22 -14
  122. package/templates/apps/mcp/__tests__/catalog-drift.test.ts +9 -4
  123. package/templates/apps/mcp/__tests__/get_offerings.test.ts +358 -0
  124. package/templates/apps/mcp/__tests__/get_pricing.test.ts +364 -0
  125. package/templates/apps/mcp/__tests__/http-transport.test.ts +4 -4
  126. package/templates/apps/mcp/__tests__/module-exclusion.test.ts +2 -2
  127. package/templates/apps/mcp/__tests__/server.test.ts +13 -11
  128. package/templates/apps/mcp/__tests__/start_purchase.test.ts +208 -12
  129. package/templates/apps/mcp/__tests__/tools/purchase.test.ts +11 -11
  130. package/templates/apps/mcp/src/lib/shown-offerings.ts +105 -0
  131. package/templates/apps/mcp/src/server.ts +5 -0
  132. package/templates/apps/mcp/src/tools/get_offerings.ts +47 -30
  133. package/templates/apps/mcp/src/tools/get_pricing.ts +40 -40
  134. package/templates/apps/mcp/src/tools/purchase.ts +30 -2
  135. package/templates/apps/mcp/src/workspace-server.ts +15 -0
  136. package/templates/apps/web/.env.example +10 -31
  137. package/templates/apps/web/CLAUDE.md +315 -42
  138. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/agreements-prompt.test.tsx +3 -3
  139. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/layout.test.tsx +3 -3
  140. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/privacy.test.tsx +3 -3
  141. package/templates/apps/web/app/[locale]/careers/[slug]/[action]/page.tsx +189 -0
  142. package/templates/apps/web/app/[locale]/careers/[slug]/apply.actions.ts +4 -2
  143. package/templates/apps/web/app/[locale]/careers/[slug]/job-application-form.ts +8 -3
  144. package/templates/apps/web/app/[locale]/careers/[slug]/page.tsx +22 -144
  145. package/templates/apps/web/app/[locale]/careers/__tests__/apply.actions.test.ts +9 -0
  146. package/templates/apps/web/app/[locale]/careers/__tests__/careers-hub.test.tsx +172 -0
  147. package/templates/apps/web/app/[locale]/careers/__tests__/careers-set.test.tsx +149 -0
  148. package/templates/apps/web/app/[locale]/careers/__tests__/job-application-form.test.ts +22 -24
  149. package/templates/apps/web/app/[locale]/careers/__tests__/job-apply-page.test.tsx +140 -66
  150. package/templates/apps/web/app/[locale]/careers/__tests__/page-renderer-migration.test.tsx +57 -22
  151. package/templates/apps/web/app/[locale]/careers/page.tsx +129 -5
  152. package/templates/apps/web/app/[locale]/contact/__tests__/contact-magnet-page.test.tsx +119 -11
  153. package/templates/apps/web/app/[locale]/contact/__tests__/contact.actions.test.ts +28 -5
  154. package/templates/apps/web/app/[locale]/contact/contact.actions.ts +6 -2
  155. package/templates/apps/web/app/[locale]/contact/page.tsx +99 -92
  156. package/templates/apps/web/app/[locale]/flows/[slug]/FlowStepperClient.tsx +237 -5
  157. package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/FlowStepperClient.count.test.tsx +43 -0
  158. package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/FlowStepperClient.deliverable.test.tsx +200 -0
  159. package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/FlowStepperClient.ending.test.tsx +197 -0
  160. package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/magnet-page.test.tsx +430 -0
  161. package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/two-route-tool.test.tsx +21 -9
  162. package/templates/apps/web/app/[locale]/flows/[slug]/magnet-page.tsx +352 -0
  163. package/templates/apps/web/app/[locale]/flows/[slug]/page.tsx +12 -73
  164. package/templates/apps/web/app/[locale]/flows/[slug]/start/page.tsx +10 -31
  165. package/templates/apps/web/app/[locale]/layout.tsx +55 -4
  166. package/templates/apps/web/app/[locale]/newsletter/__tests__/page.test.tsx +174 -104
  167. package/templates/apps/web/app/[locale]/newsletter/page.tsx +95 -58
  168. package/templates/apps/web/app/[locale]/pricing/__tests__/__snapshots__/unconnected.test.tsx.snap +7 -0
  169. package/templates/apps/web/app/[locale]/pricing/__tests__/offerings.test.tsx +599 -0
  170. package/templates/apps/web/app/[locale]/pricing/__tests__/page.test.tsx +216 -0
  171. package/templates/apps/web/app/[locale]/pricing/__tests__/unconnected.test.tsx +236 -0
  172. package/templates/apps/web/app/[locale]/pricing/page.tsx +367 -6
  173. package/templates/apps/web/app/[locale]/team/__tests__/team-routes.test.tsx +4 -1
  174. package/templates/apps/web/app/[locale]/unsubscribe/__tests__/unsubscribe-rsc-boundary.test.tsx +17 -0
  175. package/templates/apps/web/app/[locale]/unsubscribe/__tests__/unsubscribe.test.tsx +62 -3
  176. package/templates/apps/web/app/[locale]/unsubscribe/page.tsx +70 -18
  177. package/templates/apps/web/app/[locale]/unsubscribe/unsubscribe-button.tsx +30 -9
  178. package/templates/apps/web/app/api/offerings/checkout/[lookupKey]/route.ts +316 -0
  179. package/templates/apps/web/app/api/offerings/checkout/__tests__/route.test.ts +810 -0
  180. package/templates/apps/web/lib/__tests__/auth-routes.test.ts +27 -1
  181. package/templates/apps/web/lib/__tests__/deck-page.test.ts +2 -1
  182. package/templates/apps/web/lib/__tests__/in-page-magnet.test.ts +265 -0
  183. package/templates/apps/web/lib/__tests__/magnet-routes.test.ts +57 -0
  184. package/templates/apps/web/lib/__tests__/newsletter.actions.test.ts +293 -0
  185. package/templates/apps/web/lib/__tests__/offerings-pricing.test.ts +601 -0
  186. package/templates/apps/web/lib/__tests__/structured-data.test.tsx +67 -8
  187. package/templates/apps/web/lib/auth-routes.ts +36 -2
  188. package/templates/apps/web/lib/checkout-config.ts +9 -20
  189. package/templates/apps/web/lib/deck-page.ts +1 -0
  190. package/templates/apps/web/lib/deliverable-grant.ts +18 -0
  191. package/templates/apps/web/lib/in-page-magnet.ts +416 -0
  192. package/templates/apps/web/lib/magnet-routes.ts +42 -0
  193. package/templates/apps/web/lib/modules.generated.ts +9 -0
  194. package/templates/apps/web/lib/newsletter.actions.ts +202 -0
  195. package/templates/apps/web/lib/offerings-pricing.ts +326 -0
  196. package/templates/apps/web/lib/posting-title.ts +17 -0
  197. package/templates/apps/web/lib/sign-up-agreements.ts +4 -2
  198. package/templates/apps/web/messages/en.json +49 -15
  199. package/templates/apps/web/middleware.ts +12 -2
  200. package/templates/apps/web/security-headers.config.ts +17 -0
  201. package/templates/content/en/BlogList/blog.mdx +4 -71
  202. package/templates/content/en/BookCall/book-a-call.mdx +1 -41
  203. package/templates/content/en/BookCall/onsite-estimate.mdx +1 -17
  204. package/templates/content/en/BookCall/onsite-service.mdx +1 -17
  205. package/templates/content/en/BookCall/start-project.mdx +1 -17
  206. package/templates/content/en/ContactPage/contact.mdx +6 -63
  207. package/templates/content/en/HomePage/home.mdx +3 -21
  208. package/templates/database/CHANGELOG.md +31 -0
  209. package/templates/database/__tests__/in-page-magnets-migration.test.ts +161 -0
  210. package/templates/database/__tests__/offerings-checkout-session.db.test.ts +270 -0
  211. package/templates/database/__tests__/offerings-migration.test.ts +197 -0
  212. package/templates/database/__tests__/offerings-shown-price-ids-migration.test.ts +86 -0
  213. package/templates/database/__tests__/offerings.db.test.ts +303 -0
  214. package/templates/database/__tests__/seed.test.ts +31 -1
  215. package/templates/database/auth/schema.prisma +4 -0
  216. package/templates/database/config/schema.prisma +91 -0
  217. package/templates/database/migrations/20261020120000_offerings_data_model/migration.sql +146 -0
  218. package/templates/database/migrations/20261021120000_offerings_checkout_session/migration.sql +77 -0
  219. package/templates/database/migrations/20261022120000_in_page_magnets/migration.sql +45 -0
  220. package/templates/database/migrations/20261023120000_offerings_shown_price_ids/migration.sql +47 -0
  221. package/templates/database/migrations/20261024120000_organization_plan_checkout_session/migration.sql +16 -0
  222. package/templates/database/package.json +1 -1
  223. package/templates/database/seed.ts +30 -2
  224. package/versions.json +35 -35
  225. package/site-templates/marketing-site-free/content/en/Pricing/pricing.mdx +0 -199
  226. package/templates/apps/web/app/[locale]/downloads/__tests__/page-renderer-migration.test.tsx +0 -93
  227. package/templates/apps/web/app/[locale]/downloads/page.tsx +0 -37
  228. package/templates/apps/web/app/[locale]/newsletter/__tests__/newsletter.actions.test.ts +0 -139
  229. package/templates/apps/web/app/[locale]/newsletter/newsletter.actions.ts +0 -93
  230. package/templates/apps/web/app/api/checkout/[offering]/route.ts +0 -48
  231. package/templates/apps/web/app/api/checkout/__tests__/route.test.ts +0 -120
  232. package/templates/apps/web/app/api/checkout/__tests__/success-path.test.ts +0 -86
  233. package/templates/apps/web/app/api/webhooks/stripe/__tests__/route.test.ts +0 -92
  234. package/templates/apps/web/app/api/webhooks/stripe/route.ts +0 -31
  235. package/templates/apps/web/lib/__tests__/checkout.test.ts +0 -243
  236. package/templates/apps/web/lib/__tests__/meeting-slugs.test.ts +0 -120
  237. package/templates/apps/web/lib/checkout.ts +0 -93
  238. package/templates/apps/web/lib/meeting-slugs.ts +0 -63
@@ -471,6 +471,55 @@ describe("fulfillDeliverable", () => {
471
471
  );
472
472
  });
473
473
 
474
+ // ── #8848 D3 — the minted grant id, and a deliverable flow's Asset-only store ──
475
+
476
+ it("resolves { grantId } equal to the created row's id on a successful mint", async () => {
477
+ await expect(fulfillDeliverable(baseInput())).resolves.toEqual({ grantId: GRANT_ID });
478
+ });
479
+
480
+ it("resolves undefined on the asset_missing path", async () => {
481
+ (prisma.flowDefinition.findUnique as jest.Mock).mockResolvedValue({ workspaceId: WORKSPACE_ID });
482
+ (prisma.asset.findFirst as jest.Mock).mockResolvedValue(null);
483
+ (prisma.deliverableAsset.findUnique as jest.Mock).mockResolvedValue(null);
484
+
485
+ await expect(fulfillDeliverable(baseInput())).resolves.toBeUndefined();
486
+ });
487
+
488
+ it("a gated file snapshot whose key names only a legacy DeliverableAsset mints no grant (SEC-8848-B)", async () => {
489
+ (prisma.flowDefinition.findUnique as jest.Mock).mockResolvedValue({ workspaceId: WORKSPACE_ID });
490
+ (prisma.asset.findFirst as jest.Mock).mockResolvedValue(null);
491
+ const configSnapshot = {
492
+ ...baseConfigSnapshot(),
493
+ deliverables: { ...deliverablesBlock(), assets: [{ assetKey: "listing-acme-2026-10-12" }] },
494
+ deliverable: { kind: "file", gated: true, title: en("Q3"), geometry: "letter", pages: 3 },
495
+ };
496
+
497
+ await expect(
498
+ fulfillDeliverable(baseInput({ segment: undefined, configSnapshot })),
499
+ ).resolves.toBeUndefined();
500
+
501
+ expect(prisma.deliverableAsset.findUnique).not.toHaveBeenCalled();
502
+ expect(prisma.deliverableGrant.create).not.toHaveBeenCalled();
503
+ expect(mockLoggerInfo).toHaveBeenCalledWith(
504
+ expect.objectContaining({ outcome: "asset_missing" }),
505
+ expect.any(String),
506
+ );
507
+ });
508
+
509
+ it("the same legacy-only key on a flow with no deliverable block still mints (NFR1)", async () => {
510
+ (prisma.flowDefinition.findUnique as jest.Mock).mockResolvedValue({ workspaceId: WORKSPACE_ID });
511
+ (prisma.asset.findFirst as jest.Mock).mockResolvedValue(null);
512
+ const configSnapshot = {
513
+ ...baseConfigSnapshot(),
514
+ deliverables: { ...deliverablesBlock(), assets: [{ assetKey: "listing-acme-2026-10-12" }] },
515
+ };
516
+
517
+ await expect(
518
+ fulfillDeliverable(baseInput({ segment: undefined, configSnapshot })),
519
+ ).resolves.toEqual({ grantId: GRANT_ID });
520
+ expect(prisma.deliverableAsset.findUnique).toHaveBeenCalled();
521
+ });
522
+
474
523
  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
524
  (prisma.flowDefinition.findUnique as jest.Mock).mockResolvedValue({
476
525
  workspaceId: WORKSPACE_ID,
@@ -692,7 +741,7 @@ describe("fulfillDeliverable", () => {
692
741
  error: { kind: "send_failed", message: "Resend down" },
693
742
  });
694
743
 
695
- await expect(fulfillDeliverable(baseInput())).resolves.toBeUndefined();
744
+ await expect(fulfillDeliverable(baseInput())).resolves.toEqual({ grantId: GRANT_ID });
696
745
  expect(mockLoggerInfo).toHaveBeenCalled();
697
746
  // AC4: send_failed is a post-mint outcome — it stamps too.
698
747
  expect(prisma.deliverableGrant.update).toHaveBeenCalledWith({
@@ -708,7 +757,7 @@ describe("fulfillDeliverable", () => {
708
757
  new Error("connection reset"),
709
758
  );
710
759
 
711
- await expect(fulfillDeliverable(baseInput())).resolves.toBeUndefined();
760
+ await expect(fulfillDeliverable(baseInput())).resolves.toEqual({ grantId: GRANT_ID });
712
761
  expect(mockSendDeliverableEmail).toHaveBeenCalled();
713
762
  expect(mockLoggerWarn).toHaveBeenCalledWith(
714
763
  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
+ });