From 50adc7440bc07030077e19fbce93543e21cd81ed Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Oct 2026 03:39:13 +0000 Subject: [PATCH 01/11] wip(runtime): evaluate-shape refusals + write-return serve at stored-metadata reader seam Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_016GiHYRmLSNWTfbX9gVQkpz --- packages/metadata-protocol/src/index.ts | 14 + .../src/stored-metadata-reader-seam.ts | 244 +++++++++++++++--- 2 files changed, 219 insertions(+), 39 deletions(-) diff --git a/packages/metadata-protocol/src/index.ts b/packages/metadata-protocol/src/index.ts index 2bdf109ff4d..7b911fbd533 100644 --- a/packages/metadata-protocol/src/index.ts +++ b/packages/metadata-protocol/src/index.ts @@ -207,6 +207,20 @@ export { } from './metadata-redaction.js'; export type { StoredHashDigest } from './metadata-redaction.js'; +// [#21454] The generic data door's EVALUATE refusals on the same family +// (#21086 grouping, #21120 body filter / sort, #21207 content-hash evaluate and +// search). Exported so the in-process reader contexts in `@objectstack/runtime` +// refuse the evaluate shapes the way the door does — each through the door's +// OWN predicate, never a copy: a second definition of which shapes leak a +// stored body or hash is exactly the drift the family's one rule exists to +// prevent. +export { + storedMetadataBodyGroupingRefusal, + storedMetadataBodyPredicateRefusal, + storedMetadataHashEvaluateRefusal, + storedMetadataSearchRefusal, +} from './metadata-redaction.js'; + export type { MetadataHostEngine } from './host-engine.js'; // [#7560] ADR-0070's read-only-package rule. The authoring path (`saveMetaItem` diff --git a/packages/runtime/src/stored-metadata-reader-seam.ts b/packages/runtime/src/stored-metadata-reader-seam.ts index 699055f3737..50b995bdad9 100644 --- a/packages/runtime/src/stored-metadata-reader-seam.ts +++ b/packages/runtime/src/stored-metadata-reader-seam.ts @@ -4,15 +4,16 @@ * [#21454] The stored-metadata-body family at the in-process READER CONTEXTS. * * The family (`sys_metadata` / `sys_metadata_history`, #21120, #21207) closes - * every door that serves a stored metadata body, or the stored content hash - * over it: the body is served as its type's read projection, with stored - * credential material withheld, and the hash is served in keyed form, never - * the stored value. The generic data door does both, and that is the - * reference answer here. + * every door that serves, copies OR EVALUATES a stored metadata body, or the + * stored content hash over it: the body is served as its type's read + * projection with stored credential material withheld, the hash is served in + * keyed form, and a filter, sort, grouping or search that would EVALUATE either + * one is refused before the query runs. The generic data door does all three, + * and that is the reference answer here. * * Three in-process contexts read the same rows through the engine and served - * them as stored, because nothing between them and the engine applied either - * half: + * them as stored, because nothing between them and the engine applied any part + * 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 @@ -26,36 +27,58 @@ * * 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 projection is applied HERE, at the reader-context seam, - * and never at the engine. + * stored form. So the family's rule is applied HERE, at the reader-context + * seam, and never at the engine. * - * ## One serve, consumed + * ## Three things this seam does to a family READ, consuming the door's own code * - * Every function the serve calls is the data door's own, imported from - * `@objectstack/metadata-protocol`: the `type` companion for a projection - * that names only the body (`storedMetadataBodyProjection`), the body - * projection (`redactStoredMetadataRows`, which consumes the family's ONE - * redactor in `@objectstack/spec/kernel`), and the keyed serve - * (`serveStoredMetadataHashColumnRows`) under the crypto provider's digest or, - * while none is registered, the same process-scoped ephemeral key the door - * keys under (`ephemeralStoredHashDigest`). A copy of any of them here would be - * a second definition of what a credential is, or a second keyed form of one - * row. + * 1. **Refuse the EVALUATE shapes** ({@link refuseOrNarrowStoredMetadataEvaluate}), + * through the generic data door's OWN refusal predicates + * (`storedMetadataBodyGroupingRefusal`, `storedMetadataBodyPredicateRefusal`, + * `storedMetadataHashEvaluateRefusal`, `storedMetadataSearchRefusal`, + * `@objectstack/metadata-protocol`), in the door's own order — a copy of any + * of them here would be a second definition of which shapes leak. A `count` + * with such a predicate is an oracle too, so it is guarded the same way (it + * serves no row, so only the refusal applies to it). A default `$search` + * is NARROWED to the door's served set — 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 scan nothing after the removal is + * refused. + * 2. **Serve the body projected and the hash keyed** ({@link serveStoredMetadataRead}), + * using the door's `storedMetadataBodyProjection`, `redactStoredMetadataRows` + * (the family's ONE redactor in `@objectstack/spec/kernel`) and + * `serveStoredMetadataHashColumnRows`, under the crypto provider's digest or, + * while none is registered, the same process-scoped ephemeral key the door + * keys under (`ephemeralStoredHashDigest`). A copy would serve a second keyed + * form of one row. + * 3. **Serve what a WRITE verb RETURNS** ({@link serveStoredMetadataWriteReturn}): + * a write whose return carries the family's body or hash is served the same + * projected / keyed way a read is, since a returned row is a serve. Whether a + * body may write the family AT ALL is the write boundary (#21520), not this + * seam: the write itself passes through, only its RETURN is served. * * ## What it does not do * * It judges the object by name with the family's own predicate - * (`isStoredMetadataBodyObject`), exactly as the door does. Writes, `count` - * and the evaluate shapes (a filter, sort or grouping on the body or hash - * columns) are outside it: this seam changes what a READ serves, nothing else. + * (`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). */ import { isStoredMetadataBodyObject } from '@objectstack/spec/kernel'; +import { isFilterAST, parseFilterAST, resolveSearchFieldResolution } from '@objectstack/spec/data'; +import { collectConditionFields } from '@objectstack/plugin-security'; import { ephemeralStoredHashDigest, redactStoredMetadataRows, serveStoredMetadataHashColumnRows, storedMetadataBodyProjection, + storedMetadataBodyGroupingRefusal, + storedMetadataBodyPredicateRefusal, + storedMetadataHashEvaluateRefusal, + storedMetadataSearchRefusal, type StoredHashDigest, } from '@objectstack/metadata-protocol'; @@ -76,17 +99,121 @@ function isPlainRecord(value: unknown): value is Record { return value !== null && typeof value === 'object' && !Array.isArray(value); } +/** + * Every column a filter NAMES, structure discarded — lowering a `FilterArray` + * to a `FilterCondition` first so the array door a direct engine call still + * honours (`lowerWhereFilterArray`) is read the same as the object door. + * + * The walk is the generic data door's sibling, `collectConditionFields` + * (`@objectstack/plugin-security`): the door's own `collectFilterFieldKeys` is + * internal to `protocol.ts` and cannot be imported here, and restating it would + * be the second definition the family rule forbids. This collector gates on a + * dotted head and also reads a cross-field `{ $field }` comparand, so it refuses + * a DOTTED or COMPARAND reference to the body or hash columns the door's own + * collector would miss — strictly MORE than the door, never a legitimate + * scalar-column query, since the family columns are the only ones these + * predicates name. + */ +function filterHeadFields(where: unknown): string[] { + if (where == null) return []; + const lowered = isFilterAST(where) ? parseFilterAST(where) : where; + return [...collectConditionFields(lowered)]; +} + +/** The fields an `orderBy` names (`[{ field }]`, or a bare string entry). */ +function sortFieldsOf(orderBy: unknown): unknown[] { + if (!Array.isArray(orderBy)) return []; + return orderBy.map((entry) => (isPlainRecord(entry) ? entry.field : entry)); +} + +/** + * Narrow or refuse a `$search` on a family table the way the data door's + * `narrowStoredMetadataSearch` does, consuming the door's own search predicate + * ({@link storedMetadataSearchRefusal}) as the authority on which columns a + * search may never scan: + * + * - an EXPLICIT field list (`searchFields`, or the object-form `search.fields`) + * naming an unscannable column is refused; + * - a DEFAULT search is narrowed to the resolved searchable set minus the + * columns the predicate refuses, field by field, and the query runs with that + * `searchFields`; a set that narrows to empty is refused. + * + * Returns the query the read should run — the same reference when nothing + * changed, a shallow copy carrying the narrowed `searchFields` otherwise. + */ +function narrowFamilySearch(object: string, query: Record, engine: unknown): Record { + const search = query.search; + const objectForm = search !== null && typeof search === 'object'; + const explicitRaw = query.searchFields != null + ? query.searchFields + : objectForm ? (search as Record).fields : undefined; + const param = query.searchFields != null ? 'searchFields' : 'search'; + const names: string[] = typeof explicitRaw === 'string' + ? explicitRaw.split(',').map((s) => s.trim()).filter(Boolean) + : Array.isArray(explicitRaw) + ? explicitRaw.filter((f): f is string => typeof f === 'string') + : []; + if (names.length > 0) { + const refusal = storedMetadataSearchRefusal(object, names, param); + if (refusal) throw refusal; + return query; + } + if (search == null) return query; + const schema = typeof (engine as { getObject?: (n: string) => unknown })?.getObject === 'function' + ? (engine as { getObject: (n: string) => any }).getObject(object) + : undefined; + const fields = schema?.fields; + if (!fields) return query; + const { allowed } = resolveSearchFieldResolution({ + fields, + searchableFields: schema?.searchableFields, + displayField: schema?.nameField ?? schema?.displayNameField, + }); + const narrowed = allowed.filter((field) => !storedMetadataSearchRefusal(object, [field], 'search')); + if (narrowed.length === 0) { + const refusal = storedMetadataSearchRefusal(object, allowed, 'search'); + if (refusal) throw refusal; + return query; + } + return { ...query, searchFields: narrowed }; +} + +/** + * Refuse every EVALUATE shape on a family read, in the data door's own order + * (search, grouping, body filter / sort, hash filter / sort / grouping), each + * through the door's own predicate. Returns the query the read should run, + * which may carry a narrowed `$search` field set. A non-family object, and a + * query that is not a record, pass through untouched. + */ +function refuseOrNarrowStoredMetadataEvaluate(object: string, query: unknown, engine: unknown): unknown { + if (!isStoredMetadataBodyObject(object) || !isPlainRecord(query)) return query; + const next = narrowFamilySearch(object, query, engine); + const grouping = storedMetadataBodyGroupingRefusal(object, next.groupBy); + if (grouping) throw grouping; + const aggregationFilterFields = Array.isArray(next.aggregations) + ? (next.aggregations as ReadonlyArray<{ filter?: unknown }>).flatMap((a) => filterHeadFields(a?.filter)) + : []; + const filterFields = [...filterHeadFields(next.where), ...filterHeadFields(next.filter), ...aggregationFilterFields]; + const sortFields = sortFieldsOf(next.orderBy); + const bodyPredicate = storedMetadataBodyPredicateRefusal(object, { filterFields, sortFields }); + if (bodyPredicate) throw bodyPredicate; + const hashEvaluate = storedMetadataHashEvaluateRefusal(object, { groupBy: next.groupBy, filterFields, sortFields }); + if (hashEvaluate) throw hashEvaluate; + return next; +} + /** * Run one READ of `object` and serve its answer the way the generic data door * serves the same rows. An object outside the family is read and returned - * untouched, by reference. + * untouched, by reference; a family read first has its EVALUATE shapes refused + * and its `$search` narrowed ({@link refuseOrNarrowStoredMetadataEvaluate}). * - * `read` receives the query to run: the caller's own, or, when its projection - * names the body column without the `type` column that selects the redactor, - * a copy with `type` added; that column is then taken back off the served - * rows, so the caller gets exactly the columns it named. The answer may be a - * row list (`find`, `aggregate`), one row (`findOne`) or `null`; each is served - * in the shape it arrived in. + * `read` receives the query to run: the caller's own (search narrowed), or, + * when its projection names the body column without the `type` column that + * selects the redactor, a copy with `type` added; that column is then taken + * back off the served rows, so the caller gets exactly the columns it named. + * The answer may be a row list (`find`, `aggregate`), one row (`findOne`) or + * `null`; each is served in the shape it arrived in. */ export async function serveStoredMetadataRead( object: string, @@ -95,9 +222,10 @@ export async function serveStoredMetadataRead( read: (query: unknown) => Promise, ): Promise { if (!isStoredMetadataBodyObject(object)) return read(query); - const projection = storedMetadataBodyProjection(object, isPlainRecord(query) ? query.fields : undefined); + const guarded = refuseOrNarrowStoredMetadataEvaluate(object, query, engine); + const projection = storedMetadataBodyProjection(object, isPlainRecord(guarded) ? guarded.fields : undefined); const answer = await read( - projection.addedType && isPlainRecord(query) ? { ...query, fields: projection.fields } : query, + projection.addedType && isPlainRecord(guarded) ? { ...guarded, fields: projection.fields } : guarded, ); const digest = storedHashDigestOf(engine); const opts = { dropType: projection.addedType }; @@ -115,8 +243,35 @@ export async function serveStoredMetadataRead( return answer; } -/** The repository verbs whose answer carries rows. `count` answers a number and serves no row. */ +/** + * Serve what a WRITE verb RETURNS the same projected / keyed way a read is + * served — a returned row carrying the family's body or hash is a serve too. + * A write whose return is a number (an affected-row count), `null`, or carries + * no family column passes through by reference. ⛔ This serves the RETURN only; + * it neither permits nor refuses the write, which is the write boundary's + * question (#21520). + */ +async function serveStoredMetadataWriteReturn(object: string, answer: A, engine: unknown): Promise { + if (!isStoredMetadataBodyObject(object)) return answer; + const digest = storedHashDigestOf(engine); + if (Array.isArray(answer)) { + return (await serveStoredMetadataHashColumnRows(object, redactStoredMetadataRows(object, answer), digest)) as A; + } + if (isPlainRecord(answer)) { + const [served] = await serveStoredMetadataHashColumnRows(object, redactStoredMetadataRows(object, [answer]), digest); + return served as A; + } + return answer; +} + +/** The repository verbs whose answer carries rows this seam serves. */ const ROW_SERVING_READS: ReadonlySet = new Set(['find', 'findOne', 'aggregate']); +/** The verb whose answer is a number: guarded against the evaluate oracle, nothing to serve. */ +const COUNT_READ: PropertyKey = 'count'; +/** The write verbs whose RETURN can carry family content (every alias ObjectRepository exposes). */ +const WRITE_RETURN_VERBS: ReadonlySet = new Set([ + 'insert', 'create', 'update', 'updateById', 'upsert', 'delete', 'deleteById', 'updateMany', 'deleteMany', +]); /** Marks a scoped context this seam already serves through, so a second wrap is a no-op. */ const SERVED_THROUGH_SEAM = Symbol.for('objectstack.runtime.storedMetadataReaderSeam'); @@ -127,9 +282,21 @@ function serveRepository(objectName: string, repo: unknown, engine: unknown): un get(target, prop) { const value = Reflect.get(target, prop, target); if (typeof value !== 'function') return value; - if (!ROW_SERVING_READS.has(prop)) return value.bind(target); - return (query?: unknown, ...rest: unknown[]) => - serveStoredMetadataRead(objectName, query, engine, (q) => value.call(target, q, ...rest)); + if (ROW_SERVING_READS.has(prop)) { + return (query?: unknown, ...rest: unknown[]) => + serveStoredMetadataRead(objectName, query, engine, (q) => value.call(target, q, ...rest)); + } + if (prop === COUNT_READ) { + return (query?: unknown, ...rest: unknown[]) => { + refuseOrNarrowStoredMetadataEvaluate(objectName, query, engine); + return value.call(target, query, ...rest); + }; + } + if (WRITE_RETURN_VERBS.has(prop)) { + return async (...args: unknown[]) => + serveStoredMetadataWriteReturn(objectName, await value.apply(target, args), engine); + } + return value.bind(target); }, }); } @@ -137,9 +304,8 @@ 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 - * {@link serveStoredMetadataRead}. Everything else is the same object, by - * delegation: a write, a `count`, and every non-family object reach it - * unchanged. + * {@link serveStoredMetadataRead}, every evaluate shape refused, and every + * write's RETURN served. Everything else is the same object, by delegation. * * 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 From f906474fc9c96b49505ade09621a974c80dd74ff Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Oct 2026 03:46:26 +0000 Subject: [PATCH 02/11] wip(runtime): pins for evaluate refusals, write-return serve, search narrowing, execute reach Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_016GiHYRmLSNWTfbX9gVQkpz --- ...tored-metadata-reader-contexts.pin.test.ts | 134 ++++++++++++++++++ .../src/stored-metadata-reader-seam.test.ts | 133 ++++++++++++++++- .../src/stored-metadata-reader-seam.ts | 5 +- 3 files changed, 266 insertions(+), 6 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 e017693e816..2764f2297ec 100644 --- a/packages/runtime/src/stored-metadata-reader-contexts.pin.test.ts +++ b/packages/runtime/src/stored-metadata-reader-contexts.pin.test.ts @@ -118,6 +118,64 @@ 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 + // VALUE in every predicate is an immaterial constant: the query is + // refused unrun, so nothing depends on what it is. + { + name: 'body_filters_body_column', + label: 'Body filters the body column', + type: 'script', + body: actionBody(`return { rows: await ctx.api.object('sys_metadata').find({ where: { metadata: { $contains: 'z' } } }) };`), + }, + { + name: 'body_sorts_body_column', + label: 'Body sorts by the body column', + type: 'script', + body: actionBody(`return { rows: await ctx.api.object('sys_metadata').find({ orderBy: [{ field: 'metadata', order: 'asc' }] }) };`), + }, + { + name: 'body_groups_body_column', + label: 'Body groups by the body column', + type: 'script', + body: actionBody(`return { rows: await ctx.api.object('sys_metadata_history').aggregate({ groupBy: ['metadata'] }) };`), + }, + { + name: 'body_filters_hash_column', + label: 'Body filters the hash column', + type: 'script', + body: actionBody(`return { rows: await ctx.api.object('sys_metadata').find({ where: { checksum: 'z' } }) };`), + }, + { + name: 'body_counts_body_column', + label: 'Body counts by the body column', + type: 'script', + body: actionBody(`return { n: await ctx.api.object('sys_metadata').count({ where: { metadata: { $contains: 'z' } } }) };`), + }, + { + name: 'body_searches_body_column', + label: 'Body searches an explicit body column', + 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. + { + name: 'body_searches_default', + label: 'Body default search', + type: 'script', + body: actionBody(`return { rows: await ctx.api.object('sys_metadata').find({ search: '${DS_NAME}' }) };`), + }, + // The engine action verb is not on a served body's surface at all. + { + name: 'body_calls_execute', + label: 'Body calls execute', + type: 'script', + body: actionBody(`return { typeofExecute: typeof ctx.api.object('sys_metadata').execute };`), + }, + // ② 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' }, ], }, ], @@ -167,6 +225,13 @@ const PIN_HANDLER_PLUGIN: Plugin = { async (actionCtx: any) => ({ rows: await actionCtx.api.object('sys_metadata').find({ where }) }), 'pin.reader21454.handler', ); + // [#21454] ② the engine handle's evaluate shape: a filter on the body column. + ql.registerAction( + 'pin_note', + 'handler_engine_filters_body', + async (actionCtx: any) => ({ rows: await actionCtx.engine.find('sys_metadata', { where: { metadata: { $contains: 'z' } } }) }), + 'pin.reader21454.handler', + ); }, }; @@ -461,3 +526,72 @@ describe('[#21454] ② / ③ an action handler\'s engine handle and scoped API', }); } }); + +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'); + }); + } +}); + +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('[#21454] the engine action verb is unreachable from a served 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); + const payload = await readJson(res); + // The VM bridge installs only find/findOne/count/aggregate and the writes; + // `execute` is not a function the body can call, so no raw scoped context + // is ever handed to a nested action through a served body. + expect(payload?.typeofExecute).toBe('undefined'); + }); +}); diff --git a/packages/runtime/src/stored-metadata-reader-seam.test.ts b/packages/runtime/src/stored-metadata-reader-seam.test.ts index f3358f4156f..4c8830b6007 100644 --- a/packages/runtime/src/stored-metadata-reader-seam.test.ts +++ b/packages/runtime/src/stored-metadata-reader-seam.test.ts @@ -36,18 +36,24 @@ const provider = async (plain: string) => `keyed:${plain.length}`; const engineWithProvider = { getKeyedDigest: () => provider }; /** A double of the engine's scoped API: every derived context reads the same store. */ -function scopedApi(seen: { fields: unknown[] } = { fields: [] }): any { +function scopedApi(seen: { fields: unknown[]; reads: number } = { fields: [], reads: 0 }): any { const repo = (name: string) => ({ async find(query?: any) { - seen.fields.push(query?.fields); + seen.reads += 1; + seen.fields.push(query?.searchFields ?? query?.fields); return name.startsWith('sys_metadata') ? [storedRow()] : [{ id: 'n1', metadata: 'ordinary', checksum: STORED_HASH }]; }, async findOne(query?: any) { assertEngineFindOnePredicate(name, query); + seen.reads += 1; return name.startsWith('sys_metadata') ? storedRow() : null; }, - async count() { return 1; }, - async aggregate() { return [{ type: 'datasource', metadata: storedRow().metadata, checksum: STORED_HASH, count: 1 }]; }, + async count() { seen.reads += 1; return 1; }, + async aggregate() { seen.reads += 1; return [{ type: 'datasource', metadata: storedRow().metadata, checksum: STORED_HASH, count: 1 }]; }, + // Write returns: a family row comes back from a write too, and is served. + async insert() { return name.startsWith('sys_metadata') ? storedRow() : { id: 'n1' }; }, + async update() { return name.startsWith('sys_metadata') ? storedRow() : 1; }, + async delete() { return 1; }, }); return { object: repo, @@ -60,6 +66,18 @@ function scopedApi(seen: { fields: unknown[] } = { fields: [] }): any { }; } +/** + * The engine face the seam reads: the keyed-digest provider, plus a `getObject` + * answering a family-shaped field map so the default-`$search` narrowing can + * resolve a searchable set (the body column is a searchable `textarea`, the + * hash a searchable `text` — exactly the columns the narrowing must remove). + */ +const familySchema = { + fields: { name: { type: 'text' }, type: { type: 'text' }, metadata: { type: 'textarea' }, checksum: { type: 'text' } }, + nameField: 'name', +}; +const engineWithProviderAndSchema = { getKeyedDigest: () => provider, getObject: () => familySchema }; + function expectServed(row: any, hash = 'keyed:71'): void { expect(String(row.metadata)).not.toContain(SENTINEL); expect(String(row.metadata)).toContain('unit.example.invalid'); @@ -72,7 +90,8 @@ describe('[#21454] serveStoredMetadataReadsThrough — the reads it serves', () for (const object of ['sys_metadata', 'sys_metadata_history']) { expectServed((await api.object(object).find({ where: {} }))[0]); expectServed(await api.object(object).findOne({ where: { id: 'row_1' } })); - expectServed((await api.object(object).aggregate({ groupBy: ['type', 'metadata', 'checksum'] }))[0]); + // Grouping by a SCALAR column is served; grouping by the body or hash is refused below. + expectServed((await api.object(object).aggregate({ groupBy: ['type'] }))[0]); expect(await api.object(object).count({})).toBe(1); } }); @@ -141,3 +160,107 @@ describe('[#21454] serveStoredMetadataRead — one read, served in the shape it expect(out).toBe(answer); }); }); + +describe('[#21454] the EVALUATE shapes are refused, the way the data door refuses them', () => { + /** The refusal carries the data door's envelope and does NOT run the read. */ + function expectRefused(err: any, param: string, field: string): void { + expect(err?.code).toBe('INVALID_FIELD'); + expect(err?.status).toBe(400); + expect(err?.param).toBe(param); + expect(err?.field).toBe(field); + } + + it('a filter, sort or grouping on the body column, through the served read', async () => { + const seen = { fields: [] as unknown[], reads: 0 }; + const api = serveStoredMetadataReadsThrough(scopedApi(seen), engineWithProvider); + await expect(api.object('sys_metadata').find({ where: { metadata: { $contains: 'z' } } })) + .rejects.toMatchObject({ code: 'INVALID_FIELD', status: 400, param: 'filter', field: 'metadata' }); + await expect(api.object('sys_metadata').find({ orderBy: [{ field: 'metadata', order: 'asc' }] })) + .rejects.toMatchObject({ code: 'INVALID_FIELD', status: 400, param: 'sort', field: 'metadata' }); + await expect(api.object('sys_metadata_history').aggregate({ groupBy: ['metadata'] })) + .rejects.toMatchObject({ code: 'INVALID_FIELD', status: 400, param: 'groupBy', field: 'metadata' }); + // Not one read reached the double: every shape was refused before it ran. + expect(seen.reads).toBe(0); + }); + + it('the array-form filter a direct engine call still honours is refused too (lowered first)', async () => { + const api = serveStoredMetadataReadsThrough(scopedApi(), engineWithProvider); + await expect(api.object('sys_metadata').find({ where: [['metadata', 'contains', 'z']] })) + .rejects.toMatchObject({ code: 'INVALID_FIELD', status: 400, param: 'filter', field: 'metadata' }); + }); + + it('a filter, sort or grouping on a content-hash column', async () => { + const api = serveStoredMetadataReadsThrough(scopedApi(), engineWithProvider); + await expect(api.object('sys_metadata').find({ where: { checksum: 'guess' } })) + .rejects.toMatchObject({ code: 'INVALID_FIELD', status: 400, param: 'filter', field: 'checksum' }); + await expect(api.object('sys_metadata_history').find({ where: { previous_checksum: 'guess' } })) + .rejects.toMatchObject({ code: 'INVALID_FIELD', status: 400, param: 'filter', field: 'previous_checksum' }); + await expect(api.object('sys_metadata').aggregate({ groupBy: [{ field: 'checksum' }] })) + .rejects.toMatchObject({ code: 'INVALID_FIELD', status: 400, param: 'groupBy', field: 'checksum' }); + }); + + it('count with such a predicate — the oracle verb — is refused and never reaches the store', async () => { + const seen = { fields: [] as unknown[], reads: 0 }; + const api = serveStoredMetadataReadsThrough(scopedApi(seen), engineWithProvider); + await expect(api.object('sys_metadata').count({ where: { metadata: { $contains: 'z' } } })) + .rejects.toMatchObject({ code: 'INVALID_FIELD', status: 400, field: 'metadata' }); + await expect(api.object('sys_metadata').count({ where: { checksum: 'guess' } })) + .rejects.toMatchObject({ code: 'INVALID_FIELD', status: 400, field: 'checksum' }); + expect(seen.reads).toBe(0); + }); + + it('an EXPLICIT search field list naming the body or a hash column is refused', async () => { + const api = serveStoredMetadataReadsThrough(scopedApi(), engineWithProvider); + await expect(api.object('sys_metadata').find({ search: 'z', searchFields: ['metadata'] })) + .rejects.toMatchObject({ code: 'INVALID_FIELD', status: 400, param: 'searchFields', field: 'metadata' }); + await expect(api.object('sys_metadata').find({ search: { term: 'z', fields: ['checksum'] } })) + .rejects.toMatchObject({ code: 'INVALID_FIELD', status: 400, param: 'search', field: 'checksum' }); + }); + + it('a scalar-column filter / sort / grouping is NOT refused — only the family columns are', async () => { + const api = serveStoredMetadataReadsThrough(scopedApi(), engineWithProvider); + expectServed((await api.object('sys_metadata').find({ where: { type: 'datasource' }, orderBy: [{ field: 'name' }] }))[0]); + expectServed((await api.object('sys_metadata').aggregate({ groupBy: ['type', 'state'] }))[0]); + expect(await api.object('sys_metadata').count({ where: { type: 'datasource' } })).toBe(1); + }); +}); + +describe('[#21454] a DEFAULT $search is narrowed to the door\'s served set, not refused', () => { + it('the body and hash columns are removed, the read runs with the remaining searchable fields', async () => { + const seen = { fields: [] as unknown[], reads: 0 }; + const api = serveStoredMetadataReadsThrough(scopedApi(seen), engineWithProviderAndSchema); + const [row] = await api.object('sys_metadata').find({ search: 'datasource' }); + // The read ran (a body may search a family table by name), served like the door… + expect(seen.reads).toBe(1); + expect(String(row.metadata)).not.toContain(SENTINEL); + // …and the search was narrowed to name/type — never metadata or checksum. + const searchFields = seen.fields[0] as string[]; + expect(searchFields).toContain('name'); + expect(searchFields).not.toContain('metadata'); + expect(searchFields).not.toContain('checksum'); + }); +}); + +describe('[#21454] what a WRITE verb RETURNS is served — body projected, hash keyed', () => { + it('insert and update returning a family row are served; a count return and a non-family write are untouched', async () => { + const api = serveStoredMetadataReadsThrough(scopedApi(), engineWithProvider); + expectServed(await api.object('sys_metadata').insert({ type: 'datasource' })); + expectServed(await api.object('sys_metadata_history').update({ id: 'row_1' })); + expect(await api.object('sys_metadata').delete({ where: { id: 'row_1' } })).toBe(1); + // A non-family write returns by reference, nothing served. + expect(await api.object('pin_note').insert({ x: 1 })).toEqual({ id: 'n1' }); + }); +}); + +describe('[#21454] the engine action verb is never on a served body\'s surface', () => { + it('serveStoredMetadataReadsThrough exposes no execute, sudo or withRunAs beyond the engine\'s own', () => { + // The seam wraps whatever the context carries; a sandboxed body reaches + // only the VM bridge's verbs (find/findOne/count/aggregate + writes), which + // carries no `execute`. The reach reading: `execute` is recorded unreachable + // from a served body on #21454, not closed here. This pin asserts the seam + // adds no execute of its own to a repository that had none. + const repo = { find: async () => [], count: async () => 0 } as any; + const api = serveStoredMetadataReadsThrough({ object: () => repo }, engineWithProvider); + expect((api.object('sys_metadata') as any).execute).toBeUndefined(); + }); +}); diff --git a/packages/runtime/src/stored-metadata-reader-seam.ts b/packages/runtime/src/stored-metadata-reader-seam.ts index 50b995bdad9..c705ee5f9fc 100644 --- a/packages/runtime/src/stored-metadata-reader-seam.ts +++ b/packages/runtime/src/stored-metadata-reader-seam.ts @@ -287,7 +287,10 @@ function serveRepository(objectName: string, repo: unknown, engine: unknown): un serveStoredMetadataRead(objectName, query, engine, (q) => value.call(target, q, ...rest)); } if (prop === COUNT_READ) { - return (query?: unknown, ...rest: unknown[]) => { + // `async` so a refusal leaves as a rejected promise, the shape every + // other verb's refusal takes — `count` answers a number, nothing to + // serve, so only the evaluate guard runs. + return async (query?: unknown, ...rest: unknown[]) => { refuseOrNarrowStoredMetadataEvaluate(objectName, query, engine); return value.call(target, query, ...rest); }; From 3ec6afdf9e81d65e65172174b61d95fa5ef489c1 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Oct 2026 03:51:45 +0000 Subject: [PATCH 03/11] chore(changeset): reader-context evaluate refusals and write-return serve (#21454) Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_016GiHYRmLSNWTfbX9gVQkpz --- .changeset/21454-reader-context-evaluate-refusals.md | 11 +++++++++++ .../src/stored-metadata-reader-contexts.pin.test.ts | 7 ++++--- 2 files changed, 15 insertions(+), 3 deletions(-) create mode 100644 .changeset/21454-reader-context-evaluate-refusals.md diff --git a/.changeset/21454-reader-context-evaluate-refusals.md b/.changeset/21454-reader-context-evaluate-refusals.md new file mode 100644 index 00000000000..c2c467c6440 --- /dev/null +++ b/.changeset/21454-reader-context-evaluate-refusals.md @@ -0,0 +1,11 @@ +--- +'@objectstack/metadata-protocol': minor +'@objectstack/runtime': patch +--- + +The in-process reader contexts refuse the stored-metadata-body family's EVALUATE shapes and serve what a write returns, the way the generic data door does (#21454). + +Clause-②: yes + +- **`@objectstack/metadata-protocol`** now exports the generic data door's four evaluate-refusal predicates — `storedMetadataBodyGroupingRefusal`, `storedMetadataBodyPredicateRefusal`, `storedMetadataHashEvaluateRefusal` and `storedMetadataSearchRefusal` — so the `@objectstack/runtime` reader-context seam refuses the same shapes through the door's own predicates rather than a second copy. Additive: nothing that imported the package before is changed. +- **`@objectstack/runtime`** extends the stored-metadata reader-context seam (`ctx.api.object(...)` for action and hook bodies, a handler's `ctx.api`, and `ctx.engine.find`): a filter, sort, grouping or search that would evaluate the stored body or content hash of `sys_metadata` / `sys_metadata_history` is refused with the door's `INVALID_FIELD` / 400 before the query runs (a `count` with such a predicate included); a default `$search` is narrowed to the door's served field set rather than refused; and the row a write verb returns is served projected and keyed. The engine's own action verb (`ScopedRepo.execute`) is unreachable from a served body and is left untouched. 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 2764f2297ec..71119b52423 100644 --- a/packages/runtime/src/stored-metadata-reader-contexts.pin.test.ts +++ b/packages/runtime/src/stored-metadata-reader-contexts.pin.test.ts @@ -588,10 +588,11 @@ describe('[#21454] the engine action verb is unreachable from a served 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); - const payload = await readJson(res); + const text = JSON.stringify(await readJson(res) ?? null); // The VM bridge installs only find/findOne/count/aggregate and the writes; // `execute` is not a function the body can call, so no raw scoped context - // is ever handed to a nested action through a served body. - expect(payload?.typeofExecute).toBe('undefined'); + // is ever handed to a nested action through a served body. (Read from the + // response text, envelope-agnostic.) + expect(text).toContain('"typeofExecute":"undefined"'); }); }); From 05fb578221c3bbd30097ca243cde6ff8481d346b Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Oct 2026 03:54:54 +0000 Subject: [PATCH 04/11] test(runtime): fix seam test types (double arity, seen shape, drop unused helper) Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_016GiHYRmLSNWTfbX9gVQkpz --- .../src/stored-metadata-reader-seam.test.ts | 19 +++++++------------ 1 file changed, 7 insertions(+), 12 deletions(-) diff --git a/packages/runtime/src/stored-metadata-reader-seam.test.ts b/packages/runtime/src/stored-metadata-reader-seam.test.ts index 4c8830b6007..5e97e6ce5c5 100644 --- a/packages/runtime/src/stored-metadata-reader-seam.test.ts +++ b/packages/runtime/src/stored-metadata-reader-seam.test.ts @@ -51,9 +51,9 @@ function scopedApi(seen: { fields: unknown[]; reads: number } = { fields: [], re async count() { seen.reads += 1; return 1; }, async aggregate() { seen.reads += 1; return [{ type: 'datasource', metadata: storedRow().metadata, checksum: STORED_HASH, count: 1 }]; }, // Write returns: a family row comes back from a write too, and is served. - async insert() { return name.startsWith('sys_metadata') ? storedRow() : { id: 'n1' }; }, - async update() { return name.startsWith('sys_metadata') ? storedRow() : 1; }, - async delete() { return 1; }, + async insert(_data?: any) { return name.startsWith('sys_metadata') ? storedRow() : { id: 'n1' }; }, + async update(_data?: any, _opts?: any) { return name.startsWith('sys_metadata') ? storedRow() : 1; }, + async delete(_opts?: any) { return 1; }, }); return { object: repo, @@ -124,7 +124,7 @@ describe('[#21454] serveStoredMetadataReadsThrough — the reads it serves', () }); it('a projection naming the body alone reads the type beside it and serves exactly the columns named', async () => { - const seen = { fields: [] as unknown[] }; + const seen = { fields: [] as unknown[], reads: 0 }; const api = serveStoredMetadataReadsThrough(scopedApi(seen), engineWithProvider); const [row] = await api.object('sys_metadata').find({ fields: ['metadata'] }); expect(seen.fields).toEqual([['metadata', 'type']]); @@ -162,13 +162,8 @@ describe('[#21454] serveStoredMetadataRead — one read, served in the shape it }); describe('[#21454] the EVALUATE shapes are refused, the way the data door refuses them', () => { - /** The refusal carries the data door's envelope and does NOT run the read. */ - function expectRefused(err: any, param: string, field: string): void { - expect(err?.code).toBe('INVALID_FIELD'); - expect(err?.status).toBe(400); - expect(err?.param).toBe(param); - expect(err?.field).toBe(field); - } + // Each case asserts the data door's envelope directly (code / status / param / + // field): the predicates are the door's own, so the seam carries them verbatim. it('a filter, sort or grouping on the body column, through the served read', async () => { const seen = { fields: [] as unknown[], reads: 0 }; @@ -260,7 +255,7 @@ describe('[#21454] the engine action verb is never on a served body\'s surface', // from a served body on #21454, not closed here. This pin asserts the seam // adds no execute of its own to a repository that had none. const repo = { find: async () => [], count: async () => 0 } as any; - const api = serveStoredMetadataReadsThrough({ object: () => repo }, engineWithProvider); + const api = serveStoredMetadataReadsThrough({ object: (_name: string) => repo } as any, engineWithProvider); expect((api.object('sys_metadata') as any).execute).toBeUndefined(); }); }); From bc231a8cadbf0f26ee76718d61eabea3e7a1cfa4 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Oct 2026 04:09:54 +0000 Subject: [PATCH 05/11] docs(runtime): name the #21520 write-verb refusal attach point on the seam Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_016GiHYRmLSNWTfbX9gVQkpz --- packages/runtime/src/stored-metadata-reader-seam.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/packages/runtime/src/stored-metadata-reader-seam.ts b/packages/runtime/src/stored-metadata-reader-seam.ts index c705ee5f9fc..93983e0f501 100644 --- a/packages/runtime/src/stored-metadata-reader-seam.ts +++ b/packages/runtime/src/stored-metadata-reader-seam.ts @@ -296,6 +296,13 @@ function serveRepository(objectName: string, repo: unknown, engine: unknown): un }; } if (WRITE_RETURN_VERBS.has(prop)) { + // [#21454] Serve what the write RETURNS. Measured on `main` (pre-#21520): + // an elevated body's family-table write is NOT refused — it runs and + // returns the stored row — so this serve carries real family content. + // [#21520, option A] The write-verb REFUSAL (an elevated body may not + // write a family table at all) attaches HERE, on these same verbs, as a + // throw BEFORE `value.apply` — it needs no reshaping of this branch. This + // seam serves the return and leaves that policy to #21520. return async (...args: unknown[]) => serveStoredMetadataWriteReturn(objectName, await value.apply(target, args), engine); } From 1a6fbf7d93397b6361931631e1cc9a6ed3fadb2c Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Oct 2026 04:27:01 +0000 Subject: [PATCH 06/11] test(runtime): route the seam double's update/delete through engine dispatch predicates; record pinned coverage Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_016GiHYRmLSNWTfbX9gVQkpz --- .../runtime/src/stored-metadata-reader-seam.test.ts | 12 +++++++++--- scripts/engine-double-contract.pinned.json | 10 ++++++++++ 2 files changed, 19 insertions(+), 3 deletions(-) diff --git a/packages/runtime/src/stored-metadata-reader-seam.test.ts b/packages/runtime/src/stored-metadata-reader-seam.test.ts index 5e97e6ce5c5..59ea232dbd7 100644 --- a/packages/runtime/src/stored-metadata-reader-seam.test.ts +++ b/packages/runtime/src/stored-metadata-reader-seam.test.ts @@ -10,7 +10,11 @@ */ import { describe, it, expect } from 'vitest'; -import { assertEngineFindOnePredicate } from '@objectstack/metadata-core'; +import { + assertEngineFindOnePredicate, + assertEngineUpdateDispatch, + assertEngineDeleteDispatch, +} from '@objectstack/metadata-core'; import { ephemeralStoredHashDigest } from '@objectstack/metadata-protocol'; import { serveStoredMetadataRead, serveStoredMetadataReadsThrough } from './stored-metadata-reader-seam.js'; @@ -51,9 +55,11 @@ function scopedApi(seen: { fields: unknown[]; reads: number } = { fields: [], re async count() { seen.reads += 1; return 1; }, async aggregate() { seen.reads += 1; return [{ type: 'datasource', metadata: storedRow().metadata, checksum: STORED_HASH, count: 1 }]; }, // Write returns: a family row comes back from a write too, and is served. + // update/delete route through the engine's own dispatch predicates so this + // double cannot be looser than ObjectQL's (check:engine-double-contract). async insert(_data?: any) { return name.startsWith('sys_metadata') ? storedRow() : { id: 'n1' }; }, - async update(_data?: any, _opts?: any) { return name.startsWith('sys_metadata') ? storedRow() : 1; }, - async delete(_opts?: any) { return 1; }, + async update(data?: any, opts?: any) { assertEngineUpdateDispatch(data, opts); return name.startsWith('sys_metadata') ? storedRow() : 1; }, + async delete(opts?: any) { assertEngineDeleteDispatch(opts); return 1; }, }); return { object: repo, diff --git a/scripts/engine-double-contract.pinned.json b/scripts/engine-double-contract.pinned.json index 30645a03924..caf0b880e35 100644 --- a/scripts/engine-double-contract.pinned.json +++ b/scripts/engine-double-contract.pinned.json @@ -3796,11 +3796,21 @@ "verb": "update", "pinned": 1 }, + { + "file": "packages/runtime/src/stored-metadata-reader-seam.test.ts", + "verb": "delete", + "pinned": 1 + }, { "file": "packages/runtime/src/stored-metadata-reader-seam.test.ts", "verb": "findOne", "pinned": 1 }, + { + "file": "packages/runtime/src/stored-metadata-reader-seam.test.ts", + "verb": "update", + "pinned": 1 + }, { "file": "packages/services/service-automation/src/builtin/crud-bulk-intent.test.ts", "verb": "delete", From 19b2cb6e50e576813431b22928aa1057ce4de0ef Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Oct 2026 06:11:25 +0000 Subject: [PATCH 07/11] chore(changeset): declare the reader-context evaluate refusals as a narrowing (#21454) Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_016GiHYRmLSNWTfbX9gVQkpz --- .changeset/21454-reader-context-evaluate-refusals.md | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/.changeset/21454-reader-context-evaluate-refusals.md b/.changeset/21454-reader-context-evaluate-refusals.md index c2c467c6440..98a174fcae5 100644 --- a/.changeset/21454-reader-context-evaluate-refusals.md +++ b/.changeset/21454-reader-context-evaluate-refusals.md @@ -1,11 +1,15 @@ --- '@objectstack/metadata-protocol': minor -'@objectstack/runtime': patch +'@objectstack/runtime': minor --- -The in-process reader contexts refuse the stored-metadata-body family's EVALUATE shapes and serve what a write returns, the way the generic data door does (#21454). +fix(runtime)!: the in-process reader contexts refuse the stored-metadata-body family's EVALUATE shapes and serve what a write returns, the way the generic data door does (#21454) -Clause-②: yes +Clause-②: yes (narrowing) + + + +**BREAKING**: this narrows what an action or hook body's object API, an action handler's scoped API and an action handler's engine handle accept when they read the two stored-metadata tables. A read there that filters, sorts or groups on the stored body column or on a content-hash column, a read that names one of those columns in an explicit search-field list, and a `count` carrying such a filter, ran before this release and now answer the generic data door's `400 INVALID_FIELD` before the query runs. The route: filter, sort, group and search those tables by their scalar columns (the type, the name, the state and the like), and read the bodies with a plain list, which is served projected — the body as its type's read projection, the content hash in keyed form. A default search with no field list is not refused: it is narrowed to the columns the door serves. Every other column of the two tables, and every other object, is unchanged. It ships as `minor` under the launch-window convention for accept-set narrowings. - **`@objectstack/metadata-protocol`** now exports the generic data door's four evaluate-refusal predicates — `storedMetadataBodyGroupingRefusal`, `storedMetadataBodyPredicateRefusal`, `storedMetadataHashEvaluateRefusal` and `storedMetadataSearchRefusal` — so the `@objectstack/runtime` reader-context seam refuses the same shapes through the door's own predicates rather than a second copy. Additive: nothing that imported the package before is changed. - **`@objectstack/runtime`** extends the stored-metadata reader-context seam (`ctx.api.object(...)` for action and hook bodies, a handler's `ctx.api`, and `ctx.engine.find`): a filter, sort, grouping or search that would evaluate the stored body or content hash of `sys_metadata` / `sys_metadata_history` is refused with the door's `INVALID_FIELD` / 400 before the query runs (a `count` with such a predicate included); a default `$search` is narrowed to the door's served field set rather than refused; and the row a write verb returns is served projected and keyed. The engine's own action verb (`ScopedRepo.execute`) is unreachable from a served body and is left untouched. From 700cfda7cf316d7d133b2aa68236ee841941691e Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Oct 2026 06:21:23 +0000 Subject: [PATCH 08/11] fix(runtime): refuse binding an app-authored hook body to a stored-metadata table A hook body is refused at registration when its target names a table of the stored-metadata family, at hookBodyRunnerFactory: the one point every body hook passes through to become a handler, whichever door bound it (the boot artifact and an installed artifact through bindAppArtifactHandlers, and runtime-authored hooks through the engine's default runner). The refusal carries PERMISSION_DENIED / 403 and names the metadata API. A wildcard body hook still binds, and its body is never run for a family table's event. Platform hooks are code, not bodies, and are untouched. Claude-Session: https://claude.ai/code/session_016GiHYRmLSNWTfbX9gVQkpz Co-authored-by: Claude --- packages/runtime/src/sandbox/body-runner.ts | 37 ++++ .../src/stored-metadata-body-boundary.test.ts | 178 ++++++++++++++++++ .../src/stored-metadata-body-boundary.ts | 111 +++++++++++ 3 files changed, 326 insertions(+) create mode 100644 packages/runtime/src/stored-metadata-body-boundary.test.ts create mode 100644 packages/runtime/src/stored-metadata-body-boundary.ts diff --git a/packages/runtime/src/sandbox/body-runner.ts b/packages/runtime/src/sandbox/body-runner.ts index ac52a5705f4..bd4b9b1e620 100644 --- a/packages/runtime/src/sandbox/body-runner.ts +++ b/packages/runtime/src/sandbox/body-runner.ts @@ -58,6 +58,12 @@ import { resolveRelatedTitleTarget, } from '@objectstack/objectql'; import { serveStoredMetadataReadsThrough } from '../stored-metadata-reader-seam.js'; +import { isStoredMetadataBodyObject } from '@objectstack/spec/kernel'; +import { + isWildcardHookTarget, + storedMetadataBodyHookBindingRefusal, + storedMetadataFamilyTableList, +} from '../stored-metadata-body-boundary.js'; interface FactoryOptions { ql: any; @@ -290,6 +296,20 @@ export function hookBodyRunnerFactory( const raw = (hook as any).body; if (!raw) return undefined; + // [#21520] An app-authored hook BODY may not be bound to a table of the + // stored-metadata family: the metadata protocol is the family's only writer + // for a body. This is the ONE point every body hook passes through to become + // a handler, whichever door bound it — the boot artifact and an installed + // artifact (`bindAppArtifactHandlers`), and runtime-authored hooks + // (ObjectQLPlugin's metadata-service bind, through the engine's default + // runner) — so the refusal is made here, at registration, and never per + // door. Thrown rather than answered `undefined`: the binder records the + // throw against the hook and logs it at `error` (rethrows under `strict`), + // whereas `undefined` would be reported as a missing runner. Platform hooks + // are code, not bodies, and never reach this factory. + const bindingRefusal = storedMetadataBodyHookBindingRefusal(hook as any); + if (bindingRefusal) throw bindingRefusal; + const parsed = HookBodySchema.safeParse(raw); if (!parsed.success) { opts.logger?.warn?.('[BodyRunner] invalid hook.body shape', { @@ -301,7 +321,24 @@ export function hookBodyRunnerFactory( } const body = parsed.data; + // [#21520] A wildcard target names no family table, so it binds — but it + // admits every object, the family's among them, and the boundary is that a + // body never touches those tables. So the body is not run for a family + // table's event (below), and the author is told once, at bind. + if (isWildcardHookTarget((hook as any).object)) { + opts.logger?.info?.( + `[BodyRunner] hook '${hook.name}' targets every object ('*'); its body is never run for the stored-metadata ` + + `tables (${storedMetadataFamilyTableList()}). Change metadata through the metadata API.`, + { appId: opts.appId, hook: hook.name }, + ); + } + return async function boundBodyHandler(engineCtx: any): Promise { + // [#21520] The dispatch-side half of the binding refusal above: whatever + // admitted this event (a wildcard, a global registration), a body does not + // run on a stored-metadata table's event, so it never receives that row + // as its input or writes it back. + if (typeof engineCtx?.object === 'string' && isStoredMetadataBodyObject(engineCtx.object)) return; const sandboxCtx = buildSandboxContext( engineCtx, opts.ql, diff --git a/packages/runtime/src/stored-metadata-body-boundary.test.ts b/packages/runtime/src/stored-metadata-body-boundary.test.ts new file mode 100644 index 00000000000..7e93854fbfb --- /dev/null +++ b/packages/runtime/src/stored-metadata-body-boundary.test.ts @@ -0,0 +1,178 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#21520] The binding half of the stored-metadata family's boundary for + * app-authored bodies: a hook BODY may not be bound to a family table. + * + * Pinned on a REAL `ObjectQL` engine and the real QuickJS sandbox, through each + * door a body hook binds by: + * + * - `bindAppArtifactHandlers` — the boot artifact (`AppPlugin.start`) and the + * install-local plugin's install and rehydrate, which import this binder; + * - the engine's DEFAULT body runner — ObjectQLPlugin's metadata-service + * bind of runtime-authored hooks (`bindHooks(…, { packageId: + * 'metadata-service' })`, no runner of its own). + * + * Both reach `hookBodyRunnerFactory`, where the refusal is made. Each body here + * appends to a neutral `status` field of the input, so whether it RAN is + * observable on the context the engine dispatched. + */ + +import { describe, it, expect } from 'vitest'; +import { ObjectQL, bindHooksToEngine } from '@objectstack/objectql'; +import { STORED_METADATA_BODY_OBJECTS } from '@objectstack/spec/kernel'; +import { bindAppArtifactHandlers } from './app-artifact-handlers.js'; +import { hookBodyRunnerFactory } from './sandbox/body-runner.js'; +import { QuickJSScriptRunner } from './sandbox/quickjs-runner.js'; + +const FAMILY = [...STORED_METADATA_BODY_OBJECTS]; +const ORDINARY = 'boundary_note'; +const APP_ID = 'com.example.boundary'; + +const STAMP = "ctx.input.status = (typeof ctx.input.status === 'string' ? ctx.input.status : '') + 'ran';"; +const body = { language: 'js', source: STAMP }; + +function recordingLogger() { + const errors: Array<{ message: string; meta: any }> = []; + const infos: string[] = []; + return { + errors, + infos, + logger: { + debug() {}, + info(message: string) { infos.push(message); }, + warn() {}, + error(message: string, _err?: unknown, meta?: any) { errors.push({ message, meta }); }, + }, + }; +} + +/** Dispatch one `beforeInsert` on `object` and answer what the bound bodies stamped. */ +async function stampOn(ql: ObjectQL, object: string): Promise { + const ctx: any = { object, event: 'beforeInsert', input: { name: 'probe' }, session: {} }; + await ql.triggerHooks('beforeInsert', ctx); + return ctx.input.status; +} + +function expectBoundaryRefusal(err: any, object: string): void { + expect(err, 'no refusal was raised').toBeInstanceOf(Error); + expect(err.code).toBe('PERMISSION_DENIED'); + expect(err.status).toBe(403); + expect(err.object).toBe(object); + // The ruled prescription: the refusal names the metadata API. + expect(String(err.message)).toContain('/api/v1/meta/'); +} + +describe('[#21520] hookBodyRunnerFactory — the one point a body hook becomes a handler', () => { + const factory = hookBodyRunnerFactory(new QuickJSScriptRunner(), { ql: {}, appId: APP_ID }); + + for (const object of FAMILY) { + it(`refuses, at registration, a body hook whose target is '${object}' (string and list forms)`, () => { + for (const target of [object, [ORDINARY, object]]) { + let thrown: unknown; + try { + factory({ name: 'family_hook', object: target, events: ['beforeInsert'], body } as any); + } catch (err) { + thrown = err; + } + expectBoundaryRefusal(thrown, object); + } + }); + } + + it('binds an ordinary-table body hook as before (control)', () => { + const fn = factory({ name: 'ordinary_hook', object: ORDINARY, events: ['beforeInsert'], body } as any); + expect(typeof fn).toBe('function'); + }); +}); + +describe('[#21520] the boot and install-local door — bindAppArtifactHandlers', () => { + const bundle = { + manifest: { id: APP_ID, version: '0.1.0', type: 'app' }, + objects: [{ name: ORDINARY, fields: {} }], + hooks: [ + { name: 'ordinary_hook', object: ORDINARY, events: ['beforeInsert'], body }, + ...FAMILY.map((object) => ({ name: `family_hook_${object}`, object, events: ['beforeInsert'], body })), + ], + }; + + it('binds the ordinary hook, refuses each family hook, and records the refusal against it', async () => { + const rec = recordingLogger(); + const ql = new ObjectQL({ logger: rec.logger } as any); + bindAppArtifactHandlers(ql as any, bundle, { appId: APP_ID, logger: rec.logger as any }); + + expect(await stampOn(ql, ORDINARY), 'the ordinary hook binds and fires as before').toBe('ran'); + for (const object of FAMILY) { + expect(await stampOn(ql, object), `a body ran on '${object}'`).toBeUndefined(); + const logged = rec.errors.find((e) => e.meta?.hook === `family_hook_${object}`); + expect(logged, `the refusal of the '${object}' hook was not recorded`).toBeDefined(); + expect(String(logged!.meta.error)).toContain('/api/v1/meta/'); + } + }); +}); + +describe('[#21520] the runtime-authored door — the engine default runner', () => { + it('the metadata-service bind refuses a family hook and binds the ordinary one', async () => { + const rec = recordingLogger(); + const ql = new ObjectQL({ logger: rec.logger } as any); + ql.setDefaultBodyRunner( + hookBodyRunnerFactory(new QuickJSScriptRunner(), { ql, logger: rec.logger, appId: 'runtime-authored' }), + ); + ql.bindHooks( + [ + { name: 'authored_ordinary', object: ORDINARY, events: ['beforeInsert'], body }, + ...FAMILY.map((object) => ({ name: `authored_${object}`, object, events: ['beforeInsert'], body })), + ] as any, + { packageId: 'metadata-service' }, + ); + + expect(await stampOn(ql, ORDINARY)).toBe('ran'); + for (const object of FAMILY) { + expect(await stampOn(ql, object), `a body ran on '${object}'`).toBeUndefined(); + expect(rec.errors.some((e) => e.meta?.hook === `authored_${object}`)).toBe(true); + } + }); + + it('under strict binding the refusal is thrown with its envelope', () => { + const ql = new ObjectQL({ logger: recordingLogger().logger } as any); + let thrown: unknown; + try { + bindHooksToEngine(ql, [{ name: 'strict_family', object: FAMILY[0], events: ['beforeInsert'], body }] as any, { + bodyRunner: hookBodyRunnerFactory(new QuickJSScriptRunner(), { ql, appId: APP_ID }), + strict: true, + }); + } catch (err) { + thrown = err; + } + expectBoundaryRefusal(thrown, FAMILY[0]); + }); +}); + +describe('[#21520] a wildcard body hook — binds, and never runs on a family table', () => { + it('runs for an ordinary table, not for a family table, and tells the author once at bind', async () => { + const rec = recordingLogger(); + const ql = new ObjectQL({ logger: rec.logger } as any); + bindAppArtifactHandlers( + ql as any, + { manifest: { id: APP_ID, version: '0.1.0', type: 'app' }, hooks: [{ name: 'every_object', object: '*', events: ['beforeInsert'], body }] }, + { appId: APP_ID, logger: rec.logger as any }, + ); + + expect(await stampOn(ql, ORDINARY)).toBe('ran'); + for (const object of FAMILY) expect(await stampOn(ql, object), `a body ran on '${object}'`).toBeUndefined(); + expect(rec.infos.filter((m) => m.includes("'every_object'") && m.includes("('*')"))).toHaveLength(1); + expect(rec.errors, 'a wildcard is not refused').toEqual([]); + }); +}); + +describe('[#21520] platform hooks are code, outside the boundary', () => { + it('a code-handler hook on a family table still binds and fires', async () => { + const ql = new ObjectQL({ logger: recordingLogger().logger } as any); + const handler = async (ctx: any) => { ctx.input.status = 'code-ran'; }; + bindHooksToEngine(ql, [{ name: 'platform_code_hook', object: FAMILY[0], events: ['beforeInsert'], handler }] as any, { + packageId: 'sys:test', + bodyRunner: hookBodyRunnerFactory(new QuickJSScriptRunner(), { ql, appId: APP_ID }), + }); + expect(await stampOn(ql, FAMILY[0])).toBe('code-ran'); + }); +}); diff --git a/packages/runtime/src/stored-metadata-body-boundary.ts b/packages/runtime/src/stored-metadata-body-boundary.ts new file mode 100644 index 00000000000..62b7caba425 --- /dev/null +++ b/packages/runtime/src/stored-metadata-body-boundary.ts @@ -0,0 +1,111 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#21520] The stored-metadata family's WRITE 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: + * + * - **Binding** a body hook to a family table is refused at registration + * ({@link storedMetadataBodyHookBindingRefusal}, consulted by + * `hookBodyRunnerFactory` — the one point every body hook passes through + * to become a handler, whichever door bound it). + * - **Writing** a family table through a body's `ctx.api` is refused before + * the write runs ({@link storedMetadataBodyWriteRefusal}, consulted by the + * reader-context seam's body layer). + * + * 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. + * + * Both refusals carry 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 + * to a registered synonym. What the author does instead is the prescription, + * carried in the message. + */ + +import { isStoredMetadataBodyObject, STORED_METADATA_BODY_OBJECTS } from '@objectstack/spec/kernel'; + +/** The code and status both refusals carry (ADR-0112 envelope). */ +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. */ +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; + err.code = STORED_METADATA_BODY_BOUNDARY_CODE; + err.status = STORED_METADATA_BODY_BOUNDARY_STATUS; + err.object = object; + err.operation = operation; + return err; +} + +/** The hook target as a list of names (a string target is a list of one). */ +function hookTargets(target: unknown): string[] { + const names = Array.isArray(target) ? target : [target]; + return names.filter((name): name is string => typeof name === 'string'); +} + +/** + * The family tables a hook's `object` target NAMES. The wildcard `'*'` names + * none of them: a wildcard body hook binds, and its body is simply never run + * for a family table's event (see `hookBodyRunnerFactory`). + */ +export function storedMetadataFamilyTargetsOf(target: unknown): string[] { + return hookTargets(target).filter((name) => isStoredMetadataBodyObject(name)); +} + +/** Whether a hook's target admits every object (`'*'`), the family's tables among them. */ +export function isWildcardHookTarget(target: unknown): boolean { + return hookTargets(target).includes('*'); +} + +/** + * The refusal for binding a body hook whose target names a family table, or + * `undefined` when it names none. Thrown by the body runner at registration, + * so the binder records it against the hook and the hook is never registered. + */ +export function storedMetadataBodyHookBindingRefusal(hook: { name?: unknown; object?: unknown }): Error | undefined { + const named = storedMetadataFamilyTargetsOf(hook?.object); + if (named.length === 0) return undefined; + const hookName = typeof hook?.name === 'string' ? hook.name : '(unnamed)'; + return refusal( + `Hook '${hookName}' was not bound: its body targets ${named.map((n) => `'${n}'`).join(', ')}, a table of ` + + 'stored metadata, and an app-authored hook body may not be bound to one.', + named[0], + 'bind', + ); +} + +/** + * The refusal for a sandboxed body's write verb on a family table, or + * `undefined` for any other object. Thrown before the write runs, whatever its + * payload or predicate, so a refused write changes nothing and answers the + * same way whatever it names. + */ +export function storedMetadataBodyWriteRefusal(object: string, verb: string): Error | undefined { + if (!isStoredMetadataBodyObject(object)) return undefined; + return refusal( + `Cannot ${verb} '${object}' from an app-authored body: the write was not run. '${object}' holds stored ` + + 'metadata, and a body may not write it directly.', + object, + verb, + ); +} + +/** 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 99c9a41454b857de33ae9955e6650aa4135fc604 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Oct 2026 06:24:44 +0000 Subject: [PATCH 09/11] wip(runtime): refuse a sandboxed body's write of a stored-metadata table at the seam Claude-Session: https://claude.ai/code/session_016GiHYRmLSNWTfbX9gVQkpz Co-authored-by: Claude --- packages/runtime/src/sandbox/body-runner.ts | 12 +- .../src/stored-metadata-body-writes.test.ts | 190 ++++++++++++++++++ .../src/stored-metadata-reader-seam.ts | 107 ++++++++-- 3 files changed, 291 insertions(+), 18 deletions(-) create mode 100644 packages/runtime/src/stored-metadata-body-writes.test.ts diff --git a/packages/runtime/src/sandbox/body-runner.ts b/packages/runtime/src/sandbox/body-runner.ts index bd4b9b1e620..221d501f364 100644 --- a/packages/runtime/src/sandbox/body-runner.ts +++ b/packages/runtime/src/sandbox/body-runner.ts @@ -57,7 +57,7 @@ import { resolveRecordTitle, resolveRelatedTitleTarget, } from '@objectstack/objectql'; -import { serveStoredMetadataReadsThrough } from '../stored-metadata-reader-seam.js'; +import { refuseStoredMetadataBodyWrites, serveStoredMetadataReadsThrough } from '../stored-metadata-reader-seam.js'; import { isStoredMetadataBodyObject } from '@objectstack/spec/kernel'; import { isWildcardHookTarget, @@ -832,9 +832,17 @@ function buildEngineRepoFacade(ql: any, objectName: string, context?: any) { * place both body faces get their API, so the hook face, the action face and * every fallback below are served alike, and a body can copy only what it was * served. + * + * [#21520] And, layered over that, a body may not WRITE a stored-metadata table + * at all: every write of one is refused before it runs, whatever the body's + * elevation. Applied HERE and nowhere else because this is the one place a + * body gets its API — a host code handler's `ctx.api` is served by the read + * seam but keeps its writes (deployer code, outside the boundary). */ function buildSandboxApi(engineCtx: any, ql: any, errLabel: string) { - return serveStoredMetadataReadsThrough(buildSandboxApiSource(engineCtx, ql, errLabel), ql); + return refuseStoredMetadataBodyWrites( + serveStoredMetadataReadsThrough(buildSandboxApiSource(engineCtx, ql, errLabel), ql), + ); } function buildSandboxApiSource(engineCtx: any, ql: any, errLabel: string) { diff --git a/packages/runtime/src/stored-metadata-body-writes.test.ts b/packages/runtime/src/stored-metadata-body-writes.test.ts new file mode 100644 index 00000000000..8f76bd59d3f --- /dev/null +++ b/packages/runtime/src/stored-metadata-body-writes.test.ts @@ -0,0 +1,190 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#21520] The write half of the stored-metadata family's boundary for + * app-authored bodies: a sandboxed body may not write a family table. + * + * Pinned against a counting scoped-API double (it records which verb reached + * it, and stores nothing), then through the real QuickJS sandbox on both body + * faces. What each case asserts: + * + * - every write verb on each family table is refused with the boundary's + * envelope (`PERMISSION_DENIED` / 403) BEFORE the verb runs; + * - a predicate write is refused the same way whatever its predicate names, + * so a refused write answers identically and runs nothing; + * - reads still pass to the read seam, and every other object writes as + * before; + * - every derived context is refused the same way; + * - the read seam ALONE — a host code handler's `ctx.api` — keeps its writes: + * the boundary refuses bodies only. + */ + +import { describe, it, expect } from 'vitest'; +import { assertEngineUpdateDispatch, assertEngineDeleteDispatch } from '@objectstack/metadata-core'; +import { STORED_METADATA_BODY_OBJECTS } from '@objectstack/spec/kernel'; +import { refuseStoredMetadataBodyWrites, serveStoredMetadataReadsThrough } from './stored-metadata-reader-seam.js'; +import { actionBodyRunnerFactory, hookBodyRunnerFactory } from './sandbox/body-runner.js'; +import { QuickJSScriptRunner } from './sandbox/quickjs-runner.js'; + +const FAMILY = [...STORED_METADATA_BODY_OBJECTS]; +const ORDINARY = 'boundary_note'; +const WRITE_VERBS = ['insert', 'create', 'update', 'updateById', 'upsert', 'delete', 'deleteById', 'updateMany', 'deleteMany']; +const READ_VERBS = ['find', 'findOne', 'count', 'aggregate']; + +/** A scoped-API double that counts `.` calls and answers neutral values. */ +function countingApi(calls: string[] = []): any { + const repo = (name: string) => { + const record = (verb: string) => calls.push(`${name}.${verb}`); + return { + async find() { record('find'); return []; }, + async findOne() { record('findOne'); return null; }, + async count() { record('count'); return 0; }, + async aggregate() { record('aggregate'); return []; }, + async insert() { record('insert'); return { id: 'n1' }; }, + async create() { record('create'); return { id: 'n1' }; }, + async update(data?: any, opts?: any) { assertEngineUpdateDispatch(data, opts); record('update'); return 1; }, + async updateById() { record('updateById'); return { id: 'n1' }; }, + async upsert() { record('upsert'); return { id: 'n1' }; }, + async delete(opts?: any) { assertEngineDeleteDispatch(opts); record('delete'); return 1; }, + async deleteById() { record('deleteById'); return true; }, + async updateMany() { record('updateMany'); return 0; }, + async deleteMany() { record('deleteMany'); return 0; }, + }; + }; + 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 arguments a verb is called with here: a payload, then a by-id or predicate option. */ +const argsOf = (verb: string): unknown[] => + verb === 'update' ? [{ label: 'x' }, { where: { id: 'r1' } }] + : verb === 'delete' ? [{ where: { id: 'r1' } }] + : verb.endsWith('ById') ? ['r1', { label: 'x' }] + : [{ label: 'x' }]; + +function expectRefused(promise: Promise, object: string, verb: string) { + return expect(promise).rejects.toMatchObject({ + code: 'PERMISSION_DENIED', + status: 403, + object, + operation: verb, + message: expect.stringContaining('/api/v1/meta/'), + }); +} + +describe('[#21520] refuseStoredMetadataBodyWrites — every family write is refused before it runs', () => { + for (const object of FAMILY) { + it(`each write verb on '${object}' answers PERMISSION_DENIED / 403 and never reaches the store`, async () => { + const calls: string[] = []; + const api = refuseStoredMetadataBodyWrites(countingApi(calls)); + for (const verb of WRITE_VERBS) { + await expectRefused(api.object(object)[verb](...argsOf(verb)), object, verb); + } + expect(calls).toEqual([]); + }); + } + + it('a predicate write is refused identically whatever its predicate names, and runs nothing', async () => { + const calls: string[] = []; + const api = refuseStoredMetadataBodyWrites(countingApi(calls)); + const answers: unknown[] = []; + for (const where of [{ type: 'view' }, { metadata: 'x' }, { checksum: 'x' }]) { + for (const verb of ['updateMany', 'deleteMany', 'update', 'delete']) { + const args = verb === 'update' ? [{ label: 'x' }, { where, multi: true }] : [{ where, multi: true }]; + const err: any = await api.object(FAMILY[0])[verb](...args).catch((e: unknown) => e); + answers.push(`${verb}:${err?.code}:${err?.status}:${err?.message}`); + } + } + // Three predicates, four verbs: the answer depends on the verb only. + expect(new Set(answers).size).toBe(4); + expect(calls).toEqual([]); + }); + + it('reads on a family table pass through to the read seam beneath it', async () => { + const calls: string[] = []; + const api = refuseStoredMetadataBodyWrites(countingApi(calls)); + for (const verb of READ_VERBS) await api.object(FAMILY[0])[verb]({}); + expect(calls).toEqual(READ_VERBS.map((verb) => `${FAMILY[0]}.${verb}`)); + }); + + it('an ordinary table writes as before (control)', async () => { + const calls: string[] = []; + const api = refuseStoredMetadataBodyWrites(countingApi(calls)); + for (const verb of WRITE_VERBS) await api.object(ORDINARY)[verb](...argsOf(verb)); + expect(calls).toEqual(WRITE_VERBS.map((verb) => `${ORDINARY}.${verb}`)); + }); + + it('every derived context refuses the same way: sudo, withRunAs, transaction(fn), beginTransaction', async () => { + const calls: string[] = []; + const api = refuseStoredMetadataBodyWrites(countingApi(calls)); + await expectRefused(api.sudo().object(FAMILY[0]).insert({ label: 'x' }), FAMILY[0], 'insert'); + await expectRefused(api.withRunAs('system', {}).object(FAMILY[1]).insert({ label: 'x' }), FAMILY[1], 'insert'); + await api.transaction(async (trx: any) => { + await expectRefused(trx.object(FAMILY[0]).updateMany({ where: { type: 'view' } }), FAMILY[0], 'updateMany'); + }); + const begun = await api.beginTransaction(); + expect(begun.handle).toBe('trx_1'); + await expectRefused(begun.ctx.object(FAMILY[0]).delete({ where: { id: 'r1' } }), FAMILY[0], 'delete'); + expect(calls).toEqual([]); + }); + + it('layers over the read seam once: idempotent, and the read seam still sees its own mark', () => { + const served = serveStoredMetadataReadsThrough(countingApi(), {}); + const layered = refuseStoredMetadataBodyWrites(served); + expect(refuseStoredMetadataBodyWrites(layered)).toBe(layered); + expect(serveStoredMetadataReadsThrough(layered, {})).toBe(layered); + }); + + it('the read seam ALONE — a host code handler\'s ctx.api — keeps its family writes (bodies only)', async () => { + const calls: string[] = []; + const api = serveStoredMetadataReadsThrough(countingApi(calls), {}); + await api.object(FAMILY[0]).insert({ label: 'x' }); + expect(calls).toEqual([`${FAMILY[0]}.insert`]); + }); +}); + +describe('[#21520] through the real sandbox — both body faces hold the refusing API', () => { + const runner = new QuickJSScriptRunner({ hookTimeoutMs: 10_000 }); + + it('an action body\'s family write is refused with the envelope; its ordinary write lands', async () => { + const calls: string[] = []; + const factory = actionBodyRunnerFactory(runner, { ql: {}, appId: 'boundary' }); + const handler = factory({ + name: 'writes_family', + type: 'script', + body: { + language: 'js', + capabilities: ['api.write'], + source: `await ctx.api.object('${ORDINARY}').insert({ label: 'x' }); + await ctx.api.object('${FAMILY[0]}').insert({ label: 'x' }); + return { unreachable: true };`, + }, + }); + await expect(handler!({ api: countingApi(calls), params: {} })) + .rejects.toMatchObject({ code: 'PERMISSION_DENIED', status: 403 }); + expect(calls).toEqual([`${ORDINARY}.insert`]); + }); + + it('a hook body on an ordinary table, writing 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_writes_family', + object: ORDINARY, + events: ['afterInsert'], + body: { + language: 'js', + capabilities: ['api.write'], + source: `await ctx.api.object('${FAMILY[1]}').insert({ label: 'x' });`, + }, + } as any); + await expect(handler!({ object: ORDINARY, event: 'afterInsert', input: {}, api: countingApi(calls) })) + .rejects.toMatchObject({ code: 'PERMISSION_DENIED', status: 403 }); + expect(calls).toEqual([]); + }); +}); diff --git a/packages/runtime/src/stored-metadata-reader-seam.ts b/packages/runtime/src/stored-metadata-reader-seam.ts index 93983e0f501..27efc341299 100644 --- a/packages/runtime/src/stored-metadata-reader-seam.ts +++ b/packages/runtime/src/stored-metadata-reader-seam.ts @@ -54,9 +54,15 @@ * form of one row. * 3. **Serve what a WRITE verb RETURNS** ({@link serveStoredMetadataWriteReturn}): * a write whose return carries the family's body or hash is served the same - * projected / keyed way a read is, since a returned row is a serve. Whether a - * body may write the family AT ALL is the write boundary (#21520), not this - * seam: the write itself passes through, only its RETURN is served. + * 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. * * ## What it does not do * @@ -68,6 +74,7 @@ */ import { isStoredMetadataBodyObject } from '@objectstack/spec/kernel'; +import { storedMetadataBodyWriteRefusal } from './stored-metadata-body-boundary.js'; import { isFilterAST, parseFilterAST, resolveSearchFieldResolution } from '@objectstack/spec/data'; import { collectConditionFields } from '@objectstack/plugin-security'; import { @@ -248,8 +255,8 @@ export async function serveStoredMetadataRead( * served — a returned row carrying the family's body or hash is a serve too. * A write whose return is a number (an affected-row count), `null`, or carries * no family column passes through by reference. ⛔ This serves the RETURN only; - * it neither permits nor refuses the write, which is the write boundary's - * question (#21520). + * it neither permits nor refuses the write: a body's write never gets here + * (the body layer, {@link refuseStoredMetadataBodyWrites}, refuses it first). */ async function serveStoredMetadataWriteReturn(object: string, answer: A, engine: unknown): Promise { if (!isStoredMetadataBodyObject(object)) return answer; @@ -296,13 +303,14 @@ function serveRepository(objectName: string, repo: unknown, engine: unknown): un }; } if (WRITE_RETURN_VERBS.has(prop)) { - // [#21454] Serve what the write RETURNS. Measured on `main` (pre-#21520): - // an elevated body's family-table write is NOT refused — it runs and - // returns the stored row — so this serve carries real family content. - // [#21520, option A] The write-verb REFUSAL (an elevated body may not - // write a family table at all) attaches HERE, on these same verbs, as a - // throw BEFORE `value.apply` — it needs no reshaping of this branch. This - // seam serves the return and leaves that policy to #21520. + // [#21454] Serve what the write RETURNS — for the contexts that may + // still write here: a host code handler's `ctx.api` (deployer code, the + // same trust as platform code). A sandboxed BODY never reaches this + // branch for a family table: its API carries the body layer + // ({@link refuseStoredMetadataBodyWrites}, #21520), which refuses the + // write before it gets here. The refusal is not attached HERE because + // this repository is also a host handler's, and the boundary refuses + // bodies only. return async (...args: unknown[]) => serveStoredMetadataWriteReturn(objectName, await value.apply(target, args), engine); } @@ -328,17 +336,84 @@ function serveRepository(objectName: string, repo: unknown, engine: unknown): un * 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)); +} + +/** 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. */ +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 + * `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. + * Refused before the underlying verb is called, so a refused write changes + * nothing and answers the same whatever its payload or predicate names. Any + * other object's repository is returned untouched. + */ +function refuseBodyRepositoryWrites(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 value.bind(target); + return async () => { + throw storedMetadataBodyWriteRefusal(objectName, String(prop)); + }; + }, + }); +} + +/** + * [#21520, ruling A] The scoped API a sandboxed BODY (a hook body or an action + * 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. + * + * Applied at ONE place, the sandbox's `buildSandboxApi`, which only the two 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. + */ +export function refuseStoredMetadataBodyWrites(api: T): T { + return deriveThroughSeam(api, BODY_WRITES_REFUSED, refuseBodyRepositoryWrites); +} + +/** + * The walk both layers share: `api`, and every context it can derive — + * `object(name)`, `sudo()`, `withRunAs(...)`, the context a `transaction(fn)` + * callback receives, and the `ctx` `beginTransaction()` returns — with each + * repository passed through `wrapRepository`. One definition of what a scoped + * API can derive, so neither layer can leave a route around it. + */ +function deriveThroughSeam( + api: T, + marker: symbol, + wrapRepository: (name: string, repo: unknown) => unknown, +): T { if (api === null || typeof api !== 'object') return api; - if ((api as Record)[SERVED_THROUGH_SEAM] === true) return api; - const wrap = (derived: unknown) => serveStoredMetadataReadsThrough(derived, engine); + if ((api as Record)[marker] === true) return api; + const wrap = (derived: unknown) => deriveThroughSeam(derived, marker, wrapRepository); return new Proxy(api as unknown as Record, { get(target, prop) { - if (prop === SERVED_THROUGH_SEAM) return true; + if (prop === marker) return true; const value = Reflect.get(target, prop, target); if (typeof value !== 'function') return value; switch (prop) { case 'object': - return (name: string, ...rest: unknown[]) => serveRepository(name, value.call(target, name, ...rest), engine); + return (name: string, ...rest: unknown[]) => wrapRepository(name, value.call(target, name, ...rest)); case 'sudo': case 'withRunAs': return (...args: unknown[]) => wrap(value.apply(target, args)); From 0d8af06c80d77520343088b6979641869c60c99d Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Oct 2026 06:30:44 +0000 Subject: [PATCH 10/11] test(runtime): composed pins for the stored-metadata body boundary Claude-Session: https://claude.ai/code/session_016GiHYRmLSNWTfbX9gVQkpz Co-authored-by: Claude --- .../stored-metadata-body-boundary.pin.test.ts | 347 ++++++++++++++++++ 1 file changed, 347 insertions(+) create mode 100644 packages/runtime/src/stored-metadata-body-boundary.pin.test.ts diff --git a/packages/runtime/src/stored-metadata-body-boundary.pin.test.ts b/packages/runtime/src/stored-metadata-body-boundary.pin.test.ts new file mode 100644 index 00000000000..9423d04d44d --- /dev/null +++ b/packages/runtime/src/stored-metadata-body-boundary.pin.test.ts @@ -0,0 +1,347 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#21520] The stored-metadata family's boundary for app-authored bodies, end + * to end in a composed kernel: a body may not touch the family's tables + * (`sys_metadata` / `sys_metadata_history`) except by reading them through the + * read seam; changes to metadata go through the metadata API. + * + * Every observation here is a NEUTRAL marker: a hook body appends a fixed + * token to a free-text column (`tags` on `sys_metadata`, `change_note` on its + * history, `status` on the ordinary table), and an action body writes the same + * kind of token. Whether a body ran, or a write landed, is read off that + * column — nothing else of a stored row is read. + * + * ① binding — a body hook targeting a family table (string or list form, in + * the app bundle, or authored at runtime through the metadata door) is not + * bound, so the metadata door's own save does not run it; a wildcard body + * hook binds and is not run for a family table. Controls: the same hooks on + * an ordinary table bind and fire; a platform CODE hook on `sys_metadata` + * still fires on the metadata door's save. + * ② writing — an action body's write of a family table (an insert, and a + * predicate update) answers `403 PERMISSION_DENIED` and lands nothing, for + * the administrator and for a member (the body runs elevated for both). + * Control: the same body's write of an ordinary table lands. + * + * Composition: the in-process kernel `@objectstack/verify`'s `bootStack` mirrors + * (engine, sqlite-wasm default datasource, HTTP server, the app, platform + * objects, auth, security, sharing, REST, dispatcher), requests injected through + * the HTTP app as signed-in users. The boot is paid in `beforeAll`, never inside + * a case. + */ + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { ObjectKernel } from '@objectstack/core'; +import type { Plugin, PluginContext } from '@objectstack/core'; +import { ObjectQLPlugin } from '@objectstack/objectql'; +import type { ObjectQL } from '@objectstack/objectql'; +import { HonoServerPlugin } from '@objectstack/plugin-hono-server'; +import { createRestApiPlugin } from '@objectstack/rest'; +import { AuthPlugin } from '@objectstack/plugin-auth'; +import { SecurityPlugin, appSecurityPluginOptions } from '@objectstack/plugin-security'; +import { SharingServicePlugin } from '@objectstack/plugin-sharing'; +import { PlatformObjectsPlugin } from '@objectstack/platform-objects/plugin'; +import { AppPlugin } from './app-plugin.js'; +import { DefaultDatasourcePlugin } from './default-datasource-plugin.js'; +import { createDispatcherPlugin } from './dispatcher-plugin.js'; + +const BOOT_TIMEOUT = 180_000; +const ORIGIN = 'http://localhost:3000'; +const API = '/api/v1'; +const ADMIN = { email: 'admin@objectos.ai', password: 'admin123' }; +const MEMBER = { email: 'boundary-member@example.invalid', password: 'Member-Pass-123' }; +const ORDINARY = 'boundary_note'; + +const js = (source: string, capabilities: string[] = []) => ({ language: 'js', source, capabilities, timeoutMs: 5000 }); +/** Append `token` to a free-text column of the row being written. */ +const append = (column: string, token: string) => + `ctx.input.${column} = (typeof ctx.input.${column} === 'string' ? ctx.input.${column} : '') + '|${token}';`; + +const PIN_APP: any = { + manifest: { id: 'com.pin.boundary21520', name: 'Body boundary pins', version: '1.0.0' }, + objects: [ + { + name: ORDINARY, + label: 'Boundary note', + fields: { + title: { type: 'text', label: 'Title' }, + status: { type: 'text', label: 'Status' }, + }, + actions: [ + { + name: 'body_inserts_metadata', + label: 'Body inserts a stored-metadata row', + type: 'script', + body: js( + "await ctx.api.object('sys_metadata').insert({ name: 'boundary_body_row', type: 'note', metadata: '{}' });\nreturn { wrote: true };", + ['api.write'], + ), + }, + { + name: 'body_updates_metadata', + label: 'Body updates stored-metadata rows by predicate', + type: 'script', + body: js( + "await ctx.api.object('sys_metadata').update({ tags: '|body-wrote' }, { where: { type: 'action' }, multi: true });\nreturn { wrote: true };", + ['api.write'], + ), + }, + { + name: 'body_inserts_history', + label: 'Body inserts a history row', + type: 'script', + body: js( + "await ctx.api.object('sys_metadata_history').insert({ name: 'boundary_body_row', type: 'note', version: 1, operation_type: 'create', metadata: '{}' });\nreturn { wrote: true };", + ['api.write'], + ), + }, + { + name: 'body_inserts_ordinary', + label: 'Body inserts an ordinary row (control)', + type: 'script', + body: js(`await ctx.api.object('${ORDINARY}').insert({ title: 'body-wrote' });\nreturn { wrote: true };`, ['api.write']), + }, + ], + }, + ], + hooks: [ + { name: 'boundary_hook_on_metadata', object: 'sys_metadata', events: ['beforeInsert', 'beforeUpdate'], body: js(append('tags', 'explicit-ran')) }, + { name: 'boundary_hook_on_history', object: ['sys_metadata_history'], events: ['beforeInsert'], body: js(append('change_note', 'explicit-ran')) }, + { + name: 'boundary_hook_wildcard', + object: '*', + events: ['beforeInsert', 'beforeUpdate'], + body: js( + `if (ctx.object === 'sys_metadata') { ${append('tags', 'wildcard-ran')} }\n` + + `if (ctx.object === '${ORDINARY}') { ${append('status', 'wildcard-ran')} }`, + ), + }, + { name: 'boundary_hook_ordinary', object: ORDINARY, events: ['beforeInsert'], body: js(append('status', 'ordinary-ran')) }, + ], + permissions: [ + { + name: 'boundary_member_default', + label: 'Boundary member default', + isDefault: true, + objects: { [ORDINARY]: { allowRead: true, allowCreate: true } }, + }, + ], +}; + +/** A platform-shaped CODE hook on `sys_metadata` (registered as code, never a body): counts its runs. */ +let platformHookRuns = 0; +const PLATFORM_HOOK_PLUGIN: Plugin = { + name: 'pin.boundary21520.platform-hook', + version: '0.0.0', + init: async () => {}, + start: async (ctx: PluginContext) => { + const ql = ctx.getService('objectql'); + for (const event of ['afterInsert', 'afterUpdate']) { + ql.registerHook(event, async () => { platformHookRuns += 1; }, { object: 'sys_metadata', packageId: 'pin.platform' }); + } + }, +}; + +let kernel: any; +let httpServer: any; +let app: any; +let adminToken: string; +let memberToken: string; +let prevNodeEnv: string | undefined; +/** The metadata door's answer to saving a runtime-authored hook on `sys_metadata` (printed, not asserted). */ +let recordedFamilyHookSaveStatus: number | undefined; + +const req = (path: string, init?: RequestInit) => app.request(`${ORIGIN}${API}${path}`, init); +const as = (token: string | undefined, method: string, path: string, body?: unknown) => + req(path, { + method, + headers: { 'Content-Type': 'application/json', ...(token ? { Authorization: `Bearer ${token}` } : {}) }, + ...(body !== undefined ? { body: JSON.stringify(body) } : {}), + }); + +async function readJson(res: Response): Promise { + const text = await res.text(); + try { return JSON.parse(text); } catch { return text; } +} + +async function engine(): Promise { + return kernel.getServiceAsync('objectql'); +} + +/** One free-text column of the rows matching `where`, read in-process. */ +async function columnOf(object: string, where: Record, column: string): Promise { + const rows: any[] = await (await engine()).find(object, { where, fields: ['id', column], context: { isSystem: true } }); + return rows.map((r) => String(r?.[column] ?? '')); +} + +async function signIn(who: { email: string; password: string }): Promise { + const res = await req('/auth/sign-in/email', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(who), + }); + if (!res.ok) throw new Error(`pin signIn failed: ${res.status}`); + return (await res.json()).token; +} + +async function signUpMember(): Promise { + // Default audience posture is invite_only: enter through a pending invitation. + await (await engine()).insert( + 'sys_invitation', + { + id: 'inv_pin_21520', + email: MEMBER.email, + status: 'pending', + organization_id: 'org_pin_audience_gate', + role: 'member', + inviter_id: 'usr_pin_audience_gate', + expires_at: new Date(Date.now() + 3_600_000), + }, + { context: { isSystem: true } }, + ); + const res = await req('/auth/sign-up/email', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ email: MEMBER.email, password: MEMBER.password, name: 'boundary member' }), + }); + if (!res.ok) throw new Error(`pin signUp failed: ${res.status}`); + return (await res.json()).token; +} + +async function waitFor(predicate: () => Promise, ms = 15_000): Promise { + const until = Date.now() + ms; + while (Date.now() < until) { + if (await predicate()) return true; + await new Promise((r) => setTimeout(r, 100)); + } + return false; +} + +/** Save an `action` item through the metadata door, as the administrator: the platform's own family write. */ +async function saveThroughMetadataDoor(name: string, label: string): Promise { + return as(adminToken, 'PUT', `/meta/action/${name}`, { + name, + label, + objectName: ORDINARY, + type: 'script', + body: js('return { ok: true };'), + }); +} + +beforeAll(async () => { + prevNodeEnv = process.env.NODE_ENV; + process.env.NODE_ENV = 'development'; // the dev-admin seed, as `objectstack dev` / bootStack arm it + + kernel = new ObjectKernel(); + await kernel.use(new ObjectQLPlugin()); + await kernel.use(new DefaultDatasourcePlugin({ driver: 'sqlite-wasm', config: { filename: ':memory:' } })); + await kernel.use(new HonoServerPlugin({ port: 0 })); + await kernel.use(new AppPlugin(PIN_APP)); + await kernel.use(new PlatformObjectsPlugin()); + await kernel.use(new AuthPlugin({ secret: 'body-boundary-21520-secret', autoDefaultOrganization: false })); + await kernel.use(PLATFORM_HOOK_PLUGIN); + await kernel.use(new SecurityPlugin(appSecurityPluginOptions(PIN_APP))); + await kernel.use(new SharingServicePlugin()); + await kernel.use(createRestApiPlugin({})); + await kernel.use(createDispatcherPlugin({})); + await kernel.bootstrap(); + + httpServer = await kernel.getServiceAsync('http-server'); + app = httpServer.getRawApp(); + adminToken = await signIn(ADMIN); + memberToken = await signUpMember(); +}, BOOT_TIMEOUT); + +afterAll(async () => { + console.info(`[#21520 pin] metadata door save of a runtime-authored family-table hook answered: ${recordedFamilyHookSaveStatus}`); + try { await httpServer?.close?.(); } catch { /* best-effort */ } + try { await kernel?.shutdown?.(); } catch { /* best-effort */ } + if (prevNodeEnv === undefined) delete process.env.NODE_ENV; + else process.env.NODE_ENV = prevNodeEnv; +}, 60_000); + +describe('[#21520] ① binding — a body hook targeting a family table is not bound', () => { + it('the metadata door\'s save runs no body bound to a family table, and still fires the platform code hook', async () => { + const before = platformHookRuns; + const res = await saveThroughMetadataDoor('boundary_saved_one', 'Saved once'); + expect(res.status, JSON.stringify(await readJson(res))).toBeLessThan(300); + + const tags = await columnOf('sys_metadata', { type: 'action', name: 'boundary_saved_one' }, 'tags'); + expect(tags.length, 'the metadata door stored no row').toBeGreaterThan(0); + for (const value of tags) { + expect(value, 'an explicitly-bound body ran on the save').not.toContain('explicit-ran'); + expect(value, 'a wildcard body ran on the save').not.toContain('wildcard-ran'); + } + const notes = await columnOf('sys_metadata_history', { type: 'action', name: 'boundary_saved_one' }, 'change_note'); + for (const value of notes) expect(value, 'a list-form body ran on the history row').not.toContain('explicit-ran'); + + expect(platformHookRuns, 'the platform code hook did not fire on the save').toBeGreaterThan(before); + }); + + it('control: the same app\'s hooks on an ordinary table bind and fire, the wildcard among them', async () => { + const res = await as(adminToken, 'POST', `/data/${ORDINARY}`, { title: 'boundary-control' }); + expect(res.status).toBeLessThan(300); + const [status] = await columnOf(ORDINARY, { title: 'boundary-control' }, 'status'); + expect(status).toContain('ordinary-ran'); + expect(status).toContain('wildcard-ran'); + }); + + it('a hook authored at runtime through the metadata door is not bound to a family table; an ordinary one is', async () => { + const familyHook = await as(adminToken, 'PUT', '/meta/hook/boundary_authored_on_metadata', { + name: 'boundary_authored_on_metadata', + object: 'sys_metadata', + events: ['beforeInsert', 'beforeUpdate'], + body: js(append('tags', 'authored-ran')), + }); + const ordinaryHook = await as(adminToken, 'PUT', '/meta/hook/boundary_authored_ordinary', { + name: 'boundary_authored_ordinary', + object: ORDINARY, + events: ['beforeInsert'], + body: js(append('status', 'authored-ran')), + }); + expect(ordinaryHook.status, JSON.stringify(await readJson(ordinaryHook))).toBeLessThan(300); + // Recorded, not asserted: whether the metadata door accepts the family hook + // at save is the save door's question; this boundary refuses it at bind. + recordedFamilyHookSaveStatus = familyHook.status; + + // The resync has bound the ordinary authored hook once it fires… + const bound = await waitFor(async () => { + await as(adminToken, 'POST', `/data/${ORDINARY}`, { title: 'boundary-authored-probe' }); + const statuses = await columnOf(ORDINARY, { title: 'boundary-authored-probe' }, 'status'); + return statuses.some((s) => s.includes('authored-ran')); + }); + expect(bound, 'the runtime-authored ordinary hook never bound').toBe(true); + + // …and by then the family one, had it bound, would run on this save. + const res = await saveThroughMetadataDoor('boundary_saved_two', 'Saved after the authored hooks'); + expect(res.status).toBeLessThan(300); + for (const value of await columnOf('sys_metadata', { type: 'action', name: 'boundary_saved_two' }, 'tags')) { + expect(value, 'a runtime-authored body ran on the save').not.toContain('authored-ran'); + } + }, 30_000); +}); + +describe('[#21520] ② writing — an action body may not write a family table', () => { + for (const [role, token] of [['administrator', () => adminToken], ['member', () => memberToken]] as const) { + it(`invoked by the ${role}: each family write answers 403 PERMISSION_DENIED and lands nothing`, async () => { + for (const action of ['body_inserts_metadata', 'body_updates_metadata', 'body_inserts_history']) { + const res = await as(token(), 'POST', `/actions/${ORDINARY}/${action}`, { params: {} }); + const payload = await readJson(res); + expect(res.status, `${action}: ${JSON.stringify(payload)}`).toBe(403); + expect(payload?.error?.code ?? payload?.code, action).toBe('PERMISSION_DENIED'); + } + expect(await columnOf('sys_metadata', { name: 'boundary_body_row' }, 'name')).toEqual([]); + expect(await columnOf('sys_metadata_history', { name: 'boundary_body_row' }, 'name')).toEqual([]); + for (const value of await columnOf('sys_metadata', { type: 'action' }, 'tags')) { + expect(value, 'the predicate update landed').not.toContain('body-wrote'); + } + }); + + it(`invoked by the ${role}: the same body's write of an ordinary table lands (control)`, async () => { + const before = (await columnOf(ORDINARY, { title: 'body-wrote' }, 'title')).length; + const res = await as(token(), 'POST', `/actions/${ORDINARY}/body_inserts_ordinary`, { params: {} }); + expect(res.status, JSON.stringify(await readJson(res))).toBe(200); + expect((await columnOf(ORDINARY, { title: 'body-wrote' }, 'title')).length).toBe(before + 1); + }); + } +}); From d111f4922427918026fbee70afbb949e2b2ca54f Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Oct 2026 06:35:17 +0000 Subject: [PATCH 11/11] chore(changeset): declare the stored-metadata body boundary as a narrowing Claude-Session: https://claude.ai/code/session_016GiHYRmLSNWTfbX9gVQkpz Co-authored-by: Claude --- .changeset/21520-body-family-boundary.md | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) create mode 100644 .changeset/21520-body-family-boundary.md diff --git a/.changeset/21520-body-family-boundary.md b/.changeset/21520-body-family-boundary.md new file mode 100644 index 00000000000..227743874d9 --- /dev/null +++ b/.changeset/21520-body-family-boundary.md @@ -0,0 +1,17 @@ +--- +'@objectstack/runtime': minor +--- + +fix(runtime)!: an app-authored body may not bind a hook to, or write, the stored-metadata tables (#21520) + +Clause-②: yes (narrowing) + + + +**BREAKING**: this narrows what an app-authored body may do with the two stored-metadata tables, `sys_metadata` and `sys_metadata_history`. For an app-authored body, the metadata protocol is now their only writer: a change to metadata goes through the metadata API, where it is validated and its provenance is recorded. + +- **Binding.** A hook with a sandboxed `body` whose `object` names either table, alone or in a list, is no longer bound. The refusal is made at registration, at the one point every body hook becomes a handler, so it holds on every door a hook binds by: a code bundle or boot artifact, an installed artifact, and a hook authored at runtime through the metadata door. It carries `PERMISSION_DENIED` / 403, names the metadata API, and is recorded against the hook in the bind log at `error` (thrown under strict binding). A wildcard (`'*'`) body hook still binds; its body is not run for either table's events, and the bind says so once at `info`. +- **Writing.** A sandboxed action or hook body's write of either table through `ctx.api` — every write verb, inside a transaction or not, with or without elevation — answers `PERMISSION_DENIED` / 403 before the write runs, so nothing lands and the answer does not depend on what the write names. +- **Unchanged:** a body's reads of the two tables (still served as the generic data door serves them); host code that registers its own action handlers or hooks; the platform's own hooks, which are code and still fire on the metadata door's save; and every other object. + +The route: change metadata through the metadata API (`PUT /api/v1/meta/:type/:name`) rather than from a body, and bind hooks to the objects an app owns. No shipped example binds a body hook to either table or writes one from a body. It ships as `minor` under the launch-window convention for accept-set narrowings.