create-ailk 0.30.0 → 0.32.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 (95) hide show
  1. package/README.md +108 -4
  2. package/component-catalog.json +524 -8
  3. package/component-catalog.md +8 -5
  4. package/dist/body-heading.d.ts +13 -0
  5. package/dist/body-heading.js +19 -5
  6. package/dist/cli.js +99 -0
  7. package/dist/cutover.d.ts +114 -0
  8. package/dist/cutover.js +511 -0
  9. package/dist/design-presets.js +20 -4
  10. package/dist/export-content.d.ts +55 -0
  11. package/dist/export-content.js +303 -0
  12. package/dist/inventory.d.ts +216 -0
  13. package/dist/inventory.js +833 -0
  14. package/dist/language-import.d.ts +127 -0
  15. package/dist/language-import.js +451 -0
  16. package/dist/lib/env-catalog.js +1 -1
  17. package/dist/lib/import-mdx.d.ts +38 -0
  18. package/dist/lib/import-mdx.js +445 -65
  19. package/dist/lib/render-comparison.d.ts +251 -0
  20. package/dist/lib/render-comparison.js +573 -0
  21. package/dist/mdx-braces.d.ts +73 -0
  22. package/dist/mdx-braces.js +285 -0
  23. package/dist/migrate-content.d.ts +6 -2
  24. package/dist/migrate-content.js +62 -14
  25. package/dist/programmatic.js +2 -8
  26. package/dist/seed-workspace.d.ts +3 -1
  27. package/dist/seed-workspace.js +45 -16
  28. package/dist/site-manifest.js +6 -38
  29. package/dist/site-roster.d.ts +46 -3
  30. package/dist/site-roster.js +124 -4
  31. package/dist/surfaces.d.ts +8 -0
  32. package/dist/surfaces.js +16 -0
  33. package/dist/topology.d.ts +12 -0
  34. package/dist/topology.js +24 -10
  35. package/dist/update.d.ts +21 -1
  36. package/dist/update.js +142 -11
  37. package/package.json +3 -3
  38. package/templates/.claude/cloudparity-repo-hooks.json +5 -0
  39. package/templates/.claude/hooks/content-source-guard.sh +75 -0
  40. package/templates/.claude/settings.json +9 -0
  41. package/templates/.github/workflows/ci.yml +4 -0
  42. package/templates/CLAUDE.md +5 -0
  43. package/templates/apps/mcp/.env.example +7 -1
  44. package/templates/apps/mcp/CLAUDE.md +8 -4
  45. package/templates/apps/web/.env.example +4 -2
  46. package/templates/apps/web/CLAUDE.md +16 -5
  47. package/templates/apps/web/__tests__/setup.ts +9 -0
  48. package/templates/apps/web/app/[locale]/[slug]/[detail]/__tests__/page.test.tsx +156 -4
  49. package/templates/apps/web/app/[locale]/[slug]/[detail]/page.tsx +45 -2
  50. package/templates/apps/web/app/[locale]/[slug]/__tests__/page.test.tsx +3 -0
  51. package/templates/apps/web/app/[locale]/about/__tests__/about-routes.test.tsx +3 -0
  52. package/templates/apps/web/app/[locale]/careers/__tests__/page-renderer-migration.test.tsx +3 -0
  53. package/templates/apps/web/app/[locale]/contact/__tests__/site-contact-lines.test.ts +49 -0
  54. package/templates/apps/web/app/[locale]/contact/site-contact-lines.ts +6 -4
  55. package/templates/apps/web/app/[locale]/events/[slug]/page.tsx +13 -1
  56. package/templates/apps/web/app/[locale]/events/__tests__/program-event.test.tsx +120 -0
  57. package/templates/apps/web/app/[locale]/events/page.tsx +11 -0
  58. package/templates/apps/web/app/[locale]/flows/[slug]/FlowStepperClient.tsx +44 -5
  59. package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/FlowStepperClient.thanks-by-tier.test.tsx +306 -0
  60. package/templates/apps/web/app/[locale]/layout.tsx +122 -27
  61. package/templates/apps/web/app/[locale]/pricing/[slug]/__tests__/page.test.tsx +3 -0
  62. package/templates/apps/web/app/[locale]/pricing/__tests__/offerings.test.tsx +3 -0
  63. package/templates/apps/web/app/[locale]/pricing/__tests__/page.test.tsx +3 -0
  64. package/templates/apps/web/app/[locale]/pricing/__tests__/unconnected.test.tsx +3 -0
  65. package/templates/apps/web/app/[locale]/projects/__tests__/page-renderer-migration.test.tsx +3 -0
  66. package/templates/apps/web/app/[locale]/services/__tests__/page-renderer-migration.test.tsx +3 -0
  67. package/templates/apps/web/app/[locale]/team/__tests__/team-routes.test.tsx +3 -0
  68. package/templates/apps/web/app/[locale]/thanks/[slug]/__tests__/page.test.tsx +21 -2
  69. package/templates/apps/web/app/[locale]/thanks/[slug]/page.tsx +1 -0
  70. package/templates/apps/web/app/llms.txt/route.ts +16 -9
  71. package/templates/apps/web/app/sitemap.xml/route.ts +3 -0
  72. package/templates/apps/web/jest.config.cjs +46 -1
  73. package/templates/apps/web/lib/__tests__/bot-check.test.ts +247 -4
  74. package/templates/apps/web/lib/__tests__/in-page-magnet-self-request.test.ts +92 -0
  75. package/templates/apps/web/lib/__tests__/page-chrome.test.ts +93 -0
  76. package/templates/apps/web/lib/__tests__/relay.test.ts +33 -8
  77. package/templates/apps/web/lib/__tests__/site-identity-feature-list.test.ts +196 -0
  78. package/templates/apps/web/lib/__tests__/structured-data.test.tsx +145 -11
  79. package/templates/apps/web/lib/bot-check.ts +113 -13
  80. package/templates/apps/web/lib/in-page-magnet.ts +4 -1
  81. package/templates/apps/web/lib/page-chrome.ts +65 -5
  82. package/templates/apps/web/lib/relay.ts +22 -3
  83. package/templates/apps/web/lib/site-identity.ts +75 -24
  84. package/templates/apps/web/messages/en.json +5 -1
  85. package/templates/apps/web/next.config.mjs +15 -1
  86. package/templates/apps/web/package.json +1 -1
  87. package/templates/apps/web/scripts/assert-content-bundled.ts +36 -8
  88. package/templates/apps/web/scripts/content-snapshot.ts +33 -0
  89. package/templates/apps/web/src/lib/routes.ts +5 -3
  90. package/templates/package.json +3 -1
  91. package/templates/tools/body-heading.ts +440 -0
  92. package/templates/tools/check-content-source.ts +108 -0
  93. package/templates/tools/check-mdx-body.ts +123 -0
  94. package/templates/tools/mdx-braces.ts +303 -0
  95. package/versions.json +48 -48
