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
17 changes: 17 additions & 0 deletions .changeset/21520-body-family-boundary.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
---
'@objectstack/runtime': minor
---

fix(runtime)!: an app-authored body may not bind a hook to, or write, the stored-metadata tables (#21520)

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 tables a sandboxed hook or action body may be bound to and may write, 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 binding or a refused write (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`. For an app-authored body, the metadata protocol is now their only writer: a change to metadata goes through the metadata API, where it is validated and its provenance is recorded.

- **Binding.** A hook with a sandboxed `body` whose `object` names either table, alone or in a list, is no longer bound. The refusal is made at registration, at the one point every body hook becomes a handler, so it holds on every door a hook binds by: a code bundle or boot artifact, an installed artifact, and a hook authored at runtime through the metadata door. It carries `PERMISSION_DENIED` / 403, names the metadata API, and is recorded against the hook in the bind log at `error` (thrown under strict binding). A wildcard (`'*'`) body hook still binds; its body is not run for either table's events, and the bind says so once at `info`.
- **Writing.** A sandboxed action or hook body's write of either table through `ctx.api` — every write verb, inside a transaction or not, with or without elevation — answers `PERMISSION_DENIED` / 403 before the write runs, so nothing lands and the answer does not depend on what the write names.
- **Unchanged:** a body's reads of the two tables (still served as the generic data door serves them); host code that registers its own action handlers or hooks; the platform's own hooks, which are code and still fire on the metadata door's save; and every other object.

The route: change metadata through the metadata API (`PUT /api/v1/meta/:type/:name`) rather than from a body, and bind hooks to the objects an app owns. No shipped example binds a body hook to either table or writes one from a body. It ships as `minor` under the launch-window convention for accept-set narrowings.
49 changes: 47 additions & 2 deletions packages/runtime/src/sandbox/body-runner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,13 @@ import {
resolveRecordTitle,
resolveRelatedTitleTarget,
} from '@objectstack/objectql';
import { serveStoredMetadataReadsThrough } from '../stored-metadata-reader-seam.js';
import { refuseStoredMetadataBodyWrites, serveStoredMetadataReadsThrough } from '../stored-metadata-reader-seam.js';
import { isStoredMetadataBodyObject } from '@objectstack/spec/kernel';
import {
isWildcardHookTarget,
storedMetadataBodyHookBindingRefusal,
storedMetadataFamilyTableList,
} from '../stored-metadata-body-boundary.js';

interface FactoryOptions {
ql: any;
Expand Down Expand Up @@ -290,6 +296,20 @@ export function hookBodyRunnerFactory(
const raw = (hook as any).body;
if (!raw) return undefined;

// [#21520] An app-authored hook BODY may not be bound to a table of the
// stored-metadata family: the metadata protocol is the family's only writer
// for a body. This is the ONE point every body hook passes through to become
// a handler, whichever door bound it — the boot artifact and an installed
// artifact (`bindAppArtifactHandlers`), and runtime-authored hooks
// (ObjectQLPlugin's metadata-service bind, through the engine's default
// runner) — so the refusal is made here, at registration, and never per
// door. Thrown rather than answered `undefined`: the binder records the
// throw against the hook and logs it at `error` (rethrows under `strict`),
// whereas `undefined` would be reported as a missing runner. Platform hooks
// are code, not bodies, and never reach this factory.
const bindingRefusal = storedMetadataBodyHookBindingRefusal(hook as any);
if (bindingRefusal) throw bindingRefusal;

const parsed = HookBodySchema.safeParse(raw);
if (!parsed.success) {
opts.logger?.warn?.('[BodyRunner] invalid hook.body shape', {
Expand All @@ -301,7 +321,24 @@ export function hookBodyRunnerFactory(
}
const body = parsed.data;

// [#21520] A wildcard target names no family table, so it binds — but it
// admits every object, the family's among them, and the boundary is that a
// body never touches those tables. So the body is not run for a family
// table's event (below), and the author is told once, at bind.
if (isWildcardHookTarget((hook as any).object)) {
opts.logger?.info?.(
`[BodyRunner] hook '${hook.name}' targets every object ('*'); its body is never run for the stored-metadata `
+ `tables (${storedMetadataFamilyTableList()}). Change metadata through the metadata API.`,
{ appId: opts.appId, hook: hook.name },
);
}

return async function boundBodyHandler(engineCtx: any): Promise<void> {
// [#21520] The dispatch-side half of the binding refusal above: whatever
// admitted this event (a wildcard, a global registration), a body does not
// run on a stored-metadata table's event, so it never receives that row
// as its input or writes it back.
if (typeof engineCtx?.object === 'string' && isStoredMetadataBodyObject(engineCtx.object)) return;
const sandboxCtx = buildSandboxContext(
engineCtx,
opts.ql,
Expand Down Expand Up @@ -795,9 +832,17 @@ function buildEngineRepoFacade(ql: any, objectName: string, context?: any) {
* 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).
*/
function buildSandboxApi(engineCtx: any, ql: any, errLabel: string) {
return serveStoredMetadataReadsThrough(buildSandboxApiSource(engineCtx, ql, errLabel), ql);
return refuseStoredMetadataBodyWrites(
serveStoredMetadataReadsThrough(buildSandboxApiSource(engineCtx, ql, errLabel), ql),
);
}

function buildSandboxApiSource(engineCtx: any, ql: any, errLabel: string) {
Expand Down
Loading
Loading