create-ailk 0.26.0 → 0.28.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (224) hide show
  1. package/README.md +86 -9
  2. package/dist/add-module-core.js +2 -1
  3. package/dist/add-module.d.ts +2 -1
  4. package/dist/add-module.js +9 -4
  5. package/dist/add-route.js +46 -19
  6. package/dist/body-heading.d.ts +56 -0
  7. package/dist/body-heading.js +394 -0
  8. package/dist/cli.js +28 -1
  9. package/dist/content-payloads.js +2 -2
  10. package/dist/derive-architecture.js +8 -2
  11. package/dist/index.d.ts +2 -1
  12. package/dist/index.js +4 -1
  13. package/dist/lib/auth-secret-file.d.ts +6 -2
  14. package/dist/lib/auth-secret-file.js +46 -20
  15. package/dist/lib/env-catalog.js +20 -0
  16. package/dist/lib/site-modules.d.ts +8 -0
  17. package/dist/lib/site-modules.js +34 -0
  18. package/dist/module-architecture.js +25 -14
  19. package/dist/paid-sections.d.ts +17 -0
  20. package/dist/paid-sections.js +39 -0
  21. package/dist/programmatic.d.ts +16 -0
  22. package/dist/programmatic.js +73 -4
  23. package/dist/remove-module.d.ts +98 -0
  24. package/dist/remove-module.js +620 -0
  25. package/dist/seed-flows.d.ts +2 -2
  26. package/dist/seed-flows.js +16 -9
  27. package/dist/site-roster.js +4 -1
  28. package/dist/starter-grid.d.ts +26 -0
  29. package/dist/starter-grid.js +34 -0
  30. package/dist/surfaces.d.ts +26 -0
  31. package/dist/surfaces.js +112 -11
  32. package/dist/sync-routes.d.ts +7 -0
  33. package/dist/sync-routes.js +53 -6
  34. package/dist/template-pages.d.ts +36 -0
  35. package/dist/template-pages.js +129 -0
  36. package/dist/topology.d.ts +2 -1
  37. package/dist/topology.js +4 -2
  38. package/dist/update.d.ts +3 -1
  39. package/dist/update.js +90 -6
  40. package/package.json +3 -3
  41. package/release-scope.json +2 -1
  42. package/site-templates/aliases/hub.TEMPLATE-MANIFEST.json +115 -0
  43. package/templates/.env.example +12 -2
  44. package/templates/CLAUDE.md +20 -20
  45. package/templates/README.md +19 -19
  46. package/templates/apps/api/.env.example +16 -5
  47. package/templates/apps/api/CLAUDE.md +32 -11
  48. package/templates/apps/api/src/__tests__/vercel-handler.test.ts +37 -0
  49. package/templates/apps/api/src/vercel-handler.ts +8 -1
  50. package/templates/apps/api/vercel.json +7 -1
  51. package/templates/apps/mcp/CLAUDE.md +18 -10
  52. package/templates/apps/web/.env.example +27 -0
  53. package/templates/apps/web/CLAUDE.md +56 -8
  54. package/templates/apps/web/__tests__/setup.ts +7 -0
  55. package/templates/apps/web/app/.well-known/mcp.json/route.ts +7 -7
  56. package/templates/apps/web/app/[locale]/(auth)/sign-in/__tests__/next-path.test.ts +24 -1
  57. package/templates/apps/web/app/[locale]/(auth)/sign-in/__tests__/page.test.tsx +51 -0
  58. package/templates/apps/web/app/[locale]/(auth)/sign-in/next-path.ts +13 -0
  59. package/templates/apps/web/app/[locale]/(auth)/sign-in/page.tsx +10 -4
  60. package/templates/apps/web/app/[locale]/(auth)/sign-up/__tests__/page.test.tsx +197 -4
  61. package/templates/apps/web/app/[locale]/(auth)/sign-up/page.tsx +23 -7
  62. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/[pieceId]/versions/[version]/file/route.ts +61 -2
  63. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/__tests__/file-route.test.ts +81 -0
  64. package/templates/apps/web/app/[locale]/(authed)/account/connect/__tests__/page.test.tsx +197 -0
  65. package/templates/apps/web/app/[locale]/(authed)/account/connect/page.tsx +152 -0
  66. package/templates/apps/web/app/[locale]/about/__tests__/about-routes.test.tsx +2 -2
  67. package/templates/apps/web/app/[locale]/careers/[slug]/[action]/page.tsx +76 -21
  68. package/templates/apps/web/app/[locale]/careers/[slug]/apply.actions.ts +46 -16
  69. package/templates/apps/web/app/[locale]/careers/[slug]/page.tsx +20 -3
  70. package/templates/apps/web/app/[locale]/careers/__tests__/apply.actions.test.ts +180 -2
  71. package/templates/apps/web/app/[locale]/careers/__tests__/careers-set.test.tsx +6 -2
  72. package/templates/apps/web/app/[locale]/careers/__tests__/job-apply-page.test.tsx +122 -0
  73. package/templates/apps/web/app/[locale]/careers/__tests__/page-renderer-migration.test.tsx +1 -1
  74. package/templates/apps/web/app/[locale]/case-studies/[slug]/page.tsx +20 -3
  75. package/templates/apps/web/app/[locale]/case-studies/__tests__/hub-children.test.tsx +65 -0
  76. package/templates/apps/web/app/[locale]/case-studies/__tests__/page-renderer-migration.test.tsx +6 -2
  77. package/templates/apps/web/app/[locale]/case-studies/page.tsx +23 -11
  78. package/templates/apps/web/app/[locale]/checkout/__tests__/thank-you-account.test.tsx +11 -1
  79. package/templates/apps/web/app/[locale]/clients/__tests__/page-renderer-migration.test.tsx +1 -1
  80. package/templates/apps/web/app/[locale]/consent.actions.ts +44 -6
  81. package/templates/apps/web/app/[locale]/contact/__tests__/ConversationalIntakeWrapper.test.tsx +4 -4
  82. package/templates/apps/web/app/[locale]/contact/__tests__/contact.actions.test.ts +269 -42
  83. package/templates/apps/web/app/[locale]/contact/contact.actions.ts +88 -30
  84. package/templates/apps/web/app/[locale]/courses/[slug]/page.tsx +20 -3
  85. package/templates/apps/web/app/[locale]/courses/__tests__/hub-children.test.tsx +32 -0
  86. package/templates/apps/web/app/[locale]/courses/__tests__/page-renderer-migration.test.tsx +6 -2
  87. package/templates/apps/web/app/[locale]/courses/page.tsx +22 -6
  88. package/templates/apps/web/app/[locale]/events/[slug]/page.tsx +15 -2
  89. package/templates/apps/web/app/[locale]/events/__tests__/hub-children.test.tsx +32 -0
  90. package/templates/apps/web/app/[locale]/events/__tests__/page-renderer-migration.test.tsx +8 -2
  91. package/templates/apps/web/app/[locale]/events/page.tsx +23 -7
  92. package/templates/apps/web/app/[locale]/features/__tests__/hub-children.test.tsx +32 -0
  93. package/templates/apps/web/app/[locale]/features/page.tsx +23 -25
  94. package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/flow.actions.test.ts +95 -0
  95. package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/save.actions.test.ts +193 -0
  96. package/templates/apps/web/app/[locale]/flows/[slug]/email-moment.ts +92 -0
  97. package/templates/apps/web/app/[locale]/flows/[slug]/flow.actions.ts +10 -0
  98. package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/ResultsClient.tsx +513 -0
  99. package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/ResultsEmailStep.tsx +122 -0
  100. package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/__tests__/ResultsClient.no-commerce.test.tsx +195 -0
  101. package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/__tests__/landing.test.tsx +293 -0
  102. package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/__tests__/page.test.tsx +397 -0
  103. package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/checkout-seam.ts +74 -0
  104. package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/page.tsx +264 -0
  105. package/templates/apps/web/app/[locale]/flows/[slug]/save.actions.ts +75 -0
  106. package/templates/apps/web/app/[locale]/for/__tests__/hub-children.test.tsx +32 -0
  107. package/templates/apps/web/app/[locale]/for/__tests__/page-renderer-migration.test.tsx +5 -1
  108. package/templates/apps/web/app/[locale]/for/page.tsx +23 -25
  109. package/templates/apps/web/app/[locale]/how-to/[slug]/page.tsx +20 -3
  110. package/templates/apps/web/app/[locale]/how-to/__tests__/page-renderer-migration.test.tsx +1 -1
  111. package/templates/apps/web/app/[locale]/integrations/[slug]/page.tsx +25 -4
  112. package/templates/apps/web/app/[locale]/integrations/__tests__/hub-children.test.tsx +38 -0
  113. package/templates/apps/web/app/[locale]/integrations/__tests__/page-renderer-migration.test.tsx +8 -2
  114. package/templates/apps/web/app/[locale]/integrations/page.tsx +23 -11
  115. package/templates/apps/web/app/[locale]/layout.tsx +161 -128
  116. package/templates/apps/web/app/[locale]/page.tsx +51 -3
  117. package/templates/apps/web/app/[locale]/partners/[slug]/page.tsx +16 -3
  118. package/templates/apps/web/app/[locale]/partners/__tests__/hub-children.test.tsx +32 -0
  119. package/templates/apps/web/app/[locale]/partners/__tests__/page-renderer-migration.test.tsx +6 -2
  120. package/templates/apps/web/app/[locale]/partners/page.tsx +22 -6
  121. package/templates/apps/web/app/[locale]/portfolio/__tests__/hub-children.test.tsx +38 -0
  122. package/templates/apps/web/app/[locale]/portfolio/page.tsx +25 -24
  123. package/templates/apps/web/app/[locale]/pricing/[slug]/__tests__/page.test.tsx +321 -0
  124. package/templates/apps/web/app/[locale]/pricing/[slug]/page.tsx +87 -0
  125. package/templates/apps/web/app/[locale]/pricing/__tests__/offerings.test.tsx +3 -0
  126. package/templates/apps/web/app/[locale]/pricing/__tests__/pricing-pages.test.tsx +215 -0
  127. package/templates/apps/web/app/[locale]/pricing/page.tsx +56 -341
  128. package/templates/apps/web/app/[locale]/products/[slug]/page.tsx +21 -4
  129. package/templates/apps/web/app/[locale]/products/__tests__/hub-children.test.tsx +32 -0
  130. package/templates/apps/web/app/[locale]/products/__tests__/page-renderer-migration.test.tsx +8 -2
  131. package/templates/apps/web/app/[locale]/products/page.tsx +22 -6
  132. package/templates/apps/web/app/[locale]/projects/__tests__/hub-children.test.tsx +32 -0
  133. package/templates/apps/web/app/[locale]/projects/__tests__/page-renderer-migration.test.tsx +4 -0
  134. package/templates/apps/web/app/[locale]/projects/page.tsx +18 -11
  135. package/templates/apps/web/app/[locale]/resources/[slug]/page.tsx +25 -4
  136. package/templates/apps/web/app/[locale]/resources/__tests__/hub-children.test.tsx +38 -0
  137. package/templates/apps/web/app/[locale]/resources/__tests__/page-renderer-migration.test.tsx +6 -2
  138. package/templates/apps/web/app/[locale]/resources/page.tsx +20 -10
  139. package/templates/apps/web/app/[locale]/services/[slug]/[child]/page.tsx +68 -0
  140. package/templates/apps/web/app/[locale]/services/[slug]/page.tsx +64 -9
  141. package/templates/apps/web/app/[locale]/services/__tests__/hub-children.test.tsx +158 -0
  142. package/templates/apps/web/app/[locale]/services/__tests__/page-renderer-migration.test.tsx +12 -2
  143. package/templates/apps/web/app/[locale]/services/__tests__/service-detail-sections.test.tsx +223 -0
  144. package/templates/apps/web/app/[locale]/services/__tests__/service-families.test.tsx +368 -0
  145. package/templates/apps/web/app/[locale]/services/page.tsx +45 -19
  146. package/templates/apps/web/app/[locale]/software/__tests__/page-renderer-migration.test.tsx +1 -1
  147. package/templates/apps/web/app/[locale]/team/__tests__/hub-children.test.tsx +32 -0
  148. package/templates/apps/web/app/[locale]/team/page.tsx +25 -24
  149. package/templates/apps/web/app/[locale]/tech-articles/[slug]/page.tsx +24 -3
  150. package/templates/apps/web/app/[locale]/tech-articles/__tests__/page-renderer-migration.test.tsx +1 -1
  151. package/templates/apps/web/app/[locale]/templates/[slug]/__tests__/page.test.tsx +203 -0
  152. package/templates/apps/web/app/[locale]/templates/[slug]/page.tsx +81 -0
  153. package/templates/apps/web/app/[locale]/templates/__tests__/page.test.tsx +236 -0
  154. package/templates/apps/web/app/[locale]/templates/page.tsx +108 -0
  155. package/templates/apps/web/app/[locale]/testimonials/__tests__/page-renderer-migration.test.tsx +1 -1
  156. package/templates/apps/web/app/[locale]/whitepapers/[slug]/page.tsx +24 -3
  157. package/templates/apps/web/app/[locale]/whitepapers/__tests__/page-renderer-migration.test.tsx +1 -1
  158. package/templates/apps/web/app/api/auth/hub/authorize/__tests__/route.test.ts +322 -2
  159. package/templates/apps/web/app/api/auth/hub/authorize/route.ts +161 -29
  160. package/templates/apps/web/app/api/hub-session/__tests__/routes.test.ts +51 -0
  161. package/templates/apps/web/app/api/hub-session/sign-in/route.ts +13 -0
  162. package/templates/apps/web/app/api/offerings/checkout/[lookupKey]/__tests__/route.test.ts +233 -7
  163. package/templates/apps/web/app/api/offerings/checkout/[lookupKey]/route.ts +92 -41
  164. package/templates/apps/web/app/api/offerings/checkout/__tests__/route.test.ts +77 -39
  165. package/templates/apps/web/app/llms.txt/route.ts +41 -3
  166. package/templates/apps/web/app/robots.txt/route.ts +5 -1
  167. package/templates/apps/web/app/sitemap.xml/route.ts +45 -3
  168. package/templates/apps/web/auth.ts +102 -50
  169. package/templates/apps/web/components/signed-in-nav.tsx +8 -1
  170. package/templates/apps/web/lib/__tests__/bot-check.test.ts +254 -0
  171. package/templates/apps/web/lib/__tests__/consent-visitor.test.ts +212 -13
  172. package/templates/apps/web/lib/__tests__/deck-page.test.ts +13 -1
  173. package/templates/apps/web/lib/__tests__/docs-index.test.ts +93 -0
  174. package/templates/apps/web/lib/__tests__/docs-path.test.ts +168 -0
  175. package/templates/apps/web/lib/__tests__/hub-children.fixtures.ts +278 -0
  176. package/templates/apps/web/lib/__tests__/nav-label-keys.test.ts +138 -0
  177. package/templates/apps/web/lib/__tests__/newsletter.actions.test.ts +178 -2
  178. package/templates/apps/web/lib/__tests__/offerings-pricing.test.ts +183 -0
  179. package/templates/apps/web/lib/__tests__/page-chrome.test.ts +412 -0
  180. package/templates/apps/web/lib/__tests__/pricing-page.test.ts +142 -0
  181. package/templates/apps/web/lib/__tests__/relay.test.ts +484 -4
  182. package/templates/apps/web/lib/__tests__/route-family.test.ts +57 -0
  183. package/templates/apps/web/lib/__tests__/service-listing.test.ts +111 -0
  184. package/templates/apps/web/lib/__tests__/services-path.test.ts +96 -0
  185. package/templates/apps/web/lib/__tests__/site-header.test.ts +151 -0
  186. package/templates/apps/web/lib/__tests__/structured-data.test.tsx +148 -6
  187. package/templates/apps/web/lib/__tests__/telemetry-scrub.test.ts +84 -0
  188. package/templates/apps/web/lib/__tests__/template-listing.test.ts +158 -0
  189. package/templates/apps/web/lib/auth-secret.ts +57 -0
  190. package/templates/apps/web/lib/bot-check.ts +138 -0
  191. package/templates/apps/web/lib/consent-visitor.ts +81 -7
  192. package/templates/apps/web/lib/deck-page.ts +6 -3
  193. package/templates/apps/web/lib/docs-index.ts +100 -0
  194. package/templates/apps/web/lib/docs-path.ts +94 -0
  195. package/templates/apps/web/lib/hub-children.tsx +318 -0
  196. package/templates/apps/web/lib/hub-session/__tests__/sign-in-gate.test.tsx +22 -2
  197. package/templates/apps/web/lib/hub-session/sign-in-gate.tsx +11 -8
  198. package/templates/apps/web/lib/modules.generated.ts +9 -0
  199. package/templates/apps/web/lib/nav-label-check.ts +61 -0
  200. package/templates/apps/web/lib/nav-label-keys.ts +78 -0
  201. package/templates/apps/web/lib/newsletter.actions.ts +31 -6
  202. package/templates/apps/web/lib/offerings-pricing.ts +135 -6
  203. package/templates/apps/web/lib/page-chrome.ts +367 -0
  204. package/templates/apps/web/lib/posting-title.ts +26 -4
  205. package/templates/apps/web/lib/pricing-page.ts +430 -0
  206. package/templates/apps/web/lib/relay.ts +44 -4
  207. package/templates/apps/web/lib/route-family.ts +69 -0
  208. package/templates/apps/web/lib/service-listing.ts +89 -0
  209. package/templates/apps/web/lib/services-path.ts +97 -0
  210. package/templates/apps/web/lib/sign-up-agreements.ts +17 -6
  211. package/templates/apps/web/lib/site-header.ts +94 -0
  212. package/templates/apps/web/lib/telemetry-scrub.ts +68 -3
  213. package/templates/apps/web/lib/template-listing.ts +169 -0
  214. package/templates/apps/web/messages/en.json +144 -3
  215. package/templates/apps/web/middleware.ts +179 -12
  216. package/templates/apps/web/next.config.mjs +220 -3
  217. package/templates/apps/web/package.json +3 -2
  218. package/templates/apps/web/scripts/assert-content-bundled.ts +28 -6
  219. package/templates/apps/web/scripts/check-nav-labels.ts +39 -0
  220. package/templates/apps/web/sentry.server.config.ts +30 -2
  221. package/templates/apps/web/src/lib/__tests__/rate-limit.test.ts +67 -9
  222. package/templates/apps/web/src/lib/rate-limit.ts +68 -27
  223. package/templates/apps/web/src/lib/routes.ts +15 -1
  224. package/versions.json +48 -48
