create-ailk 0.27.0 → 0.28.1

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 (219) 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/chrome-readers.d.ts +21 -5
  16. package/dist/lib/chrome-readers.js +128 -20
  17. package/dist/lib/declared-env.d.ts +6 -0
  18. package/dist/lib/declared-env.js +2 -0
  19. package/dist/lib/env-catalog.js +20 -0
  20. package/dist/lib/import-graph.d.ts +16 -0
  21. package/dist/lib/import-graph.js +53 -0
  22. package/dist/lib/site-modules.d.ts +8 -0
  23. package/dist/lib/site-modules.js +34 -0
  24. package/dist/module-architecture.js +23 -14
  25. package/dist/paid-sections.d.ts +17 -0
  26. package/dist/paid-sections.js +39 -0
  27. package/dist/programmatic.d.ts +16 -0
  28. package/dist/programmatic.js +73 -4
  29. package/dist/remove-module.d.ts +98 -0
  30. package/dist/remove-module.js +620 -0
  31. package/dist/seed-flows.d.ts +2 -2
  32. package/dist/seed-flows.js +16 -9
  33. package/dist/site-roster.js +4 -1
  34. package/dist/starter-grid.d.ts +26 -0
  35. package/dist/starter-grid.js +34 -0
  36. package/dist/surfaces.d.ts +26 -0
  37. package/dist/surfaces.js +105 -9
  38. package/dist/sync-routes.d.ts +7 -0
  39. package/dist/sync-routes.js +53 -6
  40. package/dist/template-pages.d.ts +36 -0
  41. package/dist/template-pages.js +129 -0
  42. package/dist/template-record.d.ts +13 -4
  43. package/dist/template-record.js +8 -4
  44. package/dist/topology.d.ts +2 -1
  45. package/dist/topology.js +4 -2
  46. package/dist/update.d.ts +34 -2
  47. package/dist/update.js +395 -48
  48. package/package.json +3 -3
  49. package/release-scope.json +2 -1
  50. package/site-templates/aliases/hub.TEMPLATE-MANIFEST.json +115 -0
  51. package/templates/.env.example +5 -2
  52. package/templates/apps/api/.env.example +9 -5
  53. package/templates/apps/api/CLAUDE.md +14 -9
  54. package/templates/apps/api/src/__tests__/vercel-handler.test.ts +37 -0
  55. package/templates/apps/api/src/vercel-handler.ts +8 -1
  56. package/templates/apps/mcp/CLAUDE.md +18 -10
  57. package/templates/apps/web/.env.example +25 -11
  58. package/templates/apps/web/CLAUDE.md +45 -4
  59. package/templates/apps/web/__tests__/setup.ts +7 -0
  60. package/templates/apps/web/app/.well-known/mcp.json/route.ts +7 -7
  61. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/[pieceId]/versions/[version]/file/route.ts +61 -2
  62. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/__tests__/file-route.test.ts +81 -0
  63. package/templates/apps/web/app/[locale]/(authed)/account/connect/__tests__/page.test.tsx +197 -0
  64. package/templates/apps/web/app/[locale]/(authed)/account/connect/page.tsx +152 -0
  65. package/templates/apps/web/app/[locale]/about/__tests__/about-routes.test.tsx +2 -2
  66. package/templates/apps/web/app/[locale]/careers/[slug]/[action]/page.tsx +76 -21
  67. package/templates/apps/web/app/[locale]/careers/[slug]/apply.actions.ts +46 -16
  68. package/templates/apps/web/app/[locale]/careers/[slug]/page.tsx +20 -3
  69. package/templates/apps/web/app/[locale]/careers/__tests__/apply.actions.test.ts +180 -2
  70. package/templates/apps/web/app/[locale]/careers/__tests__/careers-set.test.tsx +6 -2
  71. package/templates/apps/web/app/[locale]/careers/__tests__/job-apply-page.test.tsx +122 -0
  72. package/templates/apps/web/app/[locale]/careers/__tests__/page-renderer-migration.test.tsx +1 -1
  73. package/templates/apps/web/app/[locale]/case-studies/[slug]/page.tsx +20 -3
  74. package/templates/apps/web/app/[locale]/case-studies/__tests__/hub-children.test.tsx +65 -0
  75. package/templates/apps/web/app/[locale]/case-studies/__tests__/page-renderer-migration.test.tsx +6 -2
  76. package/templates/apps/web/app/[locale]/case-studies/page.tsx +23 -11
  77. package/templates/apps/web/app/[locale]/checkout/__tests__/thank-you-account.test.tsx +11 -1
  78. package/templates/apps/web/app/[locale]/clients/__tests__/page-renderer-migration.test.tsx +1 -1
  79. package/templates/apps/web/app/[locale]/consent.actions.ts +44 -6
  80. package/templates/apps/web/app/[locale]/contact/__tests__/ConversationalIntakeWrapper.test.tsx +4 -4
  81. package/templates/apps/web/app/[locale]/contact/__tests__/contact.actions.test.ts +269 -42
  82. package/templates/apps/web/app/[locale]/contact/contact.actions.ts +88 -30
  83. package/templates/apps/web/app/[locale]/courses/[slug]/page.tsx +20 -3
  84. package/templates/apps/web/app/[locale]/courses/__tests__/hub-children.test.tsx +32 -0
  85. package/templates/apps/web/app/[locale]/courses/__tests__/page-renderer-migration.test.tsx +6 -2
  86. package/templates/apps/web/app/[locale]/courses/page.tsx +22 -6
  87. package/templates/apps/web/app/[locale]/events/[slug]/page.tsx +15 -2
  88. package/templates/apps/web/app/[locale]/events/__tests__/hub-children.test.tsx +32 -0
  89. package/templates/apps/web/app/[locale]/events/__tests__/page-renderer-migration.test.tsx +8 -2
  90. package/templates/apps/web/app/[locale]/events/page.tsx +23 -7
  91. package/templates/apps/web/app/[locale]/features/__tests__/hub-children.test.tsx +32 -0
  92. package/templates/apps/web/app/[locale]/features/page.tsx +23 -25
  93. package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/flow.actions.test.ts +95 -0
  94. package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/save.actions.test.ts +193 -0
  95. package/templates/apps/web/app/[locale]/flows/[slug]/email-moment.ts +92 -0
  96. package/templates/apps/web/app/[locale]/flows/[slug]/flow.actions.ts +10 -0
  97. package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/ResultsClient.tsx +513 -0
  98. package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/ResultsEmailStep.tsx +122 -0
  99. package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/__tests__/ResultsClient.no-commerce.test.tsx +195 -0
  100. package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/__tests__/landing.test.tsx +293 -0
  101. package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/__tests__/page.test.tsx +397 -0
  102. package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/checkout-seam.ts +74 -0
  103. package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/page.tsx +264 -0
  104. package/templates/apps/web/app/[locale]/flows/[slug]/save.actions.ts +75 -0
  105. package/templates/apps/web/app/[locale]/for/__tests__/hub-children.test.tsx +32 -0
  106. package/templates/apps/web/app/[locale]/for/__tests__/page-renderer-migration.test.tsx +5 -1
  107. package/templates/apps/web/app/[locale]/for/page.tsx +23 -25
  108. package/templates/apps/web/app/[locale]/how-to/[slug]/page.tsx +20 -3
  109. package/templates/apps/web/app/[locale]/how-to/__tests__/page-renderer-migration.test.tsx +1 -1
  110. package/templates/apps/web/app/[locale]/integrations/[slug]/page.tsx +25 -4
  111. package/templates/apps/web/app/[locale]/integrations/__tests__/hub-children.test.tsx +38 -0
  112. package/templates/apps/web/app/[locale]/integrations/__tests__/page-renderer-migration.test.tsx +8 -2
  113. package/templates/apps/web/app/[locale]/integrations/page.tsx +23 -11
  114. package/templates/apps/web/app/[locale]/layout.tsx +157 -139
  115. package/templates/apps/web/app/[locale]/page.tsx +51 -3
  116. package/templates/apps/web/app/[locale]/partners/[slug]/page.tsx +16 -3
  117. package/templates/apps/web/app/[locale]/partners/__tests__/hub-children.test.tsx +32 -0
  118. package/templates/apps/web/app/[locale]/partners/__tests__/page-renderer-migration.test.tsx +6 -2
  119. package/templates/apps/web/app/[locale]/partners/page.tsx +22 -6
  120. package/templates/apps/web/app/[locale]/portfolio/__tests__/hub-children.test.tsx +38 -0
  121. package/templates/apps/web/app/[locale]/portfolio/page.tsx +25 -24
  122. package/templates/apps/web/app/[locale]/pricing/[slug]/__tests__/page.test.tsx +321 -0
  123. package/templates/apps/web/app/[locale]/pricing/[slug]/page.tsx +87 -0
  124. package/templates/apps/web/app/[locale]/pricing/__tests__/offerings.test.tsx +3 -0
  125. package/templates/apps/web/app/[locale]/pricing/__tests__/pricing-pages.test.tsx +215 -0
  126. package/templates/apps/web/app/[locale]/pricing/page.tsx +56 -341
  127. package/templates/apps/web/app/[locale]/products/[slug]/page.tsx +21 -4
  128. package/templates/apps/web/app/[locale]/products/__tests__/hub-children.test.tsx +32 -0
  129. package/templates/apps/web/app/[locale]/products/__tests__/page-renderer-migration.test.tsx +8 -2
  130. package/templates/apps/web/app/[locale]/products/page.tsx +22 -6
  131. package/templates/apps/web/app/[locale]/projects/__tests__/hub-children.test.tsx +32 -0
  132. package/templates/apps/web/app/[locale]/projects/__tests__/page-renderer-migration.test.tsx +4 -0
  133. package/templates/apps/web/app/[locale]/projects/page.tsx +18 -11
  134. package/templates/apps/web/app/[locale]/resources/[slug]/page.tsx +25 -4
  135. package/templates/apps/web/app/[locale]/resources/__tests__/hub-children.test.tsx +38 -0
  136. package/templates/apps/web/app/[locale]/resources/__tests__/page-renderer-migration.test.tsx +6 -2
  137. package/templates/apps/web/app/[locale]/resources/page.tsx +20 -10
  138. package/templates/apps/web/app/[locale]/services/[slug]/[child]/page.tsx +68 -0
  139. package/templates/apps/web/app/[locale]/services/[slug]/page.tsx +64 -9
  140. package/templates/apps/web/app/[locale]/services/__tests__/hub-children.test.tsx +158 -0
  141. package/templates/apps/web/app/[locale]/services/__tests__/page-renderer-migration.test.tsx +12 -2
  142. package/templates/apps/web/app/[locale]/services/__tests__/service-detail-sections.test.tsx +224 -0
  143. package/templates/apps/web/app/[locale]/services/__tests__/service-families.test.tsx +368 -0
  144. package/templates/apps/web/app/[locale]/services/page.tsx +45 -19
  145. package/templates/apps/web/app/[locale]/software/__tests__/page-renderer-migration.test.tsx +1 -1
  146. package/templates/apps/web/app/[locale]/team/__tests__/hub-children.test.tsx +32 -0
  147. package/templates/apps/web/app/[locale]/team/page.tsx +25 -24
  148. package/templates/apps/web/app/[locale]/tech-articles/[slug]/page.tsx +24 -3
  149. package/templates/apps/web/app/[locale]/tech-articles/__tests__/page-renderer-migration.test.tsx +1 -1
  150. package/templates/apps/web/app/[locale]/templates/[slug]/__tests__/page.test.tsx +203 -0
  151. package/templates/apps/web/app/[locale]/templates/[slug]/page.tsx +81 -0
  152. package/templates/apps/web/app/[locale]/templates/__tests__/page.test.tsx +237 -0
  153. package/templates/apps/web/app/[locale]/templates/page.tsx +108 -0
  154. package/templates/apps/web/app/[locale]/testimonials/__tests__/page-renderer-migration.test.tsx +1 -1
  155. package/templates/apps/web/app/[locale]/whitepapers/[slug]/page.tsx +24 -3
  156. package/templates/apps/web/app/[locale]/whitepapers/__tests__/page-renderer-migration.test.tsx +1 -1
  157. package/templates/apps/web/app/api/auth/hub/authorize/__tests__/route.test.ts +204 -2
  158. package/templates/apps/web/app/api/auth/hub/authorize/route.ts +151 -33
  159. package/templates/apps/web/app/api/offerings/checkout/[lookupKey]/__tests__/route.test.ts +233 -7
  160. package/templates/apps/web/app/api/offerings/checkout/[lookupKey]/route.ts +92 -41
  161. package/templates/apps/web/app/api/offerings/checkout/__tests__/route.test.ts +77 -39
  162. package/templates/apps/web/app/llms.txt/route.ts +41 -3
  163. package/templates/apps/web/app/robots.txt/route.ts +5 -1
  164. package/templates/apps/web/app/sitemap.xml/route.ts +45 -3
  165. package/templates/apps/web/auth.ts +102 -50
  166. package/templates/apps/web/components/signed-in-nav.tsx +8 -1
  167. package/templates/apps/web/lib/__tests__/bot-check.test.ts +254 -0
  168. package/templates/apps/web/lib/__tests__/consent-visitor.test.ts +212 -13
  169. package/templates/apps/web/lib/__tests__/deck-page.test.ts +13 -1
  170. package/templates/apps/web/lib/__tests__/docs-index.test.ts +93 -0
  171. package/templates/apps/web/lib/__tests__/docs-path.test.ts +168 -0
  172. package/templates/apps/web/lib/__tests__/hub-children.fixtures.ts +279 -0
  173. package/templates/apps/web/lib/__tests__/nav-label-keys.test.ts +138 -0
  174. package/templates/apps/web/lib/__tests__/newsletter.actions.test.ts +178 -2
  175. package/templates/apps/web/lib/__tests__/offerings-pricing.test.ts +183 -0
  176. package/templates/apps/web/lib/__tests__/page-chrome.test.ts +412 -0
  177. package/templates/apps/web/lib/__tests__/pricing-page.test.ts +142 -0
  178. package/templates/apps/web/lib/__tests__/relay.test.ts +202 -10
  179. package/templates/apps/web/lib/__tests__/route-family.test.ts +57 -0
  180. package/templates/apps/web/lib/__tests__/service-listing.test.ts +111 -0
  181. package/templates/apps/web/lib/__tests__/services-path.test.ts +96 -0
  182. package/templates/apps/web/lib/__tests__/site-header.test.ts +151 -0
  183. package/templates/apps/web/lib/__tests__/structured-data.test.tsx +148 -6
  184. package/templates/apps/web/lib/__tests__/telemetry-scrub.test.ts +84 -0
  185. package/templates/apps/web/lib/__tests__/template-listing.test.ts +158 -0
  186. package/templates/apps/web/lib/auth-secret.ts +57 -0
  187. package/templates/apps/web/lib/bot-check.ts +138 -0
  188. package/templates/apps/web/lib/consent-visitor.ts +81 -7
  189. package/templates/apps/web/lib/deck-page.ts +6 -3
  190. package/templates/apps/web/lib/docs-index.ts +100 -0
  191. package/templates/apps/web/lib/docs-path.ts +94 -0
  192. package/templates/apps/web/lib/hub-children.tsx +318 -0
  193. package/templates/apps/web/lib/modules.generated.ts +9 -0
  194. package/templates/apps/web/lib/nav-label-check.ts +61 -0
  195. package/templates/apps/web/lib/nav-label-keys.ts +78 -0
  196. package/templates/apps/web/lib/newsletter.actions.ts +31 -6
  197. package/templates/apps/web/lib/offerings-pricing.ts +135 -6
  198. package/templates/apps/web/lib/page-chrome.ts +367 -0
  199. package/templates/apps/web/lib/posting-title.ts +26 -4
  200. package/templates/apps/web/lib/pricing-page.ts +430 -0
  201. package/templates/apps/web/lib/relay.ts +31 -106
  202. package/templates/apps/web/lib/route-family.ts +69 -0
  203. package/templates/apps/web/lib/service-listing.ts +89 -0
  204. package/templates/apps/web/lib/services-path.ts +97 -0
  205. package/templates/apps/web/lib/site-header.ts +94 -0
  206. package/templates/apps/web/lib/telemetry-scrub.ts +68 -3
  207. package/templates/apps/web/lib/template-listing.ts +169 -0
  208. package/templates/apps/web/messages/en.json +86 -30
  209. package/templates/apps/web/middleware.ts +171 -11
  210. package/templates/apps/web/next.config.mjs +211 -2
  211. package/templates/apps/web/package.json +1 -1
  212. package/templates/apps/web/scripts/assert-content-bundled.ts +28 -6
  213. package/templates/apps/web/scripts/check-nav-labels.ts +39 -0
  214. package/templates/apps/web/sentry.server.config.ts +30 -2
  215. package/templates/apps/web/src/lib/__tests__/rate-limit.test.ts +67 -9
  216. package/templates/apps/web/src/lib/rate-limit.ts +68 -27
  217. package/templates/apps/web/src/lib/routes.ts +15 -1
  218. package/templates/tools/declared-env.ts +7 -0
  219. package/versions.json +48 -48