@@ -32,6 +32,9 @@ jest.mock("next/navigation", () => ({
32
32
 
33
33
  import OfferingPricingPage, { dynamic, generateMetadata } from "../page";
34
34
 
35
+ // #10704 — a fixture suite: its content is shaped for the mdx source, whatever the site serves from.
36
+ process.env.CONTENT_ADAPTER = "mdx";
37
+
35
38
  const SLUG = "website-rebuild";
36
39
  const SITE_EMAIL = "hello@example.com";
37
40
 
@@ -27,6 +27,9 @@ import {
27
27
 
28
28
  import PricingPage, { generateMetadata } from "../page";
29
29
 
30
+ // #10704 — a fixture suite: its content is shaped for the mdx source, whatever the site serves from.
31
+ process.env.CONTENT_ADAPTER = "mdx";
32
+
30
33
  const SITE_EMAIL = "hello@example.com";
31
34
  const HOSTILE = "</script><script>alert(1)</script>";
32
35
 
@@ -15,6 +15,9 @@ import { act, render, screen, within } from "@testing-library/react";
15
15
 
16
16
  import PricingPage from "../page";
17
17
 
18
+ // #10704 — a fixture suite: its content is shaped for the mdx source, whatever the site serves from.
19
+ process.env.CONTENT_ADAPTER = "mdx";
20
+
18
21
  const PLANS = [
19
22
  {
20
23
  key: "basic",
@@ -27,6 +27,9 @@ import { TOPOLOGY } from "../../../../lib/topology.generated";
27
27
  import * as pricingPage from "../page";
28
28
  import PricingPage, { generateMetadata } from "../page";
29
29
 
30
+ // #10704 — a fixture suite: its content is shaped for the mdx source, whatever the site serves from.
31
+ process.env.CONTENT_ADAPTER = "mdx";
32
+
30
33
  const PLANS = [
31
34
  {
32
35
  key: "basic",
@@ -51,6 +51,9 @@ import { notFound } from "next/navigation";
51
51
  import ProjectsPage from "../page";
52
52
  import ProjectSlugPage from "../[slug]/page";
53
53
 
54
+ // #10704 — a fixture suite: its content is shaped for the mdx source, whatever the site serves from.
55
+ process.env.CONTENT_ADAPTER = "mdx";
56
+
54
57
  const mockNotFound = notFound as jest.MockedFunction<typeof notFound>;
55
58
 
56
59
  // ── Shared stub helpers ────────────────────────────────────────────────────────
@@ -31,6 +31,9 @@ import ServicesPage from "../page";
31
31
  import ServiceSlugPage from "../[slug]/page";
32
32
  import * as pageRenderer from "@working-theory/page-renderer";
33
33
 
34
+ // #10704 — a fixture suite: its content is shaped for the mdx source, whatever the site serves from.
35
+ process.env.CONTENT_ADAPTER = "mdx";
36
+
34
37
  const mockNotFound = notFound as jest.MockedFunction<typeof notFound>;
35
38
 
36
39
  // ── Shared stub helpers ────────────────────────────────────────────────────────
@@ -31,6 +31,9 @@ import TeamPage from "../page";
31
31
  import TeamMemberPage from "../[slug]/page";
32
32
  import * as pageRenderer from "@working-theory/page-renderer";
33
33
 
34
+ // #10704 — a fixture suite: its content is shaped for the mdx source, whatever the site serves from.
35
+ process.env.CONTENT_ADAPTER = "mdx";
36
+
34
37
  type JsonLd = Record<string, unknown>;
35
38
 
36
39
  function jsonLdScripts(container: HTMLElement): JsonLd[] {
@@ -52,10 +52,10 @@ jest.mock("../AccountInviteLink", () => ({
52
52
  }),
53
53
  }));
54
54
 
55
- import { readFileSync } from "node:fs";
55
+ import { existsSync, readFileSync } from "node:fs";
56
56
  import { join } from "node:path";
57
57
  import { notFound } from "next/navigation";
58
- import ThankYouSlugPage from "../page";
58
+ import ThankYouSlugPage, { generateMetadata } from "../page";
59
59
 
60
60
  const mockNotFound = notFound as jest.MockedFunction<typeof notFound>;
61
61
 
@@ -132,3 +132,22 @@ describe("ThankYouSlugPage — detail route", () => {
132
132
  expect(source).not.toMatch(/["']use client["']/);
133
133
  });
134
134
  });
135
+
136
+ // #10617 AC-1 — the route's own metadata, read from the real seed, carries
137
+ // the noindex directive the page-renderer gives every ThankYouPage.
138
+ // The scaffold matrix runs this suite inside a scaffold that ships no
139
+ // flow-thanks seed (#10763), so the test skips where the seed does not exist.
140
+ const SEED = join(
141
+ __dirname, "..", "..", "..", "..", "..", "..", "..",
142
+ "content", "en", "ThankYouPage", "flow-thanks.mdx",
143
+ );
144
+ const itWithSeed = existsSync(SEED) ? it : it.skip;
145
+
146
+ describe("ThankYouSlugPage — generateMetadata (#10617)", () => {
147
+ itWithSeed("carries robots noindex, follow", async () => {
148
+ const metadata = await generateMetadata({
149
+ params: Promise.resolve({ locale: "en", slug: "flow-thanks" }),
150
+ });
151
+ expect(metadata.robots).toEqual({ index: false, follow: true });
152
+ });
153
+ });
@@ -4,6 +4,7 @@
4
4
  // header says why there is no static-params export (#7379). Unknown slugs 404
5
5
  // via renderPage's own detail-route branch (no translationsNamespace passed
6
6
  // — renderPage calls notFound() itself when getMdxSource resolves null).
7
+ // `renderPageMetadata` makes every ThankYouPage `noindex, follow` (#10617).
7
8
  import type { getMdxSource as defaultGetMdxSource } from "@working-theory/page-renderer";
8
9
  import { renderPage, renderPageMetadata } from "@working-theory/page-renderer";
9
10
  import type { Metadata } from "next";
@@ -25,6 +25,9 @@
25
25
  * the docs path; otherwise those rows are skipped (the MDX docs route never
26
26
  * serves them) and the docs directory is listed (`getDocsPages`, default
27
27
  * `listDocsPages`).
28
+ *
29
+ * Blog (#10644 D1): under the database adapter the adapter's `BlogList` /
30
+ * `BlogPosting` rows are the blog, and `getBlogPages` is not called.
28
31
  */
29
32
 
30
33
  import { siteDiscoverable } from "@working-theory/api-client";
@@ -59,6 +62,8 @@ export interface LlmsHandlerDeps {
59
62
  * Blog pages getter. Defaults to none — Free has no blog (D-46, #5365 D9).
60
63
  * The docs + blog module's own copy of this route reads `blogSource`.
61
64
  * Override in tests to inject stub pages.
65
+ * Not called under the database adapter, whose `BlogList` / `BlogPosting`
66
+ * rows are the blog (#10644 D1).
62
67
  */
63
68
  getBlogPages?: () => Array<{ url: string; data: { title: string; description?: string } }>;
64
69
  /** The docs pages (#10041). Defaults to `listDocsPages` over this app's docs directory. */
@@ -97,10 +102,10 @@ export function createLlmsHandler(deps: LlmsHandlerDeps = {}) {
97
102
  types.map((type) => adapter.listPages(type, defaultLocale)),
98
103
  );
99
104
 
100
- const databaseDocs = resolvedAdapterKind() === "database";
105
+ const databaseCorpus = resolvedAdapterKind() === "database";
101
106
  for (const pages of pageLists) {
102
107
  for (const page of pages) {
103
- if (isDocsType(page.type) && !databaseDocs) continue;
108
+ if (isDocsType(page.type) && !databaseCorpus) continue;
104
109
  const path = isDocsType(page.type)
105
110
  ? adapterDocsUrl(page.type, page.slug)
106
111
  : pagePathFor(page);
@@ -118,18 +123,20 @@ export function createLlmsHandler(deps: LlmsHandlerDeps = {}) {
118
123
  // blogSource.getPages() returns pages ordered by Fumadocs; the index page
119
124
  // (/blog) sorts first naturally because its slug array is empty.
120
125
  lines.push("");
121
- for (const page of resolveBlogPages()) {
122
- lines.push(`- Title: ${page.data.title}`);
123
- lines.push(` URL: ${baseUrl}${page.url}`);
124
- if (page.data.description) {
125
- lines.push(` Description: ${page.data.description}`);
126
+ if (!databaseCorpus) {
127
+ for (const page of resolveBlogPages()) {
128
+ lines.push(`- Title: ${page.data.title}`);
129
+ lines.push(` URL: ${baseUrl}${page.url}`);
130
+ if (page.data.description) {
131
+ lines.push(` Description: ${page.data.description}`);
132
+ }
133
+ lines.push("");
126
134
  }
127
- lines.push("");
128
135
  }
129
136
 
130
137
  // The docs directory (#10041 D8) — not under the database adapter, whose
131
138
  // docs rows are listed above.
132
- if (!databaseDocs) {
139
+ if (!databaseCorpus) {
133
140
  for (const doc of resolveDocsPages()) {
134
141
  lines.push(`- Title: ${doc.title}`);
135
142
  lines.push(` URL: ${baseUrl}${doc.url}`);
@@ -152,6 +152,9 @@ export function createSitemapHandler(deps: SitemapHandlerDeps = {}) {
152
152
  }
153
153
  continue;
154
154
  }
155
+ // #10617 — a flow's completion target renders `robots: noindex`
156
+ // (`renderPageMetadata`), so it is never listed, in any locale.
157
+ if (page.type === "ThankYouPage") continue;
155
158
  const path = pagePathFor(page);
156
159
  const lastmod = page.publishedAt ?? today;
157
160
 
@@ -1,5 +1,50 @@
1
1
  // Mirrors apps/api's ts-jest setup (CONVENTIONS.md mentions Vitest aspirationally,
2
2
  // but no Vitest is installed anywhere yet). JSDOM env so RTL can mount components.
3
+ /**
4
+ * #10699 D4: lib/runtime-source.ts derives the toc through
5
+ * fumadocs-core/content/toc, whose remark closure ships ESM only. These are
6
+ * pnpm-store name prefixes (the `tools/jest/mdxeditor-esm.cjs` shape, inlined
7
+ * because a scaffold's copy of this file has no `tools/`): `mdast-util-`
8
+ * covers every `mdast-util-*` package, `remark` covers `remark-gfm` /
9
+ * `remark-parse`, `vfile` covers `vfile-message`. Re-derive from the
10
+ * installed tree when the lockfile moves; the runtime-source suites loading
11
+ * is the proof.
12
+ */
13
+ const REMARK_TOC_ESM = [
14
+ "bail",
15
+ "ccount",
16
+ "character-",
17
+ "decode-named-character-reference",
18
+ "devlop",
19
+ "escape-string-regexp",
20
+ "estree-util-",
21
+ "github-slugger",
22
+ "is-plain-obj",
23
+ "longest-streak",
24
+ "markdown-table",
25
+ "mdast-util-",
26
+ "micromark",
27
+ "remark",
28
+ "trough",
29
+ "unified",
30
+ "unist-util-",
31
+ "vfile",
32
+ "zwitch",
33
+ ];
34
+
35
+ /**
36
+ * Admit REMARK_TOC_ESM beside whatever a carve-out already admits, leaving
37
+ * the carve-out's own literal untouched — create-ailk's template build and the
38
+ * docs-blog `jest-config` patch both find it by exact string.
39
+ */
40
+ function admitRemarkToc(pattern) {
41
+ const head = "/node_modules/(?!(\\.pnpm/)?";
42
+ return pattern.replace(
43
+ head,
44
+ `${head}(?:${REMARK_TOC_ESM.join("|")})|(\\.pnpm/)?`,
45
+ );
46
+ }
47
+
3
48
  module.exports = {
4
49
  preset: "ts-jest",
5
50
  globalSetup: require.resolve("../../tools/jest/pin-timezone.cjs"),
@@ -17,7 +62,7 @@ module.exports = {
17
62
  // Mirrors the canonical jose carve-out used in apps/api, apps/mcp, and apps-paid/license-issuer.
18
63
  // Added: issue #1077
19
64
  transformIgnorePatterns: [
20
- "/node_modules/(?!(\\.pnpm/)?@working-theory(?:\\+[^/]*?@?[^/]*?|/[^/]+)(/|$))",
65
+ admitRemarkToc("/node_modules/(?!(\\.pnpm/)?@working-theory(?:\\+[^/]*?@?[^/]*?|/[^/]+)(/|$))"),
21
66
  ],
22
67
  moduleFileExtensions: ["ts", "tsx", "js", "jsx", "json"],
23
68
  // Workspace packages publish ESM in dist/, but ts-jest runs in CommonJS.
@@ -14,6 +14,13 @@ jest.mock("botid/server", () => ({
14
14
  checkBotId: (...args: unknown[]) => checkBotIdMock(...args),
15
15
  }));
16
16
 
17
+ // The request's headers (#10715): every case decides them here, a direct
18
+ // request with no proxy marker unless the case says otherwise.
19
+ const headersMock = jest.fn();
20
+ jest.mock("next/headers", () => ({
21
+ headers: (...args: unknown[]) => headersMock(...args),
22
+ }));
23
+
17
24
  const BOT = {
18
25
  isHuman: false,
19
26
  isBot: true,
@@ -42,6 +49,8 @@ let warnSpy: jest.SpyInstance;
42
49
 
43
50
  beforeEach(() => {
44
51
  checkBotIdMock.mockReset();
52
+ headersMock.mockReset();
53
+ headersMock.mockResolvedValue(new Headers({ host: "spoke.example" }));
45
54
  for (const name of ENV_KEYS) delete process.env[name];
46
55
  process.env.VERCEL = "1";
47
56
  errorSpy = jest.spyOn(console, "error").mockImplementation(() => {});
@@ -78,10 +87,13 @@ describe("AC-1 — runBotCheck's verdict (#9889 D3, one rule for every write)",
78
87
  checkBotIdMock.mockResolvedValue(verdict);
79
88
  expect(await runBotCheck(CHECK)).toBe("bot");
80
89
  expect(warnSpy).toHaveBeenCalledTimes(1);
81
- expect(warnSpy).toHaveBeenCalledWith("[bot-check] bot refused", {
82
- path: "testAction",
83
- outcome: "bot-detected",
84
- });
90
+ expect(warnSpy).toHaveBeenCalledWith(
91
+ "[bot-check] bot refused",
92
+ expect.objectContaining({
93
+ path: "testAction",
94
+ outcome: "bot-detected",
95
+ }),
96
+ );
85
97
  },
86
98
  );
87
99
 
@@ -252,3 +264,234 @@ describe("AC-4 — the switches and BotID's client list", () => {
252
264
  expect(compileBotIdPath(entry?.path ?? "").test(pathname)).toBe(true);
253
265
  });
254
266
  });
267
+
268
+ describe("#10715 — a refusal names its cause", () => {
269
+ const DIRECT = { host: "spoke.example" };
270
+ const PROXIED = {
271
+ host: "spoke.example",
272
+ "x-forwarded-host": "www.spoke.example",
273
+ "cf-ray": "8f2b-CLE",
274
+ cookie: "session=secret",
275
+ "x-is-human": "challenge-token",
276
+ };
277
+ const VERDICT = {
278
+ ...BOT,
279
+ verifiedBotName: undefined,
280
+ verifiedBotCategory: undefined,
281
+ classificationReason: "integrity-check-failed",
282
+ };
283
+ const FIX =
284
+ "Serve the site direct to Vercel: set its Cloudflare DNS record to DNS-only (no orange cloud), never behind a reverse proxy (#10715).";
285
+
286
+ /** Runs `run` in a fresh copy of the module, so its once-per-process flag starts unset. */
287
+ async function inFreshModule(
288
+ run: (fresh: typeof import("../bot-check")) => Promise<void>,
289
+ ): Promise<void> {
290
+ await jest.isolateModulesAsync(async () => {
291
+ await run(await import("../bot-check"));
292
+ });
293
+ }
294
+
295
+ it("a direct refusal logs the full shape: the verdict's detail, both hosts and proxied false", async () => {
296
+ headersMock.mockResolvedValue(new Headers(DIRECT));
297
+ checkBotIdMock.mockResolvedValue(VERDICT);
298
+ await inFreshModule(async (fresh) => {
299
+ expect(await fresh.runBotCheck(CHECK)).toBe("bot");
300
+ });
301
+ expect(warnSpy).toHaveBeenCalledTimes(1);
302
+ expect(warnSpy.mock.calls[0]).toStrictEqual([
303
+ "[bot-check] bot refused",
304
+ {
305
+ path: "testAction",
306
+ outcome: "bot-detected",
307
+ classificationReason: "integrity-check-failed",
308
+ bypassed: false,
309
+ isVerifiedBot: false,
310
+ host: "spoke.example",
311
+ forwardedHost: null,
312
+ proxied: false,
313
+ },
314
+ ]);
315
+ expect(errorSpy).not.toHaveBeenCalled();
316
+ });
317
+
318
+ it("a proxied refusal logs proxied true and no header value but the two hosts", async () => {
319
+ headersMock.mockResolvedValue(new Headers(PROXIED));
320
+ checkBotIdMock.mockResolvedValue({ ...VERDICT, isVerifiedBot: true });
321
+ await inFreshModule(async (fresh) => {
322
+ expect(await fresh.runBotCheck(CHECK)).toBe("bot");
323
+ });
324
+ expect(warnSpy).toHaveBeenCalledTimes(1);
325
+ expect(warnSpy.mock.calls[0]).toStrictEqual([
326
+ "[bot-check] bot refused",
327
+ {
328
+ path: "testAction",
329
+ outcome: "bot-detected",
330
+ classificationReason: "integrity-check-failed",
331
+ bypassed: false,
332
+ isVerifiedBot: true,
333
+ host: "spoke.example",
334
+ forwardedHost: "www.spoke.example",
335
+ proxied: true,
336
+ },
337
+ ]);
338
+ const logged = JSON.stringify([warnSpy.mock.calls, errorSpy.mock.calls]);
339
+ for (const secret of ["8f2b-CLE", "session=secret", "challenge-token"]) {
340
+ expect(logged).not.toContain(secret);
341
+ }
342
+ });
343
+
344
+ it("a verdict with no reason logs classificationReason null", async () => {
345
+ checkBotIdMock.mockResolvedValue(BOT);
346
+ await inFreshModule(async (fresh) => {
347
+ expect(await fresh.runBotCheck(CHECK)).toBe("bot");
348
+ });
349
+ expect(warnSpy.mock.calls[0]?.[1]).toMatchObject({
350
+ classificationReason: null,
351
+ proxied: false,
352
+ });
353
+ });
354
+
355
+ it("headers it cannot read log null hosts and proxied false, and still refuse", async () => {
356
+ headersMock.mockImplementation(() => {
357
+ throw new Error("outside a request scope");
358
+ });
359
+ checkBotIdMock.mockResolvedValue(BOT);
360
+ await inFreshModule(async (fresh) => {
361
+ expect(await fresh.runBotCheck(CHECK)).toBe("bot");
362
+ });
363
+ expect(warnSpy.mock.calls[0]?.[1]).toMatchObject({
364
+ host: null,
365
+ forwardedHost: null,
366
+ proxied: false,
367
+ });
368
+ expect(errorSpy).not.toHaveBeenCalled();
369
+ });
370
+
371
+ it.each([
372
+ ["a human", HUMAN, "pass"],
373
+ ["a bot", BOT, "bot"],
374
+ ] as const)(
375
+ "the first proxied check (%s) logs the reverse-proxy error once per process, naming the fix",
376
+ async (_label, verdict, outcome) => {
377
+ headersMock.mockResolvedValue(new Headers(PROXIED));
378
+ checkBotIdMock.mockResolvedValue(verdict);
379
+ await inFreshModule(async (fresh) => {
380
+ for (let i = 0; i < 3; i++) {
381
+ expect(await fresh.runBotCheck(CHECK)).toBe(outcome);
382
+ }
383
+ });
384
+ expect(errorSpy).toHaveBeenCalledTimes(1);
385
+ expect(errorSpy.mock.calls[0]).toStrictEqual([
386
+ "[bot-check] reverse proxy detected",
387
+ {
388
+ path: "testAction",
389
+ outcome: "proxied",
390
+ host: "spoke.example",
391
+ forwardedHost: "www.spoke.example",
392
+ fix: FIX,
393
+ },
394
+ ]);
395
+ },
396
+ );
397
+
398
+ it("a proxied check that throws still logs the reverse-proxy error, then the failure", async () => {
399
+ headersMock.mockResolvedValue(new Headers(PROXIED));
400
+ checkBotIdMock.mockRejectedValue(new Error("token abc"));
401
+ await inFreshModule(async (fresh) => {
402
+ expect(await fresh.runBotCheck(CHECK)).toBe("unavailable");
403
+ });
404
+ expect(errorSpy.mock.calls.map(([message]) => message)).toEqual([
405
+ "[bot-check] reverse proxy detected",
406
+ "[bot-check] bot check failed",
407
+ ]);
408
+ });
409
+
410
+ it("a direct check never logs the reverse-proxy error", async () => {
411
+ headersMock.mockResolvedValue(new Headers(DIRECT));
412
+ checkBotIdMock.mockResolvedValue(HUMAN);
413
+ await inFreshModule(async (fresh) => {
414
+ expect(await fresh.runBotCheck(CHECK)).toBe("pass");
415
+ });
416
+ expect(errorSpy).not.toHaveBeenCalled();
417
+ expect(warnSpy).not.toHaveBeenCalled();
418
+ });
419
+
420
+ it("proxied changes no verdict: a proxied human passes, a proxied bot refuses", async () => {
421
+ headersMock.mockResolvedValue(new Headers(PROXIED));
422
+ await inFreshModule(async (fresh) => {
423
+ checkBotIdMock.mockResolvedValue(HUMAN);
424
+ expect(await fresh.runBotCheck(CHECK)).toBe("pass");
425
+ checkBotIdMock.mockResolvedValue(BOT);
426
+ expect(await fresh.runBotCheck(CHECK)).toBe("bot");
427
+ });
428
+ });
429
+ });
430
+
431
+ describe("#10715 — AILK_BOT_CHECK=observe runs the check and never refuses", () => {
432
+ it.each(["observe", " OBSERVE ", "Observe"])(
433
+ "AILK_BOT_CHECK=%j leaves the check on for both scopes",
434
+ (value) => {
435
+ process.env.AILK_BOT_CHECK = value;
436
+ expect(botCheckOn("write")).toBe(true);
437
+ expect(botCheckOn("booking")).toBe(true);
438
+ },
439
+ );
440
+
441
+ it.each([
442
+ ["a bot", BOT, true],
443
+ ["a human", HUMAN, false],
444
+ ] as const)(
445
+ "%s passes for every scope, logging the facts and isBot as observed",
446
+ async (_label, verdict, isBot) => {
447
+ process.env.AILK_BOT_CHECK = " Observe ";
448
+ headersMock.mockResolvedValue(new Headers({ host: "spoke.example" }));
449
+ checkBotIdMock.mockResolvedValue({
450
+ ...verdict,
451
+ classificationReason: "reason-x",
452
+ });
453
+ for (const scope of ["write", "booking"] as const) {
454
+ expect(await runBotCheck({ ...CHECK, scope })).toBe("pass");
455
+ }
456
+ expect(checkBotIdMock).toHaveBeenCalledTimes(2);
457
+ expect(warnSpy).toHaveBeenCalledTimes(2);
458
+ expect(warnSpy.mock.calls[0]).toStrictEqual([
459
+ "[bot-check] bot check observed",
460
+ {
461
+ path: "testAction",
462
+ outcome: "observed",
463
+ isBot,
464
+ classificationReason: "reason-x",
465
+ bypassed: false,
466
+ isVerifiedBot: false,
467
+ host: "spoke.example",
468
+ forwardedHost: null,
469
+ proxied: false,
470
+ },
471
+ ]);
472
+ expect(errorSpy).not.toHaveBeenCalled();
473
+ },
474
+ );
475
+
476
+ it("a check that throws logs the failure and passes", async () => {
477
+ process.env.AILK_BOT_CHECK = "observe";
478
+ checkBotIdMock.mockRejectedValue(new Error("token abc"));
479
+ expect(await runBotCheck(CHECK)).toBe("pass");
480
+ expect(errorSpy).toHaveBeenCalledTimes(1);
481
+ expect(errorSpy).toHaveBeenCalledWith("[bot-check] bot check failed", {
482
+ path: "testAction",
483
+ outcome: "bot-check-failed",
484
+ error: "Error",
485
+ });
486
+ expect(warnSpy).not.toHaveBeenCalled();
487
+ });
488
+
489
+ it("AILK_BOOKING_BOT_CHECK=off still passes the booking without asking BotID", async () => {
490
+ process.env.AILK_BOT_CHECK = "observe";
491
+ process.env.AILK_BOOKING_BOT_CHECK = "off";
492
+ checkBotIdMock.mockResolvedValue(BOT);
493
+ expect(await runBotCheck({ ...CHECK, scope: "booking" })).toBe("pass");
494
+ expect(checkBotIdMock).not.toHaveBeenCalled();
495
+ expect(warnSpy).not.toHaveBeenCalled();
496
+ });
497
+ });
@@ -0,0 +1,92 @@
1
+ /**
2
+ * @jest-environment node
3
+ */
4
+ // #10553 — with AILK_API_URL unset the API client falls back to
5
+ // localhost:3001; a site server bound to 3001 then fetched its own
6
+ // /v1/flows/newsletter, whose render (the footer reads the magnet on every
7
+ // page) fetched it again, without end. The page's flow read now makes no
8
+ // request to its own address and renders without the magnet, and a hub that
9
+ // does not answer is asked once however many renders read it.
10
+ //
11
+ // Through the real `@working-theory/api-client` (jest maps it to source) with
12
+ // the global `fetch` stubbed; the Server Action modules are stubbed as in
13
+ // in-page-magnet.test.ts.
14
+
15
+ export {};
16
+
17
+ // eslint-disable-next-line no-restricted-syntax -- test-seam mock: a "use server" boundary the module only binds
18
+ jest.mock("../../app/[locale]/contact/contact.actions", () => ({
19
+ captureLeadAction: jest.fn(),
20
+ }));
21
+ // eslint-disable-next-line no-restricted-syntax -- test-seam mock: a "use server" boundary the module only binds
22
+ jest.mock("../newsletter.actions", () => ({
23
+ subscribeNewsletterAction: jest.fn(),
24
+ }));
25
+
26
+ const ENV_KEYS = [
27
+ "AILK_API_URL",
28
+ "NEXT_PUBLIC_AILK_API_URL",
29
+ "NEXT_PUBLIC_API_URL",
30
+ "AILK_SPOKE_KEY",
31
+ "__NEXT_PRIVATE_ORIGIN",
32
+ ] as const;
33
+ const priorEnv = new Map<string, string | undefined>(
34
+ ENV_KEYS.map((name) => [name, process.env[name]]),
35
+ );
36
+ const priorFetch = global.fetch;
37
+ const fetchMock = jest.fn(async (_url: string) => {
38
+ throw new TypeError("fetch failed");
39
+ });
40
+
41
+ /** A fresh `in-page-magnet` module, so the shared read starts empty. */
42
+ function freshModule(): typeof import("../in-page-magnet") {
43
+ let mod: typeof import("../in-page-magnet") | undefined;
44
+ jest.isolateModules(() => {
45
+ mod = jest.requireActual("../in-page-magnet");
46
+ });
47
+ if (mod === undefined) throw new Error("in-page-magnet did not load");
48
+ return mod;
49
+ }
50
+
51
+ beforeEach(() => {
52
+ for (const name of ENV_KEYS) delete process.env[name];
53
+ fetchMock.mockClear();
54
+ global.fetch = fetchMock as jest.Mock;
55
+ });
56
+
57
+ afterAll(() => {
58
+ global.fetch = priorFetch;
59
+ for (const [name, value] of priorEnv) {
60
+ if (value === undefined) delete process.env[name];
61
+ else process.env[name] = value;
62
+ }
63
+ });
64
+
65
+ describe("the flow read with AILK_API_URL unset (#10553)", () => {
66
+ it("on a server bound to the fallback's port, makes no request to its own /v1 and renders without the magnet", async () => {
67
+ process.env.__NEXT_PRIVATE_ORIGIN = "http://localhost:3001";
68
+ const { readServedMagnet } = freshModule();
69
+
70
+ const renders = await Promise.all(
71
+ Array.from({ length: 20 }, () => readServedMagnet("newsletter")),
72
+ );
73
+
74
+ expect(renders.every((config) => config === null)).toBe(true);
75
+ expect(fetchMock).not.toHaveBeenCalled();
76
+ });
77
+
78
+ it("on any other port, asks the unanswering fallback once for the flow and renders without it", async () => {
79
+ process.env.__NEXT_PRIVATE_ORIGIN = "http://localhost:3000";
80
+ const { readServedMagnet } = freshModule();
81
+
82
+ const first = await readServedMagnet("newsletter");
83
+ const again = await Promise.all(
84
+ Array.from({ length: 20 }, () => readServedMagnet("newsletter")),
85
+ );
86
+
87
+ expect(first).toBeNull();
88
+ expect(again.every((config) => config === null)).toBe(true);
89
+ expect(fetchMock).toHaveBeenCalledTimes(1);
90
+ expect(fetchMock.mock.calls[0]?.[0]).toBe("http://localhost:3001/v1/flows/newsletter");
91
+ });
92
+ });