From 059a2ec56c32b3c37bad933751bfd0a88d2cb0eb Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 7 Aug 2026 13:18:55 +0000 Subject: [PATCH] =?UTF-8?q?feat(runtime):=20ScriptContext.user=20=E7=94=B1?= =?UTF-8?q?=20unknown=20=E6=94=B6=E7=AA=84=E4=B8=BA=20ScriptUser=20?= =?UTF-8?q?=E8=81=94=E5=90=88=20(#5521)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 沙箱接缝的 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。 Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01Wbxm29qPKnLf44AbSxizqW --- .changeset/light-berries-tickle.md | 26 +++ packages/runtime/src/index.ts | 2 + packages/runtime/src/sandbox/index.ts | 1 + packages/runtime/src/sandbox/script-runner.ts | 84 +++++++- .../sandbox/script-user-type-assertions.ts | 201 ++++++++++++++++++ packages/runtime/src/security/index.ts | 8 + 6 files changed, 321 insertions(+), 1 deletion(-) create mode 100644 .changeset/light-berries-tickle.md create mode 100644 packages/runtime/src/sandbox/script-user-type-assertions.ts diff --git a/.changeset/light-berries-tickle.md b/.changeset/light-berries-tickle.md new file mode 100644 index 0000000000..fcb101e564 --- /dev/null +++ b/.changeset/light-berries-tickle.md @@ -0,0 +1,26 @@ +--- +'@objectstack/runtime': patch +--- + +sandbox: `ScriptContext.user` 由 `unknown` 收窄为命名联合 `ScriptUser`(#5521) + +沙箱接缝 `ScriptContext`(`packages/runtime/src/sandbox/script-runner.ts`)把交给 hook / +action body 的调用者声明为 `user?: unknown`,类型系统对这个字段一无所知 —— 第四个 +dispatch 面明天再手搓一个 user 字面量,编译器不会说一句话。而"三个 dispatcher 手搓出三种 +形状"正是 #5372 的成因:它能存在几个版本,部分原因就是没有任何声明可以违背。 + +现在它是 `user?: ScriptUser`,`ScriptUser = ActorUser | HookContext['user']` —— 两个**实测 +的真实生产者形状**的联合,与 33 行外的姊妹字段 `ScriptSession`(#5613 / #5991)同构: + +- action body 收 `ActorUser`(`security/actor-user.ts`,#5372 起的唯一生产者,#6011 后 + `positions` 为唯一拼法); +- hook body 收 `HookContext['user']`(ObjectQL `buildUser()` 的 `session.userId` 快捷方式: + `id` / `name` / `email` / `organizationId`,全部可选)。 + +刻意**不**收成单一类型:hook 快捷方式不带 `positions` / `permissions` / `systemPermissions`, +收成 `ActorUser` 会在 hook 面断言一套它从未生产过的授权词汇;也**不**收成 spec 的 +`EvalUser`(issue 选项 1)—— 实测 `buildUser()` 根本不产 `positions`,而 `EvalUser` 要求它, +那是套着 spec 外衣的同一种过度声明。 + +行为零变化:两个写入方从 `any` 引擎上下文赋值,唯一的 VM 侧读取方收 `unknown`。TS 消费者 +可见,故走 patch。`ActorUser` 同时作为**类型**从包入口导出,使联合的两支都可被消费者命名。 diff --git a/packages/runtime/src/index.ts b/packages/runtime/src/index.ts index e5d4071622..e34d3727b7 100644 --- a/packages/runtime/src/index.ts +++ b/packages/runtime/src/index.ts @@ -100,6 +100,7 @@ export { type RateLimitKeyInput, type RateLimitKeyKind, type RateLimitLogger, + type ActorUser, } from './security/index.js'; // ── Observability primitives ────────────────────────────────────────── @@ -157,6 +158,7 @@ export type { ScriptResult, ScriptRunOptions, ScriptSession, + ScriptUser, QuickJSScriptRunnerOptions, } from './sandbox/index.js'; diff --git a/packages/runtime/src/sandbox/index.ts b/packages/runtime/src/sandbox/index.ts index 05a68bde8a..8a72ec9543 100644 --- a/packages/runtime/src/sandbox/index.ts +++ b/packages/runtime/src/sandbox/index.ts @@ -10,6 +10,7 @@ export type { ScriptResult, ScriptRunOptions, ScriptSession, + ScriptUser, } from './script-runner.js'; export { QuickJSScriptRunner, SandboxError } from './quickjs-runner.js'; export type { QuickJSScriptRunnerOptions } from './quickjs-runner.js'; diff --git a/packages/runtime/src/sandbox/script-runner.ts b/packages/runtime/src/sandbox/script-runner.ts index 5fbf969240..7569888719 100644 --- a/packages/runtime/src/sandbox/script-runner.ts +++ b/packages/runtime/src/sandbox/script-runner.ts @@ -38,6 +38,8 @@ import type { HookBody, ScriptBody, ExpressionBody, HookContext } from '@objectstack/spec/data'; import type { ActionSession } from '@objectstack/spec/ui'; +import type { ActorUser } from '../security/actor-user.js'; + /** * The caller session a sandboxed body receives on `ctx.session` — the union of * the two DECLARED producer shapes this one seam carries (#5613). @@ -53,6 +55,57 @@ import type { ActionSession } from '@objectstack/spec/ui'; */ export type ScriptSession = ActionSession | HookContext['session']; +/** + * The caller a sandboxed body receives on `ctx.user` — the union of the two + * REAL producer shapes this one seam carries (#5521). + * + * Same construction, and for the same reason, as {@link ScriptSession} + * (#5613/#5991) one field over: the seam is genuinely generic over both body + * kinds, so collapsing it to either single type would be a contract lie in the + * other direction. + * + * - an ACTION body gets {@link ActorUser} — the ONE producer of the dispatch + * user shape (`../security/actor-user.ts`), built through the spec's + * `createEvalUser` factory and shared by REST `/actions`, MCP `run_action` + * and the AI routes since #5372. Every key is present with a defined value + * except `email` / `organizationId`; + * - a HOOK body gets `HookContext['user']` (`@objectstack/spec/data`) — + * ObjectQL's `buildUser()` shortcut, whose whole key set is + * `id` / `name` / `email` / `organizationId`, every one of them optional. + * + * The two do NOT converge, which is exactly why this is a union and not + * `ActorUser`: the hook shortcut carries no `positions`, no `permissions`, no + * `systemPermissions` and no `userId` / `displayName` alias, so declaring + * `ActorUser` alone here would assert an authority vocabulary the hook path has + * never produced — the "one key, two realities" defect #5613 exists to close, + * pointed at the other field. + * + * ⚠️ It is NOT `EvalUser` either, and that was measured rather than assumed. + * `EvalUser` (ADR-0068 D1) is what the issue's option 1 proposed as the + * "minimum common denominator", but it requires `id: string` and + * `positions: string[]`, and `buildUser()` (`packages/objectql/src/engine.ts`) + * emits neither guarantee — no `positions` key at all. So `EvalUser` is a + * SUPERSET of what the hook side delivers, and declaring it would have been the + * same over-claim in a spec-shaped disguise. `ActorUser extends EvalUser`, so + * the action arm still carries the ADR-0068 contract on the path that has it. + * + * The `?? …session?.user` fallback chain both writers carry (`body-runner.ts` + * `:315` / `:340`) forces no THIRD arm — measured, not presumed: neither + * session shape reaching this seam declares a `user` key + * (`HookContext['session']`, `ActionSession`) and neither producer writes one + * (`buildSession()` in objectql, `buildActionSession()` in + * `../action-execution.ts`), so that arm is unreachable on every real path — + * the #4984 dead-limb family. It is left in place here because this change + * types a seam and does not get to re-decide a runtime expression; the limb is + * filed separately. + * + * `undefined` is a member (via `HookContext['user']`'s own optionality, exactly + * as in {@link ScriptSession}) and it is a REAL value on this seam, not just + * spelling: ObjectQL's `ScopedRepo.execute()` — the second `executeAction` call + * site — passes an action context with no `user` and no `session` at all. + */ +export type ScriptUser = ActorUser | HookContext['user']; + /** * Identity / origin information used by the sandbox for diagnostics, capability * gating, and audit logs. @@ -83,7 +136,36 @@ export interface ScriptContext { */ input: unknown; previous?: unknown; - user?: unknown; + /** + * The acting caller. TWO different shapes reach this one field, for the same + * structural reason {@link session} does — this interface is a single generic + * seam over both body kinds: + * + * - a HOOK body gets `HookContext.user` (`@objectstack/spec/data`), the + * engine's `session.userId` shortcut: `id` / `name` / `email` / + * `organizationId`, built by ObjectQL's `buildUser()`; + * - an ACTION body gets an {@link ActorUser} (`../security/actor-user.ts`) — + * the identity core (`EvalUser`, ADR-0068 D1) plus the transport aliases + * (`userId` / `displayName`) and the two authority channels + * (`permissions` = permission-SET names, `systemPermissions` = + * CAPABILITIES, never merged, #4705). + * + * Typed as {@link ScriptUser}, the union of those two REAL producer shapes, + * since #5521. It was `unknown` because the seam's two dispatch faces had + * never been measured against each other — and under `unknown` a fourth + * dispatch face could hand-roll a fifth user shape here without the compiler + * saying a word, which is precisely how #5372's three disagreeing shapes + * lived for several versions: there was no declaration to violate. The + * runtime shape has been correct and pin-tested since #5372 + * (`../action-ctx-user-shape.test.ts` asserts the three dispatch paths key + * for key and value for value); this adds the compile-time half that pin + * cannot give, because a pin can only check the producers it names. + * + * ⚠️ Deliberately NOT narrowed to `ActorUser` alone, and deliberately not + * declared as the spec's `EvalUser`: the hook shortcut satisfies neither. + * See {@link ScriptUser} for the measurement behind both refusals. + */ + user?: ScriptUser; /** * The caller session. TWO different shapes reach this one field, because * this interface is a single generic seam over both body kinds: diff --git a/packages/runtime/src/sandbox/script-user-type-assertions.ts b/packages/runtime/src/sandbox/script-user-type-assertions.ts new file mode 100644 index 0000000000..533fb483e3 --- /dev/null +++ b/packages/runtime/src/sandbox/script-user-type-assertions.ts @@ -0,0 +1,201 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Compile-level pins for `ScriptContext.user` / {@link ScriptUser} (#5521). + * + * ## Why a plain src module and not a test + * + * `packages/runtime/tsconfig.json` EXCLUDES every `.test.ts` and `.spec.ts` + * file, and the package's `typecheck` script is a bare `tsc --noEmit` over that + * config (the exclusion glob is not spelled here because it cannot be: its + * leading wildcard pair would close this comment) — so a + * `@ts-expect-error` written in a runtime test file is compiled by NOTHING and + * evaluates never. Deleting such a directive leaves every gate just as green, + * which is the definition of a phantom check; `check:type-check-coverage`'s + * PINS_CHECKED invariant fails on one, and its PHANTOM_PIN_DEBT ledger is + * closed to new entries. The same reasoning put + * `packages/spec/src/ui/app.nav-type-assertions.ts` in src, and this file + * follows it. + * + * The file is referenced by no tsup entry (`entry: ['src/index.ts']`) and + * re-exported by no barrel, so it adds nothing to any build. Everything is + * `export`ed because the repo compiles with `noUnusedLocals`. + * + * ## What is pinned, and in which direction + * + * The runtime VALUE has been right and pin-tested since #5372 — + * `../action-ctx-user-shape.test.ts` asserts the three dispatch paths key for + * key and value for value. That pin cannot see the case this file exists for: + * a FOURTH dispatch face hand-rolling a fifth user shape, which is what + * `user?: unknown` used to permit in silence and is the mechanism by which + * #5372's three disagreeing shapes survived several versions — there was no + * declaration to violate. + * + * So the assertions come in two families, and BOTH matter: + * + * - POSITIVE — each of the two REAL producer shapes still assigns. These are + * the over-narrowing guard: collapse the union to `ActorUser` and the hook + * arm's assertions go red, which is the whole reason this is a union. + * - NEGATIVE (`@ts-expect-error`) — shapes that must NOT assign. If the type + * ever widens back toward `unknown`, the now-unused suppressions become the + * compile error. This is the half that catches "accepts too much", and it is + * the half the seam was missing entirely. + */ + +import type { HookContext } from '@objectstack/spec/data'; +import type { EvalUser } from '@objectstack/spec/identity'; + +import type { ActorUser } from '../security/actor-user.js'; +import type { ScriptContext, ScriptUser } from './script-runner.js'; + +/* ──────────────────────────────────────────────────────────────────────────── + * POSITIVE — the two real producers, and the third real VALUE. + * ──────────────────────────────────────────────────────────────────────────── */ + +/** + * The ACTION arm, exactly as `buildActorUser()` emits it (`../security/actor-user.ts`): + * the `EvalUser` identity core, the two transport aliases, and the two separate + * authority channels. Post-#6011 there is no `roles` alias — `positions` is the + * one spelling, and adding `roles` back here would fail the excess-property + * check, which is a bonus pin on that retirement. + */ +export const actionProducerShape: ScriptUser = { + id: 'usr_admin', + userId: 'usr_admin', + name: 'Ada Lovelace', + displayName: 'Ada Lovelace', + email: 'ada@objectos.ai', + positions: ['platform_admin'], + isPlatformAdmin: true, + organizationId: 'org_1', + permissions: ['admin_full_access'], + systemPermissions: ['manage_metadata'], +}; + +/** The same shape arriving under its own name, not as a literal. */ +export const actionProducerNamed = (u: ActorUser): ScriptUser => u; + +/** + * The HOOK arm, exactly as ObjectQL's `buildUser()` emits it + * (`packages/objectql/src/engine.ts`) for a fully-populated execution context. + * Note what is absent and must STAY absent-legal: `positions`, `permissions`, + * `systemPermissions`, `userId`, `displayName`. + */ +export const hookProducerShape: ScriptUser = { + id: 'usr_admin', + email: 'ada@objectos.ai', + organizationId: 'org_1', +}; + +/** + * `buildUser()`'s minimum: an execution context with a `userId` and nothing + * else. This is the assertion that goes red first if anyone collapses the union + * to `ActorUser`. + */ +export const hookProducerMinimal: ScriptUser = { id: 'usr_admin' }; + +/** The same shape arriving under its declared spec name. */ +export const hookProducerNamed = (u: HookContext['user']): ScriptUser => u; + +/** + * `undefined` is a REAL value on this seam, not merely the optionality of the + * key: ObjectQL's `ScopedRepo.execute()` — the second `executeAction` call site + * — passes an action context carrying neither `user` nor `session`, so both + * arms of `body-runner.ts:340` resolve to `undefined`. + */ +export const absentUser: ScriptUser = undefined; + +/** Both faces assembled at the real seam, so the field's own type is pinned too. */ +export const actionSeamContext: ScriptContext = { + input: { amount: 100 }, + user: actionProducerShape, + session: { userId: 'usr_admin', organizationId: 'org_1', positions: ['platform_admin'] }, +}; + +export const hookSeamContext: ScriptContext = { + input: { id: 'rec_1' }, + user: hookProducerShape, + session: { userId: 'usr_admin', organizationId: 'org_1' }, + event: 'beforeInsert', + object: 'crm_case', +}; + +/** A body-less / system dispatch: the seam carries no caller at all. */ +export const anonymousSeamContext: ScriptContext = { input: {} }; + +/** + * The practical payoff, and the same one the sibling `ScriptSession` states: + * `id` is declared on BOTH arms, so a consumer reading only the shared key needs no + * discrimination. It is `string | undefined` because the hook arm's `id` is + * optional — narrower than `unknown` by exactly the useful amount. + */ +export const sharedIdIsReadable = (ctx: ScriptContext): string | undefined => ctx.user?.id; + +/* ──────────────────────────────────────────────────────────────────────────── + * NEGATIVE — must NOT assign. An unused suppression here IS the failure. + * ──────────────────────────────────────────────────────────────────────────── */ + +/** A fourth dispatch face inventing its own vocabulary — the #5372 mechanism. */ +// @ts-expect-error - an arbitrary shape is not a producer shape (#5521) +export const inventedShape: ScriptUser = { currentUser: 'usr_admin', tenant: 'org_1' }; + +/** A declared key carrying the wrong type. */ +// @ts-expect-error - `id` is a string on both arms (#5521) +export const wrongIdType: ScriptUser = { id: 42 }; + +/** The caller is an object on every path, never a bare identifier. */ +// @ts-expect-error - a user id string is not a user (#5521) +export const primitiveUser: ScriptUser = 'usr_admin'; + +/** + * Action-SHAPED but incomplete — the failure mode #5372 actually shipped, where + * a dispatcher hand-rolled a partial envelope. Rejected because it satisfies + * neither arm: `ActorUser` requires the aliases and both authority channels, + * and this shares no key with the hook shortcut, so the weak-type check refuses + * it there ("no properties in common"). + */ +// @ts-expect-error - a partial ActorUser is not an ActorUser (#5521) +export const partialActionShape: ScriptUser = { userId: 'usr_admin', positions: ['platform_admin'] }; + +/** + * ⚠️ The limit of this union, pinned as a POSITIVE because it is what actually + * compiles — stated here rather than left for the next reader to discover. + * + * A partial action envelope that happens to carry a hook-arm key assigns, via + * the hook arm. Two ordinary TypeScript rules combine to allow it: the hook arm + * is a WEAK type (every key optional), so one matching key is enough to satisfy + * it; and excess-property checking on a union rejects only keys present in NO + * member, so `positions` — real on the `ActorUser` arm — is not excess here. + * + * A union of two undiscriminated producer shapes cannot do better, and neither + * can the sibling `ScriptSession`, whose `ActionSession` arm is all-optional + * for the same reason. What the declaration buys is not a proof of + * well-formedness; it is that a shape sharing NOTHING with either producer + * ({@link inventedShape}) is now refused where `unknown` accepted it silently. + * Closing the remaining gap would need a discriminant on the seam — the body + * kind, which this interface deliberately does not carry (see `ScriptSession`). + */ +export const partialShapeBorrowingHookArm: ScriptUser = { + id: 'usr_admin', + positions: ['platform_admin'], +}; + +/** + * The spec's `EvalUser` (ADR-0068 D1) — the issue's option 1, refused on + * MEASUREMENT rather than taste. It is a SUPERSET of what the hook side + * delivers (`buildUser()` emits no `positions`) and a SUBSET of what the action + * side delivers, so it describes neither producer. `ActorUser extends EvalUser` + * keeps the ADR-0068 contract on the path that actually has it. + */ +// @ts-expect-error - EvalUser is not a producer shape on this seam (#5521) +export const bareEvalUser = (u: EvalUser): ScriptUser => u; + +/** + * The union did not collapse to `ActorUser`: `positions` is unreadable without + * discriminating the body kind, because the hook arm has no such key. This is + * the over-narrowing guard stated from the READ side — if someone later + * declares `user?: ActorUser`, this suppression goes unused and fails. + */ +export const positionsNeedDiscrimination = (ctx: ScriptContext): unknown => + // @ts-expect-error - `positions` exists on the action arm only (#5521) + ctx.user?.positions; diff --git a/packages/runtime/src/security/index.ts b/packages/runtime/src/security/index.ts index bb467a6d82..c8d5a530ed 100644 --- a/packages/runtime/src/security/index.ts +++ b/packages/runtime/src/security/index.ts @@ -26,6 +26,14 @@ export { type RateLimitKeyKind, type RateLimitLogger, } from './inbound-rate-limit.js'; +// The dispatch-side arm of the sandbox seam's `ScriptUser` union (#5521). +// Exported as a TYPE only: `ScriptUser` is public, so both its arms must be +// nameable by a consumer that wants to discriminate one — the sibling +// `ScriptSession`'s arms (`ActionSession`, `HookContext['session']`) already +// are, being spec types. The builders stay internal; nothing outside this +// package produces an `ActorUser`, and #5372's whole point is that there is +// exactly ONE producer. +export type { ActorUser } from './actor-user.js'; export { API_KEY_PREFIX, hashApiKey,