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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .changeset/22301-spec-ledger-verify-provenance.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
'@objectstack/spec': minor
---

feat(spec): the error-code ledger lists `INVALID_REQUEST` under `@objectstack/verify`

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.
16 changes: 16 additions & 0 deletions .changeset/22301-verify-update-doors.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
---
'@objectstack/verify': minor
---

feat(verify): the handle gains a system-context update door and a predicate update door

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.
- **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.

Every other refusal from either door is the engine's own error, unchanged: assert on its `code` and `status`.
4 changes: 4 additions & 0 deletions packages/spec/src/api/error-code-ledger.zod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1473,6 +1473,10 @@ export const ERROR_CODE_LEDGER = {
// record.
'INVALID_ARTIFACT_PACKAGES',
],
'@objectstack/verify': [
// [#22301] An in-process test door, no HTTP path: a malformed call to the handle's update doors throws an Error carrying code / status / statusCode.
'INVALID_REQUEST',
],
} as const satisfies Record<string, readonly string[]>;

/** A code registered by at least one package (deduped union of the ledger). */
Expand Down
23 changes: 18 additions & 5 deletions packages/verify/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,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.
await stack.hooks.run('crm_opportunity', 'update', { id: deal.id, stage: 'closed_won' }, { 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.
const n = await stack.hooks.updateWhere('crm_opportunity', { name: 'Globex' }, { description: 'Bulk note' }, { as: rep });

// A validation rule, without writing.
const verdict = await stack.validate('crm_opportunity', { amount: -1 }, { as: rep });
expect(verdict.valid).toBe(false);
Expand Down Expand Up @@ -127,12 +135,17 @@ await stack.stop();
- `as` is always a bearer token minted by `signIn()` / `signUp()` on the same
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` and the default `rows` run
as the system principal, deliberately and by name.
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 refusal from `flows.*` / `actions.run` is the route's ADR-0112 envelope
(`VerifyRefusal`: `code`, `status`, `details`; `isVerifyRefusal(e)`); a
refusal from `hooks.run` / `validate` / `rows` is the engine's own error.
Assert on `code` (and `status` / `statusCode`), never on a message alone.
refusal from `hooks.run` / `hooks.updateWhere` / `validate` / `rows` is the
engine's own error. 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.
- 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
Expand Down Expand Up @@ -189,7 +202,7 @@ run" must never read like "nothing to find".
## API

- `bootStack(config, opts?)` → `VerifyStack` (`api` / `raw` / `signIn` / `signUp` / `apiAs` / `stop`, plus the handle:
`hooks.run` / `validate` / `flows.run` / `flows.resume` / `actions.run` / `seed` / `rows` / `metadata` / `tenancy` / `contextFor`).
`hooks.run` / `hooks.updateWhere` / `validate` / `flows.run` / `flows.resume` / `actions.run` / `seed` / `rows` / `metadata` / `tenancy` / `contextFor`).
- `bootStackOnce(config, opts?)` → the same, memoised per `(config, opts)` identity for the process.
- `deriveCrudCases(config)` → the auto-derived round-trip cases (write one, read one, assert) for every object.
- `runCrudVerification(stack, token, config)` → `VerifyReport`; `formatReport(report)` for a log summary.
Expand Down
122 changes: 115 additions & 7 deletions packages/verify/src/handle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,14 +14,18 @@
// call to the door that owns the semantics, and returns what that door
// returned (or rethrows what it threw). The doors, per method:
//
// hooks.run · validate · seed · rows → the ObjectQL engine (`insert` /
// `update` / `delete` / `validate` / `find`), the SAME calls
// `@objectstack/rest`'s data ingress makes (`protocol.createData` →
// hooks.run · hooks.updateWhere · validate · seed · rows → the ObjectQL
// engine (`insert` / `update` / `delete` / `validate` / `find`), the SAME
// calls `@objectstack/rest`'s data ingress makes (`protocol.createData` →
// `engine.insert(object, data, { context })`, and so on). The bound hook
// 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.
// 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.
// 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
Expand Down Expand Up @@ -51,7 +55,7 @@ import { SEED_WRITE_EXECUTION_CONTEXT, type ExecutionContext } from '@objectstac
import type { ValidateDataResponse } from '@objectstack/spec/api';
import type { AutomationResult } from '@objectstack/spec/contracts';
import type { ServiceObject } from '@objectstack/spec/data';
import type { ObjectQL } from '@objectstack/objectql';
import { resolveEngineUpdateDispatch, type ObjectQL } from '@objectstack/objectql';
import type { TenancyService } from '@objectstack/plugin-auth';

/** Any row the engine hands back. Untyped on purpose: the engine's, not the handle's. */
Expand Down Expand Up @@ -88,6 +92,24 @@ export interface AsUser {
as: string;
}

/**
* [#22301] Identify the caller as the platform's own SYSTEM principal: no
* user, no session. The write runs under `{ isSystem: true }`, the context a
* system job, an integration or the platform's own automation writes under,
* so it passes no permission gate, and it is still a real write: the bound
* hooks see `session.isSystem` and no `userId`, declared validations run, and
* the record-change trigger fires its flows with no trigger user. (`seed` is
* 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.
*/
export interface AsSystem {
system: true;
}

/**
* The engine's answer to a flow trigger or resume, plus the flow's name so the
* value can be handed straight back to `flows.resume`. The `AutomationResult`
Expand Down Expand Up @@ -141,6 +163,41 @@ export interface VerifyHandle {
input: EngineRow,
opts: AsUser,
): Promise<EngineRow>;
/**
* [#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.
*
* 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`).
*/
run(object: string, operation: 'update', input: EngineRow, opts: AsSystem): Promise<EngineRow>;
/**
* [#22301] A PREDICATE update: one payload written to every row `where`
* selects. This is the engine's own predicate path, `update(object, data,
* { where, multi: true, context })`, and no REST door reaches it (`POST
* /data/:object/updateMany` writes by id, one row at a time). The engine
* dispatches the `beforeUpdate` and `afterUpdate` hooks once per matched
* row, each bound to THAT row's pre-image as `previous` (ADR-0058's bulk
* addendum), so the record-change trigger evaluates and fires per row.
* Resolves with the affected-row count the engine answers.
*
* `where` is the engine's object-form filter (`{ name: 'x' }`, `{ stage:
* { $ne: 'won' } }`); `{}` matches every row. The caller is a person
* (`{ as }`) or the system (`{ system: true }`).
*
* Refused before the engine is touched (`INVALID_REQUEST` / `400`): a
* `where` that is not an object, and a call the engine's own update
* dispatch would write by id instead: a `where` that names only an `id`,
* or an `id` in `data` beside a `where` that selects by nothing else. One
* row by id is `hooks.run(object, 'update', { id, ...fields }, opts)`.
* Every other refusal (permission, validation, a hook's throw, the
* dispatch refusing an `id` in `data` beside a real predicate) is the
* engine's own error, rethrown unchanged.
*/
updateWhere(object: string, where: EngineRow, data: EngineRow, opts: AsUser | AsSystem): Promise<number>;
};

