create-ailk 0.29.1 → 0.30.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.
- package/dist/add-route.js +1 -0
- package/dist/cli.js +15 -1
- package/dist/lib/env-catalog.js +1 -1
- package/dist/lib/scaffold-render-sweep.d.ts +1 -1
- package/dist/lib/scaffold-render-sweep.js +1 -1
- package/dist/module-architecture.js +6 -1
- package/dist/provision-env.d.ts +28 -0
- package/dist/provision-env.js +210 -0
- package/dist/surfaces.js +3 -0
- package/dist/topology.js +4 -0
- package/package.json +3 -3
- package/templates/.env.example +7 -0
- package/templates/apps/api/CLAUDE.md +6 -2
- package/templates/apps/mcp/.env.example +20 -0
- package/templates/apps/mcp/CLAUDE.md +42 -8
- package/templates/apps/web/CLAUDE.md +24 -1
- package/templates/apps/web/app/.well-known/ai-plugin.json/route.ts +9 -1
- package/templates/apps/web/app/.well-known/mcp.json/build-manifest.ts +58 -0
- package/templates/apps/web/app/.well-known/mcp.json/route.ts +16 -0
- package/templates/apps/web/app/.well-known/oauth-authorization-server/route.ts +53 -0
- package/templates/apps/web/app/[locale]/(auth)/__tests__/password-reset.test.tsx +8 -1
- package/templates/apps/web/app/[locale]/(auth)/consent/__tests__/consent-actions.test.ts +56 -0
- package/templates/apps/web/app/[locale]/(auth)/consent/__tests__/page.test.tsx +164 -0
- package/templates/apps/web/app/[locale]/(auth)/consent/consent-form.tsx +132 -0
- package/templates/apps/web/app/[locale]/(auth)/consent/consent.actions.ts +51 -0
- package/templates/apps/web/app/[locale]/(auth)/consent/page.tsx +144 -0
- package/templates/apps/web/app/[locale]/about/__tests__/about-routes.test.tsx +2 -2
- package/templates/apps/web/app/api/auth/hub/continuation.ts +8 -3
- package/templates/apps/web/app/api/auth/mcp/__tests__/continuation.test.ts +92 -0
- package/templates/apps/web/app/api/auth/mcp/continuation.ts +89 -0
- package/templates/apps/web/app/api/auth/mcp/continue/[ref]/__tests__/route.test.ts +59 -0
- package/templates/apps/web/app/api/auth/mcp/continue/[ref]/route.ts +41 -0
- package/templates/apps/web/app/llms.txt/route.ts +10 -0
- package/templates/apps/web/app/openapi.json/__tests__/route.test.ts +40 -0
- package/templates/apps/web/app/openapi.json/route.ts +4 -0
- package/templates/apps/web/app/robots.txt/route.ts +18 -1
- package/templates/apps/web/app/sitemap.xml/route.ts +10 -0
- package/templates/apps/web/auth.ts +184 -7
- package/templates/apps/web/lib/__tests__/auth-routes.test.ts +2 -1
- package/templates/apps/web/lib/auth-routes.ts +5 -3
- package/templates/apps/web/lib/mcp-oauth.ts +702 -0
- package/templates/apps/web/messages/en.json +42 -13
- package/templates/database/seed.ts +6 -0
- package/versions.json +48 -48
|
@@ -57,6 +57,13 @@ now mean `packages/mcp-server/src/<file>`.
|
|
|
57
57
|
instance answers), `false`/`0` stateful (sessions in one process's memory). Unset, the
|
|
58
58
|
Vercel entry is stateless and `src/index.ts` is stateful. Any other value fails the boot.
|
|
59
59
|
The Vercel entry needs stateless: a session kept in one instance is lost on the next.
|
|
60
|
+
- **The owner's discovery switch (#10510 D5).** `httpTransportConfigFromEnv()` sets
|
|
61
|
+
`discoverable: siteDiscoverable` (`@working-theory/api-client`), so while the owner has hidden
|
|
62
|
+
the site, GET, POST and DELETE on the anonymous path answer Fastify's own 404 — an off server
|
|
63
|
+
looks like none — before the abuse guard or any tool runs. It is read per request through the
|
|
64
|
+
reader's 15-second shared answer; a failed read keeps the last answer, then on.
|
|
65
|
+
`/mcp/workspace`, its protected-resource metadata and stdio are untouched. A hub's own tool
|
|
66
|
+
server needs the hub site's `AILK_SPOKE_KEY`, or its hub refuses the read and the server stays on.
|
|
60
67
|
- **Content root.** Every content tool and `audit` take their root from `mcpContentRoot()`
|
|
61
68
|
(`lib/content-adapter.ts`): `AILK_CONTENT_ROOT`, else `<cwd>/content`, else
|
|
62
69
|
`<cwd>/../../content`. Never build a content path from `process.cwd()` in a tool — the one
|
|
@@ -130,9 +137,11 @@ Every tool maps 1:1 to an `apps/api` endpoint. When you change a tool, change th
|
|
|
130
137
|
|
|
131
138
|
- **22 in the `TOOLS` array / parity test**: the `TOOLS` array in
|
|
132
139
|
`packages/metadata/src/mcp-tool-catalog.ts` defines 22 anonymous tools; the
|
|
133
|
-
parity test counts that full array. It also holds the thirty-
|
|
140
|
+
parity test counts that full array. It also holds the thirty-seven signed-in
|
|
134
141
|
workspace tools (#3591; the content-suggestion pair `draft_page_suggestions`
|
|
135
|
-
and `decide_page_suggestion` since #10218
|
|
142
|
+
and `decide_page_suggestion` since #10218; the draft pair `save_draft` and
|
|
143
|
+
`read_report` since #10336; the knowledge-base pair `read_knowledge` and
|
|
144
|
+
`read_knowledge_doc` since #10345), which no anonymous view returns — see § The
|
|
136
145
|
signed-in workspace surface below.
|
|
137
146
|
- **20 registered on a KEYLESS deployment, 21 on a commerce-claimed one,
|
|
138
147
|
while the Free level is held or over HTTP — 21 and 22 over stdio once it is
|
|
@@ -178,12 +187,14 @@ here. Spec: `docs/specs/3591-tenant-scoped-agent-auth-secure-spec.md` D13–D16.
|
|
|
178
187
|
- **apps/mcp checks only the key's shape.** The key is attached as the SDK's `req.auth` for that
|
|
179
188
|
request alone and reaches a tool as `extra.authInfo.token`. apps/api is the one place it is
|
|
180
189
|
checked, and its tenant guard and the caller's clamped role decide every answer.
|
|
181
|
-
- **Thirty-
|
|
190
|
+
- **Thirty-eight tools, each a `twin`.** The catalog rows carry `surface: "workspace"`, `tier: "pro"`
|
|
182
191
|
and `modulesAnyOf` (the modules any one of which registers the tool — its twin route's gate):
|
|
183
192
|
`get_agent_context`, (#9683) `get_workspace_knowledge` — twin of `GET
|
|
184
193
|
/v1/workspaces/:workspaceId/knowledge`: the business's shown offerings, live magnets, listed
|
|
185
194
|
meeting types, sites served and strategy documents, read with the `people-read` or
|
|
186
|
-
`people-memory-propose` grant —
|
|
195
|
+
`people-memory-propose` grant — (#10393) `list_connected_clients` — twin of `GET
|
|
196
|
+
/v1/workspaces/:workspaceId/clients`: the AI clients and agent keys connected to the workspace,
|
|
197
|
+
only the caller's own person's for an agent; revoking has no tool — the five message tools (#9686 added `list_inbox_threads`, twin of
|
|
187
198
|
`GET …/messages/threads`: the inbox's threads, 25 to a page with a count per view; Done now
|
|
188
199
|
archives the message's thread and accept no longer marks it done, and an agent reaches any
|
|
189
200
|
inbox tool only with the `inbox-act` grant), the three submission tools, the three
|
|
@@ -198,18 +209,35 @@ here. Spec: `docs/specs/3591-tenant-scoped-agent-auth-secure-spec.md` D13–D16.
|
|
|
198
209
|
tools, `list_proposals` and `create_proposal`, twinning `GET`/`POST
|
|
199
210
|
/v1/workspaces/:workspaceId/people/:personId/proposals` with the same grant: a proposal is
|
|
200
211
|
private to one client, `payments` says whether approval leads to a Stripe page, and an agent's
|
|
201
|
-
price is refused `proposal_needs_person` (a person prices) — and (#9681) the four person-thread tools, `get_person_thread`, `set_person_thread_state`, `list_external_threads` and `add_external_thread`, twinning `GET`/`PATCH …/people/:personId/thread` and `GET`/`POST …/people/:personId/external-threads`: a client's whole thread page by page with its read/archived/resolved state, and pointers to email and Slack threads (never message text); reading needs the `people-read` grant, writing also `inbox-act`, and the client erasure route has no tool, by decision — and (#9682) the three person-memory tools, `list_person_memories`, `get_person_memory` and `propose_person_memory`, twinning `GET …/people/:personId/memories`, `GET …/memories/:path` and `POST …/memories`: an agent reads only live, unexpired memories (confirmed by a person, read within 180 days) and its proposal is never live — a new path is a draft, a confirmed one gets a pending version — needing the `people-read` or `people-memory-propose` grant to read and `people-memory-propose` to propose; the six person-only memory routes (edit, delete, confirm, redact, the versions list, restore) have no tool, by decision — and (#9749) `get_slack_connection_status`, twin of `GET /v1/workspaces/:workspaceId/slack-connection` (registered with `workspace`/`multi-tenant`, its route's `TENANCY` row: whether the Organization's Slack is connected, its team name and the date, never a credential; connect and disconnect are a person's act and have no tool, by decision), and `get_prospect_channel_settings` / `set_prospect_channel_settings`, twins of `GET`/`PUT …/prospect-channel` (registered with `scheduling`, their routes' module): the update replaces every field, needs the `meeting-types-manage` grant, is refused `slack_not_connected` while the Organization has no Slack connection, and an agent may change only `enabled`, `channelPrefix` and `archiveDays` — a change to `memberIds` or `welcomeTemplate` is refused `prospect_channel_needs_person`. Each takes its route's params, query and body and returns the route's
|
|
212
|
+
price is refused `proposal_needs_person` (a person prices) — and (#9681) the four person-thread tools, `get_person_thread`, `set_person_thread_state`, `list_external_threads` and `add_external_thread`, twinning `GET`/`PATCH …/people/:personId/thread` and `GET`/`POST …/people/:personId/external-threads`: a client's whole thread page by page with its read/archived/resolved state, and pointers to email and Slack threads (never message text); reading needs the `people-read` grant, writing also `inbox-act`, and the client erasure route has no tool, by decision — and (#9682) the three person-memory tools, `list_person_memories`, `get_person_memory` and `propose_person_memory`, twinning `GET …/people/:personId/memories`, `GET …/memories/:path` and `POST …/memories`: an agent reads only live, unexpired memories (confirmed by a person, read within 180 days) and its proposal is never live — a new path is a draft, a confirmed one gets a pending version — needing the `people-read` or `people-memory-propose` grant to read and `people-memory-propose` to propose; the six person-only memory routes (edit, delete, confirm, redact, the versions list, restore) have no tool, by decision — and (#9749) `get_slack_connection_status`, twin of `GET /v1/workspaces/:workspaceId/slack-connection` (registered with `workspace`/`multi-tenant`, its route's `TENANCY` row: whether the Organization's Slack is connected, its team name and the date, never a credential; connect and disconnect are a person's act and have no tool, by decision), and `get_prospect_channel_settings` / `set_prospect_channel_settings`, twins of `GET`/`PUT …/prospect-channel` (registered with `scheduling`, their routes' module): the update replaces every field, needs the `meeting-types-manage` grant, is refused `slack_not_connected` while the Organization has no Slack connection, and an agent may change only `enabled`, `channelPrefix` and `archiveDays` — a change to `memberIds` or `welcomeTemplate` is refused `prospect_channel_needs_person` — and (#10218) the content-suggestion pair, `draft_page_suggestions` and `decide_page_suggestion` — and (#10336) the draft pair, `save_draft` and `read_report`, twinning `PATCH`/`GET …/content/:type/:slug` with the `content-edit` grant: `save_draft` takes the page's `content` whole as the PATCH body and its `version` as `X-Draft-Version`, writes a draft version that never goes live (no tool publishes; a person does), and answers 409 `conflict` with `currentVersion` when the draft moved; `read_report` returns the draft tip, the version a save must send, its open suggestions and the route's check report (`ready`, `needs repair` or `needs your words`), computed by the route and never by the tool — and (#10345) the knowledge-base pair, `read_knowledge` and `read_knowledge_doc`, twinning `GET …/knowledge-base` and `GET …/knowledge-base/:type/:slug` with no grant (`docs-read` is every role's): the docs a named member may read (`userId`, by default the agent's own person), at the lower of the agent's person's role and that member's, so naming someone only narrows; Live does not gate them and a disabled doc never appears; `filter: "workspace-memory"` answers the always-loaded set with its `totalTokens` and an 8000-token `warning`. No workspace tool sets Live, widens Audience or re-enables a doc — those routes answer an agent 403 `agent_refused`, which reaches a tool as the code and status. Each takes its route's params, query and body and returns the route's
|
|
202
213
|
`data`. They are in the parity guard like every other tool, and never in
|
|
203
214
|
`/.well-known/mcp.json`.
|
|
215
|
+
- **The OAuth bearer branch (#10391).** When `BETTER_AUTH_URL` (the site's origin, its OAuth
|
|
216
|
+
2.1 authorization server, #10357) and `MCP_PUBLIC_HTTP_URL` are both `https:` (or `http:` on a
|
|
217
|
+
loopback host), `/mcp/workspace` also accepts the site's access token — only the plugin's
|
|
218
|
+
shape, `Bearer` + 32 ASCII letters, so an arbitrary string is 401 with no lookup. Each request
|
|
219
|
+
is validated by one `GET <BETTER_AUTH_URL>/api/auth/mcp/token-info` (`oauth-bearer.ts`: 5 s
|
|
220
|
+
timeout, no redirect followed, nothing cached — a revoked token fails its next call). A valid
|
|
221
|
+
token reaches tools as `AuthInfo` (`clientId`, the `ailk:*` scopes the live grants still name,
|
|
222
|
+
`expiresAt`, `resource`, and `extra: { kind: "oauth", userId, workspaceId, actingRole }`), its
|
|
223
|
+
`token` forwarded to apps/api like a key; a `tools/call` whose `arguments.workspaceId` is not
|
|
224
|
+
the token's workspace is 401 before any tool runs. While the branch is on, every 401 carries
|
|
225
|
+
`WWW-Authenticate: Bearer [error="invalid_token", ]resource_metadata="…"`, the RFC 9728
|
|
226
|
+
document is served at `/.well-known/oauth-protected-resource${path}/workspace` (no root
|
|
227
|
+
document), and an authorization server that cannot answer is `503 authorization_unavailable`
|
|
228
|
+
with `Retry-After: 5` — never a pass and never a 401. Issuer and resource come from env only,
|
|
229
|
+
never a request's `Host`. Either name unset, the route takes agent keys only, exactly as before;
|
|
230
|
+
a spoke has no authorization server, so it leaves `BETTER_AUTH_URL` unset. `/mcp` is untouched.
|
|
204
231
|
- **Handlers forward, nothing else.** `workspace-server.ts` resolves each tool from
|
|
205
232
|
`@working-theory/mcp-workspace`'s `workspaceTools` (`agent_context.ts`, `knowledge.ts`, `inbox_messages.ts`,
|
|
206
|
-
`inbox_submissions.ts`, `inbox_applications.ts`, `people_list.ts`, `person_notes.ts`, `person_thread.ts`, `person_memory.ts`, `proposals.ts`, `slack_connection.ts`, all
|
|
233
|
+
`inbox_submissions.ts`, `inbox_applications.ts`, `people_list.ts`, `person_notes.ts`, `person_thread.ts`, `person_memory.ts`, `proposals.ts`, `slack_connection.ts`, `page_suggestions.ts`, `content_draft.ts`, `knowledge_base.ts`, all
|
|
207
234
|
`// oss: false`) and, for the two prospect-channel settings tools, `@working-theory/mcp-scheduling`'s
|
|
208
235
|
`workspaceTools` (`prospect_channel_settings.ts`, #9749 — the pack the scheduling module carries, so a
|
|
209
236
|
scheduling-only build serves what it lists), loaded through the site's generated registry, as `server.ts` resolves the
|
|
210
237
|
module-gated tools from their packs (#9460). Each calls `@working-theory/api-client`'s `inbox` surface
|
|
211
238
|
(the people-list, notes, person-thread, person-memory and proposals tools, its `people` surface; the
|
|
212
|
-
Slack status and prospect-channel tools, its `slack` surface
|
|
239
|
+
Slack status and prospect-channel tools, its `slack` surface; the suggestion and draft tools, its
|
|
240
|
+
`pageSuggestions` and `contentDrafts` surfaces) with the key. A refusal from the
|
|
213
241
|
route is an MCP tool error naming the route's `error.code` and HTTP status only.
|
|
214
242
|
- **Never log or return the key.** Don't log or serialise `extra`, `extra.authInfo` or
|
|
215
243
|
`extra.requestInfo` — the SDK puts the raw `authorization` header there.
|
|
@@ -285,7 +313,13 @@ Example (lead routing):
|
|
|
285
313
|
- Don't add a tool without the matching `apps/api` endpoint.
|
|
286
314
|
- Don't put business logic in tool handlers — they're adapters.
|
|
287
315
|
|
|
288
|
-
Last updated: 2026-10-
|
|
316
|
+
Last updated: 2026-10-31 — #10391 (D2–D6): `/mcp/workspace` gains the OAuth bearer branch beside the agent key — one `token-info` lookup per request, the `WWW-Authenticate` challenge and the RFC 9728 protected-resource document, on only with `BETTER_AUTH_URL` and `MCP_PUBLIC_HTTP_URL` — verified against `packages/mcp-server/src/{http-transport,oauth-bearer}.ts`.
|
|
317
|
+
|
|
318
|
+
Prior: 2026-10-31 — #10345 (D9, D12): `read_knowledge` and `read_knowledge_doc` (`mcp-workspace`'s `knowledge_base.ts`) twin the per-user knowledge-base read — the signed-in workspace surface is thirty-seven tools — verified against `packages/metadata/src/mcp-tool-catalog.ts`, `packages-paid/mcp-workspace/src/index.ts` and `tests/parity/api-parity/kinds/site.ts`.
|
|
319
|
+
|
|
320
|
+
Prior: 2026-10-31 — #10336 (D1, D2, D10): `save_draft` and `read_report` (`mcp-workspace`'s `content_draft.ts`) twin the content-write draft save and read — the signed-in workspace surface is thirty-five tools, and the guard's `wire` row option binds the save's body and header — verified against `packages/metadata/src/mcp-tool-catalog.ts`, `packages-paid/mcp-workspace/src/index.ts` and `tests/parity/api-parity/kinds/site.ts`.
|
|
321
|
+
|
|
322
|
+
Prior: 2026-10-29 — #10202 (D8): `get_redirect` (`mcp-workspace`'s `redirect.ts`, the pack's first anonymous handler) twins `GET /v1/redirects` under `module: "content-write"` and `feature: "workspace"` — 22 in `TOOLS`, 20 registered keyless and 21 commerce-claimed while Free is held, seven `tier: "pro"` — verified against `packages/metadata/src/mcp-tool-catalog.ts`, `packages-paid/mcp-workspace/src/index.ts` and `tests/parity/api-parity/kinds/site.ts`.
|
|
289
323
|
|
|
290
324
|
Prior: 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`.
|
|
291
325
|
|
|
@@ -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
|
|
@@ -602,6 +612,15 @@ A list page whose frontmatter sets `navOnly: true` (valid on a registry list typ
|
|
|
602
612
|
- **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
613
|
- **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
614
|
|
|
615
|
+
## The owner's discovery switch
|
|
616
|
+
|
|
617
|
+
**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.
|
|
618
|
+
|
|
619
|
+
- **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.
|
|
620
|
+
- **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.
|
|
621
|
+
- **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.
|
|
622
|
+
- **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.
|
|
623
|
+
|
|
605
624
|
## AEO & performance
|
|
606
625
|
|
|
607
626
|
- Static rendering is the default. Don't introduce client rendering without a reason.
|
|
@@ -618,7 +637,11 @@ A list page whose frontmatter sets `navOnly: true` (valid on a registry list typ
|
|
|
618
637
|
- Don't reach into `apps/api` or `apps/mcp` from `apps/web`. They're peer apps — talk via HTTP/MCP.
|
|
619
638
|
- Don't commit images to `screenshots/` — only the `.gitkeep` is tracked.
|
|
620
639
|
|
|
621
|
-
Last updated: 2026-
|
|
640
|
+
Last updated: 2026-11-01 — § The owner's discovery switch: the six discovery files read the workspace's `discoverable` setting per request through `siteDiscoverable()`, off answering robots.txt's disallow-all and 404 for the rest, never cached (#10510) — verified against `app/{robots.txt,sitemap.xml,llms.txt,openapi.json}/route.ts`, `app/.well-known/{mcp.json,ai-plugin.json}/route.ts`, `lib/slices/docs-blog/llms-route.ts` and `packages/api-client/src/discovery.ts`.
|
|
641
|
+
|
|
642
|
+
Prior: 2026-10-30 — § The MCP authorization server: the site's OAuth 2.1 authorization server, its consent step, rotation and revocation (#10357) — verified against `auth.ts`, `lib/mcp-oauth.ts`, `app/[locale]/(auth)/consent/` and `app/.well-known/oauth-authorization-server/route.ts`.
|
|
643
|
+
|
|
644
|
+
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
645
|
|
|
623
646
|
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
647
|
|
|
@@ -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", () => ({
|
|
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
|
+
});
|