create-ailk 0.27.0 → 0.28.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 (210) hide show
  1. package/README.md +84 -7
  2. package/dist/add-module-core.js +2 -1
  3. package/dist/add-module.d.ts +2 -1
  4. package/dist/add-module.js +9 -4
  5. package/dist/add-route.js +46 -19
  6. package/dist/body-heading.d.ts +56 -0
  7. package/dist/body-heading.js +394 -0
  8. package/dist/cli.js +28 -1
  9. package/dist/content-payloads.js +2 -2
  10. package/dist/derive-architecture.js +8 -2
  11. package/dist/index.d.ts +2 -1
  12. package/dist/index.js +4 -1
  13. package/dist/lib/auth-secret-file.d.ts +6 -2
  14. package/dist/lib/auth-secret-file.js +46 -20
  15. package/dist/lib/env-catalog.js +20 -0
  16. package/dist/lib/site-modules.d.ts +8 -0
  17. package/dist/lib/site-modules.js +34 -0
  18. package/dist/module-architecture.js +23 -14
  19. package/dist/paid-sections.d.ts +17 -0
  20. package/dist/paid-sections.js +39 -0
  21. package/dist/programmatic.d.ts +16 -0
  22. package/dist/programmatic.js +73 -4
  23. package/dist/remove-module.d.ts +98 -0
  24. package/dist/remove-module.js +620 -0
  25. package/dist/seed-flows.d.ts +2 -2
  26. package/dist/seed-flows.js +16 -9
  27. package/dist/site-roster.js +4 -1
  28. package/dist/starter-grid.d.ts +26 -0
  29. package/dist/starter-grid.js +34 -0
  30. package/dist/surfaces.d.ts +26 -0
  31. package/dist/surfaces.js +105 -9
  32. package/dist/sync-routes.d.ts +7 -0
  33. package/dist/sync-routes.js +53 -6
  34. package/dist/template-pages.d.ts +36 -0
  35. package/dist/template-pages.js +129 -0
  36. package/dist/topology.d.ts +2 -1
  37. package/dist/topology.js +4 -2
  38. package/dist/update.d.ts +3 -1
  39. package/dist/update.js +38 -4
  40. package/package.json +3 -3
  41. package/release-scope.json +2 -1
  42. package/site-templates/aliases/hub.TEMPLATE-MANIFEST.json +115 -0
  43. package/templates/.env.example +5 -2
  44. package/templates/apps/api/.env.example +9 -5
  45. package/templates/apps/api/CLAUDE.md +14 -9
  46. package/templates/apps/api/src/__tests__/vercel-handler.test.ts +37 -0
  47. package/templates/apps/api/src/vercel-handler.ts +8 -1
  48. package/templates/apps/mcp/CLAUDE.md +18 -10
  49. package/templates/apps/web/.env.example +25 -11
  50. package/templates/apps/web/CLAUDE.md +45 -4
  51. package/templates/apps/web/__tests__/setup.ts +7 -0
  52. package/templates/apps/web/app/.well-known/mcp.json/route.ts +7 -7
  53. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/[pieceId]/versions/[version]/file/route.ts +61 -2
  54. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/__tests__/file-route.test.ts +81 -0
  55. package/templates/apps/web/app/[locale]/(authed)/account/connect/__tests__/page.test.tsx +197 -0
  56. package/templates/apps/web/app/[locale]/(authed)/account/connect/page.tsx +152 -0
  57. package/templates/apps/web/app/[locale]/about/__tests__/about-routes.test.tsx +2 -2
  58. package/templates/apps/web/app/[locale]/careers/[slug]/[action]/page.tsx +76 -21
  59. package/templates/apps/web/app/[locale]/careers/[slug]/apply.actions.ts +46 -16
  60. package/templates/apps/web/app/[locale]/careers/[slug]/page.tsx +20 -3
  61. package/templates/apps/web/app/[locale]/careers/__tests__/apply.actions.test.ts +180 -2
  62. package/templates/apps/web/app/[locale]/careers/__tests__/careers-set.test.tsx +6 -2
  63. package/templates/apps/web/app/[locale]/careers/__tests__/job-apply-page.test.tsx +122 -0
  64. package/templates/apps/web/app/[locale]/careers/__tests__/page-renderer-migration.test.tsx +1 -1
  65. package/templates/apps/web/app/[locale]/case-studies/[slug]/page.tsx +20 -3
  66. package/templates/apps/web/app/[locale]/case-studies/__tests__/hub-children.test.tsx +65 -0
  67. package/templates/apps/web/app/[locale]/case-studies/__tests__/page-renderer-migration.test.tsx +6 -2
  68. package/templates/apps/web/app/[locale]/case-studies/page.tsx +23 -11
  69. package/templates/apps/web/app/[locale]/checkout/__tests__/thank-you-account.test.tsx +11 -1
  70. package/templates/apps/web/app/[locale]/clients/__tests__/page-renderer-migration.test.tsx +1 -1
  71. package/templates/apps/web/app/[locale]/consent.actions.ts +44 -6
  72. package/templates/apps/web/app/[locale]/contact/__tests__/ConversationalIntakeWrapper.test.tsx +4 -4
  73. package/templates/apps/web/app/[locale]/contact/__tests__/contact.actions.test.ts +269 -42
  74. package/templates/apps/web/app/[locale]/contact/contact.actions.ts +88 -30
  75. package/templates/apps/web/app/[locale]/courses/[slug]/page.tsx +20 -3
  76. package/templates/apps/web/app/[locale]/courses/__tests__/hub-children.test.tsx +32 -0
  77. package/templates/apps/web/app/[locale]/courses/__tests__/page-renderer-migration.test.tsx +6 -2
  78. package/templates/apps/web/app/[locale]/courses/page.tsx +22 -6
  79. package/templates/apps/web/app/[locale]/events/[slug]/page.tsx +15 -2
  80. package/templates/apps/web/app/[locale]/events/__tests__/hub-children.test.tsx +32 -0
  81. package/templates/apps/web/app/[locale]/events/__tests__/page-renderer-migration.test.tsx +8 -2
  82. package/templates/apps/web/app/[locale]/events/page.tsx +23 -7
  83. package/templates/apps/web/app/[locale]/features/__tests__/hub-children.test.tsx +32 -0
  84. package/templates/apps/web/app/[locale]/features/page.tsx +23 -25
  85. package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/flow.actions.test.ts +95 -0
  86. package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/save.actions.test.ts +193 -0
  87. package/templates/apps/web/app/[locale]/flows/[slug]/email-moment.ts +92 -0
  88. package/templates/apps/web/app/[locale]/flows/[slug]/flow.actions.ts +10 -0
  89. package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/ResultsClient.tsx +513 -0
  90. package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/ResultsEmailStep.tsx +122 -0
  91. package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/__tests__/ResultsClient.no-commerce.test.tsx +195 -0
  92. package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/__tests__/landing.test.tsx +293 -0
  93. package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/__tests__/page.test.tsx +397 -0
  94. package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/checkout-seam.ts +74 -0
  95. package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/page.tsx +264 -0
  96. package/templates/apps/web/app/[locale]/flows/[slug]/save.actions.ts +75 -0
  97. package/templates/apps/web/app/[locale]/for/__tests__/hub-children.test.tsx +32 -0
  98. package/templates/apps/web/app/[locale]/for/__tests__/page-renderer-migration.test.tsx +5 -1
  99. package/templates/apps/web/app/[locale]/for/page.tsx +23 -25
  100. package/templates/apps/web/app/[locale]/how-to/[slug]/page.tsx +20 -3
  101. package/templates/apps/web/app/[locale]/how-to/__tests__/page-renderer-migration.test.tsx +1 -1
  102. package/templates/apps/web/app/[locale]/integrations/[slug]/page.tsx +25 -4
  103. package/templates/apps/web/app/[locale]/integrations/__tests__/hub-children.test.tsx +38 -0
  104. package/templates/apps/web/app/[locale]/integrations/__tests__/page-renderer-migration.test.tsx +8 -2
  105. package/templates/apps/web/app/[locale]/integrations/page.tsx +23 -11
  106. package/templates/apps/web/app/[locale]/layout.tsx +157 -139
  107. package/templates/apps/web/app/[locale]/page.tsx +51 -3
  108. package/templates/apps/web/app/[locale]/partners/[slug]/page.tsx +16 -3
  109. package/templates/apps/web/app/[locale]/partners/__tests__/hub-children.test.tsx +32 -0
  110. package/templates/apps/web/app/[locale]/partners/__tests__/page-renderer-migration.test.tsx +6 -2
  111. package/templates/apps/web/app/[locale]/partners/page.tsx +22 -6
  112. package/templates/apps/web/app/[locale]/portfolio/__tests__/hub-children.test.tsx +38 -0
  113. package/templates/apps/web/app/[locale]/portfolio/page.tsx +25 -24
  114. package/templates/apps/web/app/[locale]/pricing/[slug]/__tests__/page.test.tsx +321 -0
  115. package/templates/apps/web/app/[locale]/pricing/[slug]/page.tsx +87 -0
  116. package/templates/apps/web/app/[locale]/pricing/__tests__/offerings.test.tsx +3 -0
  117. package/templates/apps/web/app/[locale]/pricing/__tests__/pricing-pages.test.tsx +215 -0
  118. package/templates/apps/web/app/[locale]/pricing/page.tsx +56 -341
  119. package/templates/apps/web/app/[locale]/products/[slug]/page.tsx +21 -4
  120. package/templates/apps/web/app/[locale]/products/__tests__/hub-children.test.tsx +32 -0
  121. package/templates/apps/web/app/[locale]/products/__tests__/page-renderer-migration.test.tsx +8 -2
  122. package/templates/apps/web/app/[locale]/products/page.tsx +22 -6
  123. package/templates/apps/web/app/[locale]/projects/__tests__/hub-children.test.tsx +32 -0
  124. package/templates/apps/web/app/[locale]/projects/__tests__/page-renderer-migration.test.tsx +4 -0
  125. package/templates/apps/web/app/[locale]/projects/page.tsx +18 -11
  126. package/templates/apps/web/app/[locale]/resources/[slug]/page.tsx +25 -4
  127. package/templates/apps/web/app/[locale]/resources/__tests__/hub-children.test.tsx +38 -0
  128. package/templates/apps/web/app/[locale]/resources/__tests__/page-renderer-migration.test.tsx +6 -2
  129. package/templates/apps/web/app/[locale]/resources/page.tsx +20 -10
  130. package/templates/apps/web/app/[locale]/services/[slug]/[child]/page.tsx +68 -0
  131. package/templates/apps/web/app/[locale]/services/[slug]/page.tsx +64 -9
  132. package/templates/apps/web/app/[locale]/services/__tests__/hub-children.test.tsx +158 -0
  133. package/templates/apps/web/app/[locale]/services/__tests__/page-renderer-migration.test.tsx +12 -2
  134. package/templates/apps/web/app/[locale]/services/__tests__/service-detail-sections.test.tsx +223 -0
  135. package/templates/apps/web/app/[locale]/services/__tests__/service-families.test.tsx +368 -0
  136. package/templates/apps/web/app/[locale]/services/page.tsx +45 -19
  137. package/templates/apps/web/app/[locale]/software/__tests__/page-renderer-migration.test.tsx +1 -1
  138. package/templates/apps/web/app/[locale]/team/__tests__/hub-children.test.tsx +32 -0
  139. package/templates/apps/web/app/[locale]/team/page.tsx +25 -24
  140. package/templates/apps/web/app/[locale]/tech-articles/[slug]/page.tsx +24 -3
  141. package/templates/apps/web/app/[locale]/tech-articles/__tests__/page-renderer-migration.test.tsx +1 -1
  142. package/templates/apps/web/app/[locale]/templates/[slug]/__tests__/page.test.tsx +203 -0
  143. package/templates/apps/web/app/[locale]/templates/[slug]/page.tsx +81 -0
  144. package/templates/apps/web/app/[locale]/templates/__tests__/page.test.tsx +236 -0
  145. package/templates/apps/web/app/[locale]/templates/page.tsx +108 -0
  146. package/templates/apps/web/app/[locale]/testimonials/__tests__/page-renderer-migration.test.tsx +1 -1
  147. package/templates/apps/web/app/[locale]/whitepapers/[slug]/page.tsx +24 -3
  148. package/templates/apps/web/app/[locale]/whitepapers/__tests__/page-renderer-migration.test.tsx +1 -1
  149. package/templates/apps/web/app/api/auth/hub/authorize/__tests__/route.test.ts +204 -2
  150. package/templates/apps/web/app/api/auth/hub/authorize/route.ts +151 -33
  151. package/templates/apps/web/app/api/offerings/checkout/[lookupKey]/__tests__/route.test.ts +233 -7
  152. package/templates/apps/web/app/api/offerings/checkout/[lookupKey]/route.ts +92 -41
  153. package/templates/apps/web/app/api/offerings/checkout/__tests__/route.test.ts +77 -39
  154. package/templates/apps/web/app/llms.txt/route.ts +41 -3
  155. package/templates/apps/web/app/robots.txt/route.ts +5 -1
  156. package/templates/apps/web/app/sitemap.xml/route.ts +45 -3
  157. package/templates/apps/web/auth.ts +102 -50
  158. package/templates/apps/web/components/signed-in-nav.tsx +8 -1
  159. package/templates/apps/web/lib/__tests__/bot-check.test.ts +254 -0
  160. package/templates/apps/web/lib/__tests__/consent-visitor.test.ts +212 -13
  161. package/templates/apps/web/lib/__tests__/deck-page.test.ts +13 -1
  162. package/templates/apps/web/lib/__tests__/docs-index.test.ts +93 -0
  163. package/templates/apps/web/lib/__tests__/docs-path.test.ts +168 -0
  164. package/templates/apps/web/lib/__tests__/hub-children.fixtures.ts +278 -0
  165. package/templates/apps/web/lib/__tests__/nav-label-keys.test.ts +138 -0
  166. package/templates/apps/web/lib/__tests__/newsletter.actions.test.ts +178 -2
  167. package/templates/apps/web/lib/__tests__/offerings-pricing.test.ts +183 -0
  168. package/templates/apps/web/lib/__tests__/page-chrome.test.ts +412 -0
  169. package/templates/apps/web/lib/__tests__/pricing-page.test.ts +142 -0
  170. package/templates/apps/web/lib/__tests__/relay.test.ts +202 -10
  171. package/templates/apps/web/lib/__tests__/route-family.test.ts +57 -0
  172. package/templates/apps/web/lib/__tests__/service-listing.test.ts +111 -0
  173. package/templates/apps/web/lib/__tests__/services-path.test.ts +96 -0
  174. package/templates/apps/web/lib/__tests__/site-header.test.ts +151 -0
  175. package/templates/apps/web/lib/__tests__/structured-data.test.tsx +148 -6
  176. package/templates/apps/web/lib/__tests__/telemetry-scrub.test.ts +84 -0
  177. package/templates/apps/web/lib/__tests__/template-listing.test.ts +158 -0
  178. package/templates/apps/web/lib/auth-secret.ts +57 -0
  179. package/templates/apps/web/lib/bot-check.ts +138 -0
  180. package/templates/apps/web/lib/consent-visitor.ts +81 -7
  181. package/templates/apps/web/lib/deck-page.ts +6 -3
  182. package/templates/apps/web/lib/docs-index.ts +100 -0
  183. package/templates/apps/web/lib/docs-path.ts +94 -0
  184. package/templates/apps/web/lib/hub-children.tsx +318 -0
  185. package/templates/apps/web/lib/modules.generated.ts +9 -0
  186. package/templates/apps/web/lib/nav-label-check.ts +61 -0
  187. package/templates/apps/web/lib/nav-label-keys.ts +78 -0
  188. package/templates/apps/web/lib/newsletter.actions.ts +31 -6
  189. package/templates/apps/web/lib/offerings-pricing.ts +135 -6
  190. package/templates/apps/web/lib/page-chrome.ts +367 -0
  191. package/templates/apps/web/lib/posting-title.ts +26 -4
  192. package/templates/apps/web/lib/pricing-page.ts +430 -0
  193. package/templates/apps/web/lib/relay.ts +31 -106
  194. package/templates/apps/web/lib/route-family.ts +69 -0
  195. package/templates/apps/web/lib/service-listing.ts +89 -0
  196. package/templates/apps/web/lib/services-path.ts +97 -0
  197. package/templates/apps/web/lib/site-header.ts +94 -0
  198. package/templates/apps/web/lib/telemetry-scrub.ts +68 -3
  199. package/templates/apps/web/lib/template-listing.ts +169 -0
  200. package/templates/apps/web/messages/en.json +86 -30
  201. package/templates/apps/web/middleware.ts +171 -11
  202. package/templates/apps/web/next.config.mjs +211 -2
  203. package/templates/apps/web/package.json +1 -1
  204. package/templates/apps/web/scripts/assert-content-bundled.ts +28 -6
  205. package/templates/apps/web/scripts/check-nav-labels.ts +39 -0
  206. package/templates/apps/web/sentry.server.config.ts +30 -2
  207. package/templates/apps/web/src/lib/__tests__/rate-limit.test.ts +67 -9
  208. package/templates/apps/web/src/lib/rate-limit.ts +68 -27
  209. package/templates/apps/web/src/lib/routes.ts +15 -1
  210. package/versions.json +48 -48