@@ -71,14 +71,19 @@ now mean `packages/mcp-server/src/<file>`.
71
71
  started from the repo root — anywhere else the file is absent and so is the tool, where it
72
72
  could not run anyway (no `dist/cli.js`). The scope FILE alone decides — a release-scope
73
73
  decision, never a licence check, and the CLI's `AILK_RELEASE_SCOPE=ignore` bypass is not
74
- honoured; a missing or unreadable file fails closed with one stderr line. apps/web's
75
- `/.well-known/mcp.json` drops it on the same read (`apps/web/lib/release-scope.ts`).
74
+ honoured; a missing or unreadable file fails closed with one stderr line. It is served over
75
+ **stdio only** (#9965 D13, #8584): it spawns a filesystem-writing CLI, so
76
+ `createMcpServer()` and `listRegisteredTools()` default to the HTTP transport, which never
77
+ registers it, and only `run.ts`'s stdio branch passes `{ transport: "stdio" }`; the
78
+ call-time gate asks for the transport the server was built with. apps/web's
79
+ `/.well-known/mcp.json` describes the HTTP surface, so it always drops it.
76
80
 
77
81
  ### What a spoke's MCP host on Vercel enforces
78
82
 
79
83
  - **The address is the caller's, behind exactly one proxy.** The Vercel entry trusts one hop
80
84
  (`trustOneHop`, `src/serverless-app.ts`), so the limits key on the address Vercel's edge
81
- saw and a caller's own `X-Forwarded-For` cannot move the key. This holds when the MCP host
85
+ saw and a caller's own `X-Forwarded-For` cannot move the key. It trusts that hop only on
86
+ Vercel (`VERCEL=1`); reused anywhere else it trusts nothing and keys on the socket (#8080). This holds when the MCP host
82
87
  name points straight at the Vercel project; a second proxy in front keys every caller on
83
88
  that proxy. It is a function, not `trustProxy: 1`, because Fastify 5.12 treats a numeric
84
89
  hop count as trusting nothing. The long-running entry trusts no proxy.
@@ -116,11 +121,11 @@ Every tool maps 1:1 to an `apps/api` endpoint. When you change a tool, change th
116
121
 
117
122
  **Read (9):** `list_pages`, `read_content`, `search_content`, `get_site_info`, `get_business_hours`, `get_locations`, `get_offerings`, `get_pricing`, `get_faq`. The first three adopted their `apps/api` routes' arguments and results (#7167, #7168, #7169 — the operator's decision of 2026-10-19): `search_content` takes `q` and answers the route's `ItemList` (`query`, `items`, `totalItems`); `list_pages` requires `type`, takes `page`/`pageSize` and answers the paginated `ItemList` (`items`, `pagination`); `read_content` takes `include` and answers the page's JSON-LD at the root with `page`, plus `body` only with `include: "body"` — a miss is the not-found body. Each parses the same `@working-theory/validation` export its route parses with and answers through the same `@working-theory/content-adapters` builder, so the parity guard holds them as `twin`s
118
123
  **Conversion-write (3):** `capture_lead`, `subscribe_newsletter`, `request_callback` — each takes the body its `POST /v1/leads/{submit,subscribe,callback}` route parses (`leadSubmitBodySchema`: `source`, `email`, `consent`, `payload`; `leadSubscribeBodySchema`: `email`, `source`; `leadCallbackBodySchema`: `name`, `phone`, `preferredTime`, `payload`) and returns the route's 201 body `{ id, persisted, email }` unchanged (#7171, #7172, #7173 — the operator's decision of 2026-10-19), so the parity guard holds the three as `twin`s. `source` is the agent's to choose — `mcp` unless the operator says otherwise — and a consent-bearing source needs `consent: true`, exactly as a form does; `capture_lead` still spreads the spoke marker `payload.site` from `AILK_LEAD_SITE` last. The AbuseGuard reads the first two's `email` at the top level, else inside `payload` (the route lifts it from there), so a malformed address is refused wherever it sits
