create-ailk 0.26.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 (224) hide show
  1. package/README.md +86 -9
  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 +25 -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 +112 -11
  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 +90 -6
  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 +12 -2
  44. package/templates/CLAUDE.md +20 -20
  45. package/templates/README.md +19 -19
  46. package/templates/apps/api/.env.example +16 -5
  47. package/templates/apps/api/CLAUDE.md +32 -11
  48. package/templates/apps/api/src/__tests__/vercel-handler.test.ts +37 -0
  49. package/templates/apps/api/src/vercel-handler.ts +8 -1
  50. package/templates/apps/api/vercel.json +7 -1
  51. package/templates/apps/mcp/CLAUDE.md +18 -10
  52. package/templates/apps/web/.env.example +27 -0
  53. package/templates/apps/web/CLAUDE.md +56 -8
  54. package/templates/apps/web/__tests__/setup.ts +7 -0
  55. package/templates/apps/web/app/.well-known/mcp.json/route.ts +7 -7
  56. package/templates/apps/web/app/[locale]/(auth)/sign-in/__tests__/next-path.test.ts +24 -1
  57. package/templates/apps/web/app/[locale]/(auth)/sign-in/__tests__/page.test.tsx +51 -0
  58. package/templates/apps/web/app/[locale]/(auth)/sign-in/next-path.ts +13 -0
  59. package/templates/apps/web/app/[locale]/(auth)/sign-in/page.tsx +10 -4
  60. package/templates/apps/web/app/[locale]/(auth)/sign-up/__tests__/page.test.tsx +197 -4
  61. package/templates/apps/web/app/[locale]/(auth)/sign-up/page.tsx +23 -7
  62. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/[pieceId]/versions/[version]/file/route.ts +61 -2
  63. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/__tests__/file-route.test.ts +81 -0
  64. package/templates/apps/web/app/[locale]/(authed)/account/connect/__tests__/page.test.tsx +197 -0
  65. package/templates/apps/web/app/[locale]/(authed)/account/connect/page.tsx +152 -0
  66. package/templates/apps/web/app/[locale]/about/__tests__/about-routes.test.tsx +2 -2
  67. package/templates/apps/web/app/[locale]/careers/[slug]/[action]/page.tsx +76 -21
  68. package/templates/apps/web/app/[locale]/careers/[slug]/apply.actions.ts +46 -16
  69. package/templates/apps/web/app/[locale]/careers/[slug]/page.tsx +20 -3
  70. package/templates/apps/web/app/[locale]/careers/__tests__/apply.actions.test.ts +180 -2
  71. package/templates/apps/web/app/[locale]/careers/__tests__/careers-set.test.tsx +6 -2
  72. package/templates/apps/web/app/[locale]/careers/__tests__/job-apply-page.test.tsx +122 -0
  73. package/templates/apps/web/app/[locale]/careers/__tests__/page-renderer-migration.test.tsx +1 -1
  74. package/templates/apps/web/app/[locale]/case-studies/[slug]/page.tsx +20 -3
  75. package/templates/apps/web/app/[locale]/case-studies/__tests__/hub-children.test.tsx +65 -0
  76. package/templates/apps/web/app/[locale]/case-studies/__tests__/page-renderer-migration.test.tsx +6 -2
  77. package/templates/apps/web/app/[locale]/case-studies/page.tsx +23 -11
  78. package/templates/apps/web/app/[locale]/checkout/__tests__/thank-you-account.test.tsx +11 -1
  79. package/templates/apps/web/app/[locale]/clients/__tests__/page-renderer-migration.test.tsx +1 -1
  80. package/templates/apps/web/app/[locale]/consent.actions.ts +44 -6
  81. package/templates/apps/web/app/[locale]/contact/__tests__/ConversationalIntakeWrapper.test.tsx +4 -4
  82. package/templates/apps/web/app/[locale]/contact/__tests__/contact.actions.test.ts +269 -42
  83. package/templates/apps/web/app/[locale]/contact/contact.actions.ts +88 -30
  84. package/templates/apps/web/app/[locale]/courses/[slug]/page.tsx +20 -3
  85. package/templates/apps/web/app/[locale]/courses/__tests__/hub-children.test.tsx +32 -0
  86. package/templates/apps/web/app/[locale]/courses/__tests__/page-renderer-migration.test.tsx +6 -2
  87. package/templates/apps/web/app/[locale]/courses/page.tsx +22 -6
  88. package/templates/apps/web/app/[locale]/events/[slug]/page.tsx +15 -2
  89. package/templates/apps/web/app/[locale]/events/__tests__/hub-children.test.tsx +32 -0
  90. package/templates/apps/web/app/[locale]/events/__tests__/page-renderer-migration.test.tsx +8 -2
  91. package/templates/apps/web/app/[locale]/events/page.tsx +23 -7
  92. package/templates/apps/web/app/[locale]/features/__tests__/hub-children.test.tsx +32 -0
  93. package/templates/apps/web/app/[locale]/features/page.tsx +23 -25
  94. package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/flow.actions.test.ts +95 -0
  95. package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/save.actions.test.ts +193 -0
  96. package/templates/apps/web/app/[locale]/flows/[slug]/email-moment.ts +92 -0
  97. package/templates/apps/web/app/[locale]/flows/[slug]/flow.actions.ts +10 -0
  98. package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/ResultsClient.tsx +513 -0
  99. package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/ResultsEmailStep.tsx +122 -0
  100. package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/__tests__/ResultsClient.no-commerce.test.tsx +195 -0
  101. package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/__tests__/landing.test.tsx +293 -0
  102. package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/__tests__/page.test.tsx +397 -0
  103. package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/checkout-seam.ts +74 -0
  104. package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/page.tsx +264 -0
  105. package/templates/apps/web/app/[locale]/flows/[slug]/save.actions.ts +75 -0
  106. package/templates/apps/web/app/[locale]/for/__tests__/hub-children.test.tsx +32 -0
  107. package/templates/apps/web/app/[locale]/for/__tests__/page-renderer-migration.test.tsx +5 -1
  108. package/templates/apps/web/app/[locale]/for/page.tsx +23 -25
  109. package/templates/apps/web/app/[locale]/how-to/[slug]/page.tsx +20 -3
  110. package/templates/apps/web/app/[locale]/how-to/__tests__/page-renderer-migration.test.tsx +1 -1
  111. package/templates/apps/web/app/[locale]/integrations/[slug]/page.tsx +25 -4
  112. package/templates/apps/web/app/[locale]/integrations/__tests__/hub-children.test.tsx +38 -0
  113. package/templates/apps/web/app/[locale]/integrations/__tests__/page-renderer-migration.test.tsx +8 -2
  114. package/templates/apps/web/app/[locale]/integrations/page.tsx +23 -11
  115. package/templates/apps/web/app/[locale]/layout.tsx +161 -128
  116. package/templates/apps/web/app/[locale]/page.tsx +51 -3
  117. package/templates/apps/web/app/[locale]/partners/[slug]/page.tsx +16 -3
  118. package/templates/apps/web/app/[locale]/partners/__tests__/hub-children.test.tsx +32 -0
  119. package/templates/apps/web/app/[locale]/partners/__tests__/page-renderer-migration.test.tsx +6 -2
  120. package/templates/apps/web/app/[locale]/partners/page.tsx +22 -6
  121. package/templates/apps/web/app/[locale]/portfolio/__tests__/hub-children.test.tsx +38 -0
  122. package/templates/apps/web/app/[locale]/portfolio/page.tsx +25 -24
  123. package/templates/apps/web/app/[locale]/pricing/[slug]/__tests__/page.test.tsx +321 -0
  124. package/templates/apps/web/app/[locale]/pricing/[slug]/page.tsx +87 -0
  125. package/templates/apps/web/app/[locale]/pricing/__tests__/offerings.test.tsx +3 -0
  126. package/templates/apps/web/app/[locale]/pricing/__tests__/pricing-pages.test.tsx +215 -0
  127. package/templates/apps/web/app/[locale]/pricing/page.tsx +56 -341
  128. package/templates/apps/web/app/[locale]/products/[slug]/page.tsx +21 -4
  129. package/templates/apps/web/app/[locale]/products/__tests__/hub-children.test.tsx +32 -0
  130. package/templates/apps/web/app/[locale]/products/__tests__/page-renderer-migration.test.tsx +8 -2
  131. package/templates/apps/web/app/[locale]/products/page.tsx +22 -6
  132. package/templates/apps/web/app/[locale]/projects/__tests__/hub-children.test.tsx +32 -0
  133. package/templates/apps/web/app/[locale]/projects/__tests__/page-renderer-migration.test.tsx +4 -0
  134. package/templates/apps/web/app/[locale]/projects/page.tsx +18 -11
  135. package/templates/apps/web/app/[locale]/resources/[slug]/page.tsx +25 -4
  136. package/templates/apps/web/app/[locale]/resources/__tests__/hub-children.test.tsx +38 -0
  137. package/templates/apps/web/app/[locale]/resources/__tests__/page-renderer-migration.test.tsx +6 -2
  138. package/templates/apps/web/app/[locale]/resources/page.tsx +20 -10
  139. package/templates/apps/web/app/[locale]/services/[slug]/[child]/page.tsx +68 -0
  140. package/templates/apps/web/app/[locale]/services/[slug]/page.tsx +64 -9
  141. package/templates/apps/web/app/[locale]/services/__tests__/hub-children.test.tsx +158 -0
  142. package/templates/apps/web/app/[locale]/services/__tests__/page-renderer-migration.test.tsx +12 -2
  143. package/templates/apps/web/app/[locale]/services/__tests__/service-detail-sections.test.tsx +223 -0
  144. package/templates/apps/web/app/[locale]/services/__tests__/service-families.test.tsx +368 -0
  145. package/templates/apps/web/app/[locale]/services/page.tsx +45 -19
  146. package/templates/apps/web/app/[locale]/software/__tests__/page-renderer-migration.test.tsx +1 -1
  147. package/templates/apps/web/app/[locale]/team/__tests__/hub-children.test.tsx +32 -0
  148. package/templates/apps/web/app/[locale]/team/page.tsx +25 -24
  149. package/templates/apps/web/app/[locale]/tech-articles/[slug]/page.tsx +24 -3
  150. package/templates/apps/web/app/[locale]/tech-articles/__tests__/page-renderer-migration.test.tsx +1 -1
  151. package/templates/apps/web/app/[locale]/templates/[slug]/__tests__/page.test.tsx +203 -0
  152. package/templates/apps/web/app/[locale]/templates/[slug]/page.tsx +81 -0
  153. package/templates/apps/web/app/[locale]/templates/__tests__/page.test.tsx +236 -0
  154. package/templates/apps/web/app/[locale]/templates/page.tsx +108 -0
  155. package/templates/apps/web/app/[locale]/testimonials/__tests__/page-renderer-migration.test.tsx +1 -1
  156. package/templates/apps/web/app/[locale]/whitepapers/[slug]/page.tsx +24 -3
  157. package/templates/apps/web/app/[locale]/whitepapers/__tests__/page-renderer-migration.test.tsx +1 -1
  158. package/templates/apps/web/app/api/auth/hub/authorize/__tests__/route.test.ts +322 -2
  159. package/templates/apps/web/app/api/auth/hub/authorize/route.ts +161 -29
  160. package/templates/apps/web/app/api/hub-session/__tests__/routes.test.ts +51 -0
  161. package/templates/apps/web/app/api/hub-session/sign-in/route.ts +13 -0
  162. package/templates/apps/web/app/api/offerings/checkout/[lookupKey]/__tests__/route.test.ts +233 -7
  163. package/templates/apps/web/app/api/offerings/checkout/[lookupKey]/route.ts +92 -41
  164. package/templates/apps/web/app/api/offerings/checkout/__tests__/route.test.ts +77 -39
  165. package/templates/apps/web/app/llms.txt/route.ts +41 -3
  166. package/templates/apps/web/app/robots.txt/route.ts +5 -1
  167. package/templates/apps/web/app/sitemap.xml/route.ts +45 -3
  168. package/templates/apps/web/auth.ts +102 -50
  169. package/templates/apps/web/components/signed-in-nav.tsx +8 -1
  170. package/templates/apps/web/lib/__tests__/bot-check.test.ts +254 -0
  171. package/templates/apps/web/lib/__tests__/consent-visitor.test.ts +212 -13
  172. package/templates/apps/web/lib/__tests__/deck-page.test.ts +13 -1
  173. package/templates/apps/web/lib/__tests__/docs-index.test.ts +93 -0
  174. package/templates/apps/web/lib/__tests__/docs-path.test.ts +168 -0
  175. package/templates/apps/web/lib/__tests__/hub-children.fixtures.ts +278 -0
  176. package/templates/apps/web/lib/__tests__/nav-label-keys.test.ts +138 -0
  177. package/templates/apps/web/lib/__tests__/newsletter.actions.test.ts +178 -2
  178. package/templates/apps/web/lib/__tests__/offerings-pricing.test.ts +183 -0
  179. package/templates/apps/web/lib/__tests__/page-chrome.test.ts +412 -0
  180. package/templates/apps/web/lib/__tests__/pricing-page.test.ts +142 -0
  181. package/templates/apps/web/lib/__tests__/relay.test.ts +484 -4
  182. package/templates/apps/web/lib/__tests__/route-family.test.ts +57 -0
  183. package/templates/apps/web/lib/__tests__/service-listing.test.ts +111 -0
  184. package/templates/apps/web/lib/__tests__/services-path.test.ts +96 -0
  185. package/templates/apps/web/lib/__tests__/site-header.test.ts +151 -0
  186. package/templates/apps/web/lib/__tests__/structured-data.test.tsx +148 -6
  187. package/templates/apps/web/lib/__tests__/telemetry-scrub.test.ts +84 -0
  188. package/templates/apps/web/lib/__tests__/template-listing.test.ts +158 -0
  189. package/templates/apps/web/lib/auth-secret.ts +57 -0
  190. package/templates/apps/web/lib/bot-check.ts +138 -0
  191. package/templates/apps/web/lib/consent-visitor.ts +81 -7
  192. package/templates/apps/web/lib/deck-page.ts +6 -3
  193. package/templates/apps/web/lib/docs-index.ts +100 -0
  194. package/templates/apps/web/lib/docs-path.ts +94 -0
  195. package/templates/apps/web/lib/hub-children.tsx +318 -0
  196. package/templates/apps/web/lib/hub-session/__tests__/sign-in-gate.test.tsx +22 -2
  197. package/templates/apps/web/lib/hub-session/sign-in-gate.tsx +11 -8
  198. package/templates/apps/web/lib/modules.generated.ts +9 -0
  199. package/templates/apps/web/lib/nav-label-check.ts +61 -0
  200. package/templates/apps/web/lib/nav-label-keys.ts +78 -0
  201. package/templates/apps/web/lib/newsletter.actions.ts +31 -6
  202. package/templates/apps/web/lib/offerings-pricing.ts +135 -6
  203. package/templates/apps/web/lib/page-chrome.ts +367 -0
  204. package/templates/apps/web/lib/posting-title.ts +26 -4
  205. package/templates/apps/web/lib/pricing-page.ts +430 -0
  206. package/templates/apps/web/lib/relay.ts +44 -4
  207. package/templates/apps/web/lib/route-family.ts +69 -0
  208. package/templates/apps/web/lib/service-listing.ts +89 -0
  209. package/templates/apps/web/lib/services-path.ts +97 -0
  210. package/templates/apps/web/lib/sign-up-agreements.ts +17 -6
  211. package/templates/apps/web/lib/site-header.ts +94 -0
  212. package/templates/apps/web/lib/telemetry-scrub.ts +68 -3
  213. package/templates/apps/web/lib/template-listing.ts +169 -0
  214. package/templates/apps/web/messages/en.json +144 -3
  215. package/templates/apps/web/middleware.ts +179 -12
  216. package/templates/apps/web/next.config.mjs +220 -3
  217. package/templates/apps/web/package.json +3 -2
  218. package/templates/apps/web/scripts/assert-content-bundled.ts +28 -6
  219. package/templates/apps/web/scripts/check-nav-labels.ts +39 -0
  220. package/templates/apps/web/sentry.server.config.ts +30 -2
  221. package/templates/apps/web/src/lib/__tests__/rate-limit.test.ts +67 -9
  222. package/templates/apps/web/src/lib/rate-limit.ts +68 -27
  223. package/templates/apps/web/src/lib/routes.ts +15 -1
  224. package/versions.json +48 -48
