create-ailk 0.30.0 → 0.32.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (95) hide show
  1. package/README.md +108 -4
  2. package/component-catalog.json +524 -8
  3. package/component-catalog.md +8 -5
  4. package/dist/body-heading.d.ts +13 -0
  5. package/dist/body-heading.js +19 -5
  6. package/dist/cli.js +99 -0
  7. package/dist/cutover.d.ts +114 -0
  8. package/dist/cutover.js +511 -0
  9. package/dist/design-presets.js +20 -4
  10. package/dist/export-content.d.ts +55 -0
  11. package/dist/export-content.js +303 -0
  12. package/dist/inventory.d.ts +216 -0
  13. package/dist/inventory.js +833 -0
  14. package/dist/language-import.d.ts +127 -0
  15. package/dist/language-import.js +451 -0
  16. package/dist/lib/env-catalog.js +1 -1
  17. package/dist/lib/import-mdx.d.ts +38 -0
  18. package/dist/lib/import-mdx.js +445 -65
  19. package/dist/lib/render-comparison.d.ts +251 -0
  20. package/dist/lib/render-comparison.js +573 -0
  21. package/dist/mdx-braces.d.ts +73 -0
  22. package/dist/mdx-braces.js +285 -0
  23. package/dist/migrate-content.d.ts +6 -2
  24. package/dist/migrate-content.js +62 -14
  25. package/dist/programmatic.js +2 -8
  26. package/dist/seed-workspace.d.ts +3 -1
  27. package/dist/seed-workspace.js +45 -16
  28. package/dist/site-manifest.js +6 -38
  29. package/dist/site-roster.d.ts +46 -3
  30. package/dist/site-roster.js +124 -4
  31. package/dist/surfaces.d.ts +8 -0
  32. package/dist/surfaces.js +16 -0
  33. package/dist/topology.d.ts +12 -0
  34. package/dist/topology.js +24 -10
  35. package/dist/update.d.ts +21 -1
  36. package/dist/update.js +142 -11
  37. package/package.json +3 -3
  38. package/templates/.claude/cloudparity-repo-hooks.json +5 -0
  39. package/templates/.claude/hooks/content-source-guard.sh +75 -0
  40. package/templates/.claude/settings.json +9 -0
  41. package/templates/.github/workflows/ci.yml +4 -0
  42. package/templates/CLAUDE.md +5 -0
  43. package/templates/apps/mcp/.env.example +7 -1
  44. package/templates/apps/mcp/CLAUDE.md +8 -4
  45. package/templates/apps/web/.env.example +4 -2
  46. package/templates/apps/web/CLAUDE.md +16 -5
  47. package/templates/apps/web/__tests__/setup.ts +9 -0
  48. package/templates/apps/web/app/[locale]/[slug]/[detail]/__tests__/page.test.tsx +156 -4
  49. package/templates/apps/web/app/[locale]/[slug]/[detail]/page.tsx +45 -2
  50. package/templates/apps/web/app/[locale]/[slug]/__tests__/page.test.tsx +3 -0
  51. package/templates/apps/web/app/[locale]/about/__tests__/about-routes.test.tsx +3 -0
  52. package/templates/apps/web/app/[locale]/careers/__tests__/page-renderer-migration.test.tsx +3 -0
  53. package/templates/apps/web/app/[locale]/contact/__tests__/site-contact-lines.test.ts +49 -0
  54. package/templates/apps/web/app/[locale]/contact/site-contact-lines.ts +6 -4
  55. package/templates/apps/web/app/[locale]/events/[slug]/page.tsx +13 -1
  56. package/templates/apps/web/app/[locale]/events/__tests__/program-event.test.tsx +120 -0
  57. package/templates/apps/web/app/[locale]/events/page.tsx +11 -0
  58. package/templates/apps/web/app/[locale]/flows/[slug]/FlowStepperClient.tsx +44 -5
  59. package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/FlowStepperClient.thanks-by-tier.test.tsx +306 -0
  60. package/templates/apps/web/app/[locale]/layout.tsx +122 -27
  61. package/templates/apps/web/app/[locale]/pricing/[slug]/__tests__/page.test.tsx +3 -0
  62. package/templates/apps/web/app/[locale]/pricing/__tests__/offerings.test.tsx +3 -0
  63. package/templates/apps/web/app/[locale]/pricing/__tests__/page.test.tsx +3 -0
  64. package/templates/apps/web/app/[locale]/pricing/__tests__/unconnected.test.tsx +3 -0
  65. package/templates/apps/web/app/[locale]/projects/__tests__/page-renderer-migration.test.tsx +3 -0
  66. package/templates/apps/web/app/[locale]/services/__tests__/page-renderer-migration.test.tsx +3 -0
  67. package/templates/apps/web/app/[locale]/team/__tests__/team-routes.test.tsx +3 -0
  68. package/templates/apps/web/app/[locale]/thanks/[slug]/__tests__/page.test.tsx +21 -2
  69. package/templates/apps/web/app/[locale]/thanks/[slug]/page.tsx +1 -0
  70. package/templates/apps/web/app/llms.txt/route.ts +16 -9
  71. package/templates/apps/web/app/sitemap.xml/route.ts +3 -0
  72. package/templates/apps/web/jest.config.cjs +46 -1
  73. package/templates/apps/web/lib/__tests__/bot-check.test.ts +247 -4
  74. package/templates/apps/web/lib/__tests__/in-page-magnet-self-request.test.ts +92 -0
  75. package/templates/apps/web/lib/__tests__/page-chrome.test.ts +93 -0
  76. package/templates/apps/web/lib/__tests__/relay.test.ts +33 -8
  77. package/templates/apps/web/lib/__tests__/site-identity-feature-list.test.ts +196 -0
  78. package/templates/apps/web/lib/__tests__/structured-data.test.tsx +145 -11
  79. package/templates/apps/web/lib/bot-check.ts +113 -13
  80. package/templates/apps/web/lib/in-page-magnet.ts +4 -1
  81. package/templates/apps/web/lib/page-chrome.ts +65 -5
  82. package/templates/apps/web/lib/relay.ts +22 -3
  83. package/templates/apps/web/lib/site-identity.ts +75 -24
  84. package/templates/apps/web/messages/en.json +5 -1
  85. package/templates/apps/web/next.config.mjs +15 -1
  86. package/templates/apps/web/package.json +1 -1
  87. package/templates/apps/web/scripts/assert-content-bundled.ts +36 -8
  88. package/templates/apps/web/scripts/content-snapshot.ts +33 -0
  89. package/templates/apps/web/src/lib/routes.ts +5 -3
  90. package/templates/package.json +3 -1
  91. package/templates/tools/body-heading.ts +440 -0
  92. package/templates/tools/check-content-source.ts +108 -0
  93. package/templates/tools/check-mdx-body.ts +123 -0
  94. package/templates/tools/mdx-braces.ts +303 -0
  95. package/versions.json +48 -48
package/dist/update.js CHANGED
@@ -36,29 +36,35 @@
36
36
  * before the first write. Last, every mdx-body page in the site's `content/`
37
37
  * with no `# ` heading gets its title and description written into its body
