create-ailk 0.29.1 → 0.31.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 +71 -3
- package/component-catalog.json +524 -8
- package/component-catalog.md +8 -5
- package/dist/add-route.js +1 -0
- package/dist/cli.js +114 -1
- package/dist/cutover.d.ts +109 -0
- package/dist/cutover.js +494 -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 +114 -0
- package/dist/language-import.js +435 -0
- package/dist/lib/env-catalog.js +2 -2
- package/dist/lib/import-mdx.d.ts +38 -0
- package/dist/lib/import-mdx.js +411 -64
- package/dist/lib/render-comparison.d.ts +200 -0
- package/dist/lib/render-comparison.js +374 -0
- package/dist/lib/scaffold-render-sweep.d.ts +1 -1
- package/dist/lib/scaffold-render-sweep.js +1 -1
- package/dist/migrate-content.d.ts +6 -2
- package/dist/migrate-content.js +60 -14
- package/dist/module-architecture.js +6 -1
- package/dist/provision-env.d.ts +28 -0
- package/dist/provision-env.js +210 -0
- package/dist/seed-workspace.d.ts +3 -1
- package/dist/seed-workspace.js +45 -16
- package/dist/surfaces.js +3 -0
- package/dist/topology.d.ts +12 -0
- package/dist/topology.js +28 -10
- 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/.env.example +7 -0
- package/templates/.github/workflows/ci.yml +2 -0
- package/templates/CLAUDE.md +5 -0
- package/templates/apps/api/CLAUDE.md +6 -2
- package/templates/apps/mcp/.env.example +20 -0
- package/templates/apps/mcp/CLAUDE.md +46 -8
- package/templates/apps/web/.env.example +4 -2
- package/templates/apps/web/CLAUDE.md +39 -5
- package/templates/apps/web/app/.well-known/ai-plugin.json/route.ts +9 -1
- package/templates/apps/web/app/.well-known/mcp.json/build-manifest.ts +58 -0
- package/templates/apps/web/app/.well-known/mcp.json/route.ts +16 -0
- package/templates/apps/web/app/.well-known/oauth-authorization-server/route.ts +53 -0
- package/templates/apps/web/app/[locale]/(auth)/__tests__/password-reset.test.tsx +8 -1
- package/templates/apps/web/app/[locale]/(auth)/consent/__tests__/consent-actions.test.ts +56 -0
- package/templates/apps/web/app/[locale]/(auth)/consent/__tests__/page.test.tsx +164 -0
- package/templates/apps/web/app/[locale]/(auth)/consent/consent-form.tsx +132 -0
- package/templates/apps/web/app/[locale]/(auth)/consent/consent.actions.ts +51 -0
- package/templates/apps/web/app/[locale]/(auth)/consent/page.tsx +144 -0
- package/templates/apps/web/app/[locale]/[slug]/[detail]/__tests__/page.test.tsx +153 -4
- package/templates/apps/web/app/[locale]/[slug]/[detail]/page.tsx +45 -2
- package/templates/apps/web/app/[locale]/about/__tests__/about-routes.test.tsx +2 -2
- 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]/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/api/auth/hub/continuation.ts +8 -3
- package/templates/apps/web/app/api/auth/mcp/__tests__/continuation.test.ts +92 -0
- package/templates/apps/web/app/api/auth/mcp/continuation.ts +89 -0
- package/templates/apps/web/app/api/auth/mcp/continue/[ref]/__tests__/route.test.ts +59 -0
- package/templates/apps/web/app/api/auth/mcp/continue/[ref]/route.ts +41 -0
- package/templates/apps/web/app/llms.txt/route.ts +26 -9
- package/templates/apps/web/app/openapi.json/__tests__/route.test.ts +40 -0
- package/templates/apps/web/app/openapi.json/route.ts +4 -0
- package/templates/apps/web/app/robots.txt/route.ts +18 -1
- package/templates/apps/web/app/sitemap.xml/route.ts +13 -0
- package/templates/apps/web/auth.ts +184 -7
- package/templates/apps/web/jest.config.cjs +46 -1
- package/templates/apps/web/lib/__tests__/auth-routes.test.ts +2 -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 +90 -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 +11 -1
- package/templates/apps/web/lib/auth-routes.ts +5 -3
- 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/mcp-oauth.ts +702 -0
- 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 +47 -14
- 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/database/seed.ts +6 -0
- package/templates/package.json +2 -1
- package/templates/tools/check-content-source.ts +108 -0
- package/versions.json +48 -48
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @jest-environment node
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The identity featureList under the database adapter (#10644 AC-2, D4): read
|
|
7
|
+
* through the content adapter when a page's JSON-LD is composed, never from a
|
|
8
|
+
* directory scan. Under the mdx adapter no reader is registered.
|
|
9
|
+
*/
|
|
10
|
+
import { mkdtempSync, readdirSync, rmSync, writeFileSync } from "node:fs";
|
|
11
|
+
import { tmpdir } from "node:os";
|
|
12
|
+
import { join } from "node:path";
|
|
13
|
+
|
|
14
|
+
import type { ContentAdapter } from "@working-theory/content-adapters";
|
|
15
|
+
|
|
16
|
+
import { readFeatureListFromAdapter, readSiteIdentity } from "../site-identity";
|
|
17
|
+
|
|
18
|
+
type PageRenderer = typeof import("@working-theory/page-renderer");
|
|
19
|
+
|
|
20
|
+
// A spy on every directory read, so the no-scan case can see one.
|
|
21
|
+
jest.mock("node:fs", () => {
|
|
22
|
+
const actual = jest.requireActual<typeof import("node:fs")>("node:fs");
|
|
23
|
+
return { ...actual, readdirSync: jest.fn(actual.readdirSync) };
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
const mockGetContentAdapter = jest.fn<Pick<ContentAdapter, "listPages">, []>();
|
|
27
|
+
jest.mock("@working-theory/page-renderer", () => ({
|
|
28
|
+
...jest.requireActual<PageRenderer>("@working-theory/page-renderer"),
|
|
29
|
+
getContentAdapter: () => mockGetContentAdapter(),
|
|
30
|
+
}));
|
|
31
|
+
|
|
32
|
+
type Row = { type: string; slug: string; title: string; sections?: unknown[] };
|
|
33
|
+
|
|
34
|
+
function stubAdapter(rows: Partial<Record<string, Row[]>>): Pick<ContentAdapter, "listPages"> {
|
|
35
|
+
// eslint-disable-next-line no-restricted-syntax -- a partial row stub is not a full ContentPage; the reader reads only slug, title and sections
|
|
36
|
+
return {
|
|
37
|
+
listPages: jest.fn(async (type: string) => rows[type] ?? []),
|
|
38
|
+
} as unknown as Pick<ContentAdapter, "listPages">;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const feature = (titles: string[], variant?: string): unknown => ({
|
|
42
|
+
type: "feature",
|
|
43
|
+
data: { ...(variant ? { variant } : {}), features: titles.map((title) => ({ title })) },
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
const product = (slug: string, sections: unknown[]): Row => ({
|
|
47
|
+
type: "SoftwareProduct",
|
|
48
|
+
slug,
|
|
49
|
+
title: slug,
|
|
50
|
+
sections,
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
const PRODUCT_ROWS = {
|
|
54
|
+
SoftwareProduct: [
|
|
55
|
+
product("zeta", [feature(["Z1"])]),
|
|
56
|
+
product("alpha", [feature(["A1", "A2"]), feature(["S1"], "steps")]),
|
|
57
|
+
],
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
describe("readFeatureListFromAdapter (#10644 D4)", () => {
|
|
61
|
+
it("the first product page in seed order gives its features, steps excluded", async () => {
|
|
62
|
+
expect(await readFeatureListFromAdapter(stubAdapter(PRODUCT_ROWS), "en")).toEqual(["A1", "A2"]);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it("orders by file name, so a-b.mdx comes before a.mdx", async () => {
|
|
66
|
+
const adapter = stubAdapter({
|
|
67
|
+
SoftwareProduct: [product("a", [feature(["from-a"])]), product("a-b", [feature(["from-a-b"])])],
|
|
68
|
+
});
|
|
69
|
+
expect(await readFeatureListFromAdapter(adapter, "en")).toEqual(["from-a-b"]);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it("with no product page, the Feature page titles in seed order, empty titles dropped", async () => {
|
|
73
|
+
const adapter = stubAdapter({
|
|
74
|
+
Feature: [
|
|
75
|
+
{ type: "Feature", slug: "b-feature", title: "Beta" },
|
|
76
|
+
{ type: "Feature", slug: "a-feature", title: "Alpha" },
|
|
77
|
+
{ type: "Feature", slug: "c-feature", title: "" },
|
|
78
|
+
],
|
|
79
|
+
});
|
|
80
|
+
expect(await readFeatureListFromAdapter(adapter, "en")).toEqual(["Alpha", "Beta"]);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it("with neither, an empty list", async () => {
|
|
84
|
+
expect(await readFeatureListFromAdapter(stubAdapter({}), "en")).toEqual([]);
|
|
85
|
+
});
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
describe("the featureList registration with no content directory (#10644 AC-2)", () => {
|
|
89
|
+
const ORIGIN = "https://example.com";
|
|
90
|
+
let root: string;
|
|
91
|
+
const saved = {
|
|
92
|
+
adapter: process.env.CONTENT_ADAPTER,
|
|
93
|
+
contentRoot: process.env.AILK_CONTENT_ROOT,
|
|
94
|
+
siteUrl: process.env.NEXT_PUBLIC_SITE_URL,
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
function restore(name: string, value: string | undefined): void {
|
|
98
|
+
if (value === undefined) delete process.env[name];
|
|
99
|
+
else process.env[name] = value;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
let renderer: PageRenderer | undefined;
|
|
103
|
+
|
|
104
|
+
/** Load site-identity afresh; return the page-renderer instance it registered with. */
|
|
105
|
+
function loadSiteIdentity(): PageRenderer {
|
|
106
|
+
jest.isolateModules(() => {
|
|
107
|
+
jest.requireActual("../site-identity");
|
|
108
|
+
renderer = jest.requireMock<PageRenderer>("@working-theory/page-renderer");
|
|
109
|
+
});
|
|
110
|
+
return renderer!;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const appNode = (result: Record<string, unknown>): Record<string, unknown> | undefined =>
|
|
114
|
+
((result["@graph"] ?? []) as Array<Record<string, unknown>>).find(
|
|
115
|
+
(node) => node["@type"] === "SoftwareApplication",
|
|
116
|
+
);
|
|
117
|
+
|
|
118
|
+
beforeEach(() => {
|
|
119
|
+
root = mkdtempSync(join(tmpdir(), "ailk-10644-site-"));
|
|
120
|
+
writeFileSync(
|
|
121
|
+
join(root, "_site.mdx"),
|
|
122
|
+
[
|
|
123
|
+
"---",
|
|
124
|
+
"siteName: Acme",
|
|
125
|
+
`baseUrl: ${ORIGIN}`,
|
|
126
|
+
"siteIdentity:",
|
|
127
|
+
" kind: Organization",
|
|
128
|
+
" name: Acme Inc.",
|
|
129
|
+
` url: ${ORIGIN}`,
|
|
130
|
+
" secondary:",
|
|
131
|
+
" kind: SoftwareApplication",
|
|
132
|
+
" name: Acme App",
|
|
133
|
+
` url: ${ORIGIN}/app`,
|
|
134
|
+
"---",
|
|
135
|
+
"",
|
|
136
|
+
].join("\n"),
|
|
137
|
+
);
|
|
138
|
+
process.env.AILK_CONTENT_ROOT = root;
|
|
139
|
+
process.env.NEXT_PUBLIC_SITE_URL = ORIGIN;
|
|
140
|
+
mockGetContentAdapter.mockReset();
|
|
141
|
+
mockGetContentAdapter.mockReturnValue(stubAdapter(PRODUCT_ROWS));
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
afterEach(() => {
|
|
145
|
+
renderer?.setSiteFeatureListReader(undefined);
|
|
146
|
+
renderer?.setSiteIdentity(undefined);
|
|
147
|
+
renderer = undefined;
|
|
148
|
+
rmSync(root, { recursive: true, force: true });
|
|
149
|
+
restore("CONTENT_ADAPTER", saved.adapter);
|
|
150
|
+
restore("AILK_CONTENT_ROOT", saved.contentRoot);
|
|
151
|
+
restore("NEXT_PUBLIC_SITE_URL", saved.siteUrl);
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it("database: readSiteIdentity carries no featureList and scans no directory under the root", () => {
|
|
155
|
+
process.env.CONTENT_ADAPTER = "database";
|
|
156
|
+
const readdir = jest.mocked(readdirSync);
|
|
157
|
+
readdir.mockClear();
|
|
158
|
+
const registration = readSiteIdentity(root);
|
|
159
|
+
expect(registration).toBeDefined();
|
|
160
|
+
expect(registration).not.toHaveProperty("featureList");
|
|
161
|
+
const underRoot = readdir.mock.calls.filter(([path]) => String(path).startsWith(root));
|
|
162
|
+
expect(underRoot).toEqual([]);
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
it("database: the composed SoftwareApplication carries the adapter's featureList", async () => {
|
|
166
|
+
process.env.CONTENT_ADAPTER = "database";
|
|
167
|
+
const renderer = loadSiteIdentity();
|
|
168
|
+
const result = await renderer.pageToJsonLd(
|
|
169
|
+
{ type: "AboutPage", slug: "about", title: "About" } as Parameters<PageRenderer["pageToJsonLd"]>[0],
|
|
170
|
+
"en",
|
|
171
|
+
);
|
|
172
|
+
expect(appNode(result as Record<string, unknown>)?.["featureList"]).toEqual(["A1", "A2"]);
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
it("mdx: no reader is registered, and the adapter is never read", async () => {
|
|
176
|
+
delete process.env.CONTENT_ADAPTER;
|
|
177
|
+
const renderer = loadSiteIdentity();
|
|
178
|
+
const result = await renderer.pageToJsonLd(
|
|
179
|
+
{ type: "AboutPage", slug: "about", title: "About" } as Parameters<PageRenderer["pageToJsonLd"]>[0],
|
|
180
|
+
"en",
|
|
181
|
+
[],
|
|
182
|
+
{
|
|
183
|
+
siteName: "Acme",
|
|
184
|
+
baseUrl: ORIGIN,
|
|
185
|
+
identity: {
|
|
186
|
+
kind: "Organization",
|
|
187
|
+
name: "Acme Inc.",
|
|
188
|
+
url: ORIGIN,
|
|
189
|
+
secondary: { kind: "SoftwareApplication", name: "Acme App", url: `${ORIGIN}/app` },
|
|
190
|
+
},
|
|
191
|
+
},
|
|
192
|
+
);
|
|
193
|
+
expect(appNode(result as Record<string, unknown>)).not.toHaveProperty("featureList");
|
|
194
|
+
expect(mockGetContentAdapter).toHaveBeenCalledTimes(0);
|
|
195
|
+
});
|
|
196
|
+
});
|
|
@@ -632,7 +632,17 @@ describe("structured data — every route emits a valid JSON-LD script", () => {
|
|
|
632
632
|
return;
|
|
633
633
|
}
|
|
634
634
|
const exempt = exemption(route, src);
|
|
635
|
-
|
|
635
|
+
// #10552 (merged in #10586) put a program-Event renderPage ahead of the
|
|
636
|
+
// meet hub's BookCall one in `[slug]/[detail]`. The params this check
|
|
637
|
+
// builds for that route are the meet hub's (`renderRoute`), so its checked
|
|
638
|
+
// type stays BookCall; the Event branch is covered by the page's own suite.
|
|
639
|
+
if (route === "[slug]/[detail]") {
|
|
640
|
+
expect(src).toMatch(/renderPage\(\{\s*type:\s*"BookCall",\s*slug:\s*detail\b/);
|
|
641
|
+
}
|
|
642
|
+
const identity =
|
|
643
|
+
route === "[slug]/[detail]"
|
|
644
|
+
? { type: "BookCall", slug: null }
|
|
645
|
+
: routeIdentity(src);
|
|
636
646
|
if (exempt !== undefined) {
|
|
637
647
|
// Declared, with a reason, in NO_STRUCTURED_DATA_ROUTES above. The
|
|
638
648
|
// reason is false for a route that does render through renderPage —
|
|
@@ -4,8 +4,8 @@
|
|
|
4
4
|
* ONE module, so the list `app/[locale]/layout.tsx` branches on to drop the
|
|
5
5
|
* marketing chrome and the list the `(auth)` group's page segments exist at
|
|
6
6
|
* cannot drift. A route group contributes no URL segment, so these are the
|
|
7
|
-
* real paths: `/sign-in`, `/sign-up`, `/forgot-password`, `/reset-password
|
|
8
|
-
*
|
|
7
|
+
* real paths: `/sign-in`, `/sign-up`, `/forgot-password`, `/reset-password`,
|
|
8
|
+
* `/verify-email` and the MCP consent step `/consent` (#10357), each under next-intl's `as-needed` prefixing (bare for
|
|
9
9
|
* the default locale, `/<locale>/…` otherwise).
|
|
10
10
|
*
|
|
11
11
|
* `isAccountPath()` (#7430, spec 7423 D4) is the same predicate for the
|
|
@@ -21,13 +21,15 @@
|
|
|
21
21
|
* No Next.js import — pure functions, asserted directly by their own suite.
|
|
22
22
|
*/
|
|
23
23
|
|
|
24
|
-
/** The
|
|
24
|
+
/** The six segments the `(auth)` route group serves. */
|
|
25
25
|
export const AUTH_SEGMENTS = [
|
|
26
26
|
"sign-in",
|
|
27
27
|
"sign-up",
|
|
28
28
|
"forgot-password",
|
|
29
29
|
"reset-password",
|
|
30
30
|
"verify-email",
|
|
31
|
+
// The MCP consent step (#10357 D6).
|
|
32
|
+
"consent",
|
|
31
33
|
] as const;
|
|
32
34
|
|
|
33
35
|
export type AuthSegment = (typeof AUTH_SEGMENTS)[number];
|
|
@@ -12,11 +12,24 @@
|
|
|
12
12
|
* a check that throws, answers no boolean or takes over 5 seconds is
|
|
13
13
|
* `unavailable`. It runs only on Vercel (`VERCEL=1`); `AILK_BOT_CHECK=off`
|
|
14
14
|
* turns every check off and `AILK_BOOKING_BOT_CHECK=off` the booking's alone.
|
|
15
|
+
* `AILK_BOT_CHECK=observe` (#10715) runs the check and logs what it found as
|
|
16
|
+
* `observed`, but passes every write — for diagnosis, never for production.
|
|
15
17
|
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
+
* A BotID-protected site MUST be reached directly (#10715): Cloudflare
|
|
19
|
+
* DNS-only, no reverse proxy in front of Vercel. Behind a proxy the browser
|
|
20
|
+
* solves BotID's challenge but the write reaches Vercel from the proxy's
|
|
21
|
+
* egress, so BotID judges every real visitor a bot. A request carrying
|
|
22
|
+
* `cf-ray` is `proxied`, and the first one per process logs an error naming
|
|
23
|
+
* that fix.
|
|
24
|
+
*
|
|
25
|
+
* NO SECRET IS LOGGED. A refusal logs `{ path, outcome }`, BotID's
|
|
26
|
+
* `classificationReason`, `bypassed` and `isVerifiedBot`, the request's
|
|
27
|
+
* `host` and `x-forwarded-host` and whether it was `proxied` — no other
|
|
28
|
+
* header value, no form field — and, for a failed check, the error's name —
|
|
29
|
+
* never its message.
|
|
18
30
|
*/
|
|
19
31
|
import { checkBotId } from "botid/server";
|
|
32
|
+
import { headers } from "next/headers";
|
|
20
33
|
|
|
21
34
|
export type BotCheckScope = "write" | "booking";
|
|
22
35
|
export type BotCheckOutcome = "pass" | "bot" | "unavailable";
|
|
@@ -46,6 +59,11 @@ function switchedOff(scope: BotCheckScope): boolean {
|
|
|
46
59
|
);
|
|
47
60
|
}
|
|
48
61
|
|
|
62
|
+
/** `AILK_BOT_CHECK=observe`: the check runs and logs, but never refuses. */
|
|
63
|
+
function observing(): boolean {
|
|
64
|
+
return process.env.AILK_BOT_CHECK?.trim().toLowerCase() === "observe";
|
|
65
|
+
}
|
|
66
|
+
|
|
49
67
|
/** True when the check runs for `scope`. Read on every call, never cached. */
|
|
50
68
|
export function botCheckOn(scope: BotCheckScope): boolean {
|
|
51
69
|
return process.env.VERCEL === "1" && !switchedOff(scope);
|
|
@@ -73,13 +91,21 @@ function namedError(name: string): Error {
|
|
|
73
91
|
return err;
|
|
74
92
|
}
|
|
75
93
|
|
|
94
|
+
/** BotID's answer: `isBot` decides, the rest says why. */
|
|
95
|
+
interface BotVerdict {
|
|
96
|
+
isBot: boolean;
|
|
97
|
+
classificationReason: string | null;
|
|
98
|
+
bypassed: boolean | null;
|
|
99
|
+
isVerifiedBot: boolean | null;
|
|
100
|
+
}
|
|
101
|
+
|
|
76
102
|
/**
|
|
77
|
-
* BotID's verdict on this request: `true` for a bot, `false` for a
|
|
78
|
-
* Throws `TimeoutError` past `BOT_CHECK_TIMEOUT_MS` and
|
|
79
|
-
* when `isBot` is not a boolean, as `checkBotId`
|
|
80
|
-
* from Vercel.
|
|
103
|
+
* BotID's verdict on this request: `isBot` is `true` for a bot, `false` for a
|
|
104
|
+
* human. Throws `TimeoutError` past `BOT_CHECK_TIMEOUT_MS` and
|
|
105
|
+
* `InvalidVerdictError` when `isBot` is not a boolean, as `checkBotId`
|
|
106
|
+
* returns for an error answer from Vercel.
|
|
81
107
|
*/
|
|
82
|
-
async function
|
|
108
|
+
async function botVerdict(): Promise<BotVerdict> {
|
|
83
109
|
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
84
110
|
try {
|
|
85
111
|
const verdict = await Promise.race([
|
|
@@ -91,14 +117,67 @@ async function isBotVerdict(): Promise<boolean> {
|
|
|
91
117
|
);
|
|
92
118
|
}),
|
|
93
119
|
]);
|
|
94
|
-
const
|
|
120
|
+
const answer = verdict as Record<string, unknown> | undefined;
|
|
121
|
+
const isBot = answer?.isBot;
|
|
95
122
|
if (typeof isBot !== "boolean") throw namedError("InvalidVerdictError");
|
|
96
|
-
|
|
123
|
+
const reason = answer?.classificationReason;
|
|
124
|
+
return {
|
|
125
|
+
isBot,
|
|
126
|
+
classificationReason: typeof reason === "string" ? reason : null,
|
|
127
|
+
bypassed: typeof answer?.bypassed === "boolean" ? answer.bypassed : null,
|
|
128
|
+
isVerifiedBot:
|
|
129
|
+
typeof answer?.isVerifiedBot === "boolean"
|
|
130
|
+
? answer.isVerifiedBot
|
|
131
|
+
: null,
|
|
132
|
+
};
|
|
97
133
|
} finally {
|
|
98
134
|
clearTimeout(timer);
|
|
99
135
|
}
|
|
100
136
|
}
|
|
101
137
|
|
|
138
|
+
/** The only request facts a log carries: the two hosts and a proxy marker. */
|
|
139
|
+
interface RequestFacts {
|
|
140
|
+
host: string | null;
|
|
141
|
+
forwardedHost: string | null;
|
|
142
|
+
proxied: boolean;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** Read as `checkBotId` reads them; unreadable headers are no facts, never a throw. */
|
|
146
|
+
async function requestFacts(): Promise<RequestFacts> {
|
|
147
|
+
try {
|
|
148
|
+
const h = await headers();
|
|
149
|
+
return {
|
|
150
|
+
host: h.get("host"),
|
|
151
|
+
forwardedHost: h.get("x-forwarded-host"),
|
|
152
|
+
proxied: h.has("cf-ray"),
|
|
153
|
+
};
|
|
154
|
+
} catch {
|
|
155
|
+
return { host: null, forwardedHost: null, proxied: false };
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const PROXY_FIX =
|
|
160
|
+
"Serve the site direct to Vercel: set its Cloudflare DNS record to DNS-only (no orange cloud), never behind a reverse proxy (#10715).";
|
|
161
|
+
|
|
162
|
+
let warnedProxied = false;
|
|
163
|
+
|
|
164
|
+
/** A proxied request makes BotID refuse real visitors; say so once per process. */
|
|
165
|
+
function warnProxiedOnce(
|
|
166
|
+
source: string,
|
|
167
|
+
target: string,
|
|
168
|
+
facts: RequestFacts,
|
|
169
|
+
): void {
|
|
170
|
+
if (warnedProxied) return;
|
|
171
|
+
warnedProxied = true;
|
|
172
|
+
console.error(`[${source}] reverse proxy detected`, {
|
|
173
|
+
path: target,
|
|
174
|
+
outcome: "proxied",
|
|
175
|
+
host: facts.host,
|
|
176
|
+
forwardedHost: facts.forwardedHost,
|
|
177
|
+
fix: PROXY_FIX,
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
|
|
102
181
|
/**
|
|
103
182
|
* The check for one write. `source` names the log prefix and `target` the
|
|
104
183
|
* relayed path or the action's name — never a field value. Never throws.
|
|
@@ -116,21 +195,42 @@ export async function runBotCheck({
|
|
|
116
195
|
warnBotCheckInactiveOnce(scope);
|
|
117
196
|
return "pass";
|
|
118
197
|
}
|
|
119
|
-
|
|
198
|
+
const observe = observing();
|
|
199
|
+
let verdict: BotVerdict | undefined;
|
|
200
|
+
let failure: unknown;
|
|
120
201
|
try {
|
|
121
|
-
|
|
202
|
+
verdict = await botVerdict();
|
|
122
203
|
} catch (err) {
|
|
204
|
+
failure = err;
|
|
205
|
+
}
|
|
206
|
+
// Read after the verdict, so the timeout starts with the check.
|
|
207
|
+
const facts = await requestFacts();
|
|
208
|
+
if (facts.proxied) warnProxiedOnce(source, target, facts);
|
|
209
|
+
if (!verdict) {
|
|
123
210
|
console.error(`[${source}] bot check failed`, {
|
|
124
211
|
path: target,
|
|
125
212
|
outcome: "bot-check-failed",
|
|
126
|
-
error:
|
|
213
|
+
error: failure instanceof Error ? failure.name : "unknown",
|
|
127
214
|
});
|
|
128
|
-
return "unavailable";
|
|
215
|
+
return observe ? "pass" : "unavailable";
|
|
216
|
+
}
|
|
217
|
+
const { isBot, ...detail } = verdict;
|
|
218
|
+
if (observe) {
|
|
219
|
+
console.warn(`[${source}] bot check observed`, {
|
|
220
|
+
path: target,
|
|
221
|
+
outcome: "observed",
|
|
222
|
+
isBot,
|
|
223
|
+
...detail,
|
|
224
|
+
...facts,
|
|
225
|
+
});
|
|
226
|
+
return "pass";
|
|
129
227
|
}
|
|
130
228
|
if (isBot) {
|
|
131
229
|
console.warn(`[${source}] bot refused`, {
|
|
132
230
|
path: target,
|
|
133
231
|
outcome: "bot-detected",
|
|
232
|
+
...detail,
|
|
233
|
+
...facts,
|
|
134
234
|
});
|
|
135
235
|
return "bot";
|
|
136
236
|
}
|
|
@@ -325,6 +325,8 @@ export interface NewsletterSignupOptions {
|
|
|
325
325
|
/** The footer's compact form: stacked, the label `sr-only`, the placeholder naming the field. */
|
|
326
326
|
compact?: boolean;
|
|
327
327
|
className?: string;
|
|
328
|
+
/** #10629 D4 — the consent gate; the bound newsletter section passes `"on-submit"`. Omitted → the block's default. */
|
|
329
|
+
consentGate?: "disable" | "on-submit";
|
|
328
330
|
}
|
|
329
331
|
|
|
330
332
|
/**
|
|
@@ -335,7 +337,7 @@ export interface NewsletterSignupOptions {
|
|
|
335
337
|
*/
|
|
336
338
|
export function newsletterSignup(
|
|
337
339
|
view: InPageView & { form: NewsletterFormView },
|
|
338
|
-
{ locale, fallbackEmail, compact = false, className }: NewsletterSignupOptions,
|
|
340
|
+
{ locale, fallbackEmail, compact = false, className, consentGate }: NewsletterSignupOptions,
|
|
339
341
|
): ReactElement {
|
|
340
342
|
const { form, ending } = view;
|
|
341
343
|
const placeholder = form.placeholder ?? (compact ? form.label : undefined);
|
|
@@ -356,6 +358,7 @@ export function newsletterSignup(
|
|
|
356
358
|
}),
|
|
357
359
|
...(fallbackEmail !== undefined && { fallbackEmail }),
|
|
358
360
|
...(className !== undefined && { className }),
|
|
361
|
+
...(consentGate !== undefined && { consentGate }),
|
|
359
362
|
});
|
|
360
363
|
}
|
|
361
364
|
|