@@ -1,37 +1,49 @@
1
1
  // Case Studies list page. Reads CaseStudiesList MDX via the content adapter and emits
2
2
  // a JSON-LD CollectionPage via the dispatcher.
3
- import type { getMdxSource as defaultGetMdxSource } from "@working-theory/page-renderer";
3
+ // It lists every CaseStudy page through one generated card section (#10121,
4
+ // apps/web/lib/hub-children.tsx — the /alternatives and /templates shape).
5
+ import type {
6
+ getMdxSlugs as defaultGetMdxSlugs,
7
+ getMdxSource as defaultGetMdxSource,
8
+ getPageSections as defaultGetPageSections,
9
+ } from "@working-theory/page-renderer";
4
10
  import { renderPage, renderPageMetadata } from "@working-theory/page-renderer";
5
11
  import type { Metadata } from "next";
6
12
  import type { ReactElement } from "react";
7
13
 
14
+ import { type HubConfig, hubPageInput } from "../../../lib/hub-children";
15
+
8
16
  interface Props {
9
17
  params: Promise<{ locale: string }>;
10
18
  getMdxSource?: typeof defaultGetMdxSource;
19
+ getPageSections?: typeof defaultGetPageSections;
20
+ getMdxSlugs?: typeof defaultGetMdxSlugs;
11
21
  }
