Skip to content

Commit c51ffa5

Browse files
Jack Qclaude
andauthored
feat(runtime): ScriptContext.user 由 unknown 收窄为 ScriptUser 联合 (#5521) (#6295)
沙箱接缝的 user 字段此前是 `unknown`,类型系统对它一无所知 —— 第四个 dispatch 面手搓一个 user 字面量,编译器不会说一句话,而这正是 #5372 三种形状能存在几个 版本的部分原因:没有任何声明可以违背。 现在 `user?: ScriptUser`,`ScriptUser = ActorUser | HookContext['user']`,是两个 实测真实生产者形状的联合,与 33 行外的姊妹字段 ScriptSession(#5613 / #5991)同构。 刻意不收成单一类型:hook 侧的 buildUser() 快捷方式不带 positions / permissions / systemPermissions,收成 ActorUser 会断言一套 hook 面从未生产过的授权词汇;也不收成 spec 的 EvalUser(issue 选项 1)—— 实测 buildUser() 根本不产 positions,而 EvalUser 要求它。 两个写入方的 `?? …session?.user` 兜底链未逼出联合第三支:两种 session 形状均未声明 也未生产 user 键(#4984 死肢家族),该死肢另行立单,本 PR 不动运行时表达式。 类型钉子放在 src 下的非测试文件 script-user-type-assertions.ts —— runtime 的 tsconfig 排除测试文件,钉在测试里就是 PINS_CHECKED 所说的 phantom check。 Claude-Session: https://claude.ai/code/session_01Wbxm29qPKnLf44AbSxizqW Co-authored-by: Claude <noreply@anthropic.com>
1 parent db59e9c commit c51ffa5

6 files changed

Lines changed: 321 additions & 1 deletion

File tree

‎.changeset/light-berries-tickle.md‎

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
---
2+
'@objectstack/runtime': patch
3+
---
4+
5+
sandbox: `ScriptContext.user` 由 `unknown` 收窄为命名联合 `ScriptUser`(#5521)
6+
7+
沙箱接缝 `ScriptContext`(`packages/runtime/src/sandbox/script-runner.ts`)把交给 hook /
8+
action body 的调用者声明为 `user?: unknown`,类型系统对这个字段一无所知 —— 第四个
9+
dispatch 面明天再手搓一个 user 字面量,编译器不会说一句话。而"三个 dispatcher 手搓出三种
10+
形状"正是 #5372 的成因:它能存在几个版本,部分原因就是没有任何声明可以违背。
11+
12+
现在它是 `user?: ScriptUser`,`ScriptUser = ActorUser | HookContext['user']` —— 两个**实测
13+
的真实生产者形状**的联合,与 33 行外的姊妹字段 `ScriptSession`(#5613 / #5991)同构:
14+
15+
- action body 收 `ActorUser`(`security/actor-user.ts`,#5372 起的唯一生产者,#6011 后
16+
`positions` 为唯一拼法);
17+
- hook body 收 `HookContext['user']`(ObjectQL `buildUser()` 的 `session.userId` 快捷方式:
18+
`id` / `name` / `email` / `organizationId`,全部可选)。
19+
20+
刻意**不**收成单一类型:hook 快捷方式不带 `positions` / `permissions` / `systemPermissions`,
21+
收成 `ActorUser` 会在 hook 面断言一套它从未生产过的授权词汇;也**不**收成 spec 的
22+
`EvalUser`(issue 选项 1)—— 实测 `buildUser()` 根本不产 `positions`,而 `EvalUser` 要求它,
23+
那是套着 spec 外衣的同一种过度声明。
24+
25+
行为零变化:两个写入方从 `any` 引擎上下文赋值,唯一的 VM 侧读取方收 `unknown`。TS 消费者
26+
可见,故走 patch。`ActorUser` 同时作为**类型**从包入口导出,使联合的两支都可被消费者命名。

‎packages/runtime/src/index.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,7 @@ export {
100100
type RateLimitKeyInput,
101101
type RateLimitKeyKind,
102102
type RateLimitLogger,
103+
type ActorUser,
103104
} from './security/index.js';
104105

105106
// ── Observability primitives ──────────────────────────────────────────
@@ -157,6 +158,7 @@ export type {
157158
ScriptResult,
158159
ScriptRunOptions,
159160
ScriptSession,
161+
ScriptUser,
160162
QuickJSScriptRunnerOptions,
161163
} from './sandbox/index.js';
162164

‎packages/runtime/src/sandbox/index.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ export type {
1010
ScriptResult,
1111
ScriptRunOptions,
1212
ScriptSession,
13+
ScriptUser,
1314
} from './script-runner.js';
1415
export { QuickJSScriptRunner, SandboxError } from './quickjs-runner.js';
1516
export type { QuickJSScriptRunnerOptions } from './quickjs-runner.js';

‎packages/runtime/src/sandbox/script-runner.ts‎

Lines changed: 83 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,8 @@
3838
import type { HookBody, ScriptBody, ExpressionBody, HookContext } from '@objectstack/spec/data';
3939
import type { ActionSession } from '@objectstack/spec/ui';
4040

41+
import type { ActorUser } from '../security/actor-user.js';
42+
4143
/**
4244
* The caller session a sandboxed body receives on `ctx.session` — the union of
4345
* the two DECLARED producer shapes this one seam carries (#5613).
@@ -53,6 +55,57 @@ import type { ActionSession } from '@objectstack/spec/ui';
5355
*/
5456
export type ScriptSession = ActionSession | HookContext['session'];
5557

58+
/**
59+
* The caller a sandboxed body receives on `ctx.user` — the union of the two
60+
* REAL producer shapes this one seam carries (#5521).
61+
*
62+
* Same construction, and for the same reason, as {@link ScriptSession}
63+
* (#5613/#5991) one field over: the seam is genuinely generic over both body
64+
* kinds, so collapsing it to either single type would be a contract lie in the
65+
* other direction.
66+
*
67+
* - an ACTION body gets {@link ActorUser} — the ONE producer of the dispatch
68+
* user shape (`../security/actor-user.ts`), built through the spec's
69+
* `createEvalUser` factory and shared by REST `/actions`, MCP `run_action`
70+
* and the AI routes since #5372. Every key is present with a defined value
71+
* except `email` / `organizationId`;
72+
* - a HOOK body gets `HookContext['user']` (`@objectstack/spec/data`) —
73+
* ObjectQL's `buildUser()` shortcut, whose whole key set is
74+
* `id` / `name` / `email` / `organizationId`, every one of them optional.
75+
*
76+
* The two do NOT converge, which is exactly why this is a union and not
77+
* `ActorUser`: the hook shortcut carries no `positions`, no `permissions`, no
78+
* `systemPermissions` and no `userId` / `displayName` alias, so declaring
79+
* `ActorUser` alone here would assert an authority vocabulary the hook path has
80+
* never produced — the "one key, two realities" defect #5613 exists to close,
81+
* pointed at the other field.
82+
*
83+
* ⚠️ It is NOT `EvalUser` either, and that was measured rather than assumed.
84+
* `EvalUser` (ADR-0068 D1) is what the issue's option 1 proposed as the
85+
* "minimum common denominator", but it requires `id: string` and
86+
* `positions: string[]`, and `buildUser()` (`packages/objectql/src/engine.ts`)
87+
* emits neither guarantee — no `positions` key at all. So `EvalUser` is a
88+
* SUPERSET of what the hook side delivers, and declaring it would have been the
89+
* same over-claim in a spec-shaped disguise. `ActorUser extends EvalUser`, so
90+
* the action arm still carries the ADR-0068 contract on the path that has it.
91+
*
92+
* The `?? …session?.user` fallback chain both writers carry (`body-runner.ts`
93+
* `:315` / `:340`) forces no THIRD arm — measured, not presumed: neither
94+
* session shape reaching this seam declares a `user` key
95+
* (`HookContext['session']`, `ActionSession`) and neither producer writes one
96+
* (`buildSession()` in objectql, `buildActionSession()` in
97+
* `../action-execution.ts`), so that arm is unreachable on every real path —
98+
* the #4984 dead-limb family. It is left in place here because this change
99+
* types a seam and does not get to re-decide a runtime expression; the limb is
100+
* filed separately.
101+
*
102+
* `undefined` is a member (via `HookContext['user']`'s own optionality, exactly
103+
* as in {@link ScriptSession}) and it is a REAL value on this seam, not just
104+
* spelling: ObjectQL's `ScopedRepo.execute()` — the second `executeAction` call
105+
* site — passes an action context with no `user` and no `session` at all.
106+
*/
107+
export type ScriptUser = ActorUser | HookContext['user'];
108+
56109
/**
57110
* Identity / origin information used by the sandbox for diagnostics, capability
58111
* gating, and audit logs.
@@ -83,7 +136,36 @@ export interface ScriptContext {
83136
*/
84137
input: unknown;
85138
previous?: unknown;
86-
user?: unknown;
139+
/**
140+
* The acting caller. TWO different shapes reach this one field, for the same
141+
* structural reason {@link session} does — this interface is a single generic
142+
* seam over both body kinds:
143+
*
144+
* - a HOOK body gets `HookContext.user` (`@objectstack/spec/data`), the
145+
* engine's `session.userId` shortcut: `id` / `name` / `email` /
146+
* `organizationId`, built by ObjectQL's `buildUser()`;
147+
* - an ACTION body gets an {@link ActorUser} (`../security/actor-user.ts`) —
148+
* the identity core (`EvalUser`, ADR-0068 D1) plus the transport aliases
149+
* (`userId` / `displayName`) and the two authority channels
150+
* (`permissions` = permission-SET names, `systemPermissions` =
151+
* CAPABILITIES, never merged, #4705).
152+
*
153+
* Typed as {@link ScriptUser}, the union of those two REAL producer shapes,
154+
* since #5521. It was `unknown` because the seam's two dispatch faces had
155+
* never been measured against each other — and under `unknown` a fourth
156+
* dispatch face could hand-roll a fifth user shape here without the compiler
157+
* saying a word, which is precisely how #5372's three disagreeing shapes
158+
* lived for several versions: there was no declaration to violate. The
159+
* runtime shape has been correct and pin-tested since #5372
160+
* (`../action-ctx-user-shape.test.ts` asserts the three dispatch paths key
161+
* for key and value for value); this adds the compile-time half that pin
162+
* cannot give, because a pin can only check the producers it names.
163+
*
164+
* ⚠️ Deliberately NOT narrowed to `ActorUser` alone, and deliberately not
165+
* declared as the spec's `EvalUser`: the hook shortcut satisfies neither.
166+
* See {@link ScriptUser} for the measurement behind both refusals.
167+
*/
168+
user?: ScriptUser;
87169
/**
88170
* The caller session. TWO different shapes reach this one field, because
89171
* this interface is a single generic seam over both body kinds:
Lines changed: 201 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,201 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* Compile-level pins for `ScriptContext.user` / {@link ScriptUser} (#5521).
5+
*
6+
* ## Why a plain src module and not a test
7+
*
8+
* `packages/runtime/tsconfig.json` EXCLUDES every `.test.ts` and `.spec.ts`
9+
* file, and the package's `typecheck` script is a bare `tsc --noEmit` over that
10+
* config (the exclusion glob is not spelled here because it cannot be: its
11+
* leading wildcard pair would close this comment) — so a
12+
* `@ts-expect-error` written in a runtime test file is compiled by NOTHING and
13+
* evaluates never. Deleting such a directive leaves every gate just as green,
14+
* which is the definition of a phantom check; `check:type-check-coverage`'s
15+
* PINS_CHECKED invariant fails on one, and its PHANTOM_PIN_DEBT ledger is
16+
* closed to new entries. The same reasoning put
17+
* `packages/spec/src/ui/app.nav-type-assertions.ts` in src, and this file
18+
* follows it.
19+
*
20+
* The file is referenced by no tsup entry (`entry: ['src/index.ts']`) and
21+
* re-exported by no barrel, so it adds nothing to any build. Everything is
22+
* `export`ed because the repo compiles with `noUnusedLocals`.
23+
*
24+
* ## What is pinned, and in which direction
25+
*
26+
* The runtime VALUE has been right and pin-tested since #5372 —
27+
* `../action-ctx-user-shape.test.ts` asserts the three dispatch paths key for
28+
* key and value for value. That pin cannot see the case this file exists for:
29+
* a FOURTH dispatch face hand-rolling a fifth user shape, which is what
30+
* `user?: unknown` used to permit in silence and is the mechanism by which
31+
* #5372's three disagreeing shapes survived several versions — there was no
32+
* declaration to violate.
33+
*
34+
* So the assertions come in two families, and BOTH matter:
35+
*
36+
* - POSITIVE — each of the two REAL producer shapes still assigns. These are
37+
* the over-narrowing guard: collapse the union to `ActorUser` and the hook
38+
* arm's assertions go red, which is the whole reason this is a union.
39+
* - NEGATIVE (`@ts-expect-error`) — shapes that must NOT assign. If the type
40+
* ever widens back toward `unknown`, the now-unused suppressions become the
41+
* compile error. This is the half that catches "accepts too much", and it is
42+
* the half the seam was missing entirely.
43+
*/
44+
45+
import type { HookContext } from '@objectstack/spec/data';
46+
import type { EvalUser } from '@objectstack/spec/identity';
47+
48+
import type { ActorUser } from '../security/actor-user.js';
49+
import type { ScriptContext, ScriptUser } from './script-runner.js';
50+
51+
/* ────────────────────────────────────────────────────────────────────────────
52+
* POSITIVE — the two real producers, and the third real VALUE.
53+
* ──────────────────────────────────────────────────────────────────────────── */
54+
55+
/**
56+
* The ACTION arm, exactly as `buildActorUser()` emits it (`../security/actor-user.ts`):
57+
* the `EvalUser` identity core, the two transport aliases, and the two separate
58+
* authority channels. Post-#6011 there is no `roles` alias — `positions` is the
59+
* one spelling, and adding `roles` back here would fail the excess-property
60+
* check, which is a bonus pin on that retirement.
61+
*/
62+
export const actionProducerShape: ScriptUser = {
63+
id: 'usr_admin',
64+
userId: 'usr_admin',
65+
name: 'Ada Lovelace',
66+
displayName: 'Ada Lovelace',
67+
email: 'ada@objectos.ai',
68+
positions: ['platform_admin'],
69+
isPlatformAdmin: true,
70+
organizationId: 'org_1',
71+
permissions: ['admin_full_access'],
72+
systemPermissions: ['manage_metadata'],
73+
};
74+
75+
/** The same shape arriving under its own name, not as a literal. */
76+
export const actionProducerNamed = (u: ActorUser): ScriptUser => u;
77+
78+
/**
79+
* The HOOK arm, exactly as ObjectQL's `buildUser()` emits it
80+
* (`packages/objectql/src/engine.ts`) for a fully-populated execution context.
81+
* Note what is absent and must STAY absent-legal: `positions`, `permissions`,
82+
* `systemPermissions`, `userId`, `displayName`.
83+
*/
84+
export const hookProducerShape: ScriptUser = {
85+
id: 'usr_admin',
86+
email: 'ada@objectos.ai',
87+
organizationId: 'org_1',
88+
};
89+
90+
/**
91+
* `buildUser()`'s minimum: an execution context with a `userId` and nothing
92+
* else. This is the assertion that goes red first if anyone collapses the union
93+
* to `ActorUser`.
94+
*/
95+
export const hookProducerMinimal: ScriptUser = { id: 'usr_admin' };
96+
97+
/** The same shape arriving under its declared spec name. */
98+
export const hookProducerNamed = (u: HookContext['user']): ScriptUser => u;
99+
100+
/**
101+
* `undefined` is a REAL value on this seam, not merely the optionality of the
102+
* key: ObjectQL's `ScopedRepo.execute()` — the second `executeAction` call site
103+
* — passes an action context carrying neither `user` nor `session`, so both
104+
* arms of `body-runner.ts:340` resolve to `undefined`.
105+
*/
106+
export const absentUser: ScriptUser = undefined;
107+
108+
/** Both faces assembled at the real seam, so the field's own type is pinned too. */
109+
export const actionSeamContext: ScriptContext = {
110+
input: { amount: 100 },
111+
user: actionProducerShape,
112+
session: { userId: 'usr_admin', organizationId: 'org_1', positions: ['platform_admin'] },
113+
};
114+
115+
export const hookSeamContext: ScriptContext = {
116+
input: { id: 'rec_1' },
117+
user: hookProducerShape,
118+
session: { userId: 'usr_admin', organizationId: 'org_1' },
119+
event: 'beforeInsert',
120+
object: 'crm_case',
121+
};
122+
123+
/** A body-less / system dispatch: the seam carries no caller at all. */
124+
export const anonymousSeamContext: ScriptContext = { input: {} };
125+
126+
/**
127+
* The practical payoff, and the same one the sibling `ScriptSession` states:
128+
* `id` is declared on BOTH arms, so a consumer reading only the shared key needs no
129+
* discrimination. It is `string | undefined` because the hook arm's `id` is
130+
* optional — narrower than `unknown` by exactly the useful amount.
131+
*/
132+
export const sharedIdIsReadable = (ctx: ScriptContext): string | undefined => ctx.user?.id;
133+
134+
/* ────────────────────────────────────────────────────────────────────────────
135+
* NEGATIVE — must NOT assign. An unused suppression here IS the failure.
136+
* ──────────────────────────────────────────────────────────────────────────── */
137+
138+
/** A fourth dispatch face inventing its own vocabulary — the #5372 mechanism. */
139+
// @ts-expect-error - an arbitrary shape is not a producer shape (#5521)
140+
export const inventedShape: ScriptUser = { currentUser: 'usr_admin', tenant: 'org_1' };
141+
142+
/** A declared key carrying the wrong type. */
143+
// @ts-expect-error - `id` is a string on both arms (#5521)
144+
export const wrongIdType: ScriptUser = { id: 42 };
145+
146+
/** The caller is an object on every path, never a bare identifier. */
147+
// @ts-expect-error - a user id string is not a user (#5521)
148+
export const primitiveUser: ScriptUser = 'usr_admin';
149+
150+
/**
151+
* Action-SHAPED but incomplete — the failure mode #5372 actually shipped, where
152+
* a dispatcher hand-rolled a partial envelope. Rejected because it satisfies
153+
* neither arm: `ActorUser` requires the aliases and both authority channels,
154+
* and this shares no key with the hook shortcut, so the weak-type check refuses
155+
* it there ("no properties in common").
156+
*/
157+
// @ts-expect-error - a partial ActorUser is not an ActorUser (#5521)
158+
export const partialActionShape: ScriptUser = { userId: 'usr_admin', positions: ['platform_admin'] };
159+
160+
/**
161+
* ⚠️ The limit of this union, pinned as a POSITIVE because it is what actually
162+
* compiles — stated here rather than left for the next reader to discover.
163+
*
164+
* A partial action envelope that happens to carry a hook-arm key assigns, via
165+
* the hook arm. Two ordinary TypeScript rules combine to allow it: the hook arm
166+
* is a WEAK type (every key optional), so one matching key is enough to satisfy
167+
* it; and excess-property checking on a union rejects only keys present in NO
168+
* member, so `positions` — real on the `ActorUser` arm — is not excess here.
169+
*
170+
* A union of two undiscriminated producer shapes cannot do better, and neither
171+
* can the sibling `ScriptSession`, whose `ActionSession` arm is all-optional
172+
* for the same reason. What the declaration buys is not a proof of
173+
* well-formedness; it is that a shape sharing NOTHING with either producer
174+
* ({@link inventedShape}) is now refused where `unknown` accepted it silently.
175+
* Closing the remaining gap would need a discriminant on the seam — the body
176+
* kind, which this interface deliberately does not carry (see `ScriptSession`).
177+
*/
178+
export const partialShapeBorrowingHookArm: ScriptUser = {
179+
id: 'usr_admin',
180+
positions: ['platform_admin'],
181+
};
182+
183+
/**
184+
* The spec's `EvalUser` (ADR-0068 D1) — the issue's option 1, refused on
185+
* MEASUREMENT rather than taste. It is a SUPERSET of what the hook side
186+
* delivers (`buildUser()` emits no `positions`) and a SUBSET of what the action
187+
* side delivers, so it describes neither producer. `ActorUser extends EvalUser`
188+
* keeps the ADR-0068 contract on the path that actually has it.
189+
*/
190+
// @ts-expect-error - EvalUser is not a producer shape on this seam (#5521)
191+
export const bareEvalUser = (u: EvalUser): ScriptUser => u;
192+
193+
/**
194+
* The union did not collapse to `ActorUser`: `positions` is unreadable without
195+
* discriminating the body kind, because the hook arm has no such key. This is
196+
* the over-narrowing guard stated from the READ side — if someone later
197+
* declares `user?: ActorUser`, this suppression goes unused and fails.
198+
*/
199+
export const positionsNeedDiscrimination = (ctx: ScriptContext): unknown =>
200+
// @ts-expect-error - `positions` exists on the action arm only (#5521)
201+
ctx.user?.positions;

‎packages/runtime/src/security/index.ts‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,14 @@ export {
2626
type RateLimitKeyKind,
2727
type RateLimitLogger,
2828
} from './inbound-rate-limit.js';
29+
// The dispatch-side arm of the sandbox seam's `ScriptUser` union (#5521).
30+
// Exported as a TYPE only: `ScriptUser` is public, so both its arms must be
31+
// nameable by a consumer that wants to discriminate one — the sibling
32+
// `ScriptSession`'s arms (`ActionSession`, `HookContext['session']`) already
33+
// are, being spec types. The builders stay internal; nothing outside this
34+
// package produces an `ActorUser`, and #5372's whole point is that there is
35+
// exactly ONE producer.
36+
export type { ActorUser } from './actor-user.js';
2937
export {
3038
API_KEY_PREFIX,
3139
hashApiKey,

0 commit comments

Comments
 (0)