create-ailk 0.29.1 → 0.31.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 (101) hide show
  1. package/README.md +71 -3
  2. package/component-catalog.json +524 -8
  3. package/component-catalog.md +8 -5
  4. package/dist/add-route.js +1 -0
  5. package/dist/cli.js +114 -1
  6. package/dist/cutover.d.ts +109 -0
  7. package/dist/cutover.js +494 -0
  8. package/dist/design-presets.js +20 -4
  9. package/dist/export-content.d.ts +55 -0
  10. package/dist/export-content.js +303 -0
  11. package/dist/inventory.d.ts +216 -0
  12. package/dist/inventory.js +833 -0
  13. package/dist/language-import.d.ts +114 -0
  14. package/dist/language-import.js +435 -0
  15. package/dist/lib/env-catalog.js +2 -2
  16. package/dist/lib/import-mdx.d.ts +38 -0
  17. package/dist/lib/import-mdx.js +411 -64
  18. package/dist/lib/render-comparison.d.ts +200 -0
  19. package/dist/lib/render-comparison.js +374 -0
  20. package/dist/lib/scaffold-render-sweep.d.ts +1 -1
  21. package/dist/lib/scaffold-render-sweep.js +1 -1
  22. package/dist/migrate-content.d.ts +6 -2
  23. package/dist/migrate-content.js +60 -14
  24. package/dist/module-architecture.js +6 -1
  25. package/dist/provision-env.d.ts +28 -0
  26. package/dist/provision-env.js +210 -0
  27. package/dist/seed-workspace.d.ts +3 -1
  28. package/dist/seed-workspace.js +45 -16
  29. package/dist/surfaces.js +3 -0
  30. package/dist/topology.d.ts +12 -0
  31. package/dist/topology.js +28 -10
  32. package/package.json +3 -3
  33. package/templates/.claude/cloudparity-repo-hooks.json +5 -0
  34. package/templates/.claude/hooks/content-source-guard.sh +75 -0
  35. package/templates/.claude/settings.json +9 -0
  36. package/templates/.env.example +7 -0
  37. package/templates/.github/workflows/ci.yml +2 -0
  38. package/templates/CLAUDE.md +5 -0
  39. package/templates/apps/api/CLAUDE.md +6 -2
  40. package/templates/apps/mcp/.env.example +20 -0
  41. package/templates/apps/mcp/CLAUDE.md +46 -8
  42. package/templates/apps/web/.env.example +4 -2
  43. package/templates/apps/web/CLAUDE.md +39 -5
  44. package/templates/apps/web/app/.well-known/ai-plugin.json/route.ts +9 -1
  45. package/templates/apps/web/app/.well-known/mcp.json/build-manifest.ts +58 -0
  46. package/templates/apps/web/app/.well-known/mcp.json/route.ts +16 -0
  47. package/templates/apps/web/app/.well-known/oauth-authorization-server/route.ts +53 -0
  48. package/templates/apps/web/app/[locale]/(auth)/__tests__/password-reset.test.tsx +8 -1
  49. package/templates/apps/web/app/[locale]/(auth)/consent/__tests__/consent-actions.test.ts +56 -0
  50. package/templates/apps/web/app/[locale]/(auth)/consent/__tests__/page.test.tsx +164 -0
  51. package/templates/apps/web/app/[locale]/(auth)/consent/consent-form.tsx +132 -0
  52. package/templates/apps/web/app/[locale]/(auth)/consent/consent.actions.ts +51 -0
  53. package/templates/apps/web/app/[locale]/(auth)/consent/page.tsx +144 -0
  54. package/templates/apps/web/app/[locale]/[slug]/[detail]/__tests__/page.test.tsx +153 -4
  55. package/templates/apps/web/app/[locale]/[slug]/[detail]/page.tsx +45 -2
  56. package/templates/apps/web/app/[locale]/about/__tests__/about-routes.test.tsx +2 -2
  57. package/templates/apps/web/app/[locale]/contact/__tests__/site-contact-lines.test.ts +49 -0
  58. package/templates/apps/web/app/[locale]/contact/site-contact-lines.ts +6 -4
  59. package/templates/apps/web/app/[locale]/events/[slug]/page.tsx +13 -1
  60. package/templates/apps/web/app/[locale]/events/__tests__/program-event.test.tsx +120 -0
  61. package/templates/apps/web/app/[locale]/events/page.tsx +11 -0
  62. package/templates/apps/web/app/[locale]/flows/[slug]/FlowStepperClient.tsx +44 -5
  63. package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/FlowStepperClient.thanks-by-tier.test.tsx +306 -0
  64. package/templates/apps/web/app/[locale]/layout.tsx +122 -27
  65. package/templates/apps/web/app/[locale]/thanks/[slug]/__tests__/page.test.tsx +21 -2
  66. package/templates/apps/web/app/[locale]/thanks/[slug]/page.tsx +1 -0
  67. package/templates/apps/web/app/api/auth/hub/continuation.ts +8 -3
  68. package/templates/apps/web/app/api/auth/mcp/__tests__/continuation.test.ts +92 -0
  69. package/templates/apps/web/app/api/auth/mcp/continuation.ts +89 -0
  70. package/templates/apps/web/app/api/auth/mcp/continue/[ref]/__tests__/route.test.ts +59 -0
  71. package/templates/apps/web/app/api/auth/mcp/continue/[ref]/route.ts +41 -0
  72. package/templates/apps/web/app/llms.txt/route.ts +26 -9
  73. package/templates/apps/web/app/openapi.json/__tests__/route.test.ts +40 -0
  74. package/templates/apps/web/app/openapi.json/route.ts +4 -0
  75. package/templates/apps/web/app/robots.txt/route.ts +18 -1
  76. package/templates/apps/web/app/sitemap.xml/route.ts +13 -0
  77. package/templates/apps/web/auth.ts +184 -7
  78. package/templates/apps/web/jest.config.cjs +46 -1
  79. package/templates/apps/web/lib/__tests__/auth-routes.test.ts +2 -1
  80. package/templates/apps/web/lib/__tests__/bot-check.test.ts +247 -4
  81. package/templates/apps/web/lib/__tests__/in-page-magnet-self-request.test.ts +92 -0
  82. package/templates/apps/web/lib/__tests__/page-chrome.test.ts +90 -0
  83. package/templates/apps/web/lib/__tests__/relay.test.ts +33 -8
  84. package/templates/apps/web/lib/__tests__/site-identity-feature-list.test.ts +196 -0
  85. package/templates/apps/web/lib/__tests__/structured-data.test.tsx +11 -1
  86. package/templates/apps/web/lib/auth-routes.ts +5 -3
  87. package/templates/apps/web/lib/bot-check.ts +113 -13
  88. package/templates/apps/web/lib/in-page-magnet.ts +4 -1
  89. package/templates/apps/web/lib/mcp-oauth.ts +702 -0
  90. package/templates/apps/web/lib/page-chrome.ts +65 -5
  91. package/templates/apps/web/lib/relay.ts +22 -3
  92. package/templates/apps/web/lib/site-identity.ts +75 -24
  93. package/templates/apps/web/messages/en.json +47 -14
  94. package/templates/apps/web/next.config.mjs +15 -1
  95. package/templates/apps/web/package.json +1 -1
  96. package/templates/apps/web/scripts/assert-content-bundled.ts +36 -8
  97. package/templates/apps/web/scripts/content-snapshot.ts +33 -0
  98. package/templates/database/seed.ts +6 -0
  99. package/templates/package.json +2 -1
  100. package/templates/tools/check-content-source.ts +108 -0
  101. package/versions.json +48 -48