12
22
 
23
+ const HUB: HubConfig = {
24
+ listType: "CaseStudiesList",
25
+ slug: "case-studies",
26
+ childType: "CaseStudy",
27
+ linkLabel: "Read the case study",
28
+ sectionId: "case-study-cards",
29
+ body: "mdx-body",
30
+ };
31
+
13
32
  export async function generateMetadata({
14
33
  params,
15
34
  }: Pick<Props, "params">): Promise<Metadata> {
16
35
  const { locale } = await params;
17
- return renderPageMetadata({
18
- type: "CaseStudiesList",
19
- slug: "case-studies",
20
- locale,
21
- });
36
+ return renderPageMetadata({ type: "CaseStudiesList", slug: "case-studies", locale });
22
37
  }
23
38
 
24
39
  export default async function Page({
25
40
  params,
26
- getMdxSource,
41
+ ...loaders
27
42
  }: Props): Promise<ReactElement> {
28
43
  const { locale } = await params;
29
44
  return renderPage({
30
45
  type: "CaseStudiesList",
31
46
  slug: "case-studies",
32
- locale,
33
- translationsNamespace: "case-studies",
34
- bodyMode: "mdx-body",
35
- ...(getMdxSource !== undefined && { getMdxSource }),
47
+ ...(await hubPageInput(HUB, locale, loaders)),
36
48
  });
37
49
  }
