diff --git a/.changeset/tidy-donkeys-yawn.md b/.changeset/tidy-donkeys-yawn.md new file mode 100644 index 0000000000..d095fedf6b --- /dev/null +++ b/.changeset/tidy-donkeys-yawn.md @@ -0,0 +1,38 @@ +--- +'@objectstack/runtime': major +--- + +**BREAKING**: `ctx.user.roles` 已移除 —— action body / AI 路由处理器上的调用者位置(positions)只保留一个拼法 `ctx.user.positions`(#6011) + +`ActorUser`(action body 的 `ctx.user`、AI 路由处理器的 `req.user`)过去同时发出两个键,值完全相同(`roles` 是 `positions` 的逐字副本)。`roles` 是 ADR-0090 D3 明令保留并禁用的词,且没有关闭日期 —— 维护者 2026-08-06 裁定**立即退役**,不设弃用窗口、不双发。 + +### 迁移:FROM → TO + +```js +// FROM — v17 起该键不再存在,读到的是 undefined +const positions = ctx.user.roles; +if (ctx.user.roles.includes('sales_rep')) { … } + +// TO — 权威拼法,值逐字不变 +const positions = ctx.user.positions; +if (ctx.user.positions.includes('sales_rep')) { … } +``` + +一行修复:把 body / 路由处理器里的 `ctx.user.roles` 改写成 `ctx.user.positions`(`req.user.roles` → `req.user.positions`)。**值不变** —— 两个键此前由同一次赋值产生,所以这是一次纯粹的改键,不是改语义。`positions` 数组恒存在,空时是 `[]` 而非 `undefined`,无需 `?? []`。 + +### ⚠️ 改键不等于修好了权限判断 + +`positions` 与此前的 `roles` 一样,**都不是授权输入**。权限由 security service 判定(capability 授予、placement、ADR-0095 推导出的 posture),不由名字字符串比较判定。因此: + +```js +// 这不是迁移,这是把缺陷换了个拼法 +if (ctx.user.roles.includes('admin')) { … } // 旧的错 +if (ctx.user.positions.includes('admin')) { … } // 一样错,只是改了键名 +``` + +把 `roles.includes('admin')` 改写成 `positions.includes('admin')` 迁移的是**缺陷本身**,不是那次读取。这类判断应改为向 security service 询问能力,而不是比对位置名。(与 #5991 的 `ctx.session` 更名同一告诫。) + +### 不受影响的面 + +- **`ctx.session.roles` 不在本次范围内**,仍按 #5613 的弃用窗口双发 `positions` + `roles`,由 ADR-0087 语义迁移 `action-session-roles-to-positions` 约定其关闭时点。两个面同名不同物,请勿混为一谈。 +- better-auth 会话上的 `user.roles`、`/api/v1/auth/me/permissions` 返回体的 `roles`、CEL/formula 的 `current_user.*`,都是各自独立的生产者,均未改动。 diff --git a/packages/runtime/src/action-ctx-user-shape.test.ts b/packages/runtime/src/action-ctx-user-shape.test.ts index 31de0fb915..826146ab96 100644 --- a/packages/runtime/src/action-ctx-user-shape.test.ts +++ b/packages/runtime/src/action-ctx-user-shape.test.ts @@ -294,7 +294,7 @@ describe('#5372 — the SHAPE: one key set across every producer', () => { // the id/name aliases the dispatch surfaces already published. expect(keys(rest)).toEqual([ 'displayName', 'email', 'id', 'isPlatformAdmin', 'name', 'organizationId', - 'permissions', 'positions', 'roles', 'systemPermissions', 'userId', + 'permissions', 'positions', 'systemPermissions', 'userId', ]); }); @@ -312,7 +312,6 @@ describe('#5372 — the SHAPE: one key set across every producer', () => { displayName: 'Dev Admin', email: 'admin@objectos.ai', positions: ['platform_admin'], - roles: ['platform_admin'], // Derived by `createEvalUser`, never stored — ADR-0068 D2. isPlatformAdmin: true, permissions: ['admin_full_access'], @@ -321,10 +320,23 @@ describe('#5372 — the SHAPE: one key set across every producer', () => { }); }); - it('the ADR-0090 position aliases stay in lockstep (`roles` is `positions`)', async () => { + it('publishes positions under ONE spelling — the `roles` alias is gone (#6011)', async () => { + // REPLACED, not deleted. This pin used to assert the two spellings + // stayed "in lockstep"; the maintainer's 2026-08-06 ruling closed the + // alias outright (direction 2, immediate retirement — not a + // deprecation window), so a lockstep assertion would now pin the + // removed limb. Deleting it outright would have been worse: the + // substance it guarded (positions reaches the body verbatim) would + // have gone unguarded on this surface. So it asserts BOTH halves — + // what the surviving key carries, and that the retired one is absent. const { actionCtx } = await dispatchRest(makeEc({ positions: ['sales_rep'] }), makeQl(DEV_ADMIN)); + // Substance: the canonical key carries the caller's positions verbatim. expect(actionCtx.user.positions).toEqual(['sales_rep']); - expect(actionCtx.user.roles).toEqual(actionCtx.user.positions); + // Direction: the retired spelling is ABSENT — not present-and-empty, + // which is what a half-done removal (dropped value, surviving key) + // would leave behind and what `toBeUndefined()` alone cannot tell apart. + expect('roles' in actionCtx.user).toBe(false); + expect(Object.keys(actionCtx.user)).not.toContain('roles'); }); }); diff --git a/packages/runtime/src/domains/ai-request-user-capability-channel.test.ts b/packages/runtime/src/domains/ai-request-user-capability-channel.test.ts index 8a0d7970bc..1bfd33e4b6 100644 --- a/packages/runtime/src/domains/ai-request-user-capability-channel.test.ts +++ b/packages/runtime/src/domains/ai-request-user-capability-channel.test.ts @@ -126,7 +126,12 @@ describe('#4705 — /ai/* req.user carries the capability channel', () => { // Verbatim — the set-name channel is untouched by the addition, and // `ai_seat` (synthesized by resolveExecutionContext) still rides it. expect(user.permissions).toEqual(['admin_full_access', 'ai_seat']); - expect(user.roles).toEqual(['platform_admin']); + // The position channel, under its one canonical spelling — the `roles` + // alias this line used to read was retired in #6011. Substance kept: + // positions are still a THIRD channel, unmerged with either permission + // list, which is what this case exists to prove. + expect(user.positions).toEqual(['platform_admin']); + expect('roles' in user).toBe(false); // …and neither list has absorbed the other. A capability must NOT be // readable off `permissions`, nor a set name off `systemPermissions`: // that conflation is the failure mode this issue exists to prevent. @@ -247,7 +252,7 @@ describe('#4705 — the concrete-mount producer agrees on the shape', () => { expect(seen.user.displayName).toBe('Admin'); expect(Object.keys(seen.user).sort()).toEqual([ 'displayName', 'email', 'id', 'isPlatformAdmin', 'name', 'organizationId', - 'permissions', 'positions', 'roles', 'systemPermissions', 'userId', + 'permissions', 'positions', 'systemPermissions', 'userId', ]); }); diff --git a/packages/runtime/src/http-dispatcher.test.ts b/packages/runtime/src/http-dispatcher.test.ts index 7378be410c..ce79406c53 100644 --- a/packages/runtime/src/http-dispatcher.test.ts +++ b/packages/runtime/src/http-dispatcher.test.ts @@ -3608,7 +3608,7 @@ describe('HttpDispatcher — action body ctx.user identity (#2701)', () => { const actionUser = (executeAction: any) => executeAction.mock.calls[0]?.[2]?.user; const actionSession = (executeAction: any) => executeAction.mock.calls[0]?.[2]?.session; - it('forwards the session user id + business roles to the action body (not `system`)', async () => { + it('forwards the session user id + positions to the action body (not `system`)', async () => { const { dispatcher, executeAction, ctx } = captureCtx({ userId: 'user_42', positions: ['sales_rep', 'org_member'], @@ -3619,8 +3619,14 @@ describe('HttpDispatcher — action body ctx.user identity (#2701)', () => { await dispatcher.handleActions('/lead/convert', 'POST', {}, ctx); const user = actionUser(executeAction); expect(user.id).toBe('user_42'); - expect(user.roles).toEqual(['sales_rep', 'org_member']); expect(user.positions).toEqual(['sales_rep', 'org_member']); + // #6011 retired the `roles` alias on ctx.user outright (ADR-0090 D3's + // banned spelling; no consumer read it). The assertion that used to sit + // here read `user.roles` and is replaced by its inverse rather than + // dropped, so a re-added alias fails HERE and not only in the shape test. + // ⚠️ ctx.session is a DIFFERENT face and still dual-emits `roles` for + // #5613's deprecation window — see the test three cases below. + expect('roles' in user).toBe(false); expect(user.permissions).toEqual(['convert_lead']); expect(user.email).toBe('rep@acme.test'); // #3280 made `organizationId` the blessed name; the `tenantId` alias was @@ -3682,8 +3688,11 @@ describe('HttpDispatcher — action body ctx.user identity (#2701)', () => { await selfInvoked.dispatcher.handleActions('/lead/convert', 'POST', {}, selfInvoked.ctx); const user = actionUser(selfInvoked.executeAction); expect(user.id).toBe('system'); - expect(user.roles).toEqual([]); expect(user.positions).toEqual([]); + // The retired alias stays absent on the system principal too (#6011) — a + // partial removal that left `roles: []` here would still satisfy a + // `toEqual([])` pin, which is why this asserts the key, not the value. + expect('roles' in user).toBe(false); // No resolved caller → no session (parity with the hook surface). expect(actionSession(selfInvoked.executeAction)).toBeUndefined(); diff --git a/packages/runtime/src/security/actor-user.ts b/packages/runtime/src/security/actor-user.ts index 37e4f30614..839e79966b 100644 --- a/packages/runtime/src/security/actor-user.ts +++ b/packages/runtime/src/security/actor-user.ts @@ -92,10 +92,23 @@ export interface ActorUser extends EvalUser { name: string; /** Alias of {@link name} — the spelling AI route handlers already read. */ displayName: string; - /** ADR-0090 position names held by the caller (canonical; `EvalUser.positions`). */ + /** + * ADR-0090 position names held by the caller (canonical; `EvalUser.positions`). + * + * The pre-ADR-0090 `roles` alias of this key was REMOVED in v17 (#6011). + * It carried `positions` verbatim under a word ADR-0090 D3 reserves and + * bans, and no consumer read it — the "kept for the REST/AI shapes" claim + * its comment made was checked and disproven at removal time (the REST/AI + * shapes are BUILT here, but nothing downstream read `.roles` off them). + * A body that used to read `ctx.user.roles` reads `ctx.user.positions`. + * + * ⚠️ Do not restore it as a `??` fallback in a consumer: `positions` is the + * one spelling this surface publishes, and a second de-facto spelling is + * exactly the state #5613's ruling called a defect rather than an endpoint. + * (`ctx.session` is a DIFFERENT face and keeps its own deprecation window — + * see `buildActionSession` in `action-execution.ts`.) + */ positions: string[]; - /** Legacy alias of {@link positions} (pre-ADR-0090 spelling, kept for the REST/AI shapes). */ - roles: string[]; /** Permission-SET names (`admin_full_access`, `ai_seat`, …). */ permissions: string[]; /** CAPABILITIES (`manage_metadata`, `studio.access`, …) — a separate channel (#4705). */ @@ -205,7 +218,6 @@ export function buildActorUser(input?: { userId: id, displayName: name, positions: core.positions, - roles: core.positions, permissions: anonymous ? [] : strings(input?.permissions), systemPermissions: anonymous ? [] : strings(input?.systemPermissions), };