diff --git a/.changeset/verify-in-process-handle.md b/.changeset/verify-in-process-handle.md new file mode 100644 index 0000000000..1e23f2eff5 --- /dev/null +++ b/.changeset/verify-in-process-handle.md @@ -0,0 +1,26 @@ +--- +"@objectstack/verify": minor +--- + +**Clause-②: yes** — new exported symbols on a published package (`bootStackOnce`, `isVerifyRefusal`, and ten new members on the `VerifyStack` every `bootStack` caller already holds), so the accept set a consumer writes against widens. Contract-review tier. + +Every `VerifyStack` now carries an **in-process handle** on the stack `bootStack` boots — a way to run a hook, a validation rule, a flow, an action, a seed or a read against the REAL engine and assert on what the engine did, instead of writing through HTTP and inferring from persisted rows, or rebuilding the engine's semantics in a test stand-in. + +New members on `VerifyStack` (the same object `bootStack` returns; `api` / `apiAs` / `signIn` / `signUp` / `stop` are unchanged): + +- `hooks.run(object, 'insert' | 'update' | 'delete', input, { as })` — one write through the engine's own door as the caller `as` (a bearer token from `signIn` / `signUp`). The bound hook chain, field defaults, declared validations and the SecurityPlugin middleware run inside it, in the engine's order, because this is the very call the REST data ingress makes. Returns what the engine returned; a refusal rejects with the engine's own error (`code`, `statusCode`). +- `validate(object, record, { as, mode? })` — the engine's dry-run validation pass (`ObjectQL.validate`), nothing written. +- `flows.run(name, params, { as })` / `flows.resume(run, input, { as })` — the runtime's `/automation` trigger and resume routes driven in-process (no Hono, no socket): the caller's resolved identity is forwarded exactly as the route forwards it, and the engine's `AutomationResult` comes back (plus `flowName`, so the value hands straight to `resume`). A never-dispatched refusal or a failed run rejects with the route's ADR-0112 envelope. +- `actions.run(object, action, { as, recordId?, params? })` — the `/actions/:object/:action` route driven in-process, the one door carrying the whole action contract (ADR-0066 D4 gate, ADR-0104 param contract, subject-record load, trusted body context). Returns the handler's value. +- `seed(object, rows)` / `rows(object, where?, { as? })` — real ObjectQL writes (the platform's own seed-replay context) and reads (system-scoped, or as a caller under that caller's grants and RLS). +- `metadata.object(name)` / `objects()` / `items(type)` / `types()` — the booted `SchemaRegistry`, by its own singular type vocabulary. +- `tenancy()` — the `tenancy` service AuthPlugin registered (`posture`, `requestedPosture`, `isolationActive`, `degraded`). +- `contextFor(token)` — the dispatcher's own request-identity resolution, exposed so a test can drive any kernel service as a real caller. + +Also new: `bootStackOnce(config, opts?)`, a per-process memo of `bootStack` keyed on the `config` and `opts` object identities — the worker-scoped shared boot `packages/qa/dogfood` kept privately, promoted for suites that run many files under `isolate: false`. + +Exported types: `VerifyHandle`, `VerifyRefusal` (with the `isVerifyRefusal` predicate), `AsUser`, `FlowRun`, `FlowRunRef`, `EngineRow`. + +**Zero re-implemented semantics.** Every method is a thin facade over a door the kernel wired at boot; the handle assembles no `ExecutionContext`, orders no hooks, evaluates no permission. The package's own tests pin each method against the real service behind it (the PR's ablation record breaks each service in turn and shows only that method's pin going red), pin `hooks.run` against the REST write on the same row **and** the same refusal, and port one hotcrm exemplar (`opportunity_lifecycle`) onto `hooks.run` as the proof of ergonomics. + +No boot option was added: the tenancy posture a stack runs under is still chosen by `multiTenant` (the `--multi-tenant` option `os verify` already has) and read back through `tenancy()`. `os verify`, `runCrudVerification` and `runRlsProofs` are unchanged. diff --git a/packages/qa/dogfood/test/rls-runner.test.ts b/packages/qa/dogfood/test/rls-runner.test.ts index b31469bb8e..632691ed97 100644 --- a/packages/qa/dogfood/test/rls-runner.test.ts +++ b/packages/qa/dogfood/test/rls-runner.test.ts @@ -123,6 +123,20 @@ function fakeStack(opts: FakeOpts): VerifyStack { signIn: async () => 'admin', signUp: async () => 'member', stop: async () => {}, + // [#15951] The in-process handle every real `VerifyStack` carries is NOT + // modelled here: the runner under test drives the HTTP half only. Typed + // `never`, like `kernel` / `api` / `raw` above, so a runner that starts + // reaching for the handle fails to compile in this test rather than + // finding an `undefined` at run time. + contextFor: undefined as never, + hooks: undefined as never, + validate: undefined as never, + flows: undefined as never, + actions: undefined as never, + seed: undefined as never, + rows: undefined as never, + metadata: undefined as never, + tenancy: undefined as never, }; } diff --git a/packages/verify/README.md b/packages/verify/README.md index fd85b812a7..59ed3bfb45 100644 --- a/packages/verify/README.md +++ b/packages/verify/README.md @@ -10,6 +10,11 @@ derived from your own metadata: - **Authorization** — the cross-owner RLS invariant: *a user who cannot READ a record must not be able to WRITE it.* +And, on the same booted stack, an **in-process handle** so an app's own tests +can run a hook, a validation rule, a flow, an action, a seed or a read against +the **real** engine and assert on what it did — no HTTP round-trip, no +hand-rolled `ctx.api`, no copied permission check. + ## Why Static gates — type-check, unit tests, schema validation — verify each layer in @@ -67,6 +72,72 @@ expect(rls.summary.holes).toBe(0); await stack.stop(); ``` +## The in-process handle (drive the real engine from a test) + +Every `VerifyStack` carries it; nothing extra to boot. Each method is a thin +facade over a door the kernel wired at boot — the ObjectQL engine's own write, +dry-run and read calls, the runtime's `/automation` and `/actions` routes +driven in-process, the `SchemaRegistry`, the `tenancy` service — with **zero +re-implemented semantics**: the handle assembles no execution context, orders +no hooks, evaluates no permission. What the engine does is what you assert on. + +```ts +import { bootStack } from '@objectstack/verify'; +import myApp from './objectstack.config.js'; + +const stack = await bootStack(myApp, { automation: true }); +await stack.signIn(); // seeds the dev admin +const rep = await stack.signUp('rep@example.com'); // a plain member + +// A hook: one real write as `rep` — before* hooks, validation, the driver, +// after* hooks, and the permission check the caller is subject to. +const deal = await stack.hooks.run('crm_opportunity', 'insert', + { name: 'Globex', amount: 10_000, stage: 'proposal' }, { as: rep }); +expect(deal.expected_revenue).toBe(6_000); // the hook derived it + +// The same write a member may NOT make rejects with the engine's own error. +await expect(stack.hooks.run('crm_vault', 'insert', { name: 'x' }, { as: rep })) + .rejects.toMatchObject({ code: 'PERMISSION_DENIED', statusCode: 403 }); + +// A validation rule, without writing. +const verdict = await stack.validate('crm_opportunity', { amount: -1 }, { as: rep }); +expect(verdict.valid).toBe(false); + +// A screen flow: trigger, then resume with the screen's input. +const run = await stack.flows.run('quote_generation', { recordId: deal.id }, { as: rep }); +expect(run.status).toBe('paused'); +await stack.flows.resume(run, { quoteName: 'Q-1', discount: 10 }, { as: rep }); + +// An action body, through the route that carries its param contract. +const out = await stack.actions.run('crm_opportunity', 'apply_discount', + { as: rep, recordId: deal.id, params: { discount: 10 } }); + +// Fixtures and reads through the real engine. +const [acc] = await stack.seed('crm_account', [{ name: 'Globex' }]); +const mine = await stack.rows('crm_opportunity', { crm_account: acc.id }, { as: rep }); + +// What the boot actually holds. +stack.metadata.object('crm_opportunity')?.fields; // system columns injected +stack.metadata.items('permission'); // the registry's singular names +stack.tenancy().posture; // 'single' | 'group' | 'isolated' + +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. +- 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. +- 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 + files still use. + ## Verdicts **Data fidelity** (`runCrudVerification`): @@ -117,13 +188,17 @@ run" must never read like "nothing to find". ## API -- `bootStack(config, opts?)` → `VerifyStack` (`api` / `raw` / `signIn` / `signUp` / `apiAs` / `stop`). +- `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`). +- `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. - `runRlsProofs(stack, adminToken, memberToken, config)` → `RlsReport`; `formatRlsReport(report)`. `bootStack` options: `admin`, `authSecret`, `security` (a custom `SecurityPlugin` -for owner-scoped fixtures), `multiTenant`. +for owner-scoped fixtures), `multiTenant` (also what decides the posture +`tenancy()` reports), `automation` (register the automation service so +`flows.*` has something to drive), `orgContext`, `databaseFile`, `extraPlugins`. ## Known limitations diff --git a/packages/verify/src/handle.exemplar-deal-lifecycle.test.ts b/packages/verify/src/handle.exemplar-deal-lifecycle.test.ts new file mode 100644 index 0000000000..31b64f8647 --- /dev/null +++ b/packages/verify/src/handle.exemplar-deal-lifecycle.test.ts @@ -0,0 +1,123 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// hotcrm#1579 step 5a — ONE hotcrm exemplar, ported onto the handle. +// +// hotcrm's `test/hooks-runtime-sales.test.ts` (`describe('opportunity_lifecycle')`) +// drives the hook body through a hand-written `ctx.api` over arrays +// (`test/helpers/hook-harness.ts`, 618 lines): `hook.handler(makeCtx({ event, +// input, previous, user }))`, then asserts on the mutated `input`. Every +// assertion below is that block's assertion; what changed is the instrument. +// Each case is one real write through the booted engine as a real member — +// the L2 body runs in the QuickJS runner the runtime bound at boot, `ctx.input` +// is the engine's flat-input proxy, `ctx.previous` is the pre-image the engine +// loaded, and the permission check the stand-in never had runs first. +// +// The fixture (`./handle.fixture.ts`) carries the derivation half of +// hotcrm's hook as the L2 body hotcrm ships. The `previous`-driven cases that +// used to be constructed by hand (`makeCtx({ previous })`) are now a seeded +// row plus an `update` — the engine supplies the pre-image. + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; + +import { bootStack, type VerifyStack } from './harness.js'; +import { handleFixtureStack, STAGES, STAGE_PROBABILITY, STAGE_FORECAST, today } from './handle.fixture.js'; + +const BOOT_TIMEOUT = 120_000; + +let stack: VerifyStack; +let user: string; // hotcrm's `USER = { id: 'user_1' }` — an authenticated human edit + +beforeAll(async () => { + stack = await bootStack(handleFixtureStack); + await stack.signIn(); + user = await stack.signUp('sales-rep@verify.test'); +}, BOOT_TIMEOUT); + +afterAll(async () => { + await stack?.stop().catch(() => undefined); +}); + +const uniq = (prefix: string): string => `${prefix}-${Math.random().toString(36).slice(2, 8)}`; + +describe('opportunity_lifecycle (ported: hotcrm test/hooks-runtime-sales.test.ts)', () => { + it('derives probability, expected_revenue and forecast_category from stage on insert', async () => { + const input = await stack.hooks.run( + 'hnd_deal', + 'insert', + { name: uniq('Deal'), amount: 10_000, stage: 'proposal' }, + { as: user }, + ); + expect(input.probability).toBe(60); + expect(input.expected_revenue).toBe(6_000); + expect(input.forecast_category).toBe('commit'); + }); + + it.each( + STAGES.map((stage) => [stage, STAGE_PROBABILITY[stage], STAGE_FORECAST[stage]] as const), + )('stage %s ⇒ probability %i, forecast %s', async (stage, probability, forecast) => { + const input = await stack.hooks.run( + 'hnd_deal', + 'insert', + { name: uniq('Deal'), amount: 1_000, stage }, + { as: user }, + ); + expect(input.probability).toBe(probability); + expect(input.expected_revenue).toBe((1_000 * probability) / 100); + expect(input.forecast_category).toBe(forecast); + }); + + it('recomputes expected_revenue when only the amount changes', async () => { + // hotcrm: previous = { stage: 'proposal', amount: 10_000, probability: 60 } + const previous = await stack.hooks.run( + 'hnd_deal', + 'insert', + { name: uniq('Deal'), amount: 10_000, stage: 'proposal' }, + { as: user }, + ); + await stack.hooks.run('hnd_deal', 'update', { id: previous.id, amount: 50_000 }, { as: user }); + const [input] = await stack.rows('hnd_deal', { id: previous.id }); + expect(input.expected_revenue).toBe(30_000); // 50k × 60% + }); + + it('stamps probability and expected_revenue on the closed_won transition', async () => { + // hotcrm: previous = { stage: 'negotiation', amount: 25_000 } + const previous = await stack.hooks.run( + 'hnd_deal', + 'insert', + { name: uniq('Deal'), amount: 25_000, stage: 'negotiation' }, + { as: user }, + ); + await stack.hooks.run('hnd_deal', 'update', { id: previous.id, stage: 'closed_won' }, { as: user }); + const [input] = await stack.rows('hnd_deal', { id: previous.id }); + expect(input.probability).toBe(100); + expect(input.expected_revenue).toBe(25_000); + // `days_in_stage` is a formula over `stage_entry_date`; re-stamping IS the reset. + expect(input.stage_entry_date).toBe(today()); + }); + + it('starts the stage clock on insert so a never-moved deal is visible to the sweep', async () => { + const input = await stack.hooks.run( + 'hnd_deal', + 'insert', + { name: uniq('New Deal'), amount: 1_000, stage: 'prospecting' }, + { as: user }, + ); + expect(input.stage_entry_date).toBe(today()); + }); + + it('leaves the stage clock alone when the stage did not change', async () => { + const previous = await stack.hooks.run( + 'hnd_deal', + 'insert', + { name: uniq('Deal'), amount: 1_000, stage: 'proposal' }, + { as: user }, + ); + // Age the clock through the engine as the system (a seed/backfill write), + // then make a USER edit that does not touch the stage. + await stack.hooks.run('hnd_deal', 'update', { id: previous.id, stage_entry_date: '2026-01-01' }, { as: user }); + await stack.hooks.run('hnd_deal', 'update', { id: previous.id, amount: 2_000 }, { as: user }); + const [input] = await stack.rows('hnd_deal', { id: previous.id }); + expect(input.stage_entry_date).toBe('2026-01-01'); + expect(input.expected_revenue).toBe(1_200); // 2k × 60% + }); +}); diff --git a/packages/verify/src/handle.fixture.ts b/packages/verify/src/handle.fixture.ts new file mode 100644 index 0000000000..ca810941e2 --- /dev/null +++ b/packages/verify/src/handle.fixture.ts @@ -0,0 +1,247 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// The app the handle's own tests boot — a deliberately small CRM slice whose +// four surfaces are each declared the way a real app ships them: +// +// • `hnd_deal` carries an L2 (sandboxed JS) lifecycle hook — a port of +// hotcrm's `opportunity_lifecycle` (`src/objects/opportunity.hook.ts`, +// the derivation half): stage → probability / forecast_category, +// amount × probability → expected_revenue, and the `stage_entry_date` +// clock stamped on insert and on every stage change. hotcrm ships its +// hooks as bodies, so the exemplar has to run through the QuickJS runner +// the runtime binds at boot, not as an in-process function. +// • `hnd_deal` also declares a validation rule (`amount_non_negative`) and a +// script action with a declared param contract (`apply_discount`). +// • `hnd_resolve_note` is a screen flow — start → screen → update_record → +// end — the shape `flow-quote.test.ts` drives in hotcrm (run, then resume +// with the screen's input). +// • `hnd_vault` is granted to NOBODY but the platform admin, so a member's +// write to it is the negative case the parity pin needs: the SAME refusal +// has to come out of `hooks.run` and out of `POST /data/hnd_vault`. +// +// The `isDefault` permission set is what `bootStack` wires as the fresh +// member's baseline (#7001), exactly as `objectstack serve` would. + +import { defineStack, P } from '@objectstack/spec'; +import { ObjectSchema, Field } from '@objectstack/spec/data'; +import type { Flow } from '@objectstack/spec/automation'; +import { PermissionSetSchema } from '@objectstack/spec/security'; + +export const STAGES = [ + 'prospecting', + 'qualification', + 'needs_analysis', + 'proposal', + 'negotiation', + 'closed_won', + 'closed_lost', +] as const; + +/** The two derivation tables the hook body carries — the port keeps hotcrm's values. */ +export const STAGE_PROBABILITY: Record<(typeof STAGES)[number], number> = { + prospecting: 10, + qualification: 25, + needs_analysis: 40, + proposal: 60, + negotiation: 80, + closed_won: 100, + closed_lost: 0, +}; +export const STAGE_FORECAST: Record<(typeof STAGES)[number], string> = { + prospecting: 'pipeline', + qualification: 'pipeline', + needs_analysis: 'best_case', + proposal: 'commit', + negotiation: 'commit', + closed_won: 'closed', + closed_lost: 'omitted', +}; + +/** Today as the hook stamps it (`YYYY-MM-DD`, UTC) — the port of hotcrm's `today()`. */ +export const today = (): string => new Date().toISOString().slice(0, 10); + +export const HandleDeal = ObjectSchema.create({ + name: 'hnd_deal', + // [ADR-0090 D1] The gate these tests measure is the OBJECT grant and the + // hook chain; owner-sharing is kept out of the way on purpose. + sharingModel: 'public_read_write', + label: 'Deal', + pluralLabel: 'Deals', + fields: { + name: Field.text({ label: 'Name', required: true }), + amount: Field.number({ label: 'Amount' }), + stage: Field.select({ + label: 'Stage', + options: STAGES.map((value) => ({ label: value, value })), + }), + probability: Field.number({ label: 'Probability' }), + expected_revenue: Field.number({ label: 'Expected revenue' }), + forecast_category: Field.text({ label: 'Forecast category' }), + stage_entry_date: Field.text({ label: 'Stage entry date' }), + note: Field.text({ label: 'Note' }), + }, + validations: [ + { + name: 'amount_non_negative', + type: 'script', + severity: 'error', + message: 'Amount cannot be negative', + condition: P`record.amount < 0`, + }, + ], +}); + +/** Granted to nobody but the platform admin — the parity pin's refusal case. */ +export const HandleVault = ObjectSchema.create({ + name: 'hnd_vault', + sharingModel: 'public_read_write', + label: 'Vault', + pluralLabel: 'Vaults', + fields: { + name: Field.text({ label: 'Name', required: true }), + }, +}); + +/** The record the resumed half of the screen flow stamps. */ +export const HandleNote = ObjectSchema.create({ + name: 'hnd_note', + sharingModel: 'public_read_write', + label: 'Note', + pluralLabel: 'Notes', + fields: { + name: Field.text({ label: 'Name', required: true }), + status: Field.text({ label: 'Status' }), + resolution: Field.text({ label: 'Resolution' }), + }, +}); + +/** + * hotcrm `opportunity_lifecycle`, derivation half, as the L2 body hotcrm + * ships. Body-only: no module scope, so the tables are declared inside. + */ +const DEAL_LIFECYCLE_SOURCE = ` + const STAGE_PROBABILITY = { + prospecting: 10, qualification: 25, needs_analysis: 40, proposal: 60, + negotiation: 80, closed_won: 100, closed_lost: 0, + }; + const STAGE_FORECAST = { + prospecting: 'pipeline', qualification: 'pipeline', needs_analysis: 'best_case', + proposal: 'commit', negotiation: 'commit', closed_won: 'closed', closed_lost: 'omitted', + }; + const input = ctx.input; + const previous = ctx.previous || {}; + const stage = input.stage !== undefined ? input.stage : previous.stage; + const stageChanged = ctx.event === 'beforeInsert' || (input.stage !== undefined && input.stage !== previous.stage); + if (stage && STAGE_PROBABILITY[stage] !== undefined) { + input.probability = STAGE_PROBABILITY[stage]; + input.forecast_category = STAGE_FORECAST[stage]; + } + const amount = input.amount !== undefined ? input.amount : previous.amount; + const probability = input.probability !== undefined ? input.probability : previous.probability; + if (typeof amount === 'number' && typeof probability === 'number') { + input.expected_revenue = (amount * probability) / 100; + } + if (stageChanged) { + input.stage_entry_date = new Date().toISOString().slice(0, 10); + } +`; + +/** + * `apply_discount`: a script action with a declared param, run in the sandbox. + * + * Spelled in the sandbox's OWN dialect — the declared params arrive as + * `ctx.input`, the subject as `ctx.record` / `ctx.recordId`, and `ctx.api`'s + * `update` takes `(data, { where })` (`packages/runtime/src/sandbox/body-runner.ts`). + * The first draft wrote `ctx.params.discount`, the shape a hand-rolled harness + * would have accepted; the real runner refused it, which is the handle doing + * its job. + */ +const APPLY_DISCOUNT_SOURCE = ` + const discount = ctx.input.discount; + const amount = Math.round(ctx.record.amount * (100 - discount)) / 100; + await ctx.api.object('hnd_deal').update({ amount: amount }, { where: { id: ctx.recordId } }); + return { amount: amount, discount: discount }; +`; + +/** start → screen (pauses) → update_record → end. */ +export const resolveNoteFlow: Flow = { + name: 'hnd_resolve_note', + label: 'Resolve note', + type: 'screen', + status: 'active', + variables: [{ name: 'noteId', type: 'text', isInput: true }], + nodes: [ + { id: 'start', type: 'start', label: 'Start' }, + { + id: 'ask', + type: 'screen', + label: 'Resolution', + config: { + title: 'How was it resolved?', + fields: [{ name: 'resolution', label: 'Resolution', type: 'text', required: true }], + }, + }, + { + id: 'apply', + type: 'update_record', + label: 'Apply resolution', + config: { + objectName: 'hnd_note', + filter: { id: '{noteId}' }, + fields: { status: 'resolved', resolution: '{resolution}' }, + }, + }, + { id: 'end', type: 'end', label: 'End' }, + ], + edges: [ + { id: 'e1', source: 'start', target: 'ask' }, + { id: 'e2', source: 'ask', target: 'apply' }, + { id: 'e3', source: 'apply', target: 'end' }, + ], +}; + +export const HANDLE_MEMBER_SET = 'hnd_member_default'; + +/** The fresh member's baseline: deals and notes, never the vault. */ +export const handleMemberSet = PermissionSetSchema.parse({ + name: HANDLE_MEMBER_SET, + label: 'Handle fixture member (default)', + isDefault: true, + objects: { + hnd_deal: { allowRead: true, allowCreate: true, allowEdit: true }, + hnd_note: { allowRead: true, allowCreate: true, allowEdit: true }, + }, +}); + +export const handleFixtureStack = defineStack({ + manifest: { + id: 'com.objectstack.verify.handle-fixture', + namespace: 'hnd', + version: '0.0.0', + type: 'app', + name: 'Verify Handle Fixture', + description: 'A hook, a validation rule, a script action, a screen flow and an ungranted object.', + }, + objects: [HandleDeal, HandleVault, HandleNote], + hooks: [ + { + name: 'hnd_deal_lifecycle', + label: 'Deal lifecycle (derivations)', + object: 'hnd_deal', + events: ['beforeInsert', 'beforeUpdate'], + body: { language: 'js', source: DEAL_LIFECYCLE_SOURCE, capabilities: [] }, + }, + ], + actions: [ + { + name: 'apply_discount', + label: 'Apply discount', + objectName: 'hnd_deal', + type: 'script', + params: [{ name: 'discount', label: 'Discount %', type: 'number', required: true }], + body: { language: 'js', source: APPLY_DISCOUNT_SOURCE, capabilities: ['api.read', 'api.write'] }, + }, + ], + flows: [resolveNoteFlow], + permissions: [handleMemberSet], +} as never); diff --git a/packages/verify/src/handle.test.ts b/packages/verify/src/handle.test.ts new file mode 100644 index 0000000000..f976b5637a --- /dev/null +++ b/packages/verify/src/handle.test.ts @@ -0,0 +1,373 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// hotcrm#1579 step 5a (#15951) — the in-process handle drives the REAL engine. +// +// One `it` per handle method, each asserting on something ONLY the kernel +// service behind that method can produce: a hook-derived column, a declared +// validation rule's verdict, a screen flow's pause and its resumed write, a +// sandboxed action body's write, a batch seed, a filtered read, a registry +// item, the tenancy posture. The ablation record on the PR breaks each of +// those kernel services in turn and shows exactly the corresponding `it` +// going red while the others stay green — a method whose pin survives its +// service being broken is not testing the service. +// +// The parity block is the card's second acceptance: `hooks.run` and the +// REST write have to agree on the SAME persisted row AND the SAME refusal. +// The refusal half is the one every hand-rolled harness faked away (no +// permission check at all), so it is pinned with a control that FIRES: the +// admin, on the identical call, is admitted by both doors. + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; + +import { bootStack, bootStackOnce, type VerifyStack, type BootOptions } from './harness.js'; +import { isVerifyRefusal } from './handle.js'; +import { + handleFixtureStack, + HANDLE_MEMBER_SET, + STAGE_PROBABILITY, + STAGE_FORECAST, + today, +} from './handle.fixture.js'; + +// Booting the full in-process stack runs well past vitest's 5s default. +const BOOT_TIMEOUT = 120_000; + +/** A module constant, so `bootStackOnce` can key the shared boot on it. */ +const BOOT_OPTIONS: BootOptions = { automation: true }; + +let stack: VerifyStack; +let admin: string; +let member: string; + +beforeAll(async () => { + stack = await bootStackOnce(handleFixtureStack, BOOT_OPTIONS); + admin = await stack.signIn(); + member = await stack.signUp('handle-member@verify.test'); +}, BOOT_TIMEOUT); + +afterAll(async () => { + // This file OWNS its worker's shared boot; a file that merely shares one + // must not do this (see `bootStackOnce`). + await stack?.stop().catch(() => undefined); +}); + +/** Unique per run, so list assertions never see another test's rows. */ +const uniq = (prefix: string): string => `${prefix}-${Math.random().toString(36).slice(2, 8)}`; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +const codeOf = (e: unknown): string | undefined => (e as any)?.code; +// eslint-disable-next-line @typescript-eslint/no-explicit-any +const statusOf = (e: unknown): number | undefined => (e as any)?.statusCode ?? (e as any)?.status; + +describe('contextFor — the dispatcher resolves the caller, the handle never assembles one', () => { + it('resolves a member token to that member, not to the system principal', async () => { + const ec = await stack.contextFor(member); + expect(typeof ec.userId).toBe('string'); + expect(ec.isSystem).not.toBe(true); + expect(Array.isArray(ec.positions)).toBe(true); + // The declared default profile is what the member holds (#7001) — proof + // the resolver read the permission tables, not a stub. + expect(ec.permissions).toContain(HANDLE_MEMBER_SET); + }); + + it('refuses a token that resolves to nobody, loudly', async () => { + await expect(stack.contextFor('not-a-session-token')).rejects.toThrow(/resolved to no signed-in user/); + }); +}); + +describe('hooks.run — the bound hook chain runs inside the real write', () => { + it('insert: the L2 lifecycle hook derives probability, expected_revenue and forecast_category', async () => { + const row = await stack.hooks.run( + 'hnd_deal', + 'insert', + { name: uniq('deal'), amount: 10_000, stage: 'proposal' }, + { as: member }, + ); + expect(typeof row.id).toBe('string'); + expect(row.probability).toBe(STAGE_PROBABILITY.proposal); + expect(row.expected_revenue).toBe(6_000); + expect(row.forecast_category).toBe(STAGE_FORECAST.proposal); + expect(row.stage_entry_date).toBe(today()); + }); + + it('update: the beforeUpdate leg recomputes from the pre-image the engine loads', async () => { + const created = await stack.hooks.run( + 'hnd_deal', + 'insert', + { name: uniq('deal'), amount: 10_000, stage: 'proposal' }, + { as: member }, + ); + await stack.hooks.run('hnd_deal', 'update', { id: created.id, amount: 50_000 }, { as: member }); + const [after] = await stack.rows('hnd_deal', { id: created.id }); + // 50k × 60% — the hook read `previous.probability` off the engine's pre-image. + expect(after.expected_revenue).toBe(30_000); + expect(after.stage).toBe('proposal'); + }); + + it('delete: the engine removes the row (admin)', async () => { + const created = await stack.hooks.run( + 'hnd_deal', + 'insert', + { name: uniq('deal'), amount: 1, stage: 'prospecting' }, + { as: admin }, + ); + await stack.hooks.run('hnd_deal', 'delete', { id: created.id }, { as: admin }); + expect(await stack.rows('hnd_deal', { id: created.id })).toEqual([]); + }); + + it('update/delete without an id are refused before the engine is touched', async () => { + await expect(stack.hooks.run('hnd_deal', 'update', { amount: 1 }, { as: admin })).rejects.toThrow(/input\.id/); + await expect(stack.hooks.run('hnd_deal', 'delete', {}, { as: admin })).rejects.toThrow(/input\.id/); + }); +}); + +describe('parity pin — hooks.run is the REST write minus HTTP', () => { + it('the same input yields the same persisted row through both doors (hook-derived columns included)', async () => { + const input = { amount: 25_000, stage: 'negotiation' }; + const viaHandle = await stack.hooks.run('hnd_deal', 'insert', { name: uniq('parity'), ...input }, { as: member }); + const viaRest = await stack.apiAs(member, 'POST', '/data/hnd_deal', { name: uniq('parity'), ...input }); + expect(viaRest.status).toBe(201); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const restBody = (await viaRest.json()) as any; + const restId: string = restBody?.id ?? restBody?.record?.id; + expect(typeof restId).toBe('string'); + + const [handleRow] = await stack.rows('hnd_deal', { id: viaHandle.id }); + const [restRow] = await stack.rows('hnd_deal', { id: restId }); + const derived = (r: Record) => ({ + probability: r.probability, + expected_revenue: r.expected_revenue, + forecast_category: r.forecast_category, + stage_entry_date: r.stage_entry_date, + created_by: r.created_by, + }); + expect(derived(handleRow)).toEqual(derived(restRow)); + expect(handleRow.probability).toBe(STAGE_PROBABILITY.negotiation); + expect(handleRow.expected_revenue).toBe(20_000); + }); + + it('the same refusal comes out of both doors — and the control (admin) is admitted by both', async () => { + // The member holds no grant on hnd_vault. + let handleErr: unknown; + try { + await stack.hooks.run('hnd_vault', 'insert', { name: uniq('vault') }, { as: member }); + } catch (e) { + handleErr = e; + } + expect(handleErr, 'hooks.run must REFUSE the ungranted write').toBeDefined(); + expect(codeOf(handleErr)).toBe('PERMISSION_DENIED'); + expect(statusOf(handleErr)).toBe(403); + + const viaRest = await stack.apiAs(member, 'POST', '/data/hnd_vault', { name: uniq('vault') }); + expect(viaRest.status).toBe(403); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const restBody = (await viaRest.json()) as any; + expect(restBody?.code ?? restBody?.error?.code).toBe('PERMISSION_DENIED'); + + // Control that FIRES and DISCRIMINATES: the identical call as the platform + // admin is admitted by both doors, so the refusal above is the grant + // being evaluated — not the object being broken. + const adminRow = await stack.hooks.run('hnd_vault', 'insert', { name: uniq('vault') }, { as: admin }); + expect(typeof adminRow.id).toBe('string'); + const adminRest = await stack.apiAs(admin, 'POST', '/data/hnd_vault', { name: uniq('vault') }); + expect(adminRest.status).toBe(201); + }); +}); + +describe('validate — the engine validation pass, without a write', () => { + it('reports the declared rule verdict for a bad row and a clean verdict for a good one', async () => { + const bad = await stack.validate('hnd_deal', { name: 'x', amount: -5, stage: 'proposal' }, { as: member }); + expect(bad.valid).toBe(false); + expect(bad.results[0].valid).toBe(false); + expect(bad.results[0].errors.map((e) => e.message)).toContain('Amount cannot be negative'); + + const good = await stack.validate('hnd_deal', { name: 'x', amount: 5, stage: 'proposal' }, { as: member }); + expect(good.valid).toBe(true); + expect(good.mode).toBe('insert'); + }); + + it('writes nothing', async () => { + const name = uniq('validate-only'); + await stack.validate('hnd_deal', { name, amount: 5, stage: 'proposal' }, { as: member }); + expect(await stack.rows('hnd_deal', { name })).toEqual([]); + }); +}); + +describe('flows.run / flows.resume — the automation service registered at boot', () => { + it('pauses on the screen node, then the resumed half performs the declared write', async () => { + const [note] = await stack.seed('hnd_note', [{ name: uniq('note'), status: 'open' }]); + + const run = await stack.flows.run('hnd_resolve_note', { noteId: note.id }, { as: admin }); + expect(run.status).toBe('paused'); + expect(typeof run.runId).toBe('string'); + expect(run.flowName).toBe('hnd_resolve_note'); + expect(run.screen?.fields.map((f) => f.name)).toContain('resolution'); + + const resumed = await stack.flows.resume(run, { resolution: 'called back' }, { as: admin }); + expect(resumed.success).toBe(true); + expect(resumed.status).not.toBe('paused'); + + const [after] = await stack.rows('hnd_note', { id: note.id }); + expect(after.status).toBe('resolved'); + expect(after.resolution).toBe('called back'); + }); + + it('a never-dispatched refusal arrives as the route envelope (code + status)', async () => { + let err: unknown; + try { + await stack.flows.run('hnd_no_such_flow', {}, { as: admin }); + } catch (e) { + err = e; + } + expect(isVerifyRefusal(err)).toBe(true); + expect(statusOf(err)).toBe(404); + expect(typeof codeOf(err)).toBe('string'); + }); +}); + +describe('actions.run — the declared action, through the route that carries its contract', () => { + it('runs the sandboxed body against the loaded record and returns its value', async () => { + const [deal] = await stack.seed('hnd_deal', [{ name: uniq('discount'), amount: 1_000, stage: 'proposal' }]); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const result = (await stack.actions.run('hnd_deal', 'apply_discount', { + as: admin, + recordId: deal.id, + params: { discount: 10 }, + })) as any; + expect(result?.amount).toBe(900); + expect(result?.discount).toBe(10); + + const [after] = await stack.rows('hnd_deal', { id: deal.id }); + expect(after.amount).toBe(900); + }); + + it('the ADR-0104 param contract refuses an undeclared key — same envelope a REST client gets', async () => { + const [deal] = await stack.seed('hnd_deal', [{ name: uniq('discount'), amount: 1_000, stage: 'proposal' }]); + let err: unknown; + try { + await stack.actions.run('hnd_deal', 'apply_discount', { + as: admin, + recordId: deal.id, + params: { discount: 10, not_declared: true }, + }); + } catch (e) { + err = e; + } + expect(isVerifyRefusal(err)).toBe(true); + expect(statusOf(err)).toBe(400); + // Control: the body never ran. + const [after] = await stack.rows('hnd_deal', { id: deal.id }); + expect(after.amount).toBe(1_000); + }); +}); + +describe('seed / rows — real ObjectQL writes and reads', () => { + it('seeds a batch through the engine and reads it back filtered', async () => { + const prefix = uniq('seed'); + const written = await stack.seed('hnd_deal', [ + { name: `${prefix}-a`, amount: 1, stage: 'prospecting', note: prefix }, + { name: `${prefix}-b`, amount: 2, stage: 'negotiation', note: prefix }, + { name: `${prefix}-c`, amount: 3, stage: 'closed_won', note: prefix }, + ]); + expect(written).toHaveLength(3); + expect(written.every((r) => typeof r.id === 'string')).toBe(true); + // The seed went through the real write: the hook derived the column. + expect(written.map((r) => r.probability)).toEqual([10, 80, 100]); + + // The whole batch reads back — a read that answers fewer rows than were + // written is the find door lying, not a fixture that forgot a row. + const batch = await stack.rows('hnd_deal', { note: prefix }); + expect(batch.map((r) => r.name).sort()).toEqual([`${prefix}-a`, `${prefix}-b`, `${prefix}-c`]); + + const negotiating = await stack.rows('hnd_deal', { name: `${prefix}-b` }); + expect(negotiating).toHaveLength(1); + expect(negotiating[0].stage).toBe('negotiation'); + }); + + it('rows as a caller reads under that caller\'s grants (the vault is refused)', async () => { + let err: unknown; + try { + await stack.rows('hnd_vault', {}, { as: member }); + } catch (e) { + err = e; + } + expect(codeOf(err)).toBe('PERMISSION_DENIED'); + // Control: the same read as the admin is admitted. + expect(Array.isArray(await stack.rows('hnd_vault', {}, { as: admin }))).toBe(true); + }); +}); + +describe('metadata — the booted registry', () => { + it('object() and objects() read the registry (system columns injected)', () => { + const deal = stack.metadata.object('hnd_deal'); + expect(deal?.fields && 'probability' in deal.fields).toBe(true); + expect(deal?.fields && 'created_at' in deal.fields).toBe(true); + const names = stack.metadata.objects().map((o) => o.name); + expect(names).toContain('hnd_deal'); + expect(names).toContain('sys_user'); + }); + + it('items(type) lists the registered items of one type, by the registry\'s singular name', () => { + // `MetadataTypeSchema` spells permission sets `permission` — the registry's + // vocabulary, not a name the handle invents (the first draft asked for + // `permission_set` and the registry, correctly, held nothing by that name). + const sets = stack.metadata.items<{ name: string }>('permission').map((s) => s.name); + expect(sets).toContain(HANDLE_MEMBER_SET); + }); + + it('types() names the metadata types the registry holds', () => { + expect(stack.metadata.types()).toContain('object'); + }); +}); + +describe('tenancy — the service AuthPlugin registered', () => { + it('reports the single-tenant posture a plain boot runs under', () => { + const t = stack.tenancy(); + expect(t.posture).toBe('single'); + expect(t.requestedPosture).toBe('single'); + expect(t.isolationActive).toBe(false); + expect(t.degraded).toBe(false); + }); + + // The `single` case alone is satisfied by any stand-in that answers the + // constant `'single'` — which is exactly what the hand-written tenancy probe + // this method retires was. So the SAME reader is pointed at a stack booted + // under the other posture: a constant fails here, and the ablation that + // breaks the service's isolation probe turns this red while leaving the + // `single` case above green. + it('reports the walled posture a multi-tenant boot runs under — same reader, other stack', async () => { + const walled = await bootStack(handleFixtureStack, { multiTenant: 'posture-only' }); + try { + const t = walled.tenancy(); + expect(t.requestedPosture).toBe('isolated'); + expect(t.isolationActive).toBe(true); + expect(t.posture).toBe('isolated'); + expect(t.degraded).toBe(false); + } finally { + await walled.stop(); + } + }, BOOT_TIMEOUT); +}); + +describe('bootStackOnce — one boot per (config, opts) identity', () => { + it('hands the same boot back for the same keys, and it is this file\'s stack', async () => { + const again = bootStackOnce(handleFixtureStack, BOOT_OPTIONS); + expect(again).toBe(bootStackOnce(handleFixtureStack, BOOT_OPTIONS)); + expect(await again).toBe(stack); + }); + + it('refuses a non-object config rather than memoising on a value', () => { + expect(() => bootStackOnce('not-a-config' as never)).toThrow(/identity/); + }); + + it('bootStack (unshared) still returns a distinct stack', async () => { + const other = await bootStack(handleFixtureStack, { automation: true }); + try { + expect(other).not.toBe(stack); + expect(typeof other.hooks.run).toBe('function'); + } finally { + await other.stop(); + } + }, BOOT_TIMEOUT); +}); diff --git a/packages/verify/src/handle.ts b/packages/verify/src/handle.ts new file mode 100644 index 0000000000..9e1344859b --- /dev/null +++ b/packages/verify/src/handle.ts @@ -0,0 +1,424 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// @objectstack/verify — the in-process handle on the stack `bootStack` boots. +// +// `bootStack` already boots the real kernel — ObjectQL + hooks + validation + +// SecurityPlugin middleware + sharing + automation + the REST/dispatcher route +// surfaces — in memory. Until this module the only way to DRIVE that stack was +// HTTP request-injection (`api` / `apiAs`), so an app that wanted to assert on +// what a hook, a flow, an action or a validation rule did had to either read +// it off a JSON response or rebuild the engine's semantics in a stand-in. +// +// Every method here is a thin facade over a door the kernel wired at boot. +// Nothing in this file decides anything: it resolves the caller, hands the +// 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` → +// `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. +// 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 +// full contract for those two surfaces — the ADR-0066 D4 permission gate, +// the ADR-0104 param contract, the subject-record load, the trusted-body +// context assembly, the ADR-0112 refusal envelopes — and the runtime +// exposes no lower in-process door with the same contract. The dispatcher +// is protocol-neutral by design (`HttpProtocolContext`), so driving it +// directly IS the REST path minus HTTP. +// contextFor(token) → the dispatcher's own identity +// resolution (`resolveRequestScope` → `resolveExecutionContext` → +// `@objectstack/core`'s `resolveAuthzContext`), the exact resolver every +// dispatcher request goes through. The handle never assembles an +// `ExecutionContext` by hand. +// metadata → the engine's `SchemaRegistry`. +// tenancy → the `tenancy` service AuthPlugin +// registered at boot. +// +// ⛔ Design rule (hotcrm#1579 step 5a): if a method needs a semantic the +// kernel does not expose, that is a kernel gap to FILE — never a semantic to +// re-implement here. A `ctx.api` over arrays, a hand-sorted hook dispatch, a +// copied permission check would each turn this handle into the stand-in it +// exists to retire, and would stay green when the engine changes. + +import { HttpDispatcher, type ObjectKernel, type HttpProtocolContext } from '@objectstack/runtime'; +import type { ExecutionContext } from '@objectstack/spec/kernel'; +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 type { TenancyService } from '@objectstack/plugin-auth'; + +/** Any row the engine hands back. Untyped on purpose: the engine's, not the handle's. */ +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export type EngineRow = Record; + +/** + * The refusal a dispatcher-door method (`flows.*`, `actions.run`) throws when + * the route answered a 4xx/5xx — the route's own ADR-0112 envelope, carried + * whole. `code` and `status` are the assertion pair (the same pair a REST + * client reads off the wire); `statusCode` mirrors `status` so a test can spell + * the check the way it spells it for an engine-thrown error + * (`PermissionDeniedError` carries `statusCode`). + * + * Engine-door methods (`hooks.run`, `validate`, `seed`, `rows`) do NOT wrap: + * they rethrow the engine's error unchanged, exactly as the REST ingress would + * have caught it. + */ +export interface VerifyRefusal extends Error { + code: string; + status: number; + statusCode: number; + details?: unknown; +} + +/** `true` when `e` is a refusal a dispatcher-door method threw. */ +export function isVerifyRefusal(e: unknown): e is VerifyRefusal { + return e instanceof Error && (e as Partial).name === 'VerifyRefusal'; +} + +/** Identify the caller: a bearer token from `signIn()` / `signUp()`. */ +export interface AsUser { + /** A bearer token minted by `signIn()` / `signUp()` on the same stack. */ + as: string; +} + +/** + * 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` + * half is the engine's own object as the route returned it (`runId`, `status`, + * `screen`, `success`, `output`, `summary`, …); `flowName` is the request's, + * not the engine's. + */ +export type FlowRun = AutomationResult & { flowName: string }; + +/** + * What `flows.resume` needs to address a parked run — a `FlowRun` satisfies + * it as-is. `runId` is optional here because the engine's result carries one + * only for a run that actually paused; `flows.resume` refuses loudly when it + * is absent instead of making the caller narrow the type. + */ +export interface FlowRunRef { + flowName: string; + runId?: string; +} + +export interface VerifyHandle { + /** + * Resolve the execution context the platform resolves for `token` — user, + * positions, permission sets, tenant, locale — through the dispatcher's own + * request-identity resolver. This is the context every other method here + * hands the engine; it is exposed so a test can drive a kernel service the + * handle does not cover (`analytics`, `sharing`, …) as a real caller instead + * of hand-assembling one. Throws when the token resolves to nobody. + */ + contextFor(token: string): Promise; + + hooks: { + /** + * Run one write through the real engine as `as`, and return what the engine + * returned. The bound hook chain (`before*` → validation → the driver → + * `after*`), the field defaults, the SecurityPlugin middleware — all of it + * runs, in the engine's order, because this IS the engine's write door: + * `insert(object, input, { context })`, `update(object, { ...input, id }, + * { where: { id }, context })`, `delete(object, { where: { id }, context })` + * — the same three calls the REST data ingress makes. + * + * A refusal (permission, validation, a hook's throw) rejects with the + * engine's own error: assert on its `code` (and `statusCode`), never on + * the message alone. + * + * `update` and `delete` address the row by `input.id`. + */ + run( + object: string, + operation: 'insert' | 'update' | 'delete', + input: EngineRow, + opts: AsUser, + ): Promise; + }; + + /** + * The engine's validation pass for one record (or several), as `as`, without + * writing: `ObjectQL.validate(object, data, { mode, context })`. Same field + * defaults, same value-shape posture, same declared `validations[]` the + * write path applies. Hooks do not run (the engine's documented contract for + * a dry run — see `ObjectQL.validate`). `mode` defaults to `'insert'`. + */ + validate( + object: string, + record: EngineRow | EngineRow[], + opts: AsUser & { mode?: 'insert' | 'update' }, + ): Promise; + + flows: { + /** + * Trigger a flow as `as` through the runtime's `POST + * /automation/:name/trigger` route, driven in-process. The route builds the + * engine's `AutomationContext` from the caller's resolved identity (so a + * `runAs: 'user'` flow enforces RLS as `as`) and runs the flow through the + * `automation` service registered at boot. Returns the engine's result; + * a never-dispatched refusal (unknown flow, disabled, no start node) or a + * run that failed rejects with the route's envelope (`VerifyRefusal`). + */ + run(name: string, params: EngineRow | undefined, opts: AsUser): Promise; + /** + * Continue a parked run through `POST /automation/:name/runs/:runId/resume` + * with `input` as the screen submission. The engine's resume gate (what the + * run is parked on, the screen's field contract) applies unchanged. + */ + resume(run: FlowRunRef, input: EngineRow | undefined, opts: AsUser): Promise; + }; + + actions: { + /** + * Invoke a declared action as `as` through `POST /actions/:object/:action`, + * driven in-process — the one door that carries the whole action contract: + * the ADR-0066 D4 permission gate, the ADR-0104 param contract, the + * subject-record load under the caller's scope, and the trusted body + * context the sandboxed body receives. Returns the handler's return value; + * a refusal rejects with the route's envelope (`VerifyRefusal`). + */ + run( + object: string, + action: string, + opts: AsUser & { recordId?: string; params?: EngineRow }, + ): Promise; + }; + + /** + * Write fixture rows through the real engine as the platform's own seed + * replay does — `insert(object, rows, { context: { isSystem, seedReplay, + * skipTriggers } })`, the context `AppPlugin` uses for a stack's `data[]`. + * System-elevated (no permission gate), state-machine rules relaxed, record + * triggers not fired; hooks and declared validations still run, so a fixture + * the app itself could not write is refused rather than smuggled in. Returns + * the engine's rows (ids assigned). + */ + seed(object: string, rows: EngineRow[]): Promise; + + /** + * Read rows through the real engine: `find(object, { where }, { context })`. + * System-scoped by default (every row); pass `as` to read as a caller, under + * that caller's object grants and RLS. + */ + rows(object: string, where?: EngineRow, opts?: Partial): Promise; + + /** The booted `SchemaRegistry`, read through its own accessors. */ + metadata: { + /** One registered object (system columns injected), or `undefined`. */ + object(name: string): ServiceObject | undefined; + /** Every registered object — the app's and the platform's. */ + objects(): ServiceObject[]; + /** + * Every registered item of one metadata type, named as the registry names + * it — the SINGULAR `MetadataTypeSchema` vocabulary (`'permission'` for + * permission sets, `'flow'`, `'action'`, `'view'`, …; `types()` lists the + * ones this boot holds). An unknown name answers `[]`, never a guess. + */ + items(type: string): T[]; + /** The metadata types the registry currently holds items for. */ + types(): string[]; + }; + + /** + * The `tenancy` service AuthPlugin registered at boot — `posture`, + * `requestedPosture`, `isolationActive`, `degraded` — read live. What a stack + * booted with `multiTenant` (the `--multi-tenant` option `os verify` already + * has) reports here is the posture every posture-gated seam keys on. + */ + tenancy(): TenancyService; +} + +const API_PREFIX = '/api/v1'; + +/** + * The write context `AppPlugin` uses to replay a stack's declared `data[]` + * (`packages/runtime/src/app-plugin.ts`, `SEED_WRITE_OPTIONS`). Spelled here + * because the runtime keeps that constant module-private; the three flags are + * the engine's own documented `ExecutionContext` keys, not a dialect. + */ +const SEED_CONTEXT: ExecutionContext = { isSystem: true, skipTriggers: true, seedReplay: true } as ExecutionContext; +const SYSTEM_CONTEXT: ExecutionContext = { isSystem: true } as ExecutionContext; + +function refusalFrom(status: number, body: unknown, fallback: string): VerifyRefusal { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const b = body as any; + const envelope = b?.error && typeof b.error === 'object' ? b.error : undefined; + const message = + typeof envelope?.message === 'string' ? envelope.message + : typeof b?.error === 'string' ? b.error + : typeof b?.message === 'string' ? b.message + : fallback; + const code = + typeof envelope?.code === 'string' ? envelope.code + : typeof b?.code === 'string' ? b.code + : 'UNKNOWN'; + const err = new Error(message) as VerifyRefusal; + err.name = 'VerifyRefusal'; + err.code = code; + err.status = status; + err.statusCode = status; + if (envelope?.details !== undefined) err.details = envelope.details; + return err; +} + +/** + * Build the handle over a booted kernel. Called by `bootStack` once the kernel + * has bootstrapped; not a second boot path — it holds no state of its own + * beyond the dispatcher it drives, and every call resolves the engine and the + * services off `kernel` at call time. + */ +export async function createHandle(kernel: ObjectKernel, origin: string): Promise { + // The dispatcher class the boot mounted behind Hono, over the same kernel. + // Its constructor registers domain handlers and nothing else (no routes + // mounted, no services registered, no timers) — see `HttpDispatcher`. + const dispatcher = new HttpDispatcher(kernel); + // The slot's contract is the engine class itself: the handle reaches + // `registry`, `validate` and the write/read doors, which `IDataEngine` does + // not name (eslint.config.mjs, the slot-lookup rule). + const engine = (): Promise => kernel.getServiceAsync('objectql'); + const registry = () => kernel.getService('objectql').registry; + + const requestFor = (token: string, method: string, path: string) => ({ + method, + url: `${origin}${API_PREFIX}${path}`, + headers: { + authorization: `Bearer ${token}`, + 'content-type': 'application/json', + accept: 'application/json', + }, + }); + + const contextFor = async (token: string): Promise => { + if (typeof token !== 'string' || token.length === 0) { + throw new Error('verify: `as` must be a bearer token from signIn()/signUp() on this stack'); + } + const ctx: HttpProtocolContext = { request: requestFor(token, 'GET', '/data') }; + await dispatcher.resolveRequestScope(ctx, '/data'); + const ec = ctx.executionContext; + if (!ec?.userId) { + throw new Error( + 'verify: `as` token resolved to no signed-in user — the platform treats it as anonymous. ' + + 'Mint it with signIn()/signUp() on THIS stack; a token from another boot does not carry over.', + ); + } + return ec; + }; + + const dispatch = async (token: string, method: string, path: string, body: unknown): Promise => { + const ctx: HttpProtocolContext = { request: requestFor(token, method, path) }; + const res = await dispatcher.dispatch(method, path, body, {}, ctx); + if (!res.handled || !res.response) { + throw new Error(`verify: no route handled ${method} ${API_PREFIX}${path}`); + } + const { status, body: rb } = res.response; + if (status >= 400) throw refusalFrom(status, rb, `${method} ${API_PREFIX}${path} answered ${status}`); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return (rb as any)?.data; + }; + + const requireId = (input: EngineRow, operation: string): string => { + const id = input?.id; + if (typeof id !== 'string' || id.length === 0) { + throw new Error(`verify: hooks.run(..., '${operation}', input) addresses the row by input.id — none given`); + } + return id; + }; + + return { + contextFor, + + hooks: { + async run(object, operation, input, opts) { + // 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 id = operation === 'insert' ? undefined : requireId(input, operation); + const context = await contextFor(opts.as); + const ql = await engine(); + switch (operation) { + case 'insert': + return ql.insert(object, input, { context }); + case 'update': + // The REST PATCH door's spelling (`protocol.updateData`): the id + // rides in the payload AND selects the row. + return ql.update(object, { ...input, id }, { where: { id }, context }); + case 'delete': + return ql.delete(object, { where: { id }, context }); + } + }, + }, + + async validate(object, record, opts) { + const context = await contextFor(opts.as); + const ql = await engine(); + return ql.validate(object, record, { mode: opts.mode ?? 'insert', context }); + }, + + flows: { + async run(name, params, opts) { + const data = (await dispatch(opts.as, 'POST', `/automation/${encodeURIComponent(name)}/trigger`, { + params: params ?? {}, + })) as AutomationResult; + return { ...data, flowName: name }; + }, + async resume(run, input, opts) { + if (typeof run.runId !== 'string' || run.runId.length === 0) { + throw new Error( + `verify: flows.resume needs a runId — the run passed for '${run.flowName}' carries none` + + ('status' in run ? ` (status: ${String((run as { status?: unknown }).status)})` : '') + + '; only a run that PAUSED can be resumed.', + ); + } + const data = (await dispatch( + opts.as, + 'POST', + `/automation/${encodeURIComponent(run.flowName)}/runs/${encodeURIComponent(run.runId)}/resume`, + { inputs: input ?? {} }, + )) as AutomationResult; + return { ...data, flowName: run.flowName }; + }, + }, + + actions: { + async run(object, action, opts) { + return dispatch(opts.as, 'POST', `/actions/${encodeURIComponent(object)}/${encodeURIComponent(action)}`, { + ...(opts.recordId !== undefined ? { recordId: opts.recordId } : {}), + params: opts.params ?? {}, + }); + }, + }, + + async seed(object, rows) { + if (!Array.isArray(rows)) throw new Error('verify: seed(object, rows) takes an array of rows'); + const ql = await engine(); + const written = await ql.insert(object, rows, { context: SEED_CONTEXT }); + return Array.isArray(written) ? written : [written]; + }, + + async rows(object, where, opts) { + const context = opts?.as ? await contextFor(opts.as) : SYSTEM_CONTEXT; + const ql = await engine(); + const found = await ql.find(object, where ? { where } : {}, { context }); + return Array.isArray(found) ? found : []; + }, + + metadata: { + object: (name) => registry().getObject(name), + objects: () => registry().getAllObjects(), + items: (type: string): T[] => [...registry().listItems(type)], + types: () => registry().getRegisteredTypes(), + }, + + tenancy: () => kernel.getService('tenancy'), + }; +} diff --git a/packages/verify/src/harness.ts b/packages/verify/src/harness.ts index 6d667be3d8..249ff636c5 100644 --- a/packages/verify/src/harness.ts +++ b/packages/verify/src/harness.ts @@ -34,6 +34,7 @@ import { PlatformObjectsPlugin } from '@objectstack/platform-objects/plugin'; // verification — `@objectstack/organizations` above all — must be resolved from // THAT app, not from `packages/verify`'s own realpath inside this workspace. import { createHostImporter, hostImportFailureKind } from '@objectstack/types/node'; +import { createHandle, type VerifyHandle } from './handle.js'; /** A Hono app exposes `.request(path, init)` returning a standard `Response`. */ interface InjectableApp { @@ -74,7 +75,13 @@ const DEFAULT_ADMIN_EMAIL = 'admin@objectos.ai'; const DEFAULT_ADMIN_PASSWORD = 'admin123'; const DEFAULT_AUTH_SECRET = 'objectstack-verify-secret'; -export interface VerifyStack { +/** + * A booted stack: the HTTP surface (`api` / `raw` / `signIn` / `signUp` / + * `apiAs`) plus the in-process handle (`hooks` / `validate` / `flows` / + * `actions` / `seed` / `rows` / `metadata` / `tenancy` / `contextFor`) on the + * same kernel — see `./handle.ts` for what each method is a facade over. + */ +export interface VerifyStack extends VerifyHandle { /** The booted kernel — for direct service calls when bypassing HTTP is intentional. */ kernel: ObjectKernel; /** Inject an HTTP request through the real Hono app (no socket). Path is relative to `/api/v1`. */ @@ -836,5 +843,54 @@ export async function bootStack( restoreTenancyPosture(); }; - return { kernel, api, raw, signIn, signUp, apiAs, stop }; + // The in-process handle over the SAME kernel (hotcrm#1579 step 5a). Built + // after bootstrap so every service it resolves is the one the boot wired. + const handle = await createHandle(kernel, ORIGIN); + + return { kernel, api, raw, signIn, signUp, apiAs, stop, ...handle }; +} + +const NO_OPTIONS: unique symbol = Symbol('bootStackOnce:no-options'); +const SHARED_BOOTS = new WeakMap>>(); + +/** + * `bootStack`, memoised per (`config`, `opts`) IDENTITY for the life of the + * process — the worker-scoped shared boot `packages/qa/dogfood`'s + * `getSharedShowcase()` kept privately, promoted so a suite of many files can + * pay one boot per vitest worker instead of one per file (a plain boot costs + * seconds; measured at ~7.8s per file on the showcase). + * + * Both keys are compared by reference: pass the same `config` module export + * and the same `opts` object (a module-level constant, or none) from every + * file that should share, and the first caller's boot is the one everybody + * gets — including its dev-admin sign-in state. A different `opts` object, + * even one spelled identically, is a different stack: the memo never guesses + * that two `SecurityPlugin` instances mean the same thing. + * + * Sharing only makes sense under `isolate: false` (files in one worker share + * one module registry); under vitest's default isolation every file still + * boots its own. The eligibility rules dogfood wrote for its shared stack + * apply verbatim: no `stop()` from a sharing file (the worker's teardown + * reclaims the in-memory stack; a `stop()` would kill it under the worker's + * later files), no writes to shared global surfaces, and no exact-count + * assertions over objects other files also write to. + */ +export function bootStackOnce(config: any, opts?: BootOptions): Promise { + if (config === null || typeof config !== 'object') { + throw new Error('verify: bootStackOnce(config) memoises by identity, so `config` must be an object'); + } + let byOpts = SHARED_BOOTS.get(config); + if (!byOpts) { + byOpts = new Map(); + SHARED_BOOTS.set(config, byOpts); + } + const key: unknown = opts ?? NO_OPTIONS; + let booted = byOpts.get(key); + if (!booted) { + booted = bootStack(config, opts); + byOpts.set(key, booted); + // A failed boot must not poison the memo: the next caller boots again. + booted.catch(() => byOpts!.delete(key)); + } + return booted; } diff --git a/packages/verify/src/index.ts b/packages/verify/src/index.ts index 654f26578e..e96ce86d2a 100644 --- a/packages/verify/src/index.ts +++ b/packages/verify/src/index.ts @@ -7,9 +7,14 @@ // - data fidelity : runCrudVerification — author → write → read → assert // - authorization : runRlsProofs — "you can't write what you can't read" -export { bootStack } from './harness.js'; +export { bootStack, bootStackOnce } from './harness.js'; export type { VerifyStack, BootOptions } from './harness.js'; +// The in-process handle on the booted stack (hotcrm#1579 step 5a): every +// `VerifyStack` carries it; these are its types and its one predicate. +export { isVerifyRefusal } from './handle.js'; +export type { VerifyHandle, VerifyRefusal, AsUser, FlowRun, FlowRunRef, EngineRow } from './handle.js'; + export { deriveCrudCases, fillRelationalRefs } from './derive.js'; export type { CrudCase, DerivedAssert, AssertKind, RelationalRef } from './derive.js';