@@ -36,7 +36,9 @@ const mockVisitor = { ip: "203.0.113.7" };
36
36
  jest.mock("next/headers", () => ({
37
37
  __esModule: true,
38
38
  headers: jest.fn(async () => ({
39
- get: (name: string) => (name === "x-forwarded-for" ? mockVisitor.ip : null),
39
+ // #9967 D1 — the limiter reads only the edge-set address, on Vercel.
40
+ get: (name: string) =>
41
+ name === "x-vercel-forwarded-for" ? mockVisitor.ip : null,
40
42
  })),
41
43
  }));
42
44
 
@@ -73,10 +75,18 @@ async function linkHref(
73
75
  return href;
74
76
  }
75
77
 
78
+ const priorVercel = process.env.VERCEL;
79
+
76
80
  beforeEach(() => {
77
81
  mockRead.mockReset();
78
82
  __resetRateLimit();
79
83
  mockVisitor.ip = "203.0.113.7";
84
+ process.env.VERCEL = "1";
85
+ });
86
+
87
+ afterAll(() => {
88
+ if (priorVercel === undefined) delete process.env.VERCEL;
89
+ else process.env.VERCEL = priorVercel;
80
90
  });
81
91
 
82
92
  describe("the thank-you page's account link (#8757 D13, AC-9)", () => {
@@ -79,7 +79,7 @@ describe("ClientsPage — snapshot regression", () => {
79
79
  });
80
80
  const { container } = render(element);
81
81
  expect(container.innerHTML).toMatchInlineSnapshot(
82
- `"<script type="application/ld+json" data-testid="json-ld">{"@context":"https://schema.org","@type":"CollectionPage","inLanguage":"en","name":"Our Clients","description":"Companies that trust us.","mainEntity":{"@type":"ItemList","itemListElement":[{"@type":"Organization"}]}}</script><main id="main"><div><h1>Our Clients</h1><p data-testid="clients-description" class="mt-block-tight text-lead">Companies that trust us.</p><div data-testid="markdown-view-toggle" class="ailk-markdown-view-toggle mt-block-loose"><div class="flex justify-end mb-3"><div role="radiogroup" aria-label="Page view" data-testid="markdown-view-toggle" class="inline-flex items-center gap-0.5 rounded-lg p-1 bg-(--color-surface-secondary)"><button class="inline-flex items-center justify-center gap-2 font-medium motion-safe:transition motion-safe:duration-(--button-hover-duration) focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-(--color-accent) focus-visible:ring-offset-1 disabled:cursor-not-allowed disabled:opacity-50 [&amp;>svg]:shrink-0 text-(--color-fg) not-disabled:hover:bg-(--button-hover-quiet-bg) not-disabled:hover:text-(--button-hover-quiet-fg) not-disabled:hover:opacity-(--button-hover-opacity) not-disabled:hover:transform-(--button-hover-transform) not-disabled:hover:shadow-(--button-hover-shadow) not-disabled:active:bg-gray-200 not-disabled:active:text-(--color-fg) rounded-control px-control-x h-(--control-height-thin) text-caption leading-none rounded-md px-3 text-body-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-(--color-accent) focus-visible:ring-offset-1 min-w-(--control-height-thin) min-h-(--control-height-big) bg-(--color-bg) text-(--color-fg) shadow-sm" type="button" role="radio" aria-checked="true" aria-label="Rendered" tabindex="0" data-testid="markdown-view-toggle-rendered">Rendered</button><button class="inline-flex items-center justify-center gap-2 font-medium motion-safe:transition motion-safe:duration-(--button-hover-duration) focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-(--color-accent) focus-visible:ring-offset-1 disabled:cursor-not-allowed disabled:opacity-50 [&amp;>svg]:shrink-0 text-(--color-fg) not-disabled:hover:bg-(--button-hover-quiet-bg) not-disabled:hover:text-(--button-hover-quiet-fg) not-disabled:hover:opacity-(--button-hover-opacity) not-disabled:hover:transform-(--button-hover-transform) not-disabled:hover:shadow-(--button-hover-shadow) not-disabled:active:bg-gray-200 not-disabled:active:text-(--color-fg) rounded-control px-control-x h-(--control-height-thin) text-caption leading-none rounded-md px-3 text-body-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-(--color-accent) focus-visible:ring-offset-1 min-w-(--control-height-thin) min-h-(--control-height-big) text-(--color-text-muted) hover:text-(--color-fg)" type="button" role="radio" aria-checked="false" aria-label="Markdown" tabindex="-1" data-testid="markdown-view-toggle-markdown">Markdown</button></div></div><div role="status" aria-live="polite" aria-atomic="true" class="sr-only" data-testid="markdown-view-toggle-status">Showing Rendered view</div><div data-testid="markdown-view-rendered"><article data-testid="mdx-body"><div data-testid="mdx-remote" data-source=""></div></article></div></div></div></main>"`,
82
+ `"<script type="application/ld+json" data-testid="json-ld">{"@context":"https://schema.org","@type":"CollectionPage","inLanguage":"en","name":"Our Clients","description":"Companies that trust us.","mainEntity":{"@type":"ItemList","itemListElement":[{"@type":"Organization"}]}}</script><main id="main"><div><div data-testid="markdown-view-toggle" class="ailk-markdown-view-toggle"><div class="flex justify-end mb-3"><div role="radiogroup" aria-label="Page view" data-testid="markdown-view-toggle" class="inline-flex items-center gap-0.5 rounded-lg p-1 bg-(--color-surface-secondary)"><button class="inline-flex items-center justify-center gap-2 font-medium motion-safe:transition motion-safe:duration-(--button-hover-duration) focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-(--color-accent) focus-visible:ring-offset-1 disabled:cursor-not-allowed disabled:opacity-50 [&amp;>svg]:shrink-0 text-(--color-fg) not-disabled:hover:bg-(--button-hover-quiet-bg) not-disabled:hover:text-(--button-hover-quiet-fg) not-disabled:hover:opacity-(--button-hover-opacity) not-disabled:hover:transform-(--button-hover-transform) not-disabled:hover:shadow-(--button-hover-shadow) not-disabled:active:bg-gray-200 not-disabled:active:text-(--color-fg) rounded-control px-control-x h-(--control-height-thin) text-caption leading-none rounded-md px-3 text-body-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-(--color-accent) focus-visible:ring-offset-1 min-w-(--control-height-thin) min-h-(--control-height-big) bg-(--color-bg) text-(--color-fg) shadow-sm" type="button" role="radio" aria-checked="true" aria-label="Rendered" tabindex="0" data-testid="markdown-view-toggle-rendered">Rendered</button><button class="inline-flex items-center justify-center gap-2 font-medium motion-safe:transition motion-safe:duration-(--button-hover-duration) focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-(--color-accent) focus-visible:ring-offset-1 disabled:cursor-not-allowed disabled:opacity-50 [&amp;>svg]:shrink-0 text-(--color-fg) not-disabled:hover:bg-(--button-hover-quiet-bg) not-disabled:hover:text-(--button-hover-quiet-fg) not-disabled:hover:opacity-(--button-hover-opacity) not-disabled:hover:transform-(--button-hover-transform) not-disabled:hover:shadow-(--button-hover-shadow) not-disabled:active:bg-gray-200 not-disabled:active:text-(--color-fg) rounded-control px-control-x h-(--control-height-thin) text-caption leading-none rounded-md px-3 text-body-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-(--color-accent) focus-visible:ring-offset-1 min-w-(--control-height-thin) min-h-(--control-height-big) text-(--color-text-muted) hover:text-(--color-fg)" type="button" role="radio" aria-checked="false" aria-label="Markdown" tabindex="-1" data-testid="markdown-view-toggle-markdown">Markdown</button></div></div><div role="status" aria-live="polite" aria-atomic="true" class="sr-only" data-testid="markdown-view-toggle-status">Showing Rendered view</div><div data-testid="markdown-view-rendered"><article data-testid="mdx-body"><div data-testid="mdx-remote" data-source=""></div></article></div></div></div></main>"`,
83
83
  );
84
84
  });
