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
63 changes: 63 additions & 0 deletions .changeset/action-record-load-denied-signal.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
---
"@objectstack/runtime": minor
---

fix(runtime): tell an action handler when its caller-scope record load was refused (#14143)

Both action doors load the subject row under the **caller's** own execution
context, and both then stamp the requested id back onto the record:

```ts
if (record && record.id == null && recordId) record.id = recordId;
```

A refused or empty load leaves `record` as `{}`, so `record.id` is exactly
`null` — which is the stamp's own condition. The stamp condition and the
load-failure condition **coincided**. An action body runs elevated
(`isSystem: true`, settled design — #3914), so authorization has to be
re-established inside the handler, and the predicate every author reaches for
first was therefore always false:

```js
if (!ctx.record?.id) return refuse(); // never refused anything
```

An app author had to rediscover this by reading the dispatcher, or ship a guard
that passes on a row the caller cannot see. Undocumented, and identically broken
on both invocation paths.

**The addition: `ctx.recordLoadDenied`.** `true` exactly when a caller-scope
load was **attempted** and did not deliver the row; **absent** — never `false` —
otherwise, matching the `referentialFieldClear` marker convention on the same
seam, so a handler reads `ctx.recordLoadDenied === true`.

```js
if (ctx.recordLoadDenied) {
throw Object.assign(new Error('Record not available'), { code: 'RECORD_NOT_FOUND' });
}
```

- **Purely additive.** Nothing is refused that was not refused before, no
existing key changes value, and the `recordId` stamp is deliberately
**kept**: new-record / record-less actions depend on it, so `ctx.record.id`
still arrives exactly as it did. Pinned in both directions.
- **Both doors, one producer.** REST `POST /api/v1/actions/...` and the MCP
`run_action` bridge now share `loadActionSubjectRecord`. A signal only one
door emitted would be an authorization guard silently inert on the other.
- **The body face too.** The sandbox `ctx` is a fixed key set, so the flag is
marshalled explicitly into the VM — an inline `body` (the surface an AI author
writes most) reads it exactly as a registered handler does.
- **Documented**, in `docs/ui/actions` ("Authorization inside an action") and
from the action-`ctx` section of `docs/automation/hook-bodies` — half the
defect was that none of this was written down anywhere.

**What the flag does not claim.** It reports "the row did not resolve for this
caller", not "the platform caught an authorization error". A row hidden by
row-level security and an id that names nothing both arrive as
`RECORD_NOT_FOUND` / 404 — existence non-disclosure working as designed — and
nothing in the caught error separates them, so the flag carries no code or
status and does not pretend to. For an authorization decision the two are one
answer: this caller has not demonstrated read access to that row.

The `isSystem` elevation itself is unchanged and is not the defect (#3914); no
call that reaches a handler today stops reaching it.
2 changes: 2 additions & 0 deletions content/docs/automation/hook-bodies.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -238,6 +238,8 @@ await ctx.api.object('crm_deal').updateById(ctx.recordId, { stage: 'won' });

Mutating the snapshot *as a payload* and then handing it to such a call is fine — that write is live, and the lint leaves it alone.

`ctx.record` is also **not** an authorization input. An action body runs elevated, so any caller-specific rule has to be re-established inside it — and `ctx.record.id` carries the requested `recordId` even when the caller-scope load did **not** deliver the row, so `if (!ctx.record?.id) …` never refuses. The key that distinguishes the two is `ctx.recordLoadDenied` (`=== true` exactly when a load was attempted and returned nothing; absent otherwise). See [Authorization inside an action](/docs/ui/actions#authorization-inside-an-action).

### Engine

The sandbox engine is **`quickjs-emscripten`** — pure-WASM, runs on every JS host. We considered `isolated-vm` but its native dependency disqualifies edge targets. The choice is hidden behind the `ScriptRunner` interface in `packages/runtime/src/sandbox/`, so a node-only deployment can swap in a faster engine later without touching call sites.
Expand Down
60 changes: 59 additions & 1 deletion content/docs/ui/actions.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -161,7 +161,10 @@ rejected at authoring time.) Also note that both data surfaces a body reaches
— `ctx.api.object(name)` and the handler's `ctx.engine` facade — are
**trusted**: they run under the caller's identity elevated to system, so they
bypass row- and field-level security (writes stay attributed to the caller and
scoped to their organization). Enforce any caller-specific rules yourself.
scoped to their organization). Enforce any caller-specific rules yourself — and
read [Authorization inside an action](#authorization-inside-an-action) before
you write that guard, because `ctx.record.id` is present even when the caller
cannot read the row.
</Callout>

<Callout type="warn">
Expand DownExpand Up@@ -301,6 +304,61 @@ is a spec proposal for a properly named key, not a values map under this one.
- **`requiresFeature`** ties visibility to a feature flag (compiled into a
`visible` predicate).

### Authorization inside an action

An action body and a registered handler both run **elevated**: `ctx.api` and
`ctx.engine` carry `isSystem`, so they bypass row- and field-level security by
design (that is what lets an action do work the caller cannot do directly). The
consequence is that any caller-specific rule has to be re-established **inside**
your handler — and the predicate most people reach for first does not work:

```js
if (!ctx.record?.id) return refuse(); // ❌ always false — never refuses anything
```

Before dispatch, the platform loads the subject row in **your caller's own
scope**. If that read comes back empty — the row is invisible to them under
row-level security, or the id names nothing — the dispatcher still puts the
`recordId` from the request onto `ctx.record.id`, because a **new-record /
record-less** action legitimately needs it there. So `ctx.record.id` is present
either way, and the guard above passes for a caller who cannot see the row.

The signal that *does* distinguish them is `ctx.recordLoadDenied`:

```js
// ✅ the caller-scope load did not deliver the row — do not act on it
if (ctx.recordLoadDenied) {
throw Object.assign(new Error('Record not available'), { code: 'RECORD_NOT_FOUND' });
}
```

| | `ctx.record.id` | `ctx.recordLoadDenied` |
|:---|:---|:---|
| Caller **can** read the row | the id | absent |
| Caller **cannot** read the row | the id (stamped) | `true` |
| New-record / record-less action | the id, if one was passed | absent |

<Callout type="info">
Read it as `ctx.recordLoadDenied === true`. The key is **absent**, never
`false`, when nothing was refused — so an action that never loads a row (no
`recordId`, or an object-less action) never trips the guard.

It reports **"the row did not resolve for this caller"**, not "the platform saw
an authorization error". A row hidden by row-level security and an id that names
nothing both arrive as `RECORD_NOT_FOUND`, deliberately — the platform does not
disclose whether a record you cannot see exists — and the flag does not pretend
to separate what that read fuses. For an authorization decision they are the
same answer: this caller has not demonstrated read access to that row.

Both invocation doors set it — `POST /api/v1/actions/...` and the MCP
`run_action` tool — and it reaches inline `body` sandboxes and registered
handlers alike.
</Callout>

Set `ctx.recordLoadDenied` aside only when your action is *meant* to run without
a readable subject row (an "import this id from elsewhere" action, say). The
default for a row-scoped action on a `private` object is to refuse.

## Call it over REST

Every action is also an endpoint — the Console button and the API call run
Expand Down
112 changes: 102 additions & 10 deletions packages/runtime/src/action-execution.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -1177,6 +1177,97 @@ export function buildActionEngineFacade(_deps: ActionExecutionDeps, ql: any, ec?
};
}

/**
* The subject-record load's outcome, as the two action doors hand it to a
* handler (#14143).
*/
export interface ActionSubjectRecordLoad {
/** What the handler receives as `ctx.record`. Unchanged by #14143. */
record: Record<string, unknown>;
/**
* `true` exactly when a caller-scope load was ATTEMPTED and did not deliver
* the row. Absent-or-`false` otherwise — including for the record-less and
* new-record actions that never attempt one.
*/
recordLoadDenied: boolean;
}

/**
* Load an action's subject record IN THE CALLER'S OWN SCOPE, and report whether
* that load actually delivered the row (#14143). ONE producer for both action
* doors — the MCP `run_action` bridge below and the REST `/actions` route
* (`domains/actions.ts`) — because the signal it emits is documented to app
* authors, and a signal only one of two doors sets is an authorization guard
* that is silently inert on the other.
*
* ## Why the signal exists
*
* The load runs under the CALLER's `ExecutionContext` deliberately: an action's
* subject row must be readable by the person invoking the action. But the body
* that follows runs ELEVATED (`buildActionExecutionContext` = `isSystem: true`,
* settled design — #3914), so authorization has to be re-established INSIDE the
* handler, and the platform's most natural predicate for that was broken:
*
* - a refused/absent load leaves `record` as `{}`, so `record.id == null`;
* - the `recordId` stamp below fires on exactly that condition.
*
* The stamp condition and the load-failure condition COINCIDED, so
* `if (!ctx.record?.id) refuse()` — the guard an author reaches for first —
* was true on a row the caller cannot read, every time. The stamp is NOT the
* defect and is kept verbatim: new-record / record-less actions legitimately
* depend on `recordId` being in place, and removing it would break them.
* What was missing is a second, independent channel saying "this id did not
* resolve in your caller's scope", which is what `recordLoadDenied` is.
*
* ## What the flag can and cannot tell you
*
* It reports "the caller-scope load did not deliver the row", NOT "the platform
* caught an authorization error". The read path collapses the two on purpose:
* a row filtered out by RLS and an id that names nothing both arrive as
* `RECORD_NOT_FOUND` / 404 (`recordNotFoundError`, `@objectstack/core`), which
* is existence non-disclosure working as designed — the same reason the doc
* comment on the call site says "an unseen record reads as not-found". Nothing
* in the caught error separates them, so this flag deliberately does not
* pretend to, and carries no code/status: for an authorization decision the two
* are ONE answer — this caller has not demonstrated read access to that row.
*/
export async function loadActionSubjectRecord(
objectName: string,
recordId: string | undefined,
getRecord: () => Promise<any>,
): Promise<ActionSubjectRecordLoad> {
let record: Record<string, unknown> = {};
let recordLoadDenied = false;
if (recordId && !isObjectLessActionKey(objectName)) {
try {
const got: any = await getRecord();
if (got?.record) record = got.record;
// A resolved call that carried no row is the same fact as a thrown
// one — the protocol's own 404 arrives as a throw, but a data
// service that answers `{ record: undefined }` must not read as a
// successful load just because it declined to throw.
else recordLoadDenied = true;
} catch {
/* new-record / record-less actions pass an empty record */
recordLoadDenied = true;
}
}
// ⛔ Do NOT delete: a new-record / record-less action's handler reads its
// id from here. `recordLoadDenied` is what tells the two cases apart now.
if (record && (record as any).id == null && recordId) (record as any).id = recordId;
return { record, recordLoadDenied };
}

/**
* The `ctx` keys that carry {@link loadActionSubjectRecord}'s verdict into an
* action context — spread so the flag is ABSENT rather than `false` when no
* load was refused, matching the `referentialFieldClear` marker convention on
* the sandbox seam: a body reads `ctx.recordLoadDenied === true`.
*/
export function actionRecordLoadSignal(load: ActionSubjectRecordLoad): { recordLoadDenied?: true } {
return load.recordLoadDenied ? { recordLoadDenied: true } : {};
}

/**
* Resolve + invoke a business action by its declarative name for the MCP
* `run_action` tool. Enforces the AI-exposure gate (`ai.exposed`, #2849), the
Expand DownExpand Up@@ -1276,16 +1367,11 @@ export async function invokeBusinessAction(deps: ActionExecutionDeps,

// Load the subject record under RLS when row-context (engages the same
// permission path as get_record — an unseen record reads as not-found).
let record: Record<string, unknown> = {};
if (recordId && !isObjectLessActionKey(objectName)) {
try {
const got: any = await callData('get', { object: objectName, id: recordId }, driver, envId, ec);
if (got?.record) record = got.record;
} catch {
/* new-record / record-less actions pass an empty record */
}
}
if (record && (record as any).id == null && recordId) (record as any).id = recordId;
// [#14143] Through the ONE shared producer, so this door and the REST
// `/actions` door emit the same `recordLoadDenied` signal to handlers.
const subject = await loadActionSubjectRecord(objectName, recordId, () =>
callData('get', { object: objectName, id: recordId }, driver, envId, ec));
const record = subject.record;

