Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions .changeset/light-berries-tickle.md
Original file line number Diff line number Diff line change
@@ -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` 同时作为**类型**从包入口导出,使联合的两支都可被消费者命名。
2 changes: 2 additions & 0 deletions packages/runtime/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,7 @@ export {
type RateLimitKeyInput,
type RateLimitKeyKind,
type RateLimitLogger,
type ActorUser,
} from './security/index.js';

// ── Observability primitives ──────────────────────────────────────────
Expand Down Expand Up @@ -157,6 +158,7 @@ export type {
ScriptResult,
ScriptRunOptions,
ScriptSession,
ScriptUser,
QuickJSScriptRunnerOptions,
} from './sandbox/index.js';

Expand Down
1 change: 1 addition & 0 deletions packages/runtime/src/sandbox/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
84 changes: 83 additions & 1 deletion packages/runtime/src/sandbox/script-runner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand All @@ -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.
Expand Down Expand Up @@ -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:
Expand Down
201 changes: 201 additions & 0 deletions packages/runtime/src/sandbox/script-user-type-assertions.ts
Original file line number Diff line number Diff line change
@@ -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;
8 changes: 8 additions & 0 deletions packages/runtime/src/security/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Loading