Skip to content

Commit cadd6aa

Browse files
feat(platform-objects): an "AI Assistants" tab on Setup's user page, for platform administrators (#31125)
<!-- claude-projects-attribution: {"github_login":"marchtian"} --> _Requested by **田騏** · [project thread](https://claude.ai/code/project/chan_01VmuUhXU9SSUzqJYyXSmYSi?thread=cmsg_01VmuUhXU9SSUzqJYyXSmYSi9Hju7DThD7eoLKoJU9hnHj)_ Fixes #31085 Fixes #30894 Clause-②: no Metadata-protocol: none ## 维护者速读 - **改了什么**:Setup 里用户详情页新增「AI 助手」标签页(在 API 密钥旁),内容是已合并的 `mcp:authorized-assistants` 组件:列出此人授权过的 AI 助手(如 Claude Desktop),每条可先确认再撤销。 - **为什么改**:#30894 的最后一块。服务端接口和前端组件都已就位,但没有任何页面挂出来,管理员仍找不到地方替丢了电脑或离职的人收回授权。 - **风险与回滚**:低。只加一个标签页,仅平台管理员可见(`current_user.isPlatformAdmin == true`),服务端权限不变;回滚即 revert 本 PR。 - **先看**:`packages/platform-objects/src/pages/sys-user.page.ts`(新标签项与可见性条件)、`sys-user-ai-assistants-tab.test.ts`(谁能看见的判定)、`canonical-expression-envelopes.test.ts`(为无 props 组件登记的豁免)。 - **两分钟验证**:用平台管理员打开 Setup → 用户 → 任一用户,应看到「AI 助手」标签并列出其授权;换普通组织管理员登录,同一页不出现该标签。 - **需要你做的**:无,席位落地。 ## What changes - `sys_user_detail` (the Setup user page, `packages/platform-objects/src/pages/sys-user.page.ts`) gains one `page:tabs` item, **AI Assistants**, placed after API Keys (both are credentials that act as this user), with the page's five inline label locales (en `AI Assistants`, zh-CN `AI 助手`, ja-JP `AI アシスタント`, es-ES `Asistentes de IA`, zh-Hant `AI 助理`, matching the Account app's existing AI-assistant spellings). Its body is `{ type: 'mcp:authorized-assistants', properties: {} }`, the widget PR #31084 registered. - **Visibility.** The page already has an item-level gate mechanism: the Security tab carries `visibleWhen` in the `P` CEL envelope, which omits the tab header and panel when false (fail-closed on a fault). The new tab uses that same mechanism with the ADR-0095 D3 PLATFORM_ADMIN rung, `current_user.isPlatformAdmin == true`, spelled exactly as the `sys_user` object's platform-admin actions spell it (`platform-admin-affordance-standing.test.ts`'s `STANDING`). The widget's list and Revoke go through `/api/v1/auth/admin/authorized-clients`, which refuses every caller below platform admin, so any other viewer could only see a 403. No new gating key; the server gate is unchanged (triage 6702489619: "the tab widens nothing"). - The page's header comment names the new tab. ## Tests - New `sys-user-ai-assistants-tab.test.ts`: the whole strip in order (the tab sits after API Keys), the five-locale label (and the same locale set as every sibling tab), the body is exactly the widget with `properties: {}`, the gate is the CEL envelope with the standing, and the gate evaluated with `celEngine` under the console's binding (one subject under `current_user` / `user` / `ctx.user` / `os.user`): shown to a platform admin on someone else's record and on their own; not shown to an org owner, org admin, plain member, a tenant-written `platform_admin` position without the rung, or the signed-out identity, on either record. - `sys-user-security-tab-own-record.test.ts`: the "which tabs carry an item gate" pin now names `['Security', 'AI Assistants']`. - `canonical-expression-envelopes.test.ts`: `mcp:authorized-assistants` has no `ComponentPropsMap` row, so door 3 of the envelope audit reported it as unmapped. The detector's header says a consumer records such an exemption in its own assert, with the reason; the exemption is held, not just stated: each exempted node must resolve at its path and author an empty `properties` bag, and a liveness test reds when the type gains a `ComponentPropsMap` row (drop the exemption) or no shipped page mounts it. ## Acceptance notes - `mcp:authorized-assistants` has a UI-side zod arm (`@object-ui/types`) but no `ComponentPropsMap` row in `packages/spec` (its sibling `mcp:connect-agent` has `emptyProps`). The `mcp` namespace is not reserved by the enum, so the type parses on the open arm and `component-type-unknown` does not fire. Adding the spec row would retire the exemption above; it is outside this card's file surface (`packages/platform-objects/**`), so it is noted here, not done. - A non-admin viewing their own record does not see this tab: the admin door refuses them there too. Their self-service list is Connect an Agent in the Account app. ## Verification <details><summary>Package tests, typecheck, lint (head d18d554)</summary> - `pnpm --filter '@objectstack/platform-objects^...' build` :: exit 0 - `pnpm --filter @objectstack/platform-objects build && pnpm --filter @objectstack/platform-objects test && pnpm --filter @objectstack/platform-objects typecheck` :: exit 0 — Test Files 118 passed (118), Tests 1650 passed (1650); `check:test-typecheck: OK` - `eslint --no-inline-config` on the four touched `.ts` files :: exit 0 </details> <details><summary>Ablations (each restored, blob == HEAD, `git diff HEAD` empty)</summary> Run with `node scripts/ablation-replace.mjs` over the three page test files (31 tests): - A1, gate deleted from the tab item: 8 failed (gate envelope, admin shown, all five refused viewers, the gated-tabs pin). - A2, standing read from positions (`'platform_admin' in current_user.positions`): 3 failed (gate envelope, admin shown, tenant-written `platform_admin` position refused). - A3, the widget node authors props (`{ title: 'x' }`): 2 failed (the body pin, and door 3's exemption no longer holding). </details> <details><summary>Derived gates: 60 derived, 60 run, all exit 0 (head d18d554)</summary> `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` derived 60 commands for this diff. Four first exited 3 (prerequisite, nothing measured): `check-plugin-teardown-shape --self-test` (shallow clone; reran after `git fetch --unshallow origin main`), and `check:dual-build-cjs-loads`, `check:i18n`, `check:lean-entry-closure` (no `dist/`; reran after `pnpm exec turbo run build`, 120/120 tasks). Final codes: ```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-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-adoption.mjs :: exit 0 node scripts/check-comment-mask-adoption.mjs --self-test :: exit 0 node scripts/check-comment-mask-corpus.mjs :: exit 0 node scripts/check-dts-emitted.mjs --self-test :: 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-issue-citations.mjs :: exit 0 node scripts/check-plugin-teardown-shape.mjs :: exit 0 node scripts/check-plugin-teardown-shape.mjs --self-test :: exit 0 node scripts/ci/assert-turbo-remote-cache.mjs --self-test :: exit 0 node scripts/ci/plan-test-core-shards.mjs --self-test :: exit 0 node scripts/ci/prune-turbo-cache.mjs --self-test :: exit 0 node scripts/ci/snapshot-next.mjs --self-test :: exit 0 node scripts/docs-audit/check-affected-docs.mjs :: exit 0 node scripts/docs-audit/check-drift-comment.mjs :: exit 0 node scripts/pm/release-rehearsal-clone.mjs --self-test :: exit 0 node scripts/release-pending-publish.mjs --self-test :: 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:doc-authoring :: exit 0 pnpm check:driver-memory-census :: exit 0 pnpm check:dts-closure :: exit 0 pnpm check:dual-build-cjs-loads :: exit 0 pnpm check:engine-double-contract :: exit 0 pnpm check:gitlink-declared :: exit 0 pnpm check:i18n :: exit 0 pnpm check:i18n-stale-fill :: exit 0 pnpm check:issue-citations :: exit 0 pnpm check:keyed-text-bounds :: exit 0 pnpm check:lean-entry-closure :: exit 0 pnpm check:logger-receiver-detach :: exit 0 pnpm check:nul-bytes :: exit 0 pnpm check:objectql-double-limit :: exit 0 pnpm check:org-identifier :: exit 0 pnpm check:page-declaration-shape :: exit 0 pnpm check:platform-object-tenancy-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:registry-log-declared :: exit 0 pnpm check:rest-log-spy-declared :: exit 0 pnpm check:slot-lookup :: exit 0 pnpm check:sourcemap-no-sources-content :: exit 0 pnpm check:system-context-census :: 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:vouch-for-reference-callers :: exit 0 pnpm check:watch-hint-literal :: exit 0 pnpm check:where-matcher :: exit 0 ``` `dispatch-gates --ran`: 60 derived famil(ies) accounted for — 60 run, 0 NOT-MEASURED, 0 UNRUN. </details> 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent a794e32 commit cadd6aa

5 files changed

Lines changed: 217 additions & 6 deletions

File tree

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
---
2+
'@objectstack/platform-objects': patch
3+
---
4+
5+
feat(platform-objects): Setup's user page gains an "AI Assistants" tab, for platform administrators
6+
7+
Clause-②: no
8+
Metadata-protocol: none
9+
10+
- **`@objectstack/platform-objects`**: the `sys_user` record page (`sys_user_detail`) has a new "AI Assistants" tab, beside API Keys, labelled in en, zh-CN, ja-JP, es-ES and zh-Hant. Its body is the console widget `mcp:authorized-assistants`: the AI assistants (OAuth clients such as Claude Desktop) this person authorized, each with a confirm-first Revoke. An administrator can now see and end what acts as a person who cannot sign in to withdraw it themselves (a lost laptop, a departure). The tab is shown only to a platform administrator (`current_user.isPlatformAdmin == true` on the tab item): the widget's list and Revoke go through the platform-admin-gated `/api/v1/auth/admin/authorized-clients` doors, so any other viewer could only see a refusal. The server's gate is unchanged.

‎packages/platform-objects/src/pages/canonical-expression-envelopes.test.ts‎

Lines changed: 40 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@
3838
import { readFileSync, readdirSync } from 'node:fs';
3939
import { join, resolve } from 'node:path';
4040
import { describe, expect, it } from 'vitest';
41-
import type { Page } from '@objectstack/spec/ui';
41+
import { ComponentPropsMap, type Page } from '@objectstack/spec/ui';
4242
import { auditPageExpressionEnvelopes, renderBareExpressionFindings } from '@objectstack/lint';
4343
// The one answer this tree has to "comment, literal, or code". It is a plain
4444
// `.mjs`, but `scripts/js-comment-mask.d.mts` beside it is a hand-written
@@ -159,6 +159,30 @@ function declaredPageExports(): { name: string; file: string }[] {
159159
return out.sort((a, b) => a.name.localeCompare(b.name));
160160
}
161161

162+
/**
163+
* Console-registered widget types with no `ComponentPropsMap` row, each with
164+
* the reason door 3 loses nothing on it (the detector's header: a consumer
165+
* records such an exemption in its own assert, never by ignoring the
166+
* channel). The reason is held, not only stated: every node of an exempted
167+
* type must author an EMPTY `properties` bag, so there is no props-level
168+
* expression key for door 3 to miss. A node that starts authoring props reds
169+
* the test below, and then the type needs its props schema.
170+
*/
171+
const UNMAPPED_TYPE_EXEMPTIONS: Readonly<Record<string, string>> = {
172+
'mcp:authorized-assistants':
173+
'the user page\'s AI Assistants tab body (@object-ui/app-shell): the widget takes no props and reads '
174+
+ 'the bound sys_user record, so its authored `properties` is `{}` and holds no expression key',
175+
};
176+
177+
/** The authored node at an audit path such as `regions[0].components[2].properties.items[10].children[0]`. */
178+
function nodeAt(page: Page, path: string): unknown {
179+
let cur: unknown = page;
180+
for (const seg of path.match(/[^.[\]]+/g) ?? []) {
181+
cur = isRec(cur) || Array.isArray(cur) ? (cur as AnyRec)[seg] : undefined;
182+
}
183+
return cur;
184+
}
185+
162186
const AUDITS = EXPORTED_PAGES.map(([exportName, page]) => ({
163187
exportName,
164188
page,
@@ -205,15 +229,28 @@ describe('platform Page exports serve canonical expression envelopes', () => {
205229
expect(audit.componentCount).toBeGreaterThan(0);
206230
});
207231

208-
it.each(AUDITS)('$exportName: every component type is declared in ComponentPropsMap (door 3 precondition)', ({ audit }) => {
232+
it.each(AUDITS)('$exportName: every component type is declared in ComponentPropsMap (door 3 precondition)', ({ page, audit }) => {
233+
const exempted = audit.unmappedTypes.filter(e => Object.hasOwn(UNMAPPED_TYPE_EXEMPTIONS, e.type));
234+
for (const e of exempted) {
235+
const node = nodeAt(page, e.path);
236+
expect(isRec(node) && node.type === e.type, `${e.path} does not resolve to its ${e.type} node`).toBe(true);
237+
expect((node as AnyRec).properties ?? {}, `${e.path} [${e.type}] authors props, so its exemption no longer holds`).toEqual({});
238+
}
209239
expect(
210-
audit.unmappedTypes.map(e => `${e.path} [${e.type}]`).join('\n'),
240+
audit.unmappedTypes.filter(e => !exempted.includes(e)).map(e => `${e.path} [${e.type}]`).join('\n'),
211241
'door 3 has no props schema for these component types, so any expression key inside '
212242
+ 'their `properties` is unread by this gate. Either declare the props schema, or '
213243
+ 'record the exemption here with the reason.',
214244
).toBe('');
215245
});
216246

247+
it('every unmapped-type exemption is live: the type is still unmapped, and a shipped page still mounts it', () => {
248+
for (const type of Object.keys(UNMAPPED_TYPE_EXEMPTIONS)) {
249+
expect(Object.hasOwn(ComponentPropsMap, type), `${type} now has a ComponentPropsMap row: drop its exemption`).toBe(false);
250+
expect(AUDITS.some(({ audit }) => audit.unmappedTypes.some(e => e.type === type)), `no shipped page mounts ${type}`).toBe(true);
251+
}
252+
});
253+
217254
it.each(AUDITS)('$exportName: every authored `properties` bag parses against its props schema (door 3 precondition)', ({ audit }) => {
218255
expect(
219256
audit.unreadableProps.map(e => `${e.path} [${e.type}]: ${e.issues}`).join('\n'),
Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* #31085 — the user page's "AI Assistants" tab: the place an administrator
5+
* sees, and revokes, the AI assistants a person authorized.
6+
*
7+
* The tab's body is the console widget `mcp:authorized-assistants`, which
8+
* reads the page's bound `sys_user` record and calls
9+
* `/api/v1/auth/admin/authorized-clients`, a door that refuses every caller
10+
* below platform admin. So the tab item carries the ADR-0095 D3 PLATFORM_ADMIN
11+
* rung, in the CEL envelope, the same item-level mechanism the Security tab
12+
* uses: a viewer the door refuses is not shown a tab that can only draw a 403.
13+
*
14+
* Read from the shipped page metadata, so the pin fails if the tab leaves the
15+
* strip or moves, if its body changes, and if its gate is dropped, loses its
16+
* envelope, or starts reading standing from `positions`. The gate is evaluated
17+
* with `@objectstack/formula`'s `celEngine` under the console's binding — the
18+
* whole scope as `extra`, one subject under `current_user` / `user` /
19+
* `ctx.user` / `os.user` — and ⛔ not through `user:`, under which the engine
20+
* re-derives `isPlatformAdmin` from `positions`.
21+
*/
22+
23+
import { describe, expect, it } from 'vitest';
24+
import { celEngine } from '@objectstack/formula';
25+
import { PageSchema } from '@objectstack/spec/ui';
26+
import { SysUserDetailPage } from './sys-user.page.js';
27+
28+
type Rec = Record<string, unknown>;
29+
30+
/** The standing, spelled as the object's platform-admin actions spell it. */
31+
const STANDING = 'current_user.isPlatformAdmin == true';
32+
33+
/** The strip, in order: the new tab sits beside API Keys. */
34+
const STRIP = [
35+
'Details',
36+
'Positions',
37+
'Permission Sets',
38+
'Business Units',
39+
'Sessions',
40+
'Linked Accounts',
41+
'Organizations',
42+
'OAuth Apps',
43+
'Security',
44+
'API Keys',
45+
'AI Assistants',
46+
'Preferences',
47+
];
48+
49+
type Subject = { id: string | null; email: string; isPlatformAdmin: boolean; positions: string[] };
50+
51+
const PLATFORM_ADMIN: Subject = { id: 'u_pa', email: 'pa@example.com', isPlatformAdmin: true, positions: [] };
52+
53+
/** Every viewer the door refuses, the grades first, then the position the rung must never be read from. */
54+
const REFUSED: ReadonlyArray<[string, Subject]> = [
55+
['org owner', { id: 'u_owner', email: 'owner@example.com', isPlatformAdmin: false, positions: ['org_owner'] }],
56+
['org admin', { id: 'u_admin', email: 'admin@example.com', isPlatformAdmin: false, positions: ['org_admin'] }],
57+
['plain member', { id: 'u_member', email: 'member@example.com', isPlatformAdmin: false, positions: ['member'] }],
58+
[
59+
"a tenant-written 'platform_admin' position without the rung",
60+
{ id: 'u_pos', email: 'pos@example.com', isPlatformAdmin: false, positions: ['platform_admin', 'org_owner'] },
61+
],
62+
// The console's signed-out identity (`buildExpressionUser(null)`).
63+
['signed out', { id: null, email: '', isPlatformAdmin: false, positions: [] }],
64+
];
65+
66+
/** The record the tab is drawn on: someone else's, and the viewer's own. */
67+
const records = (subject: Subject): Array<Record<string, unknown>> => [
68+
{ id: 'u_someone_else', email: 'else@example.com' },
69+
{ id: subject.id, email: subject.email },
70+
];
71+
72+
function shown(source: string, subject: Subject, record: Record<string, unknown>): boolean {
73+
const r = celEngine.evaluate(
74+
{ dialect: 'cel', source },
75+
{ record, extra: { current_user: subject, user: subject, ctx: { user: subject }, os: { user: subject } } },
76+
);
77+
return r.ok && r.value === true;
78+
}
79+
80+
describe('sys_user_detail — the AI Assistants tab', () => {
81+
const tabs = (SysUserDetailPage.regions![0]!.components as Rec[]).find((c) => c.type === 'page:tabs') as Rec;
82+
const items = (tabs.properties as Rec).items as Rec[];
83+
const tab = items.find((it) => (it.label as Rec).en === 'AI Assistants')!;
84+
85+
it('parses through PageSchema', () => {
86+
const parsed = PageSchema.safeParse(SysUserDetailPage);
87+
expect(parsed.success, JSON.stringify(parsed.error?.issues ?? [])).toBe(true);
88+
});
89+
90+
it('sits in the strip beside API Keys', () => {
91+
expect(items.map((it) => (it.label as Rec).en)).toEqual(STRIP);
92+
});
93+
94+
it('carries the label in every locale the sibling tabs spell', () => {
95+
expect(tab.label).toEqual({
96+
en: 'AI Assistants',
97+
'zh-CN': 'AI 助手',
98+
'ja-JP': 'AI アシスタント',
99+
'es-ES': 'Asistentes de IA',
100+
'zh-Hant': 'AI 助理',
101+
});
102+
for (const sibling of items) {
103+
expect(Object.keys(sibling.label as Rec).sort(), String((sibling.label as Rec).en)).toEqual(
104+
Object.keys(tab.label as Rec).sort(),
105+
);
106+
}
107+
});
108+
109+
it('mounts the `mcp:authorized-assistants` widget, with no props, as its only child', () => {
110+
expect(tab.children).toEqual([{ type: 'mcp:authorized-assistants', properties: {} }]);
111+
});
112+
113+
it('carries the platform-admin gate on the tab item, in the CEL envelope', () => {
114+
expect(tab.visibleWhen).toEqual({ dialect: 'cel', source: STANDING });
115+
});
116+
117+
it('is shown to a platform administrator, on any user record', () => {
118+
const source = (tab.visibleWhen as { source: string }).source;
119+
for (const record of records(PLATFORM_ADMIN)) {
120+
expect(shown(source, PLATFORM_ADMIN, record), JSON.stringify(record)).toBe(true);
121+
}
122+
});
123+
124+
it.each(REFUSED)('is not shown to %s, on any user record — own record included', (_name, subject) => {
125+
const source = (tab.visibleWhen as { source: string }).source;
126+
for (const record of records(subject)) {
127+
expect(shown(source, subject, record), JSON.stringify(record)).toBe(false);
128+
}
129+
});
130+
});

‎packages/platform-objects/src/pages/sys-user-security-tab-own-record.test.ts‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -41,9 +41,11 @@ describe('sys_user_detail — the Security tab is gated to the viewer\'s own rec
4141
expect(security.visibleWhen).toEqual({ dialect: 'cel', source: OWN_RECORD });
4242
});
4343

44-
it('gates only the Security tab — every other tab item carries no item-level gate', () => {
44+
it('gates only the Security and AI Assistants tabs — every other tab item carries no item-level gate', () => {
45+
// AI Assistants carries the platform-admin gate, not an own-record one:
46+
// pinned in `sys-user-ai-assistants-tab.test.ts`.
4547
const gated = items.filter((it) => it.visibleWhen !== undefined).map((it) => (it.label as Rec).en);
46-
expect(gated).toEqual(['Security']);
48+
expect(gated).toEqual(['Security', 'AI Assistants']);
4749
});
4850

4951
it('names only actions that are themselves own-record on the object', () => {

‎packages/platform-objects/src/pages/sys-user.page.ts‎

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,8 @@ const TWO_FACTOR_OFFERED = P`has(features.twoFactor) && features.twoFactor == tr
6060
* - The other tabs are **explicitly curated** to the related lists
6161
* that matter on a user profile (Positions / Permission Sets / Business
6262
* Units / Sessions / Linked Accounts / Organizations / OAuth Apps / API
63-
* Keys / Preferences) plus a Security tab. The synthesized strip would
63+
* Keys / Preferences) plus a Security tab and, for platform
64+
* administrators only, an AI Assistants tab. The synthesized strip would
6465
* carry a tab per object that has a FK to sys_user
6566
* (sys_position.created_by, sys_email.updated_by,
6667
* sys_email_template.created_by, …), dozens of noisy "查看全部" cards
@@ -610,6 +611,37 @@ export const SysUserDetailPage: Page = {
610611
},
611612
],
612613
},
614+
// ── AI Assistants ─────────────────────────────────────────
615+
// The AI assistants (OAuth clients such as Claude Desktop) this
616+
// person authorized, each with a confirm-first Revoke: where an
617+
// administrator ends what acts as someone who cannot sign in to
618+
// withdraw it themselves (a lost laptop, a departure). Beside API
619+
// Keys, the other credential that acts as this user.
620+
//
621+
// The body is the console widget `mcp:authorized-assistants`
622+
// (`@object-ui/app-shell`). It takes no props and reads the page's
623+
// bound `sys_user` record; its list and its Revoke call
624+
// `/api/v1/auth/admin/authorized-clients`, which refuses every
625+
// caller below platform admin (the ADR-0068 platform-admin gate).
626+
//
627+
// So the tab item carries the ADR-0095 D3 PLATFORM_ADMIN rung, the
628+
// standing that door admits, spelled as the object's platform-admin
629+
// actions spell it: shown to anyone else, the tab could only draw
630+
// the server's 403. An item-level `visibleWhen` omits the tab
631+
// header AND panel, the mechanism the Security tab uses. ⛔ Never
632+
// `'platform_admin' in current_user.positions`: standing is read
633+
// from the key, and a tenant can write a position by that name.
634+
// The gate widens and narrows nothing on the server, which refuses
635+
// anyone else either way. Same `P` envelope as the Security tab:
636+
// this raw `Page` literal reaches the wire verbatim.
637+
{
638+
label: { en: 'AI Assistants', 'zh-CN': 'AI 助手', 'ja-JP': 'AI アシスタント', 'es-ES': 'Asistentes de IA', 'zh-Hant': 'AI 助理' },
639+
icon: 'bot',
640+
visibleWhen: P`current_user.isPlatformAdmin == true`,
641+
children: [
642+
{ type: 'mcp:authorized-assistants', properties: {} },
643+
],
644+
},
613645
// ── Preferences ───────────────────────────────────────────
614646
// [#23803] This user's `sys_user_preference` rows — the console's
615647
// per-user UI state (`ui.recent`, `ui.favorites`, `ui.list.<object>`

0 commit comments

Comments
 (0)