// [#5372] One shared producer for the user shape (`security/actor-user.ts`),
// the same one the REST `/actions` route and the AI routes use. What stood
Expand DownExpand Up@@ -1330,6 +1416,12 @@ export async function invokeBusinessAction(deps: ActionExecutionDeps,
);
const actionContext: any = {
record,
// [#14143] The caller-scope load's verdict, on the same context the
// record rides. `ctx.record.id` is present either way (the stamp is
// load-bearing for record-less actions), so this is the ONLY thing that
// tells a handler its subject row did not resolve for THIS caller —
// and the body face carries it too (`sandbox/body-runner.ts`).
...actionRecordLoadSignal(subject),
user,
session: buildActionSession(deps, ec),
engine: buildActionEngineFacade(deps, ql, ec),
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
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
63 changes: 63 additions & 0 deletions .changeset/action-record-load-denied-signal.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
---
"@objectstack/runtime": minor
---

fix(runtime): tell an action handler when its caller-scope record load was refused (#14143)

Both action doors load the subject row under the **caller's** own execution
context, and both then stamp the requested id back onto the record:

```ts
if (record && record.id == null && recordId) record.id = recordId;
```

A refused or empty load leaves `record` as `{}`, so `record.id` is exactly
`null` — which is the stamp's own condition. The stamp condition and the
load-failure condition **coincided**. An action body runs elevated
(`isSystem: true`, settled design — #3914), so authorization has to be
re-established inside the handler, and the predicate every author reaches for
first was therefore always false:

```js
if (!ctx.record?.id) return refuse(); // never refused anything
```

An app author had to rediscover this by reading the dispatcher, or ship a guard
that passes on a row the caller cannot see. Undocumented, and identically broken
on both invocation paths.

**The addition: `ctx.recordLoadDenied`.** `true` exactly when a caller-scope
load was **attempted** and did not deliver the row; **absent** — never `false` —
otherwise, matching the `referentialFieldClear` marker convention on the same
seam, so a handler reads `ctx.recordLoadDenied === true`.

```js
if (ctx.recordLoadDenied) {
throw Object.assign(new Error('Record not available'), { code: 'RECORD_NOT_FOUND' });
}
```

- **Purely additive.** Nothing is refused that was not refused before, no
existing key changes value, and the `recordId` stamp is deliberately
**kept**: new-record / record-less actions depend on it, so `ctx.record.id`
still arrives exactly as it did. Pinned in both directions.
- **Both doors, one producer.** REST `POST /api/v1/actions/...` and the MCP
`run_action` bridge now share `loadActionSubjectRecord`. A signal only one
door emitted would be an authorization guard silently inert on the other.
- **The body face too.** The sandbox `ctx` is a fixed key set, so the flag is
marshalled explicitly into the VM — an inline `body` (the surface an AI author
writes most) reads it exactly as a registered handler does.
- **Documented**, in `docs/ui/actions` ("Authorization inside an action") and
from the action-`ctx` section of `docs/automation/hook-bodies` — half the
defect was that none of this was written down anywhere.

**What the flag does not claim.** It reports "the row did not resolve for this
caller", not "the platform caught an authorization error". A row hidden by
row-level security and an id that names nothing both arrive as
`RECORD_NOT_FOUND` / 404 — existence non-disclosure working as designed — and
nothing in the caught error separates them, so the flag carries no code or
status and does not pretend to. For an authorization decision the two are one
answer: this caller has not demonstrated read access to that row.

The `isSystem` elevation itself is unchanged and is not the defect (#3914); no
call that reaches a handler today stops reaching it.
2 changes: 2 additions & 0 deletions content/docs/automation/hook-bodies.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -238,6 +238,8 @@ await ctx.api.object('crm_deal').updateById(ctx.recordId, { stage: 'won' });

Mutating the snapshot *as a payload* and then handing it to such a call is fine — that write is live, and the lint leaves it alone.

`ctx.record` is also **not** an authorization input. An action body runs elevated, so any caller-specific rule has to be re-established inside it — and `ctx.record.id` carries the requested `recordId` even when the caller-scope load did **not** deliver the row, so `if (!ctx.record?.id) …` never refuses. The key that distinguishes the two is `ctx.recordLoadDenied` (`=== true` exactly when a load was attempted and returned nothing; absent otherwise). See [Authorization inside an action](/docs/ui/actions#authorization-inside-an-action).

### Engine

The sandbox engine is **`quickjs-emscripten`** — pure-WASM, runs on every JS host. We considered `isolated-vm` but its native dependency disqualifies edge targets. The choice is hidden behind the `ScriptRunner` interface in `packages/runtime/src/sandbox/`, so a node-only deployment can swap in a faster engine later without touching call sites.
Expand Down
60 changes: 59 additions & 1 deletion content/docs/ui/actions.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -161,7 +161,10 @@ rejected at authoring time.) Also note that both data surfaces a body reaches
— `ctx.api.object(name)` and the handler's `ctx.engine` facade — are
**trusted**: they run under the caller's identity elevated to system, so they
bypass row- and field-level security (writes stay attributed to the caller and
scoped to their organization). Enforce any caller-specific rules yourself.
scoped to their organization). Enforce any caller-specific rules yourself — and
read [Authorization inside an action](#authorization-inside-an-action) before
you write that guard, because `ctx.record.id` is present even when the caller
cannot read the row.
</Callout>

<Callout type="warn">
Expand DownExpand Up@@ -301,6 +304,61 @@ is a spec proposal for a properly named key, not a values map under this one.
- **`requiresFeature`** ties visibility to a feature flag (compiled into a
`visible` predicate).

### Authorization inside an action

An action body and a registered handler both run **elevated**: `ctx.api` and
`ctx.engine` carry `isSystem`, so they bypass row- and field-level security by
design (that is what lets an action do work the caller cannot do directly). The
consequence is that any caller-specific rule has to be re-established **inside**
your handler — and the predicate most people reach for first does not work:

```js
if (!ctx.record?.id) return refuse(); // ❌ always false — never refuses anything
```

Before dispatch, the platform loads the subject row in **your caller's own
scope**. If that read comes back empty — the row is invisible to them under
row-level security, or the id names nothing — the dispatcher still puts the
`recordId` from the request onto `ctx.record.id`, because a **new-record /
record-less** action legitimately needs it there. So `ctx.record.id` is present
either way, and the guard above passes for a caller who cannot see the row.

The signal that *does* distinguish them is `ctx.recordLoadDenied`:

```js
// ✅ the caller-scope load did not deliver the row — do not act on it
if (ctx.recordLoadDenied) {
throw Object.assign(new Error('Record not available'), { code: 'RECORD_NOT_FOUND' });
}
```

| | `ctx.record.id` | `ctx.recordLoadDenied` |
|:---|:---|:---|
| Caller **can** read the row | the id | absent |
| Caller **cannot** read the row | the id (stamped) | `true` |
| New-record / record-less action | the id, if one was passed | absent |

<Callout type="info">
Read it as `ctx.recordLoadDenied === true`. The key is **absent**, never
`false`, when nothing was refused — so an action that never loads a row (no
`recordId`, or an object-less action) never trips the guard.

It reports **"the row did not resolve for this caller"**, not "the platform saw
an authorization error". A row hidden by row-level security and an id that names
nothing both arrive as `RECORD_NOT_FOUND`, deliberately — the platform does not
disclose whether a record you cannot see exists — and the flag does not pretend
to separate what that read fuses. For an authorization decision they are the
same answer: this caller has not demonstrated read access to that row.

Both invocation doors set it — `POST /api/v1/actions/...` and the MCP
`run_action` tool — and it reaches inline `body` sandboxes and registered
handlers alike.
</Callout>

Set `ctx.recordLoadDenied` aside only when your action is *meant* to run without
a readable subject row (an "import this id from elsewhere" action, say). The
default for a row-scoped action on a `private` object is to refuse.

## Call it over REST

Every action is also an endpoint — the Console button and the API call run
Expand Down
112 changes: 102 additions & 10 deletions packages/runtime/src/action-execution.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -1177,6 +1177,97 @@ export function buildActionEngineFacade(_deps: ActionExecutionDeps, ql: any, ec?
};
}

/**
* The subject-record load's outcome, as the two action doors hand it to a
* handler (#14143).
*/
export interface ActionSubjectRecordLoad {
/** What the handler receives as `ctx.record`. Unchanged by #14143. */
record: Record<string, unknown>;
/**
* `true` exactly when a caller-scope load was ATTEMPTED and did not deliver
* the row. Absent-or-`false` otherwise — including for the record-less and
* new-record actions that never attempt one.
*/
recordLoadDenied: boolean;
}

/**
* Load an action's subject record IN THE CALLER'S OWN SCOPE, and report whether
* that load actually delivered the row (#14143). ONE producer for both action
* doors — the MCP `run_action` bridge below and the REST `/actions` route
* (`domains/actions.ts`) — because the signal it emits is documented to app
* authors, and a signal only one of two doors sets is an authorization guard
* that is silently inert on the other.
*
* ## Why the signal exists
*
* The load runs under the CALLER's `ExecutionContext` deliberately: an action's
* subject row must be readable by the person invoking the action. But the body
* that follows runs ELEVATED (`buildActionExecutionContext` = `isSystem: true`,
* settled design — #3914), so authorization has to be re-established INSIDE the
* handler, and the platform's most natural predicate for that was broken:
*
* - a refused/absent load leaves `record` as `{}`, so `record.id == null`;
* - the `recordId` stamp below fires on exactly that condition.
*
* The stamp condition and the load-failure condition COINCIDED, so
* `if (!ctx.record?.id) refuse()` — the guard an author reaches for first —
* was true on a row the caller cannot read, every time. The stamp is NOT the
* defect and is kept verbatim: new-record / record-less actions legitimately
* depend on `recordId` being in place, and removing it would break them.
* What was missing is a second, independent channel saying "this id did not
* resolve in your caller's scope", which is what `recordLoadDenied` is.
*
* ## What the flag can and cannot tell you
*
* It reports "the caller-scope load did not deliver the row", NOT "the platform
* caught an authorization error". The read path collapses the two on purpose:
* a row filtered out by RLS and an id that names nothing both arrive as
* `RECORD_NOT_FOUND` / 404 (`recordNotFoundError`, `@objectstack/core`), which
* is existence non-disclosure working as designed — the same reason the doc
* comment on the call site says "an unseen record reads as not-found". Nothing
* in the caught error separates them, so this flag deliberately does not
* pretend to, and carries no code/status: for an authorization decision the two
* are ONE answer — this caller has not demonstrated read access to that row.
*/
export async function loadActionSubjectRecord(
objectName: string,
recordId: string | undefined,
getRecord: () => Promise<any>,
): Promise<ActionSubjectRecordLoad> {
let record: Record<string, unknown> = {};
let recordLoadDenied = false;
if (recordId && !isObjectLessActionKey(objectName)) {
try {
const got: any = await getRecord();
if (got?.record) record = got.record;
// A resolved call that carried no row is the same fact as a thrown
// one — the protocol's own 404 arrives as a throw, but a data
// service that answers `{ record: undefined }` must not read as a
// successful load just because it declined to throw.
else recordLoadDenied = true;
} catch {
/* new-record / record-less actions pass an empty record */
recordLoadDenied = true;
}
}
// ⛔ Do NOT delete: a new-record / record-less action's handler reads its
// id from here. `recordLoadDenied` is what tells the two cases apart now.
if (record && (record as any).id == null && recordId) (record as any).id = recordId;
return { record, recordLoadDenied };
}

/**
* The `ctx` keys that carry {@link loadActionSubjectRecord}'s verdict into an
* action context — spread so the flag is ABSENT rather than `false` when no
* load was refused, matching the `referentialFieldClear` marker convention on
* the sandbox seam: a body reads `ctx.recordLoadDenied === true`.
*/
export function actionRecordLoadSignal(load: ActionSubjectRecordLoad): { recordLoadDenied?: true } {
return load.recordLoadDenied ? { recordLoadDenied: true } : {};
}

/**
* Resolve + invoke a business action by its declarative name for the MCP
* `run_action` tool. Enforces the AI-exposure gate (`ai.exposed`, #2849), the
Expand DownExpand Up@@ -1276,16 +1367,11 @@ export async function invokeBusinessAction(deps: ActionExecutionDeps,

// Load the subject record under RLS when row-context (engages the same
// permission path as get_record — an unseen record reads as not-found).
let record: Record<string, unknown> = {};
if (recordId && !isObjectLessActionKey(objectName)) {
try {
const got: any = await callData('get', { object: objectName, id: recordId }, driver, envId, ec);
if (got?.record) record = got.record;
} catch {
/* new-record / record-less actions pass an empty record */
}
}
if (record && (record as any).id == null && recordId) (record as any).id = recordId;
// [#14143] Through the ONE shared producer, so this door and the REST
// `/actions` door emit the same `recordLoadDenied` signal to handlers.
const subject = await loadActionSubjectRecord(objectName, recordId, () =>
callData('get', { object: objectName, id: recordId }, driver, envId, ec));
const record = subject.record;

// [#5372] One shared producer for the user shape (`security/actor-user.ts`),
// the same one the REST `/actions` route and the AI routes use. What stood
Expand DownExpand Up@@ -1330,6 +1416,12 @@ export async function invokeBusinessAction(deps: ActionExecutionDeps,
);
const actionContext: any = {
record,
// [#14143] The caller-scope load's verdict, on the same context the
// record rides. `ctx.record.id` is present either way (the stamp is
// load-bearing for record-less actions), so this is the ONLY thing that
// tells a handler its subject row did not resolve for THIS caller —
// and the body face carries it too (`sandbox/body-runner.ts`).
...actionRecordLoadSignal(subject),
user,
session: buildActionSession(deps, ec),
engine: buildActionEngineFacade(deps, ql, ec),
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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
63 changes: 63 additions & 0 deletions .changeset/action-record-load-denied-signal.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
---
"@objectstack/runtime": minor
---

fix(runtime): tell an action handler when its caller-scope record load was refused (#14143)

Both action doors load the subject row under the **caller's** own execution
context, and both then stamp the requested id back onto the record:

```ts
if (record && record.id == null && recordId) record.id = recordId;
```

A refused or empty load leaves `record` as `{}`, so `record.id` is exactly
`null` — which is the stamp's own condition. The stamp condition and the
load-failure condition **coincided**. An action body runs elevated
(`isSystem: true`, settled design — #3914), so authorization has to be
re-established inside the handler, and the predicate every author reaches for
first was therefore always false:

```js
if (!ctx.record?.id) return refuse(); // never refused anything
```

An app author had to rediscover this by reading the dispatcher, or ship a guard
that passes on a row the caller cannot see. Undocumented, and identically broken
on both invocation paths.

**The addition: `ctx.recordLoadDenied`.** `true` exactly when a caller-scope
load was **attempted** and did not deliver the row; **absent** — never `false` —
otherwise, matching the `referentialFieldClear` marker convention on the same
seam, so a handler reads `ctx.recordLoadDenied === true`.

```js
if (ctx.recordLoadDenied) {
throw Object.assign(new Error('Record not available'), { code: 'RECORD_NOT_FOUND' });
}
```

- **Purely additive.** Nothing is refused that was not refused before, no
existing key changes value, and the `recordId` stamp is deliberately
**kept**: new-record / record-less actions depend on it, so `ctx.record.id`
still arrives exactly as it did. Pinned in both directions.
- **Both doors, one producer.** REST `POST /api/v1/actions/...` and the MCP
`run_action` bridge now share `loadActionSubjectRecord`. A signal only one
door emitted would be an authorization guard silently inert on the other.
- **The body face too.** The sandbox `ctx` is a fixed key set, so the flag is
marshalled explicitly into the VM — an inline `body` (the surface an AI author
writes most) reads it exactly as a registered handler does.
- **Documented**, in `docs/ui/actions` ("Authorization inside an action") and
from the action-`ctx` section of `docs/automation/hook-bodies` — half the
defect was that none of this was written down anywhere.

**What the flag does not claim.** It reports "the row did not resolve for this
caller", not "the platform caught an authorization error". A row hidden by
row-level security and an id that names nothing both arrive as
`RECORD_NOT_FOUND` / 404 — existence non-disclosure working as designed — and
nothing in the caught error separates them, so the flag carries no code or
status and does not pretend to. For an authorization decision the two are one
answer: this caller has not demonstrated read access to that row.

The `isSystem` elevation itself is unchanged and is not the defect (#3914); no
call that reaches a handler today stops reaching it.
2 changes: 2 additions & 0 deletions content/docs/automation/hook-bodies.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -238,6 +238,8 @@ await ctx.api.object('crm_deal').updateById(ctx.recordId, { stage: 'won' });

Mutating the snapshot *as a payload* and then handing it to such a call is fine — that write is live, and the lint leaves it alone.

`ctx.record` is also **not** an authorization input. An action body runs elevated, so any caller-specific rule has to be re-established inside it — and `ctx.record.id` carries the requested `recordId` even when the caller-scope load did **not** deliver the row, so `if (!ctx.record?.id) …` never refuses. The key that distinguishes the two is `ctx.recordLoadDenied` (`=== true` exactly when a load was attempted and returned nothing; absent otherwise). See [Authorization inside an action](/docs/ui/actions#authorization-inside-an-action).

### Engine

The sandbox engine is **`quickjs-emscripten`** — pure-WASM, runs on every JS host. We considered `isolated-vm` but its native dependency disqualifies edge targets. The choice is hidden behind the `ScriptRunner` interface in `packages/runtime/src/sandbox/`, so a node-only deployment can swap in a faster engine later without touching call sites.
Expand Down
60 changes: 59 additions & 1 deletion content/docs/ui/actions.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -161,7 +161,10 @@ rejected at authoring time.) Also note that both data surfaces a body reaches
— `ctx.api.object(name)` and the handler's `ctx.engine` facade — are
**trusted**: they run under the caller's identity elevated to system, so they
bypass row- and field-level security (writes stay attributed to the caller and
scoped to their organization). Enforce any caller-specific rules yourself.
scoped to their organization). Enforce any caller-specific rules yourself — and
read [Authorization inside an action](#authorization-inside-an-action) before
you write that guard, because `ctx.record.id` is present even when the caller
cannot read the row.
</Callout>

<Callout type="warn">
Expand DownExpand Up@@ -301,6 +304,61 @@ is a spec proposal for a properly named key, not a values map under this one.
- **`requiresFeature`** ties visibility to a feature flag (compiled into a
`visible` predicate).

### Authorization inside an action

An action body and a registered handler both run **elevated**: `ctx.api` and
`ctx.engine` carry `isSystem`, so they bypass row- and field-level security by
design (that is what lets an action do work the caller cannot do directly). The
consequence is that any caller-specific rule has to be re-established **inside**
your handler — and the predicate most people reach for first does not work:

```js
if (!ctx.record?.id) return refuse(); // ❌ always false — never refuses anything
```

Before dispatch, the platform loads the subject row in **your caller's own
scope**. If that read comes back empty — the row is invisible to them under
row-level security, or the id names nothing — the dispatcher still puts the
`recordId` from the request onto `ctx.record.id`, because a **new-record /
record-less** action legitimately needs it there. So `ctx.record.id` is present
either way, and the guard above passes for a caller who cannot see the row.

The signal that *does* distinguish them is `ctx.recordLoadDenied`:

```js
// ✅ the caller-scope load did not deliver the row — do not act on it
if (ctx.recordLoadDenied) {
throw Object.assign(new Error('Record not available'), { code: 'RECORD_NOT_FOUND' });
}
```

| | `ctx.record.id` | `ctx.recordLoadDenied` |
|:---|:---|:---|
| Caller **can** read the row | the id | absent |
| Caller **cannot** read the row | the id (stamped) | `true` |
| New-record / record-less action | the id, if one was passed | absent |

<Callout type="info">
Read it as `ctx.recordLoadDenied === true`. The key is **absent**, never
`false`, when nothing was refused — so an action that never loads a row (no
`recordId`, or an object-less action) never trips the guard.

It reports **"the row did not resolve for this caller"**, not "the platform saw
an authorization error". A row hidden by row-level security and an id that names
nothing both arrive as `RECORD_NOT_FOUND`, deliberately — the platform does not
disclose whether a record you cannot see exists — and the flag does not pretend
to separate what that read fuses. For an authorization decision they are the
same answer: this caller has not demonstrated read access to that row.

Both invocation doors set it — `POST /api/v1/actions/...` and the MCP
`run_action` tool — and it reaches inline `body` sandboxes and registered
handlers alike.
</Callout>

Set `ctx.recordLoadDenied` aside only when your action is *meant* to run without
a readable subject row (an "import this id from elsewhere" action, say). The
default for a row-scoped action on a `private` object is to refuse.

## Call it over REST

Every action is also an endpoint — the Console button and the API call run
Expand Down
112 changes: 102 additions & 10 deletions packages/runtime/src/action-execution.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -1177,6 +1177,97 @@ export function buildActionEngineFacade(_deps: ActionExecutionDeps, ql: any, ec?
};
}

/**
* The subject-record load's outcome, as the two action doors hand it to a
* handler (#14143).
*/
export interface ActionSubjectRecordLoad {
/** What the handler receives as `ctx.record`. Unchanged by #14143. */
record: Record<string, unknown>;
/**
* `true` exactly when a caller-scope load was ATTEMPTED and did not deliver
* the row. Absent-or-`false` otherwise — including for the record-less and
* new-record actions that never attempt one.
*/
recordLoadDenied: boolean;
}

/**
* Load an action's subject record IN THE CALLER'S OWN SCOPE, and report whether
* that load actually delivered the row (#14143). ONE producer for both action
* doors — the MCP `run_action` bridge below and the REST `/actions` route
* (`domains/actions.ts`) — because the signal it emits is documented to app
* authors, and a signal only one of two doors sets is an authorization guard
* that is silently inert on the other.
*
* ## Why the signal exists
*
* The load runs under the CALLER's `ExecutionContext` deliberately: an action's
* subject row must be readable by the person invoking the action. But the body
* that follows runs ELEVATED (`buildActionExecutionContext` = `isSystem: true`,
* settled design — #3914), so authorization has to be re-established INSIDE the
* handler, and the platform's most natural predicate for that was broken:
*
* - a refused/absent load leaves `record` as `{}`, so `record.id == null`;
* - the `recordId` stamp below fires on exactly that condition.
*
* The stamp condition and the load-failure condition COINCIDED, so
* `if (!ctx.record?.id) refuse()` — the guard an author reaches for first —
* was true on a row the caller cannot read, every time. The stamp is NOT the
* defect and is kept verbatim: new-record / record-less actions legitimately
* depend on `recordId` being in place, and removing it would break them.
* What was missing is a second, independent channel saying "this id did not
* resolve in your caller's scope", which is what `recordLoadDenied` is.
*
* ## What the flag can and cannot tell you
*
* It reports "the caller-scope load did not deliver the row", NOT "the platform
* caught an authorization error". The read path collapses the two on purpose:
* a row filtered out by RLS and an id that names nothing both arrive as
* `RECORD_NOT_FOUND` / 404 (`recordNotFoundError`, `@objectstack/core`), which
* is existence non-disclosure working as designed — the same reason the doc
* comment on the call site says "an unseen record reads as not-found". Nothing
* in the caught error separates them, so this flag deliberately does not
* pretend to, and carries no code/status: for an authorization decision the two
* are ONE answer — this caller has not demonstrated read access to that row.
*/
export async function loadActionSubjectRecord(
objectName: string,
recordId: string | undefined,
getRecord: () => Promise<any>,
): Promise<ActionSubjectRecordLoad> {
let record: Record<string, unknown> = {};
let recordLoadDenied = false;
if (recordId && !isObjectLessActionKey(objectName)) {
try {
const got: any = await getRecord();
if (got?.record) record = got.record;
// A resolved call that carried no row is the same fact as a thrown
// one — the protocol's own 404 arrives as a throw, but a data
// service that answers `{ record: undefined }` must not read as a
// successful load just because it declined to throw.
else recordLoadDenied = true;
} catch {
/* new-record / record-less actions pass an empty record */
recordLoadDenied = true;
}
}
// ⛔ Do NOT delete: a new-record / record-less action's handler reads its
// id from here. `recordLoadDenied` is what tells the two cases apart now.
if (record && (record as any).id == null && recordId) (record as any).id = recordId;
return { record, recordLoadDenied };
}

/**
* The `ctx` keys that carry {@link loadActionSubjectRecord}'s verdict into an
* action context — spread so the flag is ABSENT rather than `false` when no
* load was refused, matching the `referentialFieldClear` marker convention on
* the sandbox seam: a body reads `ctx.recordLoadDenied === true`.
*/
export function actionRecordLoadSignal(load: ActionSubjectRecordLoad): { recordLoadDenied?: true } {
return load.recordLoadDenied ? { recordLoadDenied: true } : {};
}

/**
* Resolve + invoke a business action by its declarative name for the MCP
* `run_action` tool. Enforces the AI-exposure gate (`ai.exposed`, #2849), the
Expand DownExpand Up@@ -1276,16 +1367,11 @@ export async function invokeBusinessAction(deps: ActionExecutionDeps,

// Load the subject record under RLS when row-context (engages the same
// permission path as get_record — an unseen record reads as not-found).
let record: Record<string, unknown> = {};
if (recordId && !isObjectLessActionKey(objectName)) {
try {
const got: any = await callData('get', { object: objectName, id: recordId }, driver, envId, ec);
if (got?.record) record = got.record;
} catch {
/* new-record / record-less actions pass an empty record */
}
}
if (record && (record as any).id == null && recordId) (record as any).id = recordId;
// [#14143] Through the ONE shared producer, so this door and the REST
// `/actions` door emit the same `recordLoadDenied` signal to handlers.
const subject = await loadActionSubjectRecord(objectName, recordId, () =>
callData('get', { object: objectName, id: recordId }, driver, envId, ec));
const record = subject.record;

// [#5372] One shared producer for the user shape (`security/actor-user.ts`),
// the same one the REST `/actions` route and the AI routes use. What stood
Expand DownExpand Up@@ -1330,6 +1416,12 @@ export async function invokeBusinessAction(deps: ActionExecutionDeps,
);
const actionContext: any = {
record,
// [#14143] The caller-scope load's verdict, on the same context the
// record rides. `ctx.record.id` is present either way (the stamp is
// load-bearing for record-less actions), so this is the ONLY thing that
// tells a handler its subject row did not resolve for THIS caller —
// and the body face carries it too (`sandbox/body-runner.ts`).
...actionRecordLoadSignal(subject),
user,
session: buildActionSession(deps, ec),
engine: buildActionEngineFacade(deps, ql, ec),
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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
63 changes: 63 additions & 0 deletions .changeset/action-record-load-denied-signal.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
---
"@objectstack/runtime": minor
---

fix(runtime): tell an action handler when its caller-scope record load was refused (#14143)

Both action doors load the subject row under the **caller's** own execution
context, and both then stamp the requested id back onto the record:

```ts
if (record && record.id == null && recordId) record.id = recordId;
```

A refused or empty load leaves `record` as `{}`, so `record.id` is exactly
`null` — which is the stamp's own condition. The stamp condition and the
load-failure condition **coincided**. An action body runs elevated
(`isSystem: true`, settled design — #3914), so authorization has to be
re-established inside the handler, and the predicate every author reaches for
first was therefore always false:

```js
if (!ctx.record?.id) return refuse(); // never refused anything
```

An app author had to rediscover this by reading the dispatcher, or ship a guard
that passes on a row the caller cannot see. Undocumented, and identically broken
on both invocation paths.

**The addition: `ctx.recordLoadDenied`.** `true` exactly when a caller-scope
load was **attempted** and did not deliver the row; **absent** — never `false` —
otherwise, matching the `referentialFieldClear` marker convention on the same
seam, so a handler reads `ctx.recordLoadDenied === true`.

```js
if (ctx.recordLoadDenied) {
throw Object.assign(new Error('Record not available'), { code: 'RECORD_NOT_FOUND' });
}
```

- **Purely additive.** Nothing is refused that was not refused before, no
existing key changes value, and the `recordId` stamp is deliberately
**kept**: new-record / record-less actions depend on it, so `ctx.record.id`
still arrives exactly as it did. Pinned in both directions.
- **Both doors, one producer.** REST `POST /api/v1/actions/...` and the MCP
`run_action` bridge now share `loadActionSubjectRecord`. A signal only one
door emitted would be an authorization guard silently inert on the other.
- **The body face too.** The sandbox `ctx` is a fixed key set, so the flag is
marshalled explicitly into the VM — an inline `body` (the surface an AI author
writes most) reads it exactly as a registered handler does.
- **Documented**, in `docs/ui/actions` ("Authorization inside an action") and
from the action-`ctx` section of `docs/automation/hook-bodies` — half the
defect was that none of this was written down anywhere.

**What the flag does not claim.** It reports "the row did not resolve for this
caller", not "the platform caught an authorization error". A row hidden by
row-level security and an id that names nothing both arrive as
`RECORD_NOT_FOUND` / 404 — existence non-disclosure working as designed — and
nothing in the caught error separates them, so the flag carries no code or
status and does not pretend to. For an authorization decision the two are one
answer: this caller has not demonstrated read access to that row.

The `isSystem` elevation itself is unchanged and is not the defect (#3914); no
call that reaches a handler today stops reaching it.
2 changes: 2 additions & 0 deletions content/docs/automation/hook-bodies.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -238,6 +238,8 @@ await ctx.api.object('crm_deal').updateById(ctx.recordId, { stage: 'won' });

Mutating the snapshot *as a payload* and then handing it to such a call is fine — that write is live, and the lint leaves it alone.

`ctx.record` is also **not** an authorization input. An action body runs elevated, so any caller-specific rule has to be re-established inside it — and `ctx.record.id` carries the requested `recordId` even when the caller-scope load did **not** deliver the row, so `if (!ctx.record?.id) …` never refuses. The key that distinguishes the two is `ctx.recordLoadDenied` (`=== true` exactly when a load was attempted and returned nothing; absent otherwise). See [Authorization inside an action](/docs/ui/actions#authorization-inside-an-action).

### Engine

The sandbox engine is **`quickjs-emscripten`** — pure-WASM, runs on every JS host. We considered `isolated-vm` but its native dependency disqualifies edge targets. The choice is hidden behind the `ScriptRunner` interface in `packages/runtime/src/sandbox/`, so a node-only deployment can swap in a faster engine later without touching call sites.
Expand Down
60 changes: 59 additions & 1 deletion content/docs/ui/actions.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -161,7 +161,10 @@ rejected at authoring time.) Also note that both data surfaces a body reaches
— `ctx.api.object(name)` and the handler's `ctx.engine` facade — are
**trusted**: they run under the caller's identity elevated to system, so they
bypass row- and field-level security (writes stay attributed to the caller and
scoped to their organization). Enforce any caller-specific rules yourself.
scoped to their organization). Enforce any caller-specific rules yourself — and
read [Authorization inside an action](#authorization-inside-an-action) before
you write that guard, because `ctx.record.id` is present even when the caller
cannot read the row.
</Callout>

<Callout type="warn">
Expand DownExpand Up@@ -301,6 +304,61 @@ is a spec proposal for a properly named key, not a values map under this one.
- **`requiresFeature`** ties visibility to a feature flag (compiled into a
`visible` predicate).

### Authorization inside an action

An action body and a registered handler both run **elevated**: `ctx.api` and
`ctx.engine` carry `isSystem`, so they bypass row- and field-level security by
design (that is what lets an action do work the caller cannot do directly). The
consequence is that any caller-specific rule has to be re-established **inside**
your handler — and the predicate most people reach for first does not work:

```js
if (!ctx.record?.id) return refuse(); // ❌ always false — never refuses anything
```

Before dispatch, the platform loads the subject row in **your caller's own
scope**. If that read comes back empty — the row is invisible to them under
row-level security, or the id names nothing — the dispatcher still puts the
`recordId` from the request onto `ctx.record.id`, because a **new-record /
record-less** action legitimately needs it there. So `ctx.record.id` is present
either way, and the guard above passes for a caller who cannot see the row.

The signal that *does* distinguish them is `ctx.recordLoadDenied`:

```js
// ✅ the caller-scope load did not deliver the row — do not act on it
if (ctx.recordLoadDenied) {
throw Object.assign(new Error('Record not available'), { code: 'RECORD_NOT_FOUND' });
}
```

| | `ctx.record.id` | `ctx.recordLoadDenied` |
|:---|:---|:---|
| Caller **can** read the row | the id | absent |
| Caller **cannot** read the row | the id (stamped) | `true` |
| New-record / record-less action | the id, if one was passed | absent |

<Callout type="info">
Read it as `ctx.recordLoadDenied === true`. The key is **absent**, never
`false`, when nothing was refused — so an action that never loads a row (no
`recordId`, or an object-less action) never trips the guard.

It reports **"the row did not resolve for this caller"**, not "the platform saw
an authorization error". A row hidden by row-level security and an id that names
nothing both arrive as `RECORD_NOT_FOUND`, deliberately — the platform does not
disclose whether a record you cannot see exists — and the flag does not pretend
to separate what that read fuses. For an authorization decision they are the
same answer: this caller has not demonstrated read access to that row.

Both invocation doors set it — `POST /api/v1/actions/...` and the MCP
`run_action` tool — and it reaches inline `body` sandboxes and registered
handlers alike.
</Callout>

Set `ctx.recordLoadDenied` aside only when your action is *meant* to run without
a readable subject row (an "import this id from elsewhere" action, say). The
default for a row-scoped action on a `private` object is to refuse.

## Call it over REST

Every action is also an endpoint — the Console button and the API call run
Expand Down
112 changes: 102 additions & 10 deletions packages/runtime/src/action-execution.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -1177,6 +1177,97 @@ export function buildActionEngineFacade(_deps: ActionExecutionDeps, ql: any, ec?
};
}

/**
* The subject-record load's outcome, as the two action doors hand it to a
* handler (#14143).
*/
export interface ActionSubjectRecordLoad {
/** What the handler receives as `ctx.record`. Unchanged by #14143. */
record: Record<string, unknown>;
/**
* `true` exactly when a caller-scope load was ATTEMPTED and did not deliver
* the row. Absent-or-`false` otherwise — including for the record-less and
* new-record actions that never attempt one.
*/
recordLoadDenied: boolean;
}

/**
* Load an action's subject record IN THE CALLER'S OWN SCOPE, and report whether
* that load actually delivered the row (#14143). ONE producer for both action
* doors — the MCP `run_action` bridge below and the REST `/actions` route
* (`domains/actions.ts`) — because the signal it emits is documented to app
* authors, and a signal only one of two doors sets is an authorization guard
* that is silently inert on the other.
*
* ## Why the signal exists
*
* The load runs under the CALLER's `ExecutionContext` deliberately: an action's
* subject row must be readable by the person invoking the action. But the body
* that follows runs ELEVATED (`buildActionExecutionContext` = `isSystem: true`,
* settled design — #3914), so authorization has to be re-established INSIDE the
* handler, and the platform's most natural predicate for that was broken:
*
* - a refused/absent load leaves `record` as `{}`, so `record.id == null`;
* - the `recordId` stamp below fires on exactly that condition.
*
* The stamp condition and the load-failure condition COINCIDED, so
* `if (!ctx.record?.id) refuse()` — the guard an author reaches for first —
* was true on a row the caller cannot read, every time. The stamp is NOT the
* defect and is kept verbatim: new-record / record-less actions legitimately
* depend on `recordId` being in place, and removing it would break them.
* What was missing is a second, independent channel saying "this id did not
* resolve in your caller's scope", which is what `recordLoadDenied` is.
*
* ## What the flag can and cannot tell you
*
* It reports "the caller-scope load did not deliver the row", NOT "the platform
* caught an authorization error". The read path collapses the two on purpose:
* a row filtered out by RLS and an id that names nothing both arrive as
* `RECORD_NOT_FOUND` / 404 (`recordNotFoundError`, `@objectstack/core`), which
* is existence non-disclosure working as designed — the same reason the doc
* comment on the call site says "an unseen record reads as not-found". Nothing
* in the caught error separates them, so this flag deliberately does not
* pretend to, and carries no code/status: for an authorization decision the two
* are ONE answer — this caller has not demonstrated read access to that row.
*/
export async function loadActionSubjectRecord(
objectName: string,
recordId: string | undefined,
getRecord: () => Promise<any>,
): Promise<ActionSubjectRecordLoad> {
let record: Record<string, unknown> = {};
let recordLoadDenied = false;
if (recordId && !isObjectLessActionKey(objectName)) {
try {
const got: any = await getRecord();
if (got?.record) record = got.record;
// A resolved call that carried no row is the same fact as a thrown
// one — the protocol's own 404 arrives as a throw, but a data
// service that answers `{ record: undefined }` must not read as a
// successful load just because it declined to throw.
else recordLoadDenied = true;
} catch {
/* new-record / record-less actions pass an empty record */
recordLoadDenied = true;
}
}
// ⛔ Do NOT delete: a new-record / record-less action's handler reads its
// id from here. `recordLoadDenied` is what tells the two cases apart now.
if (record && (record as any).id == null && recordId) (record as any).id = recordId;
return { record, recordLoadDenied };
}

/**
* The `ctx` keys that carry {@link loadActionSubjectRecord}'s verdict into an
* action context — spread so the flag is ABSENT rather than `false` when no
* load was refused, matching the `referentialFieldClear` marker convention on
* the sandbox seam: a body reads `ctx.recordLoadDenied === true`.
*/
export function actionRecordLoadSignal(load: ActionSubjectRecordLoad): { recordLoadDenied?: true } {
return load.recordLoadDenied ? { recordLoadDenied: true } : {};
}

/**
* Resolve + invoke a business action by its declarative name for the MCP
* `run_action` tool. Enforces the AI-exposure gate (`ai.exposed`, #2849), the
Expand DownExpand Up@@ -1276,16 +1367,11 @@ export async function invokeBusinessAction(deps: ActionExecutionDeps,

// Load the subject record under RLS when row-context (engages the same
// permission path as get_record — an unseen record reads as not-found).
let record: Record<string, unknown> = {};
if (recordId && !isObjectLessActionKey(objectName)) {
try {
const got: any = await callData('get', { object: objectName, id: recordId }, driver, envId, ec);
if (got?.record) record = got.record;
} catch {
/* new-record / record-less actions pass an empty record */
}
}
if (record && (record as any).id == null && recordId) (record as any).id = recordId;
// [#14143] Through the ONE shared producer, so this door and the REST
// `/actions` door emit the same `recordLoadDenied` signal to handlers.
const subject = await loadActionSubjectRecord(objectName, recordId, () =>
callData('get', { object: objectName, id: recordId }, driver, envId, ec));
const record = subject.record;

// [#5372] One shared producer for the user shape (`security/actor-user.ts`),
// the same one the REST `/actions` route and the AI routes use. What stood
Expand DownExpand Up@@ -1330,6 +1416,12 @@ export async function invokeBusinessAction(deps: ActionExecutionDeps,
);
const actionContext: any = {
record,
// [#14143] The caller-scope load's verdict, on the same context the
// record rides. `ctx.record.id` is present either way (the stamp is
// load-bearing for record-less actions), so this is the ONLY thing that
// tells a handler its subject row did not resolve for THIS caller —
// and the body face carries it too (`sandbox/body-runner.ts`).
...actionRecordLoadSignal(subject),
user,
session: buildActionSession(deps, ec),
engine: buildActionEngineFacade(deps, ql, ec),
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
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
63 changes: 63 additions & 0 deletions .changeset/action-record-load-denied-signal.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
---
"@objectstack/runtime": minor
---

fix(runtime): tell an action handler when its caller-scope record load was refused (#14143)

Both action doors load the subject row under the **caller's** own execution
context, and both then stamp the requested id back onto the record:

```ts
if (record && record.id == null && recordId) record.id = recordId;
```

A refused or empty load leaves `record` as `{}`, so `record.id` is exactly
`null` — which is the stamp's own condition. The stamp condition and the
load-failure condition **coincided**. An action body runs elevated
(`isSystem: true`, settled design — #3914), so authorization has to be
re-established inside the handler, and the predicate every author reaches for
first was therefore always false:

```js
if (!ctx.record?.id) return refuse(); // never refused anything
```

An app author had to rediscover this by reading the dispatcher, or ship a guard
that passes on a row the caller cannot see. Undocumented, and identically broken
on both invocation paths.

**The addition: `ctx.recordLoadDenied`.** `true` exactly when a caller-scope
load was **attempted** and did not deliver the row; **absent** — never `false` —
otherwise, matching the `referentialFieldClear` marker convention on the same
seam, so a handler reads `ctx.recordLoadDenied === true`.

```js
if (ctx.recordLoadDenied) {
throw Object.assign(new Error('Record not available'), { code: 'RECORD_NOT_FOUND' });
}
```

- **Purely additive.** Nothing is refused that was not refused before, no
existing key changes value, and the `recordId` stamp is deliberately
**kept**: new-record / record-less actions depend on it, so `ctx.record.id`
still arrives exactly as it did. Pinned in both directions.
- **Both doors, one producer.** REST `POST /api/v1/actions/...` and the MCP
`run_action` bridge now share `loadActionSubjectRecord`. A signal only one
door emitted would be an authorization guard silently inert on the other.
- **The body face too.** The sandbox `ctx` is a fixed key set, so the flag is
marshalled explicitly into the VM — an inline `body` (the surface an AI author
writes most) reads it exactly as a registered handler does.
- **Documented**, in `docs/ui/actions` ("Authorization inside an action") and
from the action-`ctx` section of `docs/automation/hook-bodies` — half the
defect was that none of this was written down anywhere.

**What the flag does not claim.** It reports "the row did not resolve for this
caller", not "the platform caught an authorization error". A row hidden by
row-level security and an id that names nothing both arrive as
`RECORD_NOT_FOUND` / 404 — existence non-disclosure working as designed — and
nothing in the caught error separates them, so the flag carries no code or
status and does not pretend to. For an authorization decision the two are one
answer: this caller has not demonstrated read access to that row.

The `isSystem` elevation itself is unchanged and is not the defect (#3914); no
call that reaches a handler today stops reaching it.
2 changes: 2 additions & 0 deletions content/docs/automation/hook-bodies.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -238,6 +238,8 @@ await ctx.api.object('crm_deal').updateById(ctx.recordId, { stage: 'won' });

Mutating the snapshot *as a payload* and then handing it to such a call is fine — that write is live, and the lint leaves it alone.

`ctx.record` is also **not** an authorization input. An action body runs elevated, so any caller-specific rule has to be re-established inside it — and `ctx.record.id` carries the requested `recordId` even when the caller-scope load did **not** deliver the row, so `if (!ctx.record?.id) …` never refuses. The key that distinguishes the two is `ctx.recordLoadDenied` (`=== true` exactly when a load was attempted and returned nothing; absent otherwise). See [Authorization inside an action](/docs/ui/actions#authorization-inside-an-action).

### Engine

The sandbox engine is **`quickjs-emscripten`** — pure-WASM, runs on every JS host. We considered `isolated-vm` but its native dependency disqualifies edge targets. The choice is hidden behind the `ScriptRunner` interface in `packages/runtime/src/sandbox/`, so a node-only deployment can swap in a faster engine later without touching call sites.
Expand Down
60 changes: 59 additions & 1 deletion content/docs/ui/actions.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -161,7 +161,10 @@ rejected at authoring time.) Also note that both data surfaces a body reaches
— `ctx.api.object(name)` and the handler's `ctx.engine` facade — are
**trusted**: they run under the caller's identity elevated to system, so they
bypass row- and field-level security (writes stay attributed to the caller and
scoped to their organization). Enforce any caller-specific rules yourself.
scoped to their organization). Enforce any caller-specific rules yourself — and
read [Authorization inside an action](#authorization-inside-an-action) before
you write that guard, because `ctx.record.id` is present even when the caller
cannot read the row.
</Callout>

<Callout type="warn">
Expand DownExpand Up@@ -301,6 +304,61 @@ is a spec proposal for a properly named key, not a values map under this one.
- **`requiresFeature`** ties visibility to a feature flag (compiled into a
`visible` predicate).

### Authorization inside an action

An action body and a registered handler both run **elevated**: `ctx.api` and
`ctx.engine` carry `isSystem`, so they bypass row- and field-level security by
design (that is what lets an action do work the caller cannot do directly). The
consequence is that any caller-specific rule has to be re-established **inside**
your handler — and the predicate most people reach for first does not work:

```js
if (!ctx.record?.id) return refuse(); // ❌ always false — never refuses anything
```

Before dispatch, the platform loads the subject row in **your caller's own
scope**. If that read comes back empty — the row is invisible to them under
row-level security, or the id names nothing — the dispatcher still puts the
`recordId` from the request onto `ctx.record.id`, because a **new-record /
record-less** action legitimately needs it there. So `ctx.record.id` is present
either way, and the guard above passes for a caller who cannot see the row.

The signal that *does* distinguish them is `ctx.recordLoadDenied`:

```js
// ✅ the caller-scope load did not deliver the row — do not act on it
if (ctx.recordLoadDenied) {
throw Object.assign(new Error('Record not available'), { code: 'RECORD_NOT_FOUND' });
}
```

| | `ctx.record.id` | `ctx.recordLoadDenied` |
|:---|:---|:---|
| Caller **can** read the row | the id | absent |
| Caller **cannot** read the row | the id (stamped) | `true` |
| New-record / record-less action | the id, if one was passed | absent |

<Callout type="info">
Read it as `ctx.recordLoadDenied === true`. The key is **absent**, never
`false`, when nothing was refused — so an action that never loads a row (no
`recordId`, or an object-less action) never trips the guard.

It reports **"the row did not resolve for this caller"**, not "the platform saw
an authorization error". A row hidden by row-level security and an id that names
nothing both arrive as `RECORD_NOT_FOUND`, deliberately — the platform does not
disclose whether a record you cannot see exists — and the flag does not pretend
to separate what that read fuses. For an authorization decision they are the
same answer: this caller has not demonstrated read access to that row.

Both invocation doors set it — `POST /api/v1/actions/...` and the MCP
`run_action` tool — and it reaches inline `body` sandboxes and registered
handlers alike.
</Callout>

Set `ctx.recordLoadDenied` aside only when your action is *meant* to run without
a readable subject row (an "import this id from elsewhere" action, say). The
default for a row-scoped action on a `private` object is to refuse.

## Call it over REST

Every action is also an endpoint — the Console button and the API call run
Expand Down
112 changes: 102 additions & 10 deletions packages/runtime/src/action-execution.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -1177,6 +1177,97 @@ export function buildActionEngineFacade(_deps: ActionExecutionDeps, ql: any, ec?
};
}

/**
* The subject-record load's outcome, as the two action doors hand it to a
* handler (#14143).
*/
export interface ActionSubjectRecordLoad {
/** What the handler receives as `ctx.record`. Unchanged by #14143. */
record: Record<string, unknown>;
/**
* `true` exactly when a caller-scope load was ATTEMPTED and did not deliver
* the row. Absent-or-`false` otherwise — including for the record-less and
* new-record actions that never attempt one.
*/
recordLoadDenied: boolean;
}

/**
* Load an action's subject record IN THE CALLER'S OWN SCOPE, and report whether
* that load actually delivered the row (#14143). ONE producer for both action
* doors — the MCP `run_action` bridge below and the REST `/actions` route
* (`domains/actions.ts`) — because the signal it emits is documented to app
* authors, and a signal only one of two doors sets is an authorization guard
* that is silently inert on the other.
*
* ## Why the signal exists
*
* The load runs under the CALLER's `ExecutionContext` deliberately: an action's
* subject row must be readable by the person invoking the action. But the body
* that follows runs ELEVATED (`buildActionExecutionContext` = `isSystem: true`,
* settled design — #3914), so authorization has to be re-established INSIDE the
* handler, and the platform's most natural predicate for that was broken:
*
* - a refused/absent load leaves `record` as `{}`, so `record.id == null`;
* - the `recordId` stamp below fires on exactly that condition.
*
* The stamp condition and the load-failure condition COINCIDED, so
* `if (!ctx.record?.id) refuse()` — the guard an author reaches for first —
* was true on a row the caller cannot read, every time. The stamp is NOT the
* defect and is kept verbatim: new-record / record-less actions legitimately
* depend on `recordId` being in place, and removing it would break them.
* What was missing is a second, independent channel saying "this id did not
* resolve in your caller's scope", which is what `recordLoadDenied` is.
*
* ## What the flag can and cannot tell you
*
* It reports "the caller-scope load did not deliver the row", NOT "the platform
* caught an authorization error". The read path collapses the two on purpose:
* a row filtered out by RLS and an id that names nothing both arrive as
* `RECORD_NOT_FOUND` / 404 (`recordNotFoundError`, `@objectstack/core`), which
* is existence non-disclosure working as designed — the same reason the doc
* comment on the call site says "an unseen record reads as not-found". Nothing
* in the caught error separates them, so this flag deliberately does not
* pretend to, and carries no code/status: for an authorization decision the two
* are ONE answer — this caller has not demonstrated read access to that row.
*/
export async function loadActionSubjectRecord(
objectName: string,
recordId: string | undefined,
getRecord: () => Promise<any>,
): Promise<ActionSubjectRecordLoad> {
let record: Record<string, unknown> = {};
let recordLoadDenied = false;
if (recordId && !isObjectLessActionKey(objectName)) {
try {
const got: any = await getRecord();
if (got?.record) record = got.record;
// A resolved call that carried no row is the same fact as a thrown
// one — the protocol's own 404 arrives as a throw, but a data
// service that answers `{ record: undefined }` must not read as a
// successful load just because it declined to throw.
else recordLoadDenied = true;
} catch {
/* new-record / record-less actions pass an empty record */
recordLoadDenied = true;
}
}
// ⛔ Do NOT delete: a new-record / record-less action's handler reads its
// id from here. `recordLoadDenied` is what tells the two cases apart now.
if (record && (record as any).id == null && recordId) (record as any).id = recordId;
return { record, recordLoadDenied };
}

/**
* The `ctx` keys that carry {@link loadActionSubjectRecord}'s verdict into an
* action context — spread so the flag is ABSENT rather than `false` when no
* load was refused, matching the `referentialFieldClear` marker convention on
* the sandbox seam: a body reads `ctx.recordLoadDenied === true`.
*/
export function actionRecordLoadSignal(load: ActionSubjectRecordLoad): { recordLoadDenied?: true } {
return load.recordLoadDenied ? { recordLoadDenied: true } : {};
}

/**
* Resolve + invoke a business action by its declarative name for the MCP
* `run_action` tool. Enforces the AI-exposure gate (`ai.exposed`, #2849), the
Expand DownExpand Up@@ -1276,16 +1367,11 @@ export async function invokeBusinessAction(deps: ActionExecutionDeps,

// Load the subject record under RLS when row-context (engages the same
// permission path as get_record — an unseen record reads as not-found).
let record: Record<string, unknown> = {};
if (recordId && !isObjectLessActionKey(objectName)) {
try {
const got: any = await callData('get', { object: objectName, id: recordId }, driver, envId, ec);
if (got?.record) record = got.record;
} catch {
/* new-record / record-less actions pass an empty record */
}
}
if (record && (record as any).id == null && recordId) (record as any).id = recordId;
// [#14143] Through the ONE shared producer, so this door and the REST
// `/actions` door emit the same `recordLoadDenied` signal to handlers.
const subject = await loadActionSubjectRecord(objectName, recordId, () =>
callData('get', { object: objectName, id: recordId }, driver, envId, ec));
const record = subject.record;

// [#5372] One shared producer for the user shape (`security/actor-user.ts`),
// the same one the REST `/actions` route and the AI routes use. What stood
Expand DownExpand Up@@ -1330,6 +1416,12 @@ export async function invokeBusinessAction(deps: ActionExecutionDeps,
);
const actionContext: any = {
record,
// [#14143] The caller-scope load's verdict, on the same context the
// record rides. `ctx.record.id` is present either way (the stamp is
// load-bearing for record-less actions), so this is the ONLY thing that
// tells a handler its subject row did not resolve for THIS caller —
// and the body face carries it too (`sandbox/body-runner.ts`).
...actionRecordLoadSignal(subject),
user,
session: buildActionSession(deps, ec),
engine: buildActionEngineFacade(deps, ql, ec),
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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
63 changes: 63 additions & 0 deletions .changeset/action-record-load-denied-signal.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
---
"@objectstack/runtime": minor
---

fix(runtime): tell an action handler when its caller-scope record load was refused (#14143)

Both action doors load the subject row under the **caller's** own execution
context, and both then stamp the requested id back onto the record:

```ts
if (record && record.id == null && recordId) record.id = recordId;
```

A refused or empty load leaves `record` as `{}`, so `record.id` is exactly
`null` — which is the stamp's own condition. The stamp condition and the
load-failure condition **coincided**. An action body runs elevated
(`isSystem: true`, settled design — #3914), so authorization has to be
re-established inside the handler, and the predicate every author reaches for
first was therefore always false:

```js
if (!ctx.record?.id) return refuse(); // never refused anything
```

An app author had to rediscover this by reading the dispatcher, or ship a guard
that passes on a row the caller cannot see. Undocumented, and identically broken
on both invocation paths.

**The addition: `ctx.recordLoadDenied`.** `true` exactly when a caller-scope
load was **attempted** and did not deliver the row; **absent** — never `false` —
otherwise, matching the `referentialFieldClear` marker convention on the same
seam, so a handler reads `ctx.recordLoadDenied === true`.

```js
if (ctx.recordLoadDenied) {
throw Object.assign(new Error('Record not available'), { code: 'RECORD_NOT_FOUND' });
}
```

- **Purely additive.** Nothing is refused that was not refused before, no
existing key changes value, and the `recordId` stamp is deliberately
**kept**: new-record / record-less actions depend on it, so `ctx.record.id`
still arrives exactly as it did. Pinned in both directions.
- **Both doors, one producer.** REST `POST /api/v1/actions/...` and the MCP
`run_action` bridge now share `loadActionSubjectRecord`. A signal only one
door emitted would be an authorization guard silently inert on the other.
- **The body face too.** The sandbox `ctx` is a fixed key set, so the flag is
marshalled explicitly into the VM — an inline `body` (the surface an AI author
writes most) reads it exactly as a registered handler does.
- **Documented**, in `docs/ui/actions` ("Authorization inside an action") and
from the action-`ctx` section of `docs/automation/hook-bodies` — half the
defect was that none of this was written down anywhere.

**What the flag does not claim.** It reports "the row did not resolve for this
caller", not "the platform caught an authorization error". A row hidden by
row-level security and an id that names nothing both arrive as
`RECORD_NOT_FOUND` / 404 — existence non-disclosure working as designed — and
nothing in the caught error separates them, so the flag carries no code or
status and does not pretend to. For an authorization decision the two are one
answer: this caller has not demonstrated read access to that row.

The `isSystem` elevation itself is unchanged and is not the defect (#3914); no
call that reaches a handler today stops reaching it.
2 changes: 2 additions & 0 deletions content/docs/automation/hook-bodies.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -238,6 +238,8 @@ await ctx.api.object('crm_deal').updateById(ctx.recordId, { stage: 'won' });

Mutating the snapshot *as a payload* and then handing it to such a call is fine — that write is live, and the lint leaves it alone.

`ctx.record` is also **not** an authorization input. An action body runs elevated, so any caller-specific rule has to be re-established inside it — and `ctx.record.id` carries the requested `recordId` even when the caller-scope load did **not** deliver the row, so `if (!ctx.record?.id) …` never refuses. The key that distinguishes the two is `ctx.recordLoadDenied` (`=== true` exactly when a load was attempted and returned nothing; absent otherwise). See [Authorization inside an action](/docs/ui/actions#authorization-inside-an-action).

### Engine

The sandbox engine is **`quickjs-emscripten`** — pure-WASM, runs on every JS host. We considered `isolated-vm` but its native dependency disqualifies edge targets. The choice is hidden behind the `ScriptRunner` interface in `packages/runtime/src/sandbox/`, so a node-only deployment can swap in a faster engine later without touching call sites.
Expand Down
60 changes: 59 additions & 1 deletion content/docs/ui/actions.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -161,7 +161,10 @@ rejected at authoring time.) Also note that both data surfaces a body reaches
— `ctx.api.object(name)` and the handler's `ctx.engine` facade — are
**trusted**: they run under the caller's identity elevated to system, so they
bypass row- and field-level security (writes stay attributed to the caller and
scoped to their organization). Enforce any caller-specific rules yourself.
scoped to their organization). Enforce any caller-specific rules yourself — and
read [Authorization inside an action](#authorization-inside-an-action) before
you write that guard, because `ctx.record.id` is present even when the caller
cannot read the row.
</Callout>

<Callout type="warn">
Expand DownExpand Up@@ -301,6 +304,61 @@ is a spec proposal for a properly named key, not a values map under this one.
- **`requiresFeature`** ties visibility to a feature flag (compiled into a
`visible` predicate).

### Authorization inside an action

An action body and a registered handler both run **elevated**: `ctx.api` and
`ctx.engine` carry `isSystem`, so they bypass row- and field-level security by
design (that is what lets an action do work the caller cannot do directly). The
consequence is that any caller-specific rule has to be re-established **inside**
your handler — and the predicate most people reach for first does not work:

```js
if (!ctx.record?.id) return refuse(); // ❌ always false — never refuses anything
```

Before dispatch, the platform loads the subject row in **your caller's own
scope**. If that read comes back empty — the row is invisible to them under
row-level security, or the id names nothing — the dispatcher still puts the
`recordId` from the request onto `ctx.record.id`, because a **new-record /
record-less** action legitimately needs it there. So `ctx.record.id` is present
either way, and the guard above passes for a caller who cannot see the row.

The signal that *does* distinguish them is `ctx.recordLoadDenied`:

```js
// ✅ the caller-scope load did not deliver the row — do not act on it
if (ctx.recordLoadDenied) {
throw Object.assign(new Error('Record not available'), { code: 'RECORD_NOT_FOUND' });
}
```

| | `ctx.record.id` | `ctx.recordLoadDenied` |
|:---|:---|:---|
| Caller **can** read the row | the id | absent |
| Caller **cannot** read the row | the id (stamped) | `true` |
| New-record / record-less action | the id, if one was passed | absent |

<Callout type="info">
Read it as `ctx.recordLoadDenied === true`. The key is **absent**, never
`false`, when nothing was refused — so an action that never loads a row (no
`recordId`, or an object-less action) never trips the guard.

It reports **"the row did not resolve for this caller"**, not "the platform saw
an authorization error". A row hidden by row-level security and an id that names
nothing both arrive as `RECORD_NOT_FOUND`, deliberately — the platform does not
disclose whether a record you cannot see exists — and the flag does not pretend
to separate what that read fuses. For an authorization decision they are the
same answer: this caller has not demonstrated read access to that row.

Both invocation doors set it — `POST /api/v1/actions/...` and the MCP
`run_action` tool — and it reaches inline `body` sandboxes and registered
handlers alike.
</Callout>

Set `ctx.recordLoadDenied` aside only when your action is *meant* to run without
a readable subject row (an "import this id from elsewhere" action, say). The
default for a row-scoped action on a `private` object is to refuse.

## Call it over REST

Every action is also an endpoint — the Console button and the API call run
Expand Down
112 changes: 102 additions & 10 deletions packages/runtime/src/action-execution.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -1177,6 +1177,97 @@ export function buildActionEngineFacade(_deps: ActionExecutionDeps, ql: any, ec?
};
}

/**
* The subject-record load's outcome, as the two action doors hand it to a
* handler (#14143).
*/
export interface ActionSubjectRecordLoad {
/** What the handler receives as `ctx.record`. Unchanged by #14143. */
record: Record<string, unknown>;
/**
* `true` exactly when a caller-scope load was ATTEMPTED and did not deliver
* the row. Absent-or-`false` otherwise — including for the record-less and
* new-record actions that never attempt one.
*/
recordLoadDenied: boolean;
}

/**
* Load an action's subject record IN THE CALLER'S OWN SCOPE, and report whether
* that load actually delivered the row (#14143). ONE producer for both action
* doors — the MCP `run_action` bridge below and the REST `/actions` route
* (`domains/actions.ts`) — because the signal it emits is documented to app
* authors, and a signal only one of two doors sets is an authorization guard
* that is silently inert on the other.
*
* ## Why the signal exists
*
* The load runs under the CALLER's `ExecutionContext` deliberately: an action's
* subject row must be readable by the person invoking the action. But the body
* that follows runs ELEVATED (`buildActionExecutionContext` = `isSystem: true`,
* settled design — #3914), so authorization has to be re-established INSIDE the
* handler, and the platform's most natural predicate for that was broken:
*
* - a refused/absent load leaves `record` as `{}`, so `record.id == null`;
* - the `recordId` stamp below fires on exactly that condition.
*
* The stamp condition and the load-failure condition COINCIDED, so
* `if (!ctx.record?.id) refuse()` — the guard an author reaches for first —
* was true on a row the caller cannot read, every time. The stamp is NOT the
* defect and is kept verbatim: new-record / record-less actions legitimately
* depend on `recordId` being in place, and removing it would break them.
* What was missing is a second, independent channel saying "this id did not
* resolve in your caller's scope", which is what `recordLoadDenied` is.
*
* ## What the flag can and cannot tell you
*
* It reports "the caller-scope load did not deliver the row", NOT "the platform
* caught an authorization error". The read path collapses the two on purpose:
* a row filtered out by RLS and an id that names nothing both arrive as
* `RECORD_NOT_FOUND` / 404 (`recordNotFoundError`, `@objectstack/core`), which
* is existence non-disclosure working as designed — the same reason the doc
* comment on the call site says "an unseen record reads as not-found". Nothing
* in the caught error separates them, so this flag deliberately does not
* pretend to, and carries no code/status: for an authorization decision the two
* are ONE answer — this caller has not demonstrated read access to that row.
*/
export async function loadActionSubjectRecord(
objectName: string,
recordId: string | undefined,
getRecord: () => Promise<any>,
): Promise<ActionSubjectRecordLoad> {
let record: Record<string, unknown> = {};
let recordLoadDenied = false;
if (recordId && !isObjectLessActionKey(objectName)) {
try {
const got: any = await getRecord();
if (got?.record) record = got.record;
// A resolved call that carried no row is the same fact as a thrown
// one — the protocol's own 404 arrives as a throw, but a data
// service that answers `{ record: undefined }` must not read as a
// successful load just because it declined to throw.
else recordLoadDenied = true;
} catch {
/* new-record / record-less actions pass an empty record */
recordLoadDenied = true;
}
}
// ⛔ Do NOT delete: a new-record / record-less action's handler reads its
// id from here. `recordLoadDenied` is what tells the two cases apart now.
if (record && (record as any).id == null && recordId) (record as any).id = recordId;
return { record, recordLoadDenied };
}

/**
* The `ctx` keys that carry {@link loadActionSubjectRecord}'s verdict into an
* action context — spread so the flag is ABSENT rather than `false` when no
* load was refused, matching the `referentialFieldClear` marker convention on
* the sandbox seam: a body reads `ctx.recordLoadDenied === true`.
*/
export function actionRecordLoadSignal(load: ActionSubjectRecordLoad): { recordLoadDenied?: true } {
return load.recordLoadDenied ? { recordLoadDenied: true } : {};
}

/**
* Resolve + invoke a business action by its declarative name for the MCP
* `run_action` tool. Enforces the AI-exposure gate (`ai.exposed`, #2849), the
Expand DownExpand Up@@ -1276,16 +1367,11 @@ export async function invokeBusinessAction(deps: ActionExecutionDeps,

// Load the subject record under RLS when row-context (engages the same
// permission path as get_record — an unseen record reads as not-found).
let record: Record<string, unknown> = {};
if (recordId && !isObjectLessActionKey(objectName)) {
try {
const got: any = await callData('get', { object: objectName, id: recordId }, driver, envId, ec);
if (got?.record) record = got.record;
} catch {
/* new-record / record-less actions pass an empty record */
}
}
if (record && (record as any).id == null && recordId) (record as any).id = recordId;
// [#14143] Through the ONE shared producer, so this door and the REST
// `/actions` door emit the same `recordLoadDenied` signal to handlers.
const subject = await loadActionSubjectRecord(objectName, recordId, () =>
callData('get', { object: objectName, id: recordId }, driver, envId, ec));
const record = subject.record;

// [#5372] One shared producer for the user shape (`security/actor-user.ts`),
// the same one the REST `/actions` route and the AI routes use. What stood
Expand DownExpand Up@@ -1330,6 +1416,12 @@ export async function invokeBusinessAction(deps: ActionExecutionDeps,
);
const actionContext: any = {
record,
// [#14143] The caller-scope load's verdict, on the same context the
// record rides. `ctx.record.id` is present either way (the stamp is
// load-bearing for record-less actions), so this is the ONLY thing that
// tells a handler its subject row did not resolve for THIS caller —
// and the body face carries it too (`sandbox/body-runner.ts`).
...actionRecordLoadSignal(subject),
user,
session: buildActionSession(deps, ec),
engine: buildActionEngineFacade(deps, ql, ec),
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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
63 changes: 63 additions & 0 deletions .changeset/action-record-load-denied-signal.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
---
"@objectstack/runtime": minor
---

fix(runtime): tell an action handler when its caller-scope record load was refused (#14143)

Both action doors load the subject row under the **caller's** own execution
context, and both then stamp the requested id back onto the record:

```ts
if (record && record.id == null && recordId) record.id = recordId;
```

A refused or empty load leaves `record` as `{}`, so `record.id` is exactly
`null` — which is the stamp's own condition. The stamp condition and the
load-failure condition **coincided**. An action body runs elevated
(`isSystem: true`, settled design — #3914), so authorization has to be
re-established inside the handler, and the predicate every author reaches for
first was therefore always false:

```js
if (!ctx.record?.id) return refuse(); // never refused anything
```

An app author had to rediscover this by reading the dispatcher, or ship a guard
that passes on a row the caller cannot see. Undocumented, and identically broken
on both invocation paths.

**The addition: `ctx.recordLoadDenied`.** `true` exactly when a caller-scope
load was **attempted** and did not deliver the row; **absent** — never `false` —
otherwise, matching the `referentialFieldClear` marker convention on the same
seam, so a handler reads `ctx.recordLoadDenied === true`.

```js
if (ctx.recordLoadDenied) {
throw Object.assign(new Error('Record not available'), { code: 'RECORD_NOT_FOUND' });
}
```

- **Purely additive.** Nothing is refused that was not refused before, no
existing key changes value, and the `recordId` stamp is deliberately
**kept**: new-record / record-less actions depend on it, so `ctx.record.id`
still arrives exactly as it did. Pinned in both directions.
- **Both doors, one producer.** REST `POST /api/v1/actions/...` and the MCP
`run_action` bridge now share `loadActionSubjectRecord`. A signal only one
door emitted would be an authorization guard silently inert on the other.
- **The body face too.** The sandbox `ctx` is a fixed key set, so the flag is
marshalled explicitly into the VM — an inline `body` (the surface an AI author
writes most) reads it exactly as a registered handler does.
- **Documented**, in `docs/ui/actions` ("Authorization inside an action") and
from the action-`ctx` section of `docs/automation/hook-bodies` — half the
defect was that none of this was written down anywhere.

**What the flag does not claim.** It reports "the row did not resolve for this
caller", not "the platform caught an authorization error". A row hidden by
row-level security and an id that names nothing both arrive as
`RECORD_NOT_FOUND` / 404 — existence non-disclosure working as designed — and
nothing in the caught error separates them, so the flag carries no code or
status and does not pretend to. For an authorization decision the two are one
answer: this caller has not demonstrated read access to that row.

The `isSystem` elevation itself is unchanged and is not the defect (#3914); no
call that reaches a handler today stops reaching it.
2 changes: 2 additions & 0 deletions content/docs/automation/hook-bodies.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -238,6 +238,8 @@ await ctx.api.object('crm_deal').updateById(ctx.recordId, { stage: 'won' });

Mutating the snapshot *as a payload* and then handing it to such a call is fine — that write is live, and the lint leaves it alone.

`ctx.record` is also **not** an authorization input. An action body runs elevated, so any caller-specific rule has to be re-established inside it — and `ctx.record.id` carries the requested `recordId` even when the caller-scope load did **not** deliver the row, so `if (!ctx.record?.id) …` never refuses. The key that distinguishes the two is `ctx.recordLoadDenied` (`=== true` exactly when a load was attempted and returned nothing; absent otherwise). See [Authorization inside an action](/docs/ui/actions#authorization-inside-an-action).

### Engine

The sandbox engine is **`quickjs-emscripten`** — pure-WASM, runs on every JS host. We considered `isolated-vm` but its native dependency disqualifies edge targets. The choice is hidden behind the `ScriptRunner` interface in `packages/runtime/src/sandbox/`, so a node-only deployment can swap in a faster engine later without touching call sites.
Expand Down
60 changes: 59 additions & 1 deletion content/docs/ui/actions.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -161,7 +161,10 @@ rejected at authoring time.) Also note that both data surfaces a body reaches
— `ctx.api.object(name)` and the handler's `ctx.engine` facade — are
**trusted**: they run under the caller's identity elevated to system, so they
bypass row- and field-level security (writes stay attributed to the caller and
scoped to their organization). Enforce any caller-specific rules yourself.
scoped to their organization). Enforce any caller-specific rules yourself — and
read [Authorization inside an action](#authorization-inside-an-action) before
you write that guard, because `ctx.record.id` is present even when the caller
cannot read the row.
</Callout>

<Callout type="warn">
Expand DownExpand Up@@ -301,6 +304,61 @@ is a spec proposal for a properly named key, not a values map under this one.
- **`requiresFeature`** ties visibility to a feature flag (compiled into a
`visible` predicate).

### Authorization inside an action

An action body and a registered handler both run **elevated**: `ctx.api` and
`ctx.engine` carry `isSystem`, so they bypass row- and field-level security by
design (that is what lets an action do work the caller cannot do directly). The
consequence is that any caller-specific rule has to be re-established **inside**
your handler — and the predicate most people reach for first does not work:

```js
if (!ctx.record?.id) return refuse(); // ❌ always false — never refuses anything
```

Before dispatch, the platform loads the subject row in **your caller's own
scope**. If that read comes back empty — the row is invisible to them under
row-level security, or the id names nothing — the dispatcher still puts the
`recordId` from the request onto `ctx.record.id`, because a **new-record /
record-less** action legitimately needs it there. So `ctx.record.id` is present
either way, and the guard above passes for a caller who cannot see the row.

The signal that *does* distinguish them is `ctx.recordLoadDenied`:

```js
// ✅ the caller-scope load did not deliver the row — do not act on it
if (ctx.recordLoadDenied) {
throw Object.assign(new Error('Record not available'), { code: 'RECORD_NOT_FOUND' });
}
```

| | `ctx.record.id` | `ctx.recordLoadDenied` |
|:---|:---|:---|
| Caller **can** read the row | the id | absent |
| Caller **cannot** read the row | the id (stamped) | `true` |
| New-record / record-less action | the id, if one was passed | absent |

<Callout type="info">
Read it as `ctx.recordLoadDenied === true`. The key is **absent**, never
`false`, when nothing was refused — so an action that never loads a row (no
`recordId`, or an object-less action) never trips the guard.

It reports **"the row did not resolve for this caller"**, not "the platform saw
an authorization error". A row hidden by row-level security and an id that names
nothing both arrive as `RECORD_NOT_FOUND`, deliberately — the platform does not
disclose whether a record you cannot see exists — and the flag does not pretend
to separate what that read fuses. For an authorization decision they are the
same answer: this caller has not demonstrated read access to that row.

Both invocation doors set it — `POST /api/v1/actions/...` and the MCP
`run_action` tool — and it reaches inline `body` sandboxes and registered
handlers alike.
</Callout>

Set `ctx.recordLoadDenied` aside only when your action is *meant* to run without
a readable subject row (an "import this id from elsewhere" action, say). The
default for a row-scoped action on a `private` object is to refuse.

## Call it over REST

Every action is also an endpoint — the Console button and the API call run
Expand Down
112 changes: 102 additions & 10 deletions packages/runtime/src/action-execution.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -1177,6 +1177,97 @@ export function buildActionEngineFacade(_deps: ActionExecutionDeps, ql: any, ec?
};
}

/**
* The subject-record load's outcome, as the two action doors hand it to a
* handler (#14143).
*/
export interface ActionSubjectRecordLoad {
/** What the handler receives as `ctx.record`. Unchanged by #14143. */
record: Record<string, unknown>;
/**
* `true` exactly when a caller-scope load was ATTEMPTED and did not deliver
* the row. Absent-or-`false` otherwise — including for the record-less and
* new-record actions that never attempt one.
*/
recordLoadDenied: boolean;
}

/**
* Load an action's subject record IN THE CALLER'S OWN SCOPE, and report whether
* that load actually delivered the row (#14143). ONE producer for both action
* doors — the MCP `run_action` bridge below and the REST `/actions` route
* (`domains/actions.ts`) — because the signal it emits is documented to app
* authors, and a signal only one of two doors sets is an authorization guard
* that is silently inert on the other.
*
* ## Why the signal exists
*
* The load runs under the CALLER's `ExecutionContext` deliberately: an action's
* subject row must be readable by the person invoking the action. But the body
* that follows runs ELEVATED (`buildActionExecutionContext` = `isSystem: true`,
* settled design — #3914), so authorization has to be re-established INSIDE the
* handler, and the platform's most natural predicate for that was broken:
*
* - a refused/absent load leaves `record` as `{}`, so `record.id == null`;
* - the `recordId` stamp below fires on exactly that condition.
*
* The stamp condition and the load-failure condition COINCIDED, so
* `if (!ctx.record?.id) refuse()` — the guard an author reaches for first —
* was true on a row the caller cannot read, every time. The stamp is NOT the
* defect and is kept verbatim: new-record / record-less actions legitimately
* depend on `recordId` being in place, and removing it would break them.
* What was missing is a second, independent channel saying "this id did not
* resolve in your caller's scope", which is what `recordLoadDenied` is.
*
* ## What the flag can and cannot tell you
*
* It reports "the caller-scope load did not deliver the row", NOT "the platform
* caught an authorization error". The read path collapses the two on purpose:
* a row filtered out by RLS and an id that names nothing both arrive as
* `RECORD_NOT_FOUND` / 404 (`recordNotFoundError`, `@objectstack/core`), which
* is existence non-disclosure working as designed — the same reason the doc
* comment on the call site says "an unseen record reads as not-found". Nothing
* in the caught error separates them, so this flag deliberately does not
* pretend to, and carries no code/status: for an authorization decision the two
* are ONE answer — this caller has not demonstrated read access to that row.
*/
export async function loadActionSubjectRecord(
objectName: string,
recordId: string | undefined,
getRecord: () => Promise<any>,
): Promise<ActionSubjectRecordLoad> {
let record: Record<string, unknown> = {};
let recordLoadDenied = false;
if (recordId && !isObjectLessActionKey(objectName)) {
try {
const got: any = await getRecord();
if (got?.record) record = got.record;
// A resolved call that carried no row is the same fact as a thrown
// one — the protocol's own 404 arrives as a throw, but a data
// service that answers `{ record: undefined }` must not read as a
// successful load just because it declined to throw.
else recordLoadDenied = true;
} catch {
/* new-record / record-less actions pass an empty record */
recordLoadDenied = true;
}
}
// ⛔ Do NOT delete: a new-record / record-less action's handler reads its
// id from here. `recordLoadDenied` is what tells the two cases apart now.
if (record && (record as any).id == null && recordId) (record as any).id = recordId;
return { record, recordLoadDenied };
}

/**
* The `ctx` keys that carry {@link loadActionSubjectRecord}'s verdict into an
* action context — spread so the flag is ABSENT rather than `false` when no
* load was refused, matching the `referentialFieldClear` marker convention on
* the sandbox seam: a body reads `ctx.recordLoadDenied === true`.
*/
export function actionRecordLoadSignal(load: ActionSubjectRecordLoad): { recordLoadDenied?: true } {
return load.recordLoadDenied ? { recordLoadDenied: true } : {};
}

/**
* Resolve + invoke a business action by its declarative name for the MCP
* `run_action` tool. Enforces the AI-exposure gate (`ai.exposed`, #2849), the
Expand DownExpand Up@@ -1276,16 +1367,11 @@ export async function invokeBusinessAction(deps: ActionExecutionDeps,

// Load the subject record under RLS when row-context (engages the same
// permission path as get_record — an unseen record reads as not-found).
let record: Record<string, unknown> = {};
if (recordId && !isObjectLessActionKey(objectName)) {
try {
const got: any = await callData('get', { object: objectName, id: recordId }, driver, envId, ec);
if (got?.record) record = got.record;
} catch {
/* new-record / record-less actions pass an empty record */
}
}
if (record && (record as any).id == null && recordId) (record as any).id = recordId;
// [#14143] Through the ONE shared producer, so this door and the REST
// `/actions` door emit the same `recordLoadDenied` signal to handlers.
const subject = await loadActionSubjectRecord(objectName, recordId, () =>
callData('get', { object: objectName, id: recordId }, driver, envId, ec));
const record = subject.record;

// [#5372] One shared producer for the user shape (`security/actor-user.ts`),
// the same one the REST `/actions` route and the AI routes use. What stood
Expand DownExpand Up@@ -1330,6 +1416,12 @@ export async function invokeBusinessAction(deps: ActionExecutionDeps,
);
const actionContext: any = {
record,
// [#14143] The caller-scope load's verdict, on the same context the
// record rides. `ctx.record.id` is present either way (the stamp is
// load-bearing for record-less actions), so this is the ONLY thing that
// tells a handler its subject row did not resolve for THIS caller —
// and the body face carries it too (`sandbox/body-runner.ts`).
...actionRecordLoadSignal(subject),
user,
session: buildActionSession(deps, ec),
engine: buildActionEngineFacade(deps, ql, ec),
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
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
63 changes: 63 additions & 0 deletions .changeset/action-record-load-denied-signal.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
---
"@objectstack/runtime": minor
---

fix(runtime): tell an action handler when its caller-scope record load was refused (#14143)

Both action doors load the subject row under the **caller's** own execution
context, and both then stamp the requested id back onto the record:

```ts
if (record && record.id == null && recordId) record.id = recordId;
```

A refused or empty load leaves `record` as `{}`, so `record.id` is exactly
`null` — which is the stamp's own condition. The stamp condition and the
load-failure condition **coincided**. An action body runs elevated
(`isSystem: true`, settled design — #3914), so authorization has to be
re-established inside the handler, and the predicate every author reaches for
first was therefore always false:

```js
if (!ctx.record?.id) return refuse(); // never refused anything
```

An app author had to rediscover this by reading the dispatcher, or ship a guard
that passes on a row the caller cannot see. Undocumented, and identically broken
on both invocation paths.

**The addition: `ctx.recordLoadDenied`.** `true` exactly when a caller-scope
load was **attempted** and did not deliver the row; **absent** — never `false` —
otherwise, matching the `referentialFieldClear` marker convention on the same
seam, so a handler reads `ctx.recordLoadDenied === true`.

```js
if (ctx.recordLoadDenied) {
throw Object.assign(new Error('Record not available'), { code: 'RECORD_NOT_FOUND' });
}
```

- **Purely additive.** Nothing is refused that was not refused before, no
existing key changes value, and the `recordId` stamp is deliberately
**kept**: new-record / record-less actions depend on it, so `ctx.record.id`
still arrives exactly as it did. Pinned in both directions.
- **Both doors, one producer.** REST `POST /api/v1/actions/...` and the MCP
`run_action` bridge now share `loadActionSubjectRecord`. A signal only one
door emitted would be an authorization guard silently inert on the other.
- **The body face too.** The sandbox `ctx` is a fixed key set, so the flag is
marshalled explicitly into the VM — an inline `body` (the surface an AI author
writes most) reads it exactly as a registered handler does.
- **Documented**, in `docs/ui/actions` ("Authorization inside an action") and
from the action-`ctx` section of `docs/automation/hook-bodies` — half the
defect was that none of this was written down anywhere.

**What the flag does not claim.** It reports "the row did not resolve for this
caller", not "the platform caught an authorization error". A row hidden by
row-level security and an id that names nothing both arrive as
`RECORD_NOT_FOUND` / 404 — existence non-disclosure working as designed — and
nothing in the caught error separates them, so the flag carries no code or
status and does not pretend to. For an authorization decision the two are one
answer: this caller has not demonstrated read access to that row.

The `isSystem` elevation itself is unchanged and is not the defect (#3914); no
call that reaches a handler today stops reaching it.
2 changes: 2 additions & 0 deletions content/docs/automation/hook-bodies.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -238,6 +238,8 @@ await ctx.api.object('crm_deal').updateById(ctx.recordId, { stage: 'won' });

Mutating the snapshot *as a payload* and then handing it to such a call is fine — that write is live, and the lint leaves it alone.

`ctx.record` is also **not** an authorization input. An action body runs elevated, so any caller-specific rule has to be re-established inside it — and `ctx.record.id` carries the requested `recordId` even when the caller-scope load did **not** deliver the row, so `if (!ctx.record?.id) …` never refuses. The key that distinguishes the two is `ctx.recordLoadDenied` (`=== true` exactly when a load was attempted and returned nothing; absent otherwise). See [Authorization inside an action](/docs/ui/actions#authorization-inside-an-action).

### Engine

The sandbox engine is **`quickjs-emscripten`** — pure-WASM, runs on every JS host. We considered `isolated-vm` but its native dependency disqualifies edge targets. The choice is hidden behind the `ScriptRunner` interface in `packages/runtime/src/sandbox/`, so a node-only deployment can swap in a faster engine later without touching call sites.
Expand Down
60 changes: 59 additions & 1 deletion content/docs/ui/actions.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -161,7 +161,10 @@ rejected at authoring time.) Also note that both data surfaces a body reaches
— `ctx.api.object(name)` and the handler's `ctx.engine` facade — are
**trusted**: they run under the caller's identity elevated to system, so they
bypass row- and field-level security (writes stay attributed to the caller and
scoped to their organization). Enforce any caller-specific rules yourself.
scoped to their organization). Enforce any caller-specific rules yourself — and
read [Authorization inside an action](#authorization-inside-an-action) before
you write that guard, because `ctx.record.id` is present even when the caller
cannot read the row.
</Callout>

<Callout type="warn">
Expand DownExpand Up@@ -301,6 +304,61 @@ is a spec proposal for a properly named key, not a values map under this one.
- **`requiresFeature`** ties visibility to a feature flag (compiled into a
`visible` predicate).

### Authorization inside an action

An action body and a registered handler both run **elevated**: `ctx.api` and
`ctx.engine` carry `isSystem`, so they bypass row- and field-level security by
design (that is what lets an action do work the caller cannot do directly). The
consequence is that any caller-specific rule has to be re-established **inside**
your handler — and the predicate most people reach for first does not work:

```js
if (!ctx.record?.id) return refuse(); // ❌ always false — never refuses anything
```

Before dispatch, the platform loads the subject row in **your caller's own
scope**. If that read comes back empty — the row is invisible to them under
row-level security, or the id names nothing — the dispatcher still puts the
`recordId` from the request onto `ctx.record.id`, because a **new-record /
record-less** action legitimately needs it there. So `ctx.record.id` is present
either way, and the guard above passes for a caller who cannot see the row.

The signal that *does* distinguish them is `ctx.recordLoadDenied`:

```js
// ✅ the caller-scope load did not deliver the row — do not act on it
if (ctx.recordLoadDenied) {
throw Object.assign(new Error('Record not available'), { code: 'RECORD_NOT_FOUND' });
}
```

| | `ctx.record.id` | `ctx.recordLoadDenied` |
|:---|:---|:---|
| Caller **can** read the row | the id | absent |
| Caller **cannot** read the row | the id (stamped) | `true` |
| New-record / record-less action | the id, if one was passed | absent |

<Callout type="info">
Read it as `ctx.recordLoadDenied === true`. The key is **absent**, never
`false`, when nothing was refused — so an action that never loads a row (no
`recordId`, or an object-less action) never trips the guard.

It reports **"the row did not resolve for this caller"**, not "the platform saw
an authorization error". A row hidden by row-level security and an id that names
nothing both arrive as `RECORD_NOT_FOUND`, deliberately — the platform does not
disclose whether a record you cannot see exists — and the flag does not pretend
to separate what that read fuses. For an authorization decision they are the
same answer: this caller has not demonstrated read access to that row.

Both invocation doors set it — `POST /api/v1/actions/...` and the MCP
`run_action` tool — and it reaches inline `body` sandboxes and registered
handlers alike.
</Callout>

Set `ctx.recordLoadDenied` aside only when your action is *meant* to run without
a readable subject row (an "import this id from elsewhere" action, say). The
default for a row-scoped action on a `private` object is to refuse.

## Call it over REST

Every action is also an endpoint — the Console button and the API call run
Expand Down
112 changes: 102 additions & 10 deletions packages/runtime/src/action-execution.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -1177,6 +1177,97 @@ export function buildActionEngineFacade(_deps: ActionExecutionDeps, ql: any, ec?
};
}

/**
* The subject-record load's outcome, as the two action doors hand it to a
* handler (#14143).
*/
export interface ActionSubjectRecordLoad {
/** What the handler receives as `ctx.record`. Unchanged by #14143. */
record: Record<string, unknown>;
/**
* `true` exactly when a caller-scope load was ATTEMPTED and did not deliver
* the row. Absent-or-`false` otherwise — including for the record-less and
* new-record actions that never attempt one.
*/
recordLoadDenied: boolean;
}

/**
* Load an action's subject record IN THE CALLER'S OWN SCOPE, and report whether
* that load actually delivered the row (#14143). ONE producer for both action
* doors — the MCP `run_action` bridge below and the REST `/actions` route
* (`domains/actions.ts`) — because the signal it emits is documented to app
* authors, and a signal only one of two doors sets is an authorization guard
* that is silently inert on the other.
*
* ## Why the signal exists
*
* The load runs under the CALLER's `ExecutionContext` deliberately: an action's
* subject row must be readable by the person invoking the action. But the body
* that follows runs ELEVATED (`buildActionExecutionContext` = `isSystem: true`,
* settled design — #3914), so authorization has to be re-established INSIDE the
* handler, and the platform's most natural predicate for that was broken:
*
* - a refused/absent load leaves `record` as `{}`, so `record.id == null`;
* - the `recordId` stamp below fires on exactly that condition.
*
* The stamp condition and the load-failure condition COINCIDED, so
* `if (!ctx.record?.id) refuse()` — the guard an author reaches for first —
* was true on a row the caller cannot read, every time. The stamp is NOT the
* defect and is kept verbatim: new-record / record-less actions legitimately
* depend on `recordId` being in place, and removing it would break them.
* What was missing is a second, independent channel saying "this id did not
* resolve in your caller's scope", which is what `recordLoadDenied` is.
*
* ## What the flag can and cannot tell you
*
* It reports "the caller-scope load did not deliver the row", NOT "the platform
* caught an authorization error". The read path collapses the two on purpose:
* a row filtered out by RLS and an id that names nothing both arrive as
* `RECORD_NOT_FOUND` / 404 (`recordNotFoundError`, `@objectstack/core`), which
* is existence non-disclosure working as designed — the same reason the doc
* comment on the call site says "an unseen record reads as not-found". Nothing
* in the caught error separates them, so this flag deliberately does not
* pretend to, and carries no code/status: for an authorization decision the two
* are ONE answer — this caller has not demonstrated read access to that row.
*/
export async function loadActionSubjectRecord(
objectName: string,
recordId: string | undefined,
getRecord: () => Promise<any>,
): Promise<ActionSubjectRecordLoad> {
let record: Record<string, unknown> = {};
let recordLoadDenied = false;
if (recordId && !isObjectLessActionKey(objectName)) {
try {
const got: any = await getRecord();
if (got?.record) record = got.record;
// A resolved call that carried no row is the same fact as a thrown
// one — the protocol's own 404 arrives as a throw, but a data
// service that answers `{ record: undefined }` must not read as a
// successful load just because it declined to throw.
else recordLoadDenied = true;
} catch {
/* new-record / record-less actions pass an empty record */
recordLoadDenied = true;
}
}
// ⛔ Do NOT delete: a new-record / record-less action's handler reads its
// id from here. `recordLoadDenied` is what tells the two cases apart now.
if (record && (record as any).id == null && recordId) (record as any).id = recordId;
return { record, recordLoadDenied };
}

/**
* The `ctx` keys that carry {@link loadActionSubjectRecord}'s verdict into an
* action context — spread so the flag is ABSENT rather than `false` when no
* load was refused, matching the `referentialFieldClear` marker convention on
* the sandbox seam: a body reads `ctx.recordLoadDenied === true`.
*/
export function actionRecordLoadSignal(load: ActionSubjectRecordLoad): { recordLoadDenied?: true } {
return load.recordLoadDenied ? { recordLoadDenied: true } : {};
}

/**
* Resolve + invoke a business action by its declarative name for the MCP
* `run_action` tool. Enforces the AI-exposure gate (`ai.exposed`, #2849), the
Expand DownExpand Up@@ -1276,16 +1367,11 @@ export async function invokeBusinessAction(deps: ActionExecutionDeps,

// Load the subject record under RLS when row-context (engages the same
// permission path as get_record — an unseen record reads as not-found).
let record: Record<string, unknown> = {};
if (recordId && !isObjectLessActionKey(objectName)) {
try {
const got: any = await callData('get', { object: objectName, id: recordId }, driver, envId, ec);
if (got?.record) record = got.record;
} catch {
/* new-record / record-less actions pass an empty record */
}
}
if (record && (record as any).id == null && recordId) (record as any).id = recordId;
// [#14143] Through the ONE shared producer, so this door and the REST
// `/actions` door emit the same `recordLoadDenied` signal to handlers.
const subject = await loadActionSubjectRecord(objectName, recordId, () =>
callData('get', { object: objectName, id: recordId }, driver, envId, ec));
const record = subject.record;

// [#5372] One shared producer for the user shape (`security/actor-user.ts`),
// the same one the REST `/actions` route and the AI routes use. What stood
Expand DownExpand Up@@ -1330,6 +1416,12 @@ export async function invokeBusinessAction(deps: ActionExecutionDeps,
);
const actionContext: any = {
record,
// [#14143] The caller-scope load's verdict, on the same context the
// record rides. `ctx.record.id` is present either way (the stamp is
// load-bearing for record-less actions), so this is the ONLY thing that
// tells a handler its subject row did not resolve for THIS caller —
// and the body face carries it too (`sandbox/body-runner.ts`).
...actionRecordLoadSignal(subject),
user,
session: buildActionSession(deps, ec),
engine: buildActionEngineFacade(deps, ql, ec),
Expand Down
Loading
Loading