@@ -18,10 +18,21 @@ import { captureLeadAction } from "../contact.actions.js";
18
18
 
19
19
  // Mock @working-theory/api-client. `visitorForwardHeaders` stays real (#7394)
20
20
  // so AC-9 compares the action's `init` with the helper's own output.
21
+ // #9255 — the write bot check's verdict. `checkBotId` reads Vercel's
22
+ // request context, which jest has none of, so every case decides it here.
23
+ const checkBotIdMock = jest.fn();
24
+ jest.mock("botid/server", () => ({
25
+ checkBotId: (...args: unknown[]) => checkBotIdMock(...args),
26
+ }));
27
+
21
28
  jest.mock("@working-theory/api-client", () => ({
22
29
  leads: {
23
30
  submit: jest.fn(),
24
31
  },
32
+ // #9171 — a `checkout` magnet's lookup key comes from the shown set.
33
+ offerings: {
34
+ shown: jest.fn(),
35
+ },
25
36
  visitorForwardHeaders: jest.requireActual("@working-theory/api-client")
26
37
  .visitorForwardHeaders,
27
38
  }));
@@ -53,9 +64,9 @@ jest.mock("next-intl/server", () => {
53
64
  };
54
65
  });
55
66
 
56
- // #8757 D11 — a `checkout` magnet redirects after the write. Next's
57
- // `redirect()` throws NEXT_REDIRECT; the mock throws the same way so a case
58
- // can read the target and prove nothing ran after it.
67
+ // #9171 — a `checkout` magnet never calls Next's `redirect()`: a Server
68
+ // Action redirect is fetched as RSC, which the buy route refuses. The mock
69
+ // throws NEXT_REDIRECT as Next does, so a stray call fails its case.
59
70
  const mockRedirect = jest.fn((url: string): never => {
60
71
  throw new Error(`NEXT_REDIRECT:${url}`);
61
72
  });
