From 39a05030e373784409b6a3699a41c395babd37df Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 4 Oct 2026 00:19:37 +0000 Subject: [PATCH 1/8] fix(runtime)!: an app-authored body may not read the stored-metadata tables (wip) Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_016GiHYRmLSNWTfbX9gVQkpz --- packages/runtime/src/sandbox/body-runner.ts | 33 +++-- .../src/stored-metadata-body-boundary.ts | 57 ++++++-- .../src/stored-metadata-reader-seam.ts | 138 +++++++++++++----- 3 files changed, 163 insertions(+), 65 deletions(-) diff --git a/packages/runtime/src/sandbox/body-runner.ts b/packages/runtime/src/sandbox/body-runner.ts index 4a2653b2c5..fdebb43d0f 100644 --- a/packages/runtime/src/sandbox/body-runner.ts +++ b/packages/runtime/src/sandbox/body-runner.ts @@ -61,7 +61,7 @@ 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, @@ -961,24 +961,29 @@ function buildEngineRepoFacade(ql: any, objectName: string, context?: any) { } /** - * The `ctx.api` a sandboxed body (hook or action) reads and writes through. + * The `ctx.api` a sandboxed body (hook, action or job) 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. + * 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: * - * [#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). + * - [#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)), ); } diff --git a/packages/runtime/src/stored-metadata-body-boundary.ts b/packages/runtime/src/stored-metadata-body-boundary.ts index 62b7caba42..3b7f17629f 100644 --- a/packages/runtime/src/stored-metadata-body-boundary.ts +++ b/packages/runtime/src/stored-metadata-body-boundary.ts @@ -1,14 +1,16 @@ // 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 @@ -16,14 +18,17 @@ * 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. * * 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 @@ -37,14 +42,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; +/** 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; err.code = STORED_METADATA_BODY_BOUNDARY_CODE; err.status = STORED_METADATA_BODY_BOUNDARY_STATUS; err.object = object; @@ -105,6 +116,24 @@ 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, + ); +} + /** 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(', '); diff --git a/packages/runtime/src/stored-metadata-reader-seam.ts b/packages/runtime/src/stored-metadata-reader-seam.ts index 51fe56bb5e..76eabe5afe 100644 --- a/packages/runtime/src/stored-metadata-reader-seam.ts +++ b/packages/runtime/src/stored-metadata-reader-seam.ts @@ -16,21 +16,24 @@ * of the family's rule: * * - a sandboxed body's `ctx.api.object(...)` (`sandbox/body-runner.ts`, - * `buildSandboxApi`): action and hook bodies alike, and with them every - * copy a body makes of what it read, since a body can copy only what it - * was served; + * `buildSandboxApi`): hook, action and job bodies alike. [#21594] A body is + * no longer served here at all: for an app-authored body the family is + * reached through the metadata API only, so its API refuses every read and + * every write of a family table before it runs + * ({@link refuseStoredMetadataBodyReads}, {@link refuseStoredMetadataBodyWrites}); * - an action handler's `ctx.api` (`action-execution.ts`, `buildActionApi`): - * the same scoped context an action body receives, handed to host code - * handlers too; + * host code registered with `registerAction`. It is also the source an + * action body's API is built over, and the body's own layers refuse a + * family read or write before this seam is reached; * - an action handler's `ctx.engine.find` (`action-execution.ts`, - * `buildActionEngineFacade`). + * `buildActionEngineFacade`), host code only. * * The answers all run elevated (`isSystem: true`), so the engine cannot tell * them from the platform's own internal readers of the family, which need the * stored form. So the family's rule is applied HERE, at the reader-context * seam, and never at the engine. * - * ## Three things this seam does to a family READ, consuming the door's own code + * ## What this seam does to a host handler's family READ, consuming the door's own code * * 1. **Refuse the EVALUATE shapes** ({@link refuseOrNarrowStoredMetadataEvaluate}), * through the generic data door's OWN refusal predicates @@ -44,8 +47,8 @@ * cross-field `{ $field }` comparand, at any depth), and a default `$search` * is NARROWED by the door's ONE narrowing (`narrowStoredMetadataSearch`) — * the body and hash columns removed, judged field by field by the door's own - * search predicate — rather than refused, so a body may still search a - * family table by `name` exactly as the door serves it; a search that would + * search predicate — rather than refused, so a host handler may still search + * a family table by `name` exactly as the door serves it; a search that would * scan nothing after the removal is refused. A `count` with such a predicate * is an oracle too, so it is guarded the same way and runs the guarded query * (it serves no row, so only the refusal applies to it). @@ -61,24 +64,33 @@ * projected / keyed way a read is, since a returned row is a serve. That * serve is for the contexts that may still write here (a host code * handler's `ctx.api`). - * 4. **Refuse a BODY's write** ({@link refuseStoredMetadataBodyWrites}, #21520): - * a sandboxed hook or action body may not write the family's tables at all — - * the metadata protocol is their only writer for an app-authored body — so - * the API a body holds refuses every family-table write before it runs. A - * separate layer, applied only where a body gets its API, because the served - * repository is also a host handler's, and the boundary refuses bodies only. + * + * ## The body layers: a BODY may not touch the family + * + * Applied only where a body gets its API (`buildSandboxApi`), because the + * served repository is also a host handler's, and the boundary refuses bodies + * only. Each refuses before the underlying verb is called, with the boundary's + * `PERMISSION_DENIED` / 403 and a prescription naming the metadata API + * (`stored-metadata-body-boundary.ts`): + * + * - **Refuse a BODY's write** ({@link refuseStoredMetadataBodyWrites}, #21520): + * every family-table write, and every verb not known to be a read. + * - **Refuse a BODY's read** ({@link refuseStoredMetadataBodyReads}, #21594): + * every read verb this seam serves (`find`, `findOne`, `count`, + * `aggregate`), and with them every evaluate and search shape a read can + * carry, refused alike whatever the query names. * * ## What it does not do * * It judges the object by name with the family's own predicate * (`isStoredMetadataBodyObject`), exactly as the door does. The engine's own - * action verb (`ScopedRepo.execute`) is never reached by a served body — the - * sandbox bridge exposes no `execute`, `sudo` or `withRunAs` — so this seam - * leaves it untouched (the reach is recorded on #21454, not closed here). + * action verb (`ScopedRepo.execute`) is never reached by a body — the sandbox + * bridge exposes no `execute`, `sudo` or `withRunAs` — so this seam leaves it + * untouched (the reach is recorded on #21454, not closed here). */ import { isStoredMetadataBodyObject } from '@objectstack/spec/kernel'; -import { storedMetadataBodyWriteRefusal } from './stored-metadata-body-boundary.js'; +import { storedMetadataBodyReadRefusal, storedMetadataBodyWriteRefusal } from './stored-metadata-body-boundary.js'; import { collectStoredMetadataFilterFields, ephemeralStoredHashDigest, @@ -261,20 +273,21 @@ function serveRepository(objectName: string, repo: unknown, engine: unknown): un } /** - * The scoped data API (`ctx.api`) a reader context hands to a body or a - * handler, with every read of a family object served through + * The scoped data API (`ctx.api`) an action handler's reader context hands to + * host code, with every read of a family object served through * {@link serveStoredMetadataRead}, every evaluate shape refused, and every * write's RETURN served. Everything else is the same object, by delegation. + * (A sandboxed body's API is never served here: its body layers refuse a + * family read or write first.) * * The contexts the API can derive are served the same way, so no route around * the seam opens: `object(name)`, `sudo()`, `withRunAs(...)`, the context a * `transaction(fn)` callback receives, and the `ctx` `beginTransaction()` - * returns (the sandbox's `ctx.api.transaction` reads through that one). + * returns. * - * Idempotent: an API already served through this seam is returned as is, so a - * body whose API was served at the action door is not served twice at the - * sandbox (a keyed hash keyed again would no longer be the door's form). - * A value that is not an object is returned as is. + * Idempotent: an API already served through this seam is returned as is (a + * keyed hash keyed again would no longer be the door's form). A value that is + * not an object is returned as is. */ export function serveStoredMetadataReadsThrough(api: T, engine: unknown): T { return deriveThroughSeam(api, SERVED_THROUGH_SEAM, (name, repo) => serveRepository(name, repo, engine)); @@ -283,13 +296,18 @@ export function serveStoredMetadataReadsThrough(api: T, engine: unknown): T { /** Marks a scoped context whose family-table writes are already refused for a body. */ const BODY_WRITES_REFUSED = Symbol.for('objectstack.runtime.storedMetadataBodyWritesRefused'); -/** The repository verbs a body may still call on a family table: the reads this seam serves. */ +/** + * The read verbs of a family table: the reads this seam serves a host handler. + * The body WRITE layer passes exactly these to the layer beneath it, and the + * body READ layer refuses exactly these. + */ const BODY_FAMILY_READS: ReadonlySet = new Set([...ROW_SERVING_READS, COUNT_READ]); /** - * A family table's repository as a sandboxed BODY holds it: the served reads - * pass through, and every other verb — each write alias, and anything not - * known to be a read — is refused before it runs, with the boundary's + * A family table's repository as a sandboxed BODY holds it, write half: the + * read verbs pass to the layer beneath (the body read layer, which refuses + * them), and every other verb — each write alias, and anything not known to be + * a read — is refused before it runs, with the boundary's * `PERMISSION_DENIED` / 403 and the metadata-API prescription * ({@link storedMetadataBodyWriteRefusal}). Fail-closed by construction: a verb * added to the repository later is refused here until it is named a read. @@ -312,26 +330,72 @@ function refuseBodyRepositoryWrites(objectName: string, repo: unknown): unknown } /** - * [#21520, ruling A] The scoped API a sandboxed BODY (a hook body or an action + * [#21520, ruling A] The scoped API a sandboxed BODY (a hook, action or job * body) holds, with every write of a stored-metadata family table refused: for * an app-authored body, the metadata protocol is the family's only writer. - * Reads are untouched here — they are served by - * {@link serveStoredMetadataReadsThrough}, which this layers over — and every - * other object writes as before. + * Reads pass to the layer this one sits over — the body read layer, + * {@link refuseStoredMetadataBodyReads}, which refuses them — and every other + * object writes as before. * - * Applied at ONE place, the sandbox's `buildSandboxApi`, which only the two body + * Applied at ONE place, the sandbox's `buildSandboxApi`, which only the body * runners reach. It is a separate layer rather than a branch of the served * repository because that repository is also a host code handler's `ctx.api`, * and the boundary refuses bodies only: the platform's own writers and the * deployer's host code reach the store through their own imports. Every * context the API derives is refused the same way (the same walk as the read - * seam). Idempotent, and transparent to the read seam's own marker, so a body - * API served at the action door is still served exactly once. + * seam). Idempotent, and transparent to every other layer's marker. */ export function refuseStoredMetadataBodyWrites(api: T): T { return deriveThroughSeam(api, BODY_WRITES_REFUSED, refuseBodyRepositoryWrites); } +/** Marks a scoped context whose family-table reads are already refused for a body. */ +const BODY_READS_REFUSED = Symbol.for('objectstack.runtime.storedMetadataBodyReadsRefused'); + +/** + * A family table's repository as a sandboxed BODY holds it, read half: every + * read verb ({@link BODY_FAMILY_READS}) is refused before it runs, with the + * boundary's `PERMISSION_DENIED` / 403 and the metadata-API read prescription + * ({@link storedMetadataBodyReadRefusal}). Refused before the underlying verb + * is called and before its query is looked at, so a refused read reaches no + * row and answers the same whatever its filter, sort, grouping, search or + * projection names. Every other verb is handed on (the write layer above has + * already refused it); any other object's repository is returned untouched. + */ +function refuseBodyRepositoryReads(objectName: string, repo: unknown): unknown { + if (!isStoredMetadataBodyObject(objectName) || repo === null || typeof repo !== 'object') return repo; + return new Proxy(repo as Record, { + get(target, prop) { + const value = Reflect.get(target, prop, target); + if (typeof value !== 'function') return value; + if (BODY_FAMILY_READS.has(prop)) { + return async () => { + throw storedMetadataBodyReadRefusal(objectName, String(prop)); + }; + } + return value.bind(target); + }, + }); +} + +/** + * [#21594, ruling B] The scoped API a sandboxed BODY (a hook, action or job + * body) holds, with every read of a stored-metadata family table refused: for + * an app-authored body, the family is reached through the metadata API only, + * so a body is served nothing of it — neither the stored row nor a projection + * of it. Every other object reads as before. + * + * Applied at the same ONE place as the write layer, beneath it + * (`buildSandboxApi`): a host code handler's `ctx.api` is not a body's and is + * still served by {@link serveStoredMetadataReadsThrough}. Every context the + * API derives is refused the same way (the shared walk), so a read inside + * `ctx.api.transaction`, after `sudo()` or under `withRunAs(...)` is refused + * too. Idempotent. + */ +export function refuseStoredMetadataBodyReads(api: T): T { + return deriveThroughSeam(api, BODY_READS_REFUSED, refuseBodyRepositoryReads); +} + /** * The walk both layers share: `api`, and every context it can derive — * `object(name)`, `sudo()`, `withRunAs(...)`, the context a `transaction(fn)` From 21626347bee8983c564e70468ac89d2ff80fa3cb Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 4 Oct 2026 00:21:12 +0000 Subject: [PATCH 2/8] test(runtime): pin the body read refusal (wip) Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_016GiHYRmLSNWTfbX9gVQkpz --- packages/runtime/src/action-execution.ts | 8 +- .../src/stored-metadata-body-reads.test.ts | 296 ++++++++++++++++++ 2 files changed, 301 insertions(+), 3 deletions(-) create mode 100644 packages/runtime/src/stored-metadata-body-reads.test.ts diff --git a/packages/runtime/src/action-execution.ts b/packages/runtime/src/action-execution.ts index 6f9d7095fa..8cec5e47f7 100644 --- a/packages/runtime/src/action-execution.ts +++ b/packages/runtime/src/action-execution.ts @@ -1500,9 +1500,11 @@ export function buildActionExecutionContext(ec: any): Record { * * [#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; diff --git a/packages/runtime/src/stored-metadata-body-reads.test.ts b/packages/runtime/src/stored-metadata-body-reads.test.ts new file mode 100644 index 0000000000..4fc6b01eb2 --- /dev/null +++ b/packages/runtime/src/stored-metadata-body-reads.test.ts @@ -0,0 +1,296 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#21594] The read half of the stored-metadata family's boundary for + * app-authored bodies: a sandboxed body may not read a family table. For an + * app-authored body the family is reached through the metadata API only. + * + * Pinned against a counting scoped-API double (it records which verb reached + * it, and stores nothing), then through the real QuickJS sandbox on all three + * body faces. What each case asserts: + * + * - every read verb the reader-context seam serves (`find`, `findOne`, + * `count`, `aggregate`) on each family table is refused with the + * boundary's envelope (`PERMISSION_DENIED` / 403) and a prescription naming + * the metadata API's READ route, BEFORE the verb runs; + * - a read is refused the same way whatever its query names — a filter, + * sort or grouping on the stored body or a hash column, a search, a + * projection, or nothing — so a refused read serves nothing and is no + * oracle; + * - every other object reads as before, and every derived context is + * refused the same way; + * - as a body holds it (the write layer over the read layer), a read gets + * the read refusal and a write keeps #21520's write refusal unchanged; + * - a host code handler's `ctx.api` — the read seam alone — still reads the + * family, served the way the data door serves it: the boundary refuses + * bodies only. + */ + +import { describe, it, expect } from 'vitest'; +import { STORED_METADATA_BODY_OBJECTS } from '@objectstack/spec/kernel'; +import { + refuseStoredMetadataBodyReads, + refuseStoredMetadataBodyWrites, + serveStoredMetadataReadsThrough, +} from './stored-metadata-reader-seam.js'; +import { STORED_METADATA_BODY_BOUNDARY_CODE, STORED_METADATA_BODY_BOUNDARY_STATUS } from './stored-metadata-body-boundary.js'; +import { actionBodyRunnerFactory, hookBodyRunnerFactory, jobBodyRunnerFactory } from './sandbox/body-runner.js'; +import { QuickJSScriptRunner } from './sandbox/quickjs-runner.js'; + +const FAMILY = [...STORED_METADATA_BODY_OBJECTS]; +const ORDINARY = 'boundary_note'; +const READ_VERBS = ['find', 'findOne', 'count', 'aggregate']; +const SENTINEL = 'body-read-unit-sentinel-5e02'; + +/** A stored family row: the body carries a credential, the row a content hash. */ +function storedRow(): Record { + return { + id: 'row_1', + type: 'datasource', + name: 'unit_ds', + metadata: JSON.stringify({ name: 'unit_ds', driver: 'turso', config: { url: 'libsql://unit.example.invalid', encryptionKey: SENTINEL } }), + checksum: `sha256:${'b2'.repeat(32)}`, + }; +} + +/** + * A scoped-API double that counts `.` calls and answers the + * stored form, so a read that reached it would carry the sentinel. Read verbs + * and one write verb only: no `update` / `delete` member, so it carries no + * write dispatch to hold to the engine's. + */ +function countingApi(calls: string[] = []): any { + const repo = (name: string) => { + const record = (verb: string) => calls.push(`${name}.${verb}`); + const family = name.startsWith('sys_metadata'); + return { + async find() { record('find'); return family ? [storedRow()] : [{ id: 'n1' }]; }, + async findOne() { record('findOne'); return family ? storedRow() : { id: 'n1' }; }, + async count() { record('count'); return 1; }, + async aggregate() { record('aggregate'); return family ? [{ metadata: storedRow().metadata, count: 1 }] : []; }, + async insert() { record('insert'); return { id: 'n1' }; }, + }; + }; + return { + object: repo, + sudo: () => countingApi(calls), + withRunAs: () => countingApi(calls), + async transaction(callback: (trx: any) => Promise) { return callback(countingApi(calls)); }, + async beginTransaction() { return { ctx: countingApi(calls), handle: 'trx_1', owned: true }; }, + }; +} + +/** The API a sandboxed body holds: the write layer over the read layer (`buildSandboxApi`). */ +const bodyApi = (calls: string[]) => refuseStoredMetadataBodyWrites(refuseStoredMetadataBodyReads(countingApi(calls))); + +function expectReadRefused(promise: Promise, object: string, verb: string) { + return expect(promise).rejects.toMatchObject({ + code: STORED_METADATA_BODY_BOUNDARY_CODE, + status: STORED_METADATA_BODY_BOUNDARY_STATUS, + object, + operation: verb, + message: expect.stringContaining('GET /api/v1/meta/:type/:name'), + }); +} + +describe('[#21594] refuseStoredMetadataBodyReads — every family read is refused before it runs', () => { + it('the envelope is the boundary\'s own: PERMISSION_DENIED / 403, no new code', () => { + expect(STORED_METADATA_BODY_BOUNDARY_CODE).toBe('PERMISSION_DENIED'); + expect(STORED_METADATA_BODY_BOUNDARY_STATUS).toBe(403); + }); + + for (const object of FAMILY) { + it(`each read verb on '${object}' answers PERMISSION_DENIED / 403 naming the metadata API, and never reaches the store`, async () => { + const calls: string[] = []; + const api = refuseStoredMetadataBodyReads(countingApi(calls)); + for (const verb of READ_VERBS) await expectReadRefused(api.object(object)[verb]({}), object, verb); + expect(calls).toEqual([]); + }); + } + + it('a read is refused identically whatever its query names, and reaches nothing', async () => { + const calls: string[] = []; + const api = refuseStoredMetadataBodyReads(countingApi(calls)); + const queries: unknown[] = [ + undefined, + {}, + { where: { type: 'datasource', name: 'unit_ds' } }, + { where: { metadata: { $contains: 'z' } } }, + { where: { checksum: 'z' } }, + { orderBy: [{ field: 'metadata', order: 'asc' }] }, + { groupBy: ['metadata'] }, + { search: 'unit_ds' }, + { search: 'z', searchFields: ['metadata'] }, + { fields: ['name', 'metadata'] }, + ]; + for (const verb of READ_VERBS) { + const answers = new Set(); + for (const query of queries) { + const err: any = await api.object(FAMILY[0])[verb](query).catch((e: unknown) => e); + answers.add(`${err?.code}:${err?.status}:${err?.operation}:${err?.message}`); + } + // Ten queries, one answer per verb. + expect(answers.size, verb).toBe(1); + } + expect(calls).toEqual([]); + }); + + it('an ordinary table reads as before (control)', async () => { + const calls: string[] = []; + const api = refuseStoredMetadataBodyReads(countingApi(calls)); + for (const verb of READ_VERBS) await api.object(ORDINARY)[verb]({}); + expect(calls).toEqual(READ_VERBS.map((verb) => `${ORDINARY}.${verb}`)); + }); + + it('every derived context refuses the same way: sudo, withRunAs, transaction(fn), beginTransaction', async () => { + const calls: string[] = []; + const api = refuseStoredMetadataBodyReads(countingApi(calls)); + await expectReadRefused(api.sudo().object(FAMILY[0]).find({}), FAMILY[0], 'find'); + await expectReadRefused(api.withRunAs('system', {}).object(FAMILY[1]).findOne({}), FAMILY[1], 'findOne'); + await api.transaction(async (trx: any) => { + await expectReadRefused(trx.object(FAMILY[0]).count({}), FAMILY[0], 'count'); + }); + const begun = await api.beginTransaction(); + expect(begun.handle).toBe('trx_1'); + await expectReadRefused(begun.ctx.object(FAMILY[1]).aggregate({}), FAMILY[1], 'aggregate'); + expect(calls).toEqual([]); + }); + + it('is idempotent', () => { + const once = refuseStoredMetadataBodyReads(countingApi()); + expect(refuseStoredMetadataBodyReads(once)).toBe(once); + }); +}); + +describe('[#21594] the API a body holds — the write layer over the read layer', () => { + it('a read gets the read refusal, a write keeps the write refusal unchanged, and nothing reaches the store', async () => { + const calls: string[] = []; + const api = bodyApi(calls); + for (const object of FAMILY) { + for (const verb of READ_VERBS) await expectReadRefused(api.object(object)[verb]({}), object, verb); + await expect(api.object(object).insert({ label: 'x' })).rejects.toMatchObject({ + code: 'PERMISSION_DENIED', + status: 403, + object, + operation: 'insert', + message: expect.stringContaining('the write was not run'), + }); + } + expect(calls).toEqual([]); + }); + + it('an ordinary table reads and writes as before (control)', async () => { + const calls: string[] = []; + const api = bodyApi(calls); + for (const verb of READ_VERBS) await api.object(ORDINARY)[verb]({}); + await api.object(ORDINARY).insert({ label: 'x' }); + expect(calls).toEqual([...READ_VERBS, 'insert'].map((verb) => `${ORDINARY}.${verb}`)); + }); + + it('a host code handler\'s ctx.api — the read seam alone — still reads the family, served like the data door', async () => { + const calls: string[] = []; + const api = serveStoredMetadataReadsThrough(countingApi(calls), { getKeyedDigest: () => async (plain: string) => `keyed:${plain.length}` }); + const rows: any[] = await api.object(FAMILY[0]).find({}); + expect(calls).toEqual([`${FAMILY[0]}.find`]); + expect(rows).toHaveLength(1); + expect(String(rows[0].metadata)).not.toContain(SENTINEL); + expect(String(rows[0].metadata)).toContain('unit.example.invalid'); + expect(rows[0].checksum).toMatch(/^keyed:/); + }); +}); + +describe('[#21594] through the real sandbox — every body face holds the refusing API', () => { + const runner = new QuickJSScriptRunner({ hookTimeoutMs: 10_000 }); + const readEach = (object: string) => READ_VERBS + .map((verb) => `try { await ctx.api.object('${object}').${verb}({}); out.push('${verb}:served'); } ` + + `catch (e) { out.push('${verb}:' + e.code + ':' + e.status); }`) + .join('\n'); + + it('an action body: every read verb on both tables is refused with the envelope, and served nothing', async () => { + const calls: string[] = []; + const factory = actionBodyRunnerFactory(runner, { ql: {}, appId: 'boundary' }); + const handler = factory({ + name: 'reads_family', + type: 'script', + body: { + language: 'js', + capabilities: ['api.read'], + source: `const out = [];\n${FAMILY.map(readEach).join('\n')}\nreturn { out };`, + }, + }); + const result: any = await handler!({ api: countingApi(calls), params: {} }); + expect(result.out).toEqual( + FAMILY.flatMap(() => READ_VERBS.map((verb) => `${verb}:PERMISSION_DENIED:403`)), + ); + expect(JSON.stringify(result)).not.toContain(SENTINEL); + expect(calls).toEqual([]); + }); + + it('an action body\'s uncaught read refusal rejects with the envelope and names the metadata API', async () => { + const factory = actionBodyRunnerFactory(runner, { ql: {}, appId: 'boundary' }); + const handler = factory({ + name: 'reads_family_uncaught', + type: 'script', + body: { + language: 'js', + capabilities: ['api.read'], + source: `return { rows: await ctx.api.object('${FAMILY[0]}').find({ where: { type: 'datasource' } }) };`, + }, + }); + await expect(handler!({ api: countingApi(), params: {} })).rejects.toMatchObject({ + code: 'PERMISSION_DENIED', + status: 403, + message: expect.stringContaining('GET /api/v1/meta/:type/:name'), + }); + }); + + it('an action body reading inside ctx.api.transaction is refused the same way; its ordinary read runs', async () => { + const calls: string[] = []; + const factory = actionBodyRunnerFactory(runner, { ql: {}, appId: 'boundary' }); + const handler = factory({ + name: 'reads_family_in_transaction', + type: 'script', + body: { + language: 'js', + capabilities: ['api.read', 'api.transaction'], + source: `let code;\nawait ctx.api.transaction(async () => {\n` + + ` await ctx.api.object('${ORDINARY}').find({});\n` + + ` try { await ctx.api.object('${FAMILY[1]}').find({}); } catch (e) { code = e.code + ':' + e.status; }\n` + + `});\nreturn { code };`, + }, + }); + const result: any = await handler!({ api: countingApi(calls), params: {} }); + expect(result.code).toBe('PERMISSION_DENIED:403'); + expect(calls).toEqual([`${ORDINARY}.find`]); + }); + + it('a hook body on an ordinary table, reading a family table, is refused the same way', async () => { + const calls: string[] = []; + const factory = hookBodyRunnerFactory(runner, { ql: {}, appId: 'boundary' }); + const handler = factory({ + name: 'ordinary_hook_reads_family', + object: ORDINARY, + events: ['beforeInsert'], + body: { + language: 'js', + capabilities: ['api.read'], + source: `const rows = await ctx.api.object('${FAMILY[0]}').find({});\nctx.input.observed = JSON.stringify(rows);`, + }, + } as any); + await expect(handler!({ object: ORDINARY, event: 'beforeInsert', input: {}, api: countingApi(calls) })) + .rejects.toMatchObject({ code: 'PERMISSION_DENIED', status: 403 }); + expect(calls).toEqual([]); + }); + + it('a job body is refused the same way, through the engine-built API a job gets', async () => { + const calls: string[] = []; + const ql = { createContext: () => countingApi(calls) }; + const bind = jobBodyRunnerFactory(runner, { ql, appId: 'boundary' }); + const run = bind({ + name: 'job_reads_family', + body: { language: 'js', capabilities: ['api.read'], source: `await ctx.api.object('${FAMILY[1]}').count({});` }, + })!; + await expect(run({ jobId: 'job_reads_family' } as any)).rejects.toMatchObject({ code: 'PERMISSION_DENIED', status: 403 }); + expect(calls).toEqual([]); + }); +}); From 269f49dc3d674b6686f97c8599b991200f6b6a08 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 4 Oct 2026 00:27:13 +0000 Subject: [PATCH 3/8] test(runtime): the reader-context pin refuses a body's family read at every door (wip) Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_016GiHYRmLSNWTfbX9gVQkpz --- ...tored-metadata-reader-contexts.pin.test.ts | 258 ++++++++++-------- 1 file changed, 142 insertions(+), 116 deletions(-) diff --git a/packages/runtime/src/stored-metadata-reader-contexts.pin.test.ts b/packages/runtime/src/stored-metadata-reader-contexts.pin.test.ts index 71119b5242..f85f4a4e52 100644 --- a/packages/runtime/src/stored-metadata-reader-contexts.pin.test.ts +++ b/packages/runtime/src/stored-metadata-reader-contexts.pin.test.ts @@ -1,32 +1,37 @@ // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. /** - * [#21454] The in-process reader contexts serve the stored-metadata-body family + * The in-process reader contexts over the stored-metadata-body family * (`sys_metadata` / `sys_metadata_history`: the `metadata` body column and the - * `checksum` content-hash column) the way the generic data door serves it: the - * body as its type's read projection, the hash in keyed form. + * `checksum` content-hash column), each through the door a deployment exposes. * - * Each context reads the SAME stored row the control reads through the data - * door, and each answer is judged against that control, not against a fixed - * shape: no stored credential and no stored hash anywhere in the answer, the - * row's non-credential configuration present (the projection of THIS row, not - * a withheld one), and the served hash equal to the one the door serves for the - * same row (the door's keyed form, under the door's own key). - * - * Contexts, each through the door a deployment exposes: - * ① a sandboxed body's object API, `ctx.api.object(...)` - * (`sandbox/body-runner.ts`, `buildSandboxApi`): + * [#21594] ① A sandboxed BODY may not read the family at all: for an + * app-authored body the family is reached through the metadata API only. Every + * read verb (`find`, `findOne`, `count`, `aggregate`), and every filter, sort, + * grouping or search a read carries, answers the body boundary's + * `403 PERMISSION_DENIED` with a prescription naming the metadata API's read + * route, and carries no family content: * an action body, dispatched by the REST `/actions` door, read plainly * and inside `ctx.api.transaction`; - * a hook body fired by a data-door insert, which COPIES what it read into - * an ordinary record (the copy exit: only projected content lands); - * an action body authored at runtime through the `/meta` door; - * ② an action handler's engine handle, `ctx.engine.find` - * (`buildActionEngineFacade`), host code registered with `registerAction`; - * ③ the same handler's `ctx.api` (`buildActionApi`), the scoped context an - * action body receives too. - * The action contexts run elevated, so a member invoking one is served what an - * administrator is: both are pinned. + * a hook body fired by a data-door insert, which would COPY what it read + * into an ordinary record (the insert is refused and nothing lands); + * an action body authored at runtime through the `/meta` door. + * + * [#21454] ② / ③ An action HANDLER — host code registered with `registerAction` + * — is still served the family the way the generic data door serves it: the + * body as its type's read projection, the hash in keyed form, judged against + * the SAME stored row read through the data door (no stored credential and no + * stored hash anywhere in the answer, the row's non-credential configuration + * present, and the served hash equal to the one the door serves for that row): + * ② its engine handle, `ctx.engine.find` (`buildActionEngineFacade`); + * ③ its `ctx.api` (`buildActionApi`). + * + * Platform readers are outside the boundary, pinned as controls: the generic + * data door, the metadata API (the route the refusal prescribes) and the + * engine's own in-process read of the stored form. + * + * The action contexts run elevated, so a member invoking one is answered what + * an administrator is: both are pinned. * * Composition: an in-process `ObjectKernel` assembled from the plugins, in the * order, `@objectstack/verify`'s `bootStack` uses (it mirrors `objectstack dev` @@ -105,6 +110,24 @@ const PIN_APP: any = { type: 'script', body: actionBody(`${readFamilySource('sys_metadata_history')}\nreturn { rows };`), }, + { + name: 'body_reads_family_one', + label: 'Body reads one row', + type: 'script', + body: actionBody(`return { row: await ctx.api.object('sys_metadata').findOne({ where: { type: 'datasource', name: '${DS_NAME}' } }) };`), + }, + { + name: 'body_counts_family', + label: 'Body counts rows', + type: 'script', + body: actionBody(`return { n: await ctx.api.object('sys_metadata').count({ where: { type: 'datasource' } }) };`), + }, + { + name: 'body_aggregates_family', + label: 'Body aggregates rows', + type: 'script', + body: actionBody(`return { rows: await ctx.api.object('sys_metadata_history').aggregate({ groupBy: ['type'] }) };`), + }, { name: 'body_reads_family_in_transaction', label: 'Body transaction read', @@ -118,8 +141,9 @@ const PIN_APP: any = { { name: 'handler_engine_reads_family', label: 'Handler engine read', type: 'script' }, { name: 'handler_engine_reads_history', label: 'Handler engine history read', type: 'script' }, { name: 'handler_api_reads_family', label: 'Handler api read', type: 'script' }, - // [#21454] EVALUATE shapes — each body attempts to evaluate the stored - // body or hash, and each must be refused before the query runs. The + // EVALUATE shapes — each body attempts to evaluate the stored body or + // hash. [#21594] A body's read is refused whatever it carries, so each + // answers the body boundary's refusal, the same as a plain read. The // VALUE in every predicate is an immaterial constant: the query is // refused unrun, so nothing depends on what it is. { @@ -158,8 +182,7 @@ const PIN_APP: any = { type: 'script', body: actionBody(`return { rows: await ctx.api.object('sys_metadata').find({ search: 'z', searchFields: ['metadata'] }) };`), }, - // A DEFAULT search is narrowed, not refused: a body may still search a - // family table by its scalar columns, served like the door. + // [#21594] A DEFAULT search is refused too: a body searches no family table. { name: 'body_searches_default', label: 'Body default search', @@ -180,7 +203,7 @@ const PIN_APP: any = { }, ], hooks: [ - // ① a sandboxed hook body: it COPIES what it read onto the row being inserted. + // ① a sandboxed hook body: it would COPY what it read onto the row being inserted. { name: 'hook_copies_family', object: 'pin_note', @@ -447,45 +470,67 @@ describe('[#21454] CONTROL — the same rows through the generic data door (unch }); }); -describe('[#21454] ① a sandboxed body\'s object API (ctx.api.object)', () => { +/** + * [#21594] The body boundary's read refusal, as a door serves it: `403 + * PERMISSION_DENIED`, a message naming the metadata API's read route, and no + * family content anywhere in the answer — neither the stored credential, nor a + * stored hash, nor a family row in any form (a projection included). + */ +async function expectBodyReadRefused(label: string, res: Response): Promise { + const payload = await readJson(res); + const text = JSON.stringify(payload ?? null); + // Both wire shapes in use: the nested envelope (`error.code` / `error.message`) + // and the data door's flat one (`code` beside a string `error`). + const code = payload?.error?.code ?? payload?.code; + const message = [payload?.error?.message, payload?.error, payload?.message].find((m) => typeof m === 'string') ?? ''; + expect(res.status, `${label}: ${text}`).toBe(403); + expect(code, `${label}: the body boundary's code: ${text}`).toBe('PERMISSION_DENIED'); + expect(message, `${label}: the refusal names the metadata API: ${text}`).toContain('GET /api/v1/meta/:type/:name'); + expect(text.includes(SENTINEL), `${label}: the stored credential reached the answer`).toBe(false); + for (const h of storedHashes) expect(text.includes(h), `${label}: a stored hash reached the answer`).toBe(false); + expect(familyRowsIn(payload), `${label}: a family row reached the answer`).toEqual([]); +} + +describe('[#21594] ① a sandboxed body may not read the family: every read is refused', () => { for (const [role, token] of [['administrator', () => adminToken], ['member', () => memberToken]] as const) { - it(`an action body via /actions, invoked by the ${role}: projected, keyed (both tables)`, async () => { - for (const action of ['body_reads_family', 'body_reads_history']) { - const res = await as(token(), 'POST', `/actions/pin_note/${action}`, { params: {} }); - expect(res.status, action).toBe(200); - expectServedLikeTheDoor(`${action} (${role})`, await readJson(res)); + it(`an action body via /actions, invoked by the ${role}: find, findOne, count and aggregate on both tables`, async () => { + for (const action of ['body_reads_family', 'body_reads_history', 'body_reads_family_one', 'body_counts_family', 'body_aggregates_family']) { + await expectBodyReadRefused(`${action} (${role})`, await as(token(), 'POST', `/actions/pin_note/${action}`, { params: {} })); } }); - it(`an action body reading inside ctx.api.transaction, invoked by the ${role}: projected, keyed`, async () => { - const res = await as(token(), 'POST', '/actions/pin_note/body_reads_family_in_transaction', { params: {} }); - expect(res.status).toBe(200); - expectServedLikeTheDoor(`transaction read (${role})`, await readJson(res)); + it(`an action body reading inside ctx.api.transaction, invoked by the ${role}`, async () => { + await expectBodyReadRefused( + `transaction read (${role})`, + await as(token(), 'POST', '/actions/pin_note/body_reads_family_in_transaction', { params: {} }), + ); }); - } - it('a hook body fired by the administrator\'s data-door insert copies only projected content', async () => { - const res = await as(adminToken, 'POST', '/data/pin_note', { title: 'pin-hook' }); - expect(res.status).toBeLessThan(300); - const engine: any = await kernel.getServiceAsync('objectql'); - const rows: any[] = await engine.find('pin_note', { where: { title: 'pin-hook' }, context: { isSystem: true } }); - expect(rows).toHaveLength(1); - // Judged on what landed in the ordinary record, read back as stored. - expect(String(rows[0].observed)).not.toContain(SENTINEL); - expectServedLikeTheDoor('hook copy (stored ordinary record)', JSON.parse(rows[0].observed)); - }); + it(`every evaluate and search shape a body's read carries, invoked by the ${role}: the same refusal, never the door's`, async () => { + for (const action of [ + 'body_filters_body_column', + 'body_sorts_body_column', + 'body_groups_body_column', + 'body_filters_hash_column', + 'body_counts_body_column', + 'body_searches_body_column', + 'body_searches_default', + ]) { + await expectBodyReadRefused(`${action} (${role})`, await as(token(), 'POST', `/actions/pin_note/${action}`, { params: {} })); + } + }); + } - it('a hook body fired by a member\'s data-door insert is refused the family read, and copies nothing', async () => { - const res = await as(memberToken, 'POST', '/data/pin_note', { title: 'pin-hook' }); - const payload = await readJson(res); - expect(res.status).toBe(403); - expect(payload?.error?.code ?? payload?.code).toBe('PERMISSION_DENIED'); + it('a hook body fired by a data-door insert is refused the family read, for administrator and member, and copies nothing', async () => { + for (const [role, token] of [['administrator', adminToken], ['member', memberToken]] as const) { + await expectBodyReadRefused(`hook read (${role})`, await as(token, 'POST', '/data/pin_note', { title: 'pin-hook' })); + } const engine: any = await kernel.getServiceAsync('objectql'); const rows: any[] = await engine.find('pin_note', { where: { title: 'pin-hook' }, context: { isSystem: true } }); - expect(rows).toHaveLength(1); // the administrator's row only + expect(rows, 'a refused hook let its insert land').toEqual([]); }); - it('an action body authored at runtime through /meta: projected, keyed, for administrator and member', async () => { + it('an action body authored at runtime through /meta: refused, for administrator and member', async () => { const authored = await as(adminToken, 'PUT', '/meta/action/authored_reads_family', { name: 'authored_reads_family', label: 'Authored read', @@ -495,21 +540,22 @@ describe('[#21454] ① a sandboxed body\'s object API (ctx.api.object)', () => { }); expect(authored.status).toBeLessThan(300); let res: Response | undefined; + // Bound once the door stops answering "not found": the refusal is the bound body's answer. const bound = await waitFor(async () => { const attempt: Response = await as(adminToken, 'POST', '/actions/pin_note/authored_reads_family', { params: {} }); res = attempt; - return attempt.status < 300; + return attempt.status !== 404; }); expect(bound, 'the runtime-authored action never bound').toBe(true); - expectServedLikeTheDoor('runtime-authored body (administrator)', await readJson(res as Response)); - - const member = await as(memberToken, 'POST', '/actions/pin_note/authored_reads_family', { params: {} }); - expect(member.status).toBe(200); - expectServedLikeTheDoor('runtime-authored body (member)', await readJson(member)); + await expectBodyReadRefused('runtime-authored body (administrator)', res as Response); + await expectBodyReadRefused( + 'runtime-authored body (member)', + await as(memberToken, 'POST', '/actions/pin_note/authored_reads_family', { params: {} }), + ); }, 30_000); }); -describe('[#21454] ② / ③ an action handler\'s engine handle and scoped API', () => { +describe('[#21454] ② / ③ an action handler\'s engine handle and scoped API (host code: still served)', () => { for (const [role, token] of [['administrator', () => adminToken], ['member', () => memberToken]] as const) { it(`ctx.engine.find, invoked by the ${role}: projected, keyed (both tables)`, async () => { for (const action of ['handler_engine_reads_family', 'handler_engine_reads_history']) { @@ -524,67 +570,47 @@ describe('[#21454] ② / ③ an action handler\'s engine handle and scoped API', expect(res.status).toBe(200); expectServedLikeTheDoor(`handler ctx.api (${role})`, await readJson(res)); }); - } -}); - -describe('[#21454] the EVALUATE shapes are refused end to end, the door\'s own refusal', () => { - /** - * Each shape answers the data door's refusal (`INVALID_FIELD` / 400), names - * the offending column, and carries no family content. The envelope's precise - * `param` / `field` are pinned directly on the door's predicate in the unit - * test; across the sandbox boundary only `code`, `status` and the MESSAGE are - * guaranteed (`SANDBOX_ERROR_PASSTHROUGH`), so the column is read from the - * message, which both the sandboxed-body and the host-handler paths carry. - */ - async function expectRefusedAction(action: string, token: string, column: string): Promise { - const res = await as(token, 'POST', `/actions/pin_note/${action}`, { params: {} }); - const payload = await readJson(res); - const err = payload?.error ?? payload; - const text = JSON.stringify(payload ?? null); - expect(res.status, `${action}: refused with 400`).toBe(400); - expect(err?.code, `${action}: the data door's INVALID_FIELD`).toBe('INVALID_FIELD'); - const named = (Array.isArray(err?.fields) && err.fields.includes(column)) - || String(err?.message ?? '').includes(`'${column}'`); - expect(named, `${action}: the refusal names '${column}'`).toBe(true); - expect(text.includes(SENTINEL), `${action}: the stored credential reached the answer`).toBe(false); - for (const h of storedHashes) expect(text.includes(h), `${action}: a stored hash reached the answer`).toBe(false); - } - - for (const [role, token] of [['administrator', () => adminToken], ['member', () => memberToken]] as const) { - it(`a body's filter / sort / grouping on the body column, invoked by the ${role}`, async () => { - await expectRefusedAction('body_filters_body_column', token(), 'metadata'); - await expectRefusedAction('body_sorts_body_column', token(), 'metadata'); - await expectRefusedAction('body_groups_body_column', token(), 'metadata'); - }); - it(`a body's filter on a hash column, and count as an oracle, invoked by the ${role}`, async () => { - await expectRefusedAction('body_filters_hash_column', token(), 'checksum'); - await expectRefusedAction('body_counts_body_column', token(), 'metadata'); - }); - - it(`a body's explicit search of the body column, invoked by the ${role}`, async () => { - await expectRefusedAction('body_searches_body_column', token(), 'metadata'); - }); - - it(`the engine handle's filter on the body column, invoked by the ${role}`, async () => { - await expectRefusedAction('handler_engine_filters_body', token(), 'metadata'); + it(`the engine handle's filter on the body column, invoked by the ${role}: the data door's own refusal`, async () => { + // The handler path keeps the door's evaluate refusal (`INVALID_FIELD` / + // 400), naming the offending column. Across the handler boundary only + // `code`, `status` and the MESSAGE are guaranteed, so the column is read + // from the message. + const res = await as(token(), 'POST', '/actions/pin_note/handler_engine_filters_body', { params: {} }); + const payload = await readJson(res); + const err = payload?.error ?? payload; + const text = JSON.stringify(payload ?? null); + expect(res.status).toBe(400); + expect(err?.code).toBe('INVALID_FIELD'); + const named = (Array.isArray(err?.fields) && err.fields.includes('metadata')) + || String(err?.message ?? '').includes("'metadata'"); + expect(named, 'the refusal names the body column').toBe(true); + expect(text.includes(SENTINEL)).toBe(false); + for (const h of storedHashes) expect(text.includes(h)).toBe(false); }); } }); -describe('[#21454] a DEFAULT search is narrowed to the door\'s served set, not refused', () => { - for (const [role, token] of [['administrator', () => adminToken], ['member', () => memberToken]] as const) { - it(`a body's default search runs and is served like the door, invoked by the ${role}`, async () => { - const res = await as(token(), 'POST', '/actions/pin_note/body_searches_default', { params: {} }); - expect(res.status).toBe(200); - // It ran (not refused) and answered the family served, never the stored - // body or hash — the body and hash columns were removed from the scan. - expectServedLikeTheDoor(`default search (${role})`, await readJson(res)); - }); - } +describe('[#21594] platform readers are outside the boundary (controls)', () => { + it('the metadata API — the route the refusal prescribes — answers the same item, projected', async () => { + const res = await as(adminToken, 'GET', `/meta/datasource/${DS_NAME}`); + const text = await res.text(); + expect(res.status, text).toBe(200); + expect(text).toContain(DS_HOST); + expect(text.includes(SENTINEL), 'the metadata API served the stored credential').toBe(false); + }); + + it('the engine\'s own in-process read still answers the stored form: the refusal is not in the engine', async () => { + const engine: any = await kernel.getServiceAsync('objectql'); + for (const object of ['sys_metadata', 'sys_metadata_history']) { + const rows: any[] = await engine.find(object, { where: { type: 'datasource', name: DS_NAME }, context: { isSystem: true } }); + expect(rows.length, object).toBeGreaterThan(0); + expect(rows.some((r) => String(r.metadata ?? '').includes(SENTINEL)), object).toBe(true); + } + }); }); -describe('[#21454] the engine action verb is unreachable from a served body', () => { +describe('[#21454] the engine action verb is unreachable from a body', () => { it('a sandboxed body sees no `execute` on ctx.api.object(...)', async () => { const res = await as(adminToken, 'POST', '/actions/pin_note/body_calls_execute', { params: {} }); expect(res.status).toBe(200); From 9d955004c5393dc56a201057efbcff066f76abf2 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 4 Oct 2026 00:28:00 +0000 Subject: [PATCH 4/8] chore(changeset): the body read refusal ships as a narrowing (wip) Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_016GiHYRmLSNWTfbX9gVQkpz --- .changeset/21594-body-family-read-refusal.md | 18 ++++++++++++++++++ .../src/stored-metadata-reader-seam.test.ts | 5 +++-- 2 files changed, 21 insertions(+), 2 deletions(-) create mode 100644 .changeset/21594-body-family-read-refusal.md diff --git a/.changeset/21594-body-family-read-refusal.md b/.changeset/21594-body-family-read-refusal.md new file mode 100644 index 0000000000..2db5c807b0 --- /dev/null +++ b/.changeset/21594-body-family-read-refusal.md @@ -0,0 +1,18 @@ +--- +'@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) + + + +**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. +- **FROM → TO.** FROM a body reading `ctx.api.object('sys_metadata')` or `ctx.api.object('sys_metadata_history')`, which was served the body projected and the content hash keyed, TO the metadata API: `GET /api/v1/meta/:type/:name` for a definition, and `GET /api/v1/meta/:type/:name/history` for its versions. The one-line fix is to move the read out of the body, to the metadata API or to host code. 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` and `ctx.engine.find` 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. diff --git a/packages/runtime/src/stored-metadata-reader-seam.test.ts b/packages/runtime/src/stored-metadata-reader-seam.test.ts index 85de3bd6ce..b8c5b499d6 100644 --- a/packages/runtime/src/stored-metadata-reader-seam.test.ts +++ b/packages/runtime/src/stored-metadata-reader-seam.test.ts @@ -5,8 +5,9 @@ * reads it serves, through which derived contexts, and that it serves each one * exactly once. The composed contexts are pinned end to end, against the data * door, in `stored-metadata-reader-contexts.pin.test.ts`; this file holds the - * routes a body or handler can take around the seam that the composition does - * not exercise one by one. + * routes a handler can take around the seam that the composition does not + * exercise one by one. [#21594] A sandboxed body is not served here: its body + * layers refuse a family read first (`stored-metadata-body-reads.test.ts`). */ import { describe, it, expect } from 'vitest'; From 9c8788419169e6161e6f2a24ef8149ecd8cf39b3 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 4 Oct 2026 00:29:08 +0000 Subject: [PATCH 5/8] chore(changeset): state the route in the release's shape (wip) Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_016GiHYRmLSNWTfbX9gVQkpz --- .changeset/21594-body-family-read-refusal.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/21594-body-family-read-refusal.md b/.changeset/21594-body-family-read-refusal.md index 2db5c807b0..091704a26d 100644 --- a/.changeset/21594-body-family-read-refusal.md +++ b/.changeset/21594-body-family-read-refusal.md @@ -11,7 +11,7 @@ Clause-②: yes (narrowing) **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. -- **FROM → TO.** FROM a body reading `ctx.api.object('sys_metadata')` or `ctx.api.object('sys_metadata_history')`, which was served the body projected and the content hash keyed, TO the metadata API: `GET /api/v1/meta/:type/:name` for a definition, and `GET /api/v1/meta/:type/:name/history` for its versions. The one-line fix is to move the read out of the body, to the metadata API or to host code. No shipped example reads either table from a body. +- **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` and `ctx.engine.find` 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. From d5b890226c3ebbb70fbc01ab1980e59c44142b50 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 4 Oct 2026 00:59:46 +0000 Subject: [PATCH 6/8] test(runtime): the body-read pin's double reads findOne through the engine's own predicate (wip) Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_016GiHYRmLSNWTfbX9gVQkpz --- .../src/stored-metadata-body-reads.test.ts | 16 ++++++++++++---- scripts/engine-double-contract.pinned.json | 5 +++++ 2 files changed, 17 insertions(+), 4 deletions(-) diff --git a/packages/runtime/src/stored-metadata-body-reads.test.ts b/packages/runtime/src/stored-metadata-body-reads.test.ts index 4fc6b01eb2..eaea00cf4b 100644 --- a/packages/runtime/src/stored-metadata-body-reads.test.ts +++ b/packages/runtime/src/stored-metadata-body-reads.test.ts @@ -27,6 +27,7 @@ */ import { describe, it, expect } from 'vitest'; +import { assertEngineFindOnePredicate } from '@objectstack/metadata-core'; import { STORED_METADATA_BODY_OBJECTS } from '@objectstack/spec/kernel'; import { refuseStoredMetadataBodyReads, @@ -40,6 +41,8 @@ import { QuickJSScriptRunner } from './sandbox/quickjs-runner.js'; const FAMILY = [...STORED_METADATA_BODY_OBJECTS]; const ORDINARY = 'boundary_note'; const READ_VERBS = ['find', 'findOne', 'count', 'aggregate']; +/** The query a control read is called with: `findOne` takes a predicate, as the engine requires. */ +const queryFor = (verb: string): Record => (verb === 'findOne' ? { where: { id: 'n1' } } : {}); const SENTINEL = 'body-read-unit-sentinel-5e02'; /** A stored family row: the body carries a credential, the row a content hash. */ @@ -57,7 +60,8 @@ function storedRow(): Record { * A scoped-API double that counts `.` calls and answers the * stored form, so a read that reached it would carry the sentinel. Read verbs * and one write verb only: no `update` / `delete` member, so it carries no - * write dispatch to hold to the engine's. + * write dispatch to hold to the engine's; `findOne` routes through the + * engine's own predicate (check:engine-double-contract). */ function countingApi(calls: string[] = []): any { const repo = (name: string) => { @@ -65,7 +69,11 @@ function countingApi(calls: string[] = []): any { const family = name.startsWith('sys_metadata'); return { async find() { record('find'); return family ? [storedRow()] : [{ id: 'n1' }]; }, - async findOne() { record('findOne'); return family ? storedRow() : { id: 'n1' }; }, + async findOne(query?: any) { + assertEngineFindOnePredicate(name, query); + record('findOne'); + return family ? storedRow() : { id: 'n1' }; + }, async count() { record('count'); return 1; }, async aggregate() { record('aggregate'); return family ? [{ metadata: storedRow().metadata, count: 1 }] : []; }, async insert() { record('insert'); return { id: 'n1' }; }, @@ -138,7 +146,7 @@ describe('[#21594] refuseStoredMetadataBodyReads — every family read is refuse it('an ordinary table reads as before (control)', async () => { const calls: string[] = []; const api = refuseStoredMetadataBodyReads(countingApi(calls)); - for (const verb of READ_VERBS) await api.object(ORDINARY)[verb]({}); + for (const verb of READ_VERBS) await api.object(ORDINARY)[verb](queryFor(verb)); expect(calls).toEqual(READ_VERBS.map((verb) => `${ORDINARY}.${verb}`)); }); @@ -182,7 +190,7 @@ describe('[#21594] the API a body holds — the write layer over the read layer' it('an ordinary table reads and writes as before (control)', async () => { const calls: string[] = []; const api = bodyApi(calls); - for (const verb of READ_VERBS) await api.object(ORDINARY)[verb]({}); + for (const verb of READ_VERBS) await api.object(ORDINARY)[verb](queryFor(verb)); await api.object(ORDINARY).insert({ label: 'x' }); expect(calls).toEqual([...READ_VERBS, 'insert'].map((verb) => `${ORDINARY}.${verb}`)); }); diff --git a/scripts/engine-double-contract.pinned.json b/scripts/engine-double-contract.pinned.json index a9cc6e7fd8..e99a96bfeb 100644 --- a/scripts/engine-double-contract.pinned.json +++ b/scripts/engine-double-contract.pinned.json @@ -3811,6 +3811,11 @@ "verb": "update", "pinned": 1 }, + { + "file": "packages/runtime/src/stored-metadata-body-reads.test.ts", + "verb": "findOne", + "pinned": 1 + }, { "file": "packages/runtime/src/stored-metadata-reader-seam.test.ts", "verb": "delete", From 694f4ce57d63cba5d8357179295bda4499629eca Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 4 Oct 2026 10:19:23 +0000 Subject: [PATCH 7/8] fix(runtime): an action body is not handed a stored-metadata row as its subject record (wip) Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_016GiHYRmLSNWTfbX9gVQkpz --- packages/runtime/src/sandbox/body-runner.ts | 38 +++++++++++++++++++ .../src/stored-metadata-body-boundary.ts | 25 ++++++++++++ 2 files changed, 63 insertions(+) diff --git a/packages/runtime/src/sandbox/body-runner.ts b/packages/runtime/src/sandbox/body-runner.ts index fdebb43d0f..522b737bc8 100644 --- a/packages/runtime/src/sandbox/body-runner.ts +++ b/packages/runtime/src/sandbox/body-runner.ts @@ -66,6 +66,7 @@ import { isStoredMetadataBodyObject } from '@objectstack/spec/kernel'; import { isWildcardHookTarget, storedMetadataBodyHookBindingRefusal, + storedMetadataBodySubjectRecordRefusal, storedMetadataFamilyTableList, } from '../stored-metadata-body-boundary.js'; @@ -448,6 +449,11 @@ export function actionBodyRunnerFactory( const body = parsed.data; return async function boundActionHandler(actionCtx: any): Promise { + // [#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, @@ -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///`), 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 } diff --git a/packages/runtime/src/stored-metadata-body-boundary.ts b/packages/runtime/src/stored-metadata-body-boundary.ts index 3b7f17629f..6a281d343b 100644 --- a/packages/runtime/src/stored-metadata-body-boundary.ts +++ b/packages/runtime/src/stored-metadata-body-boundary.ts @@ -22,6 +22,11 @@ * - **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 * readers and writers, the platform's internal hooks (registered as code, @@ -134,6 +139,26 @@ export function storedMetadataBodyReadRefusal(object: string, verb: string): Err ); } +/** + * [#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(', '); From 5e8fd37d78f3bc10ccf6c09837b3b64d22ddb1b3 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 4 Oct 2026 10:22:04 +0000 Subject: [PATCH 8/8] test(runtime): pin the subject-record refusal at the /actions door, with host and ordinary controls (wip) Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_016GiHYRmLSNWTfbX9gVQkpz --- .changeset/21594-body-family-read-refusal.md | 3 +- .../src/stored-metadata-body-reads.test.ts | 125 +++++++++++++++++- ...tored-metadata-reader-contexts.pin.test.ts | 111 ++++++++++++++++ 3 files changed, 237 insertions(+), 2 deletions(-) diff --git a/.changeset/21594-body-family-read-refusal.md b/.changeset/21594-body-family-read-refusal.md index 091704a26d..a7f7311982 100644 --- a/.changeset/21594-body-family-read-refusal.md +++ b/.changeset/21594-body-family-read-refusal.md @@ -11,8 +11,9 @@ Clause-②: yes (narrowing) **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` and `ctx.engine.find` 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. +- **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. diff --git a/packages/runtime/src/stored-metadata-body-reads.test.ts b/packages/runtime/src/stored-metadata-body-reads.test.ts index eaea00cf4b..578ed136ec 100644 --- a/packages/runtime/src/stored-metadata-body-reads.test.ts +++ b/packages/runtime/src/stored-metadata-body-reads.test.ts @@ -26,7 +26,7 @@ * bodies only. */ -import { describe, it, expect } from 'vitest'; +import { describe, it, expect, vi } from 'vitest'; import { assertEngineFindOnePredicate } from '@objectstack/metadata-core'; import { STORED_METADATA_BODY_OBJECTS } from '@objectstack/spec/kernel'; import { @@ -37,6 +37,7 @@ import { import { STORED_METADATA_BODY_BOUNDARY_CODE, STORED_METADATA_BODY_BOUNDARY_STATUS } from './stored-metadata-body-boundary.js'; import { actionBodyRunnerFactory, hookBodyRunnerFactory, jobBodyRunnerFactory } from './sandbox/body-runner.js'; import { QuickJSScriptRunner } from './sandbox/quickjs-runner.js'; +import { invokeBusinessAction } from './action-execution.js'; const FAMILY = [...STORED_METADATA_BODY_OBJECTS]; const ORDINARY = 'boundary_note'; @@ -302,3 +303,125 @@ describe('[#21594] through the real sandbox — every body face holds the refusi expect(calls).toEqual([]); }); }); + +/** + * [#21594] The subject record: the `/actions` door loads an action's subject + * row before it dispatches and hands it to the handler as `ctx.record`. For a + * body, a family row there is refused before the body runs, whether the action + * was declared on a family table or an object-less action was addressed under + * one. The door is pinned end to end in `stored-metadata-reader-contexts.pin.test.ts`. + */ +describe('[#21594] an action body is not handed a family row as its subject record', () => { + const runner = new QuickJSScriptRunner({ hookTimeoutMs: 10_000 }); + /** Writes a marker first, so a body that ran at all leaves a call behind. */ + const reportsRecord = (name: string, object?: string) => actionBodyRunnerFactory(runner, { ql: {}, appId: 'boundary' })({ + name, + type: 'script', + ...(object ? { object } : {}), + body: { + language: 'js', + capabilities: ['api.write'], + source: `await ctx.api.object('${ORDINARY}').insert({ label: 'ran' });\nreturn { keys: Object.keys(ctx.record || {}).sort() };`, + }, + })!; + const expectSubjectRefused = (promise: Promise, object: string) => + expect(promise).rejects.toMatchObject({ + code: STORED_METADATA_BODY_BOUNDARY_CODE, + status: STORED_METADATA_BODY_BOUNDARY_STATUS, + object, + operation: 'record', + message: expect.stringContaining('GET /api/v1/meta/:type/:name'), + }); + + for (const object of FAMILY) { + it(`an action declared on '${object}', handed one of its rows by the door, is refused and never runs`, async () => { + const calls: string[] = []; + const handler = reportsRecord('family_bound', object); + await expectSubjectRefused( + handler({ api: countingApi(calls), record: storedRow(), params: { objectName: object, recordId: 'row_1' } }), + object, + ); + expect(calls).toEqual([]); + }); + + it(`an object-less action addressed under '${object}' with a record is refused the same way`, async () => { + const calls: string[] = []; + await expectSubjectRefused( + reportsRecord('object_less')({ api: countingApi(calls), record: storedRow(), params: { objectName: object, recordId: 'row_1' } }), + object, + ); + expect(calls).toEqual([]); + }); + } + + it('with no routed object (an engine execute from host code), the declared family object stands in', async () => { + const calls: string[] = []; + await expectSubjectRefused(reportsRecord('family_bound', FAMILY[0])({ api: countingApi(calls), record: storedRow() }), FAMILY[0]); + expect(calls).toEqual([]); + }); + + it('an ordinary subject record is handed to the body as before (control)', async () => { + const calls: string[] = []; + const result: any = await reportsRecord('ordinary', ORDINARY)({ + api: countingApi(calls), + record: { id: 'n1', label: 'x' }, + params: { objectName: ORDINARY, recordId: 'n1' }, + }); + expect(result.keys).toEqual(['id', 'label']); + expect(calls).toEqual([`${ORDINARY}.insert`]); + }); + + it('a family-routed call carrying no record hands the body nothing, and runs (control)', async () => { + const calls: string[] = []; + const result: any = await reportsRecord('family_bound', FAMILY[0])({ api: countingApi(calls), record: {}, params: { objectName: FAMILY[0] } }); + expect(result.keys).toEqual([]); + expect(calls).toEqual([`${ORDINARY}.insert`]); + }); +}); + +/** + * [#21594] The MCP `run_action` door never reaches the subject load for a + * family table: it refuses an action on any `sys_*` object before it loads a + * record, and resolves an object-less action only under its own key. Pinned + * here because the composed kernel's metadata service lists no standalone + * action to drive the door with. + */ +describe('[#21594] the MCP run_action door stops a family subject before the record load', () => { + const familyAction = (name: string, objectName: string) => ({ name, objectName, type: 'script', body: { language: 'js', source: 'return 1;' }, ai: { exposed: true, description: 'probe' } }); + const run = (name: string, input: Record) => { + const callData = vi.fn(async () => ({ record: storedRow() })); + const ql = { executeAction: vi.fn(async () => ({ ran: true })) }; + const meta = { + listObjects: async () => [], + loadMany: async (type: string) => (type === 'action' + ? [...FAMILY.map((o) => familyAction(`bound_${o}`, o)), { ...familyAction('object_less', ''), objectName: undefined }] + : []), + }; + const deps: any = { resolveService: async () => undefined, getObjectQL: async () => ql }; + const promise = invokeBusinessAction(deps, { request: {} } as any, name, input as any, { + driver: undefined, envId: undefined, ec: { userId: 'usr_admin' }, getMeta: () => meta, callData, + }); + return { promise, callData, ql }; + }; + + for (const object of FAMILY) { + it(`an action declared on '${object}' is refused, with no record loaded and no handler run`, async () => { + const { promise, callData, ql } = run(`bound_${object}`, { objectName: object, recordId: 'row_1' }); + const err: any = await promise.catch((e: unknown) => e); + expect(err).toBeInstanceOf(Error); + expect(String(err.message)).toContain(`'bound_${object}'`); + expect(String(err.message)).toContain('system object'); + expect(callData).not.toHaveBeenCalled(); + expect(ql.executeAction).not.toHaveBeenCalled(); + }); + + it(`an object-less action named under '${object}' does not resolve, with no record loaded`, async () => { + const { promise, callData, ql } = run('object_less', { objectName: object, recordId: 'row_1' }); + const err: any = await promise.catch((e: unknown) => e); + expect(err).toBeInstanceOf(Error); + expect(String(err.message)).toContain(`'${object}'`); + expect(callData).not.toHaveBeenCalled(); + expect(ql.executeAction).not.toHaveBeenCalled(); + }); + } +}); diff --git a/packages/runtime/src/stored-metadata-reader-contexts.pin.test.ts b/packages/runtime/src/stored-metadata-reader-contexts.pin.test.ts index f85f4a4e52..f47b35b5a7 100644 --- a/packages/runtime/src/stored-metadata-reader-contexts.pin.test.ts +++ b/packages/runtime/src/stored-metadata-reader-contexts.pin.test.ts @@ -26,6 +26,12 @@ * ② its engine handle, `ctx.engine.find` (`buildActionEngineFacade`); * ③ its `ctx.api` (`buildActionApi`). * + * [#21594] Nor is a body HANDED a family row: an action whose subject record + * the `/actions` door loads from a family table (declared there, through the + * bundle or the `/meta` door, or object-less and addressed under it) is + * refused with the same 403 before its body runs; a host handler's subject + * record and an ordinary one are pinned unchanged. + * * Platform readers are outside the boundary, pinned as controls: the generic * data door, the metadata API (the route the refusal prescribes) and the * engine's own in-process read of the stored form. @@ -79,6 +85,9 @@ const DS_HOST = 'pin.example.invalid'; const readFamilySource = (object: string) => `const rows = await ctx.api.object('${object}').find({ where: { type: 'datasource', name: '${DS_NAME}' } });`; +/** [#21594] A body that only returns the subject record the door handed it. */ +const RETURN_RECORD = 'return { record: ctx.record };'; + const actionBody = (source: string, capabilities: string[] = ['api.read']) => ({ language: 'js', source, @@ -199,9 +208,20 @@ const PIN_APP: any = { // ② the engine handle's evaluate shape — a handler whose ctx.engine.find // filters the body column is refused the same way. { name: 'handler_engine_filters_body', label: 'Handler engine filters body', type: 'script' }, + // [#21594] subject-record control: an ordinary row is handed to a body as before. + { name: 'note_reads_record', label: 'Body reads its subject record', type: 'script', body: actionBody(RETURN_RECORD) }, ], }, ], + // [#21594] ① the subject record — actions whose subject the `/actions` door + // loads before dispatch. Declared on a family table, or object-less (a + // caller may address it under any object), each body only returns + // `ctx.record`; the host handler is code, registered by PIN_HANDLER_PLUGIN. + actions: [ + { name: 'family_bound_reads_record', label: 'Family-bound body', objectName: 'sys_metadata', type: 'script', body: actionBody(RETURN_RECORD) }, + { name: 'object_less_reads_record', label: 'Object-less body', type: 'script', body: actionBody(RETURN_RECORD) }, + { name: 'host_object_less_reads_record', label: 'Object-less host handler', type: 'script' }, + ], hooks: [ // ① a sandboxed hook body: it would COPY what it read onto the row being inserted. { @@ -255,6 +275,13 @@ const PIN_HANDLER_PLUGIN: Plugin = { async (actionCtx: any) => ({ rows: await actionCtx.engine.find('sys_metadata', { where: { metadata: { $contains: 'z' } } }) }), 'pin.reader21454.handler', ); + // [#21594] the subject-record control: a host handler under the object-less key. + ql.registerAction( + 'global', + 'host_object_less_reads_record', + async (actionCtx: any) => ({ record: actionCtx.record }), + 'pin.reader21454.handler', + ); }, }; @@ -610,6 +637,90 @@ describe('[#21594] platform readers are outside the boundary (controls)', () => }); }); +/** + * [#21594] The subject record. The `/actions` door loads an action's subject + * row through the generic data door before it dispatches. A BODY is handed no + * family row that way: an action declared on a family table, an object-less + * action addressed under one, and an action declared there through the `/meta` + * door are each refused with the body boundary's 403 before the body runs. A + * caller who cannot read the row is stopped earlier by the door's own subject + * load. A host handler's subject record and an ordinary one are unchanged. + */ +describe('[#21594] ① an action body is not handed a family row as its subject record', () => { + async function historyRowId(): Promise { + const engine: any = await kernel.getServiceAsync('objectql'); + const rows: any[] = await engine.find('sys_metadata_history', { where: { type: 'datasource', name: DS_NAME }, context: { isSystem: true } }); + expect(rows.length, 'the fixture stored no history row').toBeGreaterThan(0); + return rows[0].id; + } + + it('administrator: an action declared on a family table, and an object-less action under either table, are refused', async () => { + await expectBodyReadRefused( + 'family-bound action', + await as(adminToken, 'POST', `/actions/sys_metadata/family_bound_reads_record/${storedRow.id}`, { params: {} }), + ); + await expectBodyReadRefused( + 'object-less action under sys_metadata', + await as(adminToken, 'POST', `/actions/sys_metadata/object_less_reads_record/${storedRow.id}`, { params: {} }), + ); + await expectBodyReadRefused( + 'object-less action under sys_metadata_history', + await as(adminToken, 'POST', `/actions/sys_metadata_history/object_less_reads_record/${await historyRowId()}`, { params: {} }), + ); + }); + + it('an action declared on a family table through the /meta door is refused once bound', async () => { + const authored = await as(adminToken, 'PUT', '/meta/action/authored_family_bound_reads_record', { + name: 'authored_family_bound_reads_record', + label: 'Authored family-bound body', + objectName: 'sys_metadata', + type: 'script', + body: actionBody(RETURN_RECORD), + }); + expect(authored.status).toBeLessThan(300); + let res: Response | undefined; + const bound = await waitFor(async () => { + const attempt: Response = await as(adminToken, 'POST', `/actions/sys_metadata/authored_family_bound_reads_record/${storedRow.id}`, { params: {} }); + res = attempt; + return attempt.status !== 404; + }); + expect(bound, 'the runtime-authored action never bound').toBe(true); + await expectBodyReadRefused('runtime-authored family-bound action', res as Response); + }, 30_000); + + it('member: the door\'s own subject load stops it first (the row is not readable), and the body never runs', async () => { + for (const action of ['family_bound_reads_record', 'object_less_reads_record']) { + const res = await as(memberToken, 'POST', `/actions/sys_metadata/${action}/${storedRow.id}`, { params: {} }); + const payload = await readJson(res); + expect(res.status, action).toBe(404); + expect(payload?.error?.code ?? payload?.code, action).toBe('RECORD_NOT_FOUND'); + expect(familyRowsIn(payload), `${action}: a family row reached the answer`).toEqual([]); + } + }); + + it('control: a host handler addressed under a family table is still handed the row the data door serves', async () => { + const res = await as(adminToken, 'POST', `/actions/sys_metadata/host_object_less_reads_record/${storedRow.id}`, { params: {} }); + expect(res.status).toBe(200); + expectServedLikeTheDoor('host handler subject record', await readJson(res)); + }); + + it('control: an ordinary subject record is handed to a body as before', async () => { + const engine: any = await kernel.getServiceAsync('objectql'); + const note: any = await engine.insert('pin_note', { title: 'subject-control' }, { context: { isSystem: true } }); + const res = await as(adminToken, 'POST', `/actions/pin_note/note_reads_record/${note.id}`, { params: {} }); + const payload = await readJson(res); + expect(res.status, JSON.stringify(payload)).toBe(200); + expect(payload?.data?.record?.title).toBe('subject-control'); + }); + + it('control: a family-routed call that carries no record hands the body nothing, and runs', async () => { + const res = await as(adminToken, 'POST', '/actions/sys_metadata/family_bound_reads_record', { params: {} }); + const payload = await readJson(res); + expect(res.status, JSON.stringify(payload)).toBe(200); + expect(familyRowsIn(payload)).toEqual([]); + }); +}); + describe('[#21454] the engine action verb is unreachable from a body', () => { it('a sandboxed body sees no `execute` on ctx.api.object(...)', async () => { const res = await as(adminToken, 'POST', '/actions/pin_note/body_calls_execute', { params: {} });