Skip to content

Commit 6a6ebff

Browse files
fix(security)!: an app-authored hook's inherited elevation, its sudo() and an action it reaches by execute() write no identity assignment (#25982)
<!-- claude-projects-attribution: {"github_login":"os-project-manager"} --> _Requested by **Leehom** · [project thread](https://claude.ai/code/project/chan_01TenzsRopZj4jMbvjX8uz1X?thread=cmsg_01TenzsRopZj4jMbvjX8uz1XQXftB7pSBetnK4NoZD2gaR)_ Part of #25653 Clause-②: no (narrowing: an app-authored hook's inherited or sudo() elevation, and an action reached through ObjectRepository.execute(), are refused the four identity objects at run time, as a runAs: 'system' hook already is) Metadata-protocol: none Slice 2 of the card's run-time half. It closes the inherited-context path that slice 1's report (#25954) names among its findings: an app-authored hook that declares no `runAs` and runs under a system context it inherits. It also closes the two neighbouring elevations the order asked to be measured, the in-process `ctx.api.sudo()` and `ObjectRepository.execute()`. ## What changed Slice 1 refuses writes to the four identity objects from a context with `isSystem` and either a `flowRunId` or the app-automation mark. The mark was set only on a hook's `runAs: 'system'` api and on an action body's context. Three other app-authored elevations carried `isSystem` with neither, so the refusal did not see them. | Context | Governed write to `sys_user_position`, `sys_user_permission_set`, `sys_invitation`, `sys_member` | Writes to other objects | |---|---|---| | app-authored hook, no `runAs`, system context inherited (body or handler) | **refused, PERMISSION_DENIED** (was: admitted) | admitted | | app-authored hook's in-process `ctx.api.sudo()`, under any `runAs`, also inside `ctx.api.transaction` | **refused** (was: admitted) | admitted | | action reached through `ctx.api.object(x).execute(y)` | **refused** (was: admitted) | admitted | | app-authored hook, no `runAs`, user's context inherited | not judged here; the ordinary gates judge the user | per the user | | hook registered in code (`engine.registerHook`, no metadata binding), its api and its `sudo()` | admitted, unchanged | admitted | | platform `sudo()`, bare `{ isSystem: true }` writers | admitted, unchanged | admitted | **The predicate that tells app-authored hooks from platform hooks is the hook binder.** A hook is app-authored when it is bound through `bindHooksToEngine`. That is the one door metadata hooks come through: stack and package `hooks:`, runtime-authored hooks, and app artifacts. It is also the set the engine already treats as automations: the binder registers each hook with `meta`, and `skipAutomations` skips exactly those. A platform hook is registered in code with `engine.registerHook` and no `meta`. The audit builtins, security, and every plugin's own hooks are registered this way, and their path does not change. - `objectql` `hook-binder.ts`: the binder passes `appAuthored: true` to `wrapDeclarativeHook`. - `objectql` `hook-wrappers.ts`: when `appAuthored` is set, the wrapper derives the hook's `ctx.api` through a new key before it applies `runAs`, so every `runAs` keeps the provenance. The step is synchronous, so an `'inherit'` hook gains no extra `await`. An async (fire-and-forget) hook runs on the detached copy whenever an api was installed. A platform wrap (no flag) still hands the api through by reference. - `objectql` `hook-run-as.ts`: `APP_AUTHORED_HOOK_API` and `deriveAppAuthoredHookApi`. The key is a symbol and is not exported from the package entry, the same posture as `SEED_USER_GRANTS`. - `objectql` `ScopedContext`: - `[APP_AUTHORED_HOOK_API]()` marks an inherited `isSystem` context `'hook'` when it has no `flowRunId` and no mark. - A private `appAuthored` flag rides every derived child (`transaction`, `beginTransaction`, `withRunAs`, the grant seeding) through one `derive()` helper. - `sudo()` marks `'hook'` only on an app-authored api. Platform `sudo()` is byte-identical. - `objectql` `ObjectRepository.execute()` marks its elevation `'action'`, as `buildActionExecutionContext` already does for the REST and MCP action doors. The census found no caller of `execute()` in objectstack, `examples/`, `apps/` or HotCRM. - `plugin-security`: no predicate change. The hook's subject and prescription now name the three ways a hook is elevated, and the module doc covers them. - `content/docs/permissions/system-context.mdx` row 1b names the new producers and anchors the new `isSystem` read (`engine.ts#inheritsElevation`); `check-system-context-census` exits 0. - Changeset: `objectql` and `plugin-security` `patch`, `Metadata-protocol: none`, BREAKING banner, ADR-0087 `not-required (no-migration-prescription)`. ## Measured before any code (the order's question) On `main` `59d9181e`, with a real `ObjectQL`, the SQL driver, `SecurityPlugin` and QuickJS: - **Does app-authored code reach `ctx.api.sudo()`?** Yes, from an in-process handler. Its writes carried `isSystem` and no mark. A sandboxed body does not reach it, because the sandbox api exposes neither `sudo` nor `execute`. - **Does it reach `ObjectRepository.execute()`?** Yes, from an in-process handler, through `ctx.api.object(x).execute(y)`. The action it reached ran with an elevated, unmarked `api`. - An action host handler's own `sudo()` was already refused, because the action context's mark rides the spread. Both reaches were measured as reachable, so both are fixed here. Census: no hook, action body or host handler in objectstack (`packages/`, `examples/`, `apps/`) or HotCRM writes the four objects, calls `sudo()` from app code, or calls `execute()`. Nothing shipped starts being refused. ## Tests - `runtime/src/app-automation-identity-write-refusal.integration.test.ts`: 50 new cases beside slice 1's 50. They use the same real stack and read the store back past the middleware. - A hook with no `runAs` (body and handler) under a platform system write, × four objects × insert/update/delete, is refused with code, status, subject and prescription. The store does not move and the triggering write aborts. 24 cases. - A hook's in-process `sudo()` under a user's write, × four × three verbs, is refused. One more case covers it inside `ctx.api.transaction` and under `runAs: 'user'`. 13 cases. - An action reached through `execute()` is refused on each of the four objects. 4 cases. - Controls, 9 cases: - the inheriting hook still writes another object; - under a user's write, the ordinary gates refuse the plain member, and this refusal does not; - a hook registered in code, its `sudo()`, and the declarative wrapper with no binding all write each of the four objects. - **Red first** (commit `cb6bb395`, before the fix): 41 refusal cases failed and 59 passed (slice 1's 50 plus the 9 controls). - `objectql/src/hook-run-as.test.ts`: 4 new cases. - An inherited system context is marked, but a run's context and a user's are not. - `sudo()` is marked under every `runAs`. - A platform wrap keeps the api by reference and marks nothing. - The binder binds hooks as app-authored. - The published-face pin now counts two unexported symbol-keyed members. - `plugin-security` refusal test: the hook row's message pin follows the new wording. Pins describe behaviour only. ## Verification Head `d0489d5e`. Its tree is identical to `7d29c9ab`, where every reading below was taken: the commit messages were reworded before the first push and the files did not change. All readings ran under the verify lock. - `pnpm test`, each with VERDICT `command-exit 0`: - objectql: 469 files, 8734 passed; - plugin-security: 223 files, 4683 passed, 45 skipped; - runtime: 447 files, 7232 passed, 19 skipped; - core: 119 files, 2763 passed. - Runtime and core ran at the fix commit `4a7683e2`. Only objectql and plugin-security test files, the docs page and the changeset changed after it. Runtime reads both of those packages from `dist/`, and `dist/` was built from the fix commit. - `pnpm typecheck` in objectql, plugin-security and runtime exits 0 each, including `check:test-typecheck`. - `eslint --no-inline-config` on the 8 changed TypeScript files exits 0. - `check-system-context-census` exits 0. - Derived gates (`dispatch-gates --commands` on the real diff, 106 families): all 106 exit 0 on the first pass. Reconciled with `--ran`: 106 derived, 106 run, 0 NOT-MEASURED, 0 UNRUN. - Ablation (`scripts/ablation-replace.mjs`): one behaviour per run, each marker proven in objectql `dist/` by `ablation-dist-preflight` before the readings. - **A, the discriminator** (the binder's `appAuthored: true` forced to `false`). Runtime pin: 37 failed, 63 passed. The 37 are exactly the inherited-context and `sudo()` cases; the `execute()` cases, slice 1's cases and every control stay green. objectql pin: 1 failed (the binder case), 28 passed. - **B, `execute()`'s mark** (`'action'` forced to none). Runtime pin: 4 failed, 96 passed. The 4 are exactly the `execute()` cases. objectql pin: 29 passed. - Restore: blob equals HEAD for both files, `git diff HEAD` is empty and `git status --porcelain` is clean. After the rebuild, both markers are absent from `dist/` (preflight `--absent` exits 0) and the runtime pin is 100/100 again. - Full dogfood battery (owed for the `(narrowing)` arm): it was running at this tree when this PR was opened. Its counts go in the `os-dev-report` on #25653. ## Acceptance notes - Claimed surface: the hook wrapper, binder and `ScopedContext` in objectql, plus `ObjectRepository.execute()` as the order's measurement asked. The refusal module changed its wording only. No `packages/spec/**`, no new error code, and the predicate is unchanged. - The mark is the slice 1 symbol. A `flowRunId` context is still answered as the flow's, and a context that already carries a mark keeps it. - Branch is the harness-assigned `claude/project-thread-hg746g`; the claim comment is the identity record. --- _Generated by [Claude Code](https://claude.ai/code)_ 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01VtHeMxWXt2A79EvHgkRQ8k --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent a4eae06 commit 6a6ebff

10 files changed

Lines changed: 412 additions & 64 deletions
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
---
2+
'@objectstack/objectql': patch
3+
'@objectstack/plugin-security': patch
4+
---
5+
6+
fix(security): an app-authored hook that inherits a system context, its `ctx.api.sudo()`, and an action it reaches through `execute()` are refused writes to position holdings, permission-set grants, invitations and memberships
7+
8+
Clause-②: no (narrowing: an app-authored hook's inherited or sudo() elevation, and an action reached through ObjectRepository.execute(), are refused the four identity objects at run time, as a runAs: 'system' hook already is)
9+
Metadata-protocol: none
10+
11+
<!-- adr-0087: not-required (no-migration-prescription) No key of any metadata schema is removed, renamed or re-shaped, so there is nothing for `objectstack migrate meta` to rewrite and no tombstone. What changes is the run-time answer to an app-authored hook's elevated write to four platform objects. -->
12+
13+
**BREAKING** (a served-set narrowing) for an app-authored hook that writes `sys_user_position`, `sys_user_permission_set`, `sys_invitation` or `sys_member` while elevated. A census of the hooks in this repository and in HotCRM found none that does.
14+
15+
Since the previous release, a hook that declares `runAs: 'system'` and an action body are refused these writes. Three other ways app-authored code ran elevated were not:
16+
17+
- a hook that declares no `runAs` and fires on a write made under a system context, which it inherits;
18+
- a hook's in-process `ctx.api.sudo()`;
19+
- an action reached through `ctx.api.object(name).execute(action)`.
20+
21+
Each is now refused the same way: `403 PERMISSION_DENIED`, nothing written, and under the default `onError: 'abort'` the triggering write aborts. The message names the hook and the fix: set its `runAs` to `'user'` and leave `ctx.api.sudo()` out, or use the organization endpoints for invitations and memberships.
22+
23+
A hook is app-authored when it is bound from metadata through the hook binder (the same hooks the `skipAutomations` opt-out skips). A hook a plugin registers in code with `engine.registerHook`, and platform code's own `sudo()`, keep writing these objects. Reads of the four objects, and writes to every other object, are unchanged.

‎content/docs/permissions/system-context.mdx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -97,7 +97,7 @@ that silently does not happen.
9797
| # | Behaviour when `isSystem` | Package | What you get / what you lose | Anchor |
9898
|:--|:---|:---|:---|:---|
9999
| 1 | **The whole security middleware short-circuits** before any gate runs | plugin-security | Get: every CRUD/FLS/tenant/owner gate below skipped in one branch. Lose: every gate under it in this lane at once, down to and including the write-bypass row that widens the effective write scope to `org` (row 7) — this is the single largest behaviour on the page | `packages/plugins/plugin-security/src/security-plugin-authz-middleware.ts#registerAuthzMiddleware` |
100-
| 1b | **App-authored automation's system context is REFUSED governed writes to the identity-assignment objects** — a flow run's, a `runAs: 'system'` hook's and an action body's; the one check ahead of the middleware's short-circuit | plugin-security | Get: nothing — an insert, update, delete, transfer, restore or purge of `sys_user_position`, `sys_user_permission_set`, `sys_invitation` or `sys_member` from a context carrying `isSystem` and either a `flowRunId` (a `runAs: 'system'` flow's data nodes) or the app-automation elevation mark (`@objectstack/core`; set only by a hook's `runAs: 'system'` derivation and by the action body's execution-context builder), at any depth of their writes, is refused `PERMISSION_DENIED` before the short-circuit skips the ADR-0090 D12 gate and the identity write guard. Lose: nothing a platform writer held — a system context with neither (the seeders, better-auth and its reconciliation, invitation placement, any `sudo()`, which never sets the mark) passes on to the short-circuit unchanged, reads are not judged, and a `runAs: 'user'` flow or hook carries no `isSystem`, so the D12 gate judges it as the user it acts for | `packages/plugins/plugin-security/src/flow-system-identity-write-refusal.ts#elevationSource` |
100+
| 1b | **App-authored automation's system context is REFUSED governed writes to the identity-assignment objects** — a flow run's, an app-authored hook's (`runAs: 'system'`, a system context it inherits, its `sudo()`) and an action body's; the one check ahead of the middleware's short-circuit | plugin-security | Get: nothing — an insert, update, delete, transfer, restore or purge of `sys_user_position`, `sys_user_permission_set`, `sys_invitation` or `sys_member` from a context carrying `isSystem` and either a `flowRunId` (a `runAs: 'system'` flow's data nodes) or the app-automation elevation mark (`@objectstack/core`; set only by a hook's `runAs: 'system'` derivation, by the action body's execution-context builder and `ObjectRepository.execute`, and on the api of a hook bound through the hook binder, whose inherited system context and `sudo()` it marks), at any depth of their writes, is refused `PERMISSION_DENIED` before the short-circuit skips the ADR-0090 D12 gate and the identity write guard. Lose: nothing a platform writer held — a system context with neither (the seeders, better-auth and its reconciliation, invitation placement, platform code's `sudo()`, which never sets the mark, and a hook registered in code) passes on to the short-circuit unchanged, reads are not judged, and a `runAs: 'user'` flow or hook carries no `isSystem`, so the D12 gate judges it as the user it acts for | `packages/plugins/plugin-security/src/flow-system-identity-write-refusal.ts#elevationSource`, `packages/objectql/src/engine.ts#inheritsElevation` |
101101
| 2 | **`owner_id` is not auto-stamped on INSERT** (the step 3.5 anchor guard is inside the block row 1 skips) | plugin-security | Lose: the row lands `owner_id = NULL`, so the default `owner_only_writes` policy hides it **from its own creator**. Get: nothing — this is a gap, not a capability | the step 3.5 guard block and the short-circuit that skips it are both inside `packages/plugins/plugin-security/src/security-plugin-authz-middleware.ts#registerAuthzMiddleware` |
102102
| 3 | Row-level read filter resolves to "no filter" | plugin-security | Get: unscoped reads. Lose: row-level scoping entirely | `packages/plugins/plugin-security/src/security-plugin-read-filter.ts#getReadFilter` |
103103
| 4 | Field-level security returns **all** fields | plugin-security | Get: every column readable (`getReadableFields`) and writable (`getWritableFields`). Lose: field masking | `packages/plugins/plugin-security/src/security-plugin-field-readers.ts#resolveProjectionFieldMask` |

‎packages/objectql/src/engine.ts‎

Lines changed: 72 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -153,9 +153,12 @@ import {
153153
resolveUserAuthzGrants,
154154
// [#25653] The app-automation elevation mark: set by a hook's
155155
// `runAs: 'system'` derivation, carried across `createContext`'s parse —
156-
// see `ScopedContext.withRunAs`.
156+
// see `ScopedContext.withRunAs` — and by an app-authored hook's inherited
157+
// elevation and `sudo()`, and `ObjectRepository.execute` — see
158+
// `ScopedContext[APP_AUTHORED_HOOK_API]`.
157159
markAppAutomationElevation,
158160
carryAppAutomationElevation,
161+
appAutomationElevationOf,
159162
} from '@objectstack/core';
160163
import { WriteEpoch, isWriteEpochOperation } from './write-epoch.js';
161164
import { bridgeAuthzInvalidation } from './authz-invalidation-bridge.js';
@@ -312,7 +315,7 @@ import {
312315
divergingHookPayloadKeys,
313316
MultiUpdateHookKeyDivergenceError,
314317
} from './multi-update-hook-key-divergence.js';
315-
import { UnscopedHookApi, SEED_USER_GRANTS, type HookRunAs, type HookRunAsRef, type RunAsDerivableApi } from './hook-run-as.js';
318+
import { UnscopedHookApi, SEED_USER_GRANTS, APP_AUTHORED_HOOK_API, type HookRunAs, type HookRunAsRef, type RunAsDerivableApi } from './hook-run-as.js';
316319
import type { HookWriteRecording } from './hook-write-provenance.js';
317320
import { resolveMasterDetailRelation } from './master-detail.js';
318321
// [#19911] The settlement's question "does judging the FK's own lock need the
@@ -22957,7 +22960,14 @@ export class ObjectRepository implements IScopedObjectRepository {
2295722960
*/
2295822961
async execute(actionName: string, params?: any): Promise<any> {
2295922962
if (this.engine.executeAction) {
22960-
const executionContext: ExecutionContext = { ...this.context, isSystem: true };
22963+
// [#25653] With the `'action'` mark, as `buildActionExecutionContext`
22964+
// (runtime) gives the REST and MCP doors' action bodies: the action this
22965+
// reaches is app-authored automation running elevated, so its writes to
22966+
// the four identity-assignment objects are refused (plugin-security).
22967+
const executionContext: ExecutionContext = markAppAutomationElevation(
22968+
{ ...this.context, isSystem: true },
22969+
'action',
22970+
);
2296122971
return this.engine.executeAction(this.objectName, actionName, {
2296222972
...params,
2296322973
userId: this.context.userId,
@@ -22990,19 +23000,62 @@ export class ScopedContext implements IScopedContext, RunAsDerivableApi {
2299023000
private engine: IDataEngine
2299123001
) {}
2299223002

23003+
/**
23004+
* [#25653] Set on the api an app-authored hook is handed
23005+
* ({@link APP_AUTHORED_HOOK_API}) and kept by every api derived from it
23006+
* ({@link derive}); read by {@link sudo}.
23007+
*/
23008+
private appAuthored = false;
23009+
23010+
/** A child over `executionContext` that keeps this api's provenance. */
23011+
private derive(executionContext: ExecutionContext): ScopedContext {
23012+
const child = new ScopedContext(executionContext, this.engine);
23013+
child.appAuthored = this.appAuthored;
23014+
return child;
23015+
}
23016+
2299323017
/** Get a repository scoped to this context */
2299423018
object(name: string): ObjectRepository {
2299523019
return new ObjectRepository(name, this.executionContext, this.engine as any);
2299623020
}
2299723021

22998-
/** Create an elevated (system) context */
23022+
/**
23023+
* Create an elevated (system) context.
23024+
*
23025+
* [#25653] Platform code's `sudo()` carries no app-automation mark, so its
23026+
* writes to the four identity-assignment objects land. An app-authored
23027+
* hook's in-process `ctx.api.sudo()` is the hook's elevation: it carries
23028+
* the `'hook'` mark (unless the context already carries one).
23029+
*/
2299923030
sudo(): ScopedContext {
23000-
return new ScopedContext(
23001-
{ ...this.executionContext, isSystem: true },
23002-
this.engine
23031+
const elevated = { ...this.executionContext, isSystem: true };
23032+
return this.derive(
23033+
this.appAuthored && !appAutomationElevationOf(elevated)
23034+
? markAppAutomationElevation(elevated, 'hook')
23035+
: elevated,
2300323036
);
2300423037
}
2300523038

23039+
/**
23040+
* [#25653] The api an app-authored hook runs with — see
23041+
* {@link APP_AUTHORED_HOOK_API} (`hook-run-as.ts`), where the line between
23042+
* app-authored and platform hooks is drawn. Derived by `wrapDeclarativeHook`
23043+
* for a hook the binder bound, before its `runAs` is applied.
23044+
*
23045+
* An inherited system context with no `flowRunId` and no mark gets the
23046+
* `'hook'` mark: the hook runs with that elevation, so its writes to the four
23047+
* identity-assignment objects are refused as a `runAs: 'system'` hook's are.
23048+
* A run's context (`flowRunId`) is already judged as the run's; a marked one
23049+
* keeps its mark; a user's context is not elevated and needs none.
23050+
*/
23051+
[APP_AUTHORED_HOOK_API](): ScopedContext {
23052+
const ctx = this.executionContext;
23053+
const inheritsElevation = ctx.isSystem && !ctx.flowRunId && !appAutomationElevationOf(ctx);
23054+
const api = new ScopedContext(inheritsElevation ? markAppAutomationElevation(ctx, 'hook') : ctx, this.engine);
23055+
api.appAuthored = true;
23056+
return api;
23057+
}
23058+
2300623059
/**
2300723060
* [#14010] The api a hook that declared `runAs` is handed — derived from the
2300823061
* engine-built one (`buildHookApi(opCtx.context)`) at dispatch, by
@@ -23031,16 +23084,12 @@ export class ScopedContext implements IScopedContext, RunAsDerivableApi {
2303123084
*/
2303223085
withRunAs(runAs: Exclude<HookRunAs, 'inherit'>, ref: HookRunAsRef): IScopedContext {
2303323086
if (runAs === 'system') {
23034-
return new ScopedContext(
23087+
return this.derive(
2303523088
markAppAutomationElevation({ ...this.executionContext, isSystem: true }, 'hook'),
23036-
this.engine
2303723089
);
2303823090
}
2303923091
if (!this.executionContext.userId) return new UnscopedHookApi(ref);
23040-
return new ScopedContext(
23041-
{ ...this.executionContext, isSystem: false },
23042-
this.engine
23043-
);
23092+
return this.derive({ ...this.executionContext, isSystem: false });
2304423093
}
2304523094

2304623095
/**
@@ -23108,15 +23157,12 @@ export class ScopedContext implements IScopedContext, RunAsDerivableApi {
2310823157
ctx.userId,
2310923158
ctx.tenantId ? { tenantId: ctx.tenantId } : {},
2311023159
);
23111-
return new ScopedContext(
23112-
{
23113-
...ctx,
23114-
...(needsGrants ? { positions: grants.positions, permissions: grants.permissions } : {}),
23115-
...(needsPeers ? { org_user_ids: grants.org_user_ids } : {}),
23116-
...(needsEmail && grants.email ? { email: grants.email } : {}),
23117-
},
23118-
this.engine
23119-
);
23160+
return this.derive({
23161+
...ctx,
23162+
...(needsGrants ? { positions: grants.positions, permissions: grants.permissions } : {}),
23163+
...(needsPeers ? { org_user_ids: grants.org_user_ids } : {}),
23164+
...(needsEmail && grants.email ? { email: grants.email } : {}),
23165+
});
2312023166
}
2312123167

2312223168
/**
@@ -23201,10 +23247,7 @@ export class ScopedContext implements IScopedContext, RunAsDerivableApi {
2320123247
// boundaries the store does not. It is identity-equal to the store's
2320223248
// handle, so `transactionCoversDriverFor` still attributes it to the
2320323249
// OUTER owner and the #5351 same-origin gate judges it unchanged.
23204-
const joinedCtx = new ScopedContext(
23205-
{ ...this.executionContext, transaction: ambient.transaction },
23206-
this.engine
23207-
);
23250+
const joinedCtx = this.derive({ ...this.executionContext, transaction: ambient.transaction });
2320823251
// JOINED, not owned: the outer caller decides commit vs rollback (#5696).
2320923252
return callback(joinedCtx, { owned: false });
2321023253
}
@@ -23230,10 +23273,7 @@ export class ScopedContext implements IScopedContext, RunAsDerivableApi {
2323023273
}
2323123274

2323223275
const trx = await driver.beginTransaction();
23233-
const trxCtx = new ScopedContext(
23234-
{ ...this.executionContext, transaction: trx },
23235-
this.engine
23236-
);
23276+
const trxCtx = this.derive({ ...this.executionContext, transaction: trx });
2323723277
// Publish this transaction into the engine's ambient store so internal
2323823278
// queries during writes reuse its connection (ADR-0034) — and so a nested
2323923279
// `transaction()` on either surface can JOIN it. The store entry also
@@ -23384,20 +23424,14 @@ export class ScopedContext implements IScopedContext, RunAsDerivableApi {
2338423424
// store's handle — so `buildDriverOptions` binds every op on it to the
2338523425
// outer connection and `transactionCoversDriverFor` still attributes the
2338623426
// handle to the OUTER owner (#5351 unchanged).
23387-
const ctx = new ScopedContext(
23388-
{ ...this.executionContext, transaction: ambient.transaction },
23389-
this.engine
23390-
);
23427+
const ctx = this.derive({ ...this.executionContext, transaction: ambient.transaction });
2339123428
this.joinedHandles.add(ambient.transaction);
2339223429
return { ctx, handle: ambient.transaction, owned: false };
2339323430
}
2339423431
const driver = this.txDriver();
2339523432
if (!driver) return null;
2339623433
const trx = await driver.beginTransaction();
23397-
const ctx = new ScopedContext(
23398-
{ ...this.executionContext, transaction: trx },
23399-
this.engine
23400-
);
23434+
const ctx = this.derive({ ...this.executionContext, transaction: trx });
2340123435
return { ctx, handle: trx, owned: true };
2340223436
}
2340323437

‎packages/objectql/src/hook-binder.ts‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -312,7 +312,11 @@ export function bindHooksToEngine(
312312
continue;
313313
}
314314

315-
const wrapped = wrapDeclarativeHook(hook, resolved, { logger, metrics: opts.metrics });
315+
// [#25653] `appAuthored`: every hook this binder binds is app-authored —
316+
// the same set the `skipAutomations` opt-out keys on through `meta`
317+
// below — so its inherited elevation and its `sudo()` carry the
318+
// app-automation mark (`APP_AUTHORED_HOOK_API`, `hook-run-as.ts`).
319+
const wrapped = wrapDeclarativeHook(hook, resolved, { logger, metrics: opts.metrics, appAuthored: true });
316320
const events = Array.isArray(hook.events) ? hook.events : [];
317321

318322
for (const event of events) {

0 commit comments

Comments
 (0)