@@ -633,4 +633,41 @@ describe('vercel-handler — the request log carries no query string (#5970)', (
633
633
  });
634
634
  expect(JSON.stringify(logger.info.mock.calls)).not.toContain('SUPERSECRETTOKEN');
635
635
  });
636
+
637
+ it.each([
638
+ '/v1/webhooks/stripe/whk_SECRETTOKEN123?x=1',
639
+ '/x/../v1/webhooks/stripe/whk_SECRETTOKEN123?x=1',
640
+ '/v1/./webhooks/stripe/whk_SECRETTOKEN123?x=1',
641
+ ])('logs a secret-bearing path segment redacted, and routes on the raw path: %s (#9969 D6)', async (target) => {
642
+ const inject = jest.fn().mockResolvedValue({
643
+ statusCode: 200,
644
+ headers: {},
645
+ body: 'ok',
646
+ rawPayload: Buffer.from('ok'),
647
+ });
648
+ jest.doMock('../server', () => ({ build: jest.fn().mockResolvedValue({ inject }) }));
649
+ const handler = loadHandler();
650
+
651
+ const req: MockVercelRequest = {
652
+ method: 'POST',
653
+ url: target,
654
+ headers: {},
655
+ body: undefined,
656
+ query: { x: '1' },
657
+ };
658
+ await handler(asRequest(req), asResponse(makeRes()));
659
+
660
+ const { logger } = require('@working-theory/observability') as {
661
+ logger: { info: jest.Mock };
662
+ };
663
+ const startCall = logger.info.mock.calls.find(
664
+ ([, msg]) => msg === '[Vercel Handler] Starting request',
665
+ );
666
+ expect(startCall?.[0]).toEqual({
667
+ method: 'POST',
668
+ pathname: '/v1/webhooks/stripe/:connectionToken',
669
+ });
670
+ expect(JSON.stringify(logger.info.mock.calls)).not.toContain('whk_SECRETTOKEN123');
671
+ expect(inject.mock.calls[0][0]).toMatchObject({ url: '/v1/webhooks/stripe/whk_SECRETTOKEN123' });
672
+ });
636
673
  });
