diff --git a/.changeset/22301-spec-ledger-verify-provenance.md b/.changeset/22301-spec-ledger-verify-provenance.md index 54541486a17..ec704943fe2 100644 --- a/.changeset/22301-spec-ledger-verify-provenance.md +++ b/.changeset/22301-spec-ledger-verify-provenance.md @@ -6,4 +6,4 @@ feat(spec): the error-code ledger lists `INVALID_REQUEST` under `@objectstack/ve Clause-②: yes (widening: a new owner provenance row in the published error-code ledger) -`ERROR_CODE_LEDGER['@objectstack/verify']` now lists `INVALID_REQUEST`. The verify handle's two update doors answer it for a malformed call, such as `{ system: true }` on an insert or delete, or a `hooks.updateWhere` the engine would write by id. They answer in-process, with a thrown `Error` carrying `code`, `status` and `statusCode`; it is a test door with no HTTP path. No code is added: `ErrorCode`, `RegisteredErrorCode` and `REGISTERED_ERROR_CODES` are unchanged, so `ApiErrorSchema` accepts exactly what it accepted before. +`ERROR_CODE_LEDGER['@objectstack/verify']` now lists `INVALID_REQUEST`. The verify handle's two write doors, `hooks.run` and `hooks.updateWhere`, answer it for a malformed call, such as a call naming two callers (`{ as: token, system: true }`), or a `hooks.updateWhere` the engine would write by id. They answer in-process, with a thrown `Error` carrying `code`, `status` and `statusCode`; it is a test door with no HTTP path. No code is added: `ErrorCode`, `RegisteredErrorCode` and `REGISTERED_ERROR_CODES` are unchanged, so `ApiErrorSchema` accepts exactly what it accepted before. diff --git a/.changeset/22301-verify-system-insert-delete-door.md b/.changeset/22301-verify-system-insert-delete-door.md new file mode 100644 index 00000000000..e15b47827a3 --- /dev/null +++ b/.changeset/22301-verify-system-insert-delete-door.md @@ -0,0 +1,14 @@ +--- +'@objectstack/verify': minor +--- + +feat(verify): `hooks.run` takes the system principal on an insert and a delete, so a user-less record trigger can be driven + +Clause-②: yes (widening) + +An app's tests can drive a record-change flow fired by a write with no user (an integration's or a system job's insert or delete) through the in-process handle, so a suite no longer hands a flow a record through the booted kernel's `automation` service by hand. + +- **`hooks.run(object, 'insert' | 'delete', input, { system: true })`** is the engine's own `insert` / `delete` under `{ isSystem: true }`, the same call the person's write makes with only the context changed. `{ system: true }` on `update` is unchanged. No permission gate applies. The bound hooks still run (they see `session.isSystem` and no `userId`), declared validations still refuse, and the record-change trigger fires the object's flows with no trigger user. +- **A record the engine no longer holds.** A system (or a person's) delete hands a `record-after-delete` flow the row's pre-image as `record` and as `previous`, and by then the engine no longer holds the row: the flow's `get_record` of that id finds nothing. A `record-before-delete` flow on the same delete still finds it. +- **Not `seed`.** `seed(object, rows)` also runs as the system, and it sets `skipTriggers`, so it fires no record-change flow: use it for end-state fixture data, and `{ system: true }` for a write the app should react to. +- A call naming both callers (`{ as: token, system: true }`) is still refused with `code: 'INVALID_REQUEST'`, `status: 400`, before anything is written. Every other refusal is the engine's own error, unchanged: assert on its `code` and `status`. diff --git a/.changeset/22301-verify-update-doors.md b/.changeset/22301-verify-update-doors.md index e991d4a1566..ae8240f8225 100644 --- a/.changeset/22301-verify-update-doors.md +++ b/.changeset/22301-verify-update-doors.md @@ -9,7 +9,7 @@ Clause-②: yes (widening) An app's tests reach two more engine write paths through the in-process handle, so a suite no longer calls the booted kernel's `objectql` service by hand for them. - **The system principal on an update.** `hooks.run(object, 'update', { id, ...fields }, { system: true })` writes one row by id under `{ isSystem: true }`, the context a system job or an integration writes under. No permission gate applies. The bound hooks still run (they see `session.isSystem` and no `userId`), declared validations still refuse, and the record-change trigger fires its flows with no trigger user. The new type is `AsSystem` (`{ system: true }`), exported beside `AsUser`. - - Only `update` takes it. `{ system: true }` on `insert` or `delete`, or beside an `as` token, is refused with `code: 'INVALID_REQUEST'`, `status: 400` before anything is written. Fixture rows are still `seed(object, rows)`, which also skips record triggers. + - `{ system: true }` beside an `as` token is refused with `code: 'INVALID_REQUEST'`, `status: 400` before anything is written. Fixture rows are still `seed(object, rows)`, which also skips record triggers. (`insert` and `delete` take `{ system: true }` too: see the system insert and delete entry.) - **The predicate update.** `hooks.updateWhere(object, where, data, opts)` is the engine's own predicate path, `update(object, data, { where, multi: true, context })`. It writes one payload to every row `where` selects and resolves with the affected-row count. The engine dispatches `beforeUpdate` and `afterUpdate` once per matched row, each bound to that row's own pre-image as `previous`, so record-change flows evaluate and fire per row. No REST door reaches this path: `POST /data/:object/updateMany` writes by id. The caller is `{ as: token }` or `{ system: true }`. - It refuses with `code: 'INVALID_REQUEST'`, `status: 400`, before the engine is touched, a `where` that is not an object, and a call the engine's own update dispatch would write by id: a `where` that names only an `id`, or an `id` in `data` beside a `where` that selects by nothing else. Write one row by id through `hooks.run(object, 'update', { id, ...fields }, opts)`. An `id` inside a larger predicate (`{ id, status: 'open' }`) stays a predicate update. An `id` in `data` beside a real predicate is refused by the engine itself, as it is on every caller. diff --git a/packages/verify/README.md b/packages/verify/README.md index c47bc5d3c73..d33ece85f8c 100644 --- a/packages/verify/README.md +++ b/packages/verify/README.md @@ -100,9 +100,14 @@ expect(deal.expected_revenue).toBe(6_000); // the hook derived it await expect(stack.hooks.run('crm_vault', 'insert', { name: 'x' }, { as: rep })) .rejects.toMatchObject({ code: 'PERMISSION_DENIED', statusCode: 403 }); -// An update as the system principal (no user): no permission gate, but the -// hooks, the validations and the record-change flows all run. +// A write as the system principal (no user), the write an integration or a +// system job makes: no permission gate, but the hooks, the validations and the +// record-change flows all run, with no trigger user. Insert, update or delete. await stack.hooks.run('crm_opportunity', 'update', { id: deal.id, stage: 'closed_won' }, { system: true }); +const lead = await stack.hooks.run('crm_lead', 'insert', { last_name: 'Ada', company: 'Globex' }, { system: true }); +// A delete-triggered flow is handed the pre-image, and the engine no longer +// holds the row: its `get_record` of that id finds nothing. +await stack.hooks.run('crm_lead', 'delete', { id: lead.id }, { system: true }); // A predicate update: one payload for every matched row, hooks and record // flows once per row with that row's own `previous`. Resolves the count. @@ -144,19 +149,21 @@ await stack.stop(); stack — the handle resolves it through the dispatcher's own identity resolver (`contextFor(token)` exposes that context for services the handle does not cover). There is no way to run as "nobody"; `seed`, the default `rows` and - an update passed `{ system: true }` (`hooks.run(…, 'update', …)`, - `hooks.updateWhere`) run as the system principal, deliberately and by name. + a write passed `{ system: true }` (`hooks.run` on `insert`, `update` or + `delete`, and `hooks.updateWhere`) run as the system principal, deliberately + and by name. `seed` fires no record-change flow (it is end-state fixture + data); a `{ system: true }` write does. - A refusal from `flows.*` / `actions.run` is the route's ADR-0112 envelope (`VerifyRefusal`: `code`, `status`, `details`; `isVerifyRefusal(e)`); a refusal from `hooks.run` / `hooks.updateWhere` / `validate` / `rows` / `automation.evaluateCondition` is the engine's own error (a condition the engine cannot evaluate rejects, never answers `false`; on a stack with no automation service, `automation.evaluateCondition` rejects with the kernel's - own `SERVICE_NOT_REGISTERED` error naming `automation`). The two update doors refuse a malformed call themselves, - before the engine is touched, with `INVALID_REQUEST` / `400` (`{ system: true }` - on an insert or delete, a caller named twice, or a `hooks.updateWhere` the - engine would write by id). Assert on `code` (and `status` / `statusCode`), - never on a message alone. + own `SERVICE_NOT_REGISTERED` error naming `automation`). `hooks.run` and + `hooks.updateWhere` refuse a malformed call themselves, before the engine is + touched, with `INVALID_REQUEST` / `400` (a caller named twice, or a + `hooks.updateWhere` the engine would write by id). Assert on `code` (and + `status` / `statusCode`), never on a message alone. - Many files, one boot: `bootStackOnce(config, opts?)` memoises `bootStack` per `(config, opts)` object identity for the life of the process. Share it from one module, under vitest `isolate: false`, and never `stop()` a stack other diff --git a/packages/verify/src/handle.system-insert-delete.test.ts b/packages/verify/src/handle.system-insert-delete.test.ts new file mode 100644 index 00000000000..5ea122e2b7d --- /dev/null +++ b/packages/verify/src/handle.system-insert-delete.test.ts @@ -0,0 +1,384 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * #22301 item 5 — a user-less record trigger on an insert or a delete, and a + * flow over a record the engine no longer holds. + * + * `hooks.run(object, 'insert' | 'delete', input, { system: true })` is the + * engine's own `insert` / `delete` under `{ isSystem: true }`, the write an + * integration or a system job makes. Before this, `hooks.run` took the system + * principal on `update` only, `seed` skips record-change flows, and every other + * write door runs as a person, so an app's suite handed a flow a record through + * the automation service by hand (hotcrm's `runRecordFlow`). + * + * Every claim below is read off what only the engine produces on that path: + * the permission gate's refusal of the person (and its absence for the system), + * the bound hook chain's view of the write (the caller it saw, the pre-image it + * was bound to), the declared validation rule's refusal, and the record-change + * trigger's flow runs and the rows those runs wrote. + * + * The fixture is neutral. `sid_case` is readable but neither creatable nor + * deletable by a fresh member, so the member's write is the refusal the system + * door's success is measured against. Its three flows each write one ledger row + * per run: on create; before a delete; after a delete. The two delete flows + * read the case back with `get_record` first, so the ledger records whether the + * engine still held the row when the flow read it. + */ + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; + +import { defineStack } from '@objectstack/spec'; +import { ObjectSchema, Field } from '@objectstack/spec/data'; +import type { Flow } from '@objectstack/spec/automation'; +import { PermissionSetSchema } from '@objectstack/spec/security'; + +import { bootStack, type VerifyStack } from './harness.js'; +import type { EngineRow } from './handle.js'; + +// Booting the full in-process stack runs well past vitest's 5s default. +const BOOT_TIMEOUT = 120_000; + +const CASE = 'sid_case'; +const LEDGER = 'sid_ledger'; +const CREATED = 'sid_case_created'; +const BEFORE_DELETE = 'sid_case_before_delete'; +const DELETED = 'sid_case_deleted'; + +/** One dispatch the bound hook chain received. */ +interface HookSeen { + event: string; + name: unknown; + isSystem: boolean; + userId: unknown; + /** The pre-image's `status`, as the engine bound it (a delete's only view of the row). */ + previous: unknown; +} +const hookSaw: HookSeen[] = []; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +type HookCtx = { event: string; input?: Record; previous?: Record; session?: Record }; + +const capture = async (ctx: HookCtx) => { + hookSaw.push({ + event: ctx.event, + // An insert carries its payload; a delete carries no payload, only the pre-image. + name: ctx.input?.data?.name ?? ctx.previous?.name, + isSystem: ctx.session?.isSystem === true, + userId: ctx.session?.userId, + previous: ctx.previous?.status, + }); +}; + +const Case = ObjectSchema.create({ + name: CASE, + // The gate under test is the object grant; owner-sharing is kept out of the way. + sharingModel: 'public_read_write', + label: 'Case', + pluralLabel: 'Cases', + fields: { + name: Field.text({ label: 'Name', required: true }), + status: Field.text({ label: 'Status' }), + priority: Field.number({ label: 'Priority' }), + }, + validations: [ + { + name: 'priority_in_range', + type: 'script', + severity: 'error', + message: 'Priority must be 1 to 5', + // A case is born without a priority, so the rule guards the null. + condition: 'record.priority != null && record.priority > 5', + }, + ], +}); + +/** What the record-change flows write: one row per run that reached its write. */ +const Ledger = ObjectSchema.create({ + name: LEDGER, + sharingModel: 'public_read_write', + label: 'Ledger', + pluralLabel: 'Ledgers', + fields: { + name: Field.text({ label: 'Name', required: true }), + record_id: Field.text({ label: 'Record' }), + record_status: Field.text({ label: 'Record status' }), + previous_status: Field.text({ label: 'Previous status' }), + held: Field.text({ label: 'Held' }), + }, +}); + +const ledgerWrite = (name: string, fields: Record) => ({ + id: 'write', + type: 'create_record', + label: 'Ledger', + config: { + objectName: LEDGER, + fields: { name, record_id: { dialect: 'cel', source: 'record.id' }, record_status: { dialect: 'cel', source: 'record.status' }, ...fields }, + }, +}); + +/** start (record-after-create) → create_record(sid_ledger) → end. */ +const onCreate: Flow = { + name: CREATED, + label: CREATED, + type: 'autolaunched', + status: 'active', + // The ledger is the flow's witness, written whoever (or nobody) triggered it. + runAs: 'system', + nodes: [ + { id: 'start', type: 'start', label: 'On create', config: { objectName: CASE, triggerType: 'record-after-create' } }, + ledgerWrite(CREATED, {}), + { id: 'end', type: 'end', label: 'End' }, + ], + edges: [ + { id: 'e1', source: 'start', target: 'write' }, + { id: 'e2', source: 'write', target: 'end' }, + ], +} as Flow; + +/** + * start (`triggerType`) → get_record(sid_case, this row's id) → create_record(sid_ledger) → end. + * `held` records what the read found: `'held'` for a row, `'gone'` for none. + */ +function deleteReader(name: string, triggerType: 'record-before-delete' | 'record-after-delete'): Flow { + return { + name, + label: name, + type: 'autolaunched', + status: 'active', + runAs: 'system', + nodes: [ + { id: 'start', type: 'start', label: 'On delete', config: { objectName: CASE, triggerType } }, + { + id: 'read', + type: 'get_record', + label: 'Read the case back', + config: { objectName: CASE, filter: { id: '{record.id}' }, outputVariable: 'held' }, + }, + ledgerWrite(name, { + previous_status: { dialect: 'cel', source: 'previous.status' }, + held: { dialect: 'cel', source: "vars.held == null ? 'gone' : 'held'" }, + }), + { id: 'end', type: 'end', label: 'End' }, + ], + edges: [ + { id: 'e1', source: 'start', target: 'read' }, + { id: 'e2', source: 'read', target: 'write' }, + { id: 'e3', source: 'write', target: 'end' }, + ], + } as Flow; +} + +const memberSet = PermissionSetSchema.parse({ + name: 'sid_member_default', + label: 'System insert/delete fixture member (default)', + isDefault: true, + objects: { + // Readable, never creatable or deletable: the member's insert and delete are refused. + [CASE]: { allowRead: true }, + }, +}); + +const fixtureStack = defineStack({ + manifest: { + id: 'com.objectstack.verify.system-insert-delete', + namespace: 'sid', + version: '0.0.0', + type: 'app', + name: 'Verify System Insert/Delete Fixture', + description: 'A case a member may read but not create or delete, a capture hook, and three record-change flows.', + }, + // ADR-0097: a record-change start node needs both capabilities. + requires: ['automation', 'triggers'], + objects: [Case, Ledger], + hooks: [ + { + name: 'sid_case_capture', + object: CASE, + events: ['beforeInsert', 'afterInsert', 'beforeDelete', 'afterDelete'], + handler: capture, + }, + ], + flows: [onCreate, deleteReader(BEFORE_DELETE, 'record-before-delete'), deleteReader(DELETED, 'record-after-delete')], + permissions: [memberSet], +} as never); + +let stack: VerifyStack; +let admin: string; +let adminId: string; +let member: string; + +beforeAll(async () => { + stack = await bootStack(fixtureStack); + admin = await stack.signIn(); + adminId = String((await stack.contextFor(admin)).userId); + // The first user is the seeded dev admin, so this sign-up is a plain member. + member = await stack.signUp('sid-member@verify.test'); +}, BOOT_TIMEOUT); + +afterAll(async () => { + await stack?.stop().catch(() => undefined); +}); + +/** Unique per call, so no assertion sees another test's rows. */ +const uniq = (prefix: string): string => `${prefix}-${Math.random().toString(36).slice(2, 8)}`; + +/** The rejection `p` settles with, or a failure when it resolves. */ +async function refusalOf(p: Promise): Promise { + try { + await p; + } catch (e) { + return e; + } + throw new Error('expected a refusal, and the call resolved'); +} + +/** The ADR-0112 pair: `code`, and `status` (or the engine's `statusCode`). */ +// eslint-disable-next-line @typescript-eslint/no-explicit-any +const envelope = (e: unknown) => ({ code: (e as any)?.code, status: (e as any)?.statusCode ?? (e as any)?.status }); + +const seenFor = (name: string): HookSeen[] => hookSaw.filter((h) => h.name === name); + +const ledgerFor = (flow: string, id: unknown): Promise => stack.rows(LEDGER, { name: flow, record_id: id }); + +/** + * The engine's run log for `flow`, narrowed to runs `recordId` triggered: what + * the record-change trigger handed the automation service. The log is the + * synchronous witness; the durable `sys_automation_run` row is written after + * the triggering write has already returned. + */ +async function runsFor(flow: string, recordId: unknown): Promise }>> { + const automation = stack.kernel.getService('automation') as { + listRuns(name: string): Promise }>>; + }; + return (await automation.listRuns(flow)) + .filter((r) => r.trigger?.recordId === recordId) + .map((r) => ({ status: r.status, trigger: r.trigger ?? {} })); +} + +describe("item 5 — hooks.run(object, 'insert', input, { system: true })", () => { + it('an insert the member may not make succeeds as the system; the same insert as the member is refused', async () => { + const name = uniq('case'); + + // Control first: the member holds read but not create on sid_case. + const refused = await refusalOf(stack.hooks.run(CASE, 'insert', { name, status: 'new' }, { as: member })); + expect(envelope(refused)).toEqual({ code: 'PERMISSION_DENIED', status: 403 }); + expect(await stack.rows(CASE, { name }), 'the refused write left no row').toEqual([]); + + const written = await stack.hooks.run(CASE, 'insert', { name, status: 'new' }, { system: true }); + expect(typeof written.id).toBe('string'); + expect((await stack.rows(CASE, { name })).map((r) => r.id)).toEqual([written.id]); + }); + + it('it is a real system write: the hooks see the system and no user, and the record-change flow runs with no trigger user', async () => { + const name = uniq('case'); + const written = await stack.hooks.run(CASE, 'insert', { name, status: 'new' }, { system: true }); + + const system = seenFor(name); + expect(system.map((h) => h.event)).toEqual(['beforeInsert', 'afterInsert']); + for (const h of system) expect(h).toMatchObject({ isSystem: true, userId: undefined }); + const runs = await runsFor(CREATED, written.id); + expect(runs, 'the record-change trigger fired the flow on a system insert').toHaveLength(1); + expect(runs[0].status).toBe('completed'); + expect(runs[0].trigger).toMatchObject({ type: 'record-after-create', object: CASE }); + expect(runs[0].trigger.userId, 'no trigger user').toBeUndefined(); + expect(await ledgerFor(CREATED, written.id)).toMatchObject([{ record_status: 'new' }]); + + // Control that DISCRIMINATES: the same insert by a person (the admin, who + // may create) reaches the same hooks and the same flow carrying that person. + const personName = uniq('case'); + const byPerson = await stack.hooks.run(CASE, 'insert', { name: personName, status: 'new' }, { as: admin }); + for (const h of seenFor(personName)) expect(h).toMatchObject({ isSystem: false, userId: adminId }); + expect((await runsFor(CREATED, byPerson.id)).map((r) => r.trigger.userId)).toEqual([adminId]); + + // And it is not `seed`: the platform's seed context skips record-change + // flows, so the same row seeded fires nothing. + const [seeded] = await stack.seed(CASE, [{ name: uniq('case'), status: 'new' }]); + expect(await runsFor(CREATED, seeded.id), 'seed fired a record-change flow').toEqual([]); + }); + + it("the declared validation still runs: a system insert the rule refuses is refused with the engine's envelope", async () => { + const name = uniq('case'); + const refused = await refusalOf(stack.hooks.run(CASE, 'insert', { name, priority: 9 }, { system: true })); + // The engine's `ValidationError`, rethrown unchanged (its `code` and the + // structured `fields`; it carries no status at the engine door). + expect(refused).toMatchObject({ code: 'VALIDATION_FAILED', fields: [{ field: '_record', code: 'rule_violation' }] }); + expect(await stack.rows(CASE, { name })).toEqual([]); + // Control: an in-range value through the same door is written. + await stack.hooks.run(CASE, 'insert', { name, priority: 4 }, { system: true }); + expect((await stack.rows(CASE, { name })).map((r) => r.priority)).toEqual([4]); + }); +}); + +describe("item 5 — hooks.run(object, 'delete', { id }, { system: true })", () => { + it('a delete the member may not make succeeds as the system; the same delete as the member is refused', async () => { + const [kase] = await stack.seed(CASE, [{ name: uniq('case'), status: 'open' }]); + + // Control first: the member holds read but not delete on sid_case. + const refused = await refusalOf(stack.hooks.run(CASE, 'delete', { id: kase.id }, { as: member })); + expect(envelope(refused)).toEqual({ code: 'PERMISSION_DENIED', status: 403 }); + expect(await stack.rows(CASE, { id: kase.id }), 'the refused delete left the row').toHaveLength(1); + + await stack.hooks.run(CASE, 'delete', { id: kase.id }, { system: true }); + expect(await stack.rows(CASE, { id: kase.id })).toEqual([]); + }); + + it('the hooks see the system and the pre-image, and the record-change flow runs with no trigger user', async () => { + const name = uniq('case'); + const [kase] = await stack.seed(CASE, [{ name, status: 'closing' }]); + // `seed` dispatches no delete hook and fires no delete flow: nothing is on + // the books for this row before the write under test. + expect(await runsFor(DELETED, kase.id)).toEqual([]); + + await stack.hooks.run(CASE, 'delete', { id: kase.id }, { system: true }); + + const system = seenFor(name).filter((h) => h.event.endsWith('Delete')); + expect(system.map((h) => h.event)).toEqual(['beforeDelete', 'afterDelete']); + for (const h of system) expect(h).toMatchObject({ isSystem: true, userId: undefined, previous: 'closing' }); + const runs = await runsFor(DELETED, kase.id); + expect(runs, 'the record-change trigger fired the flow on a system delete').toHaveLength(1); + expect(runs[0].status).toBe('completed'); + expect(runs[0].trigger).toMatchObject({ type: 'record-after-delete', object: CASE }); + expect(runs[0].trigger.userId, 'no trigger user').toBeUndefined(); + + // Control that DISCRIMINATES: the same delete by a person (the admin, who + // may delete) fires the same flow carrying that person. + const [other] = await stack.seed(CASE, [{ name: uniq('case'), status: 'closing' }]); + await stack.hooks.run(CASE, 'delete', { id: other.id }, { as: admin }); + expect((await runsFor(DELETED, other.id)).map((r) => r.trigger.userId)).toEqual([adminId]); + }); + + it('a delete-triggered flow is handed the pre-image, and its get_record finds a row the engine no longer holds', async () => { + const [kase] = await stack.seed(CASE, [{ name: uniq('case'), status: 'closing' }]); + + await stack.hooks.run(CASE, 'delete', { id: kase.id }, { system: true }); + + // After the delete: `record` and `previous` are both the pre-image, and the + // flow's read of that same id finds nothing. + expect(await ledgerFor(DELETED, kase.id)).toMatchObject([ + { record_status: 'closing', previous_status: 'closing', held: 'gone' }, + ]); + // Control on the SAME delete: the before-delete flow reads the row back + // through the same `get_record`, and finds it, so `'gone'` above is the + // engine's answer about a deleted row, not a read that never matches. + expect(await ledgerFor(BEFORE_DELETE, kase.id)).toMatchObject([{ record_status: 'closing', held: 'held' }]); + }); +}); + +describe('item 5 — the caller is named once', () => { + it('a system insert or delete beside a token is refused, before the engine is touched', async () => { + const name = uniq('case'); + const [kase] = await stack.seed(CASE, [{ name: uniq('case'), status: 'open' }]); + const both = { as: admin, system: true } as never; + + const insert = await refusalOf(stack.hooks.run(CASE, 'insert', { name, status: 'new' }, both)); + expect(envelope(insert)).toEqual({ code: 'INVALID_REQUEST', status: 400 }); + expect(await stack.rows(CASE, { name }), 'the refused insert wrote nothing').toEqual([]); + expect(seenFor(name), 'and dispatched no hook').toEqual([]); + + const del = await refusalOf(stack.hooks.run(CASE, 'delete', { id: kase.id }, both)); + expect(envelope(del)).toEqual({ code: 'INVALID_REQUEST', status: 400 }); + expect(await stack.rows(CASE, { id: kase.id }), 'the refused delete left the row').toHaveLength(1); + expect(await runsFor(DELETED, kase.id), 'and fired no flow').toEqual([]); + }); +}); diff --git a/packages/verify/src/handle.ts b/packages/verify/src/handle.ts index 4b419e07847..a6e175d1c0d 100644 --- a/packages/verify/src/handle.ts +++ b/packages/verify/src/handle.ts @@ -21,11 +21,14 @@ // chain, the validation pass, the SecurityPlugin middleware (object // grants, RLS, FLS) all live INSIDE those calls, so they run here exactly // as they run for a REST write. `handle.test.ts` pins that parity on the -// same row AND the same refusal. [#22301] The two update doors also take -// the system principal by name (`{ system: true }` → `{ isSystem: true }`, -// the context a system job's write carries), and `hooks.updateWhere` is -// the engine's predicate path (`multi: true`), which no REST door reaches: -// `handle.update-doors.test.ts` pins both. +// same row AND the same refusal. [#22301] Every `hooks` write door also +// takes the system principal by name (`{ system: true }` → `{ isSystem: +// true }`, the context a system job's write carries), and +// `hooks.updateWhere` is the engine's predicate path (`multi: true`), +// which no REST door reaches: `handle.update-doors.test.ts` pins the +// update half, and `handle.system-insert-delete.test.ts` pins a system +// insert and a system delete, the user-less record trigger each fires, +// and a delete-triggered flow reading a row the engine no longer holds. // flows.run / flows.resume · actions.run → the runtime's `HttpDispatcher`, // driven in-process (no Hono, no socket, no JSON round-trip). The REST // `/automation` and `/actions` routes are the only doors that carry the @@ -125,9 +128,10 @@ export interface AsUser { * the other system door, and it is not this one: it also sets `skipTriggers` * and `seedReplay`, because a fixture is end-state data, not an event.) * - * Accepted by the UPDATE doors only: `hooks.run(object, 'update', …)` and - * `hooks.updateWhere`. Named, never defaulted: a write that names no caller - * is refused, so a forgotten `as` can never become a system write. + * Accepted by the `hooks` write doors: `hooks.run` (`insert`, `update` and + * `delete`) and `hooks.updateWhere`. Named, never defaulted: a write that + * names no caller is refused, so a forgotten `as` can never become a system + * write. */ export interface AsSystem { system: true; @@ -178,7 +182,9 @@ export interface VerifyHandle { * engine's own error: assert on its `code` (and `statusCode`), never on * the message alone. * - * `update` and `delete` address the row by `input.id`. + * `update` and `delete` address the row by `input.id`. The caller is a + * person (`{ as }`, this overload) or the system (`{ system: true }`, + * the next one), never both. * * An ANONYMOUS write, a public-form submission (the write a web-to-lead * or web-to-case branch runs on), is not this door: it is the form's own @@ -193,16 +199,25 @@ export interface VerifyHandle { opts: AsUser, ): Promise; /** - * [#22301] The same by-id `update` as the SYSTEM principal ({@link AsSystem}): - * `update(object, { ...input, id }, { where: { id }, context: { isSystem: - * true } })`. No permission gate; the hooks, the declared validations and - * the record-change trigger run as they do for any system write. + * [#22301] The same three writes as the SYSTEM principal ({@link AsSystem}): + * the engine calls above with `context: { isSystem: true }`, the write an + * integration or a system job makes. No permission gate. The bound hooks + * see `session.isSystem` and no `userId`, the declared validations run, + * and the record-change trigger fires the object's flows with no trigger + * user, as it does for any system write. * - * Only `update` takes `{ system: true }`. A system insert of fixture rows - * is `seed`; a system insert or delete that fires record triggers has no - * door here, and asking for one is refused (`INVALID_REQUEST` / `400`). + * A system `delete` hands a `record-after-delete` flow the row's + * pre-image (as `record` and as `previous`), and by then the engine no + * longer holds the row: a `get_record` of it finds nothing (a + * `record-before-delete` flow on the same delete still finds it). That + * is the door for a delete-triggered flow over a record the engine no + * longer holds. + * + * `seed` is not this door: it also sets `skipTriggers` and `seedReplay`, + * so it fires no record-change flow. Insert through `seed` for end-state + * fixture data, and through this door for a write the app should react to. */ - run(object: string, operation: 'update', input: EngineRow, opts: AsSystem): Promise; + run(object: string, operation: 'insert' | 'update' | 'delete', input: EngineRow, opts: AsSystem): Promise; /** * [#22301] A PREDICATE update: one payload written to every row `where` * selects. This is the engine's own predicate path, `update(object, data, @@ -482,13 +497,9 @@ export async function createHandle(kernel: ObjectKernel, origin: string): Promis if (operation !== 'insert' && operation !== 'update' && operation !== 'delete') { throw new Error(`verify: hooks.run operation must be 'insert' | 'update' | 'delete', got '${String(operation)}'`); } + // The system principal is named on any of the three, and the call is + // the same engine call either way: only the context differs. const system = namesSystem(opts, 'hooks.run'); - if (system && operation !== 'update') { - throw callShapeRefusal( - `verify: hooks.run takes { system: true } on 'update' only, got '${operation}'. ` + - 'A system insert of fixture rows is seed(object, rows); a system insert or delete that fires record triggers has no handle door.', - ); - } const id = operation === 'insert' ? undefined : requireId(input, operation); const context = system ? { ...SYSTEM_CONTEXT } : await contextFor((opts as AsUser).as); const ql = await engine(); diff --git a/packages/verify/src/handle.update-doors.test.ts b/packages/verify/src/handle.update-doors.test.ts index 97fc7a4502a..62675c541cc 100644 --- a/packages/verify/src/handle.update-doors.test.ts +++ b/packages/verify/src/handle.update-doors.test.ts @@ -321,24 +321,18 @@ describe("item 2 — hooks.run(object, 'update', input, { system: true })", () = expect((await stack.rows(CASE, { id: kase.id }))[0].priority).toBe(4); }); - it('the system is accepted on update only, and never beside a token — refused before the engine is touched', async () => { - const name = uniq('case'); - const [kase] = await stack.seed(CASE, [{ name, status: 'open', priority: 1 }]); - const asSystem = { system: true } as never; - - const insert = await refusalOf(stack.hooks.run(CASE, 'insert', { name: `${name}-x` }, asSystem)); - expect(envelope(insert)).toEqual({ code: 'INVALID_REQUEST', status: 400 }); - expect(await stack.rows(CASE, { name: `${name}-x` })).toEqual([]); - - const del = await refusalOf(stack.hooks.run(CASE, 'delete', { id: kase.id }, asSystem)); - expect(envelope(del)).toEqual({ code: 'INVALID_REQUEST', status: 400 }); + it('the system is never named beside a token — refused before the engine is touched', async () => { + // [#22301 item 5] A system insert and a system delete are doors now, and + // `handle.system-insert-delete.test.ts` pins them; the two-caller refusal + // holds on every operation. + const [kase] = await stack.seed(CASE, [{ name: uniq('case'), status: 'open', priority: 1 }]); const both = await refusalOf( stack.hooks.run(CASE, 'update', { id: kase.id, status: 'closed' }, { as: admin, system: true } as never), ); expect(envelope(both)).toEqual({ code: 'INVALID_REQUEST', status: 400 }); - expect((await stack.rows(CASE, { id: kase.id }))[0].status, 'none of the three wrote').toBe('open'); + expect((await stack.rows(CASE, { id: kase.id }))[0].status, 'the refused update did not write').toBe('open'); expect(seenFor(kase.id), 'and no hook was dispatched').toEqual([]); }); });