@@ -65,21 +76,19 @@ jest.mock("next/navigation", () => ({
65
76
  }));
66
77
 
67
78
  let mockSignInProvider: "site" | "hub" | "none" = "site";
68
- let mockTopology: "hub" | "spoke" = "spoke";
69
- // eslint-disable-next-line no-restricted-syntax -- #507: SIGN_IN_PROVIDER and TOPOLOGY are generated per-build constants (#8757 D8, D11), not runtime inputs; no env or HTTP surface selects them.
79
+ // eslint-disable-next-line no-restricted-syntax -- #507: SIGN_IN_PROVIDER is a generated per-build constant (#8757 D8), not a runtime input; no env or HTTP surface selects it.
70
80
  jest.mock("../../../../lib/topology.generated", () => ({
71
81
  __esModule: true,
72
82
  get SIGN_IN_PROVIDER() {
73
83
  return mockSignInProvider;
74
84
  },
75
- get TOPOLOGY() {
76
- return mockTopology;
77
- },
78
85
  }));
79
86
 
80
- import { leads, visitorForwardHeaders } from "@working-theory/api-client";
87
+ import { leads, offerings, visitorForwardHeaders } from "@working-theory/api-client";
88
+ import { workspaceShownSetSchema } from "@working-theory/validation";
81
89
  import { headers } from "next/headers";
