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.
- package/README.md +84 -7
- package/dist/add-module-core.js +2 -1
- package/dist/add-module.d.ts +2 -1
- package/dist/add-module.js +9 -4
- package/dist/add-route.js +46 -19
- package/dist/body-heading.d.ts +56 -0
- package/dist/body-heading.js +394 -0
- package/dist/cli.js +28 -1
- package/dist/content-payloads.js +2 -2
- package/dist/derive-architecture.js +8 -2
- package/dist/index.d.ts +2 -1
- package/dist/index.js +4 -1
- package/dist/lib/auth-secret-file.d.ts +6 -2
- package/dist/lib/auth-secret-file.js +46 -20
- package/dist/lib/chrome-readers.d.ts +21 -5
- package/dist/lib/chrome-readers.js +128 -20
- package/dist/lib/declared-env.d.ts +6 -0
- package/dist/lib/declared-env.js +2 -0
- package/dist/lib/env-catalog.js +20 -0
- package/dist/lib/import-graph.d.ts +16 -0
- package/dist/lib/import-graph.js +53 -0
- package/dist/lib/site-modules.d.ts +8 -0
- package/dist/lib/site-modules.js +34 -0
- package/dist/module-architecture.js +23 -14
- package/dist/paid-sections.d.ts +17 -0
- package/dist/paid-sections.js +39 -0
- package/dist/programmatic.d.ts +16 -0
- package/dist/programmatic.js +73 -4
- package/dist/remove-module.d.ts +98 -0
- package/dist/remove-module.js +620 -0
- package/dist/seed-flows.d.ts +2 -2
- package/dist/seed-flows.js +16 -9
- package/dist/site-roster.js +4 -1
- package/dist/starter-grid.d.ts +26 -0
- package/dist/starter-grid.js +34 -0
- package/dist/surfaces.d.ts +26 -0
- package/dist/surfaces.js +105 -9
- package/dist/sync-routes.d.ts +7 -0
- package/dist/sync-routes.js +53 -6
- package/dist/template-pages.d.ts +36 -0
- package/dist/template-pages.js +129 -0
- package/dist/template-record.d.ts +13 -4
- package/dist/template-record.js +8 -4
- package/dist/topology.d.ts +2 -1
- package/dist/topology.js +4 -2
- package/dist/update.d.ts +34 -2
- package/dist/update.js +395 -48
- package/package.json +3 -3
- package/release-scope.json +2 -1
- package/site-templates/aliases/hub.TEMPLATE-MANIFEST.json +115 -0
- package/templates/.env.example +5 -2
- package/templates/apps/api/.env.example +9 -5
- package/templates/apps/api/CLAUDE.md +14 -9
- package/templates/apps/api/src/__tests__/vercel-handler.test.ts +37 -0
- package/templates/apps/api/src/vercel-handler.ts +8 -1
- package/templates/apps/mcp/CLAUDE.md +18 -10
- package/templates/apps/web/.env.example +25 -11
- package/templates/apps/web/CLAUDE.md +45 -4
- package/templates/apps/web/__tests__/setup.ts +7 -0
- package/templates/apps/web/app/.well-known/mcp.json/route.ts +7 -7
- package/templates/apps/web/app/[locale]/(authed)/account/artifacts/[pieceId]/versions/[version]/file/route.ts +61 -2
- package/templates/apps/web/app/[locale]/(authed)/account/artifacts/__tests__/file-route.test.ts +81 -0
- package/templates/apps/web/app/[locale]/(authed)/account/connect/__tests__/page.test.tsx +197 -0
- package/templates/apps/web/app/[locale]/(authed)/account/connect/page.tsx +152 -0
- package/templates/apps/web/app/[locale]/about/__tests__/about-routes.test.tsx +2 -2
- package/templates/apps/web/app/[locale]/careers/[slug]/[action]/page.tsx +76 -21
- package/templates/apps/web/app/[locale]/careers/[slug]/apply.actions.ts +46 -16
- package/templates/apps/web/app/[locale]/careers/[slug]/page.tsx +20 -3
- package/templates/apps/web/app/[locale]/careers/__tests__/apply.actions.test.ts +180 -2
- package/templates/apps/web/app/[locale]/careers/__tests__/careers-set.test.tsx +6 -2
- package/templates/apps/web/app/[locale]/careers/__tests__/job-apply-page.test.tsx +122 -0
- package/templates/apps/web/app/[locale]/careers/__tests__/page-renderer-migration.test.tsx +1 -1
- package/templates/apps/web/app/[locale]/case-studies/[slug]/page.tsx +20 -3
- package/templates/apps/web/app/[locale]/case-studies/__tests__/hub-children.test.tsx +65 -0
- package/templates/apps/web/app/[locale]/case-studies/__tests__/page-renderer-migration.test.tsx +6 -2
- package/templates/apps/web/app/[locale]/case-studies/page.tsx +23 -11
- package/templates/apps/web/app/[locale]/checkout/__tests__/thank-you-account.test.tsx +11 -1
- package/templates/apps/web/app/[locale]/clients/__tests__/page-renderer-migration.test.tsx +1 -1
- package/templates/apps/web/app/[locale]/consent.actions.ts +44 -6
- package/templates/apps/web/app/[locale]/contact/__tests__/ConversationalIntakeWrapper.test.tsx +4 -4
- package/templates/apps/web/app/[locale]/contact/__tests__/contact.actions.test.ts +269 -42
- package/templates/apps/web/app/[locale]/contact/contact.actions.ts +88 -30
- package/templates/apps/web/app/[locale]/courses/[slug]/page.tsx +20 -3
- package/templates/apps/web/app/[locale]/courses/__tests__/hub-children.test.tsx +32 -0
- package/templates/apps/web/app/[locale]/courses/__tests__/page-renderer-migration.test.tsx +6 -2
- package/templates/apps/web/app/[locale]/courses/page.tsx +22 -6
- package/templates/apps/web/app/[locale]/events/[slug]/page.tsx +15 -2
- package/templates/apps/web/app/[locale]/events/__tests__/hub-children.test.tsx +32 -0
- package/templates/apps/web/app/[locale]/events/__tests__/page-renderer-migration.test.tsx +8 -2
- package/templates/apps/web/app/[locale]/events/page.tsx +23 -7
- package/templates/apps/web/app/[locale]/features/__tests__/hub-children.test.tsx +32 -0
- package/templates/apps/web/app/[locale]/features/page.tsx +23 -25
- package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/flow.actions.test.ts +95 -0
- package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/save.actions.test.ts +193 -0
- package/templates/apps/web/app/[locale]/flows/[slug]/email-moment.ts +92 -0
- package/templates/apps/web/app/[locale]/flows/[slug]/flow.actions.ts +10 -0
- package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/ResultsClient.tsx +513 -0
- package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/ResultsEmailStep.tsx +122 -0
- package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/__tests__/ResultsClient.no-commerce.test.tsx +195 -0
- package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/__tests__/landing.test.tsx +293 -0
- package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/__tests__/page.test.tsx +397 -0
- package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/checkout-seam.ts +74 -0
- package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/page.tsx +264 -0
- package/templates/apps/web/app/[locale]/flows/[slug]/save.actions.ts +75 -0
- package/templates/apps/web/app/[locale]/for/__tests__/hub-children.test.tsx +32 -0
- package/templates/apps/web/app/[locale]/for/__tests__/page-renderer-migration.test.tsx +5 -1
- package/templates/apps/web/app/[locale]/for/page.tsx +23 -25
- package/templates/apps/web/app/[locale]/how-to/[slug]/page.tsx +20 -3
- package/templates/apps/web/app/[locale]/how-to/__tests__/page-renderer-migration.test.tsx +1 -1
- package/templates/apps/web/app/[locale]/integrations/[slug]/page.tsx +25 -4
- package/templates/apps/web/app/[locale]/integrations/__tests__/hub-children.test.tsx +38 -0
- package/templates/apps/web/app/[locale]/integrations/__tests__/page-renderer-migration.test.tsx +8 -2
- package/templates/apps/web/app/[locale]/integrations/page.tsx +23 -11
- package/templates/apps/web/app/[locale]/layout.tsx +157 -139
- package/templates/apps/web/app/[locale]/page.tsx +51 -3
- package/templates/apps/web/app/[locale]/partners/[slug]/page.tsx +16 -3
- package/templates/apps/web/app/[locale]/partners/__tests__/hub-children.test.tsx +32 -0
- package/templates/apps/web/app/[locale]/partners/__tests__/page-renderer-migration.test.tsx +6 -2
- package/templates/apps/web/app/[locale]/partners/page.tsx +22 -6
- package/templates/apps/web/app/[locale]/portfolio/__tests__/hub-children.test.tsx +38 -0
- package/templates/apps/web/app/[locale]/portfolio/page.tsx +25 -24
- package/templates/apps/web/app/[locale]/pricing/[slug]/__tests__/page.test.tsx +321 -0
- package/templates/apps/web/app/[locale]/pricing/[slug]/page.tsx +87 -0
- package/templates/apps/web/app/[locale]/pricing/__tests__/offerings.test.tsx +3 -0
- package/templates/apps/web/app/[locale]/pricing/__tests__/pricing-pages.test.tsx +215 -0
- package/templates/apps/web/app/[locale]/pricing/page.tsx +56 -341
- package/templates/apps/web/app/[locale]/products/[slug]/page.tsx +21 -4
- package/templates/apps/web/app/[locale]/products/__tests__/hub-children.test.tsx +32 -0
- package/templates/apps/web/app/[locale]/products/__tests__/page-renderer-migration.test.tsx +8 -2
- package/templates/apps/web/app/[locale]/products/page.tsx +22 -6
- package/templates/apps/web/app/[locale]/projects/__tests__/hub-children.test.tsx +32 -0
- package/templates/apps/web/app/[locale]/projects/__tests__/page-renderer-migration.test.tsx +4 -0
- package/templates/apps/web/app/[locale]/projects/page.tsx +18 -11
- package/templates/apps/web/app/[locale]/resources/[slug]/page.tsx +25 -4
- package/templates/apps/web/app/[locale]/resources/__tests__/hub-children.test.tsx +38 -0
- package/templates/apps/web/app/[locale]/resources/__tests__/page-renderer-migration.test.tsx +6 -2
- package/templates/apps/web/app/[locale]/resources/page.tsx +20 -10
- package/templates/apps/web/app/[locale]/services/[slug]/[child]/page.tsx +68 -0
- package/templates/apps/web/app/[locale]/services/[slug]/page.tsx +64 -9
- package/templates/apps/web/app/[locale]/services/__tests__/hub-children.test.tsx +158 -0
- package/templates/apps/web/app/[locale]/services/__tests__/page-renderer-migration.test.tsx +12 -2
- package/templates/apps/web/app/[locale]/services/__tests__/service-detail-sections.test.tsx +224 -0
- package/templates/apps/web/app/[locale]/services/__tests__/service-families.test.tsx +368 -0
- package/templates/apps/web/app/[locale]/services/page.tsx +45 -19
- package/templates/apps/web/app/[locale]/software/__tests__/page-renderer-migration.test.tsx +1 -1
- package/templates/apps/web/app/[locale]/team/__tests__/hub-children.test.tsx +32 -0
- package/templates/apps/web/app/[locale]/team/page.tsx +25 -24
- package/templates/apps/web/app/[locale]/tech-articles/[slug]/page.tsx +24 -3
- package/templates/apps/web/app/[locale]/tech-articles/__tests__/page-renderer-migration.test.tsx +1 -1
- package/templates/apps/web/app/[locale]/templates/[slug]/__tests__/page.test.tsx +203 -0
- package/templates/apps/web/app/[locale]/templates/[slug]/page.tsx +81 -0
- package/templates/apps/web/app/[locale]/templates/__tests__/page.test.tsx +237 -0
- package/templates/apps/web/app/[locale]/templates/page.tsx +108 -0
- package/templates/apps/web/app/[locale]/testimonials/__tests__/page-renderer-migration.test.tsx +1 -1
- package/templates/apps/web/app/[locale]/whitepapers/[slug]/page.tsx +24 -3
- package/templates/apps/web/app/[locale]/whitepapers/__tests__/page-renderer-migration.test.tsx +1 -1
- package/templates/apps/web/app/api/auth/hub/authorize/__tests__/route.test.ts +204 -2
- package/templates/apps/web/app/api/auth/hub/authorize/route.ts +151 -33
- package/templates/apps/web/app/api/offerings/checkout/[lookupKey]/__tests__/route.test.ts +233 -7
- package/templates/apps/web/app/api/offerings/checkout/[lookupKey]/route.ts +92 -41
- package/templates/apps/web/app/api/offerings/checkout/__tests__/route.test.ts +77 -39
- package/templates/apps/web/app/llms.txt/route.ts +41 -3
- package/templates/apps/web/app/robots.txt/route.ts +5 -1
- package/templates/apps/web/app/sitemap.xml/route.ts +45 -3
- package/templates/apps/web/auth.ts +102 -50
- package/templates/apps/web/components/signed-in-nav.tsx +8 -1
- package/templates/apps/web/lib/__tests__/bot-check.test.ts +254 -0
- package/templates/apps/web/lib/__tests__/consent-visitor.test.ts +212 -13
- package/templates/apps/web/lib/__tests__/deck-page.test.ts +13 -1
- package/templates/apps/web/lib/__tests__/docs-index.test.ts +93 -0
- package/templates/apps/web/lib/__tests__/docs-path.test.ts +168 -0
- package/templates/apps/web/lib/__tests__/hub-children.fixtures.ts +279 -0
- package/templates/apps/web/lib/__tests__/nav-label-keys.test.ts +138 -0
- package/templates/apps/web/lib/__tests__/newsletter.actions.test.ts +178 -2
- package/templates/apps/web/lib/__tests__/offerings-pricing.test.ts +183 -0
- package/templates/apps/web/lib/__tests__/page-chrome.test.ts +412 -0
- package/templates/apps/web/lib/__tests__/pricing-page.test.ts +142 -0
- package/templates/apps/web/lib/__tests__/relay.test.ts +202 -10
- package/templates/apps/web/lib/__tests__/route-family.test.ts +57 -0
- package/templates/apps/web/lib/__tests__/service-listing.test.ts +111 -0
- package/templates/apps/web/lib/__tests__/services-path.test.ts +96 -0
- package/templates/apps/web/lib/__tests__/site-header.test.ts +151 -0
- package/templates/apps/web/lib/__tests__/structured-data.test.tsx +148 -6
- package/templates/apps/web/lib/__tests__/telemetry-scrub.test.ts +84 -0
- package/templates/apps/web/lib/__tests__/template-listing.test.ts +158 -0
- package/templates/apps/web/lib/auth-secret.ts +57 -0
- package/templates/apps/web/lib/bot-check.ts +138 -0
- package/templates/apps/web/lib/consent-visitor.ts +81 -7
- package/templates/apps/web/lib/deck-page.ts +6 -3
- package/templates/apps/web/lib/docs-index.ts +100 -0
- package/templates/apps/web/lib/docs-path.ts +94 -0
- package/templates/apps/web/lib/hub-children.tsx +318 -0
- package/templates/apps/web/lib/modules.generated.ts +9 -0
- package/templates/apps/web/lib/nav-label-check.ts +61 -0
- package/templates/apps/web/lib/nav-label-keys.ts +78 -0
- package/templates/apps/web/lib/newsletter.actions.ts +31 -6
- package/templates/apps/web/lib/offerings-pricing.ts +135 -6
- package/templates/apps/web/lib/page-chrome.ts +367 -0
- package/templates/apps/web/lib/posting-title.ts +26 -4
- package/templates/apps/web/lib/pricing-page.ts +430 -0
- package/templates/apps/web/lib/relay.ts +31 -106
- package/templates/apps/web/lib/route-family.ts +69 -0
- package/templates/apps/web/lib/service-listing.ts +89 -0
- package/templates/apps/web/lib/services-path.ts +97 -0
- package/templates/apps/web/lib/site-header.ts +94 -0
- package/templates/apps/web/lib/telemetry-scrub.ts +68 -3
- package/templates/apps/web/lib/template-listing.ts +169 -0
- package/templates/apps/web/messages/en.json +86 -30
- package/templates/apps/web/middleware.ts +171 -11
- package/templates/apps/web/next.config.mjs +211 -2
- package/templates/apps/web/package.json +1 -1
- package/templates/apps/web/scripts/assert-content-bundled.ts +28 -6
- package/templates/apps/web/scripts/check-nav-labels.ts +39 -0
- package/templates/apps/web/sentry.server.config.ts +30 -2
- package/templates/apps/web/src/lib/__tests__/rate-limit.test.ts +67 -9
- package/templates/apps/web/src/lib/rate-limit.ts +68 -27
- package/templates/apps/web/src/lib/routes.ts +15 -1
- package/templates/tools/declared-env.ts +7 -0
- 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.
|
|
75
|
-
|
|
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.
|
|
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
|
|
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)
|
|
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-
|
|
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
|
|
51
|
-
# BotID for a verdict before the hub is called:
|
|
52
|
-
#
|
|
53
|
-
#
|
|
54
|
-
#
|
|
55
|
-
#
|
|
56
|
-
#
|
|
57
|
-
#
|
|
58
|
-
#
|
|
59
|
-
#
|
|
60
|
-
#
|
|
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
|
|
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-
|
|
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
|
|
21
|
-
*
|
|
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
|
|
68
|
-
unservedTools:
|
|
69
|
-
|
|
70
|
-
|
|
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
|
-
|
|
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
|
}
|
package/templates/apps/web/app/[locale]/(authed)/account/artifacts/__tests__/file-route.test.ts
CHANGED
|
@@ -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"],
|