create-ailk 0.27.0 → 0.28.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (210) hide show
  1. package/README.md +84 -7
  2. package/dist/add-module-core.js +2 -1
  3. package/dist/add-module.d.ts +2 -1
  4. package/dist/add-module.js +9 -4
  5. package/dist/add-route.js +46 -19
  6. package/dist/body-heading.d.ts +56 -0
  7. package/dist/body-heading.js +394 -0
  8. package/dist/cli.js +28 -1
  9. package/dist/content-payloads.js +2 -2
  10. package/dist/derive-architecture.js +8 -2
  11. package/dist/index.d.ts +2 -1
  12. package/dist/index.js +4 -1
  13. package/dist/lib/auth-secret-file.d.ts +6 -2
  14. package/dist/lib/auth-secret-file.js +46 -20
  15. package/dist/lib/env-catalog.js +20 -0
  16. package/dist/lib/site-modules.d.ts +8 -0
  17. package/dist/lib/site-modules.js +34 -0
  18. package/dist/module-architecture.js +23 -14
  19. package/dist/paid-sections.d.ts +17 -0
  20. package/dist/paid-sections.js +39 -0
  21. package/dist/programmatic.d.ts +16 -0
  22. package/dist/programmatic.js +73 -4
  23. package/dist/remove-module.d.ts +98 -0
  24. package/dist/remove-module.js +620 -0
  25. package/dist/seed-flows.d.ts +2 -2
  26. package/dist/seed-flows.js +16 -9
  27. package/dist/site-roster.js +4 -1
  28. package/dist/starter-grid.d.ts +26 -0
  29. package/dist/starter-grid.js +34 -0
  30. package/dist/surfaces.d.ts +26 -0
  31. package/dist/surfaces.js +105 -9
  32. package/dist/sync-routes.d.ts +7 -0
  33. package/dist/sync-routes.js +53 -6
  34. package/dist/template-pages.d.ts +36 -0
  35. package/dist/template-pages.js +129 -0
  36. package/dist/topology.d.ts +2 -1
  37. package/dist/topology.js +4 -2
  38. package/dist/update.d.ts +3 -1
  39. package/dist/update.js +38 -4
  40. package/package.json +3 -3
  41. package/release-scope.json +2 -1
  42. package/site-templates/aliases/hub.TEMPLATE-MANIFEST.json +115 -0
  43. package/templates/.env.example +5 -2
  44. package/templates/apps/api/.env.example +9 -5
  45. package/templates/apps/api/CLAUDE.md +14 -9
  46. package/templates/apps/api/src/__tests__/vercel-handler.test.ts +37 -0
  47. package/templates/apps/api/src/vercel-handler.ts +8 -1
  48. package/templates/apps/mcp/CLAUDE.md +18 -10
  49. package/templates/apps/web/.env.example +25 -11
  50. package/templates/apps/web/CLAUDE.md +45 -4
  51. package/templates/apps/web/__tests__/setup.ts +7 -0
  52. package/templates/apps/web/app/.well-known/mcp.json/route.ts +7 -7
  53. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/[pieceId]/versions/[version]/file/route.ts +61 -2
  54. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/__tests__/file-route.test.ts +81 -0
  55. package/templates/apps/web/app/[locale]/(authed)/account/connect/__tests__/page.test.tsx +197 -0
  56. package/templates/apps/web/app/[locale]/(authed)/account/connect/page.tsx +152 -0
  57. package/templates/apps/web/app/[locale]/about/__tests__/about-routes.test.tsx +2 -2
  58. package/templates/apps/web/app/[locale]/careers/[slug]/[action]/page.tsx +76 -21
  59. package/templates/apps/web/app/[locale]/careers/[slug]/apply.actions.ts +46 -16
  60. package/templates/apps/web/app/[locale]/careers/[slug]/page.tsx +20 -3
  61. package/templates/apps/web/app/[locale]/careers/__tests__/apply.actions.test.ts +180 -2
  62. package/templates/apps/web/app/[locale]/careers/__tests__/careers-set.test.tsx +6 -2
  63. package/templates/apps/web/app/[locale]/careers/__tests__/job-apply-page.test.tsx +122 -0
  64. package/templates/apps/web/app/[locale]/careers/__tests__/page-renderer-migration.test.tsx +1 -1
  65. package/templates/apps/web/app/[locale]/case-studies/[slug]/page.tsx +20 -3
  66. package/templates/apps/web/app/[locale]/case-studies/__tests__/hub-children.test.tsx +65 -0
  67. package/templates/apps/web/app/[locale]/case-studies/__tests__/page-renderer-migration.test.tsx +6 -2
  68. package/templates/apps/web/app/[locale]/case-studies/page.tsx +23 -11
  69. package/templates/apps/web/app/[locale]/checkout/__tests__/thank-you-account.test.tsx +11 -1
  70. package/templates/apps/web/app/[locale]/clients/__tests__/page-renderer-migration.test.tsx +1 -1
  71. package/templates/apps/web/app/[locale]/consent.actions.ts +44 -6
  72. package/templates/apps/web/app/[locale]/contact/__tests__/ConversationalIntakeWrapper.test.tsx +4 -4
  73. package/templates/apps/web/app/[locale]/contact/__tests__/contact.actions.test.ts +269 -42
  74. package/templates/apps/web/app/[locale]/contact/contact.actions.ts +88 -30
  75. package/templates/apps/web/app/[locale]/courses/[slug]/page.tsx +20 -3
  76. package/templates/apps/web/app/[locale]/courses/__tests__/hub-children.test.tsx +32 -0
  77. package/templates/apps/web/app/[locale]/courses/__tests__/page-renderer-migration.test.tsx +6 -2
  78. package/templates/apps/web/app/[locale]/courses/page.tsx +22 -6
  79. package/templates/apps/web/app/[locale]/events/[slug]/page.tsx +15 -2
  80. package/templates/apps/web/app/[locale]/events/__tests__/hub-children.test.tsx +32 -0
  81. package/templates/apps/web/app/[locale]/events/__tests__/page-renderer-migration.test.tsx +8 -2
  82. package/templates/apps/web/app/[locale]/events/page.tsx +23 -7
  83. package/templates/apps/web/app/[locale]/features/__tests__/hub-children.test.tsx +32 -0
  84. package/templates/apps/web/app/[locale]/features/page.tsx +23 -25
  85. package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/flow.actions.test.ts +95 -0
  86. package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/save.actions.test.ts +193 -0
  87. package/templates/apps/web/app/[locale]/flows/[slug]/email-moment.ts +92 -0
  88. package/templates/apps/web/app/[locale]/flows/[slug]/flow.actions.ts +10 -0
  89. package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/ResultsClient.tsx +513 -0
  90. package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/ResultsEmailStep.tsx +122 -0
  91. package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/__tests__/ResultsClient.no-commerce.test.tsx +195 -0
  92. package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/__tests__/landing.test.tsx +293 -0
  93. package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/__tests__/page.test.tsx +397 -0
  94. package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/checkout-seam.ts +74 -0
  95. package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/page.tsx +264 -0
  96. package/templates/apps/web/app/[locale]/flows/[slug]/save.actions.ts +75 -0
  97. package/templates/apps/web/app/[locale]/for/__tests__/hub-children.test.tsx +32 -0
  98. package/templates/apps/web/app/[locale]/for/__tests__/page-renderer-migration.test.tsx +5 -1
  99. package/templates/apps/web/app/[locale]/for/page.tsx +23 -25
  100. package/templates/apps/web/app/[locale]/how-to/[slug]/page.tsx +20 -3
  101. package/templates/apps/web/app/[locale]/how-to/__tests__/page-renderer-migration.test.tsx +1 -1
  102. package/templates/apps/web/app/[locale]/integrations/[slug]/page.tsx +25 -4
  103. package/templates/apps/web/app/[locale]/integrations/__tests__/hub-children.test.tsx +38 -0
  104. package/templates/apps/web/app/[locale]/integrations/__tests__/page-renderer-migration.test.tsx +8 -2
  105. package/templates/apps/web/app/[locale]/integrations/page.tsx +23 -11
  106. package/templates/apps/web/app/[locale]/layout.tsx +157 -139
  107. package/templates/apps/web/app/[locale]/page.tsx +51 -3
  108. package/templates/apps/web/app/[locale]/partners/[slug]/page.tsx +16 -3
  109. package/templates/apps/web/app/[locale]/partners/__tests__/hub-children.test.tsx +32 -0
  110. package/templates/apps/web/app/[locale]/partners/__tests__/page-renderer-migration.test.tsx +6 -2
  111. package/templates/apps/web/app/[locale]/partners/page.tsx +22 -6
  112. package/templates/apps/web/app/[locale]/portfolio/__tests__/hub-children.test.tsx +38 -0
  113. package/templates/apps/web/app/[locale]/portfolio/page.tsx +25 -24
  114. package/templates/apps/web/app/[locale]/pricing/[slug]/__tests__/page.test.tsx +321 -0
  115. package/templates/apps/web/app/[locale]/pricing/[slug]/page.tsx +87 -0
  116. package/templates/apps/web/app/[locale]/pricing/__tests__/offerings.test.tsx +3 -0
  117. package/templates/apps/web/app/[locale]/pricing/__tests__/pricing-pages.test.tsx +215 -0
  118. package/templates/apps/web/app/[locale]/pricing/page.tsx +56 -341
  119. package/templates/apps/web/app/[locale]/products/[slug]/page.tsx +21 -4
  120. package/templates/apps/web/app/[locale]/products/__tests__/hub-children.test.tsx +32 -0
  121. package/templates/apps/web/app/[locale]/products/__tests__/page-renderer-migration.test.tsx +8 -2
  122. package/templates/apps/web/app/[locale]/products/page.tsx +22 -6
  123. package/templates/apps/web/app/[locale]/projects/__tests__/hub-children.test.tsx +32 -0
  124. package/templates/apps/web/app/[locale]/projects/__tests__/page-renderer-migration.test.tsx +4 -0
  125. package/templates/apps/web/app/[locale]/projects/page.tsx +18 -11
  126. package/templates/apps/web/app/[locale]/resources/[slug]/page.tsx +25 -4
  127. package/templates/apps/web/app/[locale]/resources/__tests__/hub-children.test.tsx +38 -0
  128. package/templates/apps/web/app/[locale]/resources/__tests__/page-renderer-migration.test.tsx +6 -2
  129. package/templates/apps/web/app/[locale]/resources/page.tsx +20 -10
  130. package/templates/apps/web/app/[locale]/services/[slug]/[child]/page.tsx +68 -0
  131. package/templates/apps/web/app/[locale]/services/[slug]/page.tsx +64 -9
  132. package/templates/apps/web/app/[locale]/services/__tests__/hub-children.test.tsx +158 -0
  133. package/templates/apps/web/app/[locale]/services/__tests__/page-renderer-migration.test.tsx +12 -2
  134. package/templates/apps/web/app/[locale]/services/__tests__/service-detail-sections.test.tsx +223 -0
  135. package/templates/apps/web/app/[locale]/services/__tests__/service-families.test.tsx +368 -0
  136. package/templates/apps/web/app/[locale]/services/page.tsx +45 -19
  137. package/templates/apps/web/app/[locale]/software/__tests__/page-renderer-migration.test.tsx +1 -1
  138. package/templates/apps/web/app/[locale]/team/__tests__/hub-children.test.tsx +32 -0
  139. package/templates/apps/web/app/[locale]/team/page.tsx +25 -24
  140. package/templates/apps/web/app/[locale]/tech-articles/[slug]/page.tsx +24 -3
  141. package/templates/apps/web/app/[locale]/tech-articles/__tests__/page-renderer-migration.test.tsx +1 -1
  142. package/templates/apps/web/app/[locale]/templates/[slug]/__tests__/page.test.tsx +203 -0
  143. package/templates/apps/web/app/[locale]/templates/[slug]/page.tsx +81 -0
  144. package/templates/apps/web/app/[locale]/templates/__tests__/page.test.tsx +236 -0
  145. package/templates/apps/web/app/[locale]/templates/page.tsx +108 -0
  146. package/templates/apps/web/app/[locale]/testimonials/__tests__/page-renderer-migration.test.tsx +1 -1
  147. package/templates/apps/web/app/[locale]/whitepapers/[slug]/page.tsx +24 -3
  148. package/templates/apps/web/app/[locale]/whitepapers/__tests__/page-renderer-migration.test.tsx +1 -1
  149. package/templates/apps/web/app/api/auth/hub/authorize/__tests__/route.test.ts +204 -2
  150. package/templates/apps/web/app/api/auth/hub/authorize/route.ts +151 -33
  151. package/templates/apps/web/app/api/offerings/checkout/[lookupKey]/__tests__/route.test.ts +233 -7
  152. package/templates/apps/web/app/api/offerings/checkout/[lookupKey]/route.ts +92 -41
  153. package/templates/apps/web/app/api/offerings/checkout/__tests__/route.test.ts +77 -39
  154. package/templates/apps/web/app/llms.txt/route.ts +41 -3
  155. package/templates/apps/web/app/robots.txt/route.ts +5 -1
  156. package/templates/apps/web/app/sitemap.xml/route.ts +45 -3
  157. package/templates/apps/web/auth.ts +102 -50
  158. package/templates/apps/web/components/signed-in-nav.tsx +8 -1
  159. package/templates/apps/web/lib/__tests__/bot-check.test.ts +254 -0
  160. package/templates/apps/web/lib/__tests__/consent-visitor.test.ts +212 -13
  161. package/templates/apps/web/lib/__tests__/deck-page.test.ts +13 -1
  162. package/templates/apps/web/lib/__tests__/docs-index.test.ts +93 -0
  163. package/templates/apps/web/lib/__tests__/docs-path.test.ts +168 -0
  164. package/templates/apps/web/lib/__tests__/hub-children.fixtures.ts +278 -0
  165. package/templates/apps/web/lib/__tests__/nav-label-keys.test.ts +138 -0
  166. package/templates/apps/web/lib/__tests__/newsletter.actions.test.ts +178 -2
  167. package/templates/apps/web/lib/__tests__/offerings-pricing.test.ts +183 -0
  168. package/templates/apps/web/lib/__tests__/page-chrome.test.ts +412 -0
  169. package/templates/apps/web/lib/__tests__/pricing-page.test.ts +142 -0
  170. package/templates/apps/web/lib/__tests__/relay.test.ts +202 -10
  171. package/templates/apps/web/lib/__tests__/route-family.test.ts +57 -0
  172. package/templates/apps/web/lib/__tests__/service-listing.test.ts +111 -0
  173. package/templates/apps/web/lib/__tests__/services-path.test.ts +96 -0
  174. package/templates/apps/web/lib/__tests__/site-header.test.ts +151 -0
  175. package/templates/apps/web/lib/__tests__/structured-data.test.tsx +148 -6
  176. package/templates/apps/web/lib/__tests__/telemetry-scrub.test.ts +84 -0
  177. package/templates/apps/web/lib/__tests__/template-listing.test.ts +158 -0
  178. package/templates/apps/web/lib/auth-secret.ts +57 -0
  179. package/templates/apps/web/lib/bot-check.ts +138 -0
  180. package/templates/apps/web/lib/consent-visitor.ts +81 -7
  181. package/templates/apps/web/lib/deck-page.ts +6 -3
  182. package/templates/apps/web/lib/docs-index.ts +100 -0
  183. package/templates/apps/web/lib/docs-path.ts +94 -0
  184. package/templates/apps/web/lib/hub-children.tsx +318 -0
  185. package/templates/apps/web/lib/modules.generated.ts +9 -0
  186. package/templates/apps/web/lib/nav-label-check.ts +61 -0
  187. package/templates/apps/web/lib/nav-label-keys.ts +78 -0
  188. package/templates/apps/web/lib/newsletter.actions.ts +31 -6
  189. package/templates/apps/web/lib/offerings-pricing.ts +135 -6
  190. package/templates/apps/web/lib/page-chrome.ts +367 -0
  191. package/templates/apps/web/lib/posting-title.ts +26 -4
  192. package/templates/apps/web/lib/pricing-page.ts +430 -0
  193. package/templates/apps/web/lib/relay.ts +31 -106
  194. package/templates/apps/web/lib/route-family.ts +69 -0
  195. package/templates/apps/web/lib/service-listing.ts +89 -0
  196. package/templates/apps/web/lib/services-path.ts +97 -0
  197. package/templates/apps/web/lib/site-header.ts +94 -0
  198. package/templates/apps/web/lib/telemetry-scrub.ts +68 -3
  199. package/templates/apps/web/lib/template-listing.ts +169 -0
  200. package/templates/apps/web/messages/en.json +86 -30
  201. package/templates/apps/web/middleware.ts +171 -11
  202. package/templates/apps/web/next.config.mjs +211 -2
  203. package/templates/apps/web/package.json +1 -1
  204. package/templates/apps/web/scripts/assert-content-bundled.ts +28 -6
  205. package/templates/apps/web/scripts/check-nav-labels.ts +39 -0
  206. package/templates/apps/web/sentry.server.config.ts +30 -2
  207. package/templates/apps/web/src/lib/__tests__/rate-limit.test.ts +67 -9
  208. package/templates/apps/web/src/lib/rate-limit.ts +68 -27
  209. package/templates/apps/web/src/lib/routes.ts +15 -1
  210. package/versions.json +48 -48