/**
Expand Down Expand Up @@ -246,6 +303,24 @@ const API_PREFIX = '/api/v1';
const SEED_CONTEXT: ExecutionContext = SEED_WRITE_EXECUTION_CONTEXT;
const SYSTEM_CONTEXT: ExecutionContext = { isSystem: true } as ExecutionContext;

/**
* [#22301] A call the handle refuses for its own SHAPE, before any caller is
* resolved or the engine is touched. The ADR-0112 pair is the assertion
* surface (`statusCode` mirrors `status`, as on a `VerifyRefusal`).
*/
function callShapeRefusal(message: string): Error & { code: string; status: number; statusCode: number } {
return Object.assign(new Error(message), { code: 'INVALID_REQUEST', status: 400, statusCode: 400 });
}

/** `true` when `opts` names the system principal; refuses a call that names two callers. */
function namesSystem(opts: AsUser | AsSystem, door: string): boolean {
const system = (opts as Partial<AsSystem> | undefined)?.system === true;
if (system && (opts as Partial<AsUser>).as !== undefined) {
throw callShapeRefusal(`verify: ${door} takes ONE caller: { as: token } or { system: true }, not both`);
}
return system;
}

function refusalFrom(status: number, body: unknown, fallback: string): VerifyRefusal {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const b = body as any;
Expand Down Expand Up @@ -335,15 +410,25 @@ export async function createHandle(kernel: ObjectKernel, origin: string): Promis
contextFor,

hooks: {
async run(object, operation, input, opts) {
// Parameters annotated because the member is overloaded (an overload
// set gives no contextual parameter types); the return type is left to
// inference, as it was before the overload.
async run(object: string, operation: 'insert' | 'update' | 'delete', input: EngineRow, opts: AsUser | AsSystem) {
// The call's own shape is judged before anyone is resolved: a
// malformed call is refused for its own reason, not for whatever the
// identity resolver says about the token.
if (operation !== 'insert' && operation !== 'update' && operation !== 'delete') {
throw new Error(`verify: hooks.run operation must be 'insert' | 'update' | 'delete', got '${String(operation)}'`);
}
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 = await contextFor(opts.as);
const context = system ? { ...SYSTEM_CONTEXT } : await contextFor((opts as AsUser).as);
const ql = await engine();
switch (operation) {
case 'insert':
Expand All @@ -356,6 +441,29 @@ export async function createHandle(kernel: ObjectKernel, origin: string): Promis
return ql.delete(object, { where: { id }, context });
}
},

async updateWhere(object, where, data, opts) {
if (where === null || typeof where !== 'object' || Array.isArray(where)) {
throw callShapeRefusal(
'verify: hooks.updateWhere(object, where, data, opts) takes `where` as an object filter; `{}` matches every row',
);
}
// The engine's OWN dispatch ladder (the one `ObjectQL.update` resolves
// first), asked rather than re-derived: a call it would route to the
// by-id path is not a predicate update, and answering it here would
// hand back a row where this door promises a count.
if (resolveEngineUpdateDispatch(data, { where, multi: true }).kind === 'by-id') {
throw callShapeRefusal(
"verify: hooks.updateWhere is the engine's predicate path, but this call addresses one row by id " +
"(a `where` naming only an id, or an id in `data` beside a `where` that selects by nothing else), " +
'which the engine writes by id. ' +
"Update one row through hooks.run(object, 'update', { id, ...fields }, opts).",
);
}
const context = namesSystem(opts, 'hooks.updateWhere') ? { ...SYSTEM_CONTEXT } : await contextFor((opts as AsUser).as);
const ql = await engine();
return (await ql.update(object, data, { where, multi: true, context })) as number;
},
},

async validate(object, record, opts) {
Expand Down
Loading
Loading