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
@@ -0,0 +1,142 @@
1
+ /**
2
+ * The shared Pricing render (#10044 D5, AC-4): which offering pages `/pricing`
3
+ * lists, the card it lists each with, and `plans: []` reading no Stripe data.
4
+ */
5
+ import {
6
+ PageSectionsSchema,
7
+ type PageSection,
8
+ } from "@working-theory/validation";
9
+
10
+ import {
11
+ offeringPricingPages,
12
+ pagePlans,
13
+ PRICING_HUB_SLUG,
14
+ pricingPageCard,
15
+ pricingSections,
16
+ } from "../pricing-page";
17
+ import type { DetailPage } from "../route-family";
18
+
19
+ const fetchMock: jest.Mock = jest.fn(async () => ({
20
+ ok: true,
21
+ status: 200,
22
+ json: async () => ({ ok: true, data: { status: "unconnected" } }),
23
+ }));
24
+
25
+ beforeEach(() => {
26
+ global.fetch = fetchMock;
27
+ fetchMock.mockClear();
28
+ });
29
+
30
+ function page(slug: string, frontmatter: Record<string, unknown>): DetailPage {
31
+ return { slug, frontmatter: { type: "Pricing", slug, ...frontmatter } };
32
+ }
33
+
34
+ describe("offeringPricingPages (#10044 AC-4)", () => {
35
+ it("keeps a page whose frontmatter slug is its file slug, drops the hub and a mismatched slug, in input order", () => {
36
+ const pages = [
37
+ page("a", { title: "A" }),
38
+ page("b", { title: "B" }),
39
+ page(PRICING_HUB_SLUG, { title: "Pricing" }),
40
+ { slug: "x", frontmatter: { type: "Pricing", slug: "pricing" } },
41
+ { slug: "y", frontmatter: { type: "Pricing", slug: "other" } },
42
+ ];
43
+ expect(offeringPricingPages(pages).map((p) => p.slug)).toEqual(["a", "b"]);
44
+ });
45
+ });
46
+
47
+ describe("pricingPageCard (#10044 AC-4)", () => {
48
+ const labels = { viewPricing: "See pricing" };
49
+
50
+ it("carries the title, the summary over the description, and a link to /pricing/<slug>", () => {
51
+ expect(
52
+ pricingPageCard(
53
+ page("a", { title: "A", summary: "Short", description: "Long" }),
54
+ labels,
55
+ ),
56
+ ).toEqual({
57
+ title: "A",
58
+ description: "Short",
59
+ link: { label: "See pricing", href: "/pricing/a" },
60
+ });
61
+ });
62
+
63
+ it("falls back to the description without a summary", () => {
64
+ expect(
65
+ pricingPageCard(page("b", { title: "B", description: "Long" }), labels)
66
+ .description,
67
+ ).toBe("Long");
68
+ });
69
+
70
+ it("builds a pricing-pages section that parses PageSectionsSchema", () => {
71
+ const section = {
72
+ type: "items",
73
+ id: "pricing-pages",
74
+ data: {
75
+ headline: "Pricing by offering",
76
+ variant: "tile",
77
+ tileHover: "outline",
78
+ items: [
79
+ pricingPageCard(
80
+ page("a", { title: "A", summary: "S", description: "D" }),
81
+ labels,
82
+ ),
83
+ pricingPageCard(page("b", { title: "B", description: "D" }), labels),
84
+ ],
85
+ },
86
+ };
87
+ expect(PageSectionsSchema.safeParse([section]).success).toBe(true);
88
+ });
89
+ });
90
+
91
+ describe("pagePlans (#10044 D1)", () => {
92
+ it("reads the page's plans, or undefined when it names none", () => {
93
+ expect(pagePlans({ plans: ["a", "b"] })).toEqual(["a", "b"]);
94
+ expect(pagePlans({ plans: [] })).toEqual([]);
95
+ expect(pagePlans({})).toBeUndefined();
96
+ expect(pagePlans(null)).toBeUndefined();
97
+ });
98
+ });
99
+
100
+ describe("pricingSections (#10044 AC-4)", () => {
101
+ const declared: PageSection[] = PageSectionsSchema.parse([
102
+ {
103
+ id: "plans",
104
+ type: "pricing",
105
+ data: {
106
+ variant: "three-tiers-with-toggle",
107
+ billingToggle: false,
108
+ plans: [
109
+ {
110
+ key: "basic",
111
+ name: "Basic",
112
+ price: { monthly: "$29" },
113
+ features: [],
114
+ cta: { label: "Choose Basic", href: "/checkout/basic" },
115
+ },
116
+ ],
117
+ },
118
+ },
119
+ ]);
120
+
121
+ it("makes no shown-offerings read for `plans: []` and returns the declared sections", async () => {
122
+ const sections = await pricingSections(declared, {
123
+ plans: [],
124
+ plan: undefined,
125
+ locale: "en",
126
+ });
127
+ expect(fetchMock).not.toHaveBeenCalled();
128
+ expect(sections).toBe(declared);
129
+ });
130
+
131
+ it("moves `?plan=` onto the written sections for `plans: []`", async () => {
132
+ const sections = await pricingSections(declared, {
133
+ plans: [],
134
+ plan: "basic",
135
+ locale: "en",
136
+ });
137
+ expect(fetchMock).not.toHaveBeenCalled();
138
+ expect((sections[0]!.data as { recommended?: string }).recommended).toBe(
139
+ "basic",
140
+ );
141
+ });
142
+ });
@@ -10,6 +10,7 @@
10
10
  //