85
85
  });
@@ -13,7 +13,17 @@
13
13
  * Neither ever throws to the client — Banner's `onAccept`/`onReject` are
14
14
  * plain `() => void`, and the banner island already hides itself locally;
15
15
  * a failed write here is logged and nothing more. Neither writes
16
- * `ailk-consent` — the island owns that cookie, unchanged (C3).
16
+ * `ailk-consent` — the island owns that cookie, unchanged (C3). The visitor
17
+ * id is signed with a secret the site already holds (#8556; the order is in
18
+ * `lib/consent-visitor.ts`), so a production site with none of
19
+ * `BETTER_AUTH_SECRET`, `AILK_HUB_SESSION_SECRET` or `AILK_SPOKE_KEY` makes
20
+ * `ensureConsentVisitor` throw: the choice is logged as failed and not
21
+ * recorded, and `ailk-consent` still governs.
22
+ *
23
+ * Before the write each runs a per-visitor limit (#9967 D2): 20 choices per
24
+ * 10 minutes per visitor, the shared wider bucket for one whose address is
25
+ * unreadable. A refused choice makes no hub call and is logged; the visitor's
26
+ * own `ailk-consent` cookie still governs.
17
27
  */
18
28
 
19
29
  import { consent, visitorForwardHeaders } from "@working-theory/api-client";
@@ -21,12 +31,38 @@ import { cookies, headers } from "next/headers";
21
31
  import { getLocale } from "next-intl/server";
22
32
 
23
33
  import { ensureConsentVisitor } from "../../lib/consent-visitor";
34
+ import { takeVisitorLimit } from "../../src/lib/rate-limit";
35
+
36
+ const CHOICE_LIMIT = 20;
37
+ const CHOICE_WINDOW_MS = 10 * 60_000;
38
+ /** Outside a request scope there is no address: the shared bucket. */
39
+ const NO_HEADERS = { get: () => null };
24
40
 
25
41
  async function recordChoice(choice: "accepted" | "declined"): Promise<void> {
26
42
  try {
27
43
  const cookieStore = await cookies();
28
44
  const visitorId = ensureConsentVisitor(cookieStore);
29
45
 
46
+ let requestHeaders: Awaited<ReturnType<typeof headers>> | undefined;
47
+ try {
48
+ requestHeaders = await headers();
49
+ } catch {
50
+ // No request scope to read — the shared bucket, no forwarding headers.
51
+ }
52
+ if (
53
+ !takeVisitorLimit(
54
+ "consent",
55
+ requestHeaders ?? NO_HEADERS,
56
+ CHOICE_LIMIT,
57
+ CHOICE_WINDOW_MS,
58
+ ).allowed
59
+ ) {
60
+ console.error("[consent.actions] record cookie choice refused", {
61
+ outcome: "rate_limited",
62
+ });
63
+ return;
64
+ }
65
+
30
66
  let locale = "en";
31
67
  try {
32
68
  locale = await getLocale();
@@ -36,13 +72,15 @@ async function recordChoice(choice: "accepted" | "declined"): Promise<void> {
36
72
 
37
73
  let init: { headers: Record<string, string> } | undefined;
38
74
  try {
39
- const forwarded = visitorForwardHeaders(
40
- await headers(),
41
- process.env.AILK_VISITOR_FORWARD_KEY,
42
- );
75
+ const forwarded = requestHeaders
76
+ ? visitorForwardHeaders(
77
+ requestHeaders,
78
+ process.env.AILK_VISITOR_FORWARD_KEY,
79
+ )
80
+ : undefined;
43
81
  if (forwarded) init = { headers: forwarded };
44
82
  } catch {
45
- // No request scope to read — record with no forwarding headers.
83
+ // A forwarding read that throws — record with no forwarding headers.
46
84
  }
47
85
 
48
86
  const body = { visitorId, choice, locale };
@@ -17,7 +17,7 @@
17
17
  * per turn with an increasing `turnIndex`, plus one terminal call tagging
18
18
  * the outcome.
19
19
  */
20
- import { act, fireEvent, render, screen } from "@testing-library/react";
20
+ import { act, fireEvent, render, screen, waitFor } from "@testing-library/react";
21
21
 
22
22
  jest.mock("next-intl", () => ({
23
23
  useTranslations: () => (key: string) => key,
@@ -106,7 +106,7 @@ function answer(text: string): void {
106
106
  }
107
107
 
108
108
  describe("ConversationalIntakeWrapper — completion (AC1)", () => {
109
- it("submits through the existing lead-capture endpoint's api-client surface", () => {
109
+ it("submits through the existing lead-capture endpoint's api-client surface", async () => {
110
110
  mockSubmit.mockResolvedValue({
111
111
  ok: true,
112
112
  body: { id: "lead_abc123", persisted: "db" },
@@ -125,8 +125,8 @@ describe("ConversationalIntakeWrapper — completion (AC1)", () => {
125
125
  advanceThinking(); // turn 4 (terms) revealed
126
126
  acceptTerms();
127
127
  advanceThinking(); // completion revealed, onComplete fires
128
-
129
- expect(mockSubmit).toHaveBeenCalledTimes(1);
128
+ // The action awaits its bot check (#9255) before the write.
129
+ await waitFor(() => expect(mockSubmit).toHaveBeenCalledTimes(1));
130
130
  const body = mockSubmit.mock.calls[0][0] as {
131
131
  source: string;
132
132
  email: string;
@@ -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
+ });