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.
- package/README.md +108 -4
- package/component-catalog.json +524 -8
- package/component-catalog.md +8 -5
- package/dist/body-heading.d.ts +13 -0
- package/dist/body-heading.js +19 -5
- package/dist/cli.js +99 -0
- package/dist/cutover.d.ts +114 -0
- package/dist/cutover.js +511 -0
- package/dist/design-presets.js +20 -4
- package/dist/export-content.d.ts +55 -0
- package/dist/export-content.js +303 -0
- package/dist/inventory.d.ts +216 -0
- package/dist/inventory.js +833 -0
- package/dist/language-import.d.ts +127 -0
- package/dist/language-import.js +451 -0
- package/dist/lib/env-catalog.js +1 -1
- package/dist/lib/import-mdx.d.ts +38 -0
- package/dist/lib/import-mdx.js +445 -65
- package/dist/lib/render-comparison.d.ts +251 -0
- package/dist/lib/render-comparison.js +573 -0
- package/dist/mdx-braces.d.ts +73 -0
- package/dist/mdx-braces.js +285 -0
- package/dist/migrate-content.d.ts +6 -2
- package/dist/migrate-content.js +62 -14
- package/dist/programmatic.js +2 -8
- package/dist/seed-workspace.d.ts +3 -1
- package/dist/seed-workspace.js +45 -16
- package/dist/site-manifest.js +6 -38
- package/dist/site-roster.d.ts +46 -3
- package/dist/site-roster.js +124 -4
- package/dist/surfaces.d.ts +8 -0
- package/dist/surfaces.js +16 -0
- package/dist/topology.d.ts +12 -0
- package/dist/topology.js +24 -10
- package/dist/update.d.ts +21 -1
- package/dist/update.js +142 -11
- package/package.json +3 -3
- package/templates/.claude/cloudparity-repo-hooks.json +5 -0
- package/templates/.claude/hooks/content-source-guard.sh +75 -0
- package/templates/.claude/settings.json +9 -0
- package/templates/.github/workflows/ci.yml +4 -0
- package/templates/CLAUDE.md +5 -0
- package/templates/apps/mcp/.env.example +7 -1
- package/templates/apps/mcp/CLAUDE.md +8 -4
- package/templates/apps/web/.env.example +4 -2
- package/templates/apps/web/CLAUDE.md +16 -5
- package/templates/apps/web/__tests__/setup.ts +9 -0
- package/templates/apps/web/app/[locale]/[slug]/[detail]/__tests__/page.test.tsx +156 -4
- package/templates/apps/web/app/[locale]/[slug]/[detail]/page.tsx +45 -2
- package/templates/apps/web/app/[locale]/[slug]/__tests__/page.test.tsx +3 -0
- package/templates/apps/web/app/[locale]/about/__tests__/about-routes.test.tsx +3 -0
- package/templates/apps/web/app/[locale]/careers/__tests__/page-renderer-migration.test.tsx +3 -0
- package/templates/apps/web/app/[locale]/contact/__tests__/site-contact-lines.test.ts +49 -0
- package/templates/apps/web/app/[locale]/contact/site-contact-lines.ts +6 -4
- package/templates/apps/web/app/[locale]/events/[slug]/page.tsx +13 -1
- package/templates/apps/web/app/[locale]/events/__tests__/program-event.test.tsx +120 -0
- package/templates/apps/web/app/[locale]/events/page.tsx +11 -0
- package/templates/apps/web/app/[locale]/flows/[slug]/FlowStepperClient.tsx +44 -5
- package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/FlowStepperClient.thanks-by-tier.test.tsx +306 -0
- package/templates/apps/web/app/[locale]/layout.tsx +122 -27
- package/templates/apps/web/app/[locale]/pricing/[slug]/__tests__/page.test.tsx +3 -0
- package/templates/apps/web/app/[locale]/pricing/__tests__/offerings.test.tsx +3 -0
- package/templates/apps/web/app/[locale]/pricing/__tests__/page.test.tsx +3 -0
- package/templates/apps/web/app/[locale]/pricing/__tests__/unconnected.test.tsx +3 -0
- package/templates/apps/web/app/[locale]/projects/__tests__/page-renderer-migration.test.tsx +3 -0
- package/templates/apps/web/app/[locale]/services/__tests__/page-renderer-migration.test.tsx +3 -0
- package/templates/apps/web/app/[locale]/team/__tests__/team-routes.test.tsx +3 -0
- package/templates/apps/web/app/[locale]/thanks/[slug]/__tests__/page.test.tsx +21 -2
- package/templates/apps/web/app/[locale]/thanks/[slug]/page.tsx +1 -0
- package/templates/apps/web/app/llms.txt/route.ts +16 -9
- package/templates/apps/web/app/sitemap.xml/route.ts +3 -0
- package/templates/apps/web/jest.config.cjs +46 -1
- package/templates/apps/web/lib/__tests__/bot-check.test.ts +247 -4
- package/templates/apps/web/lib/__tests__/in-page-magnet-self-request.test.ts +92 -0
- package/templates/apps/web/lib/__tests__/page-chrome.test.ts +93 -0
- package/templates/apps/web/lib/__tests__/relay.test.ts +33 -8
- package/templates/apps/web/lib/__tests__/site-identity-feature-list.test.ts +196 -0
- package/templates/apps/web/lib/__tests__/structured-data.test.tsx +145 -11
- package/templates/apps/web/lib/bot-check.ts +113 -13
- package/templates/apps/web/lib/in-page-magnet.ts +4 -1
- package/templates/apps/web/lib/page-chrome.ts +65 -5
- package/templates/apps/web/lib/relay.ts +22 -3
- package/templates/apps/web/lib/site-identity.ts +75 -24
- package/templates/apps/web/messages/en.json +5 -1
- package/templates/apps/web/next.config.mjs +15 -1
- package/templates/apps/web/package.json +1 -1
- package/templates/apps/web/scripts/assert-content-bundled.ts +36 -8
- package/templates/apps/web/scripts/content-snapshot.ts +33 -0
- package/templates/apps/web/src/lib/routes.ts +5 -3
- package/templates/package.json +3 -1
- package/templates/tools/body-heading.ts +440 -0
- package/templates/tools/check-content-source.ts +108 -0
- package/templates/tools/check-mdx-body.ts +123 -0
- package/templates/tools/mdx-braces.ts +303 -0
- 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
|
|
105
|
+
const databaseCorpus = resolvedAdapterKind() === "database";
|
|
101
106
|
for (const pages of pageLists) {
|
|
102
107
|
for (const page of pages) {
|
|
103
|
-
if (isDocsType(page.type) && !
|
|
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
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
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 (!
|
|
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(
|
|
82
|
-
|
|
83
|
-
|
|
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
|
+
});
|