38
38
  * (#7495, `body-heading.ts`); a page with one or more headings is never
39
- * touched, and `content/` is never recorded.
39
+ * touched, and `content/` is never recorded. Then every bare `{` on an
40
+ * mdx-body page is escaped as `\{`, kept only when the site's own MDX parser
41
+ * reads the page clean (#10773, `mdx-braces.ts`).
40
42
  *
41
43
  * Pure module: no `import.meta.url` — the CLI supplies `templatesDir`, the
42
44
  * `adopt.ts` / `add-route.ts` split.
43
45
  */
44
46
  import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, rmdirSync, statSync, writeFileSync, } from "node:fs";
47
+ import { createRequire } from "node:module";
45
48
  import { dirname, join, relative, resolve, sep } from "node:path";
49
+ import { pathToFileURL } from "node:url";
46
50
  import { parseArgs } from "node:util";
47
51
  import { AddModuleRefusedError, readArchitectureHeader } from "./add-module-core.js";
48
- import { headingViolations, mdxBodyRule, migrateBodyHeadings } from "./body-heading.js";
52
+ import { headingViolations, mdxBodyPages, mdxBodyRule, migrateBodyHeadings } from "./body-heading.js";
49
53
  import { buildScaffoldMarkers, isExcluded, isTextFile, renderTemplateFile, templateDestination } from "./copy.js";
54
+ import { deriveArchitecture } from "./derive-architecture.js";
50
55
  import { EnvContractError, planEnvRows, readEnvContract, readManifestTopology, } from "./lib/declared-env.js";
51
56
  import { ENV_CATALOG } from "./lib/env-catalog.js";
52
57
  import { fetchTemplateSnapshot } from "./lib/fetch-template-snapshot.js";
53
58
  import { exportsName, listFilesUnder, namedImportsOf } from "./lib/import-graph.js";
54
59
  import { mergeThreeWay } from "./lib/merge-three-way.js";
55
60
  import { publicTreeRemoved } from "./lib/site-modules.js";
61
+ import { escapeBareBraces, findBareBraces } from "./mdx-braces.js";
56
62
  import { MODULE_SLICES } from "./module-architecture.js";
57
63
  import { MODULE_FLAGS_PATH, PAID_MCP_TOOLS_PATH, writeModuleFlags, writePaidMcpTools, } from "./paid-sections.js";
58
64
  import { ParseError } from "./parse-args.js";
59
- import { SITE_ROSTER_PATH } from "./site-roster.js";
65
+ import { SITE_ROSTER_PATH, planServedRoster, readRosterPageRoutes } from "./site-roster.js";
60
66
  import { SPOKE_CHECKOUT_TEMPLATE_PATHS } from "./spoke-checkout.js";
61
- import { excludedRoutePaths, isRemovedPublicRoutePath } from "./surfaces.js";
67
+ import { GATEABLE_SURFACES, excludedRoutePaths, isRemovedPublicRoutePath, resolveSurfaces, surfacesNamedByRoutes, } from "./surfaces.js";
62
68
  import { DATABASE_BOOT_FILES, ROOT_TOOL_FILES, TEMPLATE_RECORD_PATH, isRecordable, parseTemplateRecord, serializeTemplateRecord, sha256Hex, } from "./template-record.js";
63
69
  import { MCP_SEAM_PATH, MCP_SERVER_HUB, TopologyError, isSpokeExcluded, readSpokeShape, spokeExclusions, } from "./topology.js";
64
70
  /** A refusal the CLI exits 2 on, thrown before anything is written. */
@@ -930,6 +936,35 @@ export async function updateSite(opts) {
930
936
  throw err;
931
937
  }
932
938
  const excludedRoutes = excludedRoutePaths(header.surfaces);
939
+ // #10774 D3 — a module-less surface the roster's page rows name but the header omits
940
+ // (the /projects pair a 0.19.0 scaffold rostered without its route) is in
941
+ // scope for the add loop only, and declared after the writes (even when an
942
+ // add is held back, #10173: the next run adds it). The
943
+ // roster is read even when it brings nothing, so an unreadable one is
944
+ // refused here, before the first write, not at the drop after it.
945
+ let rosterRoutes;
946
+ try {
947
+ rosterRoutes = readRosterPageRoutes(targetDir) ?? [];
948
+ }
949
+ catch (err) {
950
+ if (err instanceof AddModuleRefusedError)
951
+ throw new UpdateRefusedError(err.message);
952
+ throw err;
953
+ }
954
+ const declaredByRoster = publicTreeRemoved(targetDir)
955
+ ? []
956
+ : surfacesNamedByRoutes(rosterRoutes).filter((surface) => !header.surfaces.includes(surface));
957
+ const addExcludedRoutes = excludedRoutePaths([...header.surfaces, ...declaredByRoster]);
958
+ const declaredRouteDirs = GATEABLE_SURFACES.filter((surface) => declaredByRoster.includes(surface.id)).flatMap((surface) => surface.routes);
959
+ let declaredArchitecture;
960
+ if (declaredByRoster.length > 0) {
961
+ try {
962
+ declaredArchitecture = deriveArchitecture(header.modules, resolveSurfaces(header.modules, [...header.surfaces, ...declaredByRoster]), spoke !== undefined ? { topology: "spoke", mcp: existsSync(join(targetDir, "apps", "mcp")) } : {});
963
+ }
964
+ catch (err) {
965
+ throw new UpdateRefusedError(`architecture.yaml's \`# Surfaces:\` header cannot declare ${declaredByRoster.join(", ")}: ${err.message}`);
966
+ }
967
+ }
933
968
  const spokeExclusionList = spoke !== undefined ? spokeExclusions(opts.templatesDir, spoke) : undefined;
934
969
  // #8748 D3 — the standard spoke checkout is a commerce spoke's alone.
935
970
  const sellsAsSpoke = spoke !== undefined && header.modules.includes("commerce");
@@ -937,10 +972,10 @@ export async function updateSite(opts) {
937
972
  // never touched: its boot paths are out of scope, reported under the guard.
938
973
  const databaseIsPackage = existsSync(join(targetDir, "database", "package.json"));
939
974
  const bootFileGuarded = (destRel) => databaseIsPackage && DATABASE_BOOT_FILES.includes(destRel);
940
- const outOfScope = (destRel) => {
975
+ const outOfScope = (destRel, excluded = excludedRoutes) => {
941
976
  if (bootFileGuarded(destRel))
942
977
  return true;
943
- if (isExcluded(destRel, excludedRoutes))
978
+ if (isExcluded(destRel, excluded))
944
979
  return true;
945
980
  if (!sellsAsSpoke && isExcluded(destRel, SPOKE_CHECKOUT_TEMPLATE_PATHS))
946
981
  return true;
@@ -1232,9 +1267,11 @@ export async function updateSite(opts) {
1232
1267
  }
1233
1268
  continue;
1234
1269
  }
1235
- if (outOfScope(path))
1270
+ if (outOfScope(path, addExcludedRoutes))
1236
1271
  continue; // rule i/ii
1237
- const highest = highestMissingAncestor(path, targetDir);
1272
+ // #10774 D3 — a surface this run declares was never copied, so its
1273
+ // missing directory is no owner's deletion: rule iii does not apply.
1274
+ const highest = isExcluded(path, declaredRouteDirs) ? undefined : highestMissingAncestor(path, targetDir);
1238
1275
  if (highest !== undefined && containsKnownPath(highest, knownPaths))
1239
1276
  continue; // rule iii
1240
1277
  writes.push({ path, bytes: Buffer.from(newBytes) });
@@ -1484,6 +1521,15 @@ export async function updateSite(opts) {
1484
1521
  if (recordChanged) {
1485
1522
  writeFileSync(recordPath, serializeTemplateRecord({ schema: 1, projectName: record.projectName, files: nextFiles }));
1486
1523
  }
1524
+ // #10774 D3 — the surfaces the roster named are declared, as add-route
1525
+ // declares one: the whole generated file, re-derived.
1526
+ if (declaredArchitecture !== undefined)
1527
+ writeFileSync(archPath, declaredArchitecture);
1528
+ // #10774 D4.3 — the roster's code-route rows no page file serves, read
1529
+ // after the writes so a route this run added counts as served.
1530
+ const servedRoster = planServedRoster(targetDir);
1531
+ if (servedRoster?.next !== undefined)
1532
+ writeFileSync(join(targetDir, servedRoster.path), servedRoster.next);
1487
1533
  // #8843 D10 — the module-presence flags are generated, never copied
1488
1534
  // (REGENERATED_APP_PATHS): render them from this site's own
1489
1535
  // apps/web/package.json on every run, so a site updated onto a layout
@@ -1570,6 +1616,34 @@ export async function updateSite(opts) {
1570
1616
  for (const { path, reason } of headings.refused) {
1571
1617
  review.push({ path: sitePath(path), reason: `no '# ' heading added: ${reason}` });
1572
1618
  }
1619
+ // #10773 D6 — a bare `{` on an mdx-body page is MDX code: the page fails or
1620
+ // drops it. Escape every one, after the heading pass so its writes are read
1621
+ // too, keeping a page's write only when the site's own parser reads it clean.
1622
+ const parse = opts.mdxParse === undefined ? await loadSiteMdxParse(targetDir) : opts.mdxParse ?? undefined;
1623
+ const bracesEscaped = [];
1624
+ for (const { path, raw, bodyStart } of mdxBodyPages(contentDir, rule)) {
1625
+ // Blank lines in place of the frontmatter, so every line named is the file's.
1626
+ const pad = "\n".repeat(raw.slice(0, bodyStart).split("\n").length - 1);
1627
+ const body = pad + raw.slice(bodyStart);
1628
+ if (parse === undefined) {
1629
+ const lines = findBareBraces(body).map((brace) => brace.line);
1630
+ if (lines.length === 0)
1631
+ continue;
1632
+ review.push({
1633
+ path: sitePath(path),
1634
+ reason: `${lines.length} bare {…} at line ${[...new Set(lines)].join(", ")}; not escaped: the site's MDX parser is not installed — run pnpm install, then update again`,
1635
+ });
1636
+ continue;
1637
+ }
1638
+ const escape = escapeBareBraces(body, parse);
1639
+ if (escape.kind === "escaped") {
1640
+ writeFileSync(path, raw.slice(0, bodyStart) + escape.text.slice(pad.length));
1641
+ bracesEscaped.push(sitePath(path));
1642
+ }
1643
+ else if (escape.kind === "refused") {
1644
+ review.push({ path: sitePath(path), reason: `bare {…} not escaped: ${escape.reason}` });
1645
+ }
1646
+ }
1573
1647
  return {
1574
1648
  targetDir,
1575
1649
  version,
@@ -1578,11 +1652,48 @@ export async function updateSite(opts) {
1578
1652
  skipped,
1579
1653
  heldBack,
1580
1654
  headingsAdded: headings.written.map(sitePath),
1655
+ bracesEscaped,
1581
1656
  ...(backfilled !== undefined ? { backfilled } : {}),
1582
1657
  ...(envRowsAdded.length > 0 ? { envRowsAdded } : {}),
1583
1658
  ...(droppedRows.length > 0 ? { droppedRows: droppedRows.sort() } : {}),
1659
+ ...(declaredByRoster.length > 0 ? { surfacesDeclared: declaredByRoster } : {}),
1660
+ ...(servedRoster !== undefined && servedRoster.dropped.length > 0
1661
+ ? { rosterRowsDropped: servedRoster.dropped }
1662
+ : {}),
1584
1663
  };
1585
1664
  }
1665
+ /** Whether a `node_modules` at or above `dir` holds `pkg` — so `resolve` never falls back to NODE_PATH. */
1666
+ function installedAbove(dir, pkg) {
1667
+ for (let at = dir;; at = dirname(at)) {
1668
+ if (existsSync(join(at, "node_modules", pkg, "package.json")))
1669
+ return true;
1670
+ if (dirname(at) === at)
1671
+ return false;
1672
+ }
1673
+ }
1674
+ /**
1675
+ * The site's own MDX parser (#10773 D5): `@mdx-js/mdx` resolved through the
1676
+ * `next-mdx-remote` its `apps/web` renders with — never create-ailk's own
1677
+ * copy, and never fetched. `undefined` when it is not installed or fails to
1678
+ * load; never throws.
1679
+ */
1680
+ export async function loadSiteMdxParse(targetDir) {
1681
+ try {
1682
+ const web = join(resolve(targetDir), "apps", "web");
1683
+ if (!installedAbove(web, "next-mdx-remote"))
1684
+ return undefined;
1685
+ const remote = createRequire(join(web, "package.json")).resolve("next-mdx-remote/package.json");
1686
+ const entry = createRequire(remote).resolve("@mdx-js/mdx");
1687
+ const mdx = (await import(pathToFileURL(entry).href));
1688
+ const createProcessor = mdx.createProcessor;
1689
+ if (typeof createProcessor !== "function")
1690
+ return undefined;
1691
+ return (body) => createProcessor().parse(body);
1692
+ }
1693
+ catch {
1694
+ return undefined;
1695
+ }
1696
+ }
1586
1697
  /** The printed report (D9): one section per non-empty list, in order. */
1587
1698
  export function formatUpdateReport(result) {
1588
1699
  const lines = [`[create-ailk] update → ${result.targetDir}`];
@@ -1596,6 +1707,7 @@ export function formatUpdateReport(result) {
1596
1707
  ["Removed", result.removed],
1597
1708
  ["Already current", result.current],
1598
1709
  ["Page heading added", result.headingsAdded],
1710
+ ["Placeholder brace escaped", result.bracesEscaped],
1599
1711
  ];
1600
1712
  for (const [label, paths] of sections) {
1601
1713
  if (paths.length === 0)
@@ -1620,6 +1732,14 @@ export function formatUpdateReport(result) {
1620
1732
  lines.push(`Dropped from ${TEMPLATE_RECORD_PATH}, no longer recorded (${result.droppedRows.length}):`);
1621
1733
  lines.push(...result.droppedRows.map((path) => ` ${path}`));
1622
1734
  }
1735
+ if (result.surfacesDeclared !== undefined) {
1736
+ lines.push(`Surfaces declared from the roster (${result.surfacesDeclared.length}):`);
1737
+ lines.push(...result.surfacesDeclared.map((surface) => ` ${surface}`));
1738
+ }
1739
+ if (result.rosterRowsDropped !== undefined) {
1740
+ lines.push(`Roster rows dropped, no route serves them (${result.rosterRowsDropped.length}):`);
1741
+ lines.push(...result.rosterRowsDropped.map((route) => ` ${route}`));
1742
+ }
1623
1743
  if (result.skipped.length > 0) {
1624
1744
  lines.push(`Skipped (${result.skipped.length}):`);
1625
1745
  lines.push(...result.skipped.map((row) => ` ${row.path} — ${row.reason}`));
@@ -1631,10 +1751,13 @@ export function formatUpdateReport(result) {
1631
1751
  result.removed.length === 0 &&
1632
1752
  result.current.length === 0 &&
1633
1753
  result.headingsAdded.length === 0 &&
1754
+ result.bracesEscaped.length === 0 &&
1634
1755
  result.review.length === 0 &&
1635
1756
  result.skipped.length === 0 &&
1636
1757
  result.envRowsAdded === undefined &&
1637
- result.droppedRows === undefined;
1758
+ result.droppedRows === undefined &&
1759
+ result.surfacesDeclared === undefined &&
1760
+ result.rosterRowsDropped === undefined;
1638
1761
  if (nothingHappened) {
1639
1762
  return `[create-ailk] Already up to date (create-ailk ${result.version}).\n`;
1640
1763
  }
@@ -1677,6 +1800,11 @@ same way, under "Held back with <file> (not in this site)". Every other file
1677
1800
  in the run still updates. When project.yaml has an env_contract block, each
1678
1801
  env var the written files read that has no row gains one; no row is edited.
1679
1802
  A record row at a path create-ailk no longer records is dropped and listed.
1803
+ A surface the site's .ailk-site-manifest.json names a page on but
1804
+ architecture.yaml does not declare (one no module owns, such as projects)
1805
+ gets its route files added and is declared, listed under "Surfaces declared
1806
+ from the roster". A code-route row of that roster no page file serves is
1807
+ dropped, listed under "Roster rows dropped, no route serves them".
1680
1808
  A site with no record gets one on its first run, from the version its
1681
1809
  .ailk-site-manifest.json already records. A site scaffolded before
1682
1810
  @working-theory/mcp-server has its copied apps/mcp moved to the boot set:
@@ -1691,8 +1819,11 @@ app's package.json gained dependencies. Each mdx-body page in content/ with no
1691
1819
  '# ' heading gets its title and description written into its body as its
1692
1820
  heading and intro, listed under "Page heading added"; a page with one heading
1693
1821
  is left as it is, and one with several, or one whose frontmatter it cannot
1694
- read, is listed under "Needs review". Commit before running, and review the
1695
- diff after.
1822
+ read, is listed under "Needs review". Each bare { on an mdx-body page is
1823
+ escaped as \\{ when the site's own MDX parser then reads the page clean, listed
1824
+ under "Placeholder brace escaped"; a page it cannot escape safely, or any page
1825
+ with one while the site's dependencies are not installed, is listed under
1826
+ "Needs review". Commit before running, and review the diff after.
1696
1827
  `;
1697
1828
  /** Parse the argv AFTER the `update` word. Strict `node:util` parseArgs, like `parseAdoptArgs`. */
1698
1829
  export function parseUpdateArgs(argv) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-ailk",
3
- "version": "0.30.0",
3
+ "version": "0.32.0",
4
4
  "description": "Scaffolder for AI Launch Kit — `pnpm create ailk@latest <dir>` produces a starter repo consuming @working-theory/* from npm (per ADR 0007).",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -28,8 +28,8 @@
28
28
  "tar": "^7.5.21"
29
29
  },
30
30
  "devDependencies": {
31
- "@working-theory/schema": "0.53.0",
32
- "@working-theory/templates": "0.53.0"
31
+ "@working-theory/schema": "0.55.0",
32
+ "@working-theory/templates": "0.55.0"
33
33
  },
34
34
  "repository": {
35
35
  "type": "git",
@@ -5,6 +5,11 @@
5
5
  "matcher": "Edit|Write",
6
6
  "command": "bash .claude/hooks/template-guard.sh"
7
7
  },
8
+ {
9
+ "event": "PreToolUse",
10
+ "matcher": "Edit|Write",
11
+ "command": "bash .claude/hooks/content-source-guard.sh"
12
+ },
8
13
  {
9
14
  "event": "PostToolUse",
10
15
  "matcher": "Edit|Write",
@@ -0,0 +1,75 @@
1
+ #!/usr/bin/env bash
2
+ # PreToolUse (Edit|Write): refuse a new page file on a site whose pages come
3
+ # from the database. When CONTENT_ADAPTER=database — in the environment or in
4
+ # one of Next's eight env files under apps/web/ — a call whose file_path is a
5
+ # content/en/**/*.mdx that does not exist yet is refused (exit 2); the refusal
6
+ # on stderr names the MCP tools that change a page instead. An existing page
7
+ # file, any other path and a files site pass silently. The same rule and the
8
+ # same message as tools/check-content-source.ts, which the preflight runs.
9
+ # Pure bash and awk — nothing a scaffold has to install. Registered in
10
+ # .claude/settings.json and declared in .claude/cloudparity-repo-hooks.json;
11
+ # keep the two in step.
12
+ export LC_ALL=C
13
+ cd "$CLAUDE_PROJECT_DIR" || exit 0
14
+
15
+ IFS= read -r -d '' input
16
+ [[ $input =~ \"file_path\"[[:space:]]*:[[:space:]]*\"([^\"\\]*)\" ]] || exit 0
17
+ rel=${BASH_REMATCH[1]}
18
+ case $rel in
19
+ "$PWD"/*) rel=${rel#"$PWD"/} ;;
20
+ /*) exit 0 ;;
21
+ ./*) rel=${rel#./} ;;
22
+ esac
23
+ case "/$rel/" in
24
+ */./*|*/../*|*//*) exit 0 ;;
25
+ esac
26
+ # `*` crosses `/` in a case pattern, so this is content/en/**/*.mdx.
27
+ case $rel in
28
+ content/en/*.mdx) ;;
29
+ *) exit 0 ;;
30
+ esac
31
+ [ ! -e "$rel" ] || exit 0
32
+
33
+ # The source rule: the environment, else the first env file whose last
34
+ # CONTENT_ADAPTER assignment reads exactly `database`.
35
+ source_name=
36
+ if [ "${CONTENT_ADAPTER-}" = database ]; then
37
+ source_name="the environment"
38
+ else
39
+ for name in .env .env.local .env.development .env.development.local \
40
+ .env.production .env.production.local .env.test .env.test.local; do
41
+ file=apps/web/$name
42
+ [ -f "$file" ] || continue
43
+ value=$(awk -v sq="'" '
44
+ /^[ \t]*(export[ \t]+)?CONTENT_ADAPTER[ \t]*=/ {
45
+ v = substr($0, index($0, "=") + 1)
46
+ n = length(v); quote = ""; cut = n
47
+ for (i = 1; i <= n; i++) {
48
+ c = substr(v, i, 1)
49
+ if (quote != "") { if (c == quote) quote = ""; continue }
50
+ if (c == "\"" || c == sq) { quote = c; continue }
51
+ if (c == "#" && i > 1 && substr(v, i - 1, 1) ~ /[ \t]/) { cut = i - 1; break }
52
+ }
53
+ v = substr(v, 1, cut)
54
+ sub(/^[ \t\r]+/, "", v); sub(/[ \t\r]+$/, "", v)
55
+ f = substr(v, 1, 1)
56
+ if (length(v) >= 2 && (f == "\"" || f == sq) && substr(v, length(v), 1) == f) {
57
+ v = substr(v, 2, length(v) - 2)
58
+ }
59
+ last = v
60
+ }
61
+ END { print last }
62
+ ' "$file")
63
+ if [ "$value" = database ]; then
64
+ source_name=$file
65
+ break
66
+ fi
67
+ done
68
+ fi
69
+ [ -n "$source_name" ] || exit 0
70
+
71
+ printf '%s\n' >&2 \
72
+ "content-source: $rel is a page file, but this site's pages come from the database (CONTENT_ADAPTER=database in $source_name), so the site never reads it." \
73
+ "Change a page through the site's MCP tools instead: save_draft writes the draft and publish_draft publishes it; read_content returns the current page." \
74
+ "Pages and layout: words live in the database, layout stays in code (CLAUDE.md § Content)."
75
+ exit 2
@@ -9,6 +9,15 @@
9
9
  "command": "bash .claude/hooks/template-guard.sh"
10
10
  }
11
11
  ]
12
+ },
13
+ {
14
+ "matcher": "Edit|Write",
15
+ "hooks": [
16
+ {
17
+ "type": "command",
18
+ "command": "bash .claude/hooks/content-source-guard.sh"
19
+ }
20
+ ]
12
21
  }
13
22
  ],
14
23
  "PostToolUse": [
@@ -67,6 +67,10 @@ jobs:
67
67
  # A hand-edit inside a template-owned section of CLAUDE.md — the same
68
68
  # check `pnpm preflight` runs locally.
69
69
  - run: pnpm claude-md:check
70
+ # A new page file on a site whose pages come from the database (#10660).
71
+ - run: pnpm content-source:check
72
+ # A bare { on an mdx-body page, which fails the page or drops it (#10773).
73
+ - run: pnpm mdx-body:check
70
74
 
71
75
  - run: pnpm test
72
76
 
@@ -178,6 +178,11 @@ sections:
178
178
  Copy an existing file in the same folder, then change it. Text in `{braces}`
179
179
  is placeholder copy. Don't invent a `type` that `@working-theory/schema`
180
180
  doesn't export.
181
+
182
+ Where a page lives depends on `CONTENT_ADAPTER` in `apps/web/.env.local`.
183
+ Unset or `mdx` (the default): the file above. `database`: the database —
184
+ change a page with the MCP tools `save_draft` then `publish_draft`; a new
185
+ file under `content/en/` is refused by a hook and by `pnpm preflight`.
181
186
  <!-- /ailk:template id="content" -->
182
187
 
183
188
  <!-- ailk:template id="hard-floors" -->
@@ -91,9 +91,15 @@ MCP_ABUSE_BLOCKED_USER_AGENTS=
91
91
  # (the Artifact store — the server resolves its own single workspace at
92
92
  # bootstrap; a deployment that isn't unambiguously one-workspace serves no
93
93
  # content rather than guessing).
94
+ # On a SPOKE (#10658), CONTENT_ADAPTER=database reads this spoke's own
95
+ # published pages through the hub at AILK_API_URL under AILK_SPOKE_KEY (below),
96
+ # with no DATABASE_URL — set it once the spoke's page files are removed. A
97
+ # spoke whose key will not be sent (unset, not https or loopback, or no
98
+ # AILK_API_URL) serves no content; the boot writes one stderr line saying
99
+ # which way it went.
94
100
  # CONTENT_ADAPTER=mdx
95
101
 
96
- # Required ONLY when CONTENT_ADAPTER=database.
102
+ # Required ONLY on a hub's CONTENT_ADAPTER=database — never on a spoke.
97
103
  # DATABASE_URL=
98
104
 
99
105
  # ── Spoke key ─────────────────────────────────────────────────────────────────
@@ -187,7 +187,7 @@ here. Spec: `docs/specs/3591-tenant-scoped-agent-auth-secure-spec.md` D13–D16.
187
187
  - **apps/mcp checks only the key's shape.** The key is attached as the SDK's `req.auth` for that
188
188
  request alone and reaches a tool as `extra.authInfo.token`. apps/api is the one place it is
189
189
  checked, and its tenant guard and the caller's clamped role decide every answer.
190
- - **Thirty-eight tools, each a `twin`.** The catalog rows carry `surface: "workspace"`, `tier: "pro"`
190
+ - **Thirty-nine tools, each a `twin`.** The catalog rows carry `surface: "workspace"`, `tier: "pro"`
191
191
  and `modulesAnyOf` (the modules any one of which registers the tool — its twin route's gate):
192
192
  `get_agent_context`, (#9683) `get_workspace_knowledge` — twin of `GET
193
193
  /v1/workspaces/:workspaceId/knowledge`: the business's shown offerings, live magnets, listed
@@ -209,7 +209,7 @@ here. Spec: `docs/specs/3591-tenant-scoped-agent-auth-secure-spec.md` D13–D16.
209
209
  tools, `list_proposals` and `create_proposal`, twinning `GET`/`POST
210
210
  /v1/workspaces/:workspaceId/people/:personId/proposals` with the same grant: a proposal is
211
211
  private to one client, `payments` says whether approval leads to a Stripe page, and an agent's
212
- price is refused `proposal_needs_person` (a person prices) — and (#9681) the four person-thread tools, `get_person_thread`, `set_person_thread_state`, `list_external_threads` and `add_external_thread`, twinning `GET`/`PATCH …/people/:personId/thread` and `GET`/`POST …/people/:personId/external-threads`: a client's whole thread page by page with its read/archived/resolved state, and pointers to email and Slack threads (never message text); reading needs the `people-read` grant, writing also `inbox-act`, and the client erasure route has no tool, by decision — and (#9682) the three person-memory tools, `list_person_memories`, `get_person_memory` and `propose_person_memory`, twinning `GET …/people/:personId/memories`, `GET …/memories/:path` and `POST …/memories`: an agent reads only live, unexpired memories (confirmed by a person, read within 180 days) and its proposal is never live — a new path is a draft, a confirmed one gets a pending version — needing the `people-read` or `people-memory-propose` grant to read and `people-memory-propose` to propose; the six person-only memory routes (edit, delete, confirm, redact, the versions list, restore) have no tool, by decision — and (#9749) `get_slack_connection_status`, twin of `GET /v1/workspaces/:workspaceId/slack-connection` (registered with `workspace`/`multi-tenant`, its route's `TENANCY` row: whether the Organization's Slack is connected, its team name and the date, never a credential; connect and disconnect are a person's act and have no tool, by decision), and `get_prospect_channel_settings` / `set_prospect_channel_settings`, twins of `GET`/`PUT …/prospect-channel` (registered with `scheduling`, their routes' module): the update replaces every field, needs the `meeting-types-manage` grant, is refused `slack_not_connected` while the Organization has no Slack connection, and an agent may change only `enabled`, `channelPrefix` and `archiveDays` — a change to `memberIds` or `welcomeTemplate` is refused `prospect_channel_needs_person` — and (#10218) the content-suggestion pair, `draft_page_suggestions` and `decide_page_suggestion` — and (#10336) the draft pair, `save_draft` and `read_report`, twinning `PATCH`/`GET …/content/:type/:slug` with the `content-edit` grant: `save_draft` takes the page's `content` whole as the PATCH body and its `version` as `X-Draft-Version`, writes a draft version that never goes live (no tool publishes; a person does), and answers 409 `conflict` with `currentVersion` when the draft moved; `read_report` returns the draft tip, the version a save must send, its open suggestions and the route's check report (`ready`, `needs repair` or `needs your words`), computed by the route and never by the tool — and (#10345) the knowledge-base pair, `read_knowledge` and `read_knowledge_doc`, twinning `GET …/knowledge-base` and `GET …/knowledge-base/:type/:slug` with no grant (`docs-read` is every role's): the docs a named member may read (`userId`, by default the agent's own person), at the lower of the agent's person's role and that member's, so naming someone only narrows; Live does not gate them and a disabled doc never appears; `filter: "workspace-memory"` answers the always-loaded set with its `totalTokens` and an 8000-token `warning`. No workspace tool sets Live, widens Audience or re-enables a doc — those routes answer an agent 403 `agent_refused`, which reaches a tool as the code and status. Each takes its route's params, query and body and returns the route's
212
+ price is refused `proposal_needs_person` (a person prices) — and (#9681) the four person-thread tools, `get_person_thread`, `set_person_thread_state`, `list_external_threads` and `add_external_thread`, twinning `GET`/`PATCH …/people/:personId/thread` and `GET`/`POST …/people/:personId/external-threads`: a client's whole thread page by page with its read/archived/resolved state, and pointers to email and Slack threads (never message text); reading needs the `people-read` grant, writing also `inbox-act`, and the client erasure route has no tool, by decision — and (#9682) the three person-memory tools, `list_person_memories`, `get_person_memory` and `propose_person_memory`, twinning `GET …/people/:personId/memories`, `GET …/memories/:path` and `POST …/memories`: an agent reads only live, unexpired memories (confirmed by a person, read within 180 days) and its proposal is never live — a new path is a draft, a confirmed one gets a pending version — needing the `people-read` or `people-memory-propose` grant to read and `people-memory-propose` to propose; the six person-only memory routes (edit, delete, confirm, redact, the versions list, restore) have no tool, by decision — and (#9749) `get_slack_connection_status`, twin of `GET /v1/workspaces/:workspaceId/slack-connection` (registered with `workspace`/`multi-tenant`, its route's `TENANCY` row: whether the Organization's Slack is connected, its team name and the date, never a credential; connect and disconnect are a person's act and have no tool, by decision), and `get_prospect_channel_settings` / `set_prospect_channel_settings`, twins of `GET`/`PUT …/prospect-channel` (registered with `scheduling`, their routes' module): the update replaces every field, needs the `meeting-types-manage` grant, is refused `slack_not_connected` while the Organization has no Slack connection, and an agent may change only `enabled`, `channelPrefix` and `archiveDays` — a change to `memberIds` or `welcomeTemplate` is refused `prospect_channel_needs_person` — and (#10218) the content-suggestion pair, `draft_page_suggestions` and `decide_page_suggestion` — and (#10336) the draft pair, `save_draft` and `read_report`, twinning `PATCH`/`GET …/content/:type/:slug` with the `content-edit` grant: `save_draft` takes the page's `content` whole as the PATCH body and its `version` as `X-Draft-Version`, writes a draft version that never goes live, and answers 409 `conflict` with `currentVersion` when the draft moved; `read_report` returns the draft tip, the version a save must send, its open suggestions and the route's check report (`ready`, `needs repair` or `needs your words`), computed by the route and never by the tool — and (#10661) `publish_draft`, twinning `POST …/content/:type/:slug/publish` with the `content-publish` grant: it publishes the draft tip (or a named version) exactly as a person holding that permission does, an agent without the grant is refused 403 `agent_refused` before anything is read, and it never widens Audience or re-enables a doc — and (#10345) the knowledge-base pair, `read_knowledge` and `read_knowledge_doc`, twinning `GET …/knowledge-base` and `GET …/knowledge-base/:type/:slug` with no grant (`docs-read` is every role's): the docs a named member may read (`userId`, by default the agent's own person), at the lower of the agent's person's role and that member's, so naming someone only narrows; Live does not gate them and a disabled doc never appears; `filter: "workspace-memory"` answers the always-loaded set with its `totalTokens` and an 8000-token `warning` — and (#10733) the five Language tools, `list_language_items`, `get_language_item`, `save_language_item`, `set_language_item_live` and `list_language_item_pages`, twinning `GET …/language`, `GET`/`PUT …/language/:kind/:slug`, `POST …/language/:kind/:slug/live` and `GET …/language/:kind/:slug/appears-on` (`mcp-workspace`'s `language.ts`): a workspace's glossary terms, FAQs and keywords; reading needs no grant, saving the `content-edit` grant (`version: null` creates, a tip id updates, 409 `slug_taken` / `stale` otherwise) and the Live switch `content-publish` with the `version` the person reviewed; tags are the hub's and never written by a tool; a keyword is vocabulary and is never shown to visitors; the delete route is a person's write (FEAT-101 Disable) and has no tool, by decision. No workspace tool widens Audience or re-enables a doc, and only `publish_draft` and `set_language_item_live`, with the `content-publish` grant, set Live — the access and Language delete routes answer every agent 403 `agent_refused`, and the publish and Live routes an agent without the grant, which reaches a tool as the code and status. Each takes its route's params, query and body and returns the route's
213
213
  `data`. They are in the parity guard like every other tool, and never in
214
214
  `/.well-known/mcp.json`.
215
215
  - **The OAuth bearer branch (#10391).** When `BETTER_AUTH_URL` (the site's origin, its OAuth
@@ -230,7 +230,7 @@ here. Spec: `docs/specs/3591-tenant-scoped-agent-auth-secure-spec.md` D13–D16.
230
230
  a spoke has no authorization server, so it leaves `BETTER_AUTH_URL` unset. `/mcp` is untouched.
231
231
  - **Handlers forward, nothing else.** `workspace-server.ts` resolves each tool from
232
232
  `@working-theory/mcp-workspace`'s `workspaceTools` (`agent_context.ts`, `knowledge.ts`, `inbox_messages.ts`,
233
- `inbox_submissions.ts`, `inbox_applications.ts`, `people_list.ts`, `person_notes.ts`, `person_thread.ts`, `person_memory.ts`, `proposals.ts`, `slack_connection.ts`, `page_suggestions.ts`, `content_draft.ts`, `knowledge_base.ts`, all
233
+ `inbox_submissions.ts`, `inbox_applications.ts`, `people_list.ts`, `person_notes.ts`, `person_thread.ts`, `person_memory.ts`, `proposals.ts`, `slack_connection.ts`, `page_suggestions.ts`, `content_draft.ts`, `content_publish.ts`, `knowledge_base.ts`, all
234
234
  `// oss: false`) and, for the two prospect-channel settings tools, `@working-theory/mcp-scheduling`'s
235
235
  `workspaceTools` (`prospect_channel_settings.ts`, #9749 — the pack the scheduling module carries, so a
236
236
  scheduling-only build serves what it lists), loaded through the site's generated registry, as `server.ts` resolves the
@@ -313,7 +313,11 @@ Example (lead routing):
313
313
  - Don't add a tool without the matching `apps/api` endpoint.
314
314
  - Don't put business logic in tool handlers — they're adapters.
315
315
 
316
- Last updated: 2026-10-31 — #10391 (D2–D6): `/mcp/workspace` gains the OAuth bearer branch beside the agent key — one `token-info` lookup per request, the `WWW-Authenticate` challenge and the RFC 9728 protected-resource document, on only with `BETTER_AUTH_URL` and `MCP_PUBLIC_HTTP_URL` — verified against `packages/mcp-server/src/{http-transport,oauth-bearer}.ts`.
316
+ Last updated: 2026-11-02 — #10733 (D7, D8): the five Language twins (`mcp-workspace`'s `language.ts`) twin the Language collection's list, read, save, Live and appears-on routes; the person-only delete has no tool — the signed-in workspace surface gains five tools — verified against `packages/metadata/src/mcp-tool-catalog.ts`, `packages-paid/mcp-workspace/src/index.ts`, `packages-paid/api-workspace/src/routes/workspaces/language.ts` and `tests/parity/api-parity/kinds/site.ts`.
317
+
318
+ Prior: 2026-11-02 — #10661 (D1, D5–D8): `publish_draft` (`mcp-workspace`'s `content_publish.ts`) twins the publish route for an agent holding `content-publish`, whose blanket agent refusal narrows to agents without the grant — the signed-in workspace surface is thirty-nine tools — verified against `packages/metadata/src/mcp-tool-catalog.ts`, `packages-paid/mcp-workspace/src/index.ts`, `packages-paid/api-content-write/src/routes/workspaces/content.ts` and `tests/parity/api-parity/kinds/site.ts`.
319
+
320
+ Prior: 2026-10-31 — #10391 (D2–D6): `/mcp/workspace` gains the OAuth bearer branch beside the agent key — one `token-info` lookup per request, the `WWW-Authenticate` challenge and the RFC 9728 protected-resource document, on only with `BETTER_AUTH_URL` and `MCP_PUBLIC_HTTP_URL` — verified against `packages/mcp-server/src/{http-transport,oauth-bearer}.ts`.
317
321
 
318
322
  Prior: 2026-10-31 — #10345 (D9, D12): `read_knowledge` and `read_knowledge_doc` (`mcp-workspace`'s `knowledge_base.ts`) twin the per-user knowledge-base read — the signed-in workspace surface is thirty-seven tools — verified against `packages/metadata/src/mcp-tool-catalog.ts`, `packages-paid/mcp-workspace/src/index.ts` and `tests/parity/api-parity/kinds/site.ts`.
319
323
 
@@ -17,8 +17,10 @@ CONTENT_ADAPTER=mdx
17
17
  # Override the on-disk content root. Defaults to <repo-root>/content.
18
18
  # AILK_CONTENT_ROOT=
19
19
 
20
- # The shared secret /api/revalidate verifies apps/api's publish webhook
21
- # against — the SAME value as the API's CONTENT_REVALIDATE_SECRET (#5877).
20
+ # The secret /api/revalidate verifies apps/api's publish webhook against
21
+ # (#5877). On a site a hub serves (a spoke), the value `spokes revalidate-url`
22
+ # printed for it — NEVER the hub's own secret (#10593). Otherwise, the SAME
23
+ # value as the API's CONTENT_REVALIDATE_SECRET.
22
24
  # Needed when CONTENT_ADAPTER=database. Unset means the route returns 503 and
23
25
  # the site is not push-revalidated: a publish changes nothing user-visible
24
26
  # until a rebuild.
@@ -43,7 +43,7 @@ Raw HTML is allowed only when no `@working-theory/ui` component fits. Flag it in
43
43
  - Animations: only `transform` and `opacity`. Never `transition-all`.
44
44
  - Every interactive element needs hover, focus-visible, and active states.
45
45
 
46
- The four widths above are the responsive contract. Brand reference files (logos, photos, colour and type notes) live in `brand_assets/` at the repo root; the site's own brand colours and fonts are set in `content/_site.mdx` under `brand.colors` and `brand.fonts`. The signed-out header button is set there too, as `header.cta` (`label`, `href`), with `header.signIn: link | hidden` choosing whether Sign in stays beside it as a text link — never by editing `app/[locale]/layout.tsx`. Each page's chrome is set there too (#10049, resolved by `lib/page-chrome.ts`): `header.style` (`full | bare | none`), `footer.style` (`columns | centered | closing | none`), `layout` (`deck | default`, the home's) and one `{ header, footer }` key per route family (`blog`, `docs`, `services`, …), with a page's own `header:` / `footer:` / `layout:` frontmatter winning over all of them.
46
+ The four widths above are the responsive contract. Brand reference files (logos, photos, colour and type notes) live in `brand_assets/` at the repo root; the site's own brand colours and fonts are set in `content/_site.mdx` under `brand.colors` and `brand.fonts`. The signed-out header button is set there too, as `header.cta` (`label`, `href`), with `header.signIn: link | hidden` choosing whether Sign in stays beside it as a text link — never by editing `app/[locale]/layout.tsx`. Each page's chrome is set there too (#10049, resolved by `lib/page-chrome.ts`): `header.style` (`full | bare | none`), `footer.style` (`columns | centered | closing | none`), `layout` (`deck | default`, the home's) and one `{ header, footer }` key per route family (`blog`, `docs`, `services`, …), with a page's own `header:` / `footer:` / `layout:` frontmatter winning over all of them. The closing footer alone also reads three site-level keys (#10550): `footer.locality` (the line above the email, winning over the identity's address), `footer.actions` (one or two `{ label, href }` site paths — the primary, then the secondary — in place of the auth links) and `footer.legalBar` (off by default: `true` moves the legal line into a full-width bottom bar, on the deck's closing slide its bottom edge). The cookie notice's look is set there too (#10555, #10721): `consent.appearance` is `compact` (the floating card, bottom-left) or `hairline` (the full-width band on the page ground); absent, it follows the design's `chromeStyle` — the band under `minimal`, else the card. Only the band waits for the minimal deck home's first slide to leave view (#7782); the card shows on load on every page.
47
47
 
48
48
  ## Local dev
49
49
 
@@ -277,8 +277,8 @@ embedding for it is behind the hero CTA's lightbox gate
277
277
 
278
278
  The workspace's **Newsletter magnet** (#8847) — seeded live at slug
279
279
  `newsletter` in every workspace — is the only source of `/newsletter`,
280
- `/unsubscribe` and the footer signup; none of their copy is in page code,
281
- content or the message catalog. All three read it through
280
+ `/unsubscribe`, the footer signup and a bound `newsletter` section; none of
281
+ their copy is in page code, content or the message catalog. All four read it through
282
282
  `lib/in-page-magnet.ts`'s `readServedMagnet("newsletter")`, shared for 15
283
283
  seconds per instance like /contact's read.
284
284
 
@@ -306,6 +306,13 @@ seconds per instance like /contact's read.
306
306
  - **The footer** fills `Footer`'s `subscribe` slot with the compact form
307
307
  (stacked, `sr-only` label) only when the magnet is served and capture is
308
308
  on; the built-in config never fills it.
309
+ - **A bound `newsletter` section** (#10629) — a content `newsletter` section
310
+ with `magnet: "newsletter"` in place of `action` renders, under its own
311
+ headline, the full-size signup the locale layout builds from the footer's
312
+ same read and hands every page through `NewsletterSignupProvider` — with
313
+ `consentGate: "on-submit"`, so Subscribe stays enabled and an unticked
314
+ submit shows the consent error beside the box. It renders nothing whenever
315
+ the footer would show nothing. A section with `action` posts as before.
309
316
 
310
317
  ## Careers
311
318
 
@@ -581,7 +588,7 @@ A visitor's browser never calls the hub. Lead capture, newsletter signup, callba
581
588
  - **No cross-site POST.** A `Sec-Fetch-Site` other than `same-origin`, or an `Origin` not on this site's own host, is refused 403 before the hub is called.
582
589
  - **Headers are an allow-list.** The visitor's `x-forwarded-for` (else `x-real-ip`) and `user-agent` pass through so per-visitor rate limits still count each visitor; no `authorization`, client-sent `x-ailk-*` or `host` is forwarded, and a `cookie` goes only on `POST /v1/commerce/invoices`, the one session route.
583
590
  - **The relay has its own per-visitor budget (#7707).** Each visitor gets 8 calls per 10 minutes across the five visitor routes, and the session route (`POST /v1/commerce/invoices`) gets its own, wider budget of 30 per 10 minutes; the call that exceeds either gets `429 rate_limited` before any hub call. The budget is held in this instance's own process (no shared store, no env variable) and holds only where the site's edge overwrites `x-forwarded-for` and `x-real-ip` — Vercel does; a bare self-hosted Next server does not.
584
- - **A bot check on the public writes (#9889, #9255).** On Vercel, `lib/bot-check.ts`'s `runBotCheck` asks Vercel BotID for a verdict before the hub is called — in the relay for `POST /v1/leads/{submit,subscribe,callback}` and `POST /v1/schedule/book` (`BOT_CHECKED_ROUTES`: `403 bot_detected`, or `503 bot_check_unavailable` when the check throws, answers no boolean or takes over 5 seconds), and in the contact, careers, newsletter, flow-step, flow-checkout, saved-recommendation and `/dev/purchase` Server Actions immediately before their hub call, which answer their own existing failure variant. The locale layout's `BotIdClient` protects every same-origin POST (`BOT_CLIENT_PROTECT`, `/*`), because a Server Action posts to the page it was submitted from; `next.config.mjs` wraps `withBotId` and `middleware.ts` keeps BotID's proxy prefix out of its matcher. It is on by default and inert off Vercel: `AILK_BOT_CHECK=off` turns every check off, `AILK_BOOKING_BOT_CHECK=off` the booking's alone, and MCP tools never pass through a site, so they are never challenged.
591
+ - **A bot check on the public writes (#9889, #9255).** On Vercel, `lib/bot-check.ts`'s `runBotCheck` asks Vercel BotID for a verdict before the hub is called — in the relay for `POST /v1/leads/{submit,subscribe,callback}` and `POST /v1/schedule/book` (`BOT_CHECKED_ROUTES`: `403 bot_detected`, or `503 bot_check_unavailable` when the check throws, answers no boolean or takes over 5 seconds), and in the contact, careers, newsletter, flow-step, flow-checkout, saved-recommendation and `/dev/purchase` Server Actions immediately before their hub call, which answer their own existing failure variant. The locale layout's `BotIdClient` protects every same-origin POST (`BOT_CLIENT_PROTECT`, `/*`), because a Server Action posts to the page it was submitted from; `next.config.mjs` wraps `withBotId` and `middleware.ts` keeps BotID's proxy prefix out of its matcher. It is on by default and inert off Vercel: `AILK_BOT_CHECK=off` turns every check off, `AILK_BOOKING_BOT_CHECK=off` the booking's alone, and MCP tools never pass through a site, so they are never challenged. A refusal logs `[<source>] bot refused` with BotID's `classificationReason`, `bypassed` and `isVerifiedBot`, the request's `host` and `forwardedHost`, and `proxied` (a `cf-ray` header) — no other header value, no form field (#10715). `AILK_BOT_CHECK=observe` runs the check and logs the same facts plus `isBot` as `observed`, but passes every write — for diagnosis only. **A BotID-protected site must be reached directly — Cloudflare DNS-only, no reverse proxy in front of Vercel (#10715).** Behind a proxy the write reaches Vercel from the proxy's egress rather than the browser that solved the challenge, so BotID refuses every real visitor; the first proxied request per process logs `[<source>] reverse proxy detected` (outcome `proxied`) naming that fix.
585
592
  - `/v1` is exempt from next-intl's locale rewrite in `middleware.ts`, as `/api` is.
586
593
 
587
594
  ## Where the docs live (`docsPath`)
@@ -637,7 +644,11 @@ A list page whose frontmatter sets `navOnly: true` (valid on a registry list typ
637
644
  - Don't reach into `apps/api` or `apps/mcp` from `apps/web`. They're peer apps — talk via HTTP/MCP.
638
645
  - Don't commit images to `screenshots/` — only the `.gitkeep` is tracked.
639
646
 
640
- Last updated: 2026-11-01 — § The owner's discovery switch: the six discovery files read the workspace's `discoverable` setting per request through `siteDiscoverable()`, off answering robots.txt's disallow-all and 404 for the rest, never cached (#10510) — verified against `app/{robots.txt,sitemap.xml,llms.txt,openapi.json}/route.ts`, `app/.well-known/{mcp.json,ai-plugin.json}/route.ts`, `lib/slices/docs-blog/llms-route.ts` and `packages/api-client/src/discovery.ts`.
647
+ Last updated: 2026-11-02 — § Theme & styling: `consent.appearance` named beside the other `_site.mdx` chrome keys, and the minimal deck home's hold-back is the band's alone, so a minimal site that picks the card sees it on load there too (#10721) — verified against `app/[locale]/layout.tsx`.
648
+
649
+ Prior: 2026-11-02 — § The same-origin relay: a bot-check refusal names its cause (`classificationReason`, `bypassed`, `isVerifiedBot`, both hosts, `proxied`), `AILK_BOT_CHECK=observe` logs and never refuses, and a BotID-protected site must be reached directly, Cloudflare DNS-only (#10715) — verified against `lib/bot-check.ts`.
650
+
651
+ Prior: 2026-11-01 — § The owner's discovery switch: the six discovery files read the workspace's `discoverable` setting per request through `siteDiscoverable()`, off answering robots.txt's disallow-all and 404 for the rest, never cached (#10510) — verified against `app/{robots.txt,sitemap.xml,llms.txt,openapi.json}/route.ts`, `app/.well-known/{mcp.json,ai-plugin.json}/route.ts`, `lib/slices/docs-blog/llms-route.ts` and `packages/api-client/src/discovery.ts`.
641
652
 
642
653
  Prior: 2026-10-30 — § The MCP authorization server: the site's OAuth 2.1 authorization server, its consent step, rotation and revocation (#10357) — verified against `auth.ts`, `lib/mcp-oauth.ts`, `app/[locale]/(auth)/consent/` and `app/.well-known/oauth-authorization-server/route.ts`.
643
654
 
@@ -1,4 +1,5 @@
1
1
  import { TextDecoder, TextEncoder } from "node:util";
2
+ import { deserialize, serialize } from "node:v8";
2
3
 
3
4
  import "@testing-library/jest-dom";
4
5
 
@@ -17,6 +18,14 @@ if (typeof globalThis.TextEncoder === "undefined") {
17
18
  (globalThis as unknown as { TextDecoder: unknown }).TextDecoder = TextDecoder;
18
19
  }
19
20
 
21
+ // #10704 D3 — nor `structuredClone`, which Node exposes only as a global. The
22
+ // database render path clones a page's words with it (`@working-theory/
23
+ // validation`'s `words-codec.ts`); the v8 round trip clones the same plain data.
24
+ if (typeof globalThis.structuredClone === "undefined") {
25
+ globalThis.structuredClone = (<T>(value: T): T =>
26
+ deserialize(serialize(value)) as T) as typeof structuredClone;
27
+ }
28
+
20
29
  // SOFT_LOCK_OK: #6510 (D1) — jsdom does not implement ResizeObserver.
21
30
  // BannerConsentIsland (the layout's always-mounted consent Banner,
22
31
  // placement="bottom") now constructs one when reserveSpace is set — the