@@ -4,6 +4,7 @@
4
4
  */
5
5
 
6
6
  import type { VercelRequest, VercelResponse } from "@vercel/node";
7
+ import { redactLogPath } from "@working-theory/api";
7
8
  import { logger } from "@working-theory/observability";
8
9
  import type { InjectOptions } from "fastify";
9
10
 
@@ -169,11 +170,17 @@ export default async function handler(
169
170
  //
170
171
  // Derived once, above the first log line, and used by both — the pathname
171
172
  // was already computed below and logged safely there, three lines too late.
173
+ //
174
+ // The query strip is #5970's control; a credential in a path SEGMENT — a
175
+ // webhook connection token, a `cs_…` session id — is `redactLogPath`'s,
176
+ // the same function Fastify's request serializer calls (#9969 D6, #9262).
177
+ // It reads the RESOLVED `pathname`, the one the request is routed on, so a
178
+ // dot-segment target (`/x/../v1/webhooks/…`) cannot put a token past it.
172
179
  const url = new URL(req.url || "/", "http://localhost");
173
180
  const pathname = url.pathname;
174
181
 
175
182
  logger.info(
176
- { method: req.method, pathname },
183
+ { method: req.method, pathname: redactLogPath(pathname) },
177
184
  "[Vercel Handler] Starting request",
178
185
  );
179
186
 
@@ -13,5 +13,11 @@
13
13
  ],