11
11
  // Spec: docs/specs/7644-the-browser-talks-only-to-spec.md §5.1 AC-1 to AC-4.
12
12
 
13
+ import { ensureConsentVisitor } from "../consent-visitor";
13
14
  import { BOT_CHECKED_ROUTES, handleRelay, RELAY_ROUTES } from "../relay";
14
15
  import { __resetRelayRateLimit, __relayRateLimitBucketCount } from "../relay";
15
16
  import { TOPOLOGY } from "../topology.generated";
@@ -36,6 +37,7 @@ const ENV_KEYS = [
36
37
  "AILK_VISITOR_FORWARD_KEY",
37
38
  "VERCEL",
38
39
  "AILK_BOOKING_BOT_CHECK",
40
+ "AILK_BOT_CHECK",
39
41
  ] as const;
40
42
  const priorEnv = new Map<string, string | undefined>(
41
43
  ENV_KEYS.map((name) => [name, process.env[name]]),
@@ -469,14 +471,84 @@ describe("the banner's visitor id (#8438 D12)", () => {
469
471
  const VALID_ID = "11111111-2222-4333-8444-555555555555";
470
472
  const LEAD_PATHS = ["/v1/leads/submit", "/v1/leads/subscribe", "/v1/leads/callback"];
471
473
 
474
+ /** A cookie value the site signed (#8556), and the UUID it carries. */
475
+ function signedCookie(): { id: string; value: string } {
476
+ const set = jest.fn();
477
+ const id = ensureConsentVisitor({ get: () => undefined, set });
478
+ return { id, value: set.mock.calls[0][1] as string };
479
+ }
480
+
472
481
  it.each(LEAD_PATHS)(
473
- "%s carries x-ailk-consent-visitor from the request's own ailk-vid cookie",
482
+ "%s carries x-ailk-consent-visitor from the request's own signed ailk-vid cookie",
474
483
  async (path) => {
475
- await handleRelay(relayRequest(path, { headers: { cookie: `ailk-vid=${VALID_ID}` } }));
476
- expect(sent().headers.get("x-ailk-consent-visitor")).toBe(VALID_ID);
484
+ const { id, value } = signedCookie();
485
+ await handleRelay(relayRequest(path, { headers: { cookie: `ailk-vid=${value}` } }));
486
+ expect(sent().headers.get("x-ailk-consent-visitor")).toBe(id);
477
487
  },
478
488
  );
479
489
 
490
+ // #8556: a well-formed id the browser chose is not one this site minted.
491
+ it("sends no header when the cookie is a bare UUID the site did not sign", async () => {
492
+ await handleRelay(relayRequest("/v1/leads/submit", { headers: { cookie: `ailk-vid=${VALID_ID}` } }));
493
+ expect(sent().headers.get("x-ailk-consent-visitor")).toBeNull();
494
+ });
495
+
496
+ it("sends no header when the cookie's signature has been changed", async () => {
497
+ const { value } = signedCookie();
498
+ const at = value.indexOf(".") + 1;
499
+ const changed = `${value.slice(0, at)}${value[at] === "A" ? "B" : "A"}${value.slice(at + 1)}`;
500
+ await handleRelay(relayRequest("/v1/leads/submit", { headers: { cookie: `ailk-vid=${changed}` } }));
501
+ expect(sent().headers.get("x-ailk-consent-visitor")).toBeNull();
502
+ });
503
+
504
+ /** Run `fn` as a production process with exactly `vars` among the signing secrets. */
505
+ async function inProduction(vars: Record<string, string>, fn: () => Promise<void>): Promise<void> {
506
+ const keys = ["NODE_ENV", "NEXT_PHASE", "BETTER_AUTH_SECRET", "AILK_HUB_SESSION_SECRET", "AILK_SPOKE_KEY"];
507
+ const saved = new Map(keys.map((key) => [key, process.env[key]]));
508
+ for (const key of keys) delete process.env[key];
509
+ Object.assign(process.env, { NODE_ENV: "production" }, vars);
510
+ try {
511
+ await fn();
512
+ } finally {
513
+ for (const [key, value] of saved) {
514
+ if (value === undefined) delete process.env[key];
515
+ else process.env[key] = value;
516
+ }
517
+ }
518
+ }
519
+
520
+ it("sends no header, and still relays, when the site has no usable secret", async () => {
521
+ const { value } = signedCookie();
522
+ await inProduction({}, async () => {
523
+ const res = await handleRelay(relayRequest("/v1/leads/submit", { headers: { cookie: `ailk-vid=${value}` } }));
524
+ expect(res.status).toBe(201);
525
+ });
526
+ expect(sent().headers.get("x-ailk-consent-visitor")).toBeNull();
527
+ });
528
+
529
+ // #8556 option (b): a production spoke has no BETTER_AUTH_SECRET by design.
530
+ // A minted spoke key is `ailk_spk_` plus 43 characters; the signer skips one under 32.
531
+ const SPOKE_KEY = `ailk_spk_${"k".repeat(43)}`;
532
+
533
+ it("a production spoke holding only AILK_SPOKE_KEY forwards the id it signed", async () => {
534
+ await inProduction({ AILK_SPOKE_KEY: SPOKE_KEY }, async () => {
535
+ const { id, value } = signedCookie();
536
+ await handleRelay(relayRequest("/v1/leads/submit", { headers: { cookie: `ailk-vid=${value}` } }));
537
+ expect(sent().headers.get("x-ailk-consent-visitor")).toBe(id);
538
+ });
539
+ });
540
+
541
+ it("a production spoke sends no header for an id another spoke key signed", async () => {
542
+ let value = "";
543
+ await inProduction({ AILK_SPOKE_KEY: `ailk_spk_${"o".repeat(43)}` }, async () => {
544
+ value = signedCookie().value;
545
+ });
546
+ await inProduction({ AILK_SPOKE_KEY: SPOKE_KEY }, async () => {
547
+ await handleRelay(relayRequest("/v1/leads/submit", { headers: { cookie: `ailk-vid=${value}` } }));
548
+ });
549
+ expect(sent().headers.get("x-ailk-consent-visitor")).toBeNull();
550
+ });
551
+
480
552
  it("sends no header when the request carries no ailk-vid cookie", async () => {
481
553
  await handleRelay(relayRequest("/v1/leads/submit"));
482
554
  expect(sent().headers.get("x-ailk-consent-visitor")).toBeNull();
@@ -1118,7 +1190,10 @@ describe("the booking bot check (#9889)", () => {
1118
1190
  checkBotIdMock.mockResolvedValue(BOT);
1119
1191
  });
1120
1192
 
1121
- it.each(ENTRIES.filter((e) => e.path !== BOOK))(
1193
+ const isChecked = (e: { method: string; path: string }) =>
1194
+ BOT_CHECKED_ROUTES.some((r) => r.method === e.method && r.path === e.path);
1195
+
1196
+ it.each(ENTRIES.filter((e) => !isChecked(e)))(
1122
1197
  "$method $path forwards with no BotID call",
1123
1198
  async (entry) => {
1124
1199
  const res = await handleRelay(entryRequest(entry));
@@ -1140,7 +1215,9 @@ describe("the booking bot check (#9889)", () => {
1140
1215
  it("an over-budget booking answers 429 with no BotID call", async () => {
1141
1216
  const visitor = { "x-forwarded-for": "203.0.113.9" };
1142
1217
  for (let i = 0; i < 8; i++) {
1143
- await handleRelay(relayRequest("/v1/leads/submit", { headers: visitor }));
1218
+ await handleRelay(
1219
+ relayRequest(`/v1/schedule/availability${QUERY}`, { method: "GET", headers: visitor }),
1220
+ );
1144
1221
  }
1145
1222
  const res = await handleRelay(relayRequest(BOOK, { headers: visitor }));
1146
1223
  expect(res.status).toBe(429);
@@ -1164,12 +1241,127 @@ describe("the booking bot check (#9889)", () => {
1164
1241
  expect(checkBotIdMock).not.toHaveBeenCalled();
1165
1242
  });
1166
1243
 
1167
- it("BOT_CHECKED_ROUTES is the booking pair, frozen, and RELAY_ROUTES' own entry", () => {
1168
- expect(BOT_CHECKED_ROUTES).toEqual([{ method: "POST", path: BOOK }]);
1244
+ it("BOT_CHECKED_ROUTES is the three lead pairs and the booking pair, frozen, and RELAY_ROUTES' own entries", () => {
1245
+ expect(BOT_CHECKED_ROUTES).toEqual([
1246
+ { method: "POST", path: "/v1/leads/submit" },
1247
+ { method: "POST", path: "/v1/leads/subscribe" },
1248
+ { method: "POST", path: "/v1/leads/callback" },
1249
+ { method: "POST", path: BOOK },
1250
+ ]);
1169
1251
  expect(Object.isFrozen(BOT_CHECKED_ROUTES)).toBe(true);
1170
- expect(BOT_CHECKED_ROUTES[0]).toBe(
1171
- RELAY_ROUTES.find((r) => r.method === "POST" && r.path === BOOK),
1172
- );
1252
+ for (const checked of BOT_CHECKED_ROUTES) {
1253
+ expect(checked).toBe(
1254
+ RELAY_ROUTES.find((r) => r.method === checked.method && r.path === checked.path),
1255
+ );
1256
+ }
1257
+ });
1258
+ });
1259
+ });
1260
+
1261
+ // The write bot check (#9255, spec
1262
+ // docs/specs/9255-an-edge-bot-challenge-in-spec.md §5.1 AC-3): the relay's
1263
+ // three lead routes join the booking's check.
1264
+ describe("the write bot check on the lead routes (#9255)", () => {
1265
+ const LEAD_ROUTES = ["/v1/leads/submit", "/v1/leads/subscribe", "/v1/leads/callback"];
1266
+ const BOT = {
1267
+ isHuman: false,
1268
+ isBot: true,
1269
+ isVerifiedBot: false,
1270
+ bypassed: false,
1271
+ };
1272
+ const HUMAN = { ...BOT, isHuman: true, isBot: false };
1273
+
1274
+ let warnSpy: jest.SpyInstance;
1275
+
1276
+ beforeEach(() => {
1277
+ checkBotIdMock.mockReset();
1278
+ process.env.VERCEL = "1";
1279
+ warnSpy = jest.spyOn(console, "warn").mockImplementation(() => {});
1280
+ });
1281
+
1282
+ afterEach(() => {
1283
+ warnSpy.mockRestore();
1284
+ });
1285
+
1286
+ it.each(LEAD_ROUTES)("POST %s: a bot answers 403 bot_detected with no hub call", async (path) => {
1287
+ checkBotIdMock.mockResolvedValue(BOT);
1288
+ const res = await handleRelay(relayRequest(path));
1289
+ expect(res.status).toBe(403);
1290
+ expect(await errorCode(res)).toBe("bot_detected");
1291
+ expect(fetchMock).not.toHaveBeenCalled();
1292
+ expect(checkBotIdMock).toHaveBeenCalledTimes(1);
1293
+ expect(warnSpy).toHaveBeenCalledWith("[relay] bot refused", {
1294
+ path,
1295
+ outcome: "bot-detected",
1173
1296
  });
1174
1297
  });
1298
+
1299
+ it.each(LEAD_ROUTES)(
1300
+ "POST %s: a check that throws answers 503 bot_check_unavailable with no hub call",
1301
+ async (path) => {
1302
+ checkBotIdMock.mockRejectedValue(new Error("token abc"));
1303
+ const res = await handleRelay(relayRequest(path));
1304
+ expect(res.status).toBe(503);
1305
+ expect(await errorCode(res)).toBe("bot_check_unavailable");
1306
+ expect(fetchMock).not.toHaveBeenCalled();
1307
+ expect(errorSpy).toHaveBeenCalledWith("[relay] bot check failed", {
1308
+ path,
1309
+ outcome: "bot-check-failed",
1310
+ error: "Error",
1311
+ });
1312
+ expect(JSON.stringify(errorSpy.mock.calls)).not.toContain("token abc");
1313
+ },
1314
+ );
1315
+
1316
+ it.each(LEAD_ROUTES)(
1317
+ "POST %s: a human forwards once, exactly as with the check off",
1318
+ async (path) => {
1319
+ checkBotIdMock.mockResolvedValue(HUMAN);
1320
+ const botIdHeaders = { "x-is-human": "{}", "x-path": path, "x-method": "POST" };
1321
+
1322
+ delete process.env.VERCEL;
1323
+ expect((await handleRelay(relayRequest(path, { headers: botIdHeaders }))).status).toBe(201);
1324
+ process.env.VERCEL = "1";
1325
+ expect((await handleRelay(relayRequest(path, { headers: botIdHeaders }))).status).toBe(201);
1326
+
1327
+ expect(checkBotIdMock).toHaveBeenCalledTimes(1);
1328
+ expect(fetchMock).toHaveBeenCalledTimes(2);
1329
+ const [off, on] = fetchMock.mock.calls.map(([url, init]) => ({
1330
+ url,
1331
+ method: init.method,
1332
+ body: init.body,
1333
+ headers: [...new Headers(init.headers).entries()],
1334
+ }));
1335
+ expect(on).toEqual(off);
1336
+ expect(on?.url).toBe(`${HUB}${path}`);
1337
+ },
1338
+ );
1339
+
1340
+ it.each([
1341
+ { method: "GET", path: "/v1/schedule/availability" },
1342
+ { method: "POST", path: "/v1/commerce/invoices" },
1343
+ ])("$method $path forwards with no BotID call", async (entry) => {
1344
+ checkBotIdMock.mockResolvedValue(BOT);
1345
+ const res = await handleRelay(entryRequest(entry));
1346
+ expect(res.status).toBe(201);
1347
+ expect(fetchMock).toHaveBeenCalledTimes(1);
1348
+ expect(checkBotIdMock).not.toHaveBeenCalled();
1349
+ });
1350
+
1351
+ it("AILK_BOT_CHECK=off forwards a lead with no BotID call", async () => {
1352
+ process.env.AILK_BOT_CHECK = "off";
1353
+ checkBotIdMock.mockResolvedValue(BOT);
1354
+ const res = await handleRelay(relayRequest("/v1/leads/submit"));
1355
+ expect(res.status).toBe(201);
1356
+ expect(checkBotIdMock).not.toHaveBeenCalled();
1357
+ });
1358
+
1359
+ it("AILK_BOOKING_BOT_CHECK=off leaves the lead routes checked", async () => {
1360
+ process.env.AILK_BOOKING_BOT_CHECK = "off";
1361
+ checkBotIdMock.mockResolvedValue(BOT);
1362
+ const res = await handleRelay(relayRequest("/v1/leads/submit"));
1363
+ expect(res.status).toBe(403);
1364
+ expect(await errorCode(res)).toBe("bot_detected");
1365
+ expect(fetchMock).not.toHaveBeenCalled();
1366
+ });
1175
1367
  });
@@ -0,0 +1,57 @@
1
+ /**
2
+ * The route-family helper (#10051 D5, AC-6) — the generic pieces a content
3
+ * hub uses: list every detail page of a type, and slot a generated section in
4
+ * before the page's closing CTAs.
5
+ */
6
+ import { insertBeforeClosingCtas, listDetailPages } from "../route-family";
7
+
8
+ jest.mock("@working-theory/page-renderer", () => ({
9
+ getMdxSlugs: jest.fn(async () => ["c", "a", "gone", "b"]),
10
+ getMdxSource: jest.fn(),
11
+ }));
12
+
13
+ describe("listDetailPages", () => {
14
+ it("returns every loaded page sorted by slug, dropping a null load", async () => {
15
+ const load = jest.fn(async (_type: string, slug: string) =>
16
+ slug === "gone" ? null : { frontmatter: { title: slug.toUpperCase() } },
17
+ );
18
+ const pages = await listDetailPages("TemplatePage", "en", load as never);
19
+ expect(pages).toEqual([
20
+ { slug: "a", frontmatter: { title: "A" } },
21
+ { slug: "b", frontmatter: { title: "B" } },
22
+ { slug: "c", frontmatter: { title: "C" } },
23
+ ]);
24
+ expect(load).toHaveBeenCalledWith("TemplatePage", "a", "en");
25
+ });
26
+ });
27
+
28
+ describe("insertBeforeClosingCtas", () => {
29
+ const section = (type: string, id: string) => ({ type, id }) as never;
30
+
31
+ it("places the section before a trailing run of two cta sections", () => {
32
+ const result = insertBeforeClosingCtas(
33
+ [section("hero", "h"), section("cta", "c1"), section("cta", "c2")],
34
+ section("items", "new"),
35
+ );
36
+ expect(result.map((s: { id: string }) => s.id)).toEqual([
37
+ "h",
38
+ "new",
39
+ "c1",
40
+ "c2",
41
+ ]);
42
+ });
43
+
44
+ it("appends when the page does not end in a cta", () => {
45
+ const result = insertBeforeClosingCtas(
46
+ [section("cta", "c1"), section("hero", "h")],
47
+ section("items", "new"),
48
+ );
49
+ expect(result.map((s: { id: string }) => s.id)).toEqual(["c1", "h", "new"]);
50
+ });
51
+
52
+ it("appends to an empty page", () => {
53
+ expect(insertBeforeClosingCtas([], section("items", "new"))).toHaveLength(
54
+ 1,
55
+ );
56
+ });
57
+ });
@@ -0,0 +1,111 @@
1
+ /**
2
+ * @jest-environment node
3
+ */
4
+ // #10043 D8, AC-6 — the services listing: a service's public path, a
5
+ // category's list of its services and the hub's re-pointed links.
6
+ import { PageSectionsSchema } from "@working-theory/validation";
7
+
8
+ type Mod = typeof import("../service-listing");
9
+
10
+ function load(value: string | undefined): Mod {
11
+ const saved = process.env.NEXT_PUBLIC_AILK_SERVICES_PATH;
12
+ if (value === undefined) delete process.env.NEXT_PUBLIC_AILK_SERVICES_PATH;
13
+ else process.env.NEXT_PUBLIC_AILK_SERVICES_PATH = value;
14
+ let mod: Mod | undefined;
15
+ try {
16
+ jest.isolateModules(() => {
17
+ mod = require("../service-listing") as Mod;
18
+ });
19
+ } finally {
20
+ if (saved === undefined) delete process.env.NEXT_PUBLIC_AILK_SERVICES_PATH;
21
+ else process.env.NEXT_PUBLIC_AILK_SERVICES_PATH = saved;
22
+ }
23
+ return mod!;
24
+ }
25
+
26
+ const labels = {
27
+ childrenHeading: "Services in this area",
28
+ viewService: "View service",
29
+ };
30
+
31
+ const pages = [
32
+ {
33
+ slug: "a",
34
+ frontmatter: {
35
+ title: "A",
36
+ description: "A desc.",
37
+ summary: "A sum.",
38
+ parent: "ai",
39
+ },
40
+ },
41
+ { slug: "ai", frontmatter: { title: "AI", description: "AI desc." } },
42
+ {
43
+ slug: "b",
44
+ frontmatter: { title: "B", description: "B desc.", parent: "ai" },
45
+ },
46
+ {
47
+ slug: "c",
48
+ frontmatter: { title: "C", description: "C desc.", parent: "other" },
49
+ },
50
+ ];
51
+
52
+ describe("service-listing with services.path /capabilities", () => {
53
+ const mod = load("/capabilities");
54
+
55
+ it("builds the public path under the base and the category", () => {
56
+ expect(mod.servicePublicPath("x")).toBe("/capabilities/x");
57
+ expect(mod.servicePublicPath("x", "ai")).toBe("/capabilities/ai/x");
58
+ });
59
+
60
+ it("lists a category's services in slug order, summary else description", () => {
61
+ const children = mod.childServices(pages, "ai");
62
+ const section = mod.serviceChildrenSection(children, labels);
63
+ expect(section.data.headline).toBe("Services in this area");
64
+ expect(section.data.items).toEqual([
65
+ {
66
+ title: "A",
67
+ description: "A sum.",
68
+ link: { label: "View service", href: "/capabilities/ai/a" },
69
+ },
70
+ {
71
+ title: "B",
72
+ description: "B desc.",
73
+ link: { label: "View service", href: "/capabilities/ai/b" },
74
+ },
75
+ ]);
76
+ expect(PageSectionsSchema.safeParse([section]).success).toBe(true);
77
+ });
78
+
79
+ it("re-points a hub section link onto the base and leaves an absolute URL", () => {
80
+ const sections = [
81
+ {
82
+ type: "items",
83
+ id: "s",
84
+ data: {
85
+ items: [
86
+ { title: "X", link: { label: "Go", href: "/services/x" } },
87
+ {
88
+ title: "Y",
89
+ link: { label: "Go", href: "https://example.com/services" },
90
+ },
91
+ ],
92
+ },
93
+ },
94
+ ];
95
+ const mapped = mod.withPublicServicesSections(sections);
96
+ expect(mapped[0]?.data.items.map((i) => i.link.href)).toEqual([
97
+ "/capabilities/x",
98
+ "https://example.com/services",
99
+ ]);
100
+ });
101
+ });
102
+
103
+ describe("service-listing with no services.path", () => {
104
+ const mod = load(undefined);
105
+
106
+ it("returns the same sections array", () => {
107
+ const sections = [{ type: "items", id: "s", data: { items: [] } }];
108
+ expect(mod.withPublicServicesSections(sections)).toBe(sections);
109
+ expect(mod.servicePublicPath("x", "ai")).toBe("/services/ai/x");
110
+ });
111
+ });
@@ -0,0 +1,96 @@
1
+ /**
2
+ * @jest-environment node
3
+ */
4
+ // #10043 D5, AC-7 — the services-path mappers. `SERVICES_PATH` is read at
5
+ // module load, so each setting loads the module afresh.
6
+ import type { NavItem } from "@working-theory/ui";
7
+ import { Menu as Icon } from "lucide-react";
8
+
9
+ type Mod = typeof import("../services-path");
10
+
11
+ function load(value: string | undefined): Mod {
12
+ const saved = process.env.NEXT_PUBLIC_AILK_SERVICES_PATH;
13
+ if (value === undefined) delete process.env.NEXT_PUBLIC_AILK_SERVICES_PATH;
14
+ else process.env.NEXT_PUBLIC_AILK_SERVICES_PATH = value;
15
+ let mod: Mod | undefined;
16
+ try {
17
+ jest.isolateModules(() => {
18
+ mod = require("../services-path") as Mod;
19
+ });
20
+ } finally {
21
+ if (saved === undefined) delete process.env.NEXT_PUBLIC_AILK_SERVICES_PATH;
22
+ else process.env.NEXT_PUBLIC_AILK_SERVICES_PATH = saved;
23
+ }
24
+ return mod!;
25
+ }
26
+
27
+ describe("services-path with services.path /capabilities", () => {
28
+ const mod = load("/capabilities");
29
+
30
+ it.each([
31
+ ["/services", "/capabilities"],
32
+ ["/services/x", "/capabilities/x"],
33
+ ["/services?a=1", "/capabilities?a=1"],
34
+ ["/services#top", "/capabilities#top"],
35
+ ["/servicesx", "/servicesx"],
36
+ ["/docs", "/docs"],
37
+ ["https://example.com/services", "https://example.com/services"],
38
+ ])("toPublicServicesHref(%s) is %s", (href, expected) => {
39
+ expect(mod.toPublicServicesHref(href)).toBe(expected);
40
+ });
41
+
42
+ it.each([
43
+ ["/capabilities", "/services"],
44
+ ["/capabilities/ai/x", "/services/ai/x"],
45
+ ["/capabilitiesx", null],
46
+ ["/services/x", null],
47
+ ])("toInternalServicesPath(%s) is %p", (bare, expected) => {
48
+ expect(mod.toInternalServicesPath(bare)).toBe(expected);
49
+ });
50
+
51
+ it("maps a nav item's path, a flyout feature and a flyout action", () => {
52
+ const items: NavItem[] = [
53
+ { labelKey: "services", path: "/services" },
54
+ {
55
+ labelKey: "more",
56
+ flyout: {
57
+ features: [{ labelKey: "x", path: "/services/x" }],
58
+ actions: [{ labelKey: "all", path: "/services", icon: Icon }],
59
+ },
60
+ },
61
+ { labelKey: "docs", path: "/docs" },
62
+ { labelKey: "other", path: "/servicesx" },
63
+ ];
64
+ const mapped = mod.withPublicServicesPaths(items);
65
+ expect(mapped[0]?.path).toBe("/capabilities");
66
+ expect(mapped[1]?.flyout?.features[0]?.path).toBe("/capabilities/x");
67
+ expect(mapped[1]?.flyout?.actions[0]?.path).toBe("/capabilities");
68
+ expect(mapped[2]?.path).toBe("/docs");
69
+ expect(mapped[3]?.path).toBe("/servicesx");
70
+ });
71
+
72
+ it("maps a footer item", () => {
73
+ const columns = [
74
+ {
75
+ headingKey: "h",
76
+ items: [
77
+ { labelKey: "x", path: "/services/x" },
78
+ { labelKey: "d", path: "/docs" },
79
+ ],
80
+ },
81
+ ];
82
+ expect(
83
+ mod.withPublicServicesColumns(columns)[0]?.items.map((i) => i.path),
84
+ ).toEqual(["/capabilities/x", "/docs"]);
85
+ });
86
+ });
87
+
88
+ describe("services-path with no services.path", () => {
89
+ const mod = load(undefined);
90
+
91
+ it("is the identity", () => {
92
+ expect(mod.SERVICES_PATH).toBe("/services");
93
+ expect(mod.toPublicServicesHref("/services/x")).toBe("/services/x");
94
+ expect(mod.toInternalServicesPath("/services/x")).toBeNull();
95
+ });
96
+ });