diff --git a/.changeset/21787-write-response-credential-mask.md b/.changeset/21787-write-response-credential-mask.md new file mode 100644 index 00000000000..27c03ab3d68 --- /dev/null +++ b/.changeset/21787-write-response-credential-mask.md @@ -0,0 +1,13 @@ +--- +'@objectstack/core': patch +'@objectstack/runtime': patch +--- + +Credential-class field values are now masked on every write response, as on reads. + +Clause-②: no + +- A `secret` field, and a `password` field on an object that is not `managedBy: 'better-auth'`, already read back as `SECRET_MASK` (`null` when unset) on the generic read path (ADR-0100). Every write response that returns a record (REST, batch and MCP) now answers the same way. +- The shared write-response helper every write door already calls (`omitInternalFieldsFromWriteResponse`, `@objectstack/core`) now applies the credential mask before it omits `internal: true` fields. New exports beside it: `maskCredentialFieldsInWriteResponse` and `collectCredentialWriteResponseFields`, which read the same `isMaskedOnReadFieldType` declaration as the engine's read mask. +- `callData`'s fallback create and update arms (`@objectstack/runtime`, used when no protocol service is registered) now pass their response record through the same helper. +- Unchanged: the engine's own write results still return the stored row whole to privileged server-side callers, and the echoed-mask write guard still treats a `SECRET_MASK` value as "leave unchanged", so a client that saves back a write response does not overwrite the stored credential. diff --git a/packages/core/src/utils/internal-write-response.test.ts b/packages/core/src/utils/internal-write-response.test.ts new file mode 100644 index 00000000000..cc6e66d0971 --- /dev/null +++ b/packages/core/src/utils/internal-write-response.test.ts @@ -0,0 +1,76 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import { describe, it, expect } from 'vitest'; +import { SECRET_MASK } from '@objectstack/spec/data'; +import { + collectCredentialWriteResponseFields, + maskCredentialFieldsInWriteResponse, + omitInternalFieldsFromWriteResponse, +} from './internal-write-response.js'; + +const STORED_PASSWORD = 'stored-password-value-never-returned'; +const STORED_REF = 'secret:handle-never-returned'; + +const SCHEMA = { + name: 'cred_holder', + fields: { + id: { type: 'text' }, + name: { type: 'text' }, + login_password: { type: 'password' }, + api_token: { type: 'secret' }, + hidden_hash: { type: 'text', internal: true }, + both: { type: 'secret', internal: true }, + }, +}; + +const row = () => ({ + id: 'r1', + name: 'visible', + login_password: STORED_PASSWORD, + api_token: STORED_REF, + hidden_hash: 'h', + both: 'secret:x', +}); + +describe('write-response credential mask', () => { + it('collects the read-mask set: secret always, password outside the exempt bucket', () => { + expect(collectCredentialWriteResponseFields(SCHEMA).sort()).toEqual(['api_token', 'both', 'login_password']); + expect(collectCredentialWriteResponseFields({ ...SCHEMA, managedBy: 'better-auth' }).sort()) + .toEqual(['api_token', 'both']); + expect(collectCredentialWriteResponseFields(undefined)).toEqual([]); + expect(collectCredentialWriteResponseFields({ name: 'x' })).toEqual([]); + }); + + it('masks a set credential, keeps an unset one null, never adds a key', () => { + const r: Record = { id: 'r1', login_password: STORED_PASSWORD, api_token: null }; + maskCredentialFieldsInWriteResponse(SCHEMA, r); + expect(r).toEqual({ id: 'r1', login_password: SECRET_MASK, api_token: null }); + // Idempotent. + maskCredentialFieldsInWriteResponse(SCHEMA, r); + expect(r).toEqual({ id: 'r1', login_password: SECRET_MASK, api_token: null }); + }); + + it('the shared write-response helper masks credentials and omits internal fields, on every row', () => { + const rows = [row(), null, 7, row()]; + omitInternalFieldsFromWriteResponse(SCHEMA, rows); + for (const r of [rows[0], rows[3]] as Array>) { + expect(r).toEqual({ id: 'r1', name: 'visible', login_password: SECRET_MASK, api_token: SECRET_MASK }); + } + const wire = JSON.stringify(rows); + expect(wire.includes(STORED_PASSWORD)).toBe(false); + expect(wire.includes('secret:')).toBe(false); + }); + + it('a field removed upstream (field-level security) stays absent', () => { + const r: Record = { id: 'r1', name: 'visible' }; + omitInternalFieldsFromWriteResponse(SCHEMA, r); + expect(r).toEqual({ id: 'r1', name: 'visible' }); + }); + + it('a better-auth object keeps its password column, still masks its secret column', () => { + const r = row(); + omitInternalFieldsFromWriteResponse({ ...SCHEMA, managedBy: 'better-auth' }, r); + expect(r.login_password).toBe(STORED_PASSWORD); + expect(r.api_token).toBe(SECRET_MASK); + }); +}); diff --git a/packages/core/src/utils/internal-write-response.ts b/packages/core/src/utils/internal-write-response.ts index 70d534319ff..2a6a48c04fd 100644 --- a/packages/core/src/utils/internal-write-response.ts +++ b/packages/core/src/utils/internal-write-response.ts @@ -89,15 +89,29 @@ * columns are `required`, so a mask carries zero bits while still shipping a * value under a field whose description promises none. * + * ## The credential-class mask rides the same helper + * + * The engine masks credential-class fields (`secret`, and `password` outside + * the exempt `managedBy` buckets — ADR-0100) on its READ path only, and keeps + * its write results whole for the same reason it keeps `internal` columns: + * a privileged server-side writer may read the stored value back off the + * result. Every external write response therefore owes the credential mask + * too, at the same mouths. It is applied by the same helper, ahead of the + * omission, so no mouth can hold one guarantee and miss the other, and the + * three tripwires named above hold both by one enumeration. + * * Deletion is IN PLACE and idempotent: records that already lack the field * (a re-stripped read result, a fake engine that never returned it) pass * through unchanged, and non-record values (`null`, an affected-row count, a * driver's boolean delete verdict) are skipped rather than judged. */ +import { SECRET_MASK, isMaskedOnReadFieldType } from '@objectstack/spec/data'; + /** Minimal view of an object schema this module reads — the field map only. */ interface SchemaWithFields { - fields?: Record | undefined; + fields?: Record | undefined; + managedBy?: unknown; } /** @@ -118,9 +132,65 @@ export function collectInternalWriteResponseFields(schema: unknown): string[] { } /** - * Drop every `internal: true` field from a write response's record(s), in - * place. THE single helper every external write mouth goes through — see the - * module header; the three tripwires enforce the "every". + * Collect the names of the credential-class fields the engine masks on read + * (ADR-0100: every `secret` field, every `password` field outside the exempt + * `managedBy` buckets) — the write-response half of that mask reads the SAME + * set, so a write answers what a read of the same row would. + * + * Not restated: the type set and its per-type `managedBy` exemptions are + * declared once in `@objectstack/spec/data` and asked through + * `isMaskedOnReadFieldType`, exactly as objectql's `collectMaskedReadFields` + * asks it. ⛔ Do not add a `def.type === …` arm here; change the declaration. + */ +export function collectCredentialWriteResponseFields(schema: unknown): string[] { + const s = schema as SchemaWithFields | null | undefined; + const fields = s?.fields; + if (!fields || typeof fields !== 'object') return []; + const managedBy: unknown = s?.managedBy; + const out: string[] = []; + for (const [name, def] of Object.entries(fields)) { + if (def && isMaskedOnReadFieldType(def.type, managedBy)) out.push(name); + } + return out; +} + +/** + * Replace every credential-class field in a write response's record(s) with + * `SECRET_MASK`, in place — the write-response mirror of the engine's read + * mask (`maskSecretFields`). A set value becomes the mask; an unset one + * (`null` / `undefined`) becomes `null`, so "a credential is set" is the only + * bit a response carries. A field the record does not carry (never written, or + * already removed by field-level security upstream) stays absent: this mask + * never ADDS a key, so it composes with the security plugin's field masking + * instead of re-exposing what that removed. + * + * Idempotent; non-objects are skipped, arrays are walked. + */ +export function maskCredentialFieldsInWriteResponse(schema: unknown, records: unknown): void { + if (!records) return; + const credentialFields = collectCredentialWriteResponseFields(schema); + if (credentialFields.length === 0) return; + const list = Array.isArray(records) ? records : [records]; + for (const row of list) { + if (!row || typeof row !== 'object') continue; + const r = row as Record; + for (const field of credentialFields) { + if (!(field in r)) continue; + r[field] = r[field] == null ? null : SECRET_MASK; + } + } +} + +/** + * Apply the generic-data-path non-exposure rules to a write response's + * record(s), in place: credential-class fields are MASKED + * ({@link maskCredentialFieldsInWriteResponse}), then `internal: true` fields + * are OMITTED. THE single helper every external write mouth goes through — + * see the module header; the three tripwires enforce the "every". + * + * Mask first, omit second — the engine read path's order, so a field that is + * both credential-typed and `internal` ends up omitted (the stricter + * disposition wins). * * @param schema The registered object schema (`engine.registry.getObject(...)` * / the protocol's own registry view / `metadataService @@ -133,6 +203,7 @@ export function collectInternalWriteResponseFields(schema: unknown): string[] { */ export function omitInternalFieldsFromWriteResponse(schema: unknown, records: unknown): void { if (!records) return; + maskCredentialFieldsInWriteResponse(schema, records); const internalFields = collectInternalWriteResponseFields(schema); if (internalFields.length === 0) return; const list = Array.isArray(records) ? records : [records]; diff --git a/packages/mcp/src/mcp-write-response-internal-fields.tripwire.test.ts b/packages/mcp/src/mcp-write-response-internal-fields.tripwire.test.ts index 87a4e773e29..e2ea9b08bc6 100644 --- a/packages/mcp/src/mcp-write-response-internal-fields.tripwire.test.ts +++ b/packages/mcp/src/mcp-write-response-internal-fields.tripwire.test.ts @@ -52,6 +52,10 @@ import { assertEngineDeleteDispatch, assertEngineUpdateDispatch, assertEngineFin const SENTINEL = 'INTERNAL-SENTINEL-8497-NEVER-SERIALIZED'; /** The value that MUST appear wherever a record was promised (falsifiability). */ const CONTROL = 'CONTROL-VALUE-8497-RECORD-FLOWED'; +/** Credential-class stored values: a `password` plaintext and a `secret` handle ref. */ +const CREDENTIAL_SENTINELS = ['PASSWORD-SENTINEL-NEVER-SERIALIZED', 'secret:HANDLE-SENTINEL-NEVER-SERIALIZED']; +const NEVER_SERIALIZED = [SENTINEL, ...CREDENTIAL_SENTINELS]; +const leaks = (wire: string) => NEVER_SERIALIZED.filter((v) => wire.includes(v)); const VAULT = { name: 'vault', @@ -60,6 +64,8 @@ const VAULT = { id: { name: 'id', type: 'text' }, name: { name: 'name', type: 'text' }, vault_secret: { name: 'vault_secret', type: 'text', internal: true }, + vault_password: { name: 'vault_password', type: 'password' }, + vault_token: { name: 'vault_token', type: 'secret' }, }, // No `apiEnabled`/`apiMethods` narrowing: the ADR-0049 exposure gate must let // every verb through, or a recipe would be measuring a 404 instead of a body. @@ -78,6 +84,8 @@ function makeSentinelEngine(): IDataEngine { id, name: (data?.name as string) ?? CONTROL, vault_secret: SENTINEL, + vault_password: CREDENTIAL_SENTINELS[0], + vault_token: CREDENTIAL_SENTINELS[1], }); return { find: vi.fn(async () => [storedRow()]), @@ -157,7 +165,7 @@ const RECIPES: Record = { remove: { invoke: (b) => b.remove('vault', 'row-1'), writesRecords: false }, }; -describe('#8497 tripwire: no MCP write response carries an `internal: true` value', () => { +describe('#8497 tripwire: no MCP write response carries an `internal: true` value or a credential-class stored value', () => { it('the enumeration is real: it sees the bridge write verbs', () => { const faces = enumerateBridgeFaces(makeBridge()); expect(faces).toEqual(expect.arrayContaining(['create', 'update', 'remove'])); @@ -181,10 +189,10 @@ describe('#8497 tripwire: no MCP write response carries an `internal: true` valu }); for (const [name, recipe] of Object.entries(RECIPES)) { - it(`${name}: response never carries the internal sentinel${recipe.writesRecords ? ', and really returned a record' : ''}`, async () => { + it(`${name}: response never carries the internal or credential sentinels${recipe.writesRecords ? ', and really returned a record' : ''}`, async () => { const bridge = makeBridge(); const wire = JSON.stringify((await recipe.invoke(bridge)) ?? null); - expect(wire.includes(SENTINEL), `${name} leaked an internal field: ${wire}`).toBe(false); + expect(leaks(wire), `${name} leaked an internal or credential-class field: ${wire}`).toEqual([]); if (recipe.writesRecords) { expect( wire.includes(CONTROL), @@ -207,6 +215,16 @@ describe('#8497 tripwire: no MCP write response carries an `internal: true` valu expect(wire.includes(CONTROL)).toBe(true); // still a real record echo }); + it('the caller cannot read their own credential write back in clear from the update echo', async () => { + const bridge = makeBridge(); + const wire = JSON.stringify(await bridge.update('vault', 'row-1', { + name: CONTROL, + vault_password: 'caller-sent-password', + })); + expect(wire.includes('caller-sent-password')).toBe(false); + expect(wire.includes(CONTROL)).toBe(true); + }); + it('NEGATIVE CONTROL: the machinery goes red on a write mouth that skips the helper', async () => { // Exactly the defect this file was written after: an engine-only mouth that // echoes `engine.insert`'s (whole) result. Reintroduce it locally and prove @@ -221,10 +239,10 @@ describe('#8497 tripwire: no MCP write response carries an `internal: true` valu }; const leaked = await leaky.create('vault', { name: CONTROL }); - expect(JSON.stringify(leaked).includes(SENTINEL)).toBe(true); // the scan bites + expect(leaks(JSON.stringify(leaked))).toEqual(NEVER_SERIALIZED); // the scan bites omitInternalFieldsFromWriteResponse(VAULT, (leaked as any).record); - expect(JSON.stringify(leaked).includes(SENTINEL)).toBe(false); // the helper closes it + expect(leaks(JSON.stringify(leaked))).toEqual([]); // the helper closes it expect(JSON.stringify(leaked).includes(CONTROL)).toBe(true); // …without eating the record }); }); diff --git a/packages/metadata-protocol/src/protocol.write-response-internal-fields.tripwire.test.ts b/packages/metadata-protocol/src/protocol.write-response-internal-fields.tripwire.test.ts index 000b129eb6b..14eb4bb49f9 100644 --- a/packages/metadata-protocol/src/protocol.write-response-internal-fields.tripwire.test.ts +++ b/packages/metadata-protocol/src/protocol.write-response-internal-fields.tripwire.test.ts @@ -56,6 +56,15 @@ import { const SENTINEL = 'INTERNAL-SENTINEL-7823-NEVER-SERIALIZED'; /** The value that MUST appear wherever a record was promised (falsifiability). */ const CONTROL = 'CONTROL-VALUE-7823-RECORD-FLOWED'; +/** + * Credential-class stored values (a `password` field's plaintext and a + * `secret` field's handle ref) — masked on read by the engine, and owed the + * same mask on every write response by the shared helper. + */ +const CREDENTIAL_SENTINELS = ['PASSWORD-SENTINEL-NEVER-SERIALIZED', 'secret:HANDLE-SENTINEL-NEVER-SERIALIZED']; +/** Every stored value no write response may carry. */ +const NEVER_SERIALIZED = [SENTINEL, ...CREDENTIAL_SENTINELS]; +const leaks = (wire: string) => NEVER_SERIALIZED.filter((v) => wire.includes(v)); const VAULT_SCHEMA = { name: 'vault', @@ -63,6 +72,8 @@ const VAULT_SCHEMA = { id: { name: 'id', type: 'text' }, name: { name: 'name', type: 'text' }, vault_secret: { name: 'vault_secret', type: 'text', internal: true }, + vault_password: { name: 'vault_password', type: 'password' }, + vault_token: { name: 'vault_token', type: 'secret' }, }, enable: { clone: true }, }; @@ -82,6 +93,8 @@ function makeSentinelEngine() { id, name: (data as any)?.name ?? CONTROL, vault_secret: SENTINEL, + vault_password: CREDENTIAL_SENTINELS[0], + vault_token: CREDENTIAL_SENTINELS[1], }); let nextId = 1; const handle = { id: 'trx-1' }; @@ -241,7 +254,7 @@ const RECIPES: Record = { runAtomicBatchData: { coveredVia: 'batchData' }, }; -describe('#7823 tripwire: every generic data ingress strips `internal: true` from its write response', () => { +describe('#7823 tripwire: every generic data ingress strips `internal: true` and masks credential-class fields in its write response', () => { const enumerated = enumerateDataMethods(ObjectStackProtocolImplementation.prototype); it('the enumeration is real: it sees the three ruling-named ingresses', () => { @@ -276,12 +289,12 @@ describe('#7823 tripwire: every generic data ingress strips `internal: true` fro for (const name of enumerated) { const recipe = RECIPES[name]; if (!recipe || 'coveredVia' in recipe) continue; - it(`${name}: response never carries the internal sentinel${recipe.expectRecord ? ', and really returned a record' : ''}`, async () => { + it(`${name}: response never carries the internal or credential sentinels${recipe.expectRecord ? ', and really returned a record' : ''}`, async () => { for (const invoke of recipe.invocations) { const p = new ObjectStackProtocolImplementation(makeSentinelEngine()); const response = await invoke(p); const wire = JSON.stringify(response ?? null); - expect(wire.includes(SENTINEL), `${name} leaked an internal field: ${wire}`).toBe(false); + expect(leaks(wire), `${name} leaked an internal or credential-class field: ${wire}`).toEqual([]); if (recipe.expectRecord) { expect(wire.includes(CONTROL), `${name} returned no record at all — the probe is blind: ${wire}`).toBe(true); } @@ -305,12 +318,12 @@ describe('#7823 tripwire: every generic data ingress strips `internal: true` fro const p = new LeakyProtocol(makeSentinelEngine()); const wire = JSON.stringify(await p.leakyData({ object: 'vault', id: 'row-1', data: { name: 'x' } })); - expect(wire.includes(SENTINEL)).toBe(true); // half 2: the scan detects the leak + expect(leaks(wire)).toEqual(NEVER_SERIALIZED); // half 2: the scan detects every leak // And the helper is exactly what closes it — same response, one call. const fixed = await p.leakyData({ object: 'vault', id: 'row-1', data: { name: 'x' } }); omitInternalFieldsFromWriteResponse(VAULT_SCHEMA, (fixed as any).record); - expect(JSON.stringify(fixed).includes(SENTINEL)).toBe(false); + expect(leaks(JSON.stringify(fixed))).toEqual([]); }); it('the collector agrees with the engine rule: strict `internal === true` only', () => { diff --git a/packages/qa/dogfood/test/write-response-credential-mask.dogfood.test.ts b/packages/qa/dogfood/test/write-response-credential-mask.dogfood.test.ts new file mode 100644 index 00000000000..dead76bdad8 --- /dev/null +++ b/packages/qa/dogfood/test/write-response-credential-mask.dogfood.test.ts @@ -0,0 +1,182 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// Every write door that answers with a record answers what a read of the same +// row would for its credential-class fields, on a real boot: a `password` +// field's stored value and a `secret` field's stored handle ref never appear +// in a write response — the value a set credential reads back as is the mask. +// +// ## The composition +// +// `bootStack` with its real engine, crypto provider, SQL driver, protocol and +// REST layers. One synthetic object holds one `password` field and one +// `secret` field; the writer is the seeded admin, writing through REST. +// +// ## Arming +// +// The scene is real before anything is believed: a privileged engine read of +// the stored row (an engine write result, which keeps the row whole by design) +// shows the plaintext password and a `secret:` handle ref at rest. Without it +// every "absent" below could be true because nothing was ever stored. +// +// Falsifiability: each door's response must carry the record's own `name`, so +// an empty or refused response cannot read as a pass. +// +// Fixtures are synthetic. + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { bootStack, type VerifyStack } from '@objectstack/verify'; +import { defineStack } from '@objectstack/spec'; +import { ObjectSchema, Field, SECRET_MASK } from '@objectstack/spec/data'; + +const OBJ = 'wrcm_item'; +const PW = 'wrcm_password'; +const TOKEN = 'wrcm_token'; +const PW_VALUE = 'wrcm-plain-credential-81'; +const TOKEN_VALUE = 'wrcm-token-credential-82'; +const SYS = { context: { isSystem: true } } as const; + +const Item = ObjectSchema.create({ + name: OBJ, + label: 'WRCM Item', + pluralLabel: 'WRCM Items', + sharingModel: 'public_read_write', + fields: { + name: Field.text({ label: 'Name', required: true }), + [PW]: Field.password({ label: 'Password', ackPlaintextMasking: true }), + [TOKEN]: Field.secret({ label: 'Token' }), + }, +}); + +const fixtureStack = defineStack({ + manifest: { + id: 'com.dogfood.write-response-credential-mask', + namespace: 'wrcm', + version: '0.0.0', + type: 'app', + name: 'Write Response Credential Mask Fixture', + description: 'One object holding one password field and one secret field.', + }, + objects: [Item], +}); + +type Row = Record; + +/** Every stored credential spelling no write response may carry. */ +const leaksIn = (body: unknown): string[] => { + const wire = JSON.stringify(body ?? null); + return [PW_VALUE, TOKEN_VALUE, 'secret:'].filter((v) => wire.includes(v)); +}; + +describe('write responses mask credential-class fields as reads do', () => { + let stack: VerifyStack; + let ql: any; + let token: string; + + const write = async (method: string, path: string, body: unknown) => { + const res = await stack.apiAs(token, method, path, body); + const json: any = await res.json(); + return { status: res.status, json }; + }; + + /** Create one row with both credentials set, through the engine (no response under test). */ + const seed = async (name: string): Promise => { + const row: Row = await ql.insert(OBJ, { name, [PW]: PW_VALUE, [TOKEN]: TOKEN_VALUE }, SYS); + return String(row.id); + }; + + beforeAll(async () => { + stack = await bootStack(fixtureStack as unknown as Parameters[0], {}); + token = await stack.signIn(); + ql = await stack.kernel.getServiceAsync('objectql'); + }, 120_000); + + afterAll(async () => { await stack?.stop?.(); }); + + it('the scene is armed: the stored row holds the plaintext password and a secret handle ref', async () => { + const id = await seed('wrcm-armed'); + const whole: Row = await ql.update(OBJ, { name: 'wrcm-armed' }, { where: { id }, ...SYS }); + expect(whole[PW]).toBe(PW_VALUE); + expect(String(whole[TOKEN])).toMatch(/^secret:/); + // …and the read path masks both, which is what every write door must match. + const read = await stack.apiAs(token, 'GET', `/data/${OBJ}/${id}`); + expect(read.status).toBe(200); + const rec: Row = ((await read.json()) as any).record; + expect(rec[PW]).toBe(SECRET_MASK); + expect(rec[TOKEN]).toBe(SECRET_MASK); + }); + + it('single create', async () => { + const res = await write('POST', `/data/${OBJ}`, { name: 'wrcm-create', [PW]: PW_VALUE, [TOKEN]: TOKEN_VALUE }); + expect(res.status).toBe(201); + expect(leaksIn(res.json)).toEqual([]); + expect(res.json.record.name).toBe('wrcm-create'); + expect(res.json.record[PW]).toBe(SECRET_MASK); + expect(res.json.record[TOKEN]).toBe(SECRET_MASK); + }); + + it('single update', async () => { + const id = await seed('wrcm-update'); + const res = await write('PATCH', `/data/${OBJ}/${id}`, { name: 'wrcm-update-2' }); + expect(res.status).toBe(200); + expect(leaksIn(res.json)).toEqual([]); + expect(JSON.stringify(res.json)).toContain('wrcm-update-2'); + }); + + it('createMany', async () => { + const res = await write('POST', `/data/${OBJ}/createMany`, [ + { name: 'wrcm-many-1', [PW]: PW_VALUE, [TOKEN]: TOKEN_VALUE }, + { name: 'wrcm-many-2', [PW]: PW_VALUE }, + ]); + expect(res.status).toBeLessThan(300); + expect(leaksIn(res.json)).toEqual([]); + expect(JSON.stringify(res.json)).toContain('wrcm-many-1'); + }); + + it('updateMany', async () => { + const id = await seed('wrcm-umany'); + const res = await write('POST', `/data/${OBJ}/updateMany`, { records: [{ id, data: { name: 'wrcm-umany-2' } }] }); + expect(res.status).toBeLessThan(300); + expect(leaksIn(res.json)).toEqual([]); + expect(JSON.stringify(res.json)).toContain('wrcm-umany-2'); + }); + + it('per-object batch, create and update arms', async () => { + const id = await seed('wrcm-batch'); + const created = await write('POST', `/data/${OBJ}/batch`, { + operation: 'create', records: [{ data: { name: 'wrcm-batch-c', [PW]: PW_VALUE } }], options: {}, + }); + expect(created.status).toBeLessThan(300); + expect(leaksIn(created.json)).toEqual([]); + expect(JSON.stringify(created.json)).toContain('wrcm-batch-c'); + const updated = await write('POST', `/data/${OBJ}/batch`, { + operation: 'update', records: [{ id, data: { name: 'wrcm-batch-u' } }], options: {}, + }); + expect(updated.status).toBeLessThan(300); + expect(leaksIn(updated.json)).toEqual([]); + expect(JSON.stringify(updated.json)).toContain('wrcm-batch-u'); + }); + + it('cross-object batch, create and update arms', async () => { + const id = await seed('wrcm-xbatch'); + const res = await write('POST', '/batch', { + operations: [ + { object: OBJ, action: 'create', data: { name: 'wrcm-xbatch-c', [PW]: PW_VALUE, [TOKEN]: TOKEN_VALUE } }, + { object: OBJ, action: 'update', id, data: { name: 'wrcm-xbatch-u' } }, + ], + }); + expect(res.status).toBeLessThan(300); + expect(leaksIn(res.json)).toEqual([]); + expect(JSON.stringify(res.json)).toContain('wrcm-xbatch-c'); + expect(JSON.stringify(res.json)).toContain('wrcm-xbatch-u'); + }); + + it('a masked echo round-trips without overwriting the stored credentials', async () => { + const id = await seed('wrcm-echo'); + const res = await write('PATCH', `/data/${OBJ}/${id}`, { name: 'wrcm-echo-2', [PW]: SECRET_MASK, [TOKEN]: SECRET_MASK }); + expect(res.status).toBe(200); + expect(leaksIn(res.json)).toEqual([]); + const whole: Row = await ql.update(OBJ, { name: 'wrcm-echo-2' }, { where: { id }, ...SYS }); + expect(whole[PW]).toBe(PW_VALUE); + expect(String(whole[TOKEN])).toMatch(/^secret:/); + }); +}); diff --git a/packages/rest/src/rest-server.ts b/packages/rest/src/rest-server.ts index c51d70cf658..e49a3d11023 100644 --- a/packages/rest/src/rest-server.ts +++ b/packages/rest/src/rest-server.ts @@ -23,6 +23,9 @@ import { // [#7678] ADR-0090 D5/D9 suggested-binding `?status=` vocabulary — the one // owner, shared with the runtime dispatcher's `/security` domain. isAudienceBindingSuggestionStatus, unknownAudienceBindingSuggestionStatusMessage, + // The write-response rules (credential-class mask, `internal` omit) for + // the cross-object batch's direct-engine update arm. + omitInternalFieldsFromWriteResponse, } from '@objectstack/core'; import { isMcpServerEnabled, @@ -13390,19 +13393,23 @@ export class RestServer { const id = op.id ?? data?.id; const updated = await ql.update(op.object, { ...data, id }, { context: trxCtx, onFieldsDropped }); // [#7823] …but the RESPONSE half moved to the ingress - // (A-prime ruling, 2026-08-13): the engine no longer - // strips `internal: true` fields from its write - // results, so this direct-`ql.update` mouth must - // apply the shared strip itself before the row rides - // `results` out to the caller. Reached through the - // protocol instance because this package does not - // depend on `@objectstack/metadata-protocol` (same - // duck-typing as the `createManyData` probes). - // Dormant today — no `internal`-flagged object grants - // `bulk` — wired so the flag's guarantee does not - // depend on that staying true. - (p as any).omitInternalWriteFields?.(op.object, updated); - out.push(updated); + // (A-prime ruling, 2026-08-13): the engine keeps its + // write results whole, so this direct-`ql.update` + // mouth applies the shared write-response rules + // itself (credential-class mask, then `internal` + // omit) before the row rides `results` out. Called + // from `@objectstack/core` directly — never through + // an optional protocol method, which a protocol + // without it would skip silently. FAIL CLOSED: with + // no registered schema to judge the fields by, only + // the id is echoed. + const updateSchema = (ql as any).registry?.getObject?.(op.object); + if (!updateSchema) { + out.push(updated && typeof updated === 'object' ? { id: (updated as any).id ?? id } : updated); + } else { + omitInternalFieldsFromWriteResponse(updateSchema, updated); + out.push(updated); + } } else { // 'delete' out.push(await ql.delete(op.object, { where: { id: op.id }, context: trxCtx })); } diff --git a/packages/rest/src/rest-write-response-internal-fields.tripwire.test.ts b/packages/rest/src/rest-write-response-internal-fields.tripwire.test.ts index 24807238a5c..06dc9e2befa 100644 --- a/packages/rest/src/rest-write-response-internal-fields.tripwire.test.ts +++ b/packages/rest/src/rest-write-response-internal-fields.tripwire.test.ts @@ -83,6 +83,17 @@ const CONTROL = 'CONTROL-VALUE-8497-RECORD-FLOWED'; /** The flagged column every stored row carries — the caller never sends it. */ const SECRET_FIELD = 'vault_secret'; +/** + * Credential-class columns every stored row carries, holding what the engine + * would store: a `password` field's plaintext and a `secret` field's handle + * ref. The engine masks both on read; every write response owes the same mask. + */ +const PASSWORD_FIELD = 'vault_password'; +const TOKEN_FIELD = 'vault_token'; +const CREDENTIAL_SENTINELS = ['PASSWORD-SENTINEL-NEVER-SERIALIZED', 'secret:HANDLE-SENTINEL-NEVER-SERIALIZED']; +const STORED_CREDENTIALS = { [PASSWORD_FIELD]: CREDENTIAL_SENTINELS[0], [TOKEN_FIELD]: CREDENTIAL_SENTINELS[1] }; +const NEVER_SERIALIZED = [SENTINEL, ...CREDENTIAL_SENTINELS]; +const leaks = (wire: string) => NEVER_SERIALIZED.filter((v) => wire.includes(v)); const VAULT = { name: 'vault', @@ -91,6 +102,8 @@ const VAULT = { id: { name: 'id', label: 'ID', type: 'text', primaryKey: true }, name: { name: 'name', label: 'Name', type: 'text' }, [SECRET_FIELD]: { name: SECRET_FIELD, label: 'Secret', type: 'text', internal: true }, + [PASSWORD_FIELD]: { name: PASSWORD_FIELD, label: 'Password', type: 'password' }, + [TOKEN_FIELD]: { name: TOKEN_FIELD, label: 'Token', type: 'secret' }, }, // `clone` so the clone route is reachable; no `api`/`bulk` narrowing so the // ADR-0049 exposure gate resolves unrestricted and every bulk route is @@ -138,7 +151,7 @@ function memoryDriver() { seq += 1; const id = (data.id as string) ?? `r_${seq}`; // The stored secret the caller never sent — present on every row. - const row = { ...data, id, [SECRET_FIELD]: SENTINEL }; + const row = { ...data, id, [SECRET_FIELD]: SENTINEL, ...STORED_CREDENTIALS }; table(object).set(id, row); return { ...row }; }, @@ -146,7 +159,7 @@ function memoryDriver() { const t = table(object); const cur = t.get(id); if (!cur) return null; - const next = { ...cur, ...data, id, [SECRET_FIELD]: SENTINEL }; + const next = { ...cur, ...data, id, [SECRET_FIELD]: SENTINEL, ...STORED_CREDENTIALS }; t.set(id, next); return { ...next }; }, @@ -387,7 +400,7 @@ const DISPOSITIONS: Record = { const keyOf = (r: { method: string; path: string }) => `${r.method} ${r.path}`; -describe('#8497 tripwire: no REST write response carries an `internal: true` value', () => { +describe('#8497 tripwire: no REST write response carries an `internal: true` value or a credential-class stored value', () => { it('the enumeration is real: it sees the direct-engine mouth this guard exists for', async () => { const { rest } = await bootRest(); const writes = (rest.getRoutes() as Array<{ method: string; path: string }>) @@ -427,11 +440,11 @@ describe('#8497 tripwire: no REST write response carries an `internal: true` val for (const [key, disposition] of Object.entries(DISPOSITIONS)) { if (disposition.kind !== 'driven') continue; - it(`${key}: response never carries the internal sentinel${disposition.expectRecord ? ', and really returned a record' : ''}`, async () => { + it(`${key}: response never carries the internal or credential sentinels${disposition.expectRecord ? ', and really returned a record' : ''}`, async () => { const booted = await bootRest(); const res = await disposition.invoke(booted); const wire = JSON.stringify(res.body ?? null); - expect(wire.includes(SENTINEL), `${key} leaked an internal field: ${wire}`).toBe(false); + expect(leaks(wire), `${key} leaked an internal or credential-class field: ${wire}`).toEqual([]); if (disposition.expectRecord) { expect( wire.includes(CONTROL), @@ -452,6 +465,23 @@ describe('#8497 tripwire: no REST write response carries an `internal: true` val // The engine's WRITE result is whole — that is exactly what #7823 chose, // and exactly why every mouth above must strip. expect(raw[SECRET_FIELD]).toBe(SENTINEL); + expect(raw[PASSWORD_FIELD]).toBe(CREDENTIAL_SENTINELS[0]); + expect(raw[TOKEN_FIELD]).toBe(CREDENTIAL_SENTINELS[1]); + }, 60_000); + + it('POST /api/v1/batch update arm applies the rules even when the protocol has no helper method', async () => { + // The arm must not depend on an optional protocol method: a protocol + // occupant without it would otherwise skip both rules silently. + const booted = await bootRest(); + const protocol = (booted.rest as unknown as { protocol: Record }).protocol; + Object.defineProperty(protocol, 'omitInternalWriteFields', { value: undefined, configurable: true }); + const id = await seed(booted); + const res = await call(booted, 'POST', '/api/v1/batch', { + body: { operations: [{ object: 'vault', action: 'update', id, data: { name: CONTROL } }] }, + }); + const wire = JSON.stringify(res.body ?? null); + expect(leaks(wire), wire).toEqual([]); + expect(wire.includes(CONTROL), wire).toBe(true); }, 60_000); it('NEGATIVE CONTROL: the machinery goes red on a direct engine mouth that skips the helper', async () => { @@ -469,11 +499,11 @@ describe('#8497 tripwire: no REST write response carries an `internal: true` val }; const leaked = await leakyMouth(); - expect(JSON.stringify(leaked).includes(SENTINEL)).toBe(true); // half 1: the scan bites + expect(leaks(JSON.stringify(leaked))).toEqual(NEVER_SERIALIZED); // half 1: the scan bites // half 2: the shared helper is exactly what closes it. omitInternalFieldsFromWriteResponse(VAULT, leaked.results); - expect(JSON.stringify(leaked).includes(SENTINEL)).toBe(false); + expect(leaks(JSON.stringify(leaked))).toEqual([]); expect(JSON.stringify(leaked).includes(CONTROL)).toBe(true); // …and the record survives }, 60_000); }); diff --git a/packages/runtime/src/action-execution-calldata-write-response.test.ts b/packages/runtime/src/action-execution-calldata-write-response.test.ts new file mode 100644 index 00000000000..89aa4aa0304 --- /dev/null +++ b/packages/runtime/src/action-execution-calldata-write-response.test.ts @@ -0,0 +1,181 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * `callData`'s ObjectQL fallback (no `protocol` slot) is a write mouth of its + * own: its create arm answers with the engine's write result, which keeps the + * stored row whole. It owes the same write-response rules the protocol ingress + * applies — credential-class fields masked, `internal` fields omitted — so the + * answer cannot depend on whether a deployment registered the protocol slot. + * + * Synthetic fixtures; the stored values are sentinels. + */ + +import { describe, it, expect } from 'vitest'; +import { assertEngineUpdateDispatch } from '@objectstack/metadata-core'; +import { SECRET_MASK } from '@objectstack/spec/data'; +import { callData, executeDeclarativeUpdateAction, type ActionExecutionDeps } from './action-execution.js'; +import type { HttpProtocolContext } from './http-dispatcher.js'; + +const EC = { userId: 'u1', isSystem: false, positions: [], permissions: [] } as any; +const REQ = { request: {} } as HttpProtocolContext; +const STORED_PASSWORD = 'PASSWORD-SENTINEL-NEVER-SERIALIZED'; +const STORED_REF = 'secret:HANDLE-SENTINEL-NEVER-SERIALIZED'; +const STORED_INTERNAL = 'INTERNAL-SENTINEL-NEVER-SERIALIZED'; + +const SCHEMA = { + name: 'cred_holder', + fields: { + title: { name: 'title', type: 'text' }, + login_password: { name: 'login_password', type: 'password' }, + api_token: { name: 'api_token', type: 'secret' }, + hidden_hash: { name: 'hidden_hash', type: 'text', internal: true }, + }, +}; + +function fallbackHarness() { + const store = new Map(); + const stored = (row: Record) => ({ + ...row, + login_password: STORED_PASSWORD, + api_token: STORED_REF, + hidden_hash: STORED_INTERNAL, + }); + const ql: any = { + registry: { getObject: (n: string) => (n === 'cred_holder' ? SCHEMA : undefined) }, + insert: async (_o: string, data: any) => { + const row = stored({ ...data, id: 'r1' }); + store.set('r1', row); + return { ...row }; + }, + // The engine's READ path masks/omits — mirrored here so the update echo's + // `existing` half is what a real read returns. + find: async (_o: string, bag: any) => { + const hit = store.get(String(bag?.where?.id)); + if (!hit) return []; + const { hidden_hash: _omit, ...rest } = hit; + return [{ ...rest, login_password: SECRET_MASK, api_token: SECRET_MASK }]; + }, + update: async (_o: string, data: any, opts: any) => { + assertEngineUpdateDispatch(data, opts); + const id = String(opts?.where?.id); + const row = stored({ ...store.get(id), ...data }); + store.set(id, row); + return { ...row }; + }, + }; + const deps: ActionExecutionDeps = { + resolveService: (async (_c: HttpProtocolContext, name: string) => + name === 'metadata' ? { getObject: async () => SCHEMA } : name === 'objectql' ? ql : undefined) as any, + getObjectQL: async () => ql, + }; + return { deps, ql }; +} + +const NEVER = [STORED_PASSWORD, STORED_REF, STORED_INTERNAL, 'caller-sent-password']; +const leaks = (v: unknown) => NEVER.filter((s) => JSON.stringify(v).includes(s)); + +describe('callData fallback write arms apply the write-response rules', () => { + it('create: no stored credential value or internal value in the response; the record still flows', async () => { + const { deps, ql } = fallbackHarness(); + const res = await callData(deps, REQ, 'create', { object: 'cred_holder', data: { title: 'kept' } }, ql, undefined, EC); + expect(leaks(res)).toEqual([]); + expect(res.record.title).toBe('kept'); + expect(res.record.login_password).toBe(SECRET_MASK); + expect(res.record.api_token).toBe(SECRET_MASK); + expect(Object.keys(res.record)).not.toContain('hidden_hash'); + }); + + it('update: the echo never returns a credential the caller wrote, in clear', async () => { + const { deps, ql } = fallbackHarness(); + await ql.insert('cred_holder', { title: 'one' }); + const res = await callData( + deps, REQ, 'update', + { object: 'cred_holder', id: 'r1', data: { title: 'two', login_password: 'caller-sent-password' } }, + ql, undefined, EC, + ); + expect(leaks(res)).toEqual([]); + expect(res.record.title).toBe('two'); + expect(res.record.login_password).toBe(SECRET_MASK); + }); + + it('the fixture is armed: the engine write result really carries the stored values', async () => { + const { ql } = fallbackHarness(); + const raw = await ql.insert('cred_holder', { title: 'x' }); + expect(leaks(raw)).toEqual([STORED_PASSWORD, STORED_REF, STORED_INTERNAL]); + }); +}); + +describe('callData fallback write arms fail closed when no schema resolves', () => { + function schemaLessHarness() { + const { ql } = fallbackHarness(); + // No registry entry, and a metadata service that throws: nothing to judge the fields by. + ql.registry = { getObject: () => undefined }; + const deps: ActionExecutionDeps = { + resolveService: (async (_c: HttpProtocolContext, name: string) => { + if (name === 'metadata') throw new Error('metadata unavailable'); + return name === 'objectql' ? ql : undefined; + }) as any, + getObjectQL: async () => ql, + }; + return { deps, ql }; + } + + it('create: answers the receipt only, carrying no record', async () => { + const { deps, ql } = schemaLessHarness(); + // The exposure gate reads metadata too and falls open on its own; the write arm must not. + const res = await callData(deps, REQ, 'create', { object: 'cred_holder', data: { title: 'kept' } }, ql, undefined, EC); + expect(res).toEqual({ object: 'cred_holder', id: 'r1' }); + expect(leaks(res)).toEqual([]); + }); + + it('update: answers the receipt only, carrying no record', async () => { + const { deps, ql } = schemaLessHarness(); + await ql.insert('cred_holder', { title: 'one' }); + const res = await callData( + deps, REQ, 'update', + { object: 'cred_holder', id: 'r1', data: { login_password: 'caller-sent-password' } }, + ql, undefined, EC, + ); + expect(res).toEqual({ object: 'cred_holder', id: 'r1' }); + expect(leaks(res)).toEqual([]); + }); +}); + +describe('declarative update action result applies the write-response rules', () => { + const ACTION = { name: 'set_cred', operation: 'update', undoable: true, params: [{ name: 'login_password', type: 'text' }] }; + const run = async (opts: { schema: boolean; written?: unknown }) => { + const ql: any = { registry: { getObject: (n: string) => (opts.schema && n === 'cred_holder' ? SCHEMA : undefined) } }; + const deps: ActionExecutionDeps = { + resolveService: (async () => { throw new Error('metadata unavailable'); }) as any, + getObjectQL: async () => ql, + }; + return await executeDeclarativeUpdateAction(deps, ACTION, { + objectName: 'cred_holder', + actionName: 'set_cred', + subject: { record: { id: 'r1', title: 'one', login_password: SECRET_MASK }, recordLoadDenied: false }, + recordId: 'r1', + params: { login_password: 'caller-sent-password', hidden_hash: STORED_INTERNAL }, + ec: EC, + driver: ql, + requestContext: REQ, + callData: async () => opts.written, + }); + }; + + it('redoData and the fallback record mask the patch; a masked redo value replays as unchanged', async () => { + const res: any = await run({ schema: true, written: { object: 'cred_holder', id: 'r1' } }); + expect(leaks(res)).toEqual([]); + expect(res.undo.redoData.login_password).toBe(SECRET_MASK); + expect(Object.keys(res.undo.redoData)).not.toContain('hidden_hash'); + expect(res.record.login_password).toBe(SECRET_MASK); + // The undo half is the prior READ, which was already masked. + expect(res.undo.undoData.login_password).toBe(SECRET_MASK); + }); + + it('fails closed with no schema: no undo anchor and no echoed patch', async () => { + const res: any = await run({ schema: false, written: { object: 'cred_holder', id: 'r1' } }); + expect(leaks(res)).toEqual([]); + expect(res.undo).toBeUndefined(); + expect(res.record).toEqual({ id: 'r1' }); + }); +}); diff --git a/packages/runtime/src/action-execution.ts b/packages/runtime/src/action-execution.ts index 8cec5e47f7a..518ca427121 100644 --- a/packages/runtime/src/action-execution.ts +++ b/packages/runtime/src/action-execution.ts @@ -48,7 +48,7 @@ import type { FlowRunSummary } from '@objectstack/spec/automation'; // A pure factory — no service resolution — so importing it costs the fallback // nothing on an assembly where the protocol plugin is absent, which is exactly // when the fallback runs. -import { recordNotFoundError } from '@objectstack/metadata-protocol'; +import { recordNotFoundError, omitInternalFieldsFromWriteResponse } 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'; @@ -136,6 +136,44 @@ export interface ActionExecutionDeps { * @param dataDriver - Optional environment-scoped driver to use instead of kernel default * @param scopeId - Optional project ID for scoped service resolution (SharedProjectPlugin mode) */ +/** + * The registered object schema a write mouth in this file applies the + * write-response rules (credential-class mask, `internal` omit) from: the + * engine's live registry first, then the metadata service. `undefined` when + * neither answers — callers FAIL CLOSED on that (echo no record), because an + * unjudged record is exactly the one that may carry a stored credential. + */ +export async function resolveWriteResponseSchema( + deps: ActionExecutionDeps, + requestContext: HttpProtocolContext | undefined, + ql: any, + object: string | undefined, + scopeId?: string, +): Promise { + if (!object) return undefined; + try { + const fromEngine = ql?.registry?.getObject?.(object); + if (fromEngine) return fromEngine; + } catch { + // fall through to the metadata service + } + try { + const meta = await deps.resolveService(requestContext as HttpProtocolContext, 'metadata', scopeId); + return (await (meta as any)?.getObject?.(object)) ?? undefined; + } catch { + return undefined; + } +} + +/** The engine for a schema lookup, or `undefined` — a lookup failure is judged by the caller's fail-closed branch. */ +async function resolveEngineQuietly(deps: ActionExecutionDeps, requestContext: HttpProtocolContext | undefined, envId?: string): Promise { + try { + return (await deps.getObjectQL(requestContext as HttpProtocolContext, envId)) ?? undefined; + } catch { + return undefined; + } +} + export async function callData(deps: ActionExecutionDeps, requestContext: HttpProtocolContext, action: string, @@ -170,6 +208,8 @@ export async function callData(deps: ActionExecutionDeps, const base = qlOpts ? { ...qlOpts } : {}; return extra ? { ...base, ...extra } : (qlOpts ? base : undefined); }; + const writeResponseSchema = () => + resolveWriteResponseSchema(deps, requestContext, ql, params?.object, scopeId); if (action === 'create') { // Prefer the protocol service (validations + RLS + audit), mirroring @@ -184,6 +224,14 @@ export async function callData(deps: ActionExecutionDeps, if (ql && typeof ql.insert === 'function') { const res = await ql.insert(params.object, params.data, qlOpts); const record = { ...params.data, ...res }; + // The engine's write result keeps the stored row whole, so this + // fallback mouth owes the same write-response rules the protocol + // ingress applies (credential-class mask, `internal` omit). + // Fail closed: with no schema to judge the fields by, no record + // is echoed at all — only the receipt. + const schema = await writeResponseSchema(); + if (!schema) return { object: params.object, id: record.id }; + omitInternalFieldsFromWriteResponse(schema, record); return { object: params.object, id: record.id, record }; } throw { statusCode: 503, message: 'Data service not available' }; @@ -229,7 +277,14 @@ export async function callData(deps: ActionExecutionDeps, // with it. if (!existing) throw recordNotFoundError(params.object, params.id); await ql.update(params.object, params.data, findOpts({ where: { id: params.id } })); - return { object: params.object, id: params.id, record: { ...existing, ...params.data } }; + const record = { ...existing, ...params.data }; + // The echo carries the caller's own patch: a credential-class value + // in it is masked like a read of the stored row would be. Fail + // closed, as on create: no schema ⇒ the receipt only. + const schema = await writeResponseSchema(); + if (!schema) return { object: params.object, id: params.id }; + omitInternalFieldsFromWriteResponse(schema, record); + return { object: params.object, id: params.id, record }; } throw { statusCode: 503, message: 'Data service not available' }; } @@ -1999,6 +2054,8 @@ export async function executeDeclarativeUpdateAction( ec: any; driver?: any; envId?: string; + /** The request the action arrived on — resolves the schema the result's credential mask reads. */ + requestContext?: HttpProtocolContext; callData: (action: string, params: any, dataDriver?: any, scopeId?: string, ec?: ExecutionContext) => Promise; }, ): Promise { @@ -2056,14 +2113,32 @@ export async function executeDeclarativeUpdateAction( const written = await callData('update', { object: objectName, id: recordId, data }, driver, envId, ec); const prior: Record = subject.record ?? {}; + // The result carries the caller's merged patch (`record`'s fallback, + // `undo.redoData`), so it owes the write-response rules a data-plane write + // response applies: a credential-class value is masked (and a masked value + // replays as "unchanged" through the echoed-mask guard), an `internal` + // value is omitted. Fail closed with no schema to judge by: no echoed + // record, and no undo anchor carrying the patch. + const responseSchema = await resolveWriteResponseSchema( + _deps, + wiring.requestContext, + driver ?? await resolveEngineQuietly(_deps, wiring.requestContext, envId), + objectName, + envId, + ); + const echoed = (bag: Record): Record => { + const copy = { ...bag }; + omitInternalFieldsFromWriteResponse(responseSchema, copy); + return copy; + }; const record: Record = written && typeof written === 'object' && (written as any).record && typeof (written as any).record === 'object' ? (written as any).record - : { ...prior, ...data }; + : responseSchema ? echoed({ ...prior, ...data }) : { id: recordId }; // ── contract point 5: `undoable` gets its anchor ───────────────────────── let undo: DeclarativeUpdateUndo | undefined; - if (action?.undoable === true) { + if (action?.undoable === true && responseSchema) { const undoData: Record = {}; // EXACTLY the fields written — the patch names them, which is the whole // reason `undoable` has an anchor now. `?? null` rather than a @@ -2075,7 +2150,7 @@ export async function executeDeclarativeUpdateAction( objectName, recordId, undoData, - redoData: { ...data }, + redoData: echoed(data), }; } @@ -2231,7 +2306,7 @@ export async function invokeBusinessAction(deps: ActionExecutionDeps, // commit f19475c0a and #15168 each paid for once, on this exact seam. if (isDeclarativeUpdateAction(action)) { const result = await executeDeclarativeUpdateAction(deps, action, { - objectName, actionName: name, subject, recordId, params, ec, driver, envId, callData, + objectName, actionName: name, subject, recordId, params, ec, driver, envId, callData, requestContext, }); return { ok: true, action: action.name, objectName, ...(recordId ? { recordId } : {}), result }; } diff --git a/packages/runtime/src/domains/actions.ts b/packages/runtime/src/domains/actions.ts index f4a0f99ab35..26f4ec3157c 100644 --- a/packages/runtime/src/domains/actions.ts +++ b/packages/runtime/src/domains/actions.ts @@ -695,6 +695,7 @@ export async function handleActionsRequest(deps: DomainHandlerDeps, path: string ec, driver: _context.dataDriver, envId: _context?.environmentId, + requestContext: _context, callData: (a, params, dataDriver, scopeId, execCtx) => actionExec.callData(deps, _context, a, params, dataDriver, scopeId, execCtx), }); diff --git a/scripts/engine-double-contract.pinned.json b/scripts/engine-double-contract.pinned.json index 08439f380ae..fb20cc389bc 100644 --- a/scripts/engine-double-contract.pinned.json +++ b/scripts/engine-double-contract.pinned.json @@ -3671,6 +3671,11 @@ "verb": "findOne", "pinned": 1 }, + { + "file": "packages/runtime/src/action-execution-calldata-write-response.test.ts", + "verb": "update", + "pinned": 1 + }, { "file": "packages/runtime/src/action-governance-scope-divergence.test.ts", "verb": "findOne",