14
14
  "env": {
15
15
  "NODE_ENV": "production"
16
- }
16
+ },
17
+ "crons": [
18
+ {
19
+ "path": "/v1/cron/prospect-channel-archive",
20
+ "schedule": "0 7 * * *"
21
+ }
22
+ ]
17
23
  }
@@ -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,6 +47,28 @@ 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 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=
70
+ # AILK_BOOKING_BOT_CHECK=
71
+ #
50
72
  # EDGE PRECONDITION: the relay forwards x-forwarded-for exactly as this site
51
73
  # received it, so run the site behind an edge that OVERWRITES the client-address
52
74
  # headers (as Vercel does), not one that appends to them. Otherwise a caller
@@ -155,6 +177,11 @@ CONTENT_ADAPTER=mdx
155
177
  # passes its own origin check and the emailed reset link is built on the
156
178
  # attacker's host. It is a conjunct of resetSendConfigured(), so a deployment
157
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).
158
185
  # BETTER_AUTH_URL=http://localhost:3000
159
186
  #
160
187
  # Secret used to sign session cookies AND the email-verification tokens.
@@ -14,7 +14,7 @@ Inherits root `CLAUDE.md`. This file adds web-specific rules.
14
14
 
15
15
  ## Always do first
16
16
 
17
- - For any UI work: read § Theme & styling and § Iterate visually below before writing code. To add a whole page, run the `/ailk:add-page` skill.
17
+ - For any UI work: read § Theme & styling and § Iterate visually below before writing code. To add a whole page, run `/ailk:growth-ops page add`.
18
18
  - For any page-rendering or schema-emission work: read `packages/schema/README.md` (catalog) and `packages/schema/CLAUDE.md` (operational layer + component map) if you haven't this session.
