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

feat(verify): `bootStack(config, { artifact })` boots the compiled artifact as the app, so a test runs each hook's and action's lowered `body` in the sandbox, as a deployment does

Clause-②: yes (widening)

`objectstack build` lowers each inline hook `handler` and action `target` it can into a metadata-only `body`, and a deployment runs that body in the runtime's QuickJS sandbox, against the sandbox's `ctx`. `bootStack` mounted the source configuration only, so a handle test ran the function in-process, and a handler that refuses once lowered passed every test.

- **`BootOptions.artifact`** names the compiled `objectstack.json`: a path, resolved against `hostRoot` (so `'dist/objectstack.json'` names the app's own build), or an `http(s)://` URL. The artifact is read by the runtime's own artifact loader (`loadArtifactBundle`, with the options every artifact boot reads it with) and mounted as the app, in place of `config`. `AppPlugin` binds each hook's `body` ahead of its `handler`, through the same sandbox runner every artifact boot uses, so every door (`hooks.run`, `actions.run`, `api`, …) runs what the build shipped.
- **A refused body answers as the body-only path answers.** The handle rejects with the sandbox's `SandboxError`, which carries a `code` and `status` the body declared on its throw. A body that crashes (a `TypeError`) carries none, and REST answers it `500` `INTERNAL_ERROR`. For example, a handler that writes to `ctx.dispatch.scope` passes in-process and refuses every insert once lowered, because the sandbox hands a body `ctx.dispatch` without `scope`.
- **The rest of the stack is still composed from `config`**: its `requires`, its own `plugins`, its declared default profile, its datasources. `objectstack serve <config>` composes the same way. So `config` is the configuration the artifact was built from.
- **A local artifact gets its `objectstack-runtime.*.mjs` merged**, so a callable the build could not lower runs in-process from it, as under `objectstack start`. `objectstack build --strict-body` refuses such a callable at build time, and its artifact is body-only.
- **Refused before anything boots**, each with its `code` and `status`: an artifact that cannot be loaded (`RESOURCE_NOT_FOUND`, 404; the boot never falls back to the source), an artifact built from another app (`RESOURCE_CONFLICT`, 409), and a configuration carrying `onEnable` (`INVALID_REQUEST`, 400). No artifact carries `onEnable`; for a configuration that mounts no plugin instance of its own, `objectstack serve <config>` grafts it from the configuration module, a `@objectstack/cli` rule this package does not re-derive. No error code is added.
- The handle lowers nothing: build the artifact first. A stale artifact boots stale bodies.
194 changes: 194 additions & 0 deletions packages/qa/dogfood/test/lowered-body-door.dogfood.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,194 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* #22301 item 7 — a hook handler that passes an in-process test and refuses
* the write once the build lowers it, and the door that lets an app's test see
* it: `bootStack(config, { artifact })` (`@objectstack/verify`), which mounts
* the compiled artifact as the app.
*
* The artifact is written by `buildShapedArtifact`, which runs the build's own
* pipeline (`normalizeStackInput` → `lowerCallables` →
* `ObjectStackDefinitionSchema`, reaching the lowering in `@objectstack/cli`'s
* source). So each hook reaches the artifact boot as the `body` the real
* lowering extracted from its handler, never a body written here. Every
* handler lowers (asserted below), so the real build emits no runtime module
* for this app either: the artifact is body-only.
*
* Each hook is driven on both boots, the source configuration (the handler,
* in-process) and its artifact (the body, in the runtime's QuickJS sandbox):
*
* - `lbd_stash` writes to `ctx.dispatch.scope`, the cross-phase scratch the
* hook contract names (`HookContextSchema.dispatch`). The sandbox hands a
* body `ctx.dispatch` as `{ mode, index }`, without `scope`, so the body
* throws a TypeError and the write is refused. The handler passes. This is
* the divergence.
* - `lbd_refuse` refuses a short name with a declared `VALIDATION_FAILED` /
* 400. Both boots refuse. In-process the caller gets the handler's own
* error; on the body-only path it gets a `SandboxError` that carries the
* declared `code` and `status` out of the VM. This is the refusal envelope.
* - `lbd_derive` derives a field. Both boots store the same value. This is
* the control.
*
* Measured before the door (`origin/main` a8f24b092c): `bootStack` always
* mounted the source configuration, so the first hook passed every handle
* test while the artifact it ships refuses every insert.
*/

import { mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { bootStack, type VerifyStack } from '@objectstack/verify';
import { defineStack } from '@objectstack/spec';
import { writeBuildShapedArtifact, type BuildShapedArtifact } from './build-shaped-artifact.js';

type HookCtx = { input: Record<string, unknown>; dispatch: { scope: Record<string, unknown> } };

const text = (name: string, label: string) => ({ name, type: 'text', label });
const object = (name: string) => ({
name,
label: name,
pluralLabel: name,
sharingModel: 'public_read_write',
fields: { name: { ...text('name', 'Name'), required: true }, note: text('note', 'Note') },
});

/** The configuration, BUILT AGAIN on each call: each boot, and the build, gets its own. */
const buildApp = (): Record<string, unknown> =>
defineStack({
manifest: { id: 'com.example.lowered-body-door', name: 'Lowered Body Door', namespace: 'lbd', version: '1.0.0', type: 'app' },
objects: [object('lbd_stash_note'), object('lbd_refuse_note'), object('lbd_derive_note')],
hooks: [
{
name: 'lbd_stash',
object: 'lbd_stash_note',
events: ['beforeInsert'],
handler: async (ctx: HookCtx) => {
ctx.dispatch.scope.stashed = ctx.input.name;
ctx.input.note = 'stashed';
},
},
{
name: 'lbd_refuse',
object: 'lbd_refuse_note',
events: ['beforeInsert'],
handler: async (ctx: HookCtx) => {
if (String(ctx.input.name ?? '').length < 3) {
throw Object.assign(new Error('the name is too short'), { code: 'VALIDATION_FAILED', status: 400 });
}
},
},
{
name: 'lbd_derive',
object: 'lbd_derive_note',
events: ['beforeInsert'],
handler: async (ctx: HookCtx) => {
ctx.input.note = 'derived:' + String(ctx.input.name);
},
},
],
} as never) as unknown as Record<string, unknown>;

let tempDir: string;
let built: BuildShapedArtifact;
let source: VerifyStack;
let lowered: VerifyStack;
let sourceToken: string;
let loweredToken: string;

beforeAll(async () => {
tempDir = mkdtempSync(join(tmpdir(), 'dogfood-lowered-body-door-'));
const artifactPath = join(tempDir, 'objectstack.json');
built = writeBuildShapedArtifact(buildApp(), artifactPath);
source = await bootStack(buildApp());
lowered = await bootStack(buildApp(), { artifact: artifactPath });
sourceToken = await source.signIn();
loweredToken = await lowered.signIn();
}, 240_000);

afterAll(async () => {
await source?.stop();
await lowered?.stop();
if (tempDir) rmSync(tempDir, { recursive: true, force: true });
});

/** What a call rejected with, or a failure naming what it resolved with. */
const rejection = async (call: Promise<unknown>): Promise<Record<string, unknown>> => {
try {
const value = await call;
throw new Error(`expected a refusal, got ${JSON.stringify(value)}`);
} catch (e) {
return e as Record<string, unknown>;
}
};

const insert = (stack: VerifyStack, object: string, name: string) =>
stack.hooks.run(object, 'insert', { name }, { system: true });

describe('the artifact under test is what the build lowers', () => {
it('every hook reaches the artifact as a body the real lowering extracted from its handler', () => {
const hooks = (built.artifact.hooks ?? []) as Array<Record<string, unknown>>;
expect(hooks.map((h) => h.name).sort()).toEqual(['lbd_derive', 'lbd_refuse', 'lbd_stash']);
for (const hook of hooks) {
expect((hook.body as { source?: unknown } | undefined)?.source, `${String(hook.name)} lowered`).toEqual(
expect.any(String),
);
}
expect(built.refs).toEqual(['lbd_derive', 'lbd_refuse', 'lbd_stash']);
});
});

describe('the divergence: a handler that passes in-process and refuses once lowered', () => {
it('in-process, the handler stashes on `ctx.dispatch.scope` and the insert is stored', async () => {
const row = await insert(source, 'lbd_stash_note', 'alpha');
expect(row.note).toBe('stashed');
});

it('lowered, the body meets a `ctx.dispatch` without `scope`: a TypeError, and the insert is refused', async () => {
const e = await rejection(insert(lowered, 'lbd_stash_note', 'alpha'));
expect(e.name).toBe('SandboxError');
expect(String(e.message)).toMatch(/^hook 'lbd_stash' threw: TypeError/);
expect(e.code).toBeUndefined();
expect(await lowered.rows('lbd_stash_note')).toEqual([]);
});

it('over REST: created in-process, a 500 once lowered', async () => {
const ok = await source.apiAs(sourceToken, 'POST', '/data/lbd_stash_note', { name: 'beta' });
expect(ok.status).toBeLessThan(300);
const refused = await lowered.apiAs(loweredToken, 'POST', '/data/lbd_stash_note', { name: 'beta' });
expect(refused.status).toBe(500);
expect(await refused.json()).toMatchObject({ code: 'INTERNAL_ERROR' });
});
});

describe('the refusal envelope: a declared refusal, as each path answers it', () => {
it("in-process, the caller gets the handler's own error", async () => {
const e = await rejection(insert(source, 'lbd_refuse_note', 'ab'));
expect(e).toMatchObject({ name: 'Error', code: 'VALIDATION_FAILED', status: 400 });
});

it('lowered, a SandboxError carries the declared code and status out of the VM', async () => {
const e = await rejection(insert(lowered, 'lbd_refuse_note', 'ab'));
expect(e).toMatchObject({ name: 'SandboxError', code: 'VALIDATION_FAILED', status: 400 });
expect(await lowered.rows('lbd_refuse_note')).toEqual([]);
});

it('over REST, both paths answer the declared envelope, the same body on the wire', async () => {
const bodies: unknown[] = [];
for (const [stack, token] of [[source, sourceToken], [lowered, loweredToken]] as const) {
const res = await stack.apiAs(token, 'POST', '/data/lbd_refuse_note', { name: 'ab' });
expect(res.status).toBe(400);
const body = await res.json();
expect(body).toMatchObject({ code: 'VALIDATION_FAILED' });
bodies.push(body);
}
expect(bodies[1]).toEqual(bodies[0]);
});
});

describe('control: a lowerable hook behaves identically both ways', () => {
it('both boots store the derived value', async () => {
expect((await insert(source, 'lbd_derive_note', 'gamma')).note).toBe('derived:gamma');
expect((await insert(lowered, 'lbd_derive_note', 'gamma')).note).toBe('derived:gamma');
});
});
41 changes: 40 additions & 1 deletion packages/verify/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -195,6 +195,44 @@ await stack.stop();
one module, under vitest `isolate: false`, and never `stop()` a stack other
files still use.

### What the build ships: boot the compiled artifact

`objectstack build` lowers each inline hook `handler` and action `target` it
can into a metadata-only `body`, and a deployment runs that body in the
runtime's QuickJS sandbox, against the sandbox's `ctx`. A source boot runs the
function in-process instead. To test what ships, boot the artifact:

```ts
// After `objectstack build` (the handle lowers nothing; build first).
const shipped = await bootStack(myApp, { artifact: 'dist/objectstack.json' });

// Every door now runs the lowered body. A handler that passes in-process can
// refuse here: a body meets the sandbox's `ctx`, which has no
// `ctx.dispatch.scope`, for one.
await expect(shipped.hooks.run('crm_lead', 'insert', { last_name: 'Ada' }, { system: true }))
.rejects.toMatchObject({ name: 'SandboxError' });
```

- The artifact is read by the runtime's own artifact loader and mounted as the
app, in place of `config`; the rest of the stack is still composed from
`config` (its `requires`, its `plugins`, its default profile), as
`objectstack serve <config>` composes it. A relative path resolves against
`hostRoot`.
- A refused body answers as the body-only path answers: the handle rejects with
the sandbox's `SandboxError`, which carries a `code` and `status` the body
declared on its throw; a body that crashes (a `TypeError`) has none, and REST
answers it `500` `INTERNAL_ERROR`.
- A callable the build could not lower is bundled into
`objectstack-runtime.*.mjs`, which a local artifact boot merges, so it runs
in-process here as under `objectstack start`. `objectstack build
--strict-body` refuses it at build time instead, and its artifact is
body-only.
- Refused before anything boots: an artifact that cannot be loaded
(`RESOURCE_NOT_FOUND` / 404; the boot never falls back to the source), one
built from another app (`RESOURCE_CONFLICT` / 409), and a configuration
carrying `onEnable`, which no artifact carries (`INVALID_REQUEST` / 400). A
stale artifact boots stale bodies: build before the run.

## Verdicts

**Data fidelity** (`runCrudVerification`):
Expand Down Expand Up @@ -258,7 +296,8 @@ for owner-scoped fixtures), `multiTenant` (also what decides the posture
`tenancy()` reports), `automation` (register the automation service so
`flows.*` and `automation.evaluateCondition` have something to drive; an app
that declares `requires: ['automation']` gets it without this), `orgContext`,
`databaseFile`, `extraPlugins`.
`databaseFile`, `extraPlugins`, `artifact` (boot the compiled artifact as the
app, so hooks and actions run their lowered `body`).

## Known limitations

Expand Down
10 changes: 10 additions & 0 deletions packages/verify/src/handle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,16 @@
// metadata → the engine's `SchemaRegistry`.
// tenancy → the `tenancy` service AuthPlugin
// registered at boot.
// the lowered-body door → NOT a method here. [#22301] What a
// deployment runs for a hook or an action is the `body` `objectstack
// build` lowered its function to, in the runtime's QuickJS sandbox. Which
// of the two runs is decided by the app the boot mounts, so the door is a
// boot option, `bootStack(config, { artifact })`: the compiled artifact is
// mounted as the app, and every door above then runs its bodies. The
// handle lowers nothing and runs no sandbox of its own.
// `harness.artifact-door.test.ts` pins the door, and
// `packages/qa/dogfood/test/lowered-body-door.dogfood.test.ts` pins a
// handler the build's real lowering turns into a body that refuses.
//
// ⛔ 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
Expand Down
Loading
Loading