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.
- package/README.md +84 -7
- package/dist/add-module-core.js +2 -1
- package/dist/add-module.d.ts +2 -1
- package/dist/add-module.js +9 -4
- package/dist/add-route.js +46 -19
- package/dist/body-heading.d.ts +56 -0
- package/dist/body-heading.js +394 -0
- package/dist/cli.js +28 -1
- package/dist/content-payloads.js +2 -2
- package/dist/derive-architecture.js +8 -2
- package/dist/index.d.ts +2 -1
- package/dist/index.js +4 -1
- package/dist/lib/auth-secret-file.d.ts +6 -2
- package/dist/lib/auth-secret-file.js +46 -20
- package/dist/lib/env-catalog.js +20 -0
- package/dist/lib/site-modules.d.ts +8 -0
- package/dist/lib/site-modules.js +34 -0
- package/dist/module-architecture.js +23 -14
- package/dist/paid-sections.d.ts +17 -0
- package/dist/paid-sections.js +39 -0
- package/dist/programmatic.d.ts +16 -0
- package/dist/programmatic.js +73 -4
- package/dist/remove-module.d.ts +98 -0
- package/dist/remove-module.js +620 -0
- package/dist/seed-flows.d.ts +2 -2
- package/dist/seed-flows.js +16 -9
- package/dist/site-roster.js +4 -1
- package/dist/starter-grid.d.ts +26 -0
- package/dist/starter-grid.js +34 -0
- package/dist/surfaces.d.ts +26 -0
- package/dist/surfaces.js +105 -9
- package/dist/sync-routes.d.ts +7 -0
- package/dist/sync-routes.js +53 -6
- package/dist/template-pages.d.ts +36 -0
- package/dist/template-pages.js +129 -0
- package/dist/topology.d.ts +2 -1
- package/dist/topology.js +4 -2
- package/dist/update.d.ts +3 -1
- package/dist/update.js +38 -4
- package/package.json +3 -3
- package/release-scope.json +2 -1
- package/site-templates/aliases/hub.TEMPLATE-MANIFEST.json +115 -0
- package/templates/.env.example +5 -2
- package/templates/apps/api/.env.example +9 -5
- package/templates/apps/api/CLAUDE.md +14 -9
- package/templates/apps/api/src/__tests__/vercel-handler.test.ts +37 -0
- package/templates/apps/api/src/vercel-handler.ts +8 -1
- package/templates/apps/mcp/CLAUDE.md +18 -10
- package/templates/apps/web/.env.example +25 -11
- package/templates/apps/web/CLAUDE.md +45 -4
- package/templates/apps/web/__tests__/setup.ts +7 -0
- package/templates/apps/web/app/.well-known/mcp.json/route.ts +7 -7
- package/templates/apps/web/app/[locale]/(authed)/account/artifacts/[pieceId]/versions/[version]/file/route.ts +61 -2
- package/templates/apps/web/app/[locale]/(authed)/account/artifacts/__tests__/file-route.test.ts +81 -0
- package/templates/apps/web/app/[locale]/(authed)/account/connect/__tests__/page.test.tsx +197 -0
- package/templates/apps/web/app/[locale]/(authed)/account/connect/page.tsx +152 -0
- package/templates/apps/web/app/[locale]/about/__tests__/about-routes.test.tsx +2 -2
- package/templates/apps/web/app/[locale]/careers/[slug]/[action]/page.tsx +76 -21
- package/templates/apps/web/app/[locale]/careers/[slug]/apply.actions.ts +46 -16
- package/templates/apps/web/app/[locale]/careers/[slug]/page.tsx +20 -3
- package/templates/apps/web/app/[locale]/careers/__tests__/apply.actions.test.ts +180 -2
- package/templates/apps/web/app/[locale]/careers/__tests__/careers-set.test.tsx +6 -2
- package/templates/apps/web/app/[locale]/careers/__tests__/job-apply-page.test.tsx +122 -0
- package/templates/apps/web/app/[locale]/careers/__tests__/page-renderer-migration.test.tsx +1 -1
- package/templates/apps/web/app/[locale]/case-studies/[slug]/page.tsx +20 -3
- package/templates/apps/web/app/[locale]/case-studies/__tests__/hub-children.test.tsx +65 -0
- package/templates/apps/web/app/[locale]/case-studies/__tests__/page-renderer-migration.test.tsx +6 -2
- package/templates/apps/web/app/[locale]/case-studies/page.tsx +23 -11
- package/templates/apps/web/app/[locale]/checkout/__tests__/thank-you-account.test.tsx +11 -1
- package/templates/apps/web/app/[locale]/clients/__tests__/page-renderer-migration.test.tsx +1 -1
- package/templates/apps/web/app/[locale]/consent.actions.ts +44 -6
- package/templates/apps/web/app/[locale]/contact/__tests__/ConversationalIntakeWrapper.test.tsx +4 -4
- package/templates/apps/web/app/[locale]/contact/__tests__/contact.actions.test.ts +269 -42
- package/templates/apps/web/app/[locale]/contact/contact.actions.ts +88 -30
- package/templates/apps/web/app/[locale]/courses/[slug]/page.tsx +20 -3
- package/templates/apps/web/app/[locale]/courses/__tests__/hub-children.test.tsx +32 -0
- package/templates/apps/web/app/[locale]/courses/__tests__/page-renderer-migration.test.tsx +6 -2
- package/templates/apps/web/app/[locale]/courses/page.tsx +22 -6
- package/templates/apps/web/app/[locale]/events/[slug]/page.tsx +15 -2
- package/templates/apps/web/app/[locale]/events/__tests__/hub-children.test.tsx +32 -0
- package/templates/apps/web/app/[locale]/events/__tests__/page-renderer-migration.test.tsx +8 -2
- package/templates/apps/web/app/[locale]/events/page.tsx +23 -7
- package/templates/apps/web/app/[locale]/features/__tests__/hub-children.test.tsx +32 -0
- package/templates/apps/web/app/[locale]/features/page.tsx +23 -25
- package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/flow.actions.test.ts +95 -0
- package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/save.actions.test.ts +193 -0
- package/templates/apps/web/app/[locale]/flows/[slug]/email-moment.ts +92 -0
- package/templates/apps/web/app/[locale]/flows/[slug]/flow.actions.ts +10 -0
- package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/ResultsClient.tsx +513 -0
- package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/ResultsEmailStep.tsx +122 -0
- package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/__tests__/ResultsClient.no-commerce.test.tsx +195 -0
- package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/__tests__/landing.test.tsx +293 -0
- package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/__tests__/page.test.tsx +397 -0
- package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/checkout-seam.ts +74 -0
- package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/page.tsx +264 -0
- package/templates/apps/web/app/[locale]/flows/[slug]/save.actions.ts +75 -0
- package/templates/apps/web/app/[locale]/for/__tests__/hub-children.test.tsx +32 -0
- package/templates/apps/web/app/[locale]/for/__tests__/page-renderer-migration.test.tsx +5 -1
- package/templates/apps/web/app/[locale]/for/page.tsx +23 -25
- package/templates/apps/web/app/[locale]/how-to/[slug]/page.tsx +20 -3
- package/templates/apps/web/app/[locale]/how-to/__tests__/page-renderer-migration.test.tsx +1 -1
- package/templates/apps/web/app/[locale]/integrations/[slug]/page.tsx +25 -4
- package/templates/apps/web/app/[locale]/integrations/__tests__/hub-children.test.tsx +38 -0
- package/templates/apps/web/app/[locale]/integrations/__tests__/page-renderer-migration.test.tsx +8 -2
- package/templates/apps/web/app/[locale]/integrations/page.tsx +23 -11
- package/templates/apps/web/app/[locale]/layout.tsx +157 -139
- package/templates/apps/web/app/[locale]/page.tsx +51 -3
- package/templates/apps/web/app/[locale]/partners/[slug]/page.tsx +16 -3
- package/templates/apps/web/app/[locale]/partners/__tests__/hub-children.test.tsx +32 -0
- package/templates/apps/web/app/[locale]/partners/__tests__/page-renderer-migration.test.tsx +6 -2
- package/templates/apps/web/app/[locale]/partners/page.tsx +22 -6
- package/templates/apps/web/app/[locale]/portfolio/__tests__/hub-children.test.tsx +38 -0
- package/templates/apps/web/app/[locale]/portfolio/page.tsx +25 -24
- package/templates/apps/web/app/[locale]/pricing/[slug]/__tests__/page.test.tsx +321 -0
- package/templates/apps/web/app/[locale]/pricing/[slug]/page.tsx +87 -0
- package/templates/apps/web/app/[locale]/pricing/__tests__/offerings.test.tsx +3 -0
- package/templates/apps/web/app/[locale]/pricing/__tests__/pricing-pages.test.tsx +215 -0
- package/templates/apps/web/app/[locale]/pricing/page.tsx +56 -341
- package/templates/apps/web/app/[locale]/products/[slug]/page.tsx +21 -4
- package/templates/apps/web/app/[locale]/products/__tests__/hub-children.test.tsx +32 -0
- package/templates/apps/web/app/[locale]/products/__tests__/page-renderer-migration.test.tsx +8 -2
- package/templates/apps/web/app/[locale]/products/page.tsx +22 -6
- package/templates/apps/web/app/[locale]/projects/__tests__/hub-children.test.tsx +32 -0
- package/templates/apps/web/app/[locale]/projects/__tests__/page-renderer-migration.test.tsx +4 -0
- package/templates/apps/web/app/[locale]/projects/page.tsx +18 -11
- package/templates/apps/web/app/[locale]/resources/[slug]/page.tsx +25 -4
- package/templates/apps/web/app/[locale]/resources/__tests__/hub-children.test.tsx +38 -0
- package/templates/apps/web/app/[locale]/resources/__tests__/page-renderer-migration.test.tsx +6 -2
- package/templates/apps/web/app/[locale]/resources/page.tsx +20 -10
- package/templates/apps/web/app/[locale]/services/[slug]/[child]/page.tsx +68 -0
- package/templates/apps/web/app/[locale]/services/[slug]/page.tsx +64 -9
- package/templates/apps/web/app/[locale]/services/__tests__/hub-children.test.tsx +158 -0
- package/templates/apps/web/app/[locale]/services/__tests__/page-renderer-migration.test.tsx +12 -2
- package/templates/apps/web/app/[locale]/services/__tests__/service-detail-sections.test.tsx +223 -0
- package/templates/apps/web/app/[locale]/services/__tests__/service-families.test.tsx +368 -0
- package/templates/apps/web/app/[locale]/services/page.tsx +45 -19
- package/templates/apps/web/app/[locale]/software/__tests__/page-renderer-migration.test.tsx +1 -1
- package/templates/apps/web/app/[locale]/team/__tests__/hub-children.test.tsx +32 -0
- package/templates/apps/web/app/[locale]/team/page.tsx +25 -24
- package/templates/apps/web/app/[locale]/tech-articles/[slug]/page.tsx +24 -3
- package/templates/apps/web/app/[locale]/tech-articles/__tests__/page-renderer-migration.test.tsx +1 -1
- package/templates/apps/web/app/[locale]/templates/[slug]/__tests__/page.test.tsx +203 -0
- package/templates/apps/web/app/[locale]/templates/[slug]/page.tsx +81 -0
- package/templates/apps/web/app/[locale]/templates/__tests__/page.test.tsx +236 -0
- package/templates/apps/web/app/[locale]/templates/page.tsx +108 -0
- package/templates/apps/web/app/[locale]/testimonials/__tests__/page-renderer-migration.test.tsx +1 -1
- package/templates/apps/web/app/[locale]/whitepapers/[slug]/page.tsx +24 -3
- package/templates/apps/web/app/[locale]/whitepapers/__tests__/page-renderer-migration.test.tsx +1 -1
- package/templates/apps/web/app/api/auth/hub/authorize/__tests__/route.test.ts +204 -2
- package/templates/apps/web/app/api/auth/hub/authorize/route.ts +151 -33
- package/templates/apps/web/app/api/offerings/checkout/[lookupKey]/__tests__/route.test.ts +233 -7
- package/templates/apps/web/app/api/offerings/checkout/[lookupKey]/route.ts +92 -41
- package/templates/apps/web/app/api/offerings/checkout/__tests__/route.test.ts +77 -39
- package/templates/apps/web/app/llms.txt/route.ts +41 -3
- package/templates/apps/web/app/robots.txt/route.ts +5 -1
- package/templates/apps/web/app/sitemap.xml/route.ts +45 -3
- package/templates/apps/web/auth.ts +102 -50
- package/templates/apps/web/components/signed-in-nav.tsx +8 -1
- package/templates/apps/web/lib/__tests__/bot-check.test.ts +254 -0
- package/templates/apps/web/lib/__tests__/consent-visitor.test.ts +212 -13
- package/templates/apps/web/lib/__tests__/deck-page.test.ts +13 -1
- package/templates/apps/web/lib/__tests__/docs-index.test.ts +93 -0
- package/templates/apps/web/lib/__tests__/docs-path.test.ts +168 -0
- package/templates/apps/web/lib/__tests__/hub-children.fixtures.ts +278 -0
- package/templates/apps/web/lib/__tests__/nav-label-keys.test.ts +138 -0
- package/templates/apps/web/lib/__tests__/newsletter.actions.test.ts +178 -2
- package/templates/apps/web/lib/__tests__/offerings-pricing.test.ts +183 -0
- package/templates/apps/web/lib/__tests__/page-chrome.test.ts +412 -0
- package/templates/apps/web/lib/__tests__/pricing-page.test.ts +142 -0
- package/templates/apps/web/lib/__tests__/relay.test.ts +202 -10
- package/templates/apps/web/lib/__tests__/route-family.test.ts +57 -0
- package/templates/apps/web/lib/__tests__/service-listing.test.ts +111 -0
- package/templates/apps/web/lib/__tests__/services-path.test.ts +96 -0
- package/templates/apps/web/lib/__tests__/site-header.test.ts +151 -0
- package/templates/apps/web/lib/__tests__/structured-data.test.tsx +148 -6
- package/templates/apps/web/lib/__tests__/telemetry-scrub.test.ts +84 -0
- package/templates/apps/web/lib/__tests__/template-listing.test.ts +158 -0
- package/templates/apps/web/lib/auth-secret.ts +57 -0
- package/templates/apps/web/lib/bot-check.ts +138 -0
- package/templates/apps/web/lib/consent-visitor.ts +81 -7
- package/templates/apps/web/lib/deck-page.ts +6 -3
- package/templates/apps/web/lib/docs-index.ts +100 -0
- package/templates/apps/web/lib/docs-path.ts +94 -0
- package/templates/apps/web/lib/hub-children.tsx +318 -0
- package/templates/apps/web/lib/modules.generated.ts +9 -0
- package/templates/apps/web/lib/nav-label-check.ts +61 -0
- package/templates/apps/web/lib/nav-label-keys.ts +78 -0
- package/templates/apps/web/lib/newsletter.actions.ts +31 -6
- package/templates/apps/web/lib/offerings-pricing.ts +135 -6
- package/templates/apps/web/lib/page-chrome.ts +367 -0
- package/templates/apps/web/lib/posting-title.ts +26 -4
- package/templates/apps/web/lib/pricing-page.ts +430 -0
- package/templates/apps/web/lib/relay.ts +31 -106
- package/templates/apps/web/lib/route-family.ts +69 -0
- package/templates/apps/web/lib/service-listing.ts +89 -0
- package/templates/apps/web/lib/services-path.ts +97 -0
- package/templates/apps/web/lib/site-header.ts +94 -0
- package/templates/apps/web/lib/telemetry-scrub.ts +68 -3
- package/templates/apps/web/lib/template-listing.ts +169 -0
- package/templates/apps/web/messages/en.json +86 -30
- package/templates/apps/web/middleware.ts +171 -11
- package/templates/apps/web/next.config.mjs +211 -2
- package/templates/apps/web/package.json +1 -1
- package/templates/apps/web/scripts/assert-content-bundled.ts +28 -6
- package/templates/apps/web/scripts/check-nav-labels.ts +39 -0
- package/templates/apps/web/sentry.server.config.ts +30 -2
- package/templates/apps/web/src/lib/__tests__/rate-limit.test.ts +67 -9
- package/templates/apps/web/src/lib/rate-limit.ts +68 -27
- package/templates/apps/web/src/lib/routes.ts +15 -1
- package/versions.json +48 -48
|
@@ -19,14 +19,30 @@
|
|
|
19
19
|
* `getBlogPages` deps — tests inject stubs, and the module's variant of this
|
|
20
20
|
* route supplies the real `blogSource` reader. The exported `GET` is
|
|
21
21
|
* unchanged either way.
|
|
22
|
+
*
|
|
23
|
+
* Docs (#10041 D8): listed after the blog at the site's docs path. Under the
|
|
24
|
+
* database adapter the adapter's `DocsList` / `DocsDetail` rows are listed at
|
|
25
|
+
* the docs path; otherwise those rows are skipped (the MDX docs route never
|
|
26
|
+
* serves them) and the docs directory is listed (`getDocsPages`, default
|
|
27
|
+
* `listDocsPages`).
|
|
22
28
|
*/
|
|
23
29
|
|
|
24
|
-
import {
|
|
30
|
+
import {
|
|
31
|
+
SCHEMA_ORG_TYPES,
|
|
32
|
+
resolvedAdapterKind,
|
|
33
|
+
} from "@working-theory/content-adapters";
|
|
25
34
|
import type { ContentAdapter, SchemaOrgType } from "@working-theory/content-adapters";
|
|
26
35
|
import { getContentAdapter as defaultGetContentAdapter } from "@working-theory/page-renderer";
|
|
27
36
|
|
|
37
|
+
import {
|
|
38
|
+
adapterDocsUrl,
|
|
39
|
+
isDocsType,
|
|
40
|
+
listDocsPages,
|
|
41
|
+
type DocsIndexPage,
|
|
42
|
+
} from "../../lib/docs-index";
|
|
43
|
+
import { PUBLIC_PAGES } from "../../lib/modules.generated";
|
|
28
44
|
import { SITE_NAME } from "../../lib/site-brand";
|
|
29
|
-
import {
|
|
45
|
+
import { pagePathFor, resolveBaseUrl } from "../../src/lib/routes";
|
|
30
46
|
|
|
31
47
|
export interface LlmsHandlerDeps {
|
|
32
48
|
/**
|
|
@@ -40,6 +56,8 @@ export interface LlmsHandlerDeps {
|
|
|
40
56
|
* Override in tests to inject stub pages.
|
|
41
57
|
*/
|
|
42
58
|
getBlogPages?: () => Array<{ url: string; data: { title: string; description?: string } }>;
|
|
59
|
+
/** The docs pages (#10041). Defaults to `listDocsPages` over this app's docs directory. */
|
|
60
|
+
getDocsPages?: () => DocsIndexPage[];
|
|
43
61
|
}
|
|
44
62
|
|
|
45
63
|
/**
|
|
@@ -49,8 +67,11 @@ export interface LlmsHandlerDeps {
|
|
|
49
67
|
export function createLlmsHandler(deps: LlmsHandlerDeps = {}) {
|
|
50
68
|
const resolveAdapter = deps.getContentAdapter ?? defaultGetContentAdapter;
|
|
51
69
|
const resolveBlogPages = deps.getBlogPages ?? (() => []);
|
|
70
|
+
const resolveDocsPages = deps.getDocsPages ?? listDocsPages;
|
|
52
71
|
|
|
53
72
|
return async function llmsGET(): Promise<Response> {
|
|
73
|
+
// #10040 decision 7 — no public page, no content index: 404 before the adapter.
|
|
74
|
+
if (!PUBLIC_PAGES) return new Response(null, { status: 404 });
|
|
54
75
|
const adapter = resolveAdapter();
|
|
55
76
|
const baseUrl = resolveBaseUrl();
|
|
56
77
|
const lines: string[] = [`# ${SITE_NAME} — content index`, ""];
|
|
@@ -66,9 +87,13 @@ export function createLlmsHandler(deps: LlmsHandlerDeps = {}) {
|
|
|
66
87
|
types.map((type) => adapter.listPages(type, defaultLocale)),
|
|
67
88
|
);
|
|
68
89
|
|
|
90
|
+
const databaseDocs = resolvedAdapterKind() === "database";
|
|
69
91
|
for (const pages of pageLists) {
|
|
70
92
|
for (const page of pages) {
|
|
71
|
-
|
|
93
|
+
if (isDocsType(page.type) && !databaseDocs) continue;
|
|
94
|
+
const path = isDocsType(page.type)
|
|
95
|
+
? adapterDocsUrl(page.type, page.slug)
|
|
96
|
+
: pagePathFor(page);
|
|
72
97
|
const url = `${baseUrl}${path}`;
|
|
73
98
|
lines.push(`- Title: ${page.title}`);
|
|
74
99
|
lines.push(` URL: ${url}`);
|
|
@@ -92,6 +117,19 @@ export function createLlmsHandler(deps: LlmsHandlerDeps = {}) {
|
|
|
92
117
|
lines.push("");
|
|
93
118
|
}
|
|
94
119
|
|
|
120
|
+
// The docs directory (#10041 D8) — not under the database adapter, whose
|
|
121
|
+
// docs rows are listed above.
|
|
122
|
+
if (!databaseDocs) {
|
|
123
|
+
for (const doc of resolveDocsPages()) {
|
|
124
|
+
lines.push(`- Title: ${doc.title}`);
|
|
125
|
+
lines.push(` URL: ${baseUrl}${doc.url}`);
|
|
126
|
+
if (doc.description) {
|
|
127
|
+
lines.push(` Description: ${doc.description}`);
|
|
128
|
+
}
|
|
129
|
+
lines.push("");
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
95
133
|
return new Response(lines.join("\n"), {
|
|
96
134
|
status: 200,
|
|
97
135
|
headers: {
|
|
@@ -15,12 +15,16 @@
|
|
|
15
15
|
|
|
16
16
|
import { parseStealth } from "@working-theory/web-i18n/stealth";
|
|
17
17
|
|
|
18
|
+
import { PUBLIC_PAGES } from "../../lib/modules.generated";
|
|
18
19
|
import { resolveBaseUrl } from "../../src/lib/routes";
|
|
19
20
|
|
|
20
21
|
export function GET(): Response {
|
|
21
22
|
const baseUrl = resolveBaseUrl();
|
|
22
23
|
const disallowEnv = process.env["AILK_ROBOTS_DISALLOW"] ?? "";
|
|
23
|
-
|
|
24
|
+
// #10040 decision 7 — a site with neither marketing nor docs-blog (a hub)
|
|
25
|
+
// has no public page to crawl: the same disallow-all, no-Sitemap shape as
|
|
26
|
+
// STEALTH, read from the module list, never from a setting.
|
|
27
|
+
const stealth = parseStealth(process.env["STEALTH"]) || !PUBLIC_PAGES;
|
|
24
28
|
|
|
25
29
|
const disallowPaths = stealth
|
|
26
30
|
? ["/"]
|
|
@@ -28,13 +28,30 @@
|
|
|
28
28
|
* DI note: `createSitemapHandler` accepts an optional `getContentAdapter` dep
|
|
29
29
|
* for testing — tests inject a stub adapter to avoid mocking the internal module.
|
|
30
30
|
* The exported `GET` uses the real implementation.
|
|
31
|
+
*
|
|
32
|
+
* Docs (#10041 D8): the docs are listed at the site's docs path, default
|
|
33
|
+
* locale only, with no alternates (they exist in one locale). Under the
|
|
34
|
+
* database adapter the docs route reads the adapter's `DocsList` /
|
|
35
|
+
* `DocsDetail` rows, so those rows are listed at the docs path; otherwise
|
|
36
|
+
* those rows are skipped (the MDX docs route never serves them) and the docs
|
|
37
|
+
* directory is listed instead (`getDocsPages`, default `listDocsPages`).
|
|
31
38
|
*/
|
|
32
39
|
|
|
33
|
-
import {
|
|
40
|
+
import {
|
|
41
|
+
SCHEMA_ORG_TYPES,
|
|
42
|
+
resolvedAdapterKind,
|
|
43
|
+
} from "@working-theory/content-adapters";
|
|
34
44
|
import type { ContentAdapter, SchemaOrgType } from "@working-theory/content-adapters";
|
|
35
45
|
import { getContentAdapter as defaultGetContentAdapter } from "@working-theory/page-renderer";
|
|
36
46
|
|
|
37
|
-
import {
|
|
47
|
+
import {
|
|
48
|
+
adapterDocsUrl,
|
|
49
|
+
isDocsType,
|
|
50
|
+
listDocsPages,
|
|
51
|
+
type DocsIndexPage,
|
|
52
|
+
} from "../../lib/docs-index";
|
|
53
|
+
import { PUBLIC_PAGES } from "../../lib/modules.generated";
|
|
54
|
+
import { pagePathFor, resolveBaseUrl } from "../../src/lib/routes";
|
|
38
55
|
|
|
39
56
|
export interface SitemapHandlerDeps {
|
|
40
57
|
/**
|
|
@@ -42,6 +59,8 @@ export interface SitemapHandlerDeps {
|
|
|
42
59
|
* Override in tests to inject a stub adapter without mocking the internal module.
|
|
43
60
|
*/
|
|
44
61
|
getContentAdapter?: () => ContentAdapter;
|
|
62
|
+
/** The docs pages (#10041). Defaults to `listDocsPages` over this app's docs directory. */
|
|
63
|
+
getDocsPages?: () => DocsIndexPage[];
|
|
45
64
|
}
|
|
46
65
|
|
|
47
66
|
function escapeXml(value: string): string {
|
|
@@ -76,7 +95,10 @@ function buildLocaleUrl(
|
|
|
76
95
|
*/
|
|
77
96
|
export function createSitemapHandler(deps: SitemapHandlerDeps = {}) {
|
|
78
97
|
const resolveAdapter = deps.getContentAdapter ?? defaultGetContentAdapter;
|
|
98
|
+
const resolveDocsPages = deps.getDocsPages ?? listDocsPages;
|
|
79
99
|
return async function sitemapGET(): Promise<Response> {
|
|
100
|
+
// #10040 decision 7 — no public page, no sitemap: 404 before the adapter.
|
|
101
|
+
if (!PUBLIC_PAGES) return new Response(null, { status: 404 });
|
|
80
102
|
const adapter = resolveAdapter();
|
|
81
103
|
const baseUrl = resolveBaseUrl();
|
|
82
104
|
const today = new Date().toISOString().slice(0, 10);
|
|
@@ -87,6 +109,13 @@ export function createSitemapHandler(deps: SitemapHandlerDeps = {}) {
|
|
|
87
109
|
const defaultLocale = process.env.NEXT_PUBLIC_DEFAULT_LOCALE ?? "en";
|
|
88
110
|
|
|
89
111
|
const urlEntries: string[] = [];
|
|
112
|
+
// A docs entry: one locale, so no alternates (#10041 D8).
|
|
113
|
+
const pushDocsEntry = (url: string, lastmod: string): void => {
|
|
114
|
+
urlEntries.push(
|
|
115
|
+
` <url>\n <loc>${escapeXml(`${baseUrl}${url}`)}</loc>\n <lastmod>${lastmod}</lastmod>\n </url>`,
|
|
116
|
+
);
|
|
117
|
+
};
|
|
118
|
+
const databaseDocs = resolvedAdapterKind() === "database";
|
|
90
119
|
|
|
91
120
|
const types = SCHEMA_ORG_TYPES as readonly SchemaOrgType[];
|
|
92
121
|
|
|
@@ -104,7 +133,16 @@ export function createSitemapHandler(deps: SitemapHandlerDeps = {}) {
|
|
|
104
133
|
for (let i = 0; i < tuples.length; i++) {
|
|
105
134
|
const { locale } = tuples[i]!;
|
|
106
135
|
for (const page of lists[i]!) {
|
|
107
|
-
|
|
136
|
+
if (isDocsType(page.type)) {
|
|
137
|
+
if (databaseDocs && locale === defaultLocale) {
|
|
138
|
+
pushDocsEntry(
|
|
139
|
+
adapterDocsUrl(page.type, page.slug),
|
|
140
|
+
page.publishedAt ?? today,
|
|
141
|
+
);
|
|
142
|
+
}
|
|
143
|
+
continue;
|
|
144
|
+
}
|
|
145
|
+
const path = pagePathFor(page);
|
|
108
146
|
const lastmod = page.publishedAt ?? today;
|
|
109
147
|
|
|
110
148
|
const defaultUrl = `${baseUrl}${path}`;
|
|
@@ -126,6 +164,10 @@ export function createSitemapHandler(deps: SitemapHandlerDeps = {}) {
|
|
|
126
164
|
}
|
|
127
165
|
}
|
|
128
166
|
|
|
167
|
+
if (!databaseDocs) {
|
|
168
|
+
for (const doc of resolveDocsPages()) pushDocsEntry(doc.url, today);
|
|
169
|
+
}
|
|
170
|
+
|
|
129
171
|
const xml = [
|
|
130
172
|
'<?xml version="1.0" encoding="UTF-8"?>',
|
|
131
173
|
'<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9" xmlns:xhtml="http://www.w3.org/1999/xhtml">',
|
|
@@ -43,6 +43,7 @@ import {
|
|
|
43
43
|
isSignUpOpen,
|
|
44
44
|
resetSendConfigured,
|
|
45
45
|
} from "./lib/auth-env";
|
|
46
|
+
import { resolveSecret } from "./lib/auth-secret";
|
|
46
47
|
import {
|
|
47
48
|
signUpAgreementHooks,
|
|
48
49
|
signUpAgreementsPlugin,
|
|
@@ -181,55 +182,102 @@ function buildSocialProviders(): SocialProviders {
|
|
|
181
182
|
return providers;
|
|
182
183
|
}
|
|
183
184
|
|
|
185
|
+
// `resolveSecret()` lives in `lib/auth-secret.ts` (#8556) so the consent
|
|
186
|
+
// visitor id can be signed without loading Better Auth; re-exported here for
|
|
187
|
+
// every caller that already imports it from this module.
|
|
188
|
+
export { resolveSecret };
|
|
189
|
+
|
|
190
|
+
/** A loopback host, where `http:` is the only scheme a local site has. */
|
|
191
|
+
const LOOPBACK_HOSTS = new Set(["localhost", "127.0.0.1", "[::1]"]);
|
|
192
|
+
|
|
184
193
|
/**
|
|
185
|
-
* Resolve
|
|
186
|
-
*
|
|
187
|
-
*
|
|
188
|
-
* THROWS in production when BETTER_AUTH_SECRET is unset (§ D16). Better Auth's
|
|
189
|
-
* own `validateSecret` throws only when the secret equals ITS default; this
|
|
190
|
-
* repo's literal is a different string, so it cleared that check and produced
|
|
191
|
-
* nothing but a low-entropy warning — while sitting in a repository whose
|
|
192
|
-
* `oss`-tier tree ships publicly. Anyone who can read this file could forge
|
|
193
|
-
* both a session cookie and a verification token on any deployment that
|
|
194
|
-
* omitted the variable.
|
|
195
|
-
*
|
|
196
|
-
* This changes boot behaviour: a production deployment with no
|
|
197
|
-
* BETTER_AUTH_SECRET boots today and does not after this. That is the correct
|
|
198
|
-
* failure — a site signing sessions with a published string is not usefully
|
|
199
|
-
* "up" — and `apps/web/.env.example` has always documented the variable as
|
|
200
|
-
* REQUIRED in production. The change makes the file honest rather than adding
|
|
201
|
-
* a requirement.
|
|
194
|
+
* Resolve Better Auth's `baseURL` — and so the one trusted origin — from
|
|
195
|
+
* BETTER_AUTH_URL (#8620, #9965 D10), modelled on `resolveSecret()`.
|
|
202
196
|
*
|
|
203
|
-
*
|
|
204
|
-
*
|
|
205
|
-
*
|
|
197
|
+
* Unset, Better Auth derives the base URL from each REQUEST's own host (the
|
|
198
|
+
* § D13 account-takeover chain), so a production process that is about to
|
|
199
|
+
* answer requests THROWS without one, as it does without the secret. There
|
|
200
|
+
* the value must be an `https:` URL, or `http:` on a loopback host; anything
|
|
201
|
+
* else counts as unset. The build phase and development read it as before —
|
|
202
|
+
* as set, or `undefined` — so `resetSendConfigured()`'s raw read still
|
|
203
|
+
* agrees with it. create-ailk writes a loopback value beside the secret, so a
|
|
204
|
+
* fresh scaffold's `next start` boots; a production process serving on one
|
|
205
|
+
* WARNS, because an `http:` base URL also drops the session cookie's
|
|
206
|
+
* `Secure` flag and points every emailed link at this machine.
|
|
206
207
|
*/
|
|
207
|
-
|
|
208
|
-
const configured = process.env.
|
|
209
|
-
if (configured) return configured;
|
|
210
|
-
|
|
211
|
-
// SERVING, not BUILDING. `next build` collects page data with
|
|
212
|
-
// NODE_ENV=production, and that evaluates `app/api/auth/[...all]/route.ts`,
|
|
213
|
-
// which resolves the lazy `auth` proxy — so an unqualified production throw
|
|
214
|
-
// fails the BUILD of any tree that has not set the variable. That is much
|
|
215
|
-
// broader than the risk: a build produces an artifact, it does not serve a
|
|
216
|
-
// session, and it would break CI, preview builds, and `pnpm build` on a
|
|
217
|
-
// freshly scaffolded site that has not configured auth at all.
|
|
218
|
-
//
|
|
219
|
-
// Next sets NEXT_PHASE to PHASE_PRODUCTION_BUILD ("phase-production-build")
|
|
220
|
-
// for the duration of the build, so excluding that phase keeps the guard
|
|
221
|
-
// exactly where it was aimed: a production process that is about to answer
|
|
222
|
-
// requests. Such a process still throws on the first access to `auth`, which
|
|
223
|
-
// is the intended failure — a site signing sessions with a string published
|
|
224
|
-
// in this repository is not usefully "up".
|
|
208
|
+
function resolveBaseURL(): string | undefined {
|
|
209
|
+
const configured = process.env.BETTER_AUTH_URL;
|
|
225
210
|
const isBuildPhase = process.env.NEXT_PHASE === "phase-production-build";
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
)
|
|
211
|
+
const serving = process.env.NODE_ENV === "production" && !isBuildPhase;
|
|
212
|
+
const url = configured ? trustableBaseURL(configured) : undefined;
|
|
213
|
+
if (url) {
|
|
214
|
+
if (serving && url.protocol === "http:") {
|
|
215
|
+
console.warn(
|
|
216
|
+
`BETTER_AUTH_URL is the loopback ${url.origin} in production: session cookies are not Secure and emailed links point at this machine. Set it to the site's public https: origin.`,
|
|
217
|
+
);
|
|
218
|
+
}
|
|
219
|
+
return configured;
|
|
230
220
|
}
|
|
231
221
|
|
|
232
|
-
return
|
|
222
|
+
if (!serving) return configured || undefined;
|
|
223
|
+
throw new Error(
|
|
224
|
+
"BETTER_AUTH_URL is required in production: it is the origin Better Auth builds callbacks and emailed links on, and must be an https: URL (http: only on a loopback host).",
|
|
225
|
+
);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/** `value` parsed, when it is `https:` or `http:` on a loopback host. */
|
|
229
|
+
function trustableBaseURL(value: string): URL | undefined {
|
|
230
|
+
let url: URL;
|
|
231
|
+
try {
|
|
232
|
+
url = new URL(value);
|
|
233
|
+
} catch {
|
|
234
|
+
return undefined;
|
|
235
|
+
}
|
|
236
|
+
const trustable = url.protocol === "https:" || (url.protocol === "http:" && LOOPBACK_HOSTS.has(url.hostname));
|
|
237
|
+
return trustable ? url : undefined;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* The user-write hook (#8602, #9965 D6): a non-empty `image` must be an
|
|
242
|
+
* `http:` or `https:` URL, whatever the client checked. The users table is
|
|
243
|
+
* shared with the Admin's Better Auth, which applies the same rule, so a
|
|
244
|
+
* `javascript:` or `data:` value never reaches an `<img>` or a link. The
|
|
245
|
+
* refusal is code-shaped — no English string reaches a page.
|
|
246
|
+
*/
|
|
247
|
+
async function rejectNonHttpImage(user: { image?: unknown }): Promise<void> {
|
|
248
|
+
const { image } = user;
|
|
249
|
+
if (typeof image !== "string" || image === "") return;
|
|
250
|
+
let protocol: string | undefined;
|
|
251
|
+
try {
|
|
252
|
+
protocol = new URL(image).protocol;
|
|
253
|
+
} catch {
|
|
254
|
+
protocol = undefined;
|
|
255
|
+
}
|
|
256
|
+
if (protocol === "https:" || protocol === "http:") return;
|
|
257
|
+
const { APIError } = await import("better-auth/api");
|
|
258
|
+
throw new APIError("BAD_REQUEST", { code: "INVALID_IMAGE_URL", message: "INVALID_IMAGE_URL" });
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* The sign-up hooks, with the image check on every user write: on update, and
|
|
263
|
+
* on create (the sign-up endpoint accepts `image` too) ahead of the agreement
|
|
264
|
+
* hook.
|
|
265
|
+
*/
|
|
266
|
+
function databaseHooks(): ReturnType<typeof signUpAgreementHooks> {
|
|
267
|
+
const hooks = signUpAgreementHooks();
|
|
268
|
+
const createBefore = hooks.user?.create?.before;
|
|
269
|
+
return {
|
|
270
|
+
...hooks,
|
|
271
|
+
user: {
|
|
272
|
+
create: {
|
|
273
|
+
before: async (user, context) => {
|
|
274
|
+
await rejectNonHttpImage(user);
|
|
275
|
+
return createBefore?.(user, context);
|
|
276
|
+
},
|
|
277
|
+
},
|
|
278
|
+
update: { before: rejectNonHttpImage },
|
|
279
|
+
},
|
|
280
|
+
};
|
|
233
281
|
}
|
|
234
282
|
|
|
235
283
|
/**
|
|
@@ -313,6 +361,11 @@ export function createAuth(deps: CreateAuthDeps = {}) {
|
|
|
313
361
|
*/
|
|
314
362
|
const resetConfigured = resetSendConfigured();
|
|
315
363
|
|
|
364
|
+
// Resolved in this order so a production process missing both names the
|
|
365
|
+
// secret first, as it always has.
|
|
366
|
+
const secret = resolveSecret();
|
|
367
|
+
const baseURL = resolveBaseURL();
|
|
368
|
+
|
|
316
369
|
/**
|
|
317
370
|
* The emailed sign-in link (#7435 § D1). The SAME predicate `/sign-in` and
|
|
318
371
|
* the `(authed)` gate read, so the form renders iff the endpoint exists.
|
|
@@ -461,11 +514,12 @@ export function createAuth(deps: CreateAuthDeps = {}) {
|
|
|
461
514
|
* user carries agreements, the account hook writes them on the captured
|
|
462
515
|
* transaction client. A magic-link sign-up creates no Account, so the
|
|
463
516
|
* session hook writes its grants, in their own transaction, before the
|
|
464
|
-
* session exists (#9667 D5).
|
|
517
|
+
* session exists (#9667 D5). The one other database hook refuses a
|
|
518
|
+
* non-http(s) image on user create and update (#9965 D6).
|
|
465
519
|
*/
|
|
466
|
-
databaseHooks:
|
|
467
|
-
secret
|
|
468
|
-
baseURL
|
|
520
|
+
databaseHooks: databaseHooks(),
|
|
521
|
+
secret,
|
|
522
|
+
baseURL,
|
|
469
523
|
socialProviders: buildSocialProviders(),
|
|
470
524
|
/**
|
|
471
525
|
* The emitted reset link's host and the `redirectTo` allow-list are
|
|
@@ -483,9 +537,7 @@ export function createAuth(deps: CreateAuthDeps = {}) {
|
|
|
483
537
|
* `BETTER_AUTH_URL` is also a conjunct of `resetSendConfigured()`, so a
|
|
484
538
|
* deployment that has not pinned its origin never offers reset at all.
|
|
485
539
|
*/
|
|
486
|
-
...(
|
|
487
|
-
? { trustedOrigins: [process.env.BETTER_AUTH_URL] }
|
|
488
|
-
: {}),
|
|
540
|
+
...(baseURL ? { trustedOrigins: [baseURL] } : {}),
|
|
489
541
|
emailAndPassword: {
|
|
490
542
|
enabled: passwordMethodActive,
|
|
491
543
|
// Turning the password method on also opens POST /sign-up/email —
|
|
@@ -23,11 +23,18 @@
|
|
|
23
23
|
import { Nav, type NavProps } from "@working-theory/ui/chrome";
|
|
24
24
|
import type { ReactElement } from "react";
|
|
25
25
|
|
|
26
|
+
import { withPublicDocsPaths } from "../lib/docs-path";
|
|
26
27
|
import { navItems } from "../lib/nav-config";
|
|
28
|
+
import { withPublicServicesPaths } from "../lib/services-path";
|
|
27
29
|
import { siteChrome } from "../lib/site-chrome";
|
|
28
30
|
|
|
29
31
|
import { SignOutTrigger } from "./sign-out-trigger";
|
|
30
32
|
|
|
33
|
+
// #10041 D10 — nav paths at `/docs` render at the site's docs path.
|
|
34
|
+
// `docs-path.ts` imports nothing, so it is safe on this side of the boundary.
|
|
35
|
+
// #10043 D9 — likewise nav paths at `/services`, from `services-path.ts`.
|
|
36
|
+
const publicNavItems = withPublicServicesPaths(withPublicDocsPaths(navItems));
|
|
37
|
+
|
|
31
38
|
/** Nav's props minus every function-typed one — this type IS the boundary. */
|
|
32
39
|
export type SignedInNavProps = Omit<
|
|
33
40
|
NavProps,
|
|
@@ -48,7 +55,7 @@ export function SignedInNav({
|
|
|
48
55
|
return (
|
|
49
56
|
<Nav
|
|
50
57
|
{...props}
|
|
51
|
-
navItems={siteChrome.nav === "mark-only" ? [] :
|
|
58
|
+
navItems={siteChrome.nav === "mark-only" ? [] : publicNavItems}
|
|
52
59
|
// Both signed-in actions are flows with no lightbox: Sign out becomes a
|
|
53
60
|
// SignOutTrigger, the account action a non-navigating name label.
|
|
54
61
|
renderFlowTrigger={(action, trigger) =>
|
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @jest-environment node
|
|
3
|
+
*/
|
|
4
|
+
// The spoke edge's one bot check (#9255, spec
|
|
5
|
+
// docs/specs/9255-an-edge-bot-challenge-in-spec.md §5.1 AC-1, AC-4): the
|
|
6
|
+
// shared verdict rule, its timeout, both switches and BotID's client list.
|
|
7
|
+
|
|
8
|
+
import { BOT_CLIENT_PROTECT, botCheckOn, runBotCheck } from "../bot-check";
|
|
9
|
+
|
|
10
|
+
// `checkBotId` reads Vercel's request context, which jest has none of, so
|
|
11
|
+
// every case decides the verdict here.
|
|
12
|
+
const checkBotIdMock = jest.fn();
|
|
13
|
+
jest.mock("botid/server", () => ({
|
|
14
|
+
checkBotId: (...args: unknown[]) => checkBotIdMock(...args),
|
|
15
|
+
}));
|
|
16
|
+
|
|
17
|
+
const BOT = {
|
|
18
|
+
isHuman: false,
|
|
19
|
+
isBot: true,
|
|
20
|
+
isVerifiedBot: false,
|
|
21
|
+
bypassed: false,
|
|
22
|
+
};
|
|
23
|
+
const HUMAN = { ...BOT, isHuman: true, isBot: false };
|
|
24
|
+
|
|
25
|
+
const ENV_KEYS = [
|
|
26
|
+
"VERCEL",
|
|
27
|
+
"AILK_BOT_CHECK",
|
|
28
|
+
"AILK_BOOKING_BOT_CHECK",
|
|
29
|
+
] as const;
|
|
30
|
+
const priorEnv = new Map<string, string | undefined>(
|
|
31
|
+
ENV_KEYS.map((name) => [name, process.env[name]]),
|
|
32
|
+
);
|
|
33
|
+
|
|
34
|
+
const CHECK = {
|
|
35
|
+
source: "bot-check",
|
|
36
|
+
target: "testAction",
|
|
37
|
+
scope: "write",
|
|
38
|
+
} as const;
|
|
39
|
+
|
|
40
|
+
let errorSpy: jest.SpyInstance;
|
|
41
|
+
let warnSpy: jest.SpyInstance;
|
|
42
|
+
|
|
43
|
+
beforeEach(() => {
|
|
44
|
+
checkBotIdMock.mockReset();
|
|
45
|
+
for (const name of ENV_KEYS) delete process.env[name];
|
|
46
|
+
process.env.VERCEL = "1";
|
|
47
|
+
errorSpy = jest.spyOn(console, "error").mockImplementation(() => {});
|
|
48
|
+
warnSpy = jest.spyOn(console, "warn").mockImplementation(() => {});
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
afterEach(() => {
|
|
52
|
+
errorSpy.mockRestore();
|
|
53
|
+
warnSpy.mockRestore();
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
afterAll(() => {
|
|
57
|
+
for (const [name, value] of priorEnv) {
|
|
58
|
+
if (value === undefined) delete process.env[name];
|
|
59
|
+
else process.env[name] = value;
|
|
60
|
+
}
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
describe("AC-1 — runBotCheck's verdict (#9889 D3, one rule for every write)", () => {
|
|
64
|
+
it("a human passes", async () => {
|
|
65
|
+
checkBotIdMock.mockResolvedValue(HUMAN);
|
|
66
|
+
expect(await runBotCheck(CHECK)).toBe("pass");
|
|
67
|
+
expect(checkBotIdMock).toHaveBeenCalledTimes(1);
|
|
68
|
+
expect(errorSpy).not.toHaveBeenCalled();
|
|
69
|
+
expect(warnSpy).not.toHaveBeenCalled();
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it.each([
|
|
73
|
+
["a bot", BOT],
|
|
74
|
+
["a verified bot", { ...BOT, isVerifiedBot: true }],
|
|
75
|
+
])(
|
|
76
|
+
"%s is bot, logged with the target and no detail",
|
|
77
|
+
async (_label, verdict) => {
|
|
78
|
+
checkBotIdMock.mockResolvedValue(verdict);
|
|
79
|
+
expect(await runBotCheck(CHECK)).toBe("bot");
|
|
80
|
+
expect(warnSpy).toHaveBeenCalledTimes(1);
|
|
81
|
+
expect(warnSpy).toHaveBeenCalledWith("[bot-check] bot refused", {
|
|
82
|
+
path: "testAction",
|
|
83
|
+
outcome: "bot-detected",
|
|
84
|
+
});
|
|
85
|
+
},
|
|
86
|
+
);
|
|
87
|
+
|
|
88
|
+
function expectUnavailableLog(error: string): void {
|
|
89
|
+
expect(errorSpy).toHaveBeenCalledTimes(1);
|
|
90
|
+
expect(errorSpy).toHaveBeenCalledWith("[bot-check] bot check failed", {
|
|
91
|
+
path: "testAction",
|
|
92
|
+
outcome: "bot-check-failed",
|
|
93
|
+
error,
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
it("a throw is unavailable, logging the error's name, never its message", async () => {
|
|
98
|
+
checkBotIdMock.mockRejectedValue(new Error("token abc"));
|
|
99
|
+
expect(await runBotCheck(CHECK)).toBe("unavailable");
|
|
100
|
+
expectUnavailableLog("Error");
|
|
101
|
+
expect(JSON.stringify(errorSpy.mock.calls)).not.toContain("token abc");
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it.each([
|
|
105
|
+
["an empty verdict", {}],
|
|
106
|
+
["a non-boolean isBot", { ...HUMAN, isBot: "false" }],
|
|
107
|
+
])("%s is unavailable (InvalidVerdictError)", async (_label, verdict) => {
|
|
108
|
+
checkBotIdMock.mockResolvedValue(verdict);
|
|
109
|
+
expect(await runBotCheck(CHECK)).toBe("unavailable");
|
|
110
|
+
expectUnavailableLog("InvalidVerdictError");
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
describe("with fake timers", () => {
|
|
114
|
+
beforeEach(() => {
|
|
115
|
+
jest.useFakeTimers({
|
|
116
|
+
doNotFake: ["nextTick", "queueMicrotask", "setImmediate"],
|
|
117
|
+
});
|
|
118
|
+
});
|
|
119
|
+
afterEach(() => {
|
|
120
|
+
jest.useRealTimers();
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it("no verdict within 5 000 ms is unavailable (TimeoutError)", async () => {
|
|
124
|
+
checkBotIdMock.mockReturnValue(new Promise(() => {}));
|
|
125
|
+
const pending = runBotCheck(CHECK);
|
|
126
|
+
await jest.advanceTimersByTimeAsync(5_000);
|
|
127
|
+
expect(await pending).toBe("unavailable");
|
|
128
|
+
expectUnavailableLog("TimeoutError");
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it("a verdict in time leaves no timer pending", async () => {
|
|
132
|
+
checkBotIdMock.mockResolvedValue(BOT);
|
|
133
|
+
expect(await runBotCheck(CHECK)).toBe("bot");
|
|
134
|
+
expect(jest.getTimerCount()).toBe(0);
|
|
135
|
+
});
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it("off Vercel it passes without asking BotID", async () => {
|
|
139
|
+
delete process.env.VERCEL;
|
|
140
|
+
checkBotIdMock.mockResolvedValue(BOT);
|
|
141
|
+
expect(await runBotCheck(CHECK)).toBe("pass");
|
|
142
|
+
expect(checkBotIdMock).not.toHaveBeenCalled();
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it("AILK_BOT_CHECK=off passes the booking too, without asking BotID", async () => {
|
|
146
|
+
process.env.AILK_BOT_CHECK = "off";
|
|
147
|
+
checkBotIdMock.mockResolvedValue(BOT);
|
|
148
|
+
expect(await runBotCheck({ ...CHECK, scope: "booking" })).toBe("pass");
|
|
149
|
+
expect(checkBotIdMock).not.toHaveBeenCalled();
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
describe("in production, off Vercel", () => {
|
|
153
|
+
const env = process.env as Record<string, string | undefined>;
|
|
154
|
+
const priorNodeEnv = env.NODE_ENV;
|
|
155
|
+
beforeEach(() => {
|
|
156
|
+
env.NODE_ENV = "production";
|
|
157
|
+
delete process.env.VERCEL;
|
|
158
|
+
});
|
|
159
|
+
afterEach(() => {
|
|
160
|
+
env.NODE_ENV = priorNodeEnv;
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
async function twoChecks(scope: "write" | "booking"): Promise<void> {
|
|
164
|
+
await jest.isolateModulesAsync(async () => {
|
|
165
|
+
const fresh = await import("../bot-check");
|
|
166
|
+
for (let i = 0; i < 2; i++) {
|
|
167
|
+
expect(await fresh.runBotCheck({ ...CHECK, scope })).toBe("pass");
|
|
168
|
+
}
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
it("warns once per process that the check is not running", async () => {
|
|
173
|
+
await twoChecks("write");
|
|
174
|
+
expect(checkBotIdMock).not.toHaveBeenCalled();
|
|
175
|
+
expect(warnSpy).toHaveBeenCalledTimes(1);
|
|
176
|
+
expect(warnSpy).toHaveBeenCalledWith(
|
|
177
|
+
"[relay] booking bot check inactive",
|
|
178
|
+
{ outcome: "not-on-vercel" },
|
|
179
|
+
);
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
it("with AILK_BOT_CHECK=off, warns nothing", async () => {
|
|
183
|
+
process.env.AILK_BOT_CHECK = "off";
|
|
184
|
+
await twoChecks("write");
|
|
185
|
+
expect(warnSpy).not.toHaveBeenCalled();
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
it("with AILK_BOOKING_BOT_CHECK=off, the booking warns nothing", async () => {
|
|
189
|
+
process.env.AILK_BOOKING_BOT_CHECK = "off";
|
|
190
|
+
await twoChecks("booking");
|
|
191
|
+
expect(warnSpy).not.toHaveBeenCalled();
|
|
192
|
+
});
|
|
193
|
+
});
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
describe("AC-4 — the switches and BotID's client list", () => {
|
|
197
|
+
it.each(["off", " OFF "])(
|
|
198
|
+
"AILK_BOT_CHECK=%j turns every check off",
|
|
199
|
+
(value) => {
|
|
200
|
+
process.env.AILK_BOT_CHECK = value;
|
|
201
|
+
expect(botCheckOn("write")).toBe(false);
|
|
202
|
+
expect(botCheckOn("booking")).toBe(false);
|
|
203
|
+
},
|
|
204
|
+
);
|
|
205
|
+
|
|
206
|
+
it("AILK_BOOKING_BOT_CHECK=off turns the booking's alone off", () => {
|
|
207
|
+
process.env.AILK_BOOKING_BOT_CHECK = "off";
|
|
208
|
+
expect(botCheckOn("write")).toBe(true);
|
|
209
|
+
expect(botCheckOn("booking")).toBe(false);
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
it.each([
|
|
213
|
+
["AILK_BOT_CHECK", "on"],
|
|
214
|
+
["AILK_BOT_CHECK", "0"],
|
|
215
|
+
["AILK_BOOKING_BOT_CHECK", "on"],
|
|
216
|
+
["AILK_BOOKING_BOT_CHECK", "0"],
|
|
217
|
+
])("%s=%s leaves both on", (name, value) => {
|
|
218
|
+
process.env[name] = value;
|
|
219
|
+
expect(botCheckOn("write")).toBe(true);
|
|
220
|
+
expect(botCheckOn("booking")).toBe(true);
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
it("off Vercel both are off", () => {
|
|
224
|
+
delete process.env.VERCEL;
|
|
225
|
+
expect(botCheckOn("write")).toBe(false);
|
|
226
|
+
expect(botCheckOn("booking")).toBe(false);
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
it("BOT_CLIENT_PROTECT is every same-origin POST, frozen", () => {
|
|
230
|
+
expect(BOT_CLIENT_PROTECT).toEqual([{ path: "/*", method: "POST" }]);
|
|
231
|
+
expect(Object.isFrozen(BOT_CLIENT_PROTECT)).toBe(true);
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
// `botid/client/core`'s own rule: escape the regex characters, `*` → `.*`,
|
|
235
|
+
// anchored.
|
|
236
|
+
function compileBotIdPath(path: string): RegExp {
|
|
237
|
+
const body = path
|
|
238
|
+
.replace(/[.?+^$[\]\\(){}|-]/g, "\\$&")
|
|
239
|
+
.split("*")
|
|
240
|
+
.join(".*");
|
|
241
|
+
return new RegExp(`^${body}$`);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
it.each([
|
|
245
|
+
"/",
|
|
246
|
+
"/contact",
|
|
247
|
+
"/fr/flows/quiz/results/abc",
|
|
248
|
+
"/v1/leads/submit",
|
|
249
|
+
"/v1/schedule/book",
|
|
250
|
+
])("its path matches %s", (pathname) => {
|
|
251
|
+
const [entry] = BOT_CLIENT_PROTECT;
|
|
252
|
+
expect(compileBotIdPath(entry?.path ?? "").test(pathname)).toBe(true);
|
|
253
|
+
});
|
|
254
|
+
});
|