19
19
  - For content authoring questions: read `content/CLAUDE.md` first.
20
20
  - For a full page build, follow the per-page loop (Content intent → Layout → Author content → Visual iterate). The visual-iterate step uses `screenshots/` as scratch (see § Iterate visually).
@@ -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
 
@@ -489,10 +506,12 @@ one `[offerings-checkout] refused reason=<reason>` warning; no response or
489
506
  log line carries the key. It sends `OSS_API_INTERNAL_SECRET` as
490
507
  `x-internal-auth` only from a site that is not a spoke and only to an
491
508
  `https:` or loopback API, so the hub's own `apps/web` must hold the API's
492
- value when the API sets it. A spoke never holds that secret: it sells only
493
- when its hub lists the spoke's origin in `AILK_TRUSTED_ORIGINS` and does not
494
- set `OSS_API_INTERNAL_SECRET`, and otherwise every spoke buy returns to
495
- `/pricing`.
509
+ value when the API sets it. A spoke never holds that secret: on a hub that
510
+ sets `OSS_API_INTERNAL_SECRET` its spoke key authenticates the buy, which
511
+ sells when the spoke's hub sign-in client is enabled with its callback on the
512
+ spoke's own origin (#9910); on a hub that does not set it, the buy sells when
513
+ the hub lists the spoke's origin in `AILK_TRUSTED_ORIGINS`. Otherwise every
514
+ spoke buy returns to `/pricing`.
496
515
 
