Skip to content
Merged
19 changes: 19 additions & 0 deletions .changeset/21594-body-family-read-refusal.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
---
'@objectstack/runtime': minor
---

fix(runtime)!: an app-authored body may not read the stored-metadata tables either; it reaches them through the metadata API only (#21594)

Clause-②: yes (narrowing)

<!-- adr-0087: not-required (no-migration-prescription) no metadata body, authorable key, spelling, export or stored shape moves; what changes is which reads a sandboxed hook, action or job body may make of the two stored-metadata tables, so `objectstack migrate meta` has nothing to rewrite. The other categories are closed on facts: the package publishes (not `unpublished`); no ADR-0087 id covers a refused read (not `registered` / `already-registered`); and the change is runtime behaviour, not a declaration (not `runtime-interface-only` / `type-surface-only`). -->

**BREAKING**: this narrows what an app-authored body may do with the two stored-metadata tables, `sys_metadata` and `sys_metadata_history`. With the binding and write refusals already in this release, an app-authored body now reaches them through the metadata API only.

- **Reading.** A sandboxed hook, action or job body's read of either table through `ctx.api` answers `PERMISSION_DENIED` / 403 before the read runs. That covers `find`, `findOne`, `count` and `aggregate`, inside a transaction or not, with or without elevation, and whatever filter, sort, grouping, search or projection the read carries. A body is served nothing of these tables, neither the stored row nor a projection of it, and the answer does not depend on what the read asks. A hook body that reads them fails the write that fired it.
- **Subject record.** An action body is not handed a row of either table as its subject record either. The `/actions` door loads an action's subject row before it dispatches. When that row is from either table, the call answers the same `PERMISSION_DENIED` / 403 before the body runs, instead of handing the body the row as `ctx.record`. That covers an action declared on either table (through a bundle, an installed package or the metadata door) and an object-less action addressed under one. A call that carries no record hands the body nothing and runs as before.
- **The route.** A body that read either table through `ctx.api.object(...)` was served the body projected and the content hash keyed; read metadata through the metadata API instead: `GET /api/v1/meta/:type/:name` for a definition, and `GET /api/v1/meta/:type/:name/history` for its versions. The refusal names that route. No shipped example reads either table from a body.
- **This supersedes, for bodies, two earlier entries of this release:** the served read of these tables, and the evaluate-shape refusals (`INVALID_FIELD` / 400) and default-search narrowing of that read. For a body, all of these now give way to this refusal. It also supersedes the binding-and-write entry's note that a body's reads are unchanged.
- **Unchanged:** host code that registers its own action handlers (its `ctx.api`, `ctx.engine.find` and subject record are still served as the generic data door serves these tables, with the door's evaluate refusals); the binding and write refusals; the platform's own readers; the generic data door and the metadata API; and every other object.

It ships as `minor` under the launch-window convention for accept-set narrowings.
8 changes: 5 additions & 3 deletions packages/runtime/src/action-execution.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1500,9 +1500,11 @@ export function buildActionExecutionContext(ec: any): Record<string, unknown> {
*
* [#21454] Served through the stored-metadata reader seam
* (`stored-metadata-reader-seam.ts`): this context is elevated, and it is the
* `ctx.api` a host code handler receives as well as an action body, so a read
* of the stored-metadata-body family answers the generic data door's form
* (the body projected, the content hash keyed) and never the stored row.
* `ctx.api` a host code handler receives, so a read of the stored-metadata-body
* family answers the generic data door's form (the body projected, the content
* hash keyed) and never the stored row. [#21594] An action BODY's API is built
* over this one by the sandbox (`buildSandboxApi`), whose body layers refuse a
* family read or write before it reaches this seam.
*/
export function buildActionApi(_deps: ActionExecutionDeps, ql: any, ec: any): any | undefined {
if (!ql || typeof ql.createContext !== 'function') return undefined;
Expand Down
75 changes: 59 additions & 16 deletions packages/runtime/src/sandbox/body-runner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,11 +61,12 @@ import {
resolveRecordTitle,
resolveRelatedTitleTarget,
} from '@objectstack/objectql';
import { refuseStoredMetadataBodyWrites, serveStoredMetadataReadsThrough } from '../stored-metadata-reader-seam.js';
import { refuseStoredMetadataBodyReads, refuseStoredMetadataBodyWrites } from '../stored-metadata-reader-seam.js';
import { isStoredMetadataBodyObject } from '@objectstack/spec/kernel';
import {
isWildcardHookTarget,
storedMetadataBodyHookBindingRefusal,
storedMetadataBodySubjectRecordRefusal,
storedMetadataFamilyTableList,
} from '../stored-metadata-body-boundary.js';

Expand Down Expand Up @@ -448,6 +449,11 @@ export function actionBodyRunnerFactory(
const body = parsed.data;

return async function boundActionHandler(actionCtx: any): Promise<unknown> {
// [#21594] A body is handed nothing of the stored-metadata family: an
// action whose subject record is a family row is refused here, before
// the body runs and before its sandbox context is built.
const subjectRefusal = actionSubjectRecordRefusal(actionCtx, action);
if (subjectRefusal) throw subjectRefusal;
const sandboxCtx = buildActionSandboxContext(
actionCtx,
opts.ql,
Expand Down Expand Up @@ -478,6 +484,38 @@ export function actionBodyRunnerFactory(
};
}

/**
* [#21594] The refusal for an action body about to be handed a row of the
* stored-metadata family as its subject record, or `undefined`.
*
* Both action doors (REST `/actions` and MCP `run_action`) load the subject
* record before they dispatch, through the generic data door, and stamp the
* routed object and record id into `params` AFTER the caller's own params, so
* `params.objectName` is the door's routed object, never the caller's. That
* object is the subject: an action declared on a family table is routed under
* it, and so is an object-less action a caller addresses under it
* (`/actions/<family table>/<action>/<id>`), which no binding-time refusal
* could see. Absent a routed object (an engine `execute` call from host code),
* the action's declared object stands in.
*
* Judged here — the action body's own handler, the one point every action
* body passes through to run, whichever door bound it — because only here is
* the handler known to be a body: the doors dispatch body and host handlers
* alike, and a host code handler's subject record is outside the boundary.
* A record is "handed" when the call carries a record id or a non-empty
* record; a family-routed call with neither hands the body nothing and runs.
*/
function actionSubjectRecordRefusal(actionCtx: any, action: { name: string; object?: string }): Error | undefined {
const params = actionCtx?.params;
const routed = typeof params?.objectName === 'string' && params.objectName.length > 0 ? params.objectName : undefined;
const subject = routed ?? action.object;
if (typeof subject !== 'string' || !isStoredMetadataBodyObject(subject)) return undefined;
const record = actionCtx?.record;
const handed = (typeof params?.recordId === 'string' && params.recordId.length > 0)
|| (record !== null && typeof record === 'object' && Object.keys(record).length > 0);
return handed ? storedMetadataBodySubjectRecordRefusal(subject, action.name) : undefined;
}

/** What {@link judgeJobBody} answers for a `body` that binds, and for one that does not. */
export type JobBodyJudgement =
| { binds: true; body: ScriptBodyParsed }
Expand Down Expand Up @@ -970,24 +1008,29 @@ function buildEngineRepoFacade(ql: any, objectName: string, context?: any) {
}

/**
* The `ctx.api` a sandboxed body (hook or action) reads and writes through.
*
* [#21454] Served through the stored-metadata reader seam: a read of the
* stored-metadata-body family answers the generic data door's form (the body
* projected, the content hash keyed), never the stored row. This is the one
* place both body faces get their API, so the hook face, the action face and
* every fallback below are served alike, and a body can copy only what it was
* served.
*
* [#21520] And, layered over that, a body may not WRITE a stored-metadata table
* at all: every write of one is refused before it runs, whatever the body's
* elevation. Applied HERE and nowhere else because this is the one place a
* body gets its API — a host code handler's `ctx.api` is served by the read
* seam but keeps its writes (deployer code, outside the boundary).
* The `ctx.api` a sandboxed body (hook, action or job) reads and writes through.
*
* For an app-authored body, the stored-metadata family is reached through the
* metadata API only, so this API refuses every touch of a family table before
* it runs, whatever the body's elevation, with `PERMISSION_DENIED` / 403 and a
* prescription naming the metadata API:
*
* - [#21594] a READ (`find`, `findOne`, `count`, `aggregate`, and every filter,
* sort, grouping or search one can carry): the body is served nothing of the
* family, neither the stored row nor a projection of it, so it can copy
* nothing of it either;
* - [#21520] a WRITE (every write verb, and any verb not known to be a read).
*
* This is the one place every body face gets its API, so the hook face, the
* action face, the job face and every fallback below are refused alike.
* Applied HERE and nowhere else: a host code handler's `ctx.api` is not a
* body's, and is served by the reader-context seam
* (`serveStoredMetadataReadsThrough`) with its writes kept (deployer code,
* outside the boundary).
*/
function buildSandboxApi(engineCtx: any, ql: any, errLabel: string) {
return refuseStoredMetadataBodyWrites(
serveStoredMetadataReadsThrough(buildSandboxApiSource(engineCtx, ql, errLabel), ql),
refuseStoredMetadataBodyReads(buildSandboxApiSource(engineCtx, ql, errLabel)),
);
}

Expand Down
82 changes: 68 additions & 14 deletions packages/runtime/src/stored-metadata-body-boundary.ts
Original file line number Diff line number Diff line change
@@ -1,29 +1,39 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* [#21520] The stored-metadata family's WRITE boundary for app-authored bodies.
* [#21520, #21594] The stored-metadata family's boundary for app-authored bodies.
*
* The family's tables (`sys_metadata` / `sys_metadata_history`, the set
* `isStoredMetadataBodyObject` answers for) have one writer for an app-authored
* body: the metadata protocol, where a change is validated and its provenance
* recorded. A sandboxed body — a hook body or an action body, whether it came
* from a code bundle, an installed artifact or the metadata door — may not
* touch those tables any other way:
* `isStoredMetadataBodyObject` answers for) are reached by an app-authored body
* through the metadata API only: the metadata protocol is their one writer,
* where a change is validated and its provenance recorded, and their one
* reader, which serves each definition in its read projection. A sandboxed
* body — a hook body, an action body or a job body, whether it came from a
* code bundle, an installed artifact or the metadata door — may not touch
* those tables any other way:
*
* - **Binding** a body hook to a family table is refused at registration
* ({@link storedMetadataBodyHookBindingRefusal}, consulted by
* `hookBodyRunnerFactory` — the one point every body hook passes through
* to become a handler, whichever door bound it).
* - **Writing** a family table through a body's `ctx.api` is refused before
* the write runs ({@link storedMetadataBodyWriteRefusal}, consulted by the
* reader-context seam's body layer).
* reader-context seam's body write layer).
* - **Reading** a family table through a body's `ctx.api` is refused before
* the read runs ({@link storedMetadataBodyReadRefusal}, consulted by the
* seam's body read layer), whatever the read's query names.
* - **Being handed** a family row as an action's subject record (`ctx.record`,
* which the `/actions` door loads before dispatch) is refused before the
* body runs ({@link storedMetadataBodySubjectRecordRefusal}, consulted by
* `actionBodyRunnerFactory` — the one point every action body passes
* through to run, whichever door bound it).
*
* Platform code is outside this boundary: the metadata protocol and its own
* writers, the platform's internal hooks (registered as code, never as a
* body) and host code a deployer registers all reach the store through their
* own imports, never through a sandboxed body's API.
* readers and writers, the platform's internal hooks (registered as code,
* never as a body) and host code a deployer registers all reach the store
* through their own imports, never through a sandboxed body's API.
*
* Both refusals carry the standard catalog's `PERMISSION_DENIED` / 403: the
* Every refusal carries the standard catalog's `PERMISSION_DENIED` / 403: the
* condition is that this author context is not permitted the operation on this
* table, which is the catalog member's meaning, and the ledger's own admission
* rule sends a generic permission condition to the standard member rather than
Expand All @@ -37,14 +47,20 @@ import { isStoredMetadataBodyObject, STORED_METADATA_BODY_OBJECTS } from '@objec
export const STORED_METADATA_BODY_BOUNDARY_CODE = 'PERMISSION_DENIED';
export const STORED_METADATA_BODY_BOUNDARY_STATUS = 403;

/** The one prescription both refusals end with: the door an author uses instead. */
/** The prescription the binding and write refusals end with: the door an author changes metadata through. */
const PRESCRIPTION =
'Change metadata through the metadata API (`PUT /api/v1/meta/:type/:name`, the metadata protocol), '
+ 'where it is validated and its provenance is recorded. Elevation (`runAs`, a system context) does not '
+ 'change this.';

function refusal(message: string, object: string, operation: string): Error {
const err = new Error(`${message} ${PRESCRIPTION}`) as Error & Record<string, unknown>;
/** The prescription the read refusal ends with: the door an author reads metadata through. */
const READ_PRESCRIPTION =
'Read metadata through the metadata API (`GET /api/v1/meta/:type/:name`, the metadata protocol, and '
+ '`GET /api/v1/meta/:type/:name/history` for its versions). Elevation (`runAs`, a system context) does not '
+ 'change this.';

function refusal(message: string, object: string, operation: string, prescription: string = PRESCRIPTION): Error {
const err = new Error(`${message} ${prescription}`) as Error & Record<string, unknown>;
err.code = STORED_METADATA_BODY_BOUNDARY_CODE;
err.status = STORED_METADATA_BODY_BOUNDARY_STATUS;
err.object = object;
Expand Down Expand Up @@ -105,6 +121,44 @@ export function storedMetadataBodyWriteRefusal(object: string, verb: string): Er
);
}

/**
* [#21594] The refusal for a sandboxed body's read verb on a family table, or
* `undefined` for any other object. Thrown before the read runs, whatever its
* query names (a filter, a sort, a grouping, a search, a projection, or none),
* so a refused read reaches no row and answers the same way whatever it asks:
* it serves nothing, and it is no oracle on what the table holds.
*/
export function storedMetadataBodyReadRefusal(object: string, verb: string): Error | undefined {
if (!isStoredMetadataBodyObject(object)) return undefined;
return refusal(
`Cannot ${verb} '${object}' from an app-authored body: the read was not run. '${object}' holds stored `
+ 'metadata, and a body may not read it directly.',
object,
verb,
READ_PRESCRIPTION,
);
}

/**
* [#21594] The refusal for an action body that would be handed a family row as
* its subject record, or `undefined` for any other object. The `/actions` door
* loads an action's subject record before it dispatches, through the generic
* data door, so an action declared on a family table — or an object-less one
* addressed under it — would otherwise reach its body with a family row as
* `ctx.record`. Thrown before the body runs, so the body is handed nothing of
* the row; a host code handler's subject record is not judged here.
*/
export function storedMetadataBodySubjectRecordRefusal(object: string, action: string): Error | undefined {
if (!isStoredMetadataBodyObject(object)) return undefined;
return refusal(
`Action '${action}' was not run: its subject record is a row of '${object}', which holds stored `
+ 'metadata, and an app-authored body may not be handed one.',
object,
'record',
READ_PRESCRIPTION,
);
}

/** The family's table names, for messages and logs that list them. */
export function storedMetadataFamilyTableList(): string {
return [...STORED_METADATA_BODY_OBJECTS].map((n) => `'${n}'`).join(', ');
Expand Down
Loading
Loading