119
- **Maintenance (2):** `audit`, `create_project`
124
+ **Maintenance (2):** `audit`, `create_project` (stdio only, #9965 D13)
120
125
  **Flow (2):** `get_flow`, `submit_flow_step` — module-gated (`module: "signal-collectors"`, D-48, #5366) and licence-feature-tagged (`feature: "flows-deliverables"`, #7136): identity-blind like every tool in this list, registered only when the signal-collectors module is present, and gated on the deployment's `flows-deliverables` licence feature under `MCP_FEATURE_GATE_MODE`, the same shape Booking below has. `submit_flow_step` takes the HTTP write branch (its own daily cap, `MCP_ABUSE_SUBMIT_FLOW_STEP_DAILY_CAP`, default 50 — #8071)
121
126
  **Booking (4):** `list_availability`, `get_event_meta`, `list_meetings`, `create_booking` — module-gated (`module: "scheduling"`) and licence-feature-tagged (`feature: "scheduling"`, #7136), gated on the deployment's `scheduling` licence feature under `MCP_FEATURE_GATE_MODE`. `create_booking` takes the HTTP write branch (its own daily cap, `MCP_ABUSE_CREATE_BOOKING_DAILY_CAP`, default 20, and an `attendee.email` format check — #8071). `list_meetings` (#8843 D5) twins `GET /v1/schedule/meetings`: it takes no argument and returns the site's Live meetings — slug, visibility, position and whether each can be booked, plus a bookable one's calLink, title, length, description and price; `private` means unlisted, not secret
122
127
 
123
- ### Why the parity test counts 21 and a keyless server registers 19 (20 once Free is released)
128
+ ### Why the parity test counts 21 and a keyless server registers 19 (20 over stdio once Free is released)
124
129
 
125
130
  - **21 in the `TOOLS` array / parity test**: the `TOOLS` array in
126
131
  `packages/metadata/src/mcp-tool-catalog.ts` defines 21 anonymous tools; the
@@ -128,16 +133,17 @@ Every tool maps 1:1 to an `apps/api` endpoint. When you change a tool, change th
128
133
  workspace tools (#3591), which no anonymous view returns — see § The
129
134
  signed-in workspace surface below.
130
135
  - **19 registered on a KEYLESS deployment, 20 on a commerce-claimed one,
131
- while the Free level is held — 20 and 21 once it is released**
132
- (#5971, #9534): `createMcpServer` (`apps/mcp/src/server.ts`) registers
136
+ while the Free level is held or over HTTP — 20 and 21 over stdio once it is
137
+ released** (#5971, #9534, #9965 D13): `createMcpServer` (`apps/mcp/src/server.ts`) registers
133
138
  `getMcpToolRegistrations()` minus the topology seam's `UNSERVED_MCP_TOOLS`,
134
139
  minus `create_project` while create-ailk's `release-scope.json` holds
135
- `levels.free` (the release-scope filter, #9534), minus `OSS_DISABLED_TOOLS`
140
+ `levels.free` (the release-scope filter, #9534) and on any transport but
141
+ stdio (the transport filter, #9965 D13), minus `OSS_DISABLED_TOOLS`
136
142
  (now empty), minus module-gated tools whose module is off, minus any
137
143
  `addon`-tagged tool whose claim this deployment does not hold.
138
144
  `start_purchase` carries `addon: "commerce"`, so it is the one entry whose
139
145
  presence depends on the licence — one fewer without the claim.
140
- - **19 free anonymous tools while Free is held (20 released)**: since #5853 retired the three content-write
146
+ - **19 free anonymous tools while Free is held or over HTTP (20 over stdio once released)**: since #5853 retired the three content-write
141
147
  tools, every registered tool remains anonymous — call-time gating checks a
142
148
  licence claim, never an identity. The six carrying `tier: "pro"`
143
149
  (`get_flow`, `submit_flow_step`, `list_availability`, `get_event_meta`,
@@ -277,7 +283,9 @@ Example (lead routing):
277
283
  - Don't add a tool without the matching `apps/api` endpoint.
278
284
  - Don't put business logic in tool handlers — they're adapters.
279
285
 
280
- Last updated: 2026-10-24 — #9749: `get_slack_connection_status` (`mcp-workspace`'s `slack_connection.ts`) and `get_prospect_channel_settings` / `set_prospect_channel_settings` (`mcp-scheduling`'s `prospect_channel_settings.ts`, the first signed-in handlers outside `mcp-workspace`) twin the Slack status read and the prospect-channel settings pair — the signed-in workspace surface is thirty-one tools — verified against `packages/metadata/src/mcp-tool-catalog.ts`, `packages-paid/mcp-workspace/src/index.ts`, `packages-paid/mcp-scheduling/src/index.ts` and `tests/parity/api-parity/kinds/site.ts`.
286
+ Last updated: 2026-10-26 — #9965 (D12, D13): `create_project` is served over stdio only — the HTTP transport, the default, never registers it, and the web manifest always drops it — and `trustOneHop` trusts its hop only on Vercel — verified against `packages/mcp-server/src/{server,run,serverless-app}.ts` and `apps/web/app/.well-known/mcp.json/route.ts`.
287
+
288
+ Prior: 2026-10-24 — #9749: `get_slack_connection_status` (`mcp-workspace`'s `slack_connection.ts`) and `get_prospect_channel_settings` / `set_prospect_channel_settings` (`mcp-scheduling`'s `prospect_channel_settings.ts`, the first signed-in handlers outside `mcp-workspace`) twin the Slack status read and the prospect-channel settings pair — the signed-in workspace surface is thirty-one tools — verified against `packages/metadata/src/mcp-tool-catalog.ts`, `packages-paid/mcp-workspace/src/index.ts`, `packages-paid/mcp-scheduling/src/index.ts` and `tests/parity/api-parity/kinds/site.ts`.
281
289
 
282
290
  Prior: 2026-10-24 — #9714: `list_people` (`people_list.ts`) twins `GET /v1/workspaces/:workspaceId/people` in both its modes — the signed-in workspace surface is twenty-eight 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`.
283
291
 
@@ -47,17 +47,26 @@ CONTENT_ADAPTER=mdx
47
47
  # NEXT_PUBLIC_; see the repo-root .env.example, #7394). A cross-site post is
48
48
  # refused before the hub is called.
49
49
  #
50
- # THE BOOKING BOT CHECK (#9889). On Vercel, POST /v1/schedule/book asks Vercel
51
- # BotID for a verdict before the hub is called: a bot is refused 403
52
- # bot_detected, so no booking and no Slack invite exist. A real visitor sees no
53
- # extra step. It is ON by default; set this to `off` to turn it off (any other
54
- # value leaves it on). Off Vercel it cannot run, so it is inert and the server
55
- # logs that once. On Vercel it needs the project's OIDC token (on by default):
56
- # without it every booking answers 503 bot_check_unavailable, as it does when
57
- # BotID cannot answer. A self-hosted or local `next start` must not carry
58
- # VERCEL=1 (as `vercel env pull` writes it), or every booking answers 503.
59
- # Basic or Deep Analysis is the Vercel project's own setting; Deep Analysis is
60
- # billed per check.
50
+ # THE BOT CHECK (#9889, #9255). On Vercel, every public write a visitor sends
51
+ # asks Vercel BotID for a verdict before the hub is called: the relay's
52
+ # POST /v1/leads/submit, /v1/leads/subscribe, /v1/leads/callback and
53
+ # /v1/schedule/book, and the site's lead, flow and checkout Server Actions
54
+ # (contact, careers, newsletter, flow step, flow checkout, saved
55
+ # recommendation, /dev/purchase). A bot is refused (403 bot_detected on the
56
+ # relay; the form's own error from an action), so no lead, booking or Slack
57
+ # invite exists. A real visitor sees no extra step, except that a form posted
58
+ # before the page has finished loading (or with JavaScript off) carries no
59
+ # challenge and is refused like a bot; resubmitting works. It is ON by
60
+ # default: set AILK_BOT_CHECK to `off` to turn every check off, or
61
+ # AILK_BOOKING_BOT_CHECK to `off` to turn off the booking's alone (any other
62
+ # value leaves a check on). Off Vercel it cannot run, so it is inert and the
63
+ # server logs that once. On Vercel it needs the project's OIDC token (on by
64
+ # default): without it every checked write is refused as unavailable (503
65
+ # bot_check_unavailable on the relay), as when BotID cannot answer. A
66
+ # self-hosted or local `next start` must not carry VERCEL=1 (as `vercel env
67
+ # pull` writes it), or every checked write is refused. Basic or Deep Analysis
68
+ # is the Vercel project's own setting; Deep Analysis is billed per check.
69
+ # AILK_BOT_CHECK=
61
70
  # AILK_BOOKING_BOT_CHECK=
62
71
  #
63
72
  # EDGE PRECONDITION: the relay forwards x-forwarded-for exactly as this site
@@ -168,6 +177,11 @@ CONTENT_ADAPTER=mdx
168
177
  # passes its own origin check and the emailed reset link is built on the
169
178
  # attacker's host. It is a conjunct of resetSendConfigured(), so a deployment
170
179
  # that has not pinned its origin does not offer reset at all.
180
+ #
181
+ # REQUIRED in production (#8620, #9965 D10): apps/web/auth.ts THROWS when
182
+ # Better Auth is first built without it, outside the build phase, as it does
183
+ # without BETTER_AUTH_SECRET. Every page that reads the session then fails, not
184
+ # only sign-in. It must be an https: URL (http: only on a loopback host).
171
185
  # BETTER_AUTH_URL=http://localhost:3000
172
186
  #
173
187
  # Secret used to sign session cookies AND the email-verification tokens.
@@ -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`.
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.
47
47
 
48
48
  ## Local dev
49
49
 
@@ -149,6 +149,21 @@ exists, deleting the user if that write fails. A request with no `metadata`
149
149
  between the user and grant inserts leaves a user with no grant, whom the
150
150
  account interstitial stops.
151
151
 
152
+ **The hub asks once per spoke before it signs a person in to it (#9966 D2).**
153
+ `app/api/auth/hub/authorize/route.ts`'s GET never says yes for anyone: when
154
+ the API answers `403 consent_required` — no unrevoked grant for that
155
+ (person, spoke) carrying the `consent` scope — it seals the six authorize
156
+ keys and 303s to `(authed)/account/connect?ref=…`. That page names the spoke
157
+ from the registry (`hubSignIn.clientInfo`), renders its form only when the
158
+ ref's `redirect_uri` is on the registry's origin, answers `referrer:
159
+ same-origin`, and 404s on a spoke build. Its Continue is a form POST of `ref`
160
+ to the same route, which accepts only `Sec-Fetch-Site: same-origin` with the
161
+ pinned `Origin` (a missing or `null` one is refused) and is the one call site
162
+ that sends `consent: true` — beside `x-internal-auth` under the Offerings buy
163
+ route's rule, since the API honours consent only with it where
164
+ `OSS_API_INTERNAL_SECRET` is set. A password reset or a sign-out revokes the
165
+ grant, so the next sign-in asks again. `ref` is in both telemetry scrub lists.
166
+
152
167
  ## Contact page
153
168
 
154
169
  The contact page (`apps/web/app/[locale]/contact/page.tsx`) renders the
@@ -305,7 +320,9 @@ records (#8849):
305
320
  `noindex, follow` and full-screen. Next.js serves the static `apply`
306
321
  segment first, the `[slug]/[detail]` idiom; every other action 404s. Lead
307
322
  capture is never-gate, so every tier takes an application. Both apply pages
308
- read the posting's title through `lib/posting-title.ts`.
323
+ read the posting's title through `lib/posting-title.ts`. A posting's
324
+ `applicationFlow: <magnet slug>` picks the magnet both pages run (#10054);
325
+ an unknown slug falls back to the Job application and is logged.
309
326
 
310
327
  ## Meeting pages
311
328
 
@@ -554,9 +571,29 @@ A visitor's browser never calls the hub. Lead capture, newsletter signup, callba
554
571
  - **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.
555
572
  - **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.
556
573
  - **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.
557
- - **A bot check on the booking (#9889).** On Vercel, `POST /v1/schedule/book` asks Vercel BotID (`checkBotId`, from `BOT_CHECKED_ROUTES`) for a verdict after every refusal above and before the hub is called: a bot is `403 bot_detected`, and a check that throws, answers no boolean or takes over 5 seconds is `503 bot_check_unavailable`, so a scripted booking never reaches Cal.com or Slack. It is on unless `AILK_BOOKING_BOT_CHECK=off` and inert off Vercel; the locale layout mounts `BotIdClient` on the same list and predicate, `next.config.mjs` wraps `withBotId`, and `middleware.ts` keeps BotID's proxy prefix out of its matcher.
574
+ - **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.
558
575
  - `/v1` is exempt from next-intl's locale rewrite in `middleware.ts`, as `/api` is.
559
576
 
577
+ ## Where the docs live (`docsPath`)
578
+
579
+ A site serves its docs at `/docs` unless `content/_site.mdx` sets a top-level `docsPath` (one to four lowercase kebab segments, leading slash, no trailing slash — e.g. `docsPath: /working-theory/docs`, #10041). The route never moves: it stays at `app/[locale]/docs/`, and every surface reads the one value through `lib/docs-path.ts` (`DOCS_PATH`, `toPublicDocsHref`).
580
+
581
+ - **Build.** `next.config.mjs` reads `docsPath`, bakes it into `NEXT_PUBLIC_AILK_DOCS_PATH`, and fails the build naming the value when it breaks the pattern, starts with `api`, `og`, `v1` or an enabled locale, sits under `/docs`, shadows a `ROUTES` page (a `:param` segment matches any segment, so `/products/docs` is refused) or sits under a catch-all (`/blog/...`), or when this site's `lib/source.ts` does not set `baseUrl: DOCS_PATH` — a site with no docs-blog module, or one whose docs-blog files predate the setting. Links inside a doc's body are re-pointed only on a site that sets `docsPath`; there they render through `Link`, which forwards a fixed prop set, so a body anchor's `title` / `target` / `rel` are not kept.
582
+ - **Request.** `middleware.ts` hands next-intl the internal `/docs/...` path for a request at or under `docsPath` (bare or locale-prefixed), so the browser keeps the public URL, and maps a redirect's `Location` and each hreflang `Link` entry back onto it. `/docs` and `/docs/...` answer with the site's 404 — rewritten to `/_ailk-docs-unmounted`, whose first segment is no locale, so the locale layout calls `notFound()`.
583
+ - **Surfaces.** The docs sidebar, index and search index (both sources' `baseUrl`), the doc's breadcrumb, links inside a doc's body, every nav and footer entry at `/docs` (mapped at render in the locale layout and `components/signed-in-nav.tsx`; `nav-config.ts` is never edited), and the docs entries in `/sitemap.xml` and `/llms.txt` (`lib/docs-index.ts`, default locale only) all follow it. A site with no `docsPath` serves every URL exactly as before.
584
+ - **Existing docs sites.** `create-ailk update` carries the base readers but never refreshes a module bundle's files, so a docs-blog site installed before this release keeps a `lib/source.ts` without `DOCS_PATH` and its build refuses a `docsPath` until those files are re-installed — a standing gap no command fills yet (#10076).
585
+ - **Rollout.** A `@working-theory/site-config` older than this setting rejects a `_site.mdx` carrying `docsPath` (its schema is strict), and `@working-theory/api` / `@working-theory/mcp-server` then read the site as having no config. Set `docsPath` only once the site's packages carry it.
586
+
587
+ ## Where the services live (`services.path`)
588
+
589
+ A site serves its services at `/services` unless `content/_site.mdx` sets `services: { path: /capabilities }` (the `services` route family's one extra key, beside its `header` / `footer`; one to four lowercase kebab segments, not under `/services`, #10043). The routes never move: they stay at `app/[locale]/services/`, and every surface reads the one value through `lib/services-path.ts` (`SERVICES_PATH`, `toPublicServicesHref`) or, in `@working-theory/page-renderer`, `NEXT_PUBLIC_AILK_SERVICES_PATH`.
590
+
591
+ - **Categories.** A category is itself a `Service` page. A service names its category with frontmatter `parent: <category-slug>` and is served at `/<base>/<category>/<slug>` (`services/[slug]/[child]/page.tsx`); its flat URL answers a permanent redirect there, and the nested URL answers 404 unless the `parent` names that category and the category has no `parent` of its own. A category page lists its services after its body (`lib/service-listing.ts`, the `services` messages' `childrenHeading` / `viewService`). One level only.
592
+ - **Build.** `next.config.mjs` reads `services.path`, bakes it into `NEXT_PUBLIC_AILK_SERVICES_PATH`, and fails the build naming the value when it breaks the pattern, starts with an enabled locale, shadows a `ROUTES` page, sits under a catch-all, or overlaps `docsPath` either way. It also fails, naming the file, on a default-locale `Service` whose `parent` names no Service or a Service with its own `parent`, and on any `parent` when the nested route is missing (`create-ailk sync-routes` adds it).
593
+ - **Request.** `middleware.ts` hands next-intl the internal `/services/...` path for a request at or under the base, keeps `x-pathname` public, and maps a redirect's `Location` and each hreflang `Link` entry back. `/services` and `/services/...` answer `308` onto the same rest under the base (query and locale prefix kept), so old links keep working; links inside content are not rewritten.
594
+ - **Surfaces.** The canonical, hreflang and `BreadcrumbList` (the base's title-cased last segment, then the category's title), `/sitemap.xml` and `/llms.txt` (`pagePathFor`), every nav and footer entry at `/services` (mapped in the locale layout and `components/signed-in-nav.tsx`), the hub's own section links, and route-family chrome (`lib/page-chrome.ts`) all follow it. A site that sets neither `services.path` nor any `parent` serves every URL exactly as before.
595
+ - **Moving it.** `/ailk:growth-ops page rename` moves the base (`--from <current base> --to <new base>`): it writes `services.path` and keeps a previous moved base redirecting.
596
+
560
597
  ## AEO & performance
561
598
 
562
599
  - Static rendering is the default. Don't introduce client rendering without a reason.
@@ -573,7 +610,11 @@ A visitor's browser never calls the hub. Lead capture, newsletter signup, callba
573
610
  - Don't reach into `apps/api` or `apps/mcp` from `apps/web`. They're peer apps — talk via HTTP/MCP.
574
611
  - Don't commit images to `screenshots/` — only the `.gitkeep` is tracked.
575
612
 
576
- Last updated: 2026-10-25 — the Offerings buy route's spoke sentence: on a hub that sets `OSS_API_INTERNAL_SECRET` a spoke's key authenticates its buy, held to its hub sign-in client's origin (#9910) — verified against `app/api/offerings/checkout/[lookupKey]/route.ts` and `packages/api/src/routes/checkout/sessions.ts`.
613
+ Last updated: 2026-10-27 — § Where the services live (`services.path`): the services base, one category level, the 308 from `/services` and the `update-page` base move (#10043) — verified against `lib/services-path.ts`, `lib/service-listing.ts`, `middleware.ts`, `next.config.mjs` and `app/[locale]/services/`.
614
+
615
+ Prior: 2026-10-26 — § The same-origin relay: the bot check moves to `lib/bot-check.ts` and covers the relay's three lead routes and the seven public lead, flow and checkout Server Actions, with `BotIdClient` protecting every same-origin POST and the `AILK_BOT_CHECK` switch (#9255) — verified against `lib/bot-check.ts`, `lib/relay.ts`, `app/[locale]/layout.tsx` and the seven actions' files.
616
+
617
+ Prior: 2026-10-25 — the Offerings buy route's spoke sentence: on a hub that sets `OSS_API_INTERNAL_SECRET` a spoke's key authenticates its buy, held to its hub sign-in client's origin (#9910) — verified against `app/api/offerings/checkout/[lookupKey]/route.ts` and `packages/api/src/routes/checkout/sessions.ts`.
577
618
 
578
619
  Prior: 2026-10-25 — § The same-origin relay: the booking bot check — Vercel BotID on `POST /v1/schedule/book`, `bot_detected` / `bot_check_unavailable`, the `AILK_BOOKING_BOT_CHECK` switch and the middleware's BotID exclusion (#9889) — verified against `lib/relay.ts`, `app/[locale]/layout.tsx`, `next.config.mjs` and `middleware.ts`.
579
620
 
@@ -58,6 +58,13 @@ if (typeof window !== "undefined") {
58
58
  /* eslint-enable no-restricted-syntax */
59
59
  }
60
60
 
61
+ // A page whose generateMetadata calls @working-theory/page-renderer's
62
+ // renderPageMetadata builds hreflang from readLocales() (#9660), which reads the
63
+ // locale list next.config.mjs bakes into NEXT_PUBLIC_ENABLED_LOCALES and
64
+ // otherwise throws on a missing project.yaml. Jest runs no next build, so supply
65
+ // the baked value; a suite that sets its own keeps it.
66
+ process.env.NEXT_PUBLIC_ENABLED_LOCALES ??= "en";
67
+
61
68
  // next-intl/server and next-intl are supplied by moduleNameMapper entries in
62
69
  // jest.config.cjs (__tests__/__mocks__/next-intl-server.ts and next-intl.ts),
63
70
  // not by jest.mock() here. A mapper is path-independent; a jest.mock() binds to
@@ -17,8 +17,8 @@
17
17
  * to make impossible. The same single read also yields the licence features
18
18
  * the deployment holds (#7136), which gate the five `feature`-tagged tools
19
19
  * here exactly as they gate registration on the MCP server. `create_project`
20
- * is left out while create-ailk's release scope holds the Free level (#9534),
21
- * as `apps/mcp` leaves it unregistered — `lib/release-scope.ts`.
20
+ * is always left out: this manifest describes the HTTP surface, and the MCP
21
+ * server serves that tool over stdio only (#9965 D13, #8584).
22
22
  *
23
23
  * Mirrors /.well-known/ai-plugin.json conventions: Response.json with a
24
24
  * conservative cache-control header.
@@ -28,7 +28,6 @@
28
28
  * is appropriate per the MCP discovery spec.
29
29
  */
30
30
 
31
- import { isFreeLevelReleased } from "../../../lib/release-scope";
32
31
  import { UNSERVED_MCP_TOOLS } from "../../../lib/topology.generated";
33
32
  import packageJson from "../../../package.json";
34
33
  import {
@@ -64,10 +63,11 @@ export async function GET(): Promise<Response> {
64
63
  commerceClaimed: heldFeatures.includes(COMMERCE_FEATURE),
65
64
  heldFeatures,
66
65
  // The tools this topology's MCP server does not register (#8070 D7) —
67
- // and create_project while the Free level is held (#9534).
68
- unservedTools: isFreeLevelReleased()
69
- ? UNSERVED_MCP_TOOLS
70
- : [...UNSERVED_MCP_TOOLS, "create_project"],
66
+ // and create_project, which the HTTP transport never serves (#9965 D13).
67
+ unservedTools: [
68
+ ...UNSERVED_MCP_TOOLS.filter((name) => name !== "create_project"),
69
+ "create_project",
70
+ ],
71
71
  // Spread conditional to satisfy exactOptionalPropertyTypes: omit the key
72
72
  // entirely when the env var is not set rather than passing undefined.
73
73
  ...(mcpHttpUrlEnv ? { mcpHttpUrl: mcpHttpUrlEnv } : {}),
@@ -17,7 +17,8 @@
17
17
  * sandbox `Content-Security-Policy` and `Cache-Control: private, no-store` —
18
18
  * so the file is never a document on this origin and never cached on the way.
19
19
  * A hub answer missing any of the five is refused 502 rather than served
20
- * without it.
20
+ * without it — and so is one whose safety headers carry the wrong value
21
+ * (#8320, #9965 D8): presence alone is not the hub's promise.
21
22
  *
22
23
  * The params are shape-checked before any call (a malformed piece id or a
23
24
  * version that is not a whole number from 1 is a 404 with no call). A
@@ -43,6 +44,59 @@ const FILE_HEADER_NAMES: Record<keyof MemberFileHeaders, string> = {
43
44
  cacheControl: "Cache-Control",
44
45
  };
45
46
 
47
+ /**
48
+ * HTTP's own whitespace — SP and HTAB, never JS `\s`. A browser reads any
49
+ * other byte (a non-breaking space among them) as part of the token, so a
50
+ * check that trimmed or split on it would pass a value it does not describe.
51
+ */
52
+ const HTTP_VALUE = /^[\t\x20-\x7e]*$/;
53
+ const httpTrim = (value: string) => value.replace(/^[ \t]+|[ \t]+$/g, "");
54
+
55
+ /**
56
+ * The CSP's directives by name, or `null` for two policies joined or a
57
+ * directive named twice — a browser enforces the first copy, so a check that
58
+ * read a later one could pass a policy it does not describe.
59
+ */
60
+ function cspDirectives(policy: string): Map<string, string> | null {
61
+ // `Headers.get` joins two CSP headers with ", " — a second policy the hub
62
+ // never sent (#9965 SEC-9965-16).
63
+ if (policy.includes(",")) return null;
64
+ const directives = new Map<string, string>();
65
+ for (const part of policy.split(";")) {
66
+ const [name = "", ...rest] = httpTrim(part).split(/[ \t]+/);
67
+ if (!name) continue;
68
+ if (directives.has(name)) return null;
69
+ directives.set(name, rest.join(" "));
70
+ }
71
+ return directives;
72
+ }
73
+
74
+ /**
75
+ * Whether each safety header carries the value the hub sends (D8). Every
76
+ * comparison is case-insensitive and trimmed of SP and HTAB, and a value
77
+ * carrying any byte outside visible ASCII, SP and HTAB is refused before it
78
+ * is parsed. `Content-Type` is not checked: the other four contain it.
79
+ */
80
+ const SAFE_VALUE: Partial<
81
+ Record<keyof MemberFileHeaders, (value: string) => boolean>
82
+ > = {
83
+ contentTypeOptions: (value) => value === "nosniff",
84
+ contentDisposition: (value) => /^attachment[ \t]*(;|$)/.test(value),
85
+ contentSecurityPolicy: (value) => {
86
+ const directives = cspDirectives(value);
87
+ const sandbox = directives?.get("sandbox");
88
+ return (
89
+ sandbox !== undefined &&
90
+ !sandbox.split(" ").includes("allow-scripts") &&
91
+ directives?.get("default-src") === "'none'"
92
+ );
93
+ },
94
+ cacheControl: (value) => {
95
+ const directives = value.split(",").map((part) => part.trim());
96
+ return directives.includes("private") && directives.includes("no-store");
97
+ },
98
+ };
99
+
46
100
  function refusal(status: number): Response {
47
101
  return new Response(null, {
48
102
  status,
@@ -90,7 +144,12 @@ export async function GET(
90
144
  string,
91
145
  ][]) {
92
146
  const value = headers[key];
93
- if (value === null) {
147
+ const check = SAFE_VALUE[key];
148
+ const safe =
149
+ value !== null &&
150
+ (check === undefined ||
151
+ (HTTP_VALUE.test(value) && check(httpTrim(value).toLowerCase())));
152
+ if (!safe) {
94
153
  await stream.cancel();
95
154
  return refusal(502);
96
155
  }
@@ -14,6 +14,8 @@
14
14
  * unchanged;
15
15
  * - a hub answer missing any of the five is refused 502, its stream
16
16
  * cancelled — never bytes on this origin without them;
17
+ * - so is one carrying a safety header with the wrong value (#8320, #9965
18
+ * D8): presence is not enough;
17
19
  * - malformed params are a 404 with no call — and `locale`, which Next
18
20
  * always passes, never makes a real request fail;
19
21
  * - refusals map to a status with no body and `private, no-store`;
@@ -140,6 +142,85 @@ describe("the version file read", () => {
140
142
  },
141
143
  );
142
144
 
145
+ it.each([
146
+ [
147
+ "an inline Content-Disposition",
148
+ { contentDisposition: "inline; filename=a.pdf" },
149
+ ],
150
+ [
151
+ "X-Content-Type-Options other than nosniff",
152
+ { contentTypeOptions: "sniff" },
153
+ ],
154
+ [
155
+ "a Content-Security-Policy with no sandbox",
156
+ { contentSecurityPolicy: "default-src *" },
157
+ ],
158
+ [
159
+ "a sandbox that allows scripts",
160
+ { contentSecurityPolicy: "default-src 'none'; sandbox allow-scripts" },
161
+ ],
162
+ [
163
+ "a sandbox with default-src other than 'none'",
164
+ { contentSecurityPolicy: "default-src *; sandbox" },
165
+ ],
166
+ [
167
+ "two CSP headers, joined (the hub's plus default-src *)",
168
+ { contentSecurityPolicy: "default-src 'none'; sandbox, default-src *" },
169
+ ],
170
+ [
171
+ "a repeated sandbox directive, whose first copy allows scripts",
172
+ {
173
+ contentSecurityPolicy:
174
+ "sandbox allow-scripts; sandbox; default-src 'none'",
175
+ },
176
+ ],
177
+ // HTTP whitespace is SP and HTAB only: a browser reads a non-breaking
178
+ // space as part of the token, so a value JS would trim to the safe one
179
+ // is not the safe one.
180
+ [
181
+ "an attachment Content-Disposition with a non-breaking space",
182
+ { contentDisposition: "attachment\u00a0; filename=x.html" },
183
+ ],
184
+ [
185
+ "nosniff followed by a non-breaking space",
186
+ { contentTypeOptions: "nosniff\u00a0" },
187
+ ],
188
+ [
189
+ "a CSP whose directives are split by non-breaking spaces",
190
+ { contentSecurityPolicy: "default-src\u00a0'none'; sandbox\u00a0" },
191
+ ],
192
+ [
193
+ "a public, cacheable Cache-Control",
194
+ { cacheControl: "public, max-age=3600" },
195
+ ],
196
+ [
197
+ "a Cache-Control without no-store",
198
+ { cacheControl: "private, max-age=60" },
199
+ ],
200
+ ])("refuses 502 with no bytes on %s", async (_case, headers) => {
201
+ const result = fileResult(headers);
202
+ const cancel = jest.spyOn(result.body.stream as ReadableStream, "cancel");
203
+ mockArtifactFile.mockResolvedValue(result);
204
+ const res = await get();
205
+ expect(cancel).toHaveBeenCalled();
206
+ await expectBodylessRefusal(res, 502);
207
+ });
208
+
209
+ it("serves the hub's own attachment policy, compared case-insensitively", async () => {
210
+ mockArtifactFile.mockResolvedValue(
211
+ fileResult({
212
+ contentDisposition: 'Attachment; filename="brief-v2.pdf"',
213
+ contentTypeOptions: "NoSniff",
214
+ contentSecurityPolicy:
215
+ "default-src 'none'; sandbox; frame-ancestors 'none'; form-action 'none'; base-uri 'none'",
216
+ cacheControl: "no-store, Private",
217
+ }),
218
+ );
219
+ const res = await get();
220
+ expect(res.status).toBe(200);
221
+ await expect(res.text()).resolves.toBe("%PDF-1.7 bytes");
222
+ });
223
+
143
224
  it.each([
144
225
  ["a version of 0", PIECE, "0"],
145
226
  ["a version that is not a number", PIECE, "abc"],