497
516
  An existing scaffold adopts the new page by bringing
498
517
  `lib/offerings-pricing.ts`, `app/api/offerings/checkout/[lookupKey]/route.ts`
@@ -552,8 +571,29 @@ A visitor's browser never calls the hub. Lead capture, newsletter signup, callba
552
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.
553
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.
554
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.
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.
555
575
  - `/v1` is exempt from next-intl's locale rewrite in `middleware.ts`, as `/api` is.
556
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
+
557
597
  ## AEO & performance
558
598
 
559
599
  - Static rendering is the default. Don't introduce client rendering without a reason.
@@ -570,7 +610,15 @@ A visitor's browser never calls the hub. Lead capture, newsletter signup, callba
570
610
  - Don't reach into `apps/api` or `apps/mcp` from `apps/web`. They're peer apps — talk via HTTP/MCP.
571
611
  - Don't commit images to `screenshots/` — only the `.gitkeep` is tracked.
572
612
 
573
- Last updated: 2026-10-24 — § The `(auth)` route group: a magic link can create the account while sign-up is open, carrying the ticked agreements bound to its token (#9667) — verified against `lib/auth-email.ts`, `lib/sign-up-agreements.ts` and `app/[locale]/(auth)/sign-up/{page,sign-up-card}.tsx`.
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`.
618
+
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`.
620
+
621
+ Prior: 2026-10-24 — § The `(auth)` route group: a magic link can create the account while sign-up is open, carrying the ticked agreements bound to its token (#9667) — verified against `lib/auth-email.ts`, `lib/sign-up-agreements.ts` and `app/[locale]/(auth)/sign-up/{page,sign-up-card}.tsx`.
574
622
 
575
623
  Prior: 2026-10-21 — § Pricing page and § Magnet pages: `/pricing`, the magnet pages and the results permalink read the browser-facing API origin through `browserApiOrigin()` (#7874 D12), so a spoke that enables them passes its own `env:check` (#9480) — verified against `app/[locale]/pricing/page.tsx`, `app/[locale]/flows/[slug]/{magnet-page.tsx,results/[payload]/page.tsx}` and `lib/api-origin.generated.ts`.
576
624
 
@@ -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 } : {}),
@@ -2,8 +2,11 @@
2
2
  * Tests for `resolveNextPath` — the /sign-in `?next=` sanitizer (#4984).
3
3
  * Foreign, protocol-relative, backslash, control-char, and non-string inputs
4
4
  * all fall back; plain in-app paths pass through unchanged.
5
+ *
6
+ * `hrefWithNext` (#9912 D4) — a toggle carries `?next=` only when the page's
7
+ * resolved `next` is not its fallback.
5
8
  */
6
- import { resolveNextPath } from "../next-path";
9
+ import { hrefWithNext, resolveNextPath } from "../next-path";
7
10
 
8
11
  const FALLBACK = "/account";
9
12
 
@@ -29,3 +32,23 @@ describe("resolveNextPath()", () => {
29
32
  expect(resolveNextPath(raw, FALLBACK)).toBe(FALLBACK);
30
33
  });
31
34
  });
35
+
36
+ describe("hrefWithNext()", () => {
37
+ it("the fallback gives the bare href", () => {
38
+ expect(hrefWithNext("/sign-up", "/account", "/account")).toBe("/sign-up");
39
+ });
40
+
41
+ it("any other next rides as ?next=, URL-encoded", () => {
42
+ expect(
43
+ hrefWithNext("/sign-up", "/api/auth/hub/continue/abc_-1", "/account"),
44
+ ).toBe("/sign-up?next=%2Fapi%2Fauth%2Fhub%2Fcontinue%2Fabc_-1");
45
+ expect(
46
+ hrefWithNext("/fr/sign-in", "/fr/account?tab=x", "/fr/account"),
47
+ ).toBe("/fr/sign-in?next=%2Ffr%2Faccount%3Ftab%3Dx");
48
+ });
49
+
50
+ it("a refused next resolves to the fallback, so its toggle is bare", () => {
51
+ const next = resolveNextPath("//evil.example", FALLBACK);
52
+ expect(hrefWithNext("/sign-up", next, FALLBACK)).toBe("/sign-up");
53
+ });
54
+ });
@@ -28,6 +28,9 @@
28
28
  * the deployment's own data — a crafted query parameter cannot change
29
29
  * the name — the "Don't have an account? Sign up" line linking
30
30
  * /sign-up, and the Terms + Privacy acknowledgement.
31
+ * (k) the Sign up toggle (#9912 D4): it carries the validated `?next=` to
32
+ * /sign-up; a `next` resolveNextPath refuses, or none, gives the bare
33
+ * /sign-up.
31
34
  *
32
35
  * `next-intl/server` is mocked here against the real en.json, with `{name}`
33
36
  * placeholders filled from the values passed, so `signIn` copy is asserted
@@ -524,3 +527,51 @@ describe("/sign-in page — the one card (#9662)", () => {
524
527
  expect(within(footnote).getAllByRole("link")).toHaveLength(2);
525
528
  });
526
529
  });