82
90
  const mockSubmit = leads.submit as jest.Mock;
91
+ const mockShown = offerings.shown as jest.Mock;
83
92
  const mockHeaders = headers as jest.Mock;
84
93
 
85
94
  afterEach(() => {
@@ -612,7 +621,22 @@ describe("captureLeadAction — the built-in form's source (#8747)", () => {
612
621
 
613
622
  describe("captureLeadAction — the per-visitor limit (#8747)", () => {
614
623
  const visitor = (ip: string) => ({
615
- get: (name: string) => (name === "x-forwarded-for" ? ip : null),
624
+ get: (name: string) => (name === "x-vercel-forwarded-for" ? ip : null),
625
+ });
626
+ // On Vercel, where the edge-set address is read; the bot check has its own suite.
627
+ const ENV_KEYS = ["VERCEL", "AILK_BOT_CHECK"] as const;
628
+ const priorEnv = new Map(ENV_KEYS.map((name) => [name, process.env[name]]));
629
+
630
+ beforeEach(() => {
631
+ process.env.VERCEL = "1";
632
+ process.env.AILK_BOT_CHECK = "off";
633
+ });
634
+
635
+ afterEach(() => {
636
+ for (const [name, value] of priorEnv) {
637
+ if (value === undefined) delete process.env[name];
638
+ else process.env[name] = value;
639
+ }
616
640
  });
617
641
 
618
642
  it("admits 30 posts from one visitor in ten minutes, and refuses the 31st without calling the API", async () => {
@@ -633,17 +657,27 @@ describe("captureLeadAction — the per-visitor limit (#8747)", () => {
633
657
  expect(mockSubmit).toHaveBeenCalledTimes(30);
634
658
  });
635
659
 
636
- it("with no readable address, the limit is skipped rather than shared", async () => {
637
- mockHeaders.mockResolvedValue({ get: () => null });
660
+ it("with no readable address, every post shares one bucket at ten times the limit, never skipped (#9967)", async () => {
661
+ // A client-sent x-forwarded-for is no address: it changes on every post.
662
+ let n = 0;
663
+ mockHeaders.mockImplementation(async () => {
664
+ n += 1;
665
+ return { get: (name: string) => (name === "x-forwarded-for" ? `10.0.${n >> 8}.${n & 255}` : null) };
666
+ });
638
667
  mockSubmit.mockResolvedValue({ ok: true, body: { id: "lead_1", persisted: "db" } });
639
668
 
640
- for (let i = 0; i < 31; i += 1) {
669
+ for (let i = 0; i < 300; i += 1) {
641
670
  expect(await captureLeadAction(INITIAL_STATE, makeFormData(VALID_FIELDS))).toEqual({
642
671
  kind: "success",
643
672
  name: expect.any(String),
644
673
  });
645
674
  }
646
- expect(mockSubmit).toHaveBeenCalledTimes(31);
675
+ expect(mockSubmit).toHaveBeenCalledTimes(300);
676
+
677
+ const refused = await captureLeadAction(INITIAL_STATE, makeFormData(VALID_FIELDS));
678
+
679
+ expect(refused).toMatchObject({ kind: "error", message: expect.any(String) });
680
+ expect(mockSubmit).toHaveBeenCalledTimes(300);
647
681
  });
648
682
 
649
683
  it("another visitor keeps its own budget", async () => {
@@ -786,7 +820,16 @@ describe("captureLeadAction — the account link (#8757 D8)", () => {
786
820
  });
787
821
  });
788
822
 
789
- describe("captureLeadAction — a checkout magnet redirects after the write (#8757 D11, AC-8)", () => {
823
+ describe("captureLeadAction — a checkout magnet answers the Offerings buy route after the write (#8757 D11, #9171)", () => {
824
+ // The shown-set read is shared for 15 seconds per instance, so each case
825
+ // starts a minute past the last one's window.
826
+ let clock = Date.now();
827
+ let now: jest.SpyInstance<number, []>;
828
+ beforeEach(() => {
829
+ clock += 60_000;
830
+ now = jest.spyOn(Date, "now").mockReturnValue(clock);
831
+ });
832
+
790
833
  function answers(next: unknown): void {
791
834
  mockSubmit.mockResolvedValueOnce({
792
835
  ok: true,
@@ -794,18 +837,135 @@ describe("captureLeadAction — a checkout magnet redirects after the write (#87
794
837
  });
795
838
  }
796
839
 
797
- it("next: checkout, retainer → Next's redirect to /api/checkout/retainer, after exactly one write", async () => {
840
+ /** A shown set in `state`: one `plan` carrying a Price per `period`. */
841
+ function shownSet(
842
+ periods: readonly ("monthly" | "yearly" | "once")[],
843
+ plan = "retainer",
844
+ state = "current",
845
+ ): unknown {
846
+ return workspaceShownSetSchema.partial({ workspaceId: true, organizationId: true }).parse({
847
+ productIds: [`prod_${plan}`],
848
+ recommended: { plan: null, addon: null },
849
+ offerings: [
850
+ {
851
+ productId: `prod_${plan}`,
852
+ plan,
853
+ name: plan,
854
+ description: null,
855
+ marketingFeatures: [],
856
+ unitLabel: null,
857
+ note: null,
858
+ termsNote: null,
859
+ ailkGroup: "plan",
860
+ ailkOrder: null,
861
+ prices: periods.map((period) => ({
862
+ priceId: `price_${plan}_${period}`,
863
+ lookupKey: `${plan}_${period}`,
864
+ plan,
865
+ period,
866
+ interval: { monthly: "month", yearly: "year", once: null }[period],
867
+ amount: 4900,
868
+ currency: "usd",
869
+ })),
870
+ recommended: false,
871
+ },
872
+ ],
873
+ state,
874
+ snapshotAt: "2026-10-26T00:00:00.000Z",
875
+ });
876
+ }
877
+
878
+ afterEach(() => {
879
+ mockShown.mockReset();
880
+ now.mockRestore();
881
+ });
882
+
883
+ /** The shown-set read answers `body` (the api-client's unwrapped 2xx). */
884
+ function shows(body: unknown): void {
885
+ mockShown.mockResolvedValueOnce({ ok: true, status: 200, body });
886
+ }
887
+
888
+ it("next: checkout, retainer → the success state carries /api/offerings/checkout/retainer_monthly, after exactly one write", async () => {
798
889
  answers({ completion: "checkout", checkoutOffering: "retainer" });
890
+ shows({ status: "shown", shownSet: shownSet(["monthly", "yearly"]) });
799
891
 
800
- await expect(captureLeadAction(INITIAL_STATE, makeFormData(VALID_FIELDS))).rejects.toThrow(
801
- "NEXT_REDIRECT:/api/checkout/retainer",
802
- );
892
+ expect(await captureLeadAction(INITIAL_STATE, makeFormData(VALID_FIELDS))).toStrictEqual({
893
+ kind: "success",
894
+ name: "Jane Smith",
895
+ redirect: "/api/offerings/checkout/retainer_monthly",
896
+ });
803
897
  expect(mockSubmit).toHaveBeenCalledTimes(1);
804
- expect(mockRedirect).toHaveBeenCalledTimes(1);
805
- expect(mockRedirect).toHaveBeenCalledWith("/api/checkout/retainer");
806
898
  expect(mockSubmit.mock.invocationCallOrder[0]!).toBeLessThan(
807
- mockRedirect.mock.invocationCallOrder[0]!,
899
+ mockShown.mock.invocationCallOrder[0]!,
808
900
  );
901
+ // The form navigates; never a Server Action redirect, and never the retired route.
902
+ expect(mockRedirect).not.toHaveBeenCalled();
903
+ });
904
+
905
+ it("one shown-set read serves every submit within 15 seconds", async () => {
906
+ const checkout = { completion: "checkout", checkoutOffering: "retainer" };
907
+ answers(checkout);
908
+ answers(checkout);
909
+ answers(checkout);
910
+ shows({ status: "shown", shownSet: shownSet(["monthly"]) });
911
+ shows({ status: "shown", shownSet: shownSet(["yearly"]) });
912
+ const submit = () => captureLeadAction(INITIAL_STATE, makeFormData(VALID_FIELDS));
913
+
914
+ expect(await submit()).toMatchObject({ redirect: "/api/offerings/checkout/retainer_monthly" });
915
+ now.mockReturnValue(clock + 14_999);
916
+ expect(await submit()).toMatchObject({ redirect: "/api/offerings/checkout/retainer_monthly" });
917
+ expect(mockShown).toHaveBeenCalledTimes(1);
918
+ now.mockReturnValue(clock + 15_000);
919
+ expect(await submit()).toMatchObject({ redirect: "/api/offerings/checkout/retainer_yearly" });
920
+ expect(mockShown).toHaveBeenCalledTimes(2);
921
+ });
922
+
923
+ it.each([
924
+ [["yearly", "once"], "retainer_yearly"],
925
+ [["once"], "retainer_once"],
926
+ ] as const)(
927
+ "the key /pricing's card links first: shown %j → %s",
928
+ async (periods, lookupKey) => {
929
+ answers({ completion: "checkout", checkoutOffering: "retainer" });
930
+ shows({ status: "shown", shownSet: shownSet(periods) });
931
+
932
+ expect(await captureLeadAction(INITIAL_STATE, makeFormData(VALID_FIELDS))).toMatchObject({
933
+ redirect: `/api/offerings/checkout/${lookupKey}`,
934
+ });
935
+ },
936
+ );
937
+
938
+ it.each([
939
+ ["a plan the shown set does not show", () => shows({ status: "shown", shownSet: shownSet(["monthly"], "growth") })],
940
+ ["an unconnected site", () => shows({ status: "unconnected" })],
941
+ ["a shown set that is not current", () => shows({ status: "shown", shownSet: shownSet(["monthly"], "retainer", "expired") })],
942
+ ["a non-2xx read", () => mockShown.mockResolvedValueOnce({ ok: false, status: 503, body: null })],
943
+ ["a thrown read", () => mockShown.mockRejectedValueOnce(new Error("network"))],
944
+ ] as const)("%s → exactly { kind: success } and no buy route", async (_label, read) => {
945
+ answers({ completion: "checkout", checkoutOffering: "retainer" });
946
+ read();
947
+
948
+ expect(await captureLeadAction(INITIAL_STATE, makeFormData(VALID_FIELDS))).toStrictEqual({
949
+ kind: "success",
950
+ name: expect.any(String),
951
+ });
952
+ expect(mockSubmit).toHaveBeenCalledTimes(1);
953
+ expect(mockRedirect).not.toHaveBeenCalled();
954
+ });
955
+
956
+ it("a read with no answer within 5 seconds → exactly { kind: success } and no buy route", async () => {
957
+ jest.useFakeTimers({ doNotFake: ["nextTick", "setImmediate", "queueMicrotask", "Date"] });
958
+ try {
959
+ answers({ completion: "checkout", checkoutOffering: "retainer" });
960
+ mockShown.mockReturnValueOnce(new Promise(() => {}));
961
+
962
+ const result = captureLeadAction(INITIAL_STATE, makeFormData(VALID_FIELDS));
963
+ await jest.advanceTimersByTimeAsync(5_000);
964
+ expect(await result).toStrictEqual({ kind: "success", name: expect.any(String) });
965
+ expect(mockRedirect).not.toHaveBeenCalled();
966
+ } finally {
967
+ jest.useRealTimers();
968
+ }
809
969
  });
810
970
 
811
971
  it.each([
@@ -816,24 +976,26 @@ describe("captureLeadAction — a checkout magnet redirects after the write (#87
816
976
  { ok: false, status: 503, body: { error: "lead_capture_not_configured" } },
817
977
  "unavailable",
818
978
  ],
819
- ] as const)("a failed write (%s) → { kind: %s } and no redirect", async (_label, response, kind) => {
979
+ ] as const)("a failed write (%s) → { kind: %s } and no buy route", async (_label, response, kind) => {
820
980
  mockSubmit.mockResolvedValueOnce(response);
821
981
 
822
982
  const result = await captureLeadAction(INITIAL_STATE, makeFormData(VALID_FIELDS));
823
983
  expect(result).toMatchObject({ kind });
984
+ expect(mockShown).not.toHaveBeenCalled();
824
985
  expect(mockRedirect).not.toHaveBeenCalled();
825
986
  });
826
987
 
827
- it("a thrown write → { kind: unavailable } and no redirect", async () => {
988
+ it("a thrown write → { kind: unavailable } and no buy route", async () => {
828
989
  mockSubmit.mockRejectedValueOnce(new Error("network"));
829
990
 
830
991
  const result = await captureLeadAction(INITIAL_STATE, makeFormData(VALID_FIELDS));
831
992
  expect(result).toMatchObject({ kind: "unavailable" });
993
+ expect(mockShown).not.toHaveBeenCalled();
832
994
  expect(mockRedirect).not.toHaveBeenCalled();
833
995
  });
834
996
 
835
997
  it.each(["../x", "/evil", "Retainer", "has_underscore", "a".repeat(41), ""])(
836
- "checkoutOffering %j → exactly { kind: success } and no redirect",
998
+ "checkoutOffering %j → exactly { kind: success }, no shown-set read and no buy route",
837
999
  async (checkoutOffering) => {
838
1000
  answers({ completion: "checkout", checkoutOffering });
839
1001
 
@@ -841,27 +1003,12 @@ describe("captureLeadAction — a checkout magnet redirects after the write (#87
841
1003
  kind: "success",
842
1004
  name: expect.any(String),
843
1005
  });
1006
+ expect(mockShown).not.toHaveBeenCalled();
844
1007
  expect(mockRedirect).not.toHaveBeenCalled();
845
1008
  },
846
1009
  );
847
1010
 
848
- it("a build that is not a spoke (no standard checkout) → exactly { kind: success } and no redirect", async () => {
849
- mockTopology = "hub";
850
- try {
851
- answers({ completion: "checkout", checkoutOffering: "retainer" });
852
-
853
- expect(await captureLeadAction(INITIAL_STATE, makeFormData(VALID_FIELDS))).toStrictEqual({
854
- kind: "success",
855
- name: expect.any(String),
856
- });
857
- expect(mockSubmit).toHaveBeenCalledTimes(1);
858
- expect(mockRedirect).not.toHaveBeenCalled();
859
- } finally {
860
- mockTopology = "spoke";
861
- }
862
- });
863
-
864
- it("an invite still answers the account link and never redirects", async () => {
1011
+ it("an invite still answers the account link and no buy route", async () => {
865
1012
  answers({ completion: "invite" });
866
1013
 
867
1014
  const result = await captureLeadAction(
@@ -869,6 +1016,86 @@ describe("captureLeadAction — a checkout magnet redirects after the write (#87
869
1016
  makeFormData({ ...VALID_FIELDS, email: "a@b.example" }),
870
1017
  );
871
1018
  expect(result).toMatchObject({ kind: "success", next: { label: "Track this in your account" } });
1019
+ expect(mockShown).not.toHaveBeenCalled();
872
1020
  expect(mockRedirect).not.toHaveBeenCalled();
873
1021
  });
874
1022
  });
1023
+
1024
+ // #9255 D4 — the write bot check runs after the honeypot, the consent and
1025
+ // the schema, immediately before the write (spec
1026
+ // docs/specs/9255-an-edge-bot-challenge-in-spec.md §5.1 AC-2).
1027
+ describe("captureLeadAction — the write bot check (#9255)", () => {
1028
+ const BOT = { isHuman: false, isBot: true, isVerifiedBot: false, bypassed: false };
1029
+ const HUMAN = { ...BOT, isHuman: true, isBot: false };
1030
+ const BOT_ENV_KEYS = ["VERCEL", "AILK_BOT_CHECK", "AILK_BOOKING_BOT_CHECK"] as const;
1031
+ const priorBotEnv = new Map(BOT_ENV_KEYS.map((name) => [name, process.env[name]]));
1032
+
1033
+ let botLogSpies: jest.SpyInstance[] = [];
1034
+
1035
+ beforeEach(() => {
1036
+ checkBotIdMock.mockReset();
1037
+ for (const name of BOT_ENV_KEYS) delete process.env[name];
1038
+ process.env.VERCEL = "1";
1039
+ botLogSpies = [
1040
+ jest.spyOn(console, "warn").mockImplementation(() => undefined),
1041
+ jest.spyOn(console, "error").mockImplementation(() => undefined),
1042
+ ];
1043
+ });
1044
+
1045
+ afterEach(() => {
1046
+ for (const [name, value] of priorBotEnv) {
1047
+ if (value === undefined) delete process.env[name];
1048
+ else process.env[name] = value;
1049
+ }
1050
+ for (const spy of botLogSpies) spy.mockRestore();
1051
+ });
1052
+
1053
+ const OK = { ok: true, body: { id: "lead_abc123", persisted: "db" } };
1054
+
1055
+ it("a bot gets the generic error, and nothing is sent", async () => {
1056
+ checkBotIdMock.mockResolvedValue(BOT);
1057
+ const result = await captureLeadAction(INITIAL_STATE, makeFormData(VALID_FIELDS));
1058
+ expect(result).toStrictEqual({ kind: "error", message: "Something went wrong, please try again." });
1059
+ expect(checkBotIdMock).toHaveBeenCalledTimes(1);
1060
+ expect(mockSubmit).not.toHaveBeenCalled();
1061
+ });
1062
+
1063
+ it("a check that fails is unavailable, and nothing is sent", async () => {
1064
+ checkBotIdMock.mockRejectedValue(new Error("boom"));
1065
+ const result = await captureLeadAction(INITIAL_STATE, makeFormData(VALID_FIELDS));
1066
+ expect(result).toStrictEqual({ kind: "unavailable" });
1067
+ expect(mockSubmit).not.toHaveBeenCalled();
1068
+ });
1069
+
1070
+ it("a human sends exactly what the check off sends", async () => {
1071
+ checkBotIdMock.mockResolvedValue(HUMAN);
1072
+ mockSubmit.mockResolvedValue(OK);
1073
+ delete process.env.VERCEL;
1074
+ await captureLeadAction(INITIAL_STATE, makeFormData(VALID_FIELDS));
1075
+ process.env.VERCEL = "1";
1076
+ expect(await captureLeadAction(INITIAL_STATE, makeFormData(VALID_FIELDS))).toMatchObject({
1077
+ kind: "success",
1078
+ });
1079
+ expect(checkBotIdMock).toHaveBeenCalledTimes(1);
1080
+ expect(mockSubmit).toHaveBeenCalledTimes(2);
1081
+ expect(mockSubmit.mock.calls[1]).toEqual(mockSubmit.mock.calls[0]);
1082
+ });
1083
+
1084
+ it("a filled honeypot answers success with no check", async () => {
1085
+ checkBotIdMock.mockResolvedValue(BOT);
1086
+ const result = await captureLeadAction(
1087
+ INITIAL_STATE,
1088
+ makeFormData({ ...VALID_FIELDS, ailk_hp: "spam" }),
1089
+ );
1090
+ expect(result).toEqual({ kind: "success" });
1091
+ expect(checkBotIdMock).not.toHaveBeenCalled();
1092
+ });
1093
+
1094
+ it("a post without consent answers the consent error with no check", async () => {
1095
+ checkBotIdMock.mockResolvedValue(BOT);
1096
+ const { consent: _consent, ...unticked } = VALID_FIELDS;
1097
+ const result = await captureLeadAction(INITIAL_STATE, makeFormData(unticked));
1098
+ expect(result).toEqual({ kind: "error", fieldErrors: { consent: expect.any(String) } });
1099
+ expect(checkBotIdMock).not.toHaveBeenCalled();
1100
+ });
1101
+ });
@@ -16,8 +16,9 @@
16
16
  * calls per 10 minutes per visitor address, before the write. From a server
17
17
  * action the hub sees this site's egress address, so one visitor could
18
18
  * otherwise drain the hub's per-IP budget for every visitor of the site. With
19
- * no readable address the limit is skipped, never shared. The hub keeps its
20
- * own per-IP limit on the route.
19
+ * no readable address the post is counted in one shared bucket at ten times
20
+ * the limit, never skipped (#9967). The hub keeps its own per-IP limit on the
21
+ * route.
21
22
  *
22
23
  * The write (#8747): every lead goes through `writeLead`, the site's lead
23
24
  * writer (`src/lib/lead-write.ts`), which marks it `payload.site =
@@ -68,6 +69,11 @@
68
69
  * requires of a contact lead and records on it); an API `400
69
70
  * consent_required` is shown as the same field error.
70
71
  *
72
+ * The bot check (#9255 D4): after the honeypot, consent, schema and
73
+ * per-visitor refusals and immediately before the write, `runBotCheck`
74
+ * (lib/bot-check.ts) asks Vercel BotID for a verdict. A bot gets the generic
75
+ * error and a check that cannot decide `unavailable`; neither is sent.
76
+ *
71
77
  * Visitor forwarding (#7394): on a spoke whose API is a hub, the call
72
78
  * carries the visitor's IP and the spoke's `AILK_VISITOR_FORWARD_KEY`
73
79
  * (server-only, never `NEXT_PUBLIC_`) via `visitorForwardHeaders`, so the
@@ -86,35 +92,43 @@
86
92
  * be read — answers exactly `{ kind: "success" }`: a written lead is never
87
93
  * reported as a failure.
88
94
  *
89
- * Continue to checkout (#8757 D11): `{ completion: "checkout",
95
+ * Continue to checkout (#8757 D11, #9171): `{ completion: "checkout",
90
96
  * checkoutOffering }` — the magnet sends the submitter on to the offering's
91
- * standard checkout — calls `redirect("/api/checkout/<checkoutOffering>")`
92
- * (a Server Action redirect, a 303). It runs only after the write resolved
93
- * `ok`, and outside any `try`, since `redirect()` throws NEXT_REDIRECT by
94
- * design (`checkout.actions.ts`'s rule). The key is re-checked with
95
- * `flowCheckoutOfferingSchema` first; one that fails answers `{ kind:
96
- * "success" }`, as `lead` does. The standard checkout is served on a spoke
97
- * only (`TOPOLOGY`), so any other build answers `{ kind: "success" }` too,
98
- * never a redirect to a 404. A failed write never redirects.
97
+ * checkout — answers `{ kind: "success", redirect:
98
+ * "/api/offerings/checkout/<lookupKey>" }`, the Offerings buy route every
99
+ * `/pricing` card links; the legacy `/api/checkout/<offering>` is retired
100
+ * (#9142). The form hard-navigates there (`LeadCaptureForm`'s `redirect`),
101
+ * because the route starts a session only for a top-level navigation and a
102
+ * Server Action `redirect()` is fetched as RSC, which it sends to `/pricing`.
103
+ * The stored value is a `{plan}`, so the key is the one that plan's
104
+ * `/pricing` card links first: the first of `<plan>_monthly`,
105
+ * `<plan>_yearly` and `<plan>_once` the shown set shows, read through
106
+ * `offerings.shown()` within 5 seconds and shared per instance for 15, as
107
+ * the buy route reads it. It runs only after the write resolved `ok`. The
108
+ * value is re-checked with `flowCheckoutOfferingSchema` first; one that
109
+ * fails, a plan the shown set does not show, or a read that fails or times
110
+ * out answers `{ kind: "success" }`, as `lead` does.
111
+ * A failed write never answers the buy route.
99
112
  */
100
113
 
101
- import { visitorForwardHeaders } from "@working-theory/api-client";
114
+ import { offerings, visitorForwardHeaders } from "@working-theory/api-client";
102
115
  import { LEAD_CAPTURE_HONEYPOT, type LeadCaptureFormState } from "@working-theory/ui";
103
116
  import {
104
117
  CONTACT_LEAD_SOURCES,
105
118
  flowCheckoutOfferingSchema,
106
119
  leadSubmitNextSchema,
120
+ LOOKUP_KEY_PERIODS,
107
121
  } from "@working-theory/validation";
108
122
  import { cookies, headers } from "next/headers";
109
- import { redirect } from "next/navigation";
110
123
  import { getLocale, getTranslations } from "next-intl/server";
111
124
  import { z } from "zod";
112
125
 
113
126
  import { accountInviteHref } from "../../../lib/account-invite";
127
+ import { runBotCheck } from "../../../lib/bot-check";
114
128
  import { consentVisitorHeader, readConsentVisitor } from "../../../lib/consent-visitor";
115
- import { TOPOLOGY } from "../../../lib/topology.generated";
129
+ import { isShownLookupKey, offeringsCheckoutHref } from "../../../lib/offerings-pricing";
116
130
  import { writeLead } from "../../../src/lib/lead-write";
117
- import { checkRateLimit, clientIpFromHeaders } from "../../../src/lib/rate-limit";
131
+ import { takeVisitorLimit } from "../../../src/lib/rate-limit";
118
132
 
119
133
  const GENERIC_ERROR = "Something went wrong, please try again.";
120
134
  const RATE_LIMITED = "Too many messages from here. Please try again in a few minutes.";
@@ -122,6 +136,8 @@ const RATE_LIMITED = "Too many messages from here. Please try again in a few min
122
136
  /** The per-visitor budget (#8747): the consumer sites' own values. */
123
137
  const RATE_LIMIT_MAX_CALLS = 30;
124
138
  const RATE_LIMIT_WINDOW_MS = 10 * 60 * 1000;
139
+ /** Outside a request scope there is no address: the shared bucket. */
140
+ const NO_HEADERS = { get: () => null };
125
141
 
126
142
  /** Field names the action reads itself — never copied into `payload`. */
127
143
  const RESERVED_FIELDS = new Set([
@@ -196,16 +212,54 @@ function isApiUnreachable(res: { status: number }): boolean {
196
212
  return res.status === 0 || res.status === 502 || res.status === 504;
197
213
  }
198
214
 
215
+ /** The buy route's own bounds on its shown-set read. */
216
+ const SHOWN_READ_TIMEOUT_MS = 5_000;
217
+ const SHOWN_READ_TTL_MS = 15_000;
218
+
219
+ /** The shown set `/pricing` renders from, or `null`: any other answer, a throw, or none in time. */
220
+ async function readShownSet(): Promise<unknown> {
221
+ let timer: ReturnType<typeof setTimeout> | undefined;
222
+ const late = new Promise<null>((resolve) => {
223
+ timer = setTimeout(() => resolve(null), SHOWN_READ_TIMEOUT_MS);
224
+ });
225
+ try {
226
+ const res = await Promise.race([offerings.shown(), late]);
227
+ return res?.ok && res.body.status === "shown" ? res.body.shownSet : null;
228
+ } catch {
229
+ return null;
230
+ } finally {
231
+ clearTimeout(timer);
232
+ }
233
+ }
234
+
235
+ let shownRead: { at: number; read: Promise<unknown> } | undefined;
236
+
199
237
  /**
200
- * #8757 D11 — the offering a `checkout` answer names, when its key passes the
201
- * schema and this build serves the standard checkout (a spoke); else `null`.
238
+ * One read per instance per SHOWN_READ_TTL_MS: `offerings.shown()` forwards
239
+ * no visitor header, so a read per submit would count every visitor against
240
+ * this server's one per-IP limit at the API. Failed reads are shared too.
202
241
  */
203
- function checkoutOfferingOf(next: unknown): string | null {
204
- if (TOPOLOGY !== "spoke") return null;
242
+ function sharedShownSet(): Promise<unknown> {
243
+ const now = Date.now();
244
+ if (shownRead && now - shownRead.at < SHOWN_READ_TTL_MS) return shownRead.read;
245
+ shownRead = { at: now, read: readShownSet() };
246
+ return shownRead.read;
247
+ }
248
+
249
+ /**
250
+ * #8757 D11, #9171 — the Offerings buy route for the plan a `checkout` answer
251
+ * names: its first shown key in `/pricing`'s card order; else `null`.
252
+ */
253
+ async function checkoutHrefOf(next: unknown): Promise<string | null> {
205
254
  const parsed = leadSubmitNextSchema.safeParse(next);
206
255
  if (!parsed.success || parsed.data.completion !== "checkout") return null;
207
- const key = flowCheckoutOfferingSchema.safeParse(parsed.data.checkoutOffering);
208
- return key.success ? key.data : null;
256
+ const plan = flowCheckoutOfferingSchema.safeParse(parsed.data.checkoutOffering);
257
+ if (!plan.success) return null;
258
+ const shownSet = await sharedShownSet();
259
+ const key = LOOKUP_KEY_PERIODS.map((period) => `${plan.data}_${period}`).find((k) =>
260
+ isShownLookupKey(shownSet, k),
261
+ );
262
+ return key === undefined ? null : offeringsCheckoutHref(key);
209
263
  }
210
264
 
211
265
  /**
@@ -294,13 +348,11 @@ export async function captureLeadAction(
294
348
  // No request scope to read — no forwarding, and the visitor is "unknown".
295
349
  }
296
350
 
297
- // No readable address: skip the per-visitor limit rather than put every
298
- // visitor in one shared bucket a single caller could fill. The hub's own
299
- // per-IP limit still applies.
300
- const visitor = requestHeaders ? clientIpFromHeaders(requestHeaders) : "unknown";
351
+ // No readable address: the shared, wider bucket, never a skip (#9967 D1).
352
+ // The hub's own per-IP limit still applies.
301
353
  if (
302
- visitor !== "unknown" &&
303
- !checkRateLimit(`contact:${visitor}`, RATE_LIMIT_MAX_CALLS, RATE_LIMIT_WINDOW_MS)
354
+ !takeVisitorLimit("contact", requestHeaders ?? NO_HEADERS, RATE_LIMIT_MAX_CALLS, RATE_LIMIT_WINDOW_MS)
355
+ .allowed
304
356
  ) {
305
357
  return { kind: "error", message: RATE_LIMITED };
306
358
  }
@@ -321,6 +373,12 @@ export async function captureLeadAction(
321
373
  // No request scope to read — submit with no visitor-link header.
322
374
  }
323
375
 
376
+ // #9255 D4 — the spoke edge's bot check, after every local refusal and
377
+ // immediately before the write.
378
+ const botCheck = await runBotCheck({ source: "bot-check", target: "captureLeadAction", scope: "write" });
379
+ if (botCheck === "bot") return { kind: "error", message: GENERIC_ERROR };
380
+ if (botCheck === "unavailable") return { kind: "unavailable" };
381
+
324
382
  // writeLead never throws: a thrown submit is logged there by error name
325
383
  // only, with no field values (#4042), and resolves as `status: 0`.
326
384
  const res = await writeLead({ source, email, page: "contact", consent: true, payload }, init);
@@ -331,9 +389,9 @@ export async function captureLeadAction(
331
389
  if (isNotConfigured(res) || isApiUnreachable(res)) return { kind: "unavailable" };
332
390
  return { kind: "error", message: GENERIC_ERROR };
333
391
  }
334
- // #8757 D11 — after the write, outside any try: redirect() throws by design.
335
- const checkoutOffering = checkoutOfferingOf(res.body.next);
336
- if (checkoutOffering !== null) redirect(`/api/checkout/${checkoutOffering}`);
392
+ // #9171 — the form navigates: the buy route refuses a Server Action redirect.
393
+ const checkoutHref = await checkoutHrefOf(res.body.next);
394
+ if (checkoutHref !== null) return { kind: "success", name, redirect: checkoutHref };
337
395
  // #8847 D7 — the parsed name, which the magnet's ending fills `{name}` with.
338
396
  return { ...(await successState(res.body.next, email)), name };
339
397
  }
@@ -1,7 +1,11 @@
1
1
  // Course detail page. Reads the Course MDX for the given slug, renders via
2
2
  // @working-theory/page-renderer, and emits a JSON-LD <script>. Unknown slugs trigger 404.
3
3
  import type { getMdxSource as defaultGetMdxSource } from "@working-theory/page-renderer";
4
- import { renderPage, renderPageMetadata } from "@working-theory/page-renderer";
4
+ import {
5
+ getMdxSource as loadMdxSource,
6
+ renderPage,
7
+ renderPageMetadata,
8
+ } from "@working-theory/page-renderer";
5
9
  import type { Metadata } from "next";
6
10
  import type { ReactElement } from "react";
7
11
 
@@ -22,12 +26,25 @@ export default async function Page({
22
26
  getMdxSource,
23
27
  }: Props): Promise<ReactElement> {
24
28
  const { locale, slug } = await params;
29
+ // A page that composes `sections[]` renders them (JSON-LD is built from
30
+ // the same sections); a prose page renders its MDX body — the rule
31
+ // /vs/[slug] applies (#10122).
32
+ const source = await (getMdxSource ?? loadMdxSource)("Course", slug, locale);
33
+ const composed =
34
+ source !== null &&
35
+ Array.isArray((source.frontmatter as { sections?: unknown[] }).sections) &&
36
+ ((source.frontmatter as { sections?: unknown[] }).sections?.length ?? 0) >
37
+ 0;
25
38
  return renderPage({
26
39
  type: "Course",
27
40
  slug,
28
41
  locale,
29
- bodyMode: "mdx-body",
30
- populatedDescriptionTestId: "course-description",
42
+ ...(composed
43
+ ? {}
44
+ : {
45
+ bodyMode: "mdx-body" as const,
46
+ populatedDescriptionTestId: "course-description",
47
+ }),
31
48
  ...(getMdxSource !== undefined && { getMdxSource }),
32
49
  });
33
50
  }