@@ -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 { SCHEMA_ORG_TYPES } from "@working-theory/content-adapters";
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 { resolveBaseUrl, slugToPath } from "../../src/lib/routes";
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
- const path = slugToPath(page.type, page.slug);
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
- const stealth = parseStealth(process.env["STEALTH"]);
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 { SCHEMA_ORG_TYPES } from "@working-theory/content-adapters";
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 { resolveBaseUrl, slugToPath } from "../../src/lib/routes";
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
- const path = slugToPath(page.type, page.slug);
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 the secret used to sign session cookies AND — since #5962 § D9 — the
186
- * email-verification JWTs.
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
- * Exported (#8146 D5) so the hub's sign-in continuation seals its ref under the
204
- * same resolved secret instead of re-reading the variable: the second consumer
205
- * after `betterAuth({ secret })`.
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
- export function resolveSecret(): string {
208
- const configured = process.env.BETTER_AUTH_SECRET;
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
- if (process.env.NODE_ENV === "production" && !isBuildPhase) {
227
- throw new Error(
228
- "BETTER_AUTH_SECRET is required in production: it signs session cookies and email-verification tokens, and the development fallback is published in this repository.",
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 "ailk-dev-fallback-secret-published-in-this-repo-never-use-in-production";
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). No other database hook is installed.
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: signUpAgreementHooks(),
467
- secret: resolveSecret(),
468
- baseURL: process.env.BETTER_AUTH_URL,
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
- ...(process.env.BETTER_AUTH_URL
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" ? [] : navItems}
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
+ });