530
+
531
+ describe("/sign-in page — the Sign up toggle keeps the next (#9912 D4)", () => {
532
+ beforeEach(() => {
533
+ mockClientInfo.mockReset().mockResolvedValue({ ok: false, status: 404 });
534
+ });
535
+
536
+ it("(k) a continuation next rides the toggle to /sign-up, URL-encoded", async () => {
537
+ const ref = mintContinuation("client_id=spoke_1", TEST_SECRET);
538
+ const next = `/api/auth/hub/continue/${ref}`;
539
+ await renderPage(next);
540
+ expect(screen.getByTestId("auth-card-prospect-link")).toHaveAttribute(
541
+ "href",
542
+ `/sign-up?next=${encodeURIComponent(next)}`,
543
+ );
544
+ });
545
+
546
+ it("(k) an in-app next rides the toggle too", async () => {
547
+ await renderPage("/account/billing");
548
+ expect(screen.getByTestId("auth-card-prospect-link")).toHaveAttribute(
549
+ "href",
550
+ "/sign-up?next=%2Faccount%2Fbilling",
551
+ );
552
+ });
553
+
554
+ it.each([
555
+ ["a protocol-relative next", "//evil.example"],
556
+ ["an absolute next", "https://evil.example/x"],
557
+ ["a backslash next", "/\\evil.example"],
558
+ ["a control character", "/x\r\ny"],
559
+ ])(
560
+ "(k) %s fails resolveNextPath, so the toggle is the bare /sign-up",
561
+ async (_label, next) => {
562
+ await renderPage(next);
563
+ expect(screen.getByTestId("auth-card-prospect-link")).toHaveAttribute(
564
+ "href",
565
+ "/sign-up",
566
+ );
567
+ },
568
+ );
569
+
570
+ it("(k) no next: the bare /sign-up", async () => {
571
+ await renderPage();
572
+ expect(screen.getByTestId("auth-card-prospect-link")).toHaveAttribute(
573
+ "href",
574
+ "/sign-up",
575
+ );
576
+ });
577
+ });
@@ -3,5 +3,18 @@
3
3
  * `lib/next-path.ts` (#7894 D7) so the hub-session callback, which a spoke
4
4
  * keeps when it drops this route group, shares it. Re-exported here so this
5
5
  * group's importers are unchanged.
6
+ *
7
+ * `hrefWithNext` (#9912 D4) builds the /sign-in ↔ /sign-up toggle: it carries
8
+ * the page's validated `next` only when that is not the page's fallback, so a
9
+ * refused or absent `next` gives today's bare link.
6
10
  */
7
11
  export { resolveNextPath } from "../../../../lib/next-path";
12
+
13
+ /** `href` with `?next=<next>` when `next` is not the page's fallback, else `href`. */
14
+ export function hrefWithNext(
15
+ href: string,
16
+ next: string,
17
+ fallback: string,
18
+ ): string {
19
+ return next === fallback ? href : `${href}?next=${encodeURIComponent(next)}`;
20
+ }
@@ -24,7 +24,9 @@
24
24
  * The one card (#9662): "Sign in to <site>" (the name is the deployment's,
25
25
  * read in `resolveSignInCardText`, never the request's), the "Don't have an
26
26
  * account? Sign up" line to `/sign-up`, and the Terms + Privacy
27
- * acknowledgement.
27
+ * acknowledgement. The Sign up line carries the validated `next` (#9912 D4),
28
+ * so a buyer who switches to sign-up keeps the destination; a refused or
29
+ * absent `next` gives the bare `/sign-up`.
28
30
  *
29
31
  * Signed-in visitors are not redirected here — the Nav's session-aware action
30
32
  * cluster (Account / Sign out) is the affordance for them.
@@ -48,7 +50,7 @@ import {
48
50
  } from "../../../api/auth/hub/continuation";
49
51
  import { legalFootnote } from "../legal-footnote";
50
52
 
51
- import { resolveNextPath } from "./next-path";
53
+ import { hrefWithNext, resolveNextPath } from "./next-path";
52
54
  import { SignInCard, type SignInProvider } from "./sign-in-card";
53
55
  import { resolveSignInCardText, type SignInCardText } from "./sign-in-text";
54
56
 
@@ -147,7 +149,8 @@ export default async function SignInPage({
147
149
  );
148
150
  const method = active.includes("email") ? "password" : "sso";
149
151
  const emailLink = active.includes("magic-link");
150
- const next = resolveNextPath(rawNext, localeHref(locale, "/account"));
152
+ const fallback = localeHref(locale, "/account");
153
+ const next = resolveNextPath(rawNext, fallback);
151
154
  // A failed link returns here, keeping the visitor's destination.
152
155
  const signInHref = `${localeHref(locale, "/sign-in")}?next=${encodeURIComponent(next)}`;
153
156
  // Only the email link produces `?error=` here; with it off, today's render.
@@ -167,7 +170,10 @@ export default async function SignInPage({
167
170
  },
168
171
  }
169
172
  : {})}
170
- prospect={{ ...text.prospect, href: localeHref(locale, "/sign-up") }}
173
+ prospect={{
174
+ ...text.prospect,
175
+ href: hrefWithNext(localeHref(locale, "/sign-up"), next, fallback),
176
+ }}
171
177
  footnote={legalFootnote(locale, text.acknowledgement)}
172
178
  providers={providers}
173
179
  method={method}