Skip to content

Commit dca5bd3

Browse files
qq9340100claude
andauthored
feat(runtime)!: 退役 ctx.user 的 roles 别名,positions 成为唯一拼法 (#6011) (#6048)
ActorUser 同时声明并发出 `positions` 与 `roles`,两者由同一次赋值产生、值 逐字相同。`roles` 是 ADR-0090 D3 保留并禁用的词,且从未有关闭日期 —— 与 #5613 给 ctx.session 装上迁移窗口的同族面相比,这一面既无 spec 契约、也无 ADR-0087 台账条目、也无移除路径。维护者 2026-08-06 就地裁决:立即退役, 不设弃用窗口、不双发。 消费方核实(删除的前置条件,因为旧注释声称该别名 "kept for the REST/AI shapes"):全仓 + objectui 扫描未见任何活体读取 `ActorUser.roles` 的代码。 四个生产点(action-execution / dispatcher-plugin / domains.actions / domains.ai)构造的都是服务端 body / 路由处理器信封,不进入任何响应体; objectui 的 `.roles` 读取全部落在 better-auth 会话与 `/api/v1/auth/me/permissions` 两个独立生产者上。 钉子同批翻转而非删除:三处断言改为既断言新语义的实质(`positions` 逐字 承载),又钉住方向(`roles` 键不存在)。反向验证已做:恢复被删的构造分支 后 7 条断言转红,方向与预期一致。 ⛔ 不含 ctx.session:该面按 #5613 保留其弃用窗口,双发不动。 ⛔ 不含 packages/spec:ADR-0087 台账条目另行落地。 Claude-Session: https://claude.ai/code/session_01Wbxm29qPKnLf44AbSxizqW Co-authored-by: Claude <noreply@anthropic.com>
1 parent d1151ac commit dca5bd3

5 files changed

Lines changed: 89 additions & 13 deletions

File tree

.changeset/tidy-donkeys-yawn.md

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
---
2+
'@objectstack/runtime': major
3+
---
4+
5+
**BREAKING**: `ctx.user.roles` 已移除 —— action body / AI 路由处理器上的调用者位置(positions)只保留一个拼法 `ctx.user.positions`(#6011)
6+
7+
`ActorUser`(action body 的 `ctx.user`、AI 路由处理器的 `req.user`)过去同时发出两个键,值完全相同(`roles``positions` 的逐字副本)。`roles` 是 ADR-0090 D3 明令保留并禁用的词,且没有关闭日期 —— 维护者 2026-08-06 裁定**立即退役**,不设弃用窗口、不双发。
8+
9+
### 迁移:FROM → TO
10+
11+
```js
12+
// FROM — v17 起该键不再存在,读到的是 undefined
13+
const positions = ctx.user.roles;
14+
if (ctx.user.roles.includes('sales_rep')) { … }
15+
16+
// TO — 权威拼法,值逐字不变
17+
const positions = ctx.user.positions;
18+
if (ctx.user.positions.includes('sales_rep')) { … }
19+
```
20+
21+
一行修复:把 body / 路由处理器里的 `ctx.user.roles` 改写成 `ctx.user.positions`(`req.user.roles``req.user.positions`)。**值不变** —— 两个键此前由同一次赋值产生,所以这是一次纯粹的改键,不是改语义。`positions` 数组恒存在,空时是 `[]` 而非 `undefined`,无需 `?? []`
22+
23+
### ⚠️ 改键不等于修好了权限判断
24+
25+
`positions` 与此前的 `roles` 一样,**都不是授权输入**。权限由 security service 判定(capability 授予、placement、ADR-0095 推导出的 posture),不由名字字符串比较判定。因此:
26+
27+
```js
28+
// 这不是迁移,这是把缺陷换了个拼法
29+
if (ctx.user.roles.includes('admin')) { … } // 旧的错
30+
if (ctx.user.positions.includes('admin')) { … } // 一样错,只是改了键名
31+
```
32+
33+
`roles.includes('admin')` 改写成 `positions.includes('admin')` 迁移的是**缺陷本身**,不是那次读取。这类判断应改为向 security service 询问能力,而不是比对位置名。(与 #5991`ctx.session` 更名同一告诫。)
34+
35+
### 不受影响的面
36+
37+
- **`ctx.session.roles` 不在本次范围内**,仍按 #5613 的弃用窗口双发 `positions` + `roles`,由 ADR-0087 语义迁移 `action-session-roles-to-positions` 约定其关闭时点。两个面同名不同物,请勿混为一谈。
38+
- better-auth 会话上的 `user.roles``/api/v1/auth/me/permissions` 返回体的 `roles`、CEL/formula 的 `current_user.*`,都是各自独立的生产者,均未改动。

packages/runtime/src/action-ctx-user-shape.test.ts

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -294,7 +294,7 @@ describe('#5372 — the SHAPE: one key set across every producer', () => {
294294
// the id/name aliases the dispatch surfaces already published.
295295
expect(keys(rest)).toEqual([
296296
'displayName', 'email', 'id', 'isPlatformAdmin', 'name', 'organizationId',
297-
'permissions', 'positions', 'roles', 'systemPermissions', 'userId',
297+
'permissions', 'positions', 'systemPermissions', 'userId',
298298
]);
299299
});
300300

@@ -312,7 +312,6 @@ describe('#5372 — the SHAPE: one key set across every producer', () => {
312312
displayName: 'Dev Admin',
313313
email: 'admin@objectos.ai',
314314
positions: ['platform_admin'],
315-
roles: ['platform_admin'],
316315
// Derived by `createEvalUser`, never stored — ADR-0068 D2.
317316
isPlatformAdmin: true,
318317
permissions: ['admin_full_access'],
@@ -321,10 +320,23 @@ describe('#5372 — the SHAPE: one key set across every producer', () => {
321320
});
322321
});
323322

324-
it('the ADR-0090 position aliases stay in lockstep (`roles` is `positions`)', async () => {
323+
it('publishes positions under ONE spelling — the `roles` alias is gone (#6011)', async () => {
324+
// REPLACED, not deleted. This pin used to assert the two spellings
325+
// stayed "in lockstep"; the maintainer's 2026-08-06 ruling closed the
326+
// alias outright (direction 2, immediate retirement — not a
327+
// deprecation window), so a lockstep assertion would now pin the
328+
// removed limb. Deleting it outright would have been worse: the
329+
// substance it guarded (positions reaches the body verbatim) would
330+
// have gone unguarded on this surface. So it asserts BOTH halves —
331+
// what the surviving key carries, and that the retired one is absent.
325332
const { actionCtx } = await dispatchRest(makeEc({ positions: ['sales_rep'] }), makeQl(DEV_ADMIN));
326333

334+
// Substance: the canonical key carries the caller's positions verbatim.
327335
expect(actionCtx.user.positions).toEqual(['sales_rep']);
328-
expect(actionCtx.user.roles).toEqual(actionCtx.user.positions);
336+
// Direction: the retired spelling is ABSENT — not present-and-empty,
337+
// which is what a half-done removal (dropped value, surviving key)
338+
// would leave behind and what `toBeUndefined()` alone cannot tell apart.
339+
expect('roles' in actionCtx.user).toBe(false);
340+
expect(Object.keys(actionCtx.user)).not.toContain('roles');
329341
});
330342
});

packages/runtime/src/domains/ai-request-user-capability-channel.test.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -126,7 +126,12 @@ describe('#4705 — /ai/* req.user carries the capability channel', () => {
126126
// Verbatim — the set-name channel is untouched by the addition, and
127127
// `ai_seat` (synthesized by resolveExecutionContext) still rides it.
128128
expect(user.permissions).toEqual(['admin_full_access', 'ai_seat']);
129-
expect(user.roles).toEqual(['platform_admin']);
129+
// The position channel, under its one canonical spelling — the `roles`
130+
// alias this line used to read was retired in #6011. Substance kept:
131+
// positions are still a THIRD channel, unmerged with either permission
132+
// list, which is what this case exists to prove.
133+
expect(user.positions).toEqual(['platform_admin']);
134+
expect('roles' in user).toBe(false);
130135
// …and neither list has absorbed the other. A capability must NOT be
131136
// readable off `permissions`, nor a set name off `systemPermissions`:
132137
// 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', () => {
247252
expect(seen.user.displayName).toBe('Admin');
248253
expect(Object.keys(seen.user).sort()).toEqual([
249254
'displayName', 'email', 'id', 'isPlatformAdmin', 'name', 'organizationId',
250-
'permissions', 'positions', 'roles', 'systemPermissions', 'userId',
255+
'permissions', 'positions', 'systemPermissions', 'userId',
251256
]);
252257
});
253258

packages/runtime/src/http-dispatcher.test.ts

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3608,7 +3608,7 @@ describe('HttpDispatcher — action body ctx.user identity (#2701)', () => {
36083608
const actionUser = (executeAction: any) => executeAction.mock.calls[0]?.[2]?.user;
36093609
const actionSession = (executeAction: any) => executeAction.mock.calls[0]?.[2]?.session;
36103610

3611-
it('forwards the session user id + business roles to the action body (not `system`)', async () => {
3611+
it('forwards the session user id + positions to the action body (not `system`)', async () => {
36123612
const { dispatcher, executeAction, ctx } = captureCtx({
36133613
userId: 'user_42',
36143614
positions: ['sales_rep', 'org_member'],
@@ -3619,8 +3619,14 @@ describe('HttpDispatcher — action body ctx.user identity (#2701)', () => {
36193619
await dispatcher.handleActions('/lead/convert', 'POST', {}, ctx);
36203620
const user = actionUser(executeAction);
36213621
expect(user.id).toBe('user_42');
3622-
expect(user.roles).toEqual(['sales_rep', 'org_member']);
36233622
expect(user.positions).toEqual(['sales_rep', 'org_member']);
3623+
// #6011 retired the `roles` alias on ctx.user outright (ADR-0090 D3's
3624+
// banned spelling; no consumer read it). The assertion that used to sit
3625+
// here read `user.roles` and is replaced by its inverse rather than
3626+
// dropped, so a re-added alias fails HERE and not only in the shape test.
3627+
// ⚠️ ctx.session is a DIFFERENT face and still dual-emits `roles` for
3628+
// #5613's deprecation window — see the test three cases below.
3629+
expect('roles' in user).toBe(false);
36243630
expect(user.permissions).toEqual(['convert_lead']);
36253631
expect(user.email).toBe('rep@acme.test');
36263632
// #3280 made `organizationId` the blessed name; the `tenantId` alias was
@@ -3682,8 +3688,11 @@ describe('HttpDispatcher — action body ctx.user identity (#2701)', () => {
36823688
await selfInvoked.dispatcher.handleActions('/lead/convert', 'POST', {}, selfInvoked.ctx);
36833689
const user = actionUser(selfInvoked.executeAction);
36843690
expect(user.id).toBe('system');
3685-
expect(user.roles).toEqual([]);
36863691
expect(user.positions).toEqual([]);
3692+
// The retired alias stays absent on the system principal too (#6011) — a
3693+
// partial removal that left `roles: []` here would still satisfy a
3694+
// `toEqual([])` pin, which is why this asserts the key, not the value.
3695+
expect('roles' in user).toBe(false);
36873696
// No resolved caller → no session (parity with the hook surface).
36883697
expect(actionSession(selfInvoked.executeAction)).toBeUndefined();
36893698

packages/runtime/src/security/actor-user.ts

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -92,10 +92,23 @@ export interface ActorUser extends EvalUser {
9292
name: string;
9393
/** Alias of {@link name} — the spelling AI route handlers already read. */
9494
displayName: string;
95-
/** ADR-0090 position names held by the caller (canonical; `EvalUser.positions`). */
95+
/**
96+
* ADR-0090 position names held by the caller (canonical; `EvalUser.positions`).
97+
*
98+
* The pre-ADR-0090 `roles` alias of this key was REMOVED in v17 (#6011).
99+
* It carried `positions` verbatim under a word ADR-0090 D3 reserves and
100+
* bans, and no consumer read it — the "kept for the REST/AI shapes" claim
101+
* its comment made was checked and disproven at removal time (the REST/AI
102+
* shapes are BUILT here, but nothing downstream read `.roles` off them).
103+
* A body that used to read `ctx.user.roles` reads `ctx.user.positions`.
104+
*
105+
* ⚠️ Do not restore it as a `??` fallback in a consumer: `positions` is the
106+
* one spelling this surface publishes, and a second de-facto spelling is
107+
* exactly the state #5613's ruling called a defect rather than an endpoint.
108+
* (`ctx.session` is a DIFFERENT face and keeps its own deprecation window —
109+
* see `buildActionSession` in `action-execution.ts`.)
110+
*/
96111
positions: string[];
97-
/** Legacy alias of {@link positions} (pre-ADR-0090 spelling, kept for the REST/AI shapes). */
98-
roles: string[];
99112
/** Permission-SET names (`admin_full_access`, `ai_seat`, …). */
100113
permissions: string[];
101114
/** CAPABILITIES (`manage_metadata`, `studio.access`, …) — a separate channel (#4705). */
@@ -205,7 +218,6 @@ export function buildActorUser(input?: {
205218
userId: id,
206219
displayName: name,
207220
positions: core.positions,
208-
roles: core.positions,
209221
permissions: anonymous ? [] : strings(input?.permissions),
210222
systemPermissions: anonymous ? [] : strings(input?.systemPermissions),
211223
};

0 commit comments

Comments
 (0)