diff --git a/.changeset/21520-body-family-boundary.md b/.changeset/21520-body-family-boundary.md new file mode 100644 index 0000000000..227743874d --- /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. diff --git a/packages/runtime/src/sandbox/body-runner.ts b/packages/runtime/src/sandbox/body-runner.ts index ac52a5705f..221d501f36 100644 --- a/packages/runtime/src/sandbox/body-runner.ts +++ b/packages/runtime/src/sandbox/body-runner.ts @@ -57,7 +57,13 @@ import { resolveRecordTitle, resolveRelatedTitleTarget, } from '@objectstack/objectql'; -import { serveStoredMetadataReadsThrough } from '../stored-metadata-reader-seam.js'; +import { refuseStoredMetadataBodyWrites, serveStoredMetadataReadsThrough } from '../stored-metadata-reader-seam.js'; +import { isStoredMetadataBodyObject } from '@objectstack/spec/kernel'; +import { + isWildcardHookTarget, + storedMetadataBodyHookBindingRefusal, + storedMetadataFamilyTableList, +} from '../stored-metadata-body-boundary.js'; interface FactoryOptions { ql: any; @@ -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, @@ -795,9 +832,17 @@ function buildEngineRepoFacade(ql: any, objectName: string, context?: any) { * place both body faces get their API, so the hook face, the action face and * every fallback below are served alike, and a body can copy only what it was * served. + * + * [#21520] And, layered over that, a body may not WRITE a stored-metadata table + * at all: every write of one is refused before it runs, whatever the body's + * elevation. Applied HERE and nowhere else because this is the one place a + * body gets its API — a host code handler's `ctx.api` is served by the read + * seam but keeps its writes (deployer code, outside the boundary). */ function buildSandboxApi(engineCtx: any, ql: any, errLabel: string) { - return serveStoredMetadataReadsThrough(buildSandboxApiSource(engineCtx, ql, errLabel), ql); + return refuseStoredMetadataBodyWrites( + serveStoredMetadataReadsThrough(buildSandboxApiSource(engineCtx, ql, errLabel), ql), + ); } function buildSandboxApiSource(engineCtx: any, ql: any, errLabel: string) { 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 0000000000..9423d04d44 --- /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); + }); + } +}); 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 0000000000..7e93854fbf --- /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 0000000000..62b7caba42 --- /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(', '); +} 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 0000000000..8f76bd59d3 --- /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 93983e0f50..27efc34129 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));