diff --git a/.changeset/21454-reader-context-family-serve.md b/.changeset/21454-reader-context-family-serve.md new file mode 100644 index 00000000000..f99c1295aac --- /dev/null +++ b/.changeset/21454-reader-context-family-serve.md @@ -0,0 +1,24 @@ +--- +'@objectstack/runtime': patch +'@objectstack/metadata-protocol': minor +--- + +fix(runtime): a sandboxed body or an action handler that reads the stored-metadata tables is served what the generic data door serves (#21454) + +Clause-②: yes + +The two stored-metadata tables (the current metadata bodies and their version history) hold each body as stored, credential material included, and a content hash computed over it. The generic data door serves such a row with the body as its type's read projection, with the stored credential material withheld, and the hash in keyed form. Three in-process reader contexts served the same rows as stored: + +- a sandboxed action or hook body that reads through `ctx.api.object(...)`, inside `ctx.api.transaction(...)` too; +- an action handler that reads through `ctx.engine.find(...)`; +- an action handler that reads through `ctx.api.object(...)`. + +An action body and an action handler run elevated, so the stored form reached whoever could invoke the action, a member included. + +**What changes.** A read of either table through any of these contexts now answers the data door's form: the projected body, and the content hash under the same key the data door uses. That key is the crypto provider's, or the process-scoped ephemeral key when no provider is registered. `find`, `findOne` and `aggregate` are served this way, and so is every context the scoped API derives: `sudo()`, `withRunAs(...)`, a `transaction(...)` callback's context, and the context `beginTransaction()` returns. A hook body that copies what it read into another record can now copy only the projected form. A projection that names the body column without the type column reads the type beside it and drops it again, as on the data door. + +**What does not change.** Every other object, every write and `count` behave as before. The platform's own readers of these tables still read the stored form, because the projection is applied at the reader contexts and not in the engine. + +`@objectstack/metadata-protocol` now exports the data door's stored-row serve, so these contexts consume it and keep no copy: `storedMetadataBodyProjection`, `redactStoredMetadataRows`, `serveStoredMetadataHashColumnRows`, `ephemeralStoredHashDigest` and the `StoredHashDigest` type. The exports are additive. + +The four functions `storedMetadataBodyProjection`, `redactStoredMetadataRows`, `serveStoredMetadataHashColumnRows` and `ephemeralStoredHashDigest`, and the type `StoredHashDigest`, are new public API of `@objectstack/metadata-protocol`, and `@objectstack/runtime` consumes them. diff --git a/packages/metadata-protocol/src/index.ts b/packages/metadata-protocol/src/index.ts index ae8e39db97f..2bdf109ff4d 100644 --- a/packages/metadata-protocol/src/index.ts +++ b/packages/metadata-protocol/src/index.ts @@ -190,6 +190,23 @@ export { redactMetadataItems, } from './metadata-redaction.js'; +// [#21454] The generic data door's serve of a stored-metadata ROW: the body +// projected (`storedMetadataBodyProjection` plus this package's +// `redactStoredMetadataRows`, the `dropType` wrinkle included) and the stored +// content hash keyed (`serveStoredMetadataHashColumnRows`, under the crypto +// provider's digest or, while none is registered, `ephemeralStoredHashDigest`, +// this module's ONE process-scoped key). Exported so the in-process reader +// contexts in `@objectstack/runtime` serve the family exactly as the door does +// by consuming these functions, never a copy: a second ephemeral key would +// serve a second keyed form of the same row. +export { + ephemeralStoredHashDigest, + redactStoredMetadataRows, + serveStoredMetadataHashColumnRows, + storedMetadataBodyProjection, +} from './metadata-redaction.js'; +export type { StoredHashDigest } 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/action-execution.ts b/packages/runtime/src/action-execution.ts index 932e00d2897..6f9d7095fad 100644 --- a/packages/runtime/src/action-execution.ts +++ b/packages/runtime/src/action-execution.ts @@ -49,6 +49,7 @@ import type { FlowRunSummary } from '@objectstack/spec/automation'; // nothing on an assembly where the protocol plugin is absent, which is exactly // when the fallback runs. import { recordNotFoundError } from '@objectstack/metadata-protocol'; +import { serveStoredMetadataRead, serveStoredMetadataReadsThrough } from './stored-metadata-reader-seam.js'; import { actorUserFromExecutionContext, resolveActorDisplayName } from './security/actor-user.js'; import type { HttpProtocolContext } from './http-dispatcher.js'; import { @@ -1496,16 +1497,22 @@ export function buildActionExecutionContext(ec: any): Record { * via `engine.createContext()`, which the action path never called), so the * facade proxied every call context-less. Returns `undefined` when the engine * predates `createContext`, leaving the sandbox's own fallback in charge. + * + * [#21454] Served through the stored-metadata reader seam + * (`stored-metadata-reader-seam.ts`): this context is elevated, and it is the + * `ctx.api` a host code handler receives as well as an action body, so a read + * of the stored-metadata-body family answers the generic data door's form + * (the body projected, the content hash keyed) and never the stored row. */ export function buildActionApi(_deps: ActionExecutionDeps, ql: any, ec: any): any | undefined { if (!ql || typeof ql.createContext !== 'function') return undefined; try { - return ql.createContext(buildActionExecutionContext(ec)); + return serveStoredMetadataReadsThrough(ql.createContext(buildActionExecutionContext(ec)), ql); } catch { // A malformed caller envelope must not sink the action — fall back to // the bare elevated context (the same shape hooks default to). try { - return ql.createContext({ isSystem: true }); + return serveStoredMetadataReadsThrough(ql.createContext({ isSystem: true }), ql); } catch { return undefined; } @@ -1677,8 +1684,14 @@ export function buildActionEngineFacade(_deps: ActionExecutionDeps, ql: any, ec? // (`assertActionEngineFindEnvelope` above says why the engine's own // check cannot be the whole of it). assertActionEngineFindEnvelope(object, query); - const rows = await ql.find(object, { ...(query ?? {}), context } as any); - return Array.isArray(rows) ? rows : ((rows as any)?.value ?? []); + // [#21454] The answer is served through the stored-metadata reader + // seam: this read is elevated, so a read of the stored-metadata-body + // family answers the generic data door's form (the body projected, + // the content hash keyed), never the stored row. + return serveStoredMetadataRead(object, query, ql, async (q) => { + const rows = await ql.find(object, { ...((q as Record | undefined) ?? {}), context } as any); + return Array.isArray(rows) ? rows : ((rows as any)?.value ?? []); + }); }, }; } diff --git a/packages/runtime/src/sandbox/body-runner.ts b/packages/runtime/src/sandbox/body-runner.ts index 1e79101baf1..ac52a5705f4 100644 --- a/packages/runtime/src/sandbox/body-runner.ts +++ b/packages/runtime/src/sandbox/body-runner.ts @@ -57,6 +57,7 @@ import { resolveRecordTitle, resolveRelatedTitleTarget, } from '@objectstack/objectql'; +import { serveStoredMetadataReadsThrough } from '../stored-metadata-reader-seam.js'; interface FactoryOptions { ql: any; @@ -785,7 +786,21 @@ function buildEngineRepoFacade(ql: any, objectName: string, context?: any) { }; } +/** + * The `ctx.api` a sandboxed body (hook or action) reads and writes through. + * + * [#21454] Served through the stored-metadata reader seam: a read of the + * stored-metadata-body family answers the generic data door's form (the body + * projected, the content hash keyed), never the stored row. This is the one + * place both body faces get their API, so the hook face, the action face and + * every fallback below are served alike, and a body can copy only what it was + * served. + */ function buildSandboxApi(engineCtx: any, ql: any, errLabel: string) { + return serveStoredMetadataReadsThrough(buildSandboxApiSource(engineCtx, ql, errLabel), ql); +} + +function buildSandboxApiSource(engineCtx: any, ql: any, errLabel: string) { const engineApi = engineCtx?.api; if (engineApi && typeof engineApi.object === 'function') return engineApi; // [#3914] The host's own execution envelope, when it supplied one. Hooks get diff --git a/packages/runtime/src/stored-metadata-reader-contexts.pin.test.ts b/packages/runtime/src/stored-metadata-reader-contexts.pin.test.ts new file mode 100644 index 00000000000..e017693e816 --- /dev/null +++ b/packages/runtime/src/stored-metadata-reader-contexts.pin.test.ts @@ -0,0 +1,463 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#21454] The in-process reader contexts serve the stored-metadata-body family + * (`sys_metadata` / `sys_metadata_history`: the `metadata` body column and the + * `checksum` content-hash column) the way the generic data door serves it: the + * body as its type's read projection, the hash in keyed form. + * + * Each context reads the SAME stored row the control reads through the data + * door, and each answer is judged against that control, not against a fixed + * shape: no stored credential and no stored hash anywhere in the answer, the + * row's non-credential configuration present (the projection of THIS row, not + * a withheld one), and the served hash equal to the one the door serves for the + * same row (the door's keyed form, under the door's own key). + * + * Contexts, each through the door a deployment exposes: + * ① a sandboxed body's object API, `ctx.api.object(...)` + * (`sandbox/body-runner.ts`, `buildSandboxApi`): + * an action body, dispatched by the REST `/actions` door, read plainly + * and inside `ctx.api.transaction`; + * a hook body fired by a data-door insert, which COPIES what it read into + * an ordinary record (the copy exit: only projected content lands); + * an action body authored at runtime through the `/meta` door; + * ② an action handler's engine handle, `ctx.engine.find` + * (`buildActionEngineFacade`), host code registered with `registerAction`; + * ③ the same handler's `ctx.api` (`buildActionApi`), the scoped context an + * action body receives too. + * The action contexts run elevated, so a member invoking one is served what an + * administrator is: both are pinned. + * + * Composition: an in-process `ObjectKernel` assembled from the plugins, in the + * order, `@objectstack/verify`'s `bootStack` uses (it mirrors `objectstack dev` + * / `serve`): engine, declared default datasource (sqlite-wasm), HTTP server, + * the app, platform objects, auth, security, sharing, REST and the dispatcher, + * with requests injected through the HTTP app, signed in as real users. Two of + * `bootStack`'s plugins are absent because this package does not depend on + * them: the settings service (the crypto provider, so the keyed hash runs on the + * process-scoped ephemeral key the family declares for that case) and + * analytics (on no path here). The boot is paid in `beforeAll`, never inside a + * case. + * + * The credential is a synthetic sentinel, stored by the production writer + * (`PUT /meta/datasource/:name`, as the administrator) in the one credential + * slot the datasource write door still admits. + */ + +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: 'pin-member@example.invalid', password: 'Member-Pass-123' }; + +/** The synthetic credential, stored in the datasource body's still-writable credential slot. */ +const SENTINEL = 'reader-seam-sentinel-6c1e9b'; +const DS_NAME = 'pin_ds'; +/** Non-credential configuration of the same body: present in a projection of THIS row. */ +const DS_HOST = 'pin.example.invalid'; + +const readFamilySource = (object: string) => + `const rows = await ctx.api.object('${object}').find({ where: { type: 'datasource', name: '${DS_NAME}' } });`; + +const actionBody = (source: string, capabilities: string[] = ['api.read']) => ({ + language: 'js', + source, + capabilities, + timeoutMs: 5000, +}); + +const PIN_APP: any = { + manifest: { id: 'com.pin.reader21454', name: 'Reader seam pins', version: '1.0.0' }, + objects: [ + { + name: 'pin_note', + label: 'Pin note', + fields: { + title: { type: 'text', label: 'Title' }, + observed: { type: 'textarea', label: 'Observed' }, + }, + actions: [ + // ① sandboxed action bodies. + { + name: 'body_reads_family', + label: 'Body read', + type: 'script', + body: actionBody(`${readFamilySource('sys_metadata')}\nreturn { rows };`), + }, + { + name: 'body_reads_history', + label: 'Body history read', + type: 'script', + body: actionBody(`${readFamilySource('sys_metadata_history')}\nreturn { rows };`), + }, + { + name: 'body_reads_family_in_transaction', + label: 'Body transaction read', + type: 'script', + body: actionBody( + `let rows;\nawait ctx.api.transaction(async () => {\n rows = await ctx.api.object('sys_metadata').find({ where: { type: 'datasource', name: '${DS_NAME}' } });\n});\nreturn { rows };`, + ['api.read', 'api.transaction'], + ), + }, + // ② / ③ declared here; their handlers are code registered by PIN_HANDLER_PLUGIN. + { 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' }, + ], + }, + ], + hooks: [ + // ① a sandboxed hook body: it COPIES what it read onto the row being inserted. + { + name: 'hook_copies_family', + object: 'pin_note', + events: ['beforeInsert'], + body: actionBody( + `if (ctx.input.title !== 'pin-hook') return;\n${readFamilySource('sys_metadata')}\nctx.input.observed = JSON.stringify(rows);`, + ), + }, + ], + permissions: [ + { + name: 'pin_member_default', + label: 'Pin member default', + isDefault: true, + objects: { pin_note: { allowRead: true, allowCreate: true } }, + }, + ], +}; + +/** ② / ③ — host code registering action handlers, the way a plugin does. */ +const PIN_HANDLER_PLUGIN: Plugin = { + name: 'pin.reader21454.handler', + version: '0.0.0', + init: async () => {}, + start: async (ctx: PluginContext) => { + const ql = ctx.getService('objectql'); + const where = { type: 'datasource', name: DS_NAME }; + for (const [action, object] of [ + ['handler_engine_reads_family', 'sys_metadata'], + ['handler_engine_reads_history', 'sys_metadata_history'], + ] as const) { + ql.registerAction( + 'pin_note', + action, + async (actionCtx: any) => ({ rows: await actionCtx.engine.find(object, { where }) }), + 'pin.reader21454.handler', + ); + } + ql.registerAction( + 'pin_note', + 'handler_api_reads_family', + async (actionCtx: any) => ({ rows: await actionCtx.api.object('sys_metadata').find({ where }) }), + 'pin.reader21454.handler', + ); + }, +}; + +let kernel: any; +let httpServer: any; +let app: any; +let adminToken: string; +let memberToken: string; +let storedRow: Record; +/** Every stored content hash of the pin row, both tables (`checksum`, `previous_checksum`). */ +const storedHashes = new Set(); +/** The data door's served (keyed) `checksum`, by row id, both tables: the reference form. */ +const doorServedChecksum = new Map(); +let prevNodeEnv: string | 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; + } +} + +/** Every object anywhere in `value` that carries a `metadata` key: a family row as served. */ +function familyRowsIn(value: unknown, out: Array> = []): Array> { + if (Array.isArray(value)) { + for (const v of value) familyRowsIn(v, out); + } else if (value && typeof value === 'object') { + const obj = value as Record; + if ('metadata' in obj && ('checksum' in obj || 'type' in obj)) out.push(obj); + for (const v of Object.values(obj)) familyRowsIn(v, out); + } + return out; +} + +/** + * The pin: `payload` serves the family the way the data door does. Stated per + * property so a failure names the half that regressed. + */ +function expectServedLikeTheDoor(label: string, payload: unknown): void { + const text = JSON.stringify(payload ?? null); + expect(text.includes(SENTINEL), `${label}: the stored credential reached the answer`).toBe(false); + const storedHashesServed = [...storedHashes].filter((h) => text.includes(h)); + expect(storedHashesServed, `${label}: a stored content hash reached the answer`).toEqual([]); + const rows = familyRowsIn(payload); + expect(rows.length, `${label}: no family row in the answer at all`).toBeGreaterThan(0); + for (const row of rows) { + expect(String(row.metadata), `${label}: the body is not the projection of this row`).toContain(DS_HOST); + expect(typeof row.checksum, `${label}: the content hash was not served`).toBe('string'); + const reference = doorServedChecksum.get(row.id); + expect(reference, `${label}: the door served no hash for this row to compare with`).toBeDefined(); + expect(row.checksum, `${label}: the hash is not the door's keyed form for this row`).toBe(reference); + } +} + +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} ${await res.text()}`); + const data: any = await res.json(); + return data.token; +} + +async function signUpMember(): Promise { + // Default audience posture is invite_only: enter through a pending invitation, + // the same lane `@objectstack/verify`'s signUp takes. + const engine: any = await kernel.getServiceAsync('objectql'); + await engine.insert( + 'sys_invitation', + { + id: 'inv_pin_21454', + 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: 'pin member' }), + }); + if (!res.ok) throw new Error(`pin signUp failed: ${res.status} ${await res.text()}`); + const data: any = await res.json(); + return data.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; +} + +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: 'reader-seam-21454-secret', autoDefaultOrganization: false })); + await kernel.use(PIN_HANDLER_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(); + + // The stored credential, written by the production writer as the administrator. + const save = await as(adminToken, 'PUT', `/meta/datasource/${DS_NAME}`, { + name: DS_NAME, + label: 'Pin datasource', + driver: 'turso', + config: { url: `libsql://${DS_HOST}`, encryptionKey: SENTINEL }, + }); + if (save.status >= 300) throw new Error(`pin seed refused: ${save.status} ${JSON.stringify(await readJson(save))}`); + + // The STORED form, read in-process (not a door): what no answer may carry. + const engine: any = await kernel.getServiceAsync('objectql'); + const where = { type: 'datasource', name: DS_NAME }; + const rows: any[] = await engine.find('sys_metadata', { where, context: { isSystem: true } }); + const history: any[] = await engine.find('sys_metadata_history', { where, context: { isSystem: true } }); + storedRow = rows[0]; + for (const row of [...rows, ...history]) { + for (const column of ['checksum', 'previous_checksum']) { + if (typeof row?.[column] === 'string' && row[column].length > 0) storedHashes.add(row[column]); + } + } + + // The REFERENCE form: the same rows through the data door, as the administrator. + for (const object of ['sys_metadata', 'sys_metadata_history']) { + const res = await as(adminToken, 'GET', `/data/${object}?type=datasource&name=${DS_NAME}`); + for (const row of familyRowsIn(await readJson(res))) { + if (typeof row.id === 'string' && typeof row.checksum === 'string') doorServedChecksum.set(row.id, row.checksum); + } + } +}, BOOT_TIMEOUT); + +afterAll(async () => { + 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('[#21454] precondition — the fixture stores what the family protects', () => { + it('the stored rows carry the credential in the body and a content hash, in both tables', async () => { + expect(storedRow, 'the datasource row exists in sys_metadata').toBeDefined(); + expect(String(storedRow.metadata)).toContain(SENTINEL); + expect(typeof storedRow.checksum).toBe('string'); + const engine: any = await kernel.getServiceAsync('objectql'); + const history: any[] = await engine.find('sys_metadata_history', { + where: { type: 'datasource', name: DS_NAME }, + context: { isSystem: true }, + }); + expect(history.some((h) => String(h.metadata ?? '').includes(SENTINEL))).toBe(true); + }); +}); + +describe('[#21454] CONTROL — the same rows through the generic data door (unchanged)', () => { + it('administrator, list and history list: the body projected, the hash keyed', async () => { + for (const object of ['sys_metadata', 'sys_metadata_history']) { + const res = await as(adminToken, 'GET', `/data/${object}?type=datasource&name=${DS_NAME}`); + expect(res.status).toBe(200); + const payload = await readJson(res); + // Keyed: present, and never a stored value (the shared check below compares it with itself). + for (const row of familyRowsIn(payload)) expect(row.checksum).toMatch(/^hmac-sha256:[0-9a-f]{64}$/); + expectServedLikeTheDoor(`data door ${object}`, payload); + } + }); + + it('administrator, get by id: the body projected, the hash keyed', async () => { + const res = await as(adminToken, 'GET', `/data/sys_metadata/${storedRow.id}`); + expect(res.status).toBe(200); + expectServedLikeTheDoor('data door get', await readJson(res)); + }); + + it('member, list: refused', async () => { + const res = await as(memberToken, 'GET', `/data/sys_metadata?type=datasource&name=${DS_NAME}`); + const payload = await readJson(res); + expect(res.status).toBe(403); + expect(payload?.error?.code ?? payload?.code).toBe('PERMISSION_DENIED'); + }); +}); + +describe('[#21454] ① a sandboxed body\'s object API (ctx.api.object)', () => { + for (const [role, token] of [['administrator', () => adminToken], ['member', () => memberToken]] as const) { + it(`an action body via /actions, invoked by the ${role}: projected, keyed (both tables)`, async () => { + for (const action of ['body_reads_family', 'body_reads_history']) { + const res = await as(token(), 'POST', `/actions/pin_note/${action}`, { params: {} }); + expect(res.status, action).toBe(200); + expectServedLikeTheDoor(`${action} (${role})`, await readJson(res)); + } + }); + + it(`an action body reading inside ctx.api.transaction, invoked by the ${role}: projected, keyed`, async () => { + const res = await as(token(), 'POST', '/actions/pin_note/body_reads_family_in_transaction', { params: {} }); + expect(res.status).toBe(200); + expectServedLikeTheDoor(`transaction read (${role})`, await readJson(res)); + }); + } + + it('a hook body fired by the administrator\'s data-door insert copies only projected content', async () => { + const res = await as(adminToken, 'POST', '/data/pin_note', { title: 'pin-hook' }); + expect(res.status).toBeLessThan(300); + const engine: any = await kernel.getServiceAsync('objectql'); + const rows: any[] = await engine.find('pin_note', { where: { title: 'pin-hook' }, context: { isSystem: true } }); + expect(rows).toHaveLength(1); + // Judged on what landed in the ordinary record, read back as stored. + expect(String(rows[0].observed)).not.toContain(SENTINEL); + expectServedLikeTheDoor('hook copy (stored ordinary record)', JSON.parse(rows[0].observed)); + }); + + it('a hook body fired by a member\'s data-door insert is refused the family read, and copies nothing', async () => { + const res = await as(memberToken, 'POST', '/data/pin_note', { title: 'pin-hook' }); + const payload = await readJson(res); + expect(res.status).toBe(403); + expect(payload?.error?.code ?? payload?.code).toBe('PERMISSION_DENIED'); + const engine: any = await kernel.getServiceAsync('objectql'); + const rows: any[] = await engine.find('pin_note', { where: { title: 'pin-hook' }, context: { isSystem: true } }); + expect(rows).toHaveLength(1); // the administrator's row only + }); + + it('an action body authored at runtime through /meta: projected, keyed, for administrator and member', async () => { + const authored = await as(adminToken, 'PUT', '/meta/action/authored_reads_family', { + name: 'authored_reads_family', + label: 'Authored read', + objectName: 'pin_note', + type: 'script', + body: actionBody(`${readFamilySource('sys_metadata')}\nreturn { rows };`), + }); + expect(authored.status).toBeLessThan(300); + let res: Response | undefined; + const bound = await waitFor(async () => { + const attempt: Response = await as(adminToken, 'POST', '/actions/pin_note/authored_reads_family', { params: {} }); + res = attempt; + return attempt.status < 300; + }); + expect(bound, 'the runtime-authored action never bound').toBe(true); + expectServedLikeTheDoor('runtime-authored body (administrator)', await readJson(res as Response)); + + const member = await as(memberToken, 'POST', '/actions/pin_note/authored_reads_family', { params: {} }); + expect(member.status).toBe(200); + expectServedLikeTheDoor('runtime-authored body (member)', await readJson(member)); + }, 30_000); +}); + +describe('[#21454] ② / ③ an action handler\'s engine handle and scoped API', () => { + for (const [role, token] of [['administrator', () => adminToken], ['member', () => memberToken]] as const) { + it(`ctx.engine.find, invoked by the ${role}: projected, keyed (both tables)`, async () => { + for (const action of ['handler_engine_reads_family', 'handler_engine_reads_history']) { + const res = await as(token(), 'POST', `/actions/pin_note/${action}`, { params: {} }); + expect(res.status, action).toBe(200); + expectServedLikeTheDoor(`${action} (${role})`, await readJson(res)); + } + }); + + it(`ctx.api.object(...).find, invoked by the ${role}: projected, keyed`, async () => { + const res = await as(token(), 'POST', '/actions/pin_note/handler_api_reads_family', { params: {} }); + expect(res.status).toBe(200); + expectServedLikeTheDoor(`handler ctx.api (${role})`, await readJson(res)); + }); + } +}); diff --git a/packages/runtime/src/stored-metadata-reader-seam.test.ts b/packages/runtime/src/stored-metadata-reader-seam.test.ts new file mode 100644 index 00000000000..f3358f4156f --- /dev/null +++ b/packages/runtime/src/stored-metadata-reader-seam.test.ts @@ -0,0 +1,143 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#21454] The stored-metadata reader seam, against a scoped-API double: which + * reads it serves, through which derived contexts, and that it serves each one + * exactly once. The composed contexts are pinned end to end, against the data + * door, in `stored-metadata-reader-contexts.pin.test.ts`; this file holds the + * routes a body or handler can take around the seam that the composition does + * not exercise one by one. + */ + +import { describe, it, expect } from 'vitest'; +import { assertEngineFindOnePredicate } from '@objectstack/metadata-core'; +import { ephemeralStoredHashDigest } from '@objectstack/metadata-protocol'; +import { serveStoredMetadataRead, serveStoredMetadataReadsThrough } from './stored-metadata-reader-seam.js'; + +const SENTINEL = 'seam-unit-sentinel-41d7'; +const STORED_HASH = `sha256:${'a1'.repeat(32)}`; + +function storedRow(): Record { + return { + id: 'row_1', + type: 'datasource', + name: 'unit_ds', + metadata: JSON.stringify({ + name: 'unit_ds', + driver: 'turso', + config: { url: 'libsql://unit.example.invalid', encryptionKey: SENTINEL }, + }), + checksum: STORED_HASH, + }; +} + +/** A keyed-digest provider whose output names what it keyed. */ +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 { + const repo = (name: string) => ({ + async find(query?: any) { + seen.fields.push(query?.fields); + return name.startsWith('sys_metadata') ? [storedRow()] : [{ id: 'n1', metadata: 'ordinary', checksum: STORED_HASH }]; + }, + async findOne(query?: any) { + assertEngineFindOnePredicate(name, query); + return name.startsWith('sys_metadata') ? storedRow() : null; + }, + async count() { return 1; }, + async aggregate() { return [{ type: 'datasource', metadata: storedRow().metadata, checksum: STORED_HASH, count: 1 }]; }, + }); + return { + object: repo, + sudo: () => scopedApi(seen), + withRunAs: () => scopedApi(seen), + async transaction(callback: (trx: any, info: unknown) => Promise) { + return callback(scopedApi(seen), { owned: true }); + }, + async beginTransaction() { return { ctx: scopedApi(seen), handle: 'trx_1', owned: true }; }, + }; +} + +function expectServed(row: any, hash = 'keyed:71'): void { + expect(String(row.metadata)).not.toContain(SENTINEL); + expect(String(row.metadata)).toContain('unit.example.invalid'); + expect(row.checksum).toBe(hash); +} + +describe('[#21454] serveStoredMetadataReadsThrough — the reads it serves', () => { + it('find, findOne and aggregate on a family object answer the projected body and the keyed hash', async () => { + const api = serveStoredMetadataReadsThrough(scopedApi(), engineWithProvider); + 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]); + expect(await api.object(object).count({})).toBe(1); + } + }); + + it('an object outside the family is answered as read, by reference', async () => { + const raw = scopedApi(); + const api = serveStoredMetadataReadsThrough(raw, engineWithProvider); + const rows = await api.object('pin_note').find({}); + expect(rows[0]).toEqual({ id: 'n1', metadata: 'ordinary', checksum: STORED_HASH }); + }); + + it('every derived context is served too: sudo, withRunAs, transaction(fn), beginTransaction', async () => { + const api = serveStoredMetadataReadsThrough(scopedApi(), engineWithProvider); + expectServed((await api.sudo().object('sys_metadata').find({}))[0]); + expectServed((await api.withRunAs('system', {}).object('sys_metadata').find({}))[0]); + await api.transaction(async (trx: any) => { + expectServed((await trx.object('sys_metadata').find({}))[0]); + }); + const begun = await api.beginTransaction(); + expect(begun.handle).toBe('trx_1'); + expect(begun.owned).toBe(true); + expectServed((await begun.ctx.object('sys_metadata').find({}))[0]); + }); + + it('serves once: an API already served through the seam is returned as is', async () => { + const once = serveStoredMetadataReadsThrough(scopedApi(), engineWithProvider); + const twice = serveStoredMetadataReadsThrough(once, engineWithProvider); + expect(twice).toBe(once); + expectServed((await twice.object('sys_metadata').find({}))[0]); + }); + + 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 api = serveStoredMetadataReadsThrough(scopedApi(seen), engineWithProvider); + const [row] = await api.object('sys_metadata').find({ fields: ['metadata'] }); + expect(seen.fields).toEqual([['metadata', 'type']]); + expect('type' in row).toBe(false); + expect(String(row.metadata)).not.toContain(SENTINEL); + }); + + it('with no crypto provider, the hash is keyed under the data door\'s own process key', async () => { + const api = serveStoredMetadataReadsThrough(scopedApi(), {}); + const [row] = await api.object('sys_metadata').find({}); + expect(row.checksum).toBe(await ephemeralStoredHashDigest(STORED_HASH)); + expect(row.checksum).toMatch(/^hmac-sha256:[0-9a-f]{64}$/); + }); +}); + +describe('[#21454] serveStoredMetadataRead — one read, served in the shape it arrived in', () => { + it('a row list, one row, and null', async () => { + const [listed] = await serveStoredMetadataRead('sys_metadata', {}, engineWithProvider, async () => [storedRow()]); + expectServed(listed); + expectServed(await serveStoredMetadataRead('sys_metadata', {}, engineWithProvider, async () => storedRow())); + expect(await serveStoredMetadataRead('sys_metadata', {}, engineWithProvider, async () => null)).toBeNull(); + }); + + it('an object outside the family runs the caller\'s query untouched and returns the answer by reference', async () => { + const query = { fields: ['metadata'] }; + const answer = [storedRow()]; + let received: unknown; + const out = await serveStoredMetadataRead('pin_note', query, engineWithProvider, async (q) => { + received = q; + return answer; + }); + expect(received).toBe(query); + expect(out).toBe(answer); + }); +}); diff --git a/packages/runtime/src/stored-metadata-reader-seam.ts b/packages/runtime/src/stored-metadata-reader-seam.ts new file mode 100644 index 00000000000..699055f3737 --- /dev/null +++ b/packages/runtime/src/stored-metadata-reader-seam.ts @@ -0,0 +1,188 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#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. + * + * 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: + * + * - a sandboxed body's `ctx.api.object(...)` (`sandbox/body-runner.ts`, + * `buildSandboxApi`): action and hook bodies alike, and with them every + * copy a body makes of what it read, since a body can copy only what it + * was served; + * - an action handler's `ctx.api` (`action-execution.ts`, `buildActionApi`): + * the same scoped context an action body receives, handed to host code + * handlers too; + * - an action handler's `ctx.engine.find` (`action-execution.ts`, + * `buildActionEngineFacade`). + * + * 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. + * + * ## One serve, consumed + * + * 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. + * + * ## 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. + */ + +import { isStoredMetadataBodyObject } from '@objectstack/spec/kernel'; +import { + ephemeralStoredHashDigest, + redactStoredMetadataRows, + serveStoredMetadataHashColumnRows, + storedMetadataBodyProjection, + type StoredHashDigest, +} from '@objectstack/metadata-protocol'; + +/** + * The keyed digest a family read is served under: the engine's registered + * crypto provider's, read at the moment of use (a host registers it after the + * kernel starts), else the process-scoped ephemeral key. The same two sources, + * in the same order, the data door reads. + */ +function storedHashDigestOf(engine: unknown): StoredHashDigest { + const accessor = (engine as { getKeyedDigest?: () => StoredHashDigest | undefined } | null | undefined) + ?.getKeyedDigest; + const provider = typeof accessor === 'function' ? accessor.call(engine) : undefined; + return provider ?? ephemeralStoredHashDigest; +} + +function isPlainRecord(value: unknown): value is Record { + return value !== null && typeof value === 'object' && !Array.isArray(value); +} + +/** + * 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. + * + * `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. + */ +export async function serveStoredMetadataRead( + object: string, + query: unknown, + engine: unknown, + read: (query: unknown) => Promise, +): Promise { + if (!isStoredMetadataBodyObject(object)) return read(query); + const projection = storedMetadataBodyProjection(object, isPlainRecord(query) ? query.fields : undefined); + const answer = await read( + projection.addedType && isPlainRecord(query) ? { ...query, fields: projection.fields } : query, + ); + const digest = storedHashDigestOf(engine); + const opts = { dropType: projection.addedType }; + if (Array.isArray(answer)) { + return (await serveStoredMetadataHashColumnRows(object, redactStoredMetadataRows(object, answer, opts), digest)) as A; + } + if (isPlainRecord(answer)) { + const [served] = await serveStoredMetadataHashColumnRows( + object, + redactStoredMetadataRows(object, [answer], opts), + digest, + ); + return served as A; + } + return answer; +} + +/** The repository verbs whose answer carries rows. `count` answers a number and serves no row. */ +const ROW_SERVING_READS: ReadonlySet = new Set(['find', 'findOne', 'aggregate']); + +/** 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'); + +function serveRepository(objectName: string, repo: unknown, engine: 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 (!ROW_SERVING_READS.has(prop)) return value.bind(target); + return (query?: unknown, ...rest: unknown[]) => + serveStoredMetadataRead(objectName, query, engine, (q) => value.call(target, q, ...rest)); + }, + }); +} + +/** + * 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. + * + * The contexts the API can derive are served the same way, so no route around + * the seam opens: `object(name)`, `sudo()`, `withRunAs(...)`, the context a + * `transaction(fn)` callback receives, and the `ctx` `beginTransaction()` + * returns (the sandbox's `ctx.api.transaction` reads through that one). + * + * Idempotent: an API already served through this seam is returned as is, so a + * body whose API was served at the action door is not served twice at the + * sandbox (a keyed hash keyed again would no longer be the door's form). + * A value that is not an object is returned as is. + */ +export function serveStoredMetadataReadsThrough(api: T, engine: 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); + return new Proxy(api as unknown as Record, { + get(target, prop) { + if (prop === SERVED_THROUGH_SEAM) 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); + case 'sudo': + case 'withRunAs': + return (...args: unknown[]) => wrap(value.apply(target, args)); + case 'transaction': + return (callback: unknown, ...rest: unknown[]) => + value.call( + target, + typeof callback === 'function' + ? (trx: unknown, ...info: unknown[]) => (callback as (...a: unknown[]) => unknown)(wrap(trx), ...info) + : callback, + ...rest, + ); + case 'beginTransaction': + return async (...args: unknown[]) => { + const begun = await value.apply(target, args); + return isPlainRecord(begun) && 'ctx' in begun ? { ...begun, ctx: wrap(begun.ctx) } : begun; + }; + default: + return value.bind(target); + } + }, + }) as unknown as T; +} diff --git a/scripts/engine-double-contract.pinned.json b/scripts/engine-double-contract.pinned.json index 4d773ca99f8..30645a03924 100644 --- a/scripts/engine-double-contract.pinned.json +++ b/scripts/engine-double-contract.pinned.json @@ -3796,6 +3796,11 @@ "verb": "update", "pinned": 1 }, + { + "file": "packages/runtime/src/stored-metadata-reader-seam.test.ts", + "verb": "findOne", + "pinned": 1 + }, { "file": "packages/services/service-automation/src/builtin/crud-bulk-intent.test.ts", "verb": "delete",