Skip to content

Commit 8222fa8

Browse files
fix(console): the OAuth consent screen explains metadata:write and never shows an unknown scope bare (#26659)
<!-- claude-projects-attribution: {"github_login":"zhuangjianguo"} --> _Requested by **Steedos** · [project thread](https://claude.ai/code/project/chan_018ETKaNwMZCNRYD7D8Q9Ubb?thread=cmsg_018ETKaNwMZCNRYD7D8Q9Ubb51xwoNvcyPQLyWAgj42Yro)_ Fixes #26587 Clause-②: yes Metadata-protocol: none ## 维护者速读 - **改了什么:** Console 的 OAuth 授权同意页(`/oauth/consent`)现在把 `metadata:write` 讲成一句话:「以草稿形式构建和修改此应用的元数据(不超出您自己的权限),由人来发布」,十种语言都有。页面不认识的 scope 显示为「无法识别的权限:xxx」,不再裸露原始字符串。 - **为什么改:** #26550 上线了 `metadata:write`,它是唯一能让 AI 客户端改动环境元数据的授权,却是同意页上唯一没有解释、只显示原始字符串的一项;用户在不知道授予了什么的情况下点「授权」。 - **风险与回滚:** 低。只改同意页的文案渲染和语言包两个新键;授权流程、提交内容不变。四个 MCP scope 的英文句子改为直接取自 `@objectstack/spec` 的 `MCP_OAUTH_SCOPE_CONSENT_COPY`,与服务端声明一致。回滚即 revert 本 PR。 - **先看:** `ui/apps/console/src/pages/auth/OAuthConsentPage.tsx` 的 `describeAgentScope`(对 `McpOauthScope` 穷尽的 switch)与 `default` 分支;`ui/apps/console/src/pages/auth/__tests__/OAuthConsentPage.mcp-scopes-26587.test.tsx`。 - **两分钟验证:** `pnpm --dir ui exec vitest run apps/console/src/pages/auth/__tests__/OAuthConsentPage.mcp-scopes-26587.test.tsx`,预期 12 个用例全绿(英文逐句等于 spec 文案,其余九种语言均不出现原始 scope,未知 scope 显示为「无法识别的权限」)。 - **需要你做的:** 无,席位落地。 ## What changes **`@object-ui/console` — `OAuthConsentPage`.** The consent list used to map the three older agent scopes by hand and send everything else to `default: return scope`, so `metadata:write` (added by #26550) reached the user as its raw spelling. Now: - `describeScope` routes every member of `MCP_OAUTH_SCOPES` to `describeAgentScope`, a switch that is exhaustive over `McpOauthScope` with no default. Each case reads its own pack key and passes `MCP_OAUTH_SCOPE_CONSENT_COPY[scope]` (from `@objectstack/spec/ai`) as the `defaultValue`. A scope the spec adds without a case here fails the Console type-check instead of rendering unexplained. - The `default` branch renders `oauth.consent.scope.unrecognised` ("Unrecognised permission: SCOPE"), per the triage ruling (comment 6438780502): "An unknown scope renders as an explicit "unrecognised permission", ⛔ never as a bare string." **`@object-ui/i18n` — all ten locale packs.** Two keys under `oauth.consent.scope`: `metadataWrite` (the `en` value is byte-identical to the spec's sentence) and `unrecognised` (one `{{scope}}` placeholder). The `en` comment above the block now says where the agent-scope English comes from. `TranslationKeys = typeof en` widens by these two keys, hence `Clause-②: yes`. **`docs/qa/platform-checklist/areas/ai.json` — `ai.mcp-http-surface`, revision 6** (the second item the card carries; a devx file). Main at c739de7 still said "three" scopes. The acceptance[5] clause and its verify now name the four scopes the 403 body and the WWW-Authenticate `scope` list carry (`packages/runtime/src/domains/mcp.ts` joins `MCP_OAUTH_SCOPES`), steps[5] says "four", and the variants matrix gains the `metadata:write` row (the host-injected authoring tools, served only to an OAuth grant naming the scope, per `packages/mcp/src/mcp-http-tools.ts`). History row added. **Changeset:** `.changeset/26587-consent-metadata-write.md`, `patch` for `@object-ui/i18n` and `@object-ui/console`. ## Tests - New: `ui/apps/console/src/pages/auth/__tests__/OAuthConsentPage.mcp-scopes-26587.test.tsx`. It walks `MCP_OAUTH_SCOPES`, so a scope the spec adds is a subject the moment it lands. In `en` the rows equal `MCP_OAUTH_SCOPE_CONSENT_COPY` in order. In each of the nine other built-in languages (catalogue loaded before the instance, so a missing key cannot hide behind the `en` fallback), no row contains the raw scope and no row equals the English sentence. An unknown scope renders through the `unrecognised` pack value in `en`, and in `zh` as a translated line that still names the scope. - Updated: the `CONTROL` case of `OAuthConsentPage.agent-scopes-12853.test.tsx` pinned the old raw-string behaviour; it now asserts the unrecognised line. ## Gates Local subset at `3f2b32c8`: the 41 commands `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` derives for this diff, each run with its exit code captured before any pipe, all 41 exit 0. `--ran` reconciliation: "41 derived famil(ies) accounted for — 41 run, 0 NOT-MEASURED (a DERIVED zero — all 41 recorded an exit code and none of them is 3)". The type-check lanes, the artifact rosters and the whole-tree families are CI's; their check-runs on this PR are the reading. <details><summary>The 41 derived commands and their exit codes</summary> ```text node scripts/check-adr-0087-registration.mjs --base origin/main :: exit 0 node scripts/check-adr-0087-registration.mjs --self-test :: exit 0 node scripts/check-adr-links.mjs :: exit 0 node scripts/check-adr-links.mjs --self-test :: exit 0 node scripts/check-changeset-no-major.mjs --base origin/main :: exit 0 node scripts/check-closing-keyword-parity.mjs :: exit 0 node scripts/check-closing-keyword-parity.mjs --self-test :: exit 0 node scripts/check-comment-mask-corpus.mjs :: exit 0 node scripts/check-empty-changeset.mjs --base origin/main :: exit 0 node scripts/check-empty-changeset.mjs --self-test :: exit 0 node scripts/check-sdui-manifest.mjs :: exit 0 node scripts/check-sdui-manifest.mjs --self-test :: exit 0 node scripts/ci/plan-test-core-shards.mjs --self-test :: exit 0 node scripts/pm/release-rehearsal-clone.mjs --self-test :: exit 0 node scripts/release-pending-publish.mjs --self-test :: exit 0 pnpm --dir ui exec vitest run scripts/ :: exit 0 pnpm --filter @objectstack/lint run check:doc-formula-expressions :: exit 0 pnpm --filter @objectstack/spec run check:duration-unit-keys :: exit 0 pnpm check:changeset-gate-self-tests :: exit 0 pnpm check:ci-filter-parity :: exit 0 pnpm check:closed-ceremony-text :: exit 0 pnpm check:cross-package-test-inputs :: exit 0 pnpm check:dispatcher-error-vocabulary :: exit 0 pnpm check:doc-authoring :: exit 0 pnpm check:driver-memory-census :: exit 0 pnpm check:engine-double-contract :: exit 0 pnpm check:gitlink-declared :: exit 0 pnpm check:nul-bytes :: exit 0 pnpm check:platform-checklist :: exit 0 pnpm check:pm-changeset-deadline-census :: exit 0 pnpm check:published-files :: exit 0 pnpm check:query-options-erasure :: exit 0 pnpm check:refd-timer-probe :: exit 0 pnpm check:skill-identifier-liveness :: exit 0 pnpm check:test-source-alias :: exit 0 pnpm check:tier-file-adoption :: exit 0 pnpm check:type-check-coverage :: exit 0 pnpm check:type-check-debt :: exit 0 pnpm check:undeclared-dep-imports :: exit 0 pnpm check:watch-hint-literal :: exit 0 pnpm check:where-matcher :: exit 0 ``` </details> Also run, outside the derivation (ui gates no root workflow runs today): `node scripts/check-i18n-call-site-keys.mjs` exit 0 (every call-site key resolves, interpolation parity holds), `node scripts/check-i18n-en-drift.mjs` exit 0 ("No en value changed in this range"), `node scripts/check-new-cross-file-line-citations.mjs` exit 0 ("0 new citation(s)"). ## Acceptance notes - The claim's file surface said "one new consent key"; the card's route also asks for a translated "unrecognised permission" line, so the packs gain two keys (`metadataWrite`, `unrecognised`). - The `zh` / `ja` / … sentences for `metadataWrite` follow each pack's existing pattern for the other agent scopes (the permission bound in parentheses) and add the "a person publishes" clause; they are hand translations, not machine-checked. - The Console now imports `@objectstack/spec/ai` from an eagerly loaded page. The constants live in a spec chunk that `@objectstack/spec/system` (already in the Console's dependency set) also reaches; the eager-closure byte cost was not measured locally and is left to CI's performance budget. - `ai.json`: besides the clause the card names, steps[5] ("NONE of the three MCP scopes") and the variants matrix were also stale for the same reason and are corrected in the same revision. ## Verification All readings at `3f2b32c8`. <details><summary>Tests</summary> - `cd ui && pnpm exec vitest run apps/console/src/pages/auth/__tests__/OAuthConsentPage.mcp-scopes-26587.test.tsx apps/console/src/pages/auth/__tests__/OAuthConsentPage.agent-scopes-12853.test.tsx apps/console/src/pages/auth/__tests__/OAuthConsentPage.public-client-12301.test.tsx --maxWorkers=1` (under `os-verify-lock.sh`): Test Files 3 passed (3), Tests 23 passed (23). - `cd ui && pnpm exec vitest run packages/i18n/ --maxWorkers=1`: Test Files 89 passed (89), Tests 1373 passed, 13 skipped (all-locales-key-parity among them). - `pnpm --dir ui exec vitest run scripts/` (derived): Test Files 178 passed, 2 skipped; Tests 5065 passed, 2 skipped. </details> <details><summary>Type-check</summary> `pnpm exec turbo run type-check --filter=@object-ui/i18n --filter=@object-ui/console --concurrency=2`: Tasks 71 successful, 71 total, 0 cached (the two packages' `type-check`, which covers their tests, plus the `^build` closure). VERDICT command-exit 0. </details> <details><summary>Ablations (one-shot, through scripts/ablation-replace.mjs, restored to HEAD each time)</summary> - Drop the `zh` pack's `metadataWrite` line: "anchor 1 -> 0, blob 4a8ff35 -> 0d88d403f28e"; the walk goes red on the `zh` case only ("Tests 1 failed | 11 passed (12)"); restore: "blob == HEAD (4a8ff35) and `git diff HEAD` is empty". - Send `metadata:write` past the agent-scope routing in `describeScope` (it falls to the unrecognised branch): "anchor 1 -> 0"; the `en` walk and all nine language cases go red ("Tests 10 failed | 2 passed (12)"); restore: "blob == HEAD (935b2c4) and `git diff HEAD` is empty". </details> --- _Generated by [Claude Code](https://claude.ai/code/session_01QsR2jUZATdbPBDkvjTyX8D)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 02b1392 commit 8222fa8

15 files changed

Lines changed: 229 additions & 32 deletions

File tree

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
---
2+
'@object-ui/i18n': patch
3+
'@object-ui/console': patch
4+
---
5+
6+
fix(console,i18n): the OAuth consent screen explains the `metadata:write` scope and never shows an unknown scope as its bare spelling
7+
8+
Clause-②: yes
9+
Metadata-protocol: none
10+
11+
- **`@object-ui/console`.** The consent screen at `/oauth/consent` names every MCP scope in a sentence. The sentences come from `@objectstack/spec`'s `MCP_OAUTH_SCOPE_CONSENT_COPY`, the copy the spec declares beside each scope, through a switch that is exhaustive over `McpOauthScope`: a scope added to the spec without a case here fails to compile. `metadata:write` used to appear as that raw string beside three plain sentences. It now reads "Build and change this app's metadata as drafts, within your own permissions — a person publishes them". A scope the page cannot name renders as "Unrecognised permission: SCOPE" instead of the bare scope string, which could read as a description of the grant.
12+
- **`@object-ui/i18n`.** Every locale pack gains `oauth.consent.scope.metadataWrite` and `oauth.consent.scope.unrecognised` (the latter takes a `{{scope}}` placeholder). `TranslationKeys` widens by these two keys.

‎docs/qa/platform-checklist/areas/ai.json‎

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -133,7 +133,7 @@
133133
"title": "MCP HTTP transport (/mcp) and the public /mcp/skill endpoint gate exactly as documented: 404 when opted out, 501 when unimplemented, 401 anonymous, 403 scopeless-OAuth, full tool list when keyed",
134134
"since": "v15",
135135
"status": "active",
136-
"revision": 5,
136+
"revision": 6,
137137
"priority": "P1",
138138
"surface": "api",
139139
"fixtures": {
@@ -146,15 +146,16 @@
146146
"variants": [
147147
"OAuth scope data:read → read-family tools: list_objects, describe_object, validate_expression, query_records, get_record, and aggregate_records where the bridge implements aggregate (packages/mcp/src/mcp-http-tools.ts canRead block; packages/spec/src/ai/mcp.zod.ts MCP_OAUTH_SCOPE_DATA_READ)",
148148
"OAuth scope data:write → write-family tools: create_record, update_record, delete_record",
149-
"OAuth scope actions:execute → list_actions, run_action"
149+
"OAuth scope actions:execute → list_actions, run_action",
150+
"OAuth scope metadata:write → the authoring tools the host injects (packages/mcp/src/mcp-server-runtime.ts registerAuthoringTools; packages/spec/src/ai/mcp.zod.ts MCP_OAUTH_SCOPE_METADATA_WRITE), served only to an OAuth grant naming metadata:write — a key or a session is served none"
150151
],
151152
"steps": [
152153
"boot with OS_MCP_SERVER_ENABLED=false; GET /api/v1/mcp/skill and POST /api/v1/mcp; record status + body of both",
153154
"boot with the default-on config; GET /api/v1/mcp/skill UNAUTHENTICATED; capture status, content-type, content-disposition and the markdown head",
154155
"POST /api/v1/mcp/skill (wrong method); capture the 405 and its Allow header",
155156
"POST /api/v1/mcp with NO credentials (an MCP initialize body); capture the 401 body and any WWW-Authenticate header",
156157
"open an MCP Streamable HTTP session against /api/v1/mcp with the osk_ key: initialize, then tools/list; record the full tool-name set",
157-
"if the OAuth track is live in the environment, request /mcp with an OAuth token (to mint one locally, register the client with application_type: 'native' — dynamic client registration refuses a web client whose redirect is http or a loopback host, 'web clients require https redirect URIs on non-loopback hosts', @better-auth/oauth-provider) carrying NONE of the three MCP scopes; capture the 403 + WWW-Authenticate error=\"insufficient_scope\"",
158+
"if the OAuth track is live in the environment, request /mcp with an OAuth token (to mint one locally, register the client with application_type: 'native' — dynamic client registration refuses a web client whose redirect is http or a loopback host, 'web clients require https redirect URIs on non-loopback hosts', @better-auth/oauth-provider) carrying NONE of the four MCP scopes; capture the 403 + WWW-Authenticate error=\"insufficient_scope\"",
158159
"capture the dispatcher-vs-hono distinction: the disabled boot's 404 comes from the gate (surface not advertised), never a hang or 500"
159160
],
160161
"acceptance": [
@@ -189,9 +190,9 @@
189190
"evidence": "the session trace + tool-name list"
190191
},
191192
{
192-
"clause": "an OAuth token granting none of the MCP scopes is refused 403 up front with insufficient_scope naming all three scopes (data:read, data:write, actions:execute); a partial grant narrows the tool list per the variants matrix (#2698, ADR-0090 D10)",
193+
"clause": "an OAuth token granting none of the MCP scopes is refused 403 up front with insufficient_scope naming all four scopes (data:read, data:write, actions:execute, metadata:write); a partial grant narrows the tool list per the variants matrix (#2698, ADR-0090 D10)",
193194
"oracle": "api",
194-
"verify": "403 body 'Forbidden: the access token grants none of the MCP scopes …' + WWW-Authenticate error=\"insufficient_scope\" scope=\"data:read data:write actions:execute\"; for a data:read-only token, tools/list carries the read family only",
195+
"verify": "403 body 'Forbidden: the access token grants none of the MCP scopes …' + WWW-Authenticate error=\"insufficient_scope\" scope=\"data:read data:write actions:execute metadata:write\"; for a data:read-only token, tools/list carries the read family only",
195196
"evidence": "the 403 + the narrowed tools/list"
196197
}
197198
],
@@ -215,7 +216,8 @@
215216
{ "revision": 2, "date": "2026-08-07", "change": "expanded to deep-test contract: concrete steps, multi-clause acceptance, negatives, variants", "ref": "claude/platform-test-checklist-ocwugl" },
216217
{ "revision": 3, "date": "2026-10-16", "change": "clause 5's tool list omitted resume_run (#23878 checklist-accuracy finding). Re-measured on main c91af8ed6 with an admin osk_ key: tools/list returns twelve tools, resume_run among them; mcp-http-tools.ts registers it only when the bridge exposes resumeRun, the same optional shape as aggregate_records, so the verify now names that degradation too", "ref": "#24036" },
217218
{ "revision": 4, "date": "2026-10-19", "change": "variants[0] and steps[5] corrected — checklist-accuracy findings of the 18.0 QA sweep (QA run #24540, ai.mcp-http-surface; wave anchor #24579 §3). The data:read family also serves aggregate_records and validate_expression (both read-only), and a local OAuth leg needs a native client. Re-derived at base 9a7838edf5 (mcp-http-tools.ts registers validate_expression and, when the bridge exposes aggregate, aggregate_records inside the canRead gate; the oauth-provider DCR rule in the installed @better-auth/oauth-provider 1.7.3). No clause changed", "ref": "#24649" },
218-
{ "revision": 5, "date": "2026-10-26", "change": "automated.ref extended and annotated per clause — checklist-accuracy findings of the 18.2.0 sweep, Tier 1 record #25815 at 6679644a (wave anchor #25807): the one-file ref left acceptance[0]–[2] and [5] with no named pin, and the pin asserts acceptance[3]'s status only and acceptance[4] with a session bearer, no initialize and two tool names. Re-read on main at b4199c96: the record's 'no pin at any layer' for acceptance[0]–[2] and [5] no longer holds, since dispatcher-unit and Hono-integration pins in packages/runtime and the scope-narrowing unit in packages/mcp assert parts of them; they join the ref with the layer each reaches and what each leaves unasserted. No clause changed, no status changed", "ref": "#25836" }
219+
{ "revision": 5, "date": "2026-10-26", "change": "automated.ref extended and annotated per clause — checklist-accuracy findings of the 18.2.0 sweep, Tier 1 record #25815 at 6679644a (wave anchor #25807): the one-file ref left acceptance[0]–[2] and [5] with no named pin, and the pin asserts acceptance[3]'s status only and acceptance[4] with a session bearer, no initialize and two tool names. Re-read on main at b4199c96: the record's 'no pin at any layer' for acceptance[0]–[2] and [5] no longer holds, since dispatcher-unit and Hono-integration pins in packages/runtime and the scope-narrowing unit in packages/mcp assert parts of them; they join the ref with the layer each reaches and what each leaves unasserted. No clause changed, no status changed", "ref": "#25836" },
220+
{ "revision": 6, "date": "2026-10-27", "change": "acceptance[5] clause and verify, steps[5] and variants follow the fourth MCP scope, metadata:write: the scopeless 403 names all four scopes in its body and in its WWW-Authenticate scope list (packages/runtime/src/domains/mcp.ts joins MCP_OAUTH_SCOPES), and the variants matrix gains the metadata:write row (the host-injected authoring tools, served only to an OAuth grant naming the scope). Re-read on main at c739de75. No status changed", "ref": "#26587" }
219221
]
220222
},
221223
{

‎ui/apps/console/src/pages/auth/OAuthConsentPage.tsx‎

Lines changed: 47 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,15 @@ import { useNavigate } from 'react-router-dom';
3434
import { Check, KeyRound, ShieldAlert, X } from 'lucide-react';
3535
import { toast } from 'sonner';
3636
import type { OAuthApplicationPublic } from '@objectstack/client';
37+
import {
38+
MCP_OAUTH_SCOPE_ACTIONS,
39+
MCP_OAUTH_SCOPE_CONSENT_COPY,
40+
MCP_OAUTH_SCOPE_DATA_READ,
41+
MCP_OAUTH_SCOPE_DATA_WRITE,
42+
MCP_OAUTH_SCOPE_METADATA_WRITE,
43+
MCP_OAUTH_SCOPES,
44+
type McpOauthScope,
45+
} from '@objectstack/spec/ai';
3746
import { useAuth } from '@object-ui/auth';
3847
import { useObjectTranslation } from '@object-ui/i18n';
3948
import {
@@ -271,10 +280,14 @@ export function OAuthConsentPage() {
271280
);
272281
}
273282

274-
function describeScope(
275-
scope: string,
276-
t: ReturnType<typeof useObjectTranslation>['t'],
277-
): string {
283+
type Translate = ReturnType<typeof useObjectTranslation>['t'];
284+
285+
function isMcpOauthScope(scope: string): scope is McpOauthScope {
286+
return (MCP_OAUTH_SCOPES as readonly string[]).includes(scope);
287+
}
288+
289+
function describeScope(scope: string, t: Translate): string {
290+
if (isMcpOauthScope(scope)) return describeAgentScope(scope, t);
278291
switch (scope) {
279292
case 'openid':
280293
return t('oauth.consent.scope.openid', {
@@ -292,23 +305,37 @@ function describeScope(
292305
return t('oauth.consent.scope.offlineAccess', {
293306
defaultValue: 'Stay signed in (refresh access)',
294307
});
295-
// The three agent scopes, as the server grants them: the agent's tools and
296-
// data ceiling follow the scope, and every call still runs under the
297-
// signed-in user's own permissions, so each sentence carries that bound.
298-
case 'data:read':
299-
return t('oauth.consent.scope.dataRead', {
300-
defaultValue: 'Read records, within your own permissions',
301-
});
302-
case 'data:write':
303-
return t('oauth.consent.scope.dataWrite', {
304-
defaultValue: 'Create, change and delete records, within your own permissions',
305-
});
306-
case 'actions:execute':
307-
return t('oauth.consent.scope.actionsExecute', {
308-
defaultValue: 'Run actions on your behalf, within your own permissions',
309-
});
308+
// A scope the page cannot name is still a grant the user is asked to
309+
// make, so it is announced as unrecognised, never shown as its bare
310+
// spelling: a raw string reads like a description of what is granted.
310311
default:
311-
return scope;
312+
return t('oauth.consent.scope.unrecognised', {
313+
scope,
314+
defaultValue: 'Unrecognised permission: {{scope}}',
315+
});
316+
}
317+
}
318+
319+
/**
320+
* The MCP agent scopes, as the server grants them: the agent's tools and data
321+
* ceiling follow the scope, and every call still runs under the signed-in
322+
* user's own permissions, so each sentence carries that bound. The sentence is
323+
* the spec's `MCP_OAUTH_SCOPE_CONSENT_COPY`, the one the server side declares
324+
* beside the scope; the pack translates it. The switch is exhaustive over
325+
* `McpOauthScope`, so a scope added to the spec without a case here fails to
326+
* compile instead of reaching the user as "unrecognised".
327+
*/
328+
function describeAgentScope(scope: McpOauthScope, t: Translate): string {
329+
const defaultValue = MCP_OAUTH_SCOPE_CONSENT_COPY[scope];
330+
switch (scope) {
331+
case MCP_OAUTH_SCOPE_DATA_READ:
332+
return t('oauth.consent.scope.dataRead', { defaultValue });
333+
case MCP_OAUTH_SCOPE_DATA_WRITE:
334+
return t('oauth.consent.scope.dataWrite', { defaultValue });
335+
case MCP_OAUTH_SCOPE_ACTIONS:
336+
return t('oauth.consent.scope.actionsExecute', { defaultValue });
337+
case MCP_OAUTH_SCOPE_METADATA_WRITE:
338+
return t('oauth.consent.scope.metadataWrite', { defaultValue });
312339
}
313340
}
314341

‎ui/apps/console/src/pages/auth/__tests__/OAuthConsentPage.agent-scopes-12853.test.tsx‎

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -15,8 +15,10 @@
1515
* sentence, carrying the bound the server enforces: the agent acts within the
1616
* user's own permissions.
1717
*
18-
* Any scope the page does not map still renders as its raw string — the
19-
* `CONTROL` case — so no sentence is guessed from a scope's spelling.
18+
* Any scope the page does not map renders as an explicit "unrecognised
19+
* permission" line naming it — the `CONTROL` case — so no sentence is guessed
20+
* from a scope's spelling, and the bare spelling never reads as a description
21+
* of the grant (objectstack#26587, which also walks every MCP scope).
2022
*
2123
* The catalogue reaches the page through the global instance `createI18n`
2224
* registers, the same harness as `FormPage.chromeI18n-11071.test.tsx`.
@@ -106,12 +108,12 @@ describe('OAuthConsentPage — the agent scopes in words (objectui#12853)', () =
106108
]);
107109
});
108110

109-
it('CONTROL: a scope the page does not map still renders as its raw string', async () => {
111+
it('CONTROL: a scope the page does not map renders as unrecognised, never as its raw string', async () => {
110112
renderConsent('data:read reports:export', 'en');
111113

112114
expect(await scopeRows()).toEqual([
113115
'Read records, within your own permissions',
114-
'reports:export',
116+
'Unrecognised permission: reports:export',
115117
]);
116118
});
117119
});
Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
/**
2+
* ObjectUI
3+
* Copyright (c) 2024-present ObjectStack Inc.
4+
*
5+
* This source code is licensed under the MIT license found in the
6+
* LICENSE file in the root directory of this source tree.
7+
*/
8+
9+
/**
10+
* OAuthConsentPage names every MCP scope the spec declares, in every built-in
11+
* language, and announces any other scope as unrecognised (objectstack#26587).
12+
*
13+
* `metadata:write` reached the consent screen as its raw spelling: the page
14+
* mapped the three older agent scopes by hand and had no case for the fourth,
15+
* so the one scope that lets an agent build in the user's environment was the
16+
* only one left unexplained. The walk below takes its subjects from
17+
* `MCP_OAUTH_SCOPES`, so a scope the spec adds is a subject here the moment it
18+
* lands, and its English is pinned to `MCP_OAUTH_SCOPE_CONSENT_COPY`, the
19+
* sentence the spec declares beside the scope.
20+
*
21+
* Each language's catalogue is loaded BEFORE the instance is created, so the
22+
* rows are read from that language's pack, never from the `en` fallback a
23+
* missing key would render through.
24+
*/
25+
26+
import { describe, it, expect, vi, afterEach } from 'vitest';
27+
import { render, screen, cleanup } from '@testing-library/react';
28+
import { MemoryRouter, Route, Routes } from 'react-router-dom';
29+
import { MCP_OAUTH_SCOPE_CONSENT_COPY, MCP_OAUTH_SCOPES } from '@objectstack/spec/ai';
30+
import { AuthProvider } from '@object-ui/auth';
31+
import type { AuthClient } from '@object-ui/auth';
32+
import { BUILT_IN_LANGUAGE_CODES, createI18n, en, loadBuiltInLocale } from '@object-ui/i18n';
33+
import { OAuthConsentPage } from '../OAuthConsentPage';
34+
35+
const CLIENT_ID = 'mcp-client';
36+
37+
afterEach(() => {
38+
cleanup();
39+
vi.unstubAllGlobals();
40+
});
41+
42+
function authClient(): AuthClient {
43+
return {
44+
getSession: vi
45+
.fn()
46+
.mockResolvedValue({ user: { id: 'u1', email: 'me@example.com' }, session: {} }),
47+
getConfig: vi.fn().mockResolvedValue({ features: {} }),
48+
} as unknown as AuthClient;
49+
}
50+
51+
function stubPublicClient() {
52+
vi.stubGlobal(
53+
'fetch',
54+
vi.fn(() =>
55+
Promise.resolve({
56+
ok: true,
57+
status: 200,
58+
json: async () => ({ client_id: CLIENT_ID, client_name: 'Claude Code' }),
59+
}),
60+
),
61+
);
62+
}
63+
64+
async function renderConsent(scope: string, language: string) {
65+
await loadBuiltInLocale(language);
66+
createI18n({ defaultLanguage: language, detectBrowserLanguage: false });
67+
stubPublicClient();
68+
const search = `?client_id=${CLIENT_ID}&scope=${encodeURIComponent(scope)}&sig=s1`;
69+
window.history.replaceState({}, '', `/oauth/consent${search}`);
70+
return render(
71+
<AuthProvider authUrl="/api/v1/auth" client={authClient()}>
72+
<MemoryRouter initialEntries={[`/oauth/consent${search}`]}>
73+
<Routes>
74+
<Route path="/oauth/consent" element={<OAuthConsentPage />} />
75+
</Routes>
76+
</MemoryRouter>
77+
</AuthProvider>,
78+
);
79+
}
80+
81+
/** The consent list's rows, in the order the page draws them. */
82+
async function scopeRows(): Promise<string[]> {
83+
const list = await screen.findByRole('list');
84+
return Array.from(list.querySelectorAll('li')).map((li) => li.textContent ?? '');
85+
}
86+
87+
const UNKNOWN_SCOPE = 'reports:export';
88+
const UNRECOGNISED_EN = en.oauth.consent.scope.unrecognised;
89+
90+
describe('OAuthConsentPage — every MCP scope in words (objectstack#26587)', () => {
91+
it('names each MCP scope in en with the sentence the spec declares for it', async () => {
92+
await renderConsent(MCP_OAUTH_SCOPES.join(' '), 'en');
93+
94+
expect(await scopeRows()).toEqual(
95+
MCP_OAUTH_SCOPES.map((scope) => MCP_OAUTH_SCOPE_CONSENT_COPY[scope]),
96+
);
97+
});
98+
99+
it.each(BUILT_IN_LANGUAGE_CODES.filter((language) => language !== 'en'))(
100+
'names each MCP scope from the %s pack, never as its raw spelling',
101+
async (language) => {
102+
await renderConsent(MCP_OAUTH_SCOPES.join(' '), language);
103+
const rows = await scopeRows();
104+
105+
expect(rows).toHaveLength(MCP_OAUTH_SCOPES.length);
106+
MCP_OAUTH_SCOPES.forEach((scope, index) => {
107+
expect(rows[index]).not.toContain(scope);
108+
// The `en` sentence here would mean the key is missing from this pack
109+
// and the row fell back to English.
110+
expect(rows[index]).not.toBe(MCP_OAUTH_SCOPE_CONSENT_COPY[scope]);
111+
});
112+
},
113+
);
114+
115+
it('announces a scope the page does not name as unrecognised, never as the bare string', async () => {
116+
await renderConsent(`data:read ${UNKNOWN_SCOPE}`, 'en');
117+
118+
expect(await scopeRows()).toEqual([
119+
MCP_OAUTH_SCOPE_CONSENT_COPY['data:read'],
120+
UNRECOGNISED_EN.replace('{{scope}}', UNKNOWN_SCOPE),
121+
]);
122+
});
123+
124+
it('announces an unrecognised scope in the user language too', async () => {
125+
await renderConsent(UNKNOWN_SCOPE, 'zh');
126+
const [row] = await scopeRows();
127+
128+
expect(row).not.toBe(UNKNOWN_SCOPE);
129+
expect(row).toContain(UNKNOWN_SCOPE);
130+
expect(row).not.toBe(UNRECOGNISED_EN.replace('{{scope}}', UNKNOWN_SCOPE));
131+
});
132+
});

‎ui/packages/i18n/src/locales/ar.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2960,6 +2960,8 @@ const ar = {
29602960
dataRead: "قراءة السجلات (ضمن صلاحياتك الخاصة)",
29612961
dataWrite: "إنشاء السجلات وتعديلها وحذفها (ضمن صلاحياتك الخاصة)",
29622962
actionsExecute: "تنفيذ الإجراءات نيابةً عنك (ضمن صلاحياتك الخاصة)",
2963+
metadataWrite: "إنشاء بيانات التعريف لهذا التطبيق وتعديلها كمسودات (ضمن صلاحياتك الخاصة)، ويتولى شخص نشرها",
2964+
unrecognised: "إذن غير معروف: {{scope}}",
29632965
},
29642966
deny: "رفض",
29652967
authorize: "تفويض",

‎ui/packages/i18n/src/locales/de.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2750,6 +2750,8 @@ const de = {
27502750
dataRead: "Datensätze lesen (im Rahmen Ihrer eigenen Berechtigungen)",
27512751
dataWrite: "Datensätze erstellen, ändern und löschen (im Rahmen Ihrer eigenen Berechtigungen)",
27522752
actionsExecute: "Aktionen in Ihrem Namen ausführen (im Rahmen Ihrer eigenen Berechtigungen)",
2753+
metadataWrite: "Die Metadaten dieser App als Entwürfe erstellen und ändern (im Rahmen Ihrer eigenen Berechtigungen) – veröffentlicht werden sie von einer Person",
2754+
unrecognised: "Unbekannte Berechtigung: {{scope}}",
27532755
},
27542756
deny: "Ablehnen",
27552757
authorize: "Autorisieren",

0 commit comments

Comments
 (0)