diff --git a/.changeset/21867-flow-trigger-record-credential-mask.md b/.changeset/21867-flow-trigger-record-credential-mask.md new file mode 100644 index 00000000000..7f67ba6acc6 --- /dev/null +++ b/.changeset/21867-flow-trigger-record-credential-mask.md @@ -0,0 +1,27 @@ +--- +'@objectstack/trigger-record-change': minor +'@objectstack/spec': patch +--- + +fix(trigger-record-change)!: a record-change flow's trigger record carries the credential mask and omits internal fields + +Clause-②: no + + + +**BREAKING**: the `record` and `previous` a record-change flow receives are now served on the generic read path's terms (ADR-0100). A credential-class field — every `secret` field, and every `password` field outside the exempt `managedBy` buckets — reads as the mask `SECRET_MASK` when set and `null` when unset, and a field declared `internal: true` is absent. It ships as `minor` under the launch-window convention for a changed answer. No export, schema key or error code is added or removed. + +**What changed.** The trigger built both roots from the engine's own write result, which keeps the stored row whole for privileged in-process callers. A credential's stored value and an internal field's value therefore reached the flow, and from there its variables map, a paused run's persisted state and the read doors over that state. The trigger now projects both roots through `omitInternalFieldsFromWriteResponse` from `@objectstack/core`, the helper every external write response already uses, with the trigger object's definition. Everything downstream inherits the projection: the variables map, a paused run's persisted state and its read doors, and the run a resume rehydrates, in the same process and after a restart. + +**FROM → TO.** +- `{record.}` and `{previous.}` in a record-change flow: FROM the stored value (the plaintext password, or the secret's stored handle) → TO `SECRET_MASK` when set, `null` when unset. +- `{record.}` and `{previous.}`: FROM the stored value → TO absent. + +**If you are affected.** A flow that needs a credential reads it through a privileged binder (the flow credential channel, or a privileged server-side read such as the engine's `resolveSecretField`), never off the trigger record. A start or edge condition that compared such a field with a literal tests whether it is set (`!= null`) instead. A condition that compares `record.` with `previous.` now sees two equal masks whenever the field is set on both sides, so it can no longer detect a change; use a privileged binder to detect a credential change. + +**Runs stored before this release.** The mask applies to trigger records built after the upgrade. Paused runs, and terminal runs that keep a restorable snapshot, created before it still hold the clear values in `variables_json`, `context_json` and `steps_json`. After upgrading, resume, cancel or purge those runs. + +**Unchanged.** +- Every ordinary field of the trigger record keeps its value, and every other flow variable is untouched. +- The engine's own write result, the stored row and the privileged read paths (`resolveSecret`, `resolveSecretField`) are unchanged. +- Records a flow reads later through its data nodes already came through the generic read path, which masks them. diff --git a/docs/qa/platform-checklist/areas/automation.json b/docs/qa/platform-checklist/areas/automation.json index 973d54d692c..edfe6ad936f 100644 --- a/docs/qa/platform-checklist/areas/automation.json +++ b/docs/qa/platform-checklist/areas/automation.json @@ -944,6 +944,98 @@ } ] }, + { + "id": "automation.paused-run-trigger-record-masked", + "title": "A paused record-change run's stored state serves its trigger record masked — credential-class fields as the mask, internal fields absent — to a run-state reader, hot and after a cold boot", + "since": "v17", + "status": "active", + "revision": 1, + "priority": "P2", + "surface": "api", + "personas": [ + "admin", + "a member granted read on sys_automation_run and nothing elevated" + ], + "fixtures": { + "app": "showcase", + "requires": [ + "a FILE-backed database (the cold-boot clause is structurally unreachable on the in-memory store — record the db path in the run env)", + "an object declaring one ordinary field, one password field, one secret field and one internal: true field, and an ACTIVE record-change flow on it (record-after-update) that pauses at a screen node and, after the resume, copies {record.} and {previous.} into a second object", + "a member persona whose permission set grants read on sys_automation_run (and on the echo object) but is not a platform admin" + ], + "knownGaps": [ + "Stock showcase has the credential-class object (showcase_field_zoo carries f_password and f_secret) but no ACTIVE record-change flow on it that pauses: showcase_approver_bindings is draft on purpose. Author the fixture flow at runtime (and the member's permission set) or score the item from its pin, recording which the verdict rests on.", + "The pin reads every surface as the seeded admin. The mask is applied where the trigger record is built, so it does not depend on the reader, but the member-persona reads in steps 3 and 4 are this item's own clause: score them by hand, never from the pin." + ] + }, + "steps": [ + "boot showcase isolated against a file DB (dogfood §0); sign in as the dev admin", + "create a row of the fixture object with all three non-ordinary fields set (a password, a secret, an internal value), then PATCH its ordinary field and its password so the record-change flow fires and pauses", + "as the member persona, read the paused sys_automation_run row by id over GET /data/sys_automation_run/:id and parse variables_json and context_json", + "as the member persona, read GET /automation/:name/runs/:runId", + "resume the run (POST /automation/:name/runs/:runId/resume with the screen's required input) and read the echo row the post-pause node wrote", + "repeat the pause on a second row, stop the server process entirely, cold-boot a second server over the SAME database file, resume there, and read the echo row" + ], + "acceptance": [ + { + "clause": "the paused row's variables_json and context_json serve record, $record and previous with the password and secret fields as the mask and the internal field absent, while the ordinary field reads its value", + "oracle": "api", + "verify": "parse both columns: record/previous password and secret = the SECRET_MASK constant (null where unset), the internal key absent, name = the written value; and no stored credential spelling (the plaintext password, a secret: handle, the internal value) appears anywhere in either column", + "evidence": "row read + parsed columns" + }, + { + "clause": "GET /automation/:name/runs/:runId serves the same masked roots", + "oracle": "api", + "verify": "the run's variables.record and variables.previous carry the mask for both credential fields, omit the internal field, and keep the ordinary field's value; no stored credential spelling in the body", + "evidence": "response body" + }, + { + "clause": "a node after the pause reads the mask off record and previous, and an ordinary field reads its value", + "oracle": "api", + "verify": "the echo row: seen_name = the written name; seen_password, seen_token and seen_previous_password = the mask", + "evidence": "echo row read" + }, + { + "clause": "after a cold boot the rehydrated run resumes with the same masked record", + "oracle": "api", + "verify": "the echo row written by the SECOND process carries the mask for both credential fields and the ordinary field's value", + "evidence": "pre-restart run id + post-restart echo row read" + }, + { + "clause": "the privileged read path is unchanged: the stored row still holds the credential and resolveSecretField still returns the secret's plaintext", + "oracle": "test", + "verify": "run the pin; its armed case reads the engine's write result (plaintext password, secret: handle, internal value) and its last case resolves the secret field to its plaintext", + "evidence": "pin output naming the revision" + } + ], + "negative": [ + "a stored credential spelling anywhere in variables_json, context_json or the run read door — whichever persona reads it — is a FAIL: the run's trigger record is served on the generic read path's terms, so no run-state reader holds more than a read of the record would give it", + "a mask on an ORDINARY field, or on a variable that is not the trigger record, is a FAIL in the other direction: only the trigger record's credential-class and internal fields change" + ], + "traps": [ + "wrong-persona", + "stale-dist", + "seed-data-thin" + ], + "automated": { + "kind": "e2e", + "ref": "packages/qa/dogfood/test/flow-trigger-record-credential-mask.dogfood.test.ts" + }, + "source": [ + "packages/qa/dogfood/test/flow-trigger-record-credential-mask.dogfood.test.ts (the pin, hot and cold boot)", + "packages/triggers/trigger-record-change/src/record-change-trigger.ts#RecordChangeTrigger (buildContext projects record and previous through the write-response helper)", + "packages/core/src/utils/internal-write-response.ts#omitInternalFieldsFromWriteResponse (the one mask-and-omit helper)", + "docs/adr/0100-credential-field-channels.md (masked on every generic channel; plaintext only through a privileged dereference)" + ], + "history": [ + { + "revision": 1, + "date": "2026-10-06", + "change": "initial — the run-state path had no item reading a paused run's stored state as a non-privileged holder; adds it with the hot, cold-boot and privileged-path clauses", + "ref": "#21867" + } + ] + }, { "id": "automation.connector-dispatch-matrix", "title": "connector_action dispatches through every registered connector kind, and the registry feeds the designer pickers", diff --git a/packages/qa/dogfood/test/flow-trigger-record-credential-mask.dogfood.test.ts b/packages/qa/dogfood/test/flow-trigger-record-credential-mask.dogfood.test.ts new file mode 100644 index 00000000000..ded43da92a7 --- /dev/null +++ b/packages/qa/dogfood/test/flow-trigger-record-credential-mask.dogfood.test.ts @@ -0,0 +1,304 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// A paused flow's run state carries its trigger record on the generic read +// path's terms (ADR-0100), on a real boot: the record-change trigger builds the +// flow's `record` and `previous` already masked, so a credential-class field +// reads as the mask (or `null` when unset) and an `internal: true` field is +// absent — in the persisted `sys_automation_run` row (`variables_json`, +// `context_json`), in the run read door (`GET /automation/:name/runs/:runId`), +// and in the nodes that run after the resume, in the same process and in a +// second process booted over the same database file. +// +// ## The composition +// +// `bootStack` with automation on and a FILE-backed database: the real engine, +// crypto provider, SQL driver, suspended-run store, REST layer and record-change +// trigger. One synthetic object holds an ordinary field, one `password` field, +// one `secret` field and one `internal: true` field. One flow fires on its +// update, pauses at a `screen` node, and after the resume copies what it reads +// off `record` into a second object, so what a post-pause node SEES is a stored +// fact. +// +// ## Arming +// +// The scene is real before anything is believed: a privileged engine read shows +// the plaintext password, a `secret:` handle ref and the internal value at rest, +// and the privileged `resolveSecretField` path still returns the secret's +// plaintext after the flow ran. Without that every "absent" below could be true +// because nothing was ever stored. Falsifiability: every assertion of a mask +// sits beside an ordinary field's real value read off the same root. +// +// Fixtures are synthetic. + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { bootStack, type VerifyStack } from '@objectstack/verify'; +import { defineStack } from '@objectstack/spec'; +import { ObjectSchema, Field, SECRET_MASK } from '@objectstack/spec/data'; +import type { Flow } from '@objectstack/spec/automation'; +import { RecordChangeTriggerPlugin } from '@objectstack/trigger-record-change'; + +const OBJ = 'ftrm_vault'; +const ECHO = 'ftrm_echo'; +const FLOW = 'ftrm_vault_paused'; +const PW = 'ftrm_password'; +const TOKEN = 'ftrm_token'; +const HIDDEN = 'ftrm_hidden'; +const PW_VALUE = 'ftrm-plain-credential-41'; +const PW_VALUE_OLD = 'ftrm-plain-credential-40'; +const TOKEN_VALUE = 'ftrm-token-credential-42'; +const HIDDEN_VALUE = 'ftrm-internal-value-43'; +const SYS = { context: { isSystem: true } } as const; + +const Vault = ObjectSchema.create({ + name: OBJ, + label: 'FTRM Vault', + pluralLabel: 'FTRM Vaults', + sharingModel: 'public_read_write', + fields: { + name: Field.text({ label: 'Name', required: true }), + [PW]: Field.password({ label: 'Password', ackPlaintextMasking: true }), + [TOKEN]: Field.secret({ label: 'Token' }), + [HIDDEN]: Field.text({ label: 'Hidden', internal: true }), + }, +}); + +/** What a node after the pause read off `record`, stored so it can be read back. */ +const Echo = ObjectSchema.create({ + name: ECHO, + label: 'FTRM Echo', + pluralLabel: 'FTRM Echoes', + sharingModel: 'public_read_write', + fields: { + name: Field.text({ label: 'Name', required: true }), + seen_name: Field.text({ label: 'Seen name' }), + seen_password: Field.text({ label: 'Seen password' }), + seen_token: Field.text({ label: 'Seen token' }), + seen_previous_password: Field.text({ label: 'Seen previous password' }), + }, +}); + +const flow: Flow = { + name: FLOW, + label: 'FTRM Vault Paused', + type: 'autolaunched', + status: 'active', + nodes: [ + { + id: 'start', + type: 'start', + label: 'On vault updated', + config: { objectName: OBJ, triggerType: 'record-after-update' }, + }, + { + id: 'ask', + type: 'screen', + label: 'Confirm', + config: { + title: 'Confirm', + fields: [{ name: 'note', label: 'Note', type: 'text', required: true }], + }, + }, + { + id: 'echo', + type: 'create_record', + label: 'Echo what the record reads', + config: { + objectName: ECHO, + fields: { + name: '{note}', + seen_name: '{record.name}', + seen_password: `{record.${PW}}`, + seen_token: `{record.${TOKEN}}`, + seen_previous_password: `{previous.${PW}}`, + }, + }, + }, + { id: 'end', type: 'end', label: 'End' }, + ], + edges: [ + { id: 'e1', source: 'start', target: 'ask' }, + { id: 'e2', source: 'ask', target: 'echo' }, + { id: 'e3', source: 'echo', target: 'end' }, + ], +}; + +const fixtureStack = defineStack({ + manifest: { + id: 'com.dogfood.flow-trigger-record-credential-mask', + namespace: 'ftrm', + version: '0.0.0', + type: 'app', + name: 'Flow Trigger Record Credential Mask Fixture', + description: 'One credential-holding object and one record-change flow that pauses.', + }, + // ADR-0097: the flow's record-change start node needs both capabilities. + requires: ['automation', 'triggers'], + objects: [Vault, Echo], + flows: [flow], +}); + +type Row = Record; + +/** Every stored credential spelling no run-state surface may carry. */ +const leaksIn = (body: unknown): string[] => { + const wire = typeof body === 'string' ? body : JSON.stringify(body ?? null); + return [PW_VALUE, PW_VALUE_OLD, TOKEN_VALUE, HIDDEN_VALUE, 'secret:'].filter((v) => wire.includes(v)); +}; + +const boot = (dbFile: string) => + bootStack(fixtureStack as unknown as Parameters[0], { + automation: true, + databaseFile: dbFile, + extraPlugins: [new RecordChangeTriggerPlugin()], + }); + +/** Create a vault row through the engine, then update it through REST so the flow fires and pauses. */ +async function pauseOne( + stack: VerifyStack, + token: string, + name: string, +): Promise<{ id: string; runId: string; stored: Row }> { + const ql: any = await stack.kernel.getServiceAsync('objectql'); + const row: Row = await ql.insert(OBJ, { name, [PW]: PW_VALUE_OLD, [TOKEN]: TOKEN_VALUE, [HIDDEN]: HIDDEN_VALUE }, SYS); + const id = String(row.id); + const res = await stack.apiAs(token, 'PATCH', `/data/${OBJ}/${id}`, { name: `${name}-2`, [PW]: PW_VALUE }); + expect(res.status, await res.clone().text()).toBe(200); + const runs: Row[] = await ql.find('sys_automation_run', { + where: { flow_name: FLOW, status: 'paused', trigger_record_id: id }, + ...SYS, + }); + expect(runs, 'the record-change flow did not pause into sys_automation_run').toHaveLength(1); + // The engine's insert result keeps the stored row whole by design — the + // arming read for the scene. + return { id, runId: String(runs[0].id), stored: row }; +} + +function expectMaskedRoot(root: Row, ordinaryName: string) { + expect(root.name).toBe(ordinaryName); + expect(root[PW]).toBe(SECRET_MASK); + expect(root[TOKEN]).toBe(SECRET_MASK); + expect(HIDDEN in root).toBe(false); +} + +describe('a paused record-change run carries its trigger record masked (ADR-0100)', () => { + let dir: string; + let stack: VerifyStack; + let token: string; + let ql: any; + let id: string; + let runId: string; + let stored: Row; + + beforeAll(async () => { + dir = mkdtempSync(join(tmpdir(), 'os-ftrm-')); + stack = await boot(join(dir, 'verify.sqlite')); + token = await stack.signIn(); + ql = await stack.kernel.getServiceAsync('objectql'); + ({ id, runId, stored } = await pauseOne(stack, token, 'ftrm-a')); + }, 120_000); + + afterAll(async () => { + await stack?.stop?.().catch(() => {}); + if (dir) rmSync(dir, { recursive: true, force: true }); + }); + + it('the scene is armed: the stored row holds the plaintext password, a secret ref and the internal value', () => { + expect(stored[PW]).toBe(PW_VALUE_OLD); + expect(String(stored[TOKEN])).toMatch(/^secret:/); + expect(stored[HIDDEN]).toBe(HIDDEN_VALUE); + }); + + it('the persisted variables_json and context_json carry the mask and omit the internal field', async () => { + const [row]: Row[] = await ql.find('sys_automation_run', { where: { id: runId }, ...SYS }); + expect(row.status).toBe('paused'); + expect(leaksIn(row.variables_json)).toEqual([]); + expect(leaksIn(row.context_json)).toEqual([]); + + const vars = JSON.parse(String(row.variables_json)); + expectMaskedRoot(vars.record, 'ftrm-a-2'); + expectMaskedRoot(vars.$record, 'ftrm-a-2'); + expectMaskedRoot(vars.previous, 'ftrm-a'); + + const context = JSON.parse(String(row.context_json)); + expectMaskedRoot(context.record, 'ftrm-a-2'); + expectMaskedRoot(context.previous, 'ftrm-a'); + }); + + it('the data door over sys_automation_run serves the same masked columns', async () => { + const res = await stack.apiAs(token, 'GET', `/data/sys_automation_run/${runId}`); + expect(res.status).toBe(200); + const rec: Row = ((await res.json()) as any).record; + expect(leaksIn(rec)).toEqual([]); + expectMaskedRoot(JSON.parse(String(rec.variables_json)).record, 'ftrm-a-2'); + }); + + it('GET /automation/:name/runs/:runId shows the same', async () => { + const res = await stack.apiAs(token, 'GET', `/automation/${FLOW}/runs/${runId}`); + expect(res.status, await res.clone().text()).toBe(200); + const body: any = await res.json(); + expect(leaksIn(body)).toEqual([]); + const run = body.data ?? body; + expectMaskedRoot(run.variables.record, 'ftrm-a-2'); + expectMaskedRoot(run.variables.previous, 'ftrm-a'); + }); + + it('a node after the pause reads the mask off record and previous, and an ordinary field reads its value', async () => { + const resumed = await stack.apiAs(token, 'POST', `/automation/${FLOW}/runs/${runId}/resume`, { + inputs: { note: 'ftrm-echo-hot' }, + }); + expect(resumed.status, await resumed.clone().text()).toBeLessThan(300); + const [echo]: Row[] = await ql.find(ECHO, { where: { name: 'ftrm-echo-hot' }, ...SYS }); + expect(echo, 'the post-pause node never ran').toBeDefined(); + expect(echo.seen_name).toBe('ftrm-a-2'); + expect(echo.seen_password).toBe(SECRET_MASK); + expect(echo.seen_token).toBe(SECRET_MASK); + expect(echo.seen_previous_password).toBe(SECRET_MASK); + }); + + it('the privileged resolveSecretField path is unchanged — it still returns the plaintext', async () => { + await expect(ql.resolveSecretField(OBJ, id, TOKEN)).resolves.toBe(TOKEN_VALUE); + }); +}); + +describe('a resumed record-change run reads the mask after a cold boot too', () => { + let dir: string; + let dbFile: string; + let hot: VerifyStack | undefined; + let cold: VerifyStack | undefined; + let runId: string; + + beforeAll(async () => { + dir = mkdtempSync(join(tmpdir(), 'os-ftrm-cold-')); + dbFile = join(dir, 'verify.sqlite'); + hot = await boot(dbFile); + const hotToken = await hot.signIn(); + ({ runId } = await pauseOne(hot, hotToken, 'ftrm-b')); + await hot.stop(); + hot = undefined; + cold = await boot(dbFile); + }, 180_000); + + afterAll(async () => { + await hot?.stop().catch(() => {}); + await cold?.stop().catch(() => {}); + if (dir) rmSync(dir, { recursive: true, force: true }); + }); + + it('the rehydrated run resumes with the masked record — the post-pause node sees the mask', async () => { + const token = await cold!.signIn(); + const resumed = await cold!.apiAs(token, 'POST', `/automation/${FLOW}/runs/${runId}/resume`, { + inputs: { note: 'ftrm-echo-cold' }, + }); + expect(resumed.status, await resumed.clone().text()).toBeLessThan(300); + const ql: any = await cold!.kernel.getServiceAsync('objectql'); + const [echo]: Row[] = await ql.find(ECHO, { where: { name: 'ftrm-echo-cold' }, ...SYS }); + expect(echo, 'the post-pause node never ran after the cold boot').toBeDefined(); + expect(echo.seen_name).toBe('ftrm-b-2'); + expect(echo.seen_password).toBe(SECRET_MASK); + expect(echo.seen_token).toBe(SECRET_MASK); + expect(echo.seen_previous_password).toBe(SECRET_MASK); + }); +}); diff --git a/packages/qa/dogfood/test/per-file-cwd.global-setup.ts b/packages/qa/dogfood/test/per-file-cwd.global-setup.ts index eaa656093d0..284a6f6c9ec 100644 --- a/packages/qa/dogfood/test/per-file-cwd.global-setup.ts +++ b/packages/qa/dogfood/test/per-file-cwd.global-setup.ts @@ -10,22 +10,34 @@ // left by a developer's earlier run on a tree without this isolation, or by // a crashed run. The guard judges only what THIS run leaves, so an old // leftover never reds a run that wrote nothing. -// 2. It creates ONE temporary root for the run and hands it to every worker -// through `provide` / `inject`. Each test file makes its own working -// directory under that root. +// 2. It reserves a TAG for the run, `os-dogfood-run-XXXXXX`, as a directory +// `mkdtempSync` creates under the system temp directory, and hands the tag +// (a name, never a path) to every worker through `provide` / `inject`. +// Each test file makes its own working directory directly under the system +// temp directory, named `-file-XXXXXX`. // -// At the END of the run it removes that root, and with it every per-file -// directory. The removal is run-level, not per-file: on the `shared-showcase` -// project (`isolate: false`) one memoized boot serves every file on a worker, -// and its SQLite handles stay open in the directory of the file that booted it. +// At the END of the run it removes every directory whose name starts with this +// run's `-file-`, then the reservation itself. Another run's directories +// carry another tag, so a concurrent run on the same machine is never touched. +// The removal is run-level, not per-file: on the `shared-showcase` project +// (`isolate: false`) one memoized boot serves every file on a worker, and its +// SQLite handles stay open in the directory of the file that booted it. +// +// Why a tag and not a shared parent path (#21924): every `mkdtempSync` base in +// this tree must be one the tree's scratch-directory scan can read, so that an +// in-tree fixture root can never hide behind an expression +// (`scripts/pm/dispatch-gates.mjs`, "no mkdtempSync site in this tree takes a +// base the scan cannot read"). A path handed over through `inject()` is such an +// expression. `join(tmpdir(), ...)` is not: it is outside the tree by +// construction, whatever name follows it. // // ⛔ This teardown never JUDGES anything. On vitest 4.1.11 an error thrown from // a globalSetup teardown is printed as `error during close` and the run still // exits 0 (measured), so a guard placed here would be a false green. The guard // is a throwing `afterAll` in the per-file module, which fails a test file. -import { mkdtempSync, rmSync } from 'node:fs'; +import { mkdtempSync, readdirSync, rmSync } from 'node:fs'; import { tmpdir } from 'node:os'; -import { join } from 'node:path'; +import { basename, join } from 'node:path'; import { fileURLToPath } from 'node:url'; import type { TestProject } from 'vitest/node'; @@ -34,19 +46,29 @@ const PACKAGE_ROOT = fileURLToPath(new URL('..', import.meta.url)); declare module 'vitest' { export interface ProvidedContext { - /** The run's temporary root; each test file makes its working directory under it. */ - dogfoodCwdRoot: string; + /** The run's tag; each test file makes its working directory as `join(tmpdir(), '-file-')`. */ + dogfoodRunTag: string; } } -let runRoot: string | undefined; +/** The prefix of every per-file directory a run tagged `tag` creates under the system temp directory. */ +export function perFileDirPrefix(tag: string): string { + return `${tag}-file-`; +} + +let reservation: string | undefined; export function setup(project: TestProject): void { rmSync(join(PACKAGE_ROOT, '.objectstack'), { recursive: true, force: true }); - runRoot = mkdtempSync(join(tmpdir(), 'os-dogfood-run-')); - project.provide('dogfoodCwdRoot', runRoot); + reservation = mkdtempSync(join(tmpdir(), 'os-dogfood-run-')); + project.provide('dogfoodRunTag', basename(reservation)); } export function teardown(): void { - if (runRoot) rmSync(runRoot, { recursive: true, force: true }); + if (!reservation) return; + const prefix = perFileDirPrefix(basename(reservation)); + for (const name of readdirSync(tmpdir())) { + if (name.startsWith(prefix)) rmSync(join(tmpdir(), name), { recursive: true, force: true }); + } + rmSync(reservation, { recursive: true, force: true }); } diff --git a/packages/qa/dogfood/test/per-file-cwd.setup.ts b/packages/qa/dogfood/test/per-file-cwd.setup.ts index 0991eb9c55f..dc1d3de3d4b 100644 --- a/packages/qa/dogfood/test/per-file-cwd.setup.ts +++ b/packages/qa/dogfood/test/per-file-cwd.setup.ts @@ -19,10 +19,18 @@ // ## What it does // // At module top level, which runs before the test file's own imports, it makes -// a directory under the run's temporary root and `chdir`s into it. `afterAll` -// restores the previous working directory. The directories are removed at the -// end of the run by the globalSetup, not here: the memoized `shared-showcase` -// boot keeps its SQLite handles open in the first file's directory. +// a directory directly under the system temp directory, named with the run's +// tag (`-file-XXXXXX`), and `chdir`s into it. `afterAll` restores the +// previous working directory. The directories are removed at the end of the +// run by the globalSetup, which sweeps its own tag, not here: the memoized +// `shared-showcase` boot keeps its SQLite handles open in the first file's +// directory. +// +// The base is spelled `join(tmpdir(), ...)` on purpose (#21924): the tree's +// scratch-directory scan must be able to read every `mkdtempSync` base, and a +// path received through `inject()` is one it cannot read. Only the run's TAG +// comes through `inject()`, as a name component, and it is refused below if +// it could carry a separator. // // The invariant for every dogfood author: a file runs in its own temporary // cwd, so anything cwd-relative it writes is its own and disappears with the @@ -38,26 +46,31 @@ // what this run leaves. import { afterAll, inject } from 'vitest'; import { existsSync, mkdtempSync, readdirSync } from 'node:fs'; +import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { fileURLToPath } from 'node:url'; +import { perFileDirPrefix } from './per-file-cwd.global-setup.js'; /** `packages/qa/dogfood`, resolved from this module's own location. */ const PACKAGE_ROOT = fileURLToPath(new URL('..', import.meta.url)); /** What a file must never leave in the package directory. */ const LEFTOVER = join(PACKAGE_ROOT, '.objectstack', 'data'); -const runRoot = inject('dogfoodCwdRoot'); -if (!runRoot) { +const runTag = inject('dogfoodRunTag'); +if (!runTag) { throw new Error( - 'per-file-cwd.setup.ts: no run root was provided. The globalSetup ' + + 'per-file-cwd.setup.ts: no run tag was provided. The globalSetup ' + '`test/per-file-cwd.global-setup.ts` must be wired in packages/qa/dogfood/vitest.config.ts; ' + 'without it this file would run in the package directory.', ); } +if (/[\\/]|\.\./.test(runTag)) { + throw new Error(`per-file-cwd.setup.ts: the run tag ${JSON.stringify(runTag)} is not a plain directory name.`); +} const previousCwd = process.cwd(); const presentAtStart = existsSync(LEFTOVER); -process.chdir(mkdtempSync(join(runRoot, 'file-'))); +process.chdir(mkdtempSync(join(tmpdir(), perFileDirPrefix(runTag)))); afterAll(() => { process.chdir(previousCwd); diff --git a/packages/qa/dogfood/vitest.config.ts b/packages/qa/dogfood/vitest.config.ts index bbef0be21e5..841365bfeba 100644 --- a/packages/qa/dogfood/vitest.config.ts +++ b/packages/qa/dogfood/vitest.config.ts @@ -143,7 +143,7 @@ runProjectCliOverridePreflight({ // `.objectstack/data` exists in the package directory: that throw is the guard. // - The `globalSetup` below is ROOT-level: one run, one call, covering both // projects and each `OS_TEST_SHARD` slice (measured). It clears a stale -// `.objectstack` at the start and removes the run's temporary root at the end. +// `.objectstack` at the start and removes the run's per-file directories at the end. // Its teardown judges nothing, because a throw there exits 0 on vitest 4.1.11. // Both modules' headers carry the rest, including what a dogfood author owes. const PER_FILE_CWD = './test/per-file-cwd.setup.ts'; diff --git a/packages/spec/src/migrations/entries/semantic/18.flow-trigger-record-credential-masked.ts b/packages/spec/src/migrations/entries/semantic/18.flow-trigger-record-credential-masked.ts new file mode 100644 index 00000000000..8d66f31cd0f --- /dev/null +++ b/packages/spec/src/migrations/entries/semantic/18.flow-trigger-record-credential-masked.ts @@ -0,0 +1,40 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import type { SemanticMigration } from '../../types.js'; + +// A value a flow reads, not an authorable key: there is no D2 conversion and +// nothing for `objectstack migrate meta` to rewrite. The sibling of +// `18.by-id-write-unreadable-row-not-found` in kind — the entry carries the +// changed answer to the one reader the ledger serves here, the upgrade guide, +// because a flow that read a credential off its trigger record has no schema +// error to find it by. No backticks in `surface`: the upgrade guide renders it +// inside a code span and a table cell. +export const entry: SemanticMigration = { + id: 'flow-trigger-record-credential-masked', + surface: + 'the record and previous roots a record-change flow receives — a password or secret field, ' + + 'and an internal field, of the triggering record, on every object', + replacement: + 'read a credential through a privileged binder — the flow credential channel for an http node\'s ' + + 'signing secret, or a privileged server-side read such as the engine\'s resolveSecretField — ' + + 'never off `record` or `previous`; on those roots ' + + 'a set credential-class field now reads as the mask `SECRET_MASK`, an unset one as null, and an ' + + '`internal: true` field is absent', + reason: + 'ADR-0100: a credential-class value leaves the engine only through a privileged dereference, and ' + + 'every generic channel serves the mask. The record-change trigger built a flow\'s record and ' + + 'previous from the engine\'s own write result, which keeps the stored row whole for privileged ' + + 'in-process callers, so a password field\'s plaintext, a secret field\'s stored handle and an ' + + 'internal field\'s value reached the flow — and from there its variables, a paused run\'s ' + + 'persisted state and that state\'s read doors. The trigger now projects both roots through the ' + + 'same helper every external write response uses: a credential-class field (secret, and ' + + 'password outside the exempt managedBy buckets) carries the mask, or null when unset, and an ' + + 'internal field is omitted. Every other field keeps its value, every other variable is ' + + 'untouched, and the engine\'s own write result, the stored row and the privileged read paths ' + + 'are unchanged.', + acceptanceCriteria: + 'No flow reads a password, secret or internal field off its trigger record or previous values ' + + 'expecting the stored value; a flow that needs a credential obtains it through a privileged ' + + 'binder; a start or edge condition that compared such a field against a literal is rewritten to ' + + 'test whether it is set (not null).', +}; diff --git a/packages/spec/src/migrations/registry.ts b/packages/spec/src/migrations/registry.ts index 134b21ea792..a42138475e1 100644 --- a/packages/spec/src/migrations/registry.ts +++ b/packages/spec/src/migrations/registry.ts @@ -13505,6 +13505,42 @@ const step18: MigrationStep = { + 'predicate parses and registers byte-identically to before, and a non-string in these ' + 'slots keeps its own earlier refusal (at `registerFlow` and `objectstack validate`).', }, + // A value a flow reads, not an authorable key: there is no D2 conversion and + // nothing for `objectstack migrate meta` to rewrite. The sibling of + // `18.by-id-write-unreadable-row-not-found` in kind — the entry carries the + // changed answer to the one reader the ledger serves here, the upgrade guide, + // because a flow that read a credential off its trigger record has no schema + // error to find it by. No backticks in `surface`: the upgrade guide renders it + // inside a code span and a table cell. + { + id: 'flow-trigger-record-credential-masked', + surface: + 'the record and previous roots a record-change flow receives — a password or secret field, ' + + 'and an internal field, of the triggering record, on every object', + replacement: + 'read a credential through a privileged binder — the flow credential channel for an http node\'s ' + + 'signing secret, or a privileged server-side read such as the engine\'s resolveSecretField — ' + + 'never off `record` or `previous`; on those roots ' + + 'a set credential-class field now reads as the mask `SECRET_MASK`, an unset one as null, and an ' + + '`internal: true` field is absent', + reason: + 'ADR-0100: a credential-class value leaves the engine only through a privileged dereference, and ' + + 'every generic channel serves the mask. The record-change trigger built a flow\'s record and ' + + 'previous from the engine\'s own write result, which keeps the stored row whole for privileged ' + + 'in-process callers, so a password field\'s plaintext, a secret field\'s stored handle and an ' + + 'internal field\'s value reached the flow — and from there its variables, a paused run\'s ' + + 'persisted state and that state\'s read doors. The trigger now projects both roots through the ' + + 'same helper every external write response uses: a credential-class field (secret, and ' + + 'password outside the exempt managedBy buckets) carries the mask, or null when unset, and an ' + + 'internal field is omitted. Every other field keeps its value, every other variable is ' + + 'untouched, and the engine\'s own write result, the stored row and the privileged read paths ' + + 'are unchanged.', + acceptanceCriteria: + 'No flow reads a password, secret or internal field off its trigger record or previous values ' + + 'expecting the stored value; a flow that needs a credential obtains it through a privileged ' + + 'binder; a start or edge condition that compared such a field against a literal is rewritten to ' + + 'test whether it is set (not null).', + }, // #21654 — the D3 entry for `FlowSchema`'s refusal of a write node aimed at a // stored-metadata table: the save-time half of #21624, which applies #21520's // ruling A (record 5965059068) to flows, whose run-time half refuses the same diff --git a/packages/triggers/trigger-record-change/src/record-change-trigger.ts b/packages/triggers/trigger-record-change/src/record-change-trigger.ts index 561545dd9a9..04e3ca86825 100644 --- a/packages/triggers/trigger-record-change/src/record-change-trigger.ts +++ b/packages/triggers/trigger-record-change/src/record-change-trigger.ts @@ -2,6 +2,7 @@ import type { AutomationContext } from '@objectstack/spec/contracts'; import type { HookContext } from '@objectstack/spec/data'; +import { omitInternalFieldsFromWriteResponse } from '@objectstack/core'; import { decoupleFromEngineState } from './decouple-flow-record.js'; @@ -213,6 +214,11 @@ export class RecordChangeTrigger implements FlowTrigger { * no unbounded growth. */ private readonly hydrationCache = new WeakMap | undefined>>>(); + /** + * Objects whose definition {@link readObjectDefinition} already reported as + * unresolved — the `error` is said once per object, not once per write. + */ + private readonly unresolvedDefinitionLogged = new Set(); constructor(engine: RecordChangeDataEngine, logger: TriggerLogger) { this.engine = engine; @@ -472,6 +478,35 @@ export class RecordChangeTrigger implements FlowTrigger { const isolatedRecord = decoupleFromEngineState(hydrated, isolation); const isolatedPrevious = decoupleFromEngineState(materializedPrevious, isolation); + // ADR-0100 — the flow's trigger record is served on the GENERIC read + // path's terms: a credential-class field (`secret`, and `password` + // outside the exempt `managedBy` buckets) carries `SECRET_MASK` (or + // `null` when unset) and an `internal: true` field is omitted. The + // engine's own write result keeps the stored row whole for privileged + // in-process callers, so this consumer — the one that hands the row to + // flow authoring — is where the mask belongs, through THE one helper + // every other external mouth uses (no second statement of the rule). + // Everything downstream inherits it: the variables map (`record`, + // `$record`, `previous`), a paused run's persisted state and its read + // doors, and the run a resume rehydrates, in-process or after a + // restart. A flow that needs a credential reads it through a + // privileged binder, never off the trigger record. + // + // Applied LAST — after hydration, materialisation and the decoupling + // copy — so no later layer re-introduces a clear value, and IN PLACE on + // the decoupled copies only, so the engine's `ctx.result` / + // `ctx.previous` (shared with every other binding and hook on this + // write) are never touched. The definition is read here regardless of + // `groundTruth`: materialisation needs persisted state, the mask does + // not. No definition ⇒ nothing to mask, and nothing upstream refuses + // that case: the bind-time existence probe in `start()` only WARNS and + // still binds, so a write to an object whose definition cannot be + // resolved here still dispatches. `readObjectDefinition` says so once + // per object at `error` — the flow then receives the record unmasked. + const definition = object ? this.readObjectDefinition(object, binding.flowName) : undefined; + omitInternalFieldsFromWriteResponse(definition, isolatedRecord); + omitInternalFieldsFromWriteResponse(definition, isolatedPrevious); + return { record: isolatedRecord, previous: isolatedPrevious, @@ -501,6 +536,44 @@ export class RecordChangeTrigger implements FlowTrigger { }; } + /** + * The trigger object's registered definition, through the engine's + * optional `getObject` accessor — `undefined` when the accessor is absent, + * answers nothing, or throws. Read for the ADR-0100 mask in + * {@link buildContext}. + * + * An unresolved definition does NOT stop the dispatch (behaviour is + * unchanged: the flow still runs), but it means the mask cannot be + * applied, so it is logged at `error` — once per object for this trigger, + * not once per write — naming the object, the consequence and the fix. + */ + private readObjectDefinition(object: string, flowName: string): unknown { + const getObj = this.engine.getObject; + let definition: unknown; + let reason: string; + if (typeof getObj !== 'function') { + reason = 'the data engine exposes no getObject accessor'; + } else { + try { + definition = getObj.call(this.engine, object) ?? undefined; + reason = 'getObject returned no definition'; + } catch (err) { + reason = `getObject threw: ${(err as Error)?.message ?? String(err)}`; + } + } + if (definition === undefined && !this.unresolvedDefinitionLogged.has(object)) { + this.unresolvedDefinitionLogged.add(object); + const log = this.logger.error?.bind(this.logger) ?? this.logger.warn.bind(this.logger); + log( + `[record-change] object '${object}' definition could not be resolved (${reason}) — flow '${flowName}' still runs, ` + + `but its trigger record and previous values are NOT masked: credential-class fields and internal fields reach the flow, ` + + `its variables and any persisted run state as stored, and nothing else will look wrong. ` + + `Fix: register the object with the data engine under this exact name so getObject resolves it.`, + ); + } + return definition; + } + /** * Re-read the just-written record through the data engine so the seeded * `record` carries the SAME read-time computed fields the data API returns — diff --git a/packages/triggers/trigger-record-change/src/trigger-record-credential-mask.test.ts b/packages/triggers/trigger-record-change/src/trigger-record-credential-mask.test.ts new file mode 100644 index 00000000000..3b16fe4b89f --- /dev/null +++ b/packages/triggers/trigger-record-change/src/trigger-record-credential-mask.test.ts @@ -0,0 +1,266 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * ADR-0100 — the flow's trigger record is served on the generic read path's + * terms. `RecordChangeTrigger.buildContext` projects BOTH roots it hands a flow + * (`record` and `previous`, and `params`, which is the same object as + * `record`) through `omitInternalFieldsFromWriteResponse` with the trigger + * object's definition: a credential-class field carries `SECRET_MASK` (or + * `null` when unset), an `internal: true` field is omitted, every other field + * keeps its value, and the engine's own hook objects are left untouched. + * + * Everything downstream of the trigger (the variables map, a paused run's + * persisted state and its read doors, a resumed run) inherits the projection; + * the end-to-end half of that is pinned in + * `packages/qa/dogfood/test/flow-trigger-record-credential-mask.dogfood.test.ts`. + */ + +import { describe, it, expect, vi } from 'vitest'; +import type { AutomationContext } from '@objectstack/spec/contracts'; +import type { HookContext } from '@objectstack/spec/data'; +import { SECRET_MASK } from '@objectstack/spec/data'; +import { + RecordChangeTrigger, + type FlowTriggerBinding, + type RecordChangeDataEngine, + type TriggerLogger, +} from './record-change-trigger.js'; + +const VAULT_FIELDS = { + name: { type: 'text' }, + f_password: { type: 'password' }, + f_secret: { type: 'secret' }, + f_internal: { type: 'text', internal: true }, +}; + +type Hook = { event: string; handler: (ctx: HookContext) => unknown | Promise }; + +function engineWith(schema: Record | undefined): { engine: RecordChangeDataEngine; hooks: Hook[] } { + const hooks: Hook[] = []; + const engine: RecordChangeDataEngine = { + registerHook(event, handler) { + hooks.push({ event, handler }); + }, + unregisterHooksByPackage() { + return 0; + }, + getObject: (name: string) => (name === 'vault' ? (schema as never) : undefined), + }; + return { engine, hooks }; +} + +const logger: TriggerLogger = { info: () => {}, warn: () => {}, debug: () => {} }; + +const binding: FlowTriggerBinding = { flowName: 'vault_flow', object: 'vault', event: 'record-after-update' }; + +function updateCtx(): HookContext { + return { + object: 'vault', + event: 'afterUpdate', + input: { id: 'v1', data: { name: 'renamed', f_password: 'new-plain' } }, + result: { id: 'v1', name: 'renamed', f_password: 'new-plain', f_secret: 'sec_ref_1', f_internal: 'hidden-now' }, + previous: { id: 'v1', name: 'original', f_password: 'old-plain', f_secret: 'sec_ref_0', f_internal: 'hidden-before' }, + session: { userId: 'u1' }, + ql: {}, + } as unknown as HookContext; +} + +async function fire( + schema: Record | undefined, + ctx: HookContext, + on: FlowTriggerBinding = binding, +): Promise { + const { engine, hooks } = engineWith(schema); + const trigger = new RecordChangeTrigger(engine, logger); + let seen: AutomationContext | undefined; + trigger.start(on, async (c) => { + seen = c; + }); + expect(hooks).toHaveLength(1); + await hooks[0].handler(ctx); + expect(seen, 'the flow callback never ran').toBeDefined(); + return seen!; +} + +describe('the trigger record a flow receives carries the credential mask (ADR-0100)', () => { + it('masks `password` and `secret` on `record` AND `previous`, and omits `internal: true` fields', async () => { + const c = await fire({ name: 'vault', fields: VAULT_FIELDS }, updateCtx()); + const record = c.record as Record; + const previous = c.previous as Record; + + expect(record.f_password).toBe(SECRET_MASK); + expect(record.f_secret).toBe(SECRET_MASK); + expect('f_internal' in record).toBe(false); + + expect(previous.f_password).toBe(SECRET_MASK); + expect(previous.f_secret).toBe(SECRET_MASK); + expect('f_internal' in previous).toBe(false); + }); + + it('an ordinary field still reads its value on both roots', async () => { + const c = await fire({ name: 'vault', fields: VAULT_FIELDS }, updateCtx()); + expect((c.record as Record).name).toBe('renamed'); + expect((c.previous as Record).name).toBe('original'); + expect((c.record as Record).id).toBe('v1'); + }); + + it('`params` is the same masked object as `record`', async () => { + const c = await fire({ name: 'vault', fields: VAULT_FIELDS }, updateCtx()); + expect(c.params).toBe(c.record); + }); + + it('an UNSET credential field reads `null`, never the mask', async () => { + const ctx = updateCtx(); + (ctx.result as Record).f_secret = null; + (ctx.previous as Record).f_password = null; + const c = await fire({ name: 'vault', fields: VAULT_FIELDS }, ctx); + expect((c.record as Record).f_secret).toBeNull(); + expect((c.previous as Record).f_password).toBeNull(); + }); + + it("leaves the engine's own hook objects whole — privileged in-process readers are unchanged", async () => { + const ctx = updateCtx(); + await fire({ name: 'vault', fields: VAULT_FIELDS }, ctx); + expect(ctx.result).toEqual({ + id: 'v1', name: 'renamed', f_password: 'new-plain', f_secret: 'sec_ref_1', f_internal: 'hidden-now', + }); + expect(ctx.previous).toEqual({ + id: 'v1', name: 'original', f_password: 'old-plain', f_secret: 'sec_ref_0', f_internal: 'hidden-before', + }); + expect((ctx.input as { data: Record }).data.f_password).toBe('new-plain'); + }); + + it('masks on an insert too (no prior row: the definition is read regardless of ground truth)', async () => { + const ctx = { + object: 'vault', + event: 'afterInsert', + input: { data: { name: 'n', f_password: 'p' } }, + result: { id: 'v2', name: 'n', f_password: 'p', f_secret: 'sec_ref_2', f_internal: 'x' }, + session: {}, + ql: {}, + } as unknown as HookContext; + const c = await fire({ name: 'vault', fields: VAULT_FIELDS }, ctx); + const record = c.record as Record; + expect(record.f_password).toBe(SECRET_MASK); + expect(record.f_secret).toBe(SECRET_MASK); + expect('f_internal' in record).toBe(false); + expect(record.name).toBe('n'); + }); + + it('a `password` field on a better-auth managed object stays clear — the same exemption the read path applies', async () => { + const c = await fire({ name: 'vault', managedBy: 'better-auth', fields: VAULT_FIELDS }, updateCtx()); + const record = c.record as Record; + expect(record.f_password).toBe('new-plain'); + expect(record.f_secret).toBe(SECRET_MASK); + }); + + it('masks on an `afterDelete`, where `record` comes from the prior row', async () => { + const ctx = { + object: 'vault', + event: 'afterDelete', + input: { id: 'v1' }, + previous: { id: 'v1', name: 'gone', f_password: 'old-plain', f_secret: 'sec_ref_0', f_internal: 'hidden-before' }, + session: { userId: 'u1' }, + ql: {}, + } as unknown as HookContext; + const c = await fire({ name: 'vault', fields: VAULT_FIELDS }, ctx, { + flowName: 'vault_flow', object: 'vault', event: 'record-after-delete', + }); + const record = c.record as Record; + const previous = c.previous as Record; + expect(record.name, 'the record is seeded from the prior row').toBe('gone'); + for (const root of [record, previous]) { + expect(root.f_password).toBe(SECRET_MASK); + expect(root.f_secret).toBe(SECRET_MASK); + expect('f_internal' in root).toBe(false); + } + expect((ctx.previous as Record).f_password).toBe('old-plain'); + }); + + it('masks on a `beforeUpdate`, where `record` is the payload over the prior row', async () => { + const ctx = { + object: 'vault', + event: 'beforeUpdate', + input: { id: 'v1', data: { name: 'renamed', f_password: 'new-plain' } }, + previous: { id: 'v1', name: 'original', f_password: 'old-plain', f_secret: 'sec_ref_0', f_internal: 'hidden-before' }, + session: { userId: 'u1' }, + ql: {}, + } as unknown as HookContext; + const c = await fire({ name: 'vault', fields: VAULT_FIELDS }, ctx, { + flowName: 'vault_flow', object: 'vault', event: 'record-before-update', + }); + const record = c.record as Record; + const previous = c.previous as Record; + expect(record.name).toBe('renamed'); + expect(previous.name).toBe('original'); + for (const root of [record, previous]) { + expect(root.f_password).toBe(SECRET_MASK); + expect(root.f_secret).toBe(SECRET_MASK); + expect('f_internal' in root).toBe(false); + } + expect((ctx.input as { data: Record }).data.f_password).toBe('new-plain'); + }); +}); + +describe('an unresolvable trigger-object definition is logged at error and still dispatches', () => { + // No `previous` on this update, so the materialisation read (gated on + // ground truth) is skipped and the definition read for the mask is the + // only `getObject` call a dispatch makes. + function noPriorUpdate(): HookContext { + return { + object: 'vault', + event: 'afterUpdate', + input: { id: 'v1', data: { name: 'renamed' } }, + result: { id: 'v1', name: 'renamed' }, + session: { userId: 'u1' }, + ql: {}, + } as unknown as HookContext; + } + + const cases: Array<[string, RecordChangeDataEngine['getObject']]> = [ + ['getObject is absent', undefined], + ['getObject returns nothing', () => undefined], + [ + 'getObject throws', + () => { + throw new Error('registry offline'); + }, + ], + ]; + + for (const [label, getObject] of cases) { + it(`${label}: one error naming the object, the flow still runs on every write`, async () => { + const hooks: Hook[] = []; + const engine: RecordChangeDataEngine = { + registerHook(event, handler) { + hooks.push({ event, handler }); + }, + unregisterHooksByPackage() { + return 0; + }, + ...(getObject ? { getObject } : {}), + }; + const error = vi.fn(); + const trigger = new RecordChangeTrigger(engine, { info: () => {}, warn: () => {}, debug: () => {}, error }); + let runs = 0; + trigger.start(binding, async () => { + runs += 1; + }); + await hooks[0].handler(noPriorUpdate()); + await hooks[0].handler(noPriorUpdate()); + + expect(runs, 'dispatch is unchanged: the flow ran for both writes').toBe(2); + expect(error, 'said once per object, not once per write').toHaveBeenCalledTimes(1); + expect(String(error.mock.calls[0][0])).toContain("object 'vault'"); + }); + } + + it('a resolved definition logs no error', async () => { + const { engine, hooks } = engineWith({ name: 'vault', fields: VAULT_FIELDS }); + const error = vi.fn(); + const trigger = new RecordChangeTrigger(engine, { info: () => {}, warn: () => {}, debug: () => {}, error }); + trigger.start(binding, async () => {}); + await hooks[0].handler(updateCtx()); + expect(error).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/triggers/trigger-record-change/vitest.config.ts b/packages/triggers/trigger-record-change/vitest.config.ts index 5a7df708fc3..b095a97a8b6 100644 --- a/packages/triggers/trigger-record-change/vitest.config.ts +++ b/packages/triggers/trigger-record-change/vitest.config.ts @@ -14,9 +14,10 @@ import path from 'path'; * `exports` to `dist/` with no config, so it is aliased to source here — * exactly the fix that gate's own header prescribes, not a widening of its * `KNOWN_UNALIASED_TEST_IMPORTS` registry entry for this package (which stays - * unchanged: `@objectstack/core`, `@objectstack/driver-sql`, - * `@objectstack/objectql`, `@objectstack/service-automation` are untouched by - * this file and remain that registry's problem to eventually retire). + * unchanged: `@objectstack/driver-sql`, `@objectstack/objectql`, + * `@objectstack/service-automation` are untouched by this file and remain that + * registry's problem to eventually retire; `@objectstack/core` has since been + * aliased below and taken off that entry). */ export default defineConfig({ test: { @@ -31,8 +32,17 @@ export default defineConfig({ environment: 'node', }, resolve: { - alias: { - '@objectstack/formula': path.resolve(__dirname, '../../formula/src/index.ts'), - }, + // Anchored array form: the object form matches by PREFIX, so a bare + // `@objectstack/spec` key would swallow every subpath (the ENOTDIR trap + // `check-test-source-alias` names). `@objectstack/spec/data` is imported + // for a VALUE (`SECRET_MASK`) by `trigger-record-credential-mask.test.ts`. + alias: [ + { find: /^@objectstack\/formula$/, replacement: path.resolve(__dirname, '../../formula/src/index.ts') }, + { find: /^@objectstack\/spec\/data$/, replacement: path.resolve(__dirname, '../../spec/src/data/index.ts') }, + // `@objectstack/core` carries `omitInternalFieldsFromWriteResponse`, the + // ADR-0100 mask helper `record-change-trigger.ts` applies, so the mask + // pins read it from source rather than from core's `dist/`. + { find: /^@objectstack\/core$/, replacement: path.resolve(__dirname, '../../core/src/index.ts') }, + ], }, }); diff --git a/scripts/check-test-source-alias.mjs b/scripts/check-test-source-alias.mjs index 466e681dc0a..e7475347403 100644 --- a/scripts/check-test-source-alias.mjs +++ b/scripts/check-test-source-alias.mjs @@ -557,8 +557,7 @@ const KNOWN_UNALIASED_TEST_IMPORTS = { '@objectstack/spec', '@objectstack/types', ], '@objectstack/trigger-record-change': [ - '@objectstack/core', '@objectstack/driver-sql', '@objectstack/objectql', - '@objectstack/service-automation', + '@objectstack/driver-sql', '@objectstack/objectql', '@objectstack/service-automation', ], '@objectstack/trigger-schedule': ['@objectstack/service-automation', '@objectstack/spec'], '@objectstack/types': ['@objectstack/spec'],