@@ -43,7 +43,7 @@ Raw HTML is allowed only when no `@working-theory/ui` component fits. Flag it in
43
43
  - Animations: only `transform` and `opacity`. Never `transition-all`.
44
44
  - Every interactive element needs hover, focus-visible, and active states.
45
45
 
46
- The four widths above are the responsive contract. Brand reference files (logos, photos, colour and type notes) live in `brand_assets/` at the repo root; the site's own brand colours and fonts are set in `content/_site.mdx` under `brand.colors` and `brand.fonts`. The signed-out header button is set there too, as `header.cta` (`label`, `href`), with `header.signIn: link | hidden` choosing whether Sign in stays beside it as a text link — never by editing `app/[locale]/layout.tsx`. Each page's chrome is set there too (#10049, resolved by `lib/page-chrome.ts`): `header.style` (`full | bare | none`), `footer.style` (`columns | centered | closing | none`), `layout` (`deck | default`, the home's) and one `{ header, footer }` key per route family (`blog`, `docs`, `services`, …), with a page's own `header:` / `footer:` / `layout:` frontmatter winning over all of them.
46
+ The four widths above are the responsive contract. Brand reference files (logos, photos, colour and type notes) live in `brand_assets/` at the repo root; the site's own brand colours and fonts are set in `content/_site.mdx` under `brand.colors` and `brand.fonts`. The signed-out header button is set there too, as `header.cta` (`label`, `href`), with `header.signIn: link | hidden` choosing whether Sign in stays beside it as a text link — never by editing `app/[locale]/layout.tsx`. Each page's chrome is set there too (#10049, resolved by `lib/page-chrome.ts`): `header.style` (`full | bare | none`), `footer.style` (`columns | centered | closing | none`), `layout` (`deck | default`, the home's) and one `{ header, footer }` key per route family (`blog`, `docs`, `services`, …), with a page's own `header:` / `footer:` / `layout:` frontmatter winning over all of them. The closing footer alone also reads three site-level keys (#10550): `footer.locality` (the line above the email, winning over the identity's address), `footer.actions` (one or two `{ label, href }` site paths — the primary, then the secondary — in place of the auth links) and `footer.legalBar` (off by default: `true` moves the legal line into a full-width bottom bar, on the deck's closing slide its bottom edge). The cookie notice's look is set there too (#10555, #10721): `consent.appearance` is `compact` (the floating card, bottom-left) or `hairline` (the full-width band on the page ground); absent, it follows the design's `chromeStyle` — the band under `minimal`, else the card. Only the band waits for the minimal deck home's first slide to leave view (#7782); the card shows on load on every page.
47
47
 
48
48
  ## Local dev
49
49
 
@@ -164,6 +164,16 @@ route's rule, since the API honours consent only with it where
164
164
  `OSS_API_INTERNAL_SECRET` is set. A password reset or a sign-out revokes the
165
165
  grant, so the next sign-in asks again. `ref` is in both telemetry scrub lists.
166
166
 
167
+ ## The MCP authorization server
168
+
169
+ **The site is the OAuth 2.1 authorization server an MCP client signs in through (#10357).** `auth.ts`'s block between `// mcp-oauth:begin` and `// mcp-oauth:end` installs better-auth's `mcp` plugin (dynamic client registration, S256 PKCE, one-hour access tokens, seven-day refresh tokens) and AILK's `lib/mcp-oauth.ts` beside it, only when `BETTER_AUTH_URL` pins the issuer. The plugin is configured, never patched: every AILK behaviour is a hook or an endpoint in that file.
170
+
171
+ - **Tokens are opaque, validated by lookup** (decision (a)): no JWKS and no `jwt` plugin. `GET /api/auth/mcp/token-info` is the self-check: the bound workspace, the member-clamped `actingRole` and the grants the person's live role still holds — never a token.
172
+ - **Every authorization asks** (decision (b)): a before-hook forces `prompt=consent`, so the plugin always redirects to `/consent` (an `(auth)` page, `AUTH_SEGMENTS`). The page reads only `consent_code` and asks `GET /mcp/consent-details`; Allow posts `POST /mcp/consent`, which binds one `ailk:workspace:<id>` and only role-held `ailk:*` scopes. `/mcp/token` refuses any code that did not pass it or whose approving session has ended, any `code` not shaped like one (so no AILK verification row can be named and consumed), and any non-string body field. The plugin's own `/oauth2/consent`, `/mcp/get-session` and discovery copy are `disabledPaths`; the site serves `/.well-known/oauth-authorization-server` at its root.
173
+ - **Rotation and revocation**: a refresh deletes the used pair and leaves a tombstone, so a replayed refresh token ends the person's line for that client. `POST /mcp/revoke` (RFC 7009) and the person's own `POST /mcp/revoke-client` delete tokens.
174
+ - **A password reset or change deletes every MCP token** of the person: `onPasswordReset`'s guarded blocks, which end the person's sessions first (so none can mint a token in between) and then their tokens (`reason: "session_revocation_failed"` / `"mcp_token_revocation_failed"` on failure, never a rejection) and an after-hook on `/change-password`.
175
+ - Two plugin facts AILK works around: `/mcp/register` answers a raw `Response`, so no after-hook runs on it (the sweep runs before), and the plugin's `/mcp/token` treats any `oauthApplication.type` but `public` as confidential, so `POST /mcp/consent` re-derives it from the stored secret (`public` for a client with no secret, the column defaulting to `web`).
176
+
167
177
  ## Contact page
168
178
 
169
179
  The contact page (`apps/web/app/[locale]/contact/page.tsx`) renders the
@@ -267,8 +277,8 @@ embedding for it is behind the hero CTA's lightbox gate
267
277
 
268
278
  The workspace's **Newsletter magnet** (#8847) — seeded live at slug
269
279
  `newsletter` in every workspace — is the only source of `/newsletter`,
270
- `/unsubscribe` and the footer signup; none of their copy is in page code,
271
- content or the message catalog. All three read it through
280
+ `/unsubscribe`, the footer signup and a bound `newsletter` section; none of
281
+ their copy is in page code, content or the message catalog. All four read it through
272
282
  `lib/in-page-magnet.ts`'s `readServedMagnet("newsletter")`, shared for 15
273
283
  seconds per instance like /contact's read.
274
284
 
@@ -296,6 +306,13 @@ seconds per instance like /contact's read.
296
306
  - **The footer** fills `Footer`'s `subscribe` slot with the compact form
297
307
  (stacked, `sr-only` label) only when the magnet is served and capture is
298
308
  on; the built-in config never fills it.
309
+ - **A bound `newsletter` section** (#10629) — a content `newsletter` section
310
+ with `magnet: "newsletter"` in place of `action` renders, under its own
311
+ headline, the full-size signup the locale layout builds from the footer's
312
+ same read and hands every page through `NewsletterSignupProvider` — with
313
+ `consentGate: "on-submit"`, so Subscribe stays enabled and an unticked
314
+ submit shows the consent error beside the box. It renders nothing whenever
315
+ the footer would show nothing. A section with `action` posts as before.
299
316
 
300
317
  ## Careers
301
318
 
@@ -571,7 +588,7 @@ A visitor's browser never calls the hub. Lead capture, newsletter signup, callba
571
588
  - **No cross-site POST.** A `Sec-Fetch-Site` other than `same-origin`, or an `Origin` not on this site's own host, is refused 403 before the hub is called.
572
589
  - **Headers are an allow-list.** The visitor's `x-forwarded-for` (else `x-real-ip`) and `user-agent` pass through so per-visitor rate limits still count each visitor; no `authorization`, client-sent `x-ailk-*` or `host` is forwarded, and a `cookie` goes only on `POST /v1/commerce/invoices`, the one session route.
573
590
  - **The relay has its own per-visitor budget (#7707).** Each visitor gets 8 calls per 10 minutes across the five visitor routes, and the session route (`POST /v1/commerce/invoices`) gets its own, wider budget of 30 per 10 minutes; the call that exceeds either gets `429 rate_limited` before any hub call. The budget is held in this instance's own process (no shared store, no env variable) and holds only where the site's edge overwrites `x-forwarded-for` and `x-real-ip` — Vercel does; a bare self-hosted Next server does not.
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.
591
+ - **A bot check on the public writes (#9889, #9255).** On Vercel, `lib/bot-check.ts`'s `runBotCheck` asks Vercel BotID for a verdict before the hub is called — in the relay for `POST /v1/leads/{submit,subscribe,callback}` and `POST /v1/schedule/book` (`BOT_CHECKED_ROUTES`: `403 bot_detected`, or `503 bot_check_unavailable` when the check throws, answers no boolean or takes over 5 seconds), and in the contact, careers, newsletter, flow-step, flow-checkout, saved-recommendation and `/dev/purchase` Server Actions immediately before their hub call, which answer their own existing failure variant. The locale layout's `BotIdClient` protects every same-origin POST (`BOT_CLIENT_PROTECT`, `/*`), because a Server Action posts to the page it was submitted from; `next.config.mjs` wraps `withBotId` and `middleware.ts` keeps BotID's proxy prefix out of its matcher. It is on by default and inert off Vercel: `AILK_BOT_CHECK=off` turns every check off, `AILK_BOOKING_BOT_CHECK=off` the booking's alone, and MCP tools never pass through a site, so they are never challenged. A refusal logs `[<source>] bot refused` with BotID's `classificationReason`, `bypassed` and `isVerifiedBot`, the request's `host` and `forwardedHost`, and `proxied` (a `cf-ray` header) — no other header value, no form field (#10715). `AILK_BOT_CHECK=observe` runs the check and logs the same facts plus `isBot` as `observed`, but passes every write — for diagnosis only. **A BotID-protected site must be reached directly — Cloudflare DNS-only, no reverse proxy in front of Vercel (#10715).** Behind a proxy the write reaches Vercel from the proxy's egress rather than the browser that solved the challenge, so BotID refuses every real visitor; the first proxied request per process logs `[<source>] reverse proxy detected` (outcome `proxied`) naming that fix.
575
592
  - `/v1` is exempt from next-intl's locale rewrite in `middleware.ts`, as `/api` is.
576
593
 
577
594
  ## Where the docs live (`docsPath`)
@@ -602,6 +619,15 @@ A list page whose frontmatter sets `navOnly: true` (valid on a registry list typ
602
619
  - **Not honoured.** The docs-blog module's Fumadocs `/blog` and `/docs`; `DownloadsList` (`/downloads` already redirects to `/reports`); `LocationsList` (no route). `/sitemap.xml` and `/llms.txt` still list a Nav-only list's URL.
603
620
  - **The nav is untouched.** `lib/nav-config.ts` is generated from route surfaces, not from list pages, so the heading and its children stay in the menu.
604
621
 
622
+ ## The owner's discovery switch
623
+
624
+ **One switch hides the site from AI and search tools (#10510).** The Admin's MCP tab writes the workspace's `discoverable` setting; this app reads it through `siteDiscoverable()` from `@working-theory/api-client` (`GET /v1/content/discovery`, the workspace the API resolves for this site's key), shared per server instance for 15 seconds, the last answer on a failed read and on when there is none — it never rejects.
625
+
626
+ - **The seven handlers.** `robots.txt`, `sitemap.xml`, `llms.txt` (both copies — the docs + blog module's `lib/slices/docs-blog/llms-route.ts` too), `.well-known/mcp.json`, `.well-known/ai-plugin.json` and `openapi.json` are `force-dynamic` and read it once per request, after the checks each already makes and before any other work. `createSitemapHandler` and `createLlmsHandler` take a `discoverable` dep (default `siteDiscoverable`); every test caller passes one, so no suite reads a hub.
627
+ - **On** is today, byte for byte. **Off**: robots.txt answers STEALTH's `User-agent: *` / `Disallow: /` with no Sitemap line; the other five answer 404 with no body (mcp.json keeps its CORS headers; its `OPTIONS` is unchanged). Every off answer is `cache-control: no-store`, so a crawler asks again.
628
+ - **Hidden wins.** With `STEALTH` on, or no public page (`PUBLIC_PAGES` false), robots.txt answers as before and makes no read; sitemap.xml and llms.txt 404 before it.
629
+ - **Pages are never hidden.** No page, layout or `middleware.ts` reads the switch, and `<meta robots>` and `X-Robots-Tag` stay `STEALTH`'s alone: off is a preference about discovery, not access control. `STEALTH` remains the hard, deploy-time hide.
630
+
605
631
  ## AEO & performance
606
632
 
607
633
  - Static rendering is the default. Don't introduce client rendering without a reason.
@@ -618,7 +644,15 @@ A list page whose frontmatter sets `navOnly: true` (valid on a registry list typ
618
644
  - Don't reach into `apps/api` or `apps/mcp` from `apps/web`. They're peer apps — talk via HTTP/MCP.
619
645
  - Don't commit images to `screenshots/` — only the `.gitkeep` is tracked.
620
646
 
621
- Last updated: 2026-10-28 — § Nav-only lists (`navOnly`): a Nav-only list's route answers 308 to its first child and 404 with none (#10200) — verified against `lib/hub-children.tsx` and the nine list routes it is called from beside the hubs.
647
+ Last updated: 2026-11-02 — § Theme & styling: `consent.appearance` named beside the other `_site.mdx` chrome keys, and the minimal deck home's hold-back is the band's alone, so a minimal site that picks the card sees it on load there too (#10721) — verified against `app/[locale]/layout.tsx`.
648
+
649
+ Prior: 2026-11-02 — § The same-origin relay: a bot-check refusal names its cause (`classificationReason`, `bypassed`, `isVerifiedBot`, both hosts, `proxied`), `AILK_BOT_CHECK=observe` logs and never refuses, and a BotID-protected site must be reached directly, Cloudflare DNS-only (#10715) — verified against `lib/bot-check.ts`.
650
+
651
+ Prior: 2026-11-01 — § The owner's discovery switch: the six discovery files read the workspace's `discoverable` setting per request through `siteDiscoverable()`, off answering robots.txt's disallow-all and 404 for the rest, never cached (#10510) — verified against `app/{robots.txt,sitemap.xml,llms.txt,openapi.json}/route.ts`, `app/.well-known/{mcp.json,ai-plugin.json}/route.ts`, `lib/slices/docs-blog/llms-route.ts` and `packages/api-client/src/discovery.ts`.
652
+
653
+ Prior: 2026-10-30 — § The MCP authorization server: the site's OAuth 2.1 authorization server, its consent step, rotation and revocation (#10357) — verified against `auth.ts`, `lib/mcp-oauth.ts`, `app/[locale]/(auth)/consent/` and `app/.well-known/oauth-authorization-server/route.ts`.
654
+
655
+ Prior: 2026-10-28 — § Nav-only lists (`navOnly`): a Nav-only list's route answers 308 to its first child and 404 with none (#10200) — verified against `lib/hub-children.tsx` and the nine list routes it is called from beside the hubs.
622
656
 
623
657
  Prior: 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/`.
624
658
 
@@ -28,9 +28,15 @@
28
28
  * See `docs/security/agent-manifest.md` for the full consumer guidance.
29
29
  */
30
30
 
31
+ import { siteDiscoverable } from "@working-theory/api-client";
32
+
31
33
  import { SITE_DESCRIPTION, SITE_NAME } from "../../../lib/site-brand";
32
34
  import { resolveBaseUrl } from "../../../src/lib/routes";
33
35
 
36
+ // #10510 D4 — read per request: the owner's discovery switch is a runtime
37
+ // setting a prerendered file could never follow.
38
+ export const dynamic = "force-dynamic";
39
+
34
40
  /** Soft cap to detect runaway `description_for_model` growth. */
35
41
  export const DESCRIPTION_FOR_MODEL_SOFT_CAP_CHARS = 8_000;
36
42
 
@@ -59,7 +65,9 @@ export function validateDescriptionForModel(description: string): boolean {
59
65
  // are flagged before the first request lands.
60
66
  validateDescriptionForModel(MANIFEST_DESCRIPTION_FOR_MODEL);
61
67
 
62
- export function GET(): Response {
68
+ export async function GET(): Promise<Response> {
69
+ // #10510 D4 — hidden by the owner: 404, never cached.
70
+ if (!(await siteDiscoverable())) return new Response(null, { status: 404, headers: { "cache-control": "no-store" } });
63
71
  const baseUrl = resolveBaseUrl();
64
72
 
65
73
  const manifest = {
@@ -24,6 +24,8 @@
24
24
  * Issue #8057 — `description` resolves from SITE_DESCRIPTION
25
25
  * (`siteIdentity.description` in _site.mdx → neutral default) instead of the
26
26
  * template's own blurb on every site.
27
+ * Issue #10392 — `authorization` names where an OAuth client signs in to the
28
+ * workspace tools, when the site pins its issuer and advertises its MCP host.
27
29
  */
28
30
 
29
31
  import {
@@ -78,6 +80,16 @@ export interface McpManifest {
78
80
  ailk: {
79
81
  version: string;
80
82
  };
83
+ /**
84
+ * Where an OAuth client signs in to the workspace tools (#10392 D8). Present
85
+ * only when the site pins its issuer and advertises its MCP host.
86
+ */
87
+ authorization?: {
88
+ /** RFC 8414: the site's authorization-server metadata. */
89
+ authorizationServerMetadata: string;
90
+ /** RFC 9728: the workspace endpoint's protected-resource metadata. */
91
+ protectedResourceMetadata: string;
92
+ };
81
93
  }
82
94
 
83
95
  export interface BuildMcpManifestOptions {
@@ -91,6 +103,13 @@ export interface BuildMcpManifestOptions {
91
103
  * Typically sourced from the MCP_PUBLIC_HTTP_URL env var in the web app.
92
104
  */
93
105
  mcpHttpUrl?: string;
106
+ /**
107
+ * Optional: the site's OAuth issuer — the origin apps/web serves its
108
+ * authorization server from (`BETTER_AUTH_URL`, #10392 D8). With
109
+ * `mcpHttpUrl`, the manifest names the two OAuth metadata URLs. Injected,
110
+ * like `mcpHttpUrl`, so the builder stays pure.
111
+ */
112
+ authIssuer?: string;
94
113
  /**
95
114
  * Whether this deployment holds the `commerce` add-on licence claim
96
115
  * (#5971). Add-on-gated tools — today only `start_purchase` — are advertised
@@ -125,6 +144,42 @@ export interface BuildMcpManifestOptions {
125
144
  /** MCP HTTP transport version this server implements. */
126
145
  const MCP_HTTP_VERSION = "2025-03-26";
127
146
 
147
+ const LOOPBACK_HOSTS = ["localhost", "127.0.0.1", "[::1]"];
148
+
149
+ /** A URL a client may trust for OAuth: `https:`, or `http:` on a loopback host. */
150
+ function trustableUrl(value: string | undefined): URL | null {
151
+ if (!value) return null;
152
+ let url: URL;
153
+ try {
154
+ url = new URL(value);
155
+ } catch {
156
+ return null;
157
+ }
158
+ if (url.protocol === "https:") return url;
159
+ if (url.protocol === "http:" && LOOPBACK_HOSTS.includes(url.hostname)) return url;
160
+ return null;
161
+ }
162
+
163
+ /**
164
+ * The two OAuth metadata URLs (#10392 D8), or `undefined` unless both the
165
+ * issuer and the MCP host are trustable — so the manifest never advertises a
166
+ * sign-in surface the site has not turned on. The protected resource is the
167
+ * workspace endpoint, RFC 9728's path-inserted location on the MCP origin.
168
+ */
169
+ function authorizationFor(
170
+ authIssuer: string | undefined,
171
+ mcpHttpUrl: string | undefined,
172
+ ): McpManifest["authorization"] {
173
+ const issuer = trustableUrl(authIssuer);
174
+ const mcp = trustableUrl(mcpHttpUrl);
175
+ if (!issuer || !mcp) return undefined;
176
+ const mcpPath = mcp.pathname.replace(/\/+$/, "");
177
+ return {
178
+ authorizationServerMetadata: new URL("/.well-known/oauth-authorization-server", issuer).href,
179
+ protectedResourceMetadata: `${mcp.origin}/.well-known/oauth-protected-resource${mcpPath}/workspace`,
180
+ };
181
+ }
182
+
128
183
  /**
129
184
  * Builds the /.well-known/mcp.json manifest. Pure: same input → same output.
130
185
  */
@@ -142,6 +197,8 @@ export function buildMcpManifest(opts: BuildMcpManifestOptions): McpManifest {
142
197
  });
143
198
  }
144
199
 
200
+ const authorization = authorizationFor(opts.authIssuer, opts.mcpHttpUrl);
201
+
145
202
  return {
146
203
  schemaVersion: "1",
147
204
  name: SITE_NAME,
@@ -160,5 +217,6 @@ export function buildMcpManifest(opts: BuildMcpManifestOptions): McpManifest {
160
217
  },
161
218
  transports,
162
219
  ailk: { version: opts.ailkVersion },
220
+ ...(authorization ? { authorization } : {}),
163
221
  };
164
222
  }
@@ -28,6 +28,8 @@
28
28
  * is appropriate per the MCP discovery spec.
29
29
  */
30
30
 
31
+ import { siteDiscoverable } from "@working-theory/api-client";
32
+
31
33
  import { UNSERVED_MCP_TOOLS } from "../../../lib/topology.generated";
32
34
  import packageJson from "../../../package.json";
33
35
  import {
@@ -37,6 +39,10 @@ import {
37
39
 
38
40
  import { buildMcpManifest } from "./build-manifest";
39
41
 
42
+ // #10510 D4 — read per request: the owner's discovery switch is a runtime
43
+ // setting a prerendered file could never follow.
44
+ export const dynamic = "force-dynamic";
45
+
40
46
  const CORS_HEADERS = {
41
47
  "Access-Control-Allow-Origin": "*",
42
48
  "Access-Control-Allow-Methods": "GET, OPTIONS",
@@ -51,11 +57,20 @@ export function OPTIONS(): Response {
51
57
  }
52
58
 
53
59
  export async function GET(): Promise<Response> {
60
+ // #10510 D4 — hidden by the owner: 404, never cached, before the licence read.
61
+ if (!(await siteDiscoverable())) {
62
+ return new Response(null, {
63
+ status: 404,
64
+ headers: { "cache-control": "no-store", ...CORS_HEADERS },
65
+ });
66
+ }
54
67
  // MCP_PUBLIC_HTTP_URL advertises the standalone HTTP transport endpoint
55
68
  // (apps/mcp running in HTTP mode). When set, the manifest names it as
56
69
  // `mcpEndpoint` and as an http transport descriptor; when unset, it
57
70
  // advertises no MCP URL at all (#7396).
58
71
  const mcpHttpUrlEnv = process.env["MCP_PUBLIC_HTTP_URL"];
72
+ // With it, the site's pinned issuer turns on the OAuth metadata (#10392).
73
+ const authIssuerEnv = process.env["BETTER_AUTH_URL"];
59
74
  const heldFeatures = await resolveLicenceFeatures();
60
75
 
61
76
  const manifest = buildMcpManifest({
@@ -71,6 +86,7 @@ export async function GET(): Promise<Response> {
71
86
  // Spread conditional to satisfy exactOptionalPropertyTypes: omit the key
72
87
  // entirely when the env var is not set rather than passing undefined.
73
88
  ...(mcpHttpUrlEnv ? { mcpHttpUrl: mcpHttpUrlEnv } : {}),
89
+ ...(authIssuerEnv ? { authIssuer: authIssuerEnv } : {}),
74
90
  });
75
91
 
76
92
  return Response.json(manifest, {
@@ -0,0 +1,53 @@
1
+ /**
2
+ * GET /.well-known/oauth-authorization-server
3
+ *
4
+ * The site's RFC 8414 authorization-server metadata (#10357 D7), at the root
5
+ * where RFC 8414 and MCP client discovery look for it. It is the `mcp`
6
+ * plugin's own document — whose HTTP copy under `/api/auth` is disabled, so
7
+ * there is one — with four AILK edits:
8
+ *
9
+ * - `revocation_endpoint` (AILK's RFC 7009 route) and its auth methods;
10
+ * - `scopes_supported` limited to `offline_access` and the `ailk:*` scopes;
11
+ * - `jwks_uri` and `userinfo_endpoint` removed — neither route exists; the
12
+ * plugin's tokens are opaque and validated by lookup (D1);
13
+ * - the grant types, PKCE method and RFC 9207 `iss` support stated.
14
+ *
15
+ * 404 when the authorization server is not installed (no pinned
16
+ * BETTER_AUTH_URL — `auth.ts` D4). Public, credential-free, cross-origin by
17
+ * design, like `.well-known/mcp.json`.
18
+ */
19
+ import { MCP_SCOPES } from "@working-theory/validation";
20
+
21
+ import { auth } from "../../../auth";
22
+
23
+ const HEADERS = {
24
+ "Cache-Control": "public, max-age=300",
25
+ "Access-Control-Allow-Origin": "*",
26
+ } as const;
27
+
28
+ type Metadata = Record<string, unknown> & {
29
+ token_endpoint?: string;
30
+ token_endpoint_auth_methods_supported?: unknown;
31
+ };
32
+
33
+ export async function GET(): Promise<Response> {
34
+ const read: unknown = Reflect.get(auth.api, "getMcpOAuthConfig");
35
+ if (typeof read !== "function") return new Response(null, { status: 404 });
36
+ const metadata = (await read()) as Metadata | null;
37
+ if (!metadata?.token_endpoint) return new Response(null, { status: 404 });
38
+
39
+ const { jwks_uri: _jwks, userinfo_endpoint: _userinfo, ...rest } = metadata;
40
+ const base = metadata.token_endpoint.replace(/\/mcp\/token$/, "");
41
+ return Response.json(
42
+ {
43
+ ...rest,
44
+ revocation_endpoint: `${base}/mcp/revoke`,
45
+ revocation_endpoint_auth_methods_supported: metadata.token_endpoint_auth_methods_supported,
46
+ scopes_supported: ["offline_access", ...MCP_SCOPES],
47
+ grant_types_supported: ["authorization_code", "refresh_token"],
48
+ code_challenge_methods_supported: ["S256"],
49
+ authorization_response_iss_parameter_supported: true,
50
+ },
51
+ { headers: HEADERS },
52
+ );
53
+ }
@@ -62,7 +62,14 @@ jest.mock("better-auth/next-js", () => ({
62
62
  nextCookies: () => ({ id: "next-cookies" }),
63
63
  }));
64
64
 
65
- jest.mock("better-auth/plugins", () => ({ magicLink: () => ({ id: "magic-link" }) }));
65
+ jest.mock("better-auth/plugins", () => ({
66
+ magicLink: () => ({ id: "magic-link" }),
67
+ mcp: () => ({ id: "mcp" }),
68
+ }));
69
+
70
+ // The MCP authorization server (#10357 D12): its ESM-only module never loads here.
71
+ // eslint-disable-next-line no-restricted-syntax -- #507: lib/mcp-oauth statically imports ESM-only better-auth/api, which CommonJS Jest cannot load.
72
+ jest.mock("../../../../lib/mcp-oauth", () => ({ mcpOAuthPlugin: () => ({ id: "ailk-mcp-oauth" }) }));
66
73
 
67
74
  /**
68
75
  * The signer seam. `buildAuthEmailRequest` has exactly one external
@@ -0,0 +1,56 @@
1
+ /**
2
+ * @jest-environment node
3
+ *
4
+ * `decideConsent` — the consent page's Allow and Deny (#10357 D6). `auth` is
5
+ * mocked; `POST /mcp/consent` runs for real in
6
+ * `__tests__/auth/mcp-oauth-flow.test.ts`. This suite proves the fallback
7
+ * redirect is always a path on this site.
8
+ */
9
+ const mockConsent = jest.fn();
10
+ const mockRedirect = jest.fn();
11
+
12
+ // eslint-disable-next-line no-restricted-syntax -- #507: auth.ts loads ESM-only better-auth; its consent endpoint runs for real in mcp-oauth-flow.test.ts.
13
+ jest.mock("../../../../../auth", () => ({
14
+ auth: { api: { mcpConsent: (...args: unknown[]) => mockConsent(...args) } },
15
+ }));
16
+
17
+ jest.mock("next/headers", () => ({ headers: jest.fn().mockResolvedValue(new Headers()) }));
18
+
19
+ jest.mock("next/navigation", () => ({ redirect: (...args: unknown[]) => mockRedirect(...args) }));
20
+
21
+ import { decideConsent } from "../consent.actions";
22
+
23
+ function form(values: Record<string, string>): FormData {
24
+ const data = new FormData();
25
+ for (const [key, value] of Object.entries(values)) data.append(key, value);
26
+ return data;
27
+ }
28
+
29
+ beforeEach(() => {
30
+ mockConsent.mockReset();
31
+ mockRedirect.mockReset();
32
+ });
33
+
34
+ it("goes to the client's callback when the consent endpoint answers one", async () => {
35
+ mockConsent.mockResolvedValue({ redirectURI: "http://127.0.0.1:53682/callback?code=c" });
36
+ await decideConsent(form({ consent_code: "abc", locale: "en", decision: "deny" }));
37
+ expect(mockRedirect).toHaveBeenCalledWith("http://127.0.0.1:53682/callback?code=c");
38
+ });
39
+
40
+ it("returns to this site's consent page on a refusal", async () => {
41
+ mockConsent.mockRejectedValue(new Error("refused"));
42
+ await decideConsent(form({ consent_code: "abc", locale: "en", decision: "allow" }));
43
+ expect(mockRedirect).toHaveBeenCalledWith(expect.stringMatching(/^\/(en\/)?consent\?consent_code=abc$/));
44
+ });
45
+
46
+ it.each(["/evil.example", "//evil.example", "en/../..", "https://evil.example"])(
47
+ "never builds the fallback from a posted locale of %s",
48
+ async (locale) => {
49
+ mockConsent.mockRejectedValue(new Error("refused"));
50
+ await decideConsent(form({ consent_code: "abc", locale, decision: "allow" }));
51
+ const target = String(mockRedirect.mock.calls[0]?.[0]);
52
+ expect(target.startsWith("//")).toBe(false);
53
+ expect(target).not.toContain("evil.example");
54
+ expect(target).toMatch(/^\/(en\/)?consent\?consent_code=abc$/);
55
+ },
56
+ );
@@ -0,0 +1,164 @@
1
+ /**
2
+ * The MCP consent page (#10357 D6, AC-6). `auth` is mocked — the endpoints it
3
+ * calls run for real in `__tests__/auth/mcp-oauth-flow.test.ts`; this suite
4
+ * proves what the page renders from their answer and what it never reads.
5
+ *
6
+ * `next-intl/server` is mocked globally (apps/web/__tests__/setup.ts) against
7
+ * the real en.json.
8
+ */
9
+ import { fireEvent, render, screen } from "@testing-library/react";
10
+
11
+ const mockGetSession = jest.fn();
12
+ const mockDetails = jest.fn();
13
+
14
+ // eslint-disable-next-line no-restricted-syntax -- #507: auth.ts loads ESM-only better-auth; its consent endpoints run for real in mcp-oauth-flow.test.ts.
15
+ jest.mock("../../../../../auth", () => ({
16
+ auth: {
17
+ api: {
18
+ getSession: (...args: unknown[]) => mockGetSession(...args),
19
+ mcpConsentDetails: (...args: unknown[]) => mockDetails(...args),
20
+ },
21
+ },
22
+ }));
23
+
24
+ // eslint-disable-next-line no-restricted-syntax -- #507: the server action imports auth.ts (ESM-only better-auth).
25
+ jest.mock("../consent.actions", () => ({ decideConsent: jest.fn() }));
26
+
27
+ jest.mock("next/headers", () => ({
28
+ headers: jest.fn().mockResolvedValue(new Headers({ cookie: "session=1" })),
29
+ }));
30
+
31
+ jest.mock("better-auth/react", () => ({
32
+ __esModule: true,
33
+ createAuthClient: () => ({ signOut: jest.fn() }),
34
+ }));
35
+
36
+ // The shared mock neither interpolates nor reads nested keys; this page shows
37
+ // the email, the client name and the scope labels, so this suite's mock does.
38
+ jest.mock("next-intl/server", () => {
39
+ const catalog = jest.requireActual("../../../../../messages/en.json") as Record<string, unknown>;
40
+ return {
41
+ getTranslations: async ({ namespace }: { namespace: string }) =>
42
+ (key: string, values: Record<string, string> = {}) => {
43
+ const found = `${namespace}.${key}`
44
+ .split(".")
45
+ .reduce<unknown>((node, part) => (node as Record<string, unknown> | undefined)?.[part], catalog);
46
+ return String(found ?? `${namespace}.${key}`).replace(/\{(\w+)\}/g, (_, name: string) => values[name] ?? "");
47
+ },
48
+ };
49
+ });
50
+
51
+ jest.mock("next/navigation", () => ({
52
+ useRouter: () => ({ push: jest.fn(), refresh: jest.fn() }),
53
+ redirect: jest.fn(),
54
+ }));
55
+
56
+ import en from "../../../../../messages/en.json";
57
+ import ConsentPage, { generateMetadata } from "../page";
58
+
59
+ const params = Promise.resolve({ locale: "en" });
60
+ const MEMBER_SCOPES = ["ailk:people-read", "ailk:member-pieces-manage", "ailk:people-memory-propose", "ailk:inbox-act"];
61
+ const t = en.mcpConsent;
62
+
63
+ const twoWorkspaces = {
64
+ client: { name: "Some App", redirectOrigin: "http://127.0.0.1:53682" },
65
+ workspaces: [
66
+ { id: "wksp_a", name: "Alpha", organizationName: "Alpha Co", role: "member", offered: MEMBER_SCOPES },
67
+ { id: "wksp_b", name: "Beta", organizationName: "Beta Co", role: "member", offered: MEMBER_SCOPES },
68
+ ],
69
+ };
70
+
71
+ async function renderPage(query: Record<string, string> = { consent_code: "code-1" }) {
72
+ render(await ConsentPage({ params, searchParams: Promise.resolve(query) }));
73
+ }
74
+
75
+ beforeEach(() => {
76
+ mockGetSession.mockResolvedValue({ user: { email: "person@example.test" } });
77
+ mockDetails.mockResolvedValue(twoWorkspaces);
78
+ });
79
+
80
+ describe("the consent page (#10357 AC-6)", () => {
81
+ it("names the signed-in person, the client's own claim and where the code goes", async () => {
82
+ await renderPage();
83
+ expect(screen.getByTestId("mcp-consent-signed-in")).toHaveTextContent("person@example.test");
84
+ expect(screen.getByTestId("mcp-consent-client")).toHaveTextContent(
85
+ t.clientClaim.replace("{name}", "Some App"),
86
+ );
87
+ expect(screen.getByTestId("mcp-consent-redirect-origin")).toHaveTextContent("http://127.0.0.1:53682");
88
+ expect(screen.getByTestId("mcp-consent-sign-out")).toBeInTheDocument();
89
+ });
90
+
91
+ it("renders one radio per workspace, none checked when there are two, and Allow disabled", async () => {
92
+ await renderPage();
93
+ for (const id of ["wksp_a", "wksp_b"]) {
94
+ expect(screen.getByTestId(`mcp-consent-workspace-${id}`)).toHaveAttribute("aria-checked", "false");
95
+ }
96
+ expect(screen.getByTestId("mcp-consent-allow")).toBeDisabled();
97
+ expect(screen.queryByTestId("mcp-consent-scope-people-read")).toBeNull();
98
+ });
99
+
100
+ it("on choosing a workspace shows its offered scopes unticked, the member-reads line, and enables Allow", async () => {
101
+ await renderPage();
102
+ fireEvent.click(screen.getByTestId("mcp-consent-workspace-wksp_b"));
103
+ for (const scope of MEMBER_SCOPES) {
104
+ const permission = scope.slice("ailk:".length);
105
+ expect(screen.getByTestId(`mcp-consent-scope-${permission}`)).toHaveAttribute("aria-checked", "false");
106
+ }
107
+ expect(screen.queryByTestId("mcp-consent-scope-content-edit")).toBeNull();
108
+ expect(screen.getByText(t.scopes["people-read"])).toBeInTheDocument();
109
+ expect(screen.getByTestId("mcp-consent-member-reads")).toHaveTextContent(t.memberReads);
110
+ expect(screen.getByTestId("mcp-consent-allow")).not.toBeDisabled();
111
+ });
112
+
113
+ it("pre-selects the only workspace", async () => {
114
+ mockDetails.mockResolvedValue({ ...twoWorkspaces, workspaces: [twoWorkspaces.workspaces[0]] });
115
+ await renderPage();
116
+ expect(screen.getByTestId("mcp-consent-workspace-wksp_a")).toHaveAttribute("aria-checked", "true");
117
+ expect(screen.getByTestId("mcp-consent-allow")).not.toBeDisabled();
118
+ });
119
+
120
+ it("reads only consent_code from its query — never a client-controlled scope or client_id", async () => {
121
+ await renderPage({ consent_code: "code-1", scope: "ailk:content-edit", client_id: "evil" });
122
+ expect(mockDetails).toHaveBeenCalledWith(
123
+ expect.objectContaining({ query: { consent_code: "code-1" } }),
124
+ );
125
+ fireEvent.click(screen.getByTestId("mcp-consent-workspace-wksp_a"));
126
+ expect(screen.queryByTestId("mcp-consent-scope-content-edit")).toBeNull();
127
+ expect(document.body.textContent).not.toContain(t.scopes["content-edit"]);
128
+ expect(document.body.textContent).not.toContain("evil");
129
+ });
130
+
131
+ it("has Allow and Deny as 44 px buttons in one form", async () => {
132
+ await renderPage();
133
+ expect(screen.getByTestId("mcp-consent-form")).toContainElement(screen.getByTestId("mcp-consent-deny"));
134
+ expect(screen.getByTestId("mcp-consent-deny").className).toContain("control-height-big");
135
+ });
136
+
137
+ it("renders the unavailable line and no form when details are refused", async () => {
138
+ mockDetails.mockRejectedValue(new Error("invalid_request"));
139
+ await renderPage();
140
+ expect(screen.getByTestId("mcp-consent-unavailable")).toHaveTextContent(t.unavailable);
141
+ expect(screen.queryByTestId("mcp-consent-form")).toBeNull();
142
+ });
143
+
144
+ it("renders the unavailable line with no session, and without a consent_code", async () => {
145
+ mockGetSession.mockResolvedValue(null);
146
+ await renderPage();
147
+ expect(screen.getByTestId("mcp-consent-unavailable")).toBeInTheDocument();
148
+ mockGetSession.mockResolvedValue({ user: { email: "person@example.test" } });
149
+ });
150
+
151
+ it("offers Deny only when the person has no workspace", async () => {
152
+ mockDetails.mockResolvedValue({ ...twoWorkspaces, workspaces: [] });
153
+ await renderPage();
154
+ expect(screen.getByTestId("mcp-consent-no-workspace")).toHaveTextContent(t.noWorkspace);
155
+ expect(screen.queryByTestId("mcp-consent-allow")).toBeNull();
156
+ expect(screen.getByTestId("mcp-consent-deny")).toBeInTheDocument();
157
+ });
158
+
159
+ it("is noindex and sends no cross-site referrer", async () => {
160
+ const metadata = await generateMetadata({ params, searchParams: Promise.resolve({}) });
161
+ expect(metadata.robots).toEqual({ index: false, follow: false });
162
+ expect(metadata.referrer).toBe("same-origin");
163
+ });
164
+ });