diff --git a/.changeset/execution-context-single-assembler.md b/.changeset/execution-context-single-assembler.md new file mode 100644 index 0000000000..fbe7fc8845 --- /dev/null +++ b/.changeset/execution-context-single-assembler.md @@ -0,0 +1,47 @@ +--- +"@objectstack/core": minor +"@objectstack/runtime": patch +"@objectstack/rest": patch +--- + +refactor: one shared `ExecutionContext` assembler, two named anonymous entries (#6216) + +`resolveAuthzContext` already made AUTHORIZATION resolution single-sourced; the +step after it — turning the resolved envelope into the `ExecutionContext` that +reaches enforcement — was still one hand-written copy per transport, and the +copies drifted twice for real: **#6071** (the REST copy never set +`principalKind`, so every enforcement judgment reading it was silently +never-true on that face) and **#6206 / #6551** (a dropped `accessible_org_ids` +produced real 403s on the share-link faces). + +**@objectstack/core** gains the single assembly, with the anonymous divergence +as named API rather than drift (maintainer ruling 2026-08-08 on #6216, Option +A): + +- `assembleExecutionContext(input)` — the **fail-closed default** entry. No + resolved principal → `undefined`, and the surface answers 401. +- `assembleExecutionContextOrGuest(input)` — the **explicit guest** entry. No + resolved principal → a first-class guest envelope (`principalKind: 'guest'`, + `positions: ['guest']`), whose consumers are live (`explain-engine`'s + guest ⇒ `EXTERNAL` posture floor). Adopted only by a surface whose product + semantics serve anonymous principals. +- The field set is **closed by type**: `ExecutionContextEntryFields` requires a + decision for every `ExecutionContext` field that is not explicitly declared + non-entry-resolved, so a new field cannot reach one transport and miss + another. Also exported: `ENTRY_EXECUTION_CONTEXT_FIELDS`, + `EntryExecutionContextField`, `ExecutionContextAssemblyInput`, + `OAuthTokenProvenance`, `EntryLocalization`. + +**@objectstack/runtime** (`resolveExecutionContext`, the runtime / MCP +dispatcher) and **@objectstack/rest** (`computeExecCtx`) now assemble through +that module — the dispatcher via the guest entry, REST via the fail-closed +default. + +**No runtime behaviour change on either surface.** The remaining per-face +divergences are required inputs rather than silent omissions: REST passes +`accessToken: undefined` (it has never carried the session bearer on the +envelope, and `session.accessToken` is a published hook surface) and +`oauth: undefined` (OAuth bearers are honoured on the `/mcp` door alone). The +one measurable difference is that a key whose value was `undefined` is now +omitted rather than spelled — invisible to `ctx.x` reads, to `JSON.stringify` +and to spreading the envelope. diff --git a/packages/core/src/security/assemble-execution-context.test.ts b/packages/core/src/security/assemble-execution-context.test.ts new file mode 100644 index 0000000000..688272120d --- /dev/null +++ b/packages/core/src/security/assemble-execution-context.test.ts @@ -0,0 +1,454 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// #6216 — the ExecutionContext assembly converges onto ONE module, and the +// maintainer ruling of 2026-08-08 (Option A) is explicit that **neither surface +// changes runtime behaviour**: what changes is that the anonymous divergence +// becomes named API instead of drift. +// +// A green suite proves nothing about that on its own — the suite was green +// before the change too. So the load-bearing test here is a PARITY PIN: the +// pre-#6216 assembly of each face is transcribed VERBATIM below, frozen, and +// every shape either face can serve is assembled both ways and compared. +// +// ⚠ The two `legacy*` functions are FROZEN TRANSCRIPTIONS of code that no +// longer exists (runtime `resolve-execution-context.ts` and REST +// `rest-server.ts` `computeExecCtx`, at `origin/main@0caf122f0`). They are a +// pin, NOT a second live implementation: nothing imports them, and they must +// never be "kept up to date" with the shared assembler — the day they need +// editing is the day a face's output changed and the change must be argued on +// its own merits, not absorbed here. + +import { describe, it, expect } from 'vitest'; +import { ExecutionContextSchema } from '@objectstack/spec/kernel'; + +import { + assembleExecutionContext, + assembleExecutionContextOrGuest, + ENTRY_EXECUTION_CONTEXT_FIELDS, + type EntryLocalization, + type OAuthTokenProvenance, +} from './assemble-execution-context.js'; +import type { ResolvedAuthzContext } from './resolve-authz-context.js'; + +// ───────────────────────── frozen pre-#6216 transcriptions ───────────────────────── + +/** Runtime / MCP dispatcher assembly, verbatim, pre-#6216. FROZEN — see header. */ +function legacyDispatcherAssembly( + authz: ResolvedAuthzContext, + oauthPrincipal: OAuthTokenProvenance | undefined, + localization: EntryLocalization | undefined, + requestLocale: string | undefined, +): any { + const ctx: any = { + positions: authz.positions, + permissions: authz.permissions, + systemPermissions: authz.systemPermissions, + isSystem: false, + }; + if (authz.userId) { + if (oauthPrincipal?.clientId) { + ctx.principalKind = 'agent'; + ctx.onBehalfOf = { userId: authz.userId, principalKind: 'human' }; + // Pre-#6216 these two read `scopesToAgentPermissionSets(oauthPrincipal.scopes)` + // and `oauthPrincipal.scopes?.includes(MCP_OAUTH_SCOPE_ACTIONS)` inline. + // Both are now interpreted at the `/mcp` door and arrive pre-derived; the + // scope→ceiling mapping itself is pinned end-to-end through the real + // resolver in `packages/runtime/src/security/resolve-execution-context.test.ts` + // ("ADR-0090 D10 agent principal"). What THIS pin freezes is unchanged: + // which envelope fields the ceiling replaces. + ctx.permissions = oauthPrincipal.scopePermissions; + ctx.positions = []; + ctx.systemPermissions = oauthPrincipal.delegatesActions + ? (authz.systemPermissions ?? []) + : []; + } else { + ctx.principalKind = 'human'; + } + } else { + ctx.principalKind = 'guest'; + ctx.positions = ['guest']; + } + if (authz.userId) ctx.userId = authz.userId; + if (authz.tenantId) ctx.tenantId = authz.tenantId; + if (authz.email) ctx.email = authz.email; + if (authz.accessToken) ctx.accessToken = authz.accessToken; + if (authz.tabPermissions) ctx.tabPermissions = authz.tabPermissions; + if (authz.posture) ctx.posture = authz.posture; + ctx.org_user_ids = authz.org_user_ids; + ctx.accessible_org_ids = authz.accessible_org_ids; + if (oauthPrincipal && ctx.userId === oauthPrincipal.userId) { + ctx.oauthScopes = oauthPrincipal.scopes; + } + if (authz.userId) { + ctx.timezone = localization?.timezone; + ctx.locale = requestLocale ?? localization?.locale; + if (localization?.currency) ctx.currency = localization.currency; + } + return ctx; +} + +/** + * REST `computeExecCtx` assembly, verbatim, pre-#6216. FROZEN — see header. + * `authGate` / `__kernel` are deliberately outside: neither is an + * `ExecutionContext` field, and the REST face still adds them after assembly. + */ +function legacyRestAssembly( + authz: ResolvedAuthzContext, + localization: EntryLocalization, + requestLocale: string | undefined, +): any { + if (!authz.userId) return undefined; // anonymous → no ctx → 401 + const effectiveLocale = requestLocale ?? localization.locale; + return { + userId: authz.userId, + tenantId: authz.tenantId, + email: authz.email, + positions: authz.positions, + permissions: authz.permissions, + systemPermissions: authz.systemPermissions, + ...(authz.tabPermissions ? { tabPermissions: authz.tabPermissions } : {}), + ...(authz.posture ? { posture: authz.posture } : {}), + principalKind: 'human', + isSystem: false, + org_user_ids: authz.org_user_ids, + accessible_org_ids: authz.accessible_org_ids, + ...(localization.timezone ? { timezone: localization.timezone } : {}), + ...(effectiveLocale ? { locale: effectiveLocale } : {}), + ...(localization.currency ? { currency: localization.currency } : {}), + }; +} + +// ───────────────────────── comparison + fixtures ───────────────────────── + +/** + * What a CONSUMER of the envelope can observe: every key carrying a defined + * value, and that value. Key insertion order and keys explicitly set to + * `undefined` are invisible to `ctx.x` reads, to `JSON.stringify`, and to + * object spread of the result — the shared assembler emits in its own declared + * order and omits undefined-valued keys, which is exactly the delta this helper + * normalizes away. The undefined-key residual is asserted separately below so + * it is measured, not waved off. + */ +function observable(ctx: any): Record { + return Object.fromEntries(Object.entries(ctx).filter(([, v]) => v !== undefined)); +} + +const baseAuthz = (over: Partial = {}): ResolvedAuthzContext => ({ + positions: [], + permissions: [], + systemPermissions: [], + org_user_ids: [], + accessible_org_ids: [], + ...over, +}); + +const ANONYMOUS = baseAuthz(); + +const HUMAN_MINIMAL = baseAuthz({ + userId: 'u1', + positions: ['member_default'], + permissions: ['task.read'], + systemPermissions: [], + org_user_ids: ['u1', 'u2'], + accessible_org_ids: ['org1'], +}); + +const HUMAN_FULL = baseAuthz({ + userId: 'u1', + tenantId: 'org1', + email: 'u1@example.com', + accessToken: 'sess_token_abc', + positions: ['member_default', 'sales'], + permissions: ['task.read', 'task.write'], + systemPermissions: ['admin_full_access'], + tabPermissions: { task: 'visible' }, + posture: 'PLATFORM_ADMIN', + org_user_ids: ['u1', 'u2'], + accessible_org_ids: ['org1', 'org2'], +}); + +const LOCALIZATIONS: Array<[string, EntryLocalization | undefined]> = [ + ['no localization', undefined], + ['empty localization', {}], + ['timezone only', { timezone: 'Asia/Shanghai' }], + ['full localization', { timezone: 'Asia/Shanghai', locale: 'zh-CN', currency: 'CNY' }], +]; + +const REQUEST_LOCALES: Array<[string, string | undefined]> = [ + ['no request locale', undefined], + ['request locale wins', 'en-US'], +]; + +// ───────────────────────── the parity pins ───────────────────────── + +describe('#6216 — runtime/dispatcher face: byte-for-byte parity with the pre-#6216 assembly', () => { + const AUTHZ_SHAPES: Array<[string, ResolvedAuthzContext]> = [ + ['anonymous', ANONYMOUS], + ['minimal human', HUMAN_MINIMAL], + ['full human (tenant/email/token/tabs/posture)', HUMAN_FULL], + ]; + + const OAUTH_SHAPES: Array<[string, OAuthTokenProvenance | undefined]> = [ + ['no oauth', undefined], + ['agent, read-only ceiling', { + userId: 'u1', scopes: ['data:read'], clientId: 'cli1', + scopePermissions: ['agent_data_read'], delegatesActions: false, + }], + ['agent, read+write ceiling', { + userId: 'u1', scopes: ['data:write'], clientId: 'cli1', + scopePermissions: ['agent_data_read', 'agent_data_write'], delegatesActions: false, + }], + ['agent delegating actions', { + userId: 'u1', scopes: ['data:read', 'actions:execute'], clientId: 'cli1', + scopePermissions: ['agent_data_read'], delegatesActions: true, + }], + ['agent with an EMPTY ceiling (no data scope)', { + userId: 'u1', scopes: ['actions:execute'], clientId: 'cli1', + scopePermissions: [], delegatesActions: true, + }], + ['bearer WITHOUT a client (azp) — still human', { + userId: 'u1', scopes: ['data:read'], + scopePermissions: ['agent_data_read'], delegatesActions: false, + }], + ['bearer for a DIFFERENT user — scopes withheld', { + userId: 'other', scopes: ['data:read'], + scopePermissions: ['agent_data_read'], delegatesActions: false, + }], + ]; + + for (const [authzName, authz] of AUTHZ_SHAPES) { + for (const [oauthName, oauth] of OAUTH_SHAPES) { + for (const [locName, localization] of LOCALIZATIONS) { + for (const [rlName, requestLocale] of REQUEST_LOCALES) { + it(`${authzName} · ${oauthName} · ${locName} · ${rlName}`, () => { + const now = assembleExecutionContextOrGuest({ + authz, + oauth, + localization, + requestLocale, + accessToken: authz.accessToken, + }); + const before = legacyDispatcherAssembly(authz, oauth, localization, requestLocale); + expect(observable(now)).toEqual(observable(before)); + }); + } + } + } + } +}); + +describe('#6216 — REST face: byte-for-byte parity with the pre-#6216 assembly', () => { + const AUTHZ_SHAPES: Array<[string, ResolvedAuthzContext]> = [ + ['anonymous', ANONYMOUS], + ['minimal human', HUMAN_MINIMAL], + ['full human (tenant/email/token/tabs/posture)', HUMAN_FULL], + ]; + + for (const [authzName, authz] of AUTHZ_SHAPES) { + for (const [locName, localization] of LOCALIZATIONS) { + for (const [rlName, requestLocale] of REQUEST_LOCALES) { + it(`${authzName} · ${locName} · ${rlName}`, () => { + const now = assembleExecutionContext({ + authz, + oauth: undefined, + localization, + requestLocale, + // The named per-face divergence: REST has never carried the + // session bearer, and #6216 preserves that. + accessToken: undefined, + }); + const before = legacyRestAssembly(authz, localization ?? {}, requestLocale); + if (before === undefined) { + expect(now).toBeUndefined(); + return; + } + expect(now).toBeDefined(); + expect(observable(now)).toEqual(observable(before)); + }); + } + } + } +}); + +describe('#6216 — the anonymous face, in BOTH directions', () => { + it('the DEFAULT entry is fail-closed: no principal → no context (the surface answers 401)', () => { + expect( + assembleExecutionContext({ + authz: ANONYMOUS, + oauth: undefined, + localization: { timezone: 'Asia/Shanghai', locale: 'zh-CN', currency: 'CNY' }, + requestLocale: 'en-US', + accessToken: 'sess_token_abc', + }), + ).toBeUndefined(); + }); + + it('the GUEST entry produces the dispatcher guest envelope, unchanged', () => { + const ctx = assembleExecutionContextOrGuest({ + authz: ANONYMOUS, + oauth: undefined, + localization: undefined, + requestLocale: undefined, + accessToken: undefined, + }); + // The exact envelope, key set included — `explain-engine.ts` reads + // `principalKind === 'guest'` for its EXTERNAL posture floor, and the + // `guest` position is the declared vocabulary for what anonymous may do. + expect(ctx).toEqual({ + positions: ['guest'], + permissions: [], + systemPermissions: [], + isSystem: false, + principalKind: 'guest', + org_user_ids: [], + accessible_org_ids: [], + }); + expect(Object.keys(ctx)).not.toContain('userId'); + expect(Object.keys(ctx)).not.toContain('posture'); + }); + + it('an authenticated request is unaffected by WHICH entry the face chose', () => { + const input = { + authz: HUMAN_FULL, + oauth: undefined, + localization: { timezone: 'Asia/Shanghai', locale: 'zh-CN' }, + requestLocale: undefined, + accessToken: HUMAN_FULL.accessToken, + } as const; + expect(assembleExecutionContextOrGuest(input)).toEqual(assembleExecutionContext(input)); + }); +}); + +describe('#6216 — the named per-face divergences are values, not switches', () => { + it('a face that withholds the session bearer emits NO accessToken key', () => { + const ctx = assembleExecutionContext({ + authz: HUMAN_FULL, + oauth: undefined, + localization: undefined, + requestLocale: undefined, + accessToken: undefined, + })!; + expect(Object.keys(ctx)).not.toContain('accessToken'); + }); + + it('a face that carries it emits the bearer verbatim', () => { + const ctx = assembleExecutionContext({ + authz: HUMAN_FULL, + oauth: undefined, + localization: undefined, + requestLocale: undefined, + accessToken: HUMAN_FULL.accessToken, + })!; + expect(ctx.accessToken).toBe('sess_token_abc'); + }); + + it('a face that accepts no OAuth token cannot produce an agent principal', () => { + const ctx = assembleExecutionContext({ + authz: HUMAN_FULL, + oauth: undefined, + localization: undefined, + requestLocale: undefined, + accessToken: undefined, + })!; + expect(ctx.principalKind).toBe('human'); + expect(Object.keys(ctx)).not.toContain('onBehalfOf'); + expect(Object.keys(ctx)).not.toContain('oauthScopes'); + }); +}); + +describe('#6216 — the field set is CLOSED', () => { + /** + * The non-entry partition, spelled again here on purpose: the module's + * `NonEntryExecutionContextField` is a type and cannot be read at runtime, so + * this list is the runtime mirror of it. A new `ExecutionContext` field makes + * this test red until it is added to ONE of the two lists — the same decision + * the compiler demands of `ExecutionContextEntryFields`, enforced a second + * way in case someone reaches for `as any` to get past the first. + */ + const NON_ENTRY_FIELDS = [ + 'actor', + 'attributedUserId', + 'rlsMembership', + 'transaction', + 'traceId', + 'flowRunId', + 'skipTriggers', + 'skipAutomations', + 'seedReplay', + 'skipStateMachine', + 'preserveAudit', + ]; + + it('every ExecutionContext field is either assembled at the entry or declared non-entry', () => { + const schemaFields = Object.keys((ExecutionContextSchema as any).shape).sort(); + const decided = [...ENTRY_EXECUTION_CONTEXT_FIELDS, ...NON_ENTRY_FIELDS].sort(); + expect(decided).toEqual(schemaFields); + }); + + it('no field is decided twice', () => { + const decided = [...ENTRY_EXECUTION_CONTEXT_FIELDS, ...NON_ENTRY_FIELDS]; + expect(new Set(decided).size).toBe(decided.length); + }); + + it('every assembled key belongs to the closed set — nothing leaks in', () => { + const ctx = assembleExecutionContextOrGuest({ + authz: HUMAN_FULL, + oauth: { + userId: 'u1', scopes: ['data:write', 'actions:execute'], clientId: 'cli1', + scopePermissions: ['agent_data_read', 'agent_data_write'], delegatesActions: true, + }, + localization: { timezone: 'Asia/Shanghai', locale: 'zh-CN', currency: 'CNY' }, + requestLocale: 'en-US', + accessToken: 'sess_token_abc', + }); + for (const key of Object.keys(ctx)) { + expect(ENTRY_EXECUTION_CONTEXT_FIELDS).toContain(key); + } + }); +}); + +describe('#6216 — the measured residual: keys that were present-with-undefined', () => { + // Reported rather than hidden. The pre-#6216 dispatcher assigned + // `ctx.timezone` / `ctx.locale` unconditionally inside its authenticated + // branch, and the REST literal always spelled `tenantId` / `email` — so both + // faces could emit a key whose value was `undefined`. The shared assembler + // omits those keys instead. This is invisible to `ctx.x` reads, to + // `JSON.stringify`, and to spreading the envelope; it is visible to + // `Object.keys` / `in`, so it is pinned here rather than left to be + // rediscovered. + it('dispatcher: an authenticated request with no localization no longer spells timezone/locale as undefined', () => { + const input = { + authz: HUMAN_MINIMAL, + oauth: undefined, + localization: undefined, + requestLocale: undefined, + accessToken: undefined, + } as const; + const before = legacyDispatcherAssembly(HUMAN_MINIMAL, undefined, undefined, undefined); + const now = assembleExecutionContextOrGuest(input); + + expect(Object.keys(before)).toContain('timezone'); + expect(before.timezone).toBeUndefined(); + expect(Object.keys(now)).not.toContain('timezone'); + expect((now as any).timezone).toBeUndefined(); + // …and nothing observable moved. + expect(observable(now)).toEqual(observable(before)); + expect(JSON.stringify(now)).toBe(JSON.stringify(observable(before))); + }); + + it('REST: a principal with no tenant no longer spells tenantId/email as undefined', () => { + const before = legacyRestAssembly(HUMAN_MINIMAL, {}, undefined); + const now = assembleExecutionContext({ + authz: HUMAN_MINIMAL, + oauth: undefined, + localization: {}, + requestLocale: undefined, + accessToken: undefined, + })!; + + expect(Object.keys(before)).toContain('tenantId'); + expect(before.tenantId).toBeUndefined(); + expect(Object.keys(now)).not.toContain('tenantId'); + expect(observable(now)).toEqual(observable(before)); + }); +}); diff --git a/packages/core/src/security/assemble-execution-context.ts b/packages/core/src/security/assemble-execution-context.ts new file mode 100644 index 0000000000..b85887375e --- /dev/null +++ b/packages/core/src/security/assemble-execution-context.ts @@ -0,0 +1,356 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * assembleExecutionContext — the SINGLE assembly of an inbound request's + * {@link ExecutionContext}, shared by every transport entry point. + * + * `resolveAuthzContext` (next door) already made AUTHORIZATION resolution + * single-sourced. The step AFTER it — turning the resolved + * {@link ResolvedAuthzContext} into the `ExecutionContext` envelope that + * reaches enforcement — stayed hand-written per transport, and that duplication + * produced a measured defect family: + * + * - **#6071 — field drift.** The REST copy never set `principalKind`, so every + * enforcement judgment reading it (explain's guest⇒EXTERNAL floor, the + * security plugin's agent baseline, the perf-disclosure gate) was silently + * never-true on that face. + * - **#6206 / #6551 — dropped fields.** The share-link copies omitted + * `accessible_org_ids`, and the `group` posture's Layer 0 wall reads it + * directly: real 403s for callers who should have been let through. Both + * surfaces were since converted to pass the WHOLE envelope through. + * + * Both defects are the same shape: a field exists on `ExecutionContext`, one + * copy carries it, another silently does not. This module makes that shape + * unrepresentable by CLOSING the field set with a type + * ({@link ExecutionContextEntryFields}) — every field a transport entry point + * decides must be decided HERE, explicitly, and a new `ExecutionContext` field + * fails to compile until it is either assembled or listed as + * non-entry-resolved. + * + * ## Two named entries — the anonymous face is genuinely divergent (#6216) + * + * The maintainer ruling of 2026-08-08 on #6216 (Option A) settled the one + * question that blocked convergence: what an anonymous request yields. + * + * - {@link assembleExecutionContext} — the DEFAULT, fail-closed entry. No + * resolved principal → `undefined`, and the surface answers 401. This is the + * REST face's contract, unchanged. + * - {@link assembleExecutionContextOrGuest} — the EXPLICIT guest entry. No + * resolved principal → a first-class guest envelope + * (`principalKind: 'guest'`, `positions: ['guest']`), which the runtime / + * MCP dispatcher has always produced and whose consumers are live + * (`plugin-security/explain-engine.ts`: guest ⇒ `EXTERNAL` posture). A + * surface adopts this entry ONLY when its product semantics serve anonymous + * principals. + * + * Neither surface's runtime behaviour changes. What changes is that the + * divergence is now NAMED API rather than drift — and the same is true of the + * per-face values below (`accessToken`, `oauth`, `localization`): they are + * REQUIRED inputs, so a face cannot silently omit one, and what a face chooses + * to withhold it withholds on the record. + * + * Options B (guest everywhere — turns REST's anonymous 401s into authz-shaped + * denials and makes anonymous-serving the DEFAULT posture of a new surface) and + * C (no-ctx everywhere — deletes the guest principal, the `guest` position and + * explain's `EXTERNAL` floor) were both considered and rejected: each breaks a + * live consumer side. + */ + +import type { ExecutionContext } from '@objectstack/spec/kernel'; + +import type { ResolvedAuthzContext } from './resolve-authz-context.js'; + +/** + * `ExecutionContext` fields a transport ENTRY POINT does not resolve — they are + * per-operation flags, engine internals, or attribution supplied further down + * the stack. Listing one here is a deliberate, reviewable statement that no + * request-identity resolution produces it; everything NOT listed is part of the + * closed entry set below and must be assembled. + * + * - `actor` / `attributedUserId` — audit attribution, set by the host or the + * hook layer (ADR-0014 D2, #4586), never by identity resolution. + * - `rlsMembership` — engine-side RLS scoping cache. + * - `transaction` / `traceId` — per-operation handles. + * - `flowRunId`, `skipTriggers`, `skipAutomations`, `seedReplay`, + * `skipStateMachine`, `preserveAudit` — per-write behaviour flags, + * server-constructed at the call site. + */ +type NonEntryExecutionContextField = + | 'actor' + | 'attributedUserId' + | 'rlsMembership' + | 'transaction' + | 'traceId' + | 'flowRunId' + | 'skipTriggers' + | 'skipAutomations' + | 'seedReplay' + | 'skipStateMachine' + | 'preserveAudit'; + +/** + * The CLOSED field set every transport entry point must decide. Derived from + * `ExecutionContext` itself, so adding a field to `ExecutionContextSchema` + * widens this union automatically — and the assembly below stops compiling + * until the new field is either assembled or declared non-entry-resolved. + */ +export type EntryExecutionContextField = Exclude< + keyof ExecutionContext, + NonEntryExecutionContextField +>; + +/** + * One value per closed field. Every key is REQUIRED (`-?`) while the VALUE may + * be `undefined` — the decision may not be omitted, only made explicitly. This + * is the type that makes the #6071 drift class unrepresentable. + */ +export type ExecutionContextEntryFields = { + [K in EntryExecutionContextField]-?: ExecutionContext[K]; +}; + +/** + * Emission order of the assembled envelope, and a second, independent + * exhaustiveness bite: `satisfies` rejects a stale name, and + * `_ENTRY_FIELDS_EXHAUSTIVE` below rejects a missing one. + */ +export const ENTRY_EXECUTION_CONTEXT_FIELDS = [ + 'positions', + 'permissions', + 'systemPermissions', + 'isSystem', + 'principalKind', + 'onBehalfOf', + 'audience', + 'userId', + 'tenantId', + 'email', + 'accessToken', + 'tabPermissions', + 'posture', + 'org_user_ids', + 'accessible_org_ids', + 'oauthScopes', + 'timezone', + 'locale', + 'currency', +] as const satisfies readonly EntryExecutionContextField[]; + +/** + * Compile-time proof that {@link ENTRY_EXECUTION_CONTEXT_FIELDS} covers the + * whole closed set. A new `ExecutionContext` field that is neither assembled + * nor listed as non-entry-resolved makes this `never`, and the initializer + * below fails to compile. + */ +type MissingEntryField = Exclude< + EntryExecutionContextField, + (typeof ENTRY_EXECUTION_CONTEXT_FIELDS)[number] +>; +const _ENTRY_FIELDS_EXHAUSTIVE: [MissingEntryField] extends [never] ? true : never = true; +void _ENTRY_FIELDS_EXHAUSTIVE; + +/** + * OAuth 2.1 access-token provenance. Reaches the assembler from the `/mcp` + * dispatch door ALONE (`acceptOAuthAccessToken`) — OAuth bearers carry coarse + * tool-family scopes enforced at MCP tool dispatch, so honouring them on + * another surface would bypass that scope model entirely. + */ +export interface OAuthTokenProvenance { + /** The human `sub` the token was issued for. */ + userId: string; + /** Granted scopes, surfaced on the envelope so MCP can narrow tool families. */ + scopes: string[]; + /** + * The authorized client (`azp`). Present ⇒ this is an AI AGENT acting on + * behalf of the human `userId`; absent ⇒ the token names no client and the + * principal stays human (the scopes are still surfaced). + */ + clientId?: string; + /** + * The agent's OWN permission CEILING, derived from {@link scopes} by the door + * that speaks the OAuth scope vocabulary + * (`scopesToAgentPermissionSets`, `@objectstack/spec/ai`). + * + * Interpreted THERE and not here on purpose: the scope vocabulary is + * MCP-domain knowledge and `@objectstack/core` is the microkernel — it should + * not acquire a dependency on the AI subdomain (concretely, every package + * whose test config aliases `@objectstack/core` to its source would then have + * to resolve `@objectstack/spec/ai` too, down to `driver-memory`). What the + * ceiling REPLACES on the envelope is decided below, once, for every face — + * and that is the part that drifted. + */ + scopePermissions: string[]; + /** + * Whether the token carries the user's consent to let this agent invoke + * actions on their behalf — the `actions:execute` scope + * (`MCP_OAUTH_SCOPE_ACTIONS`), evaluated at the same door for the same + * reason. + */ + delegatesActions: boolean; +} + +/** Reference localization for an authenticated principal (@see resolveLocalizationContext). */ +export interface EntryLocalization { + timezone?: string; + locale?: string; + currency?: string; +} + +/** + * Everything the shared assembly needs. Every key is REQUIRED so a face cannot + * silently omit one — a face that has no value for an input passes `undefined` + * on the record, which is what turns the remaining divergences into named API. + */ +export interface ExecutionContextAssemblyInput { + /** The shared authorization envelope (@see resolveAuthzContext). */ + authz: ResolvedAuthzContext; + /** + * OAuth access-token provenance, or `undefined` on a face that does not + * accept one. Only the `/mcp` dispatch door passes a value; REST passes + * `undefined` — which is why `principalKind: 'agent'`, `onBehalfOf` and + * `oauthScopes` are not representable there. + */ + oauth: OAuthTokenProvenance | undefined; + /** + * Resolved reference localization, or `undefined` when the face resolved + * none (anonymous requests have no scope to resolve against). + */ + localization: EntryLocalization | undefined; + /** + * The request's OWN locale preference (`Accept-Language`, `?locale`, …), + * which wins over the workspace default; `undefined` when the caller + * expresses none. Each face extracts it its own way — the PRECEDENCE lives + * here so the two cannot disagree about it (#3957). + */ + requestLocale: string | undefined; + /** + * The session bearer to carry on the envelope, surfaced to hooks as + * `session.accessToken` (`objectql/engine.ts` `buildSession`, + * `spec/data/hook.zod.ts`). + * + * A NAMED per-face divergence, preserved deliberately (#6216): the runtime / + * MCP dispatcher passes `authz.accessToken`; the REST face has never carried + * it and passes `undefined`, because widening a published hook surface to + * expose the session token on a second transport is a product decision, not a + * refactor. Being a required input, the choice is on the record at each face + * instead of being an omission nobody can see. + */ + accessToken: string | undefined; +} + +/** Drop `undefined`-valued keys, emitting in the closed set's declared order. */ +function emit(fields: ExecutionContextEntryFields): ExecutionContext { + const ctx: Record = {}; + for (const key of ENTRY_EXECUTION_CONTEXT_FIELDS) { + const value = fields[key]; + if (value !== undefined) ctx[key] = value; + } + return ctx as ExecutionContext; +} + +/** + * Decide every field of the closed set. The one branch is the principal's + * PROVENANCE (agent / human / guest); everything else is a single expression + * shared by every face. + */ +function entryFields( + input: ExecutionContextAssemblyInput, + anonymous: boolean, +): ExecutionContextEntryFields { + const { authz, oauth, localization, requestLocale, accessToken } = input; + + // [ADR-0090 D10 — agent principal] An OAuth access token naming an authorized + // client (`azp`) is an AI agent acting ON BEHALF OF the human `sub`. The + // agent's OWN grants are its scope-derived CEILING, NOT the user's — so the + // user-derived positions/permissions/systemPermissions are REPLACED with that + // ceiling. The human stays the delegator (`onBehalfOf`), and the security + // engine intersects the two so the agent can never exceed EITHER its + // consented scope OR the user's own reach (confused-deputy prevention). + // `userId` stays the human so owner-stamping and `current_user.*` RLS resolve + // to them. + const agent = !anonymous && oauth?.clientId ? oauth : undefined; + + return { + // [ADR-0090 D9/D10] Principal taxonomy at the HTTP entry: a session-backed + // request is a human principal; a sessionless one is a guest, holding the + // built-in `guest` position implicitly and exclusively. Internal engine + // calls that construct bare contexts never pass through here, so the + // security plugin's empty-context skip path keeps its meaning. + positions: agent ? [] : anonymous ? ['guest'] : authz.positions, + permissions: agent ? agent.scopePermissions : authz.permissions, + // [ADR-0090 D10] System capabilities on the agent principal gate business + // ACTION invocation (`actionPermissionError` reads `ctx.systemPermissions`) + // — a door SEPARATE from the object CRUD/FLS/RLS intersection, which is + // driven by the resolved ceiling SETS (they carry no caps, so cap-gated + // OBJECT access stays denied to the agent regardless of this line). The + // `actions:execute` scope IS the user's consent to let this agent invoke + // actions on their behalf; without it the agent holds none. + systemPermissions: agent + ? agent.delegatesActions + ? (authz.systemPermissions ?? []) + : [] + : authz.systemPermissions, + isSystem: false, + principalKind: agent ? 'agent' : anonymous ? 'guest' : 'human', + onBehalfOf: agent ? { userId: authz.userId!, principalKind: 'human' } : undefined, + // [ADR-0090 D10/D11 — P1 shape] No transport resolves an external + // (portal/partner) audience yet; `undefined` reads as 'internal'. Named + // here rather than excluded so the gap is visible in the closed set instead + // of being invisible outside it — when an external principal type lands, + // this is the line that must change, on every face at once. + audience: undefined, + userId: authz.userId, + tenantId: authz.tenantId, + email: authz.email, + accessToken, + tabPermissions: authz.tabPermissions, + // [ADR-0095 D2 / #2947] The derived posture rung, carried so every + // transport presents enforcement the SAME value. Present only for an + // authenticated principal (guest → absent). + posture: authz.posture, + /** Fellow-org user IDs for RLS scoping of identity tables. */ + org_user_ids: authz.org_user_ids, + // [ADR-0105 D2] The caller's org access set — the `group` posture's Layer 0 + // wall reads it directly, so every transport must carry it (#6206). + accessible_org_ids: authz.accessible_org_ids, + // OAuth provenance: surface the token's granted scopes so the MCP + // dispatcher can narrow the exposed tool families (undefined for every + // other provenance = not scope-limited). + oauthScopes: oauth && authz.userId === oauth.userId ? oauth.scopes : undefined, + // Anonymous → no localization (no scope to resolve against); the engine + // default stands. [#3957] The request's OWN language preference wins over + // the workspace default, so a rejection message is not rendered in English + // beside the Chinese label of the very field it names. + timezone: anonymous ? undefined : localization?.timezone, + locale: anonymous ? undefined : (requestLocale ?? localization?.locale), + currency: anonymous ? undefined : localization?.currency, + }; +} + +/** + * The DEFAULT, fail-closed entry (#6216 Option A). An unauthenticated request + * yields NO context — the surface answers 401. Every surface uses this one + * unless serving anonymous principals is part of its product semantics. + */ +export function assembleExecutionContext( + input: ExecutionContextAssemblyInput, +): ExecutionContext | undefined { + if (!input.authz.userId) return undefined; + return emit(entryFields(input, false)); +} + +/** + * The EXPLICIT guest entry (#6216 Option A). An unauthenticated request becomes + * a first-class guest principal — `principalKind: 'guest'`, `positions: + * ['guest']` — which enforcement consumers read today + * (`plugin-security/explain-engine.ts`: guest ⇒ `EXTERNAL` posture). + * + * Adopt this ONLY on a surface that genuinely serves anonymous principals: the + * built-in `guest` position is the declared vocabulary for "what anonymous may + * do", and handing a guest envelope to a surface that previously answered 401 + * converts an authentication failure into an authorization evaluation. + */ +export function assembleExecutionContextOrGuest( + input: ExecutionContextAssemblyInput, +): ExecutionContext { + return emit(entryFields(input, !input.authz.userId)); +} diff --git a/packages/core/src/security/index.ts b/packages/core/src/security/index.ts index cf9f330a9b..47fc70e8f9 100644 --- a/packages/core/src/security/index.ts +++ b/packages/core/src/security/index.ts @@ -91,6 +91,20 @@ export { type ResolveLocalizationInput, } from './resolve-authz-context.js'; +// #6216 (maintainer ruling 2026-08-08, Option A) — the SINGLE ExecutionContext +// assembly shared by every transport entry point, with the anonymous face as +// two NAMED entries (fail-closed default / explicit guest) instead of drift. +export { + assembleExecutionContext, + assembleExecutionContextOrGuest, + ENTRY_EXECUTION_CONTEXT_FIELDS, + type EntryExecutionContextField, + type ExecutionContextEntryFields, + type ExecutionContextAssemblyInput, + type OAuthTokenProvenance, + type EntryLocalization, +} from './assemble-execution-context.js'; + // ADR-0095 D2/D3 — the monotonic posture ladder: derivation from capability // grants + the rung→injection-rule mapping and its tested invariants. export { diff --git a/packages/rest/src/rest-exec-ctx-principal-kind.test.ts b/packages/rest/src/rest-exec-ctx-principal-kind.test.ts index 61fde0ee8d..c7578a6e8b 100644 --- a/packages/rest/src/rest-exec-ctx-principal-kind.test.ts +++ b/packages/rest/src/rest-exec-ctx-principal-kind.test.ts @@ -77,6 +77,12 @@ const makeAuth = () => ({ const cookie = headers?.get?.('cookie'); if (cookie === 'admin') return { user: { id: 'admin1' } }; if (cookie === 'member') return { user: { id: 'member1' } }; + // [#6216] A session that DOES carry a bearer token, so the + // `accessToken` pin below exercises a live branch of + // `resolveAuthzContext` rather than an absent value. + if (cookie === 'member-tok') { + return { user: { id: 'member1' }, session: { token: 'sess_tok_rest' } }; + } return undefined; }, }, @@ -218,3 +224,46 @@ describe('#6071 — activated-branch pin: the perf-disclosure gate (perf-timing. expect(gate.privileged).toBe(true); }); }); + +describe('#6216 — the REST face assembles through the SHARED assembler, output unchanged', () => { + // The maintainer ruling of 2026-08-08 (Option A) converged the two + // hand-written assemblies onto one module and is explicit that NEITHER + // surface changes runtime behaviour. The frozen-transcription parity matrix + // lives next to the assembler + // (`packages/core/src/security/assemble-execution-context.test.ts`); these + // two pins are the same claim measured on the WIRE, through the real + // `computeExecCtx` → `resolveAuthzContext` pipeline, because that is where a + // wiring mistake (wrong entry, wrong per-face input) would actually show. + + it('emits exactly the pre-#6216 key set for a session-backed request', async () => { + const { ctx } = await request({ cookie: 'member' }); + + // Golden key set, not a subset check: a field ARRIVING that never used + // to (the direction a naive convergence fails in) is as much a + // behaviour change as a field going missing, and only an exact + // comparison catches both. + expect(new Set(Object.keys(ctx))).toEqual(new Set([ + 'positions', 'permissions', 'systemPermissions', 'isSystem', 'principalKind', + 'userId', 'email', 'posture', 'org_user_ids', 'accessible_org_ids', + 'timezone', 'locale', + // Not ExecutionContext fields — the REST face still adds these + // itself, after assembly (ADR-0069 gate posture / ADR-0057 D10). + '__kernel', + ])); + }); + + it('still WITHHOLDS accessToken — the named per-face divergence, not an omission', async () => { + // `ExecutionContext.accessToken` reaches hooks as `session.accessToken` + // (`objectql/engine.ts` buildSession, `spec/data/hook.zod.ts`), and the + // runtime / MCP face has always carried it. This transport never has. + // #6216 makes that an explicit `accessToken: undefined` input at this + // face rather than a silent gap — widening a published hook surface to + // a second transport is a product decision, not a refactor. If REST + // should carry it, this pin is the thing that must change, deliberately. + const { ctx } = await request({ cookie: 'member-tok' }); + + expect(ctx?.userId).toBe('member1'); + expect(Object.keys(ctx)).not.toContain('accessToken'); + expect(ctx.accessToken).toBeUndefined(); + }); +}); diff --git a/packages/rest/src/rest-server.ts b/packages/rest/src/rest-server.ts index aed1d2eb7a..a4daa1d194 100644 --- a/packages/rest/src/rest-server.ts +++ b/packages/rest/src/rest-server.ts @@ -2,6 +2,7 @@ import { IHttpServer, resolveAuthzContext, resolveLocalizationContext, isAuthGateAllowlisted, + assembleExecutionContext, shouldDenyAnonymous, ANONYMOUS_DENY_BODY, ANONYMOUS_DENY_STATUS, } from '@objectstack/core'; import { @@ -2556,6 +2557,11 @@ export class RestServer { try { return await api.getSession({ headers: h }); } catch { return undefined; } }; const authz = await resolveAuthzContext({ ql, headers, getSession }); + // [#6216] The anonymous contract IS the shared assembler's default + // entry: no resolved principal → no context → 401. Taken early here + // only so an anonymous request does not pay for the localization and + // auth-gate reads it would never use; `assembleExecutionContext` + // below re-affirms the same rule. if (!authz.userId) return undefined; const settings = this.settingsServiceProvider @@ -2580,69 +2586,49 @@ export class RestServer { } } catch { /* gate is best-effort — never break context resolution */ } - // [#3957] The request's OWN locale wins over the workspace default. - // Metadata (labels, option text) is already translated per - // `Accept-Language` / `?locale` — so if the write path read only the - // workspace `localization.locale`, one screen could show a Chinese - // field label next to an English rejection message for that same - // field. `localization.locale` stays the fallback for a caller that - // expresses no preference (a server-to-server client, a job). - const effectiveLocale = this.extractLocale(req) ?? localization.locale; + // [#6216 — maintainer ruling 2026-08-08, Option A] The assembly of + // the ExecutionContext itself is now the SINGLE shared one + // (`assembleExecutionContext`, @objectstack/core), the same module + // the runtime / MCP dispatcher assembles through. Before this, the + // step AFTER `resolveAuthzContext` was two hand-written copies and + // the copies drifted: #6071 (this face never set `principalKind`, + // so every enforcement judgment reading it was silently never-true + // here) and #6206 / #6551 (a dropped `accessible_org_ids` produced + // real 403s on the share-link faces). The field set is closed by + // type there, so a new `ExecutionContext` field cannot land on one + // face and miss another. + // + // This face takes the FAIL-CLOSED DEFAULT entry — no resolved + // principal → no context → `enforceAuth` answers 401. The runtime + // face takes the explicit guest entry instead. Both behaviours are + // unchanged; the divergence is now named API rather than drift. + const base = assembleExecutionContext({ + authz, + // OAuth access tokens are honoured on the `/mcp` door alone + // (`acceptOAuthAccessToken`), precisely so coarse tool-family + // scopes cannot ride onto REST — so `principalKind: 'agent'`, + // `onBehalfOf` and `oauthScopes` are not representable here. + oauth: undefined, + localization, + // [#3957] The request's OWN locale wins over the workspace + // default; the precedence itself lives in the shared assembler. + requestLocale: this.extractLocale(req), + // A NAMED divergence, deliberately preserved (#6216): this + // transport has never carried the better-auth session bearer on + // the envelope, and `ExecutionContext.accessToken` is a + // PUBLISHED hook surface (`session.accessToken`, hook.zod.ts). + // Widening it to a second transport is a product decision, not + // a refactor — so REST withholds it on the record. + accessToken: undefined, + }); + // Unreachable: the anonymous early-return above already took this + // branch. Kept because the shared entry — not this method — is the + // authority on what an anonymous request yields. + if (!base) return undefined; const execCtx = { - userId: authz.userId, - tenantId: authz.tenantId, - email: authz.email, - positions: authz.positions, - permissions: authz.permissions, - systemPermissions: authz.systemPermissions, - ...(authz.tabPermissions ? { tabPermissions: authz.tabPermissions } : {}), - // [ADR-0095 D2 / #2947] Carry the derived posture rung so the - // enforcement side reads the SAME value the resolver computed, - // instead of dropping it here (the boundary this issue closes). - ...(authz.posture ? { posture: authz.posture } : {}), - // [ADR-0090 D9/D10 / #6071] Principal taxonomy, resolved by the - // SAME rule the runtime / MCP entry applies - // (`packages/runtime/src/security/resolve-execution-context.ts`) - // so the two transports can no longer disagree about WHO is - // asking. Enforcement reads this field - // (`plugin-security/explain-engine.ts` derivePosture, - // `security-plugin.ts` agent baseline, `perf-timing.ts` - // disclosure gate), and until now it arrived on the dispatcher - // face only — every such judgment was silently never-true on - // REST. - // - // `'human'` is the ONLY kind this transport can produce, and - // that is the runtime rule restricted to the provenances this - // door accepts, not a second derivation: - // - `agent` (+ the `onBehalfOf` delegation link, which ONLY - // the agent arm sets) requires an OAuth access token naming - // an authorized client. This transport never accepts one: - // OAuth bearers are honoured on the `/mcp` door alone - // (`acceptOAuthAccessToken`, set solely by the dispatcher's - // `/mcp` path match) precisely so coarse tool-family scopes - // cannot ride onto REST. So `onBehalfOf` is not - // representable here — same as every human principal on the - // dispatcher face, which also leaves it undefined. - // - `guest` is not representable either: this method returned - // `undefined` above when the envelope carried no `userId`, - // so an anonymous REST caller gets NO context at all (and - // `enforceAuth` 401s it). Anonymous REST is unchanged. - // - A session-backed OR API-key-backed principal is `human` on - // both faces (pinned on the runtime side by - // resolve-execution-context.test.ts, "an authenticated - // (API-key) request resolves as a human principal"). - principalKind: 'human', - isSystem: false, - org_user_ids: authz.org_user_ids, - // [ADR-0105 D2] The caller's org access set — the `group` - // posture's Layer 0 wall reads it directly, so it must reach - // enforcement on every transport, not just this one. - accessible_org_ids: authz.accessible_org_ids, + ...base, ...(authGate ? { authGate } : {}), - ...(localization.timezone ? { timezone: localization.timezone } : {}), - ...(effectiveLocale ? { locale: effectiveLocale } : {}), - ...(localization.currency ? { currency: localization.currency } : {}), // Internal: resolved kernel so the nav-serving path can probe // requiresService capability gates (ADR-0057 D10). NOT an // authorization input — never read by RLS/permission logic. diff --git a/packages/runtime/src/security/resolve-execution-context.test.ts b/packages/runtime/src/security/resolve-execution-context.test.ts index 2173642c19..8b1aac439c 100644 --- a/packages/runtime/src/security/resolve-execution-context.test.ts +++ b/packages/runtime/src/security/resolve-execution-context.test.ts @@ -510,3 +510,49 @@ describe('resolveExecutionContext — ADR-0090 D10 agent principal (OAuth on /mc }); }); + +describe('#6216 — this face keeps the EXPLICIT GUEST entry, and its own named inputs', () => { + // The maintainer ruling of 2026-08-08 (Option A) converged the dispatcher and + // REST assemblies onto one shared module with two named entries, and is + // explicit that NEITHER surface changes runtime behaviour. The frozen + // transcription parity matrix lives beside the assembler + // (`packages/core/src/security/assemble-execution-context.test.ts`); these + // pins are the same claim measured through THIS resolver, where a wiring + // mistake (the wrong entry, a per-face input dropped) would actually show. + + it('a sessionless request still yields the WHOLE guest envelope, key set included', async () => { + const ctx = await resolveExecutionContext(makeOpts([], {})); + // Exact, not a subset: taking the fail-closed default entry here by mistake + // would return no context at all, and picking up an authenticated-only + // field would be a behaviour change in the other direction. + expect(ctx).toEqual({ + positions: ['guest'], + permissions: [], + systemPermissions: [], + isSystem: false, + principalKind: 'guest', + org_user_ids: [], + accessible_org_ids: [], + }); + }); + + it('carries the session bearer as accessToken — the divergence REST names as withheld', async () => { + // `ExecutionContext.accessToken` reaches hooks as `session.accessToken` + // (`objectql/engine.ts` buildSession). This face has always carried it and + // still does; the REST face passes `accessToken: undefined` on the record. + const authService = { + api: { + getSession: async () => ({ user: { id: 'u1' }, session: { token: 'sess_tok_rt' } }), + }, + }; + const ctx = await resolveExecutionContext({ + getService: async (name: string) => (name === 'auth' ? authService : undefined), + getQl: async () => makeQl([]), + request: { headers: {} }, + } as any); + + expect(ctx.userId).toBe('u1'); + expect(ctx.principalKind).toBe('human'); + expect(ctx.accessToken).toBe('sess_tok_rt'); + }); +}); diff --git a/packages/runtime/src/security/resolve-execution-context.ts b/packages/runtime/src/security/resolve-execution-context.ts index 93327a5188..5224f4e146 100644 --- a/packages/runtime/src/security/resolve-execution-context.ts +++ b/packages/runtime/src/security/resolve-execution-context.ts @@ -27,6 +27,8 @@ import { preferredLocaleFromHeader } from '@objectstack/spec/system'; import { resolveAuthzContext, resolveLocalizationContext, + assembleExecutionContextOrGuest, + type EntryLocalization, } from '@objectstack/core'; /** @@ -162,108 +164,63 @@ export async function resolveExecutionContext(opts: ResolveOptions): Promise undefined); - const localization = await resolveLocalizationContext({ + localization = await resolveLocalizationContext({ ql, settings, tenantId: authz.tenantId, userId: authz.userId, }); - ctx.timezone = localization.timezone; - // [#3957] The request's OWN language preference wins over the workspace - // default. `ExecutionContext.locale` drives the write path's message catalog - // (rejected-write messages, field labels inside them), and metadata is - // already translated per `Accept-Language` — reading only the workspace - // locale here would put an English rejection next to the Chinese label of - // the very field it names. The workspace value stays the fallback for a - // caller that expresses no preference (a job, a server-to-server client). - ctx.locale = preferredLocaleFromHeader(headers.get('accept-language')) ?? localization.locale; - if (localization.currency) ctx.currency = localization.currency; } - return ctx; + return assembleExecutionContextOrGuest({ + authz, + // The OAuth SCOPE VOCABULARY is interpreted here, at the only door that + // speaks it (`acceptOAuthAccessToken` is set solely by the `/mcp` path + // match), and the shared assembler receives the already-derived grant. It + // decides what that ceiling REPLACES on the envelope — the part that + // drifted — without `@objectstack/core` taking a dependency on the AI + // subdomain. See `OAuthTokenProvenance`. + oauth: oauthPrincipal && { + ...oauthPrincipal, + // [ADR-0090 D10] `data:read` → read-only, `data:write` → CRUD, neither → + // no data access. The agent's OWN grants, never the user's. + scopePermissions: scopesToAgentPermissionSets(oauthPrincipal.scopes), + // The `actions:execute` scope IS the user's consent to let this agent + // invoke actions on their behalf; without it the agent holds none. + delegatesActions: oauthPrincipal.scopes?.includes(MCP_OAUTH_SCOPE_ACTIONS) ?? false, + }, + localization, + // [#3957] The request's OWN language preference wins over the workspace + // default — `ExecutionContext.locale` drives the write path's message + // catalog, and metadata is already translated per `Accept-Language`, so + // reading only the workspace locale would put an English rejection next to + // the Chinese label of the very field it names. The PRECEDENCE lives in the + // shared assembler so the two faces cannot disagree about it. + requestLocale: preferredLocaleFromHeader(headers.get('accept-language')), + // A NAMED divergence (#6216): this face has always carried the session + // bearer down to hooks (`session.accessToken`); the REST face never has. + // Both are preserved — see the assembler's `accessToken` doc. + accessToken: authz.accessToken, + }); } /**