Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions .changeset/21454-reader-context-family-serve.md
Original file line number Diff line number Diff line change
@@ -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.
17 changes: 17 additions & 0 deletions packages/metadata-protocol/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down
21 changes: 17 additions & 4 deletions packages/runtime/src/action-execution.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -1496,16 +1497,22 @@ export function buildActionExecutionContext(ec: any): Record<string, unknown> {
* 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;
}
Expand Down Expand Up @@ -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<string, unknown> | undefined) ?? {}), context } as any);
return Array.isArray(rows) ? rows : ((rows as any)?.value ?? []);
});
},
};
}
Expand Down
15 changes: 15 additions & 0 deletions packages/runtime/src/sandbox/body-runner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ import {
resolveRecordTitle,
resolveRelatedTitleTarget,
} from '@objectstack/objectql';
import { serveStoredMetadataReadsThrough } from '../stored-metadata-reader-seam.js';

interface FactoryOptions {
ql: any;
Expand Down Expand Up @@ -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
Expand Down
Loading
Loading