diff --git a/.changeset/22301-verify-lowered-body-door.md b/.changeset/22301-verify-lowered-body-door.md new file mode 100644 index 00000000000..174d3b85430 --- /dev/null +++ b/.changeset/22301-verify-lowered-body-door.md @@ -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 ` 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 ` 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. diff --git a/packages/qa/dogfood/test/lowered-body-door.dogfood.test.ts b/packages/qa/dogfood/test/lowered-body-door.dogfood.test.ts new file mode 100644 index 00000000000..d79cd96fbbc --- /dev/null +++ b/packages/qa/dogfood/test/lowered-body-door.dogfood.test.ts @@ -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; dispatch: { scope: Record } }; + +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 => + 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; + +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): Promise> => { + try { + const value = await call; + throw new Error(`expected a refusal, got ${JSON.stringify(value)}`); + } catch (e) { + return e as Record; + } +}; + +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>; + 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'); + }); +}); diff --git a/packages/verify/README.md b/packages/verify/README.md index bb04e58a531..553d1aee0f4 100644 --- a/packages/verify/README.md +++ b/packages/verify/README.md @@ -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 ` 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`): @@ -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 diff --git a/packages/verify/src/handle.ts b/packages/verify/src/handle.ts index 62a14c3b71e..6641851314f 100644 --- a/packages/verify/src/handle.ts +++ b/packages/verify/src/handle.ts @@ -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 diff --git a/packages/verify/src/harness.artifact-door.test.ts b/packages/verify/src/harness.artifact-door.test.ts new file mode 100644 index 00000000000..aee1d6e9fce --- /dev/null +++ b/packages/verify/src/harness.artifact-door.test.ts @@ -0,0 +1,142 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * #22301 item 7 — the lowered-body door: `bootStack(config, { artifact })` + * mounts the compiled artifact as the app, read by the runtime's own artifact + * loader, so a hook runs the `body` the artifact carries, in the sandbox, where + * a source boot of the same configuration runs its inline `handler` + * in-process. + * + * The artifact here is written by this file. Its hook carries a `body` in the + * authored form, which `objectstack build` ships unchanged. The build's own + * lowering lives in `@objectstack/cli`, which depends on this package, so a + * handler the real lowering turned into a body is pinned downstream, in + * `packages/qa/dogfood/test/lowered-body-door.dogfood.test.ts`. + * + * Measured before the door (`origin/main` a8f24b092c): `BootOptions` had no way + * to name an artifact and `bootStack` always mounted `new AppPlugin(config)`, + * so a handle test could run only the in-process handler. + */ + +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterAll, afterEach, beforeAll, describe, expect, it } from 'vitest'; +import { defineStack } from '@objectstack/spec'; +import { bootStack, type VerifyStack } from './harness.js'; + +const BOOT_TIMEOUT = 120_000; + +const manifest = { id: 'com.example.artifact-door', name: 'Artifact Door', namespace: 'adr', version: '1.0.0', type: 'app' }; +const note = { + name: 'adr_note', label: 'Note', pluralLabel: 'Notes', sharingModel: 'public_read_write', + fields: { + name: { name: 'name', type: 'text', label: 'Name', required: true }, + ran: { name: 'ran', type: 'text', label: 'Ran' }, + }, +}; + +/** The configuration: one hook, an inline function that runs in-process on a source boot. */ +const app = (extra: Record = {}): Record => + defineStack({ + manifest, + objects: [note], + hooks: [ + { + name: 'adr_mark', + object: 'adr_note', + events: ['beforeInsert'], + handler: async (ctx: { input: Record }) => { + ctx.input.ran = 'handler'; + }, + }, + ], + ...extra, + } as never) as unknown as Record; + +/** The artifact: the same app, its hook carrying a `body`, as an artifact carries one. */ +const artifactOf = (id: string = manifest.id): Record => + JSON.parse( + JSON.stringify( + defineStack({ + manifest: { ...manifest, id }, + objects: [note], + hooks: [ + { + name: 'adr_mark', + object: 'adr_note', + events: ['beforeInsert'], + body: { language: 'js', source: "ctx.input.ran = 'body';", capabilities: [] }, + }, + ], + } as never), + ), + ) as Record; + +let hostRoot: string; +beforeAll(() => { + hostRoot = mkdtempSync(join(tmpdir(), 'verify-artifact-door-')); + mkdirSync(join(hostRoot, 'dist')); + writeFileSync(join(hostRoot, 'dist', 'objectstack.json'), JSON.stringify(artifactOf())); + writeFileSync(join(hostRoot, 'other-app.json'), JSON.stringify(artifactOf('com.example.another-app'))); +}); +afterAll(() => { + if (hostRoot) rmSync(hostRoot, { recursive: true, force: true }); +}); + +const live: VerifyStack[] = []; +const keep = (stack: VerifyStack): VerifyStack => { + live.push(stack); + return stack; +}; +afterEach(async () => { + for (const stack of live.splice(0)) await stack.stop(); +}); + +const inserted = async (stack: VerifyStack): Promise => + (await stack.hooks.run('adr_note', 'insert', { name: 'n1' }, { system: true })).ran; + +describe('bootStack(config, { artifact }) mounts the compiled artifact as the app', () => { + it("the artifact's body runs, in place of the configuration's in-process handler", async () => { + // A relative path names the app's own build, under hostRoot. + const stack = keep(await bootStack(app(), { hostRoot, artifact: 'dist/objectstack.json' })); + expect(await inserted(stack)).toBe('body'); + }, BOOT_TIMEOUT); + + it('control — a source boot of the same configuration runs the handler', async () => { + const stack = keep(await bootStack(app(), { hostRoot })); + expect(await inserted(stack)).toBe('handler'); + }, BOOT_TIMEOUT); +}); + +describe('the boot refuses an artifact it cannot honour, before anything is booted', () => { + it('an artifact that cannot be loaded is refused, never replaced by the source', async () => { + await expect(bootStack(app(), { hostRoot, artifact: 'dist/missing.json' })).rejects.toMatchObject({ + code: 'RESOURCE_NOT_FOUND', + status: 404, + }); + }); + + it('an artifact built from another app is refused', async () => { + await expect(bootStack(app(), { hostRoot, artifact: 'other-app.json' })).rejects.toMatchObject({ + code: 'RESOURCE_CONFLICT', + status: 409, + }); + }); + + it('a configuration carrying an `onEnable` is refused: no artifact carries one', async () => { + // Spread after `defineStack`, as an app hands its named `onEnable` export over. + const config = { ...app(), onEnable: async () => {} }; + await expect(bootStack(config, { hostRoot, artifact: 'dist/objectstack.json' })).rejects.toMatchObject({ + code: 'INVALID_REQUEST', + status: 400, + }); + }); + + it('an option that names no artifact is refused', async () => { + await expect(bootStack(app(), { hostRoot, artifact: ' ' })).rejects.toMatchObject({ + code: 'INVALID_REQUEST', + status: 400, + }); + }); +}); diff --git a/packages/verify/src/harness.ts b/packages/verify/src/harness.ts index a53e9a3fadc..f100aaf6ef1 100644 --- a/packages/verify/src/harness.ts +++ b/packages/verify/src/harness.ts @@ -23,7 +23,15 @@ // `harnessCryptoProvider`). import { randomBytes } from 'node:crypto'; -import { ObjectKernel, AppPlugin, DefaultDatasourcePlugin, createDispatcherPlugin } from '@objectstack/runtime'; +import { resolve as resolvePath } from 'node:path'; +import { + ObjectKernel, + AppPlugin, + DefaultDatasourcePlugin, + createDispatcherPlugin, + isHttpUrl, + loadArtifactBundle, +} from '@objectstack/runtime'; import { ObjectQLPlugin } from '@objectstack/objectql'; import { HonoServerPlugin } from '@objectstack/plugin-hono-server'; import { createRestApiPlugin } from '@objectstack/rest'; @@ -350,6 +358,62 @@ export interface BootOptions { * matter what the app declared (#4700, same defect class as cloud#1013). */ hostRoot?: string; + /** + * [#22301] Boot the app from its COMPILED artifact, the `objectstack.json` + * that `objectstack build` writes, instead of from the configuration's + * source. A path, resolved against {@link BootOptions.hostRoot} (so + * `'dist/objectstack.json'` names the app's own build), or an `http(s)://` + * URL. + * + * ## Why + * + * The build lowers each inline hook `handler` and action `target` it can + * into a metadata-only `body`, and the body is what a deployment runs: in + * the runtime's QuickJS sandbox, against the sandbox's `ctx`. A source boot + * runs the function in-process, against the host's `ctx`. The two can + * disagree: a handler can pass every in-process test and throw once lowered, + * or refuse with a different error. Only a boot of the artifact shows it. + * With this option every door on the stack (`hooks.run`, `actions.run`, + * `api`, …) runs what the build shipped, and a refusal is what the + * body-only path answers: the sandbox's `SandboxError`, not the function's + * own error. + * + * ## What it boots + * + * The artifact is read by the runtime's own artifact loader + * (`loadArtifactBundle`, `@objectstack/runtime`, with the options + * `createStandaloneStack` reads it with) and mounted as the app, the bundle + * `AppPlugin` binds, seeds and serves, in the slot the configuration takes + * on a source boot. `AppPlugin` binds a hook's `body` ahead of its + * `handler`, through the same sandbox runner on every artifact boot. A + * local artifact gets its `objectstack-runtime.*.mjs` merged, as every + * local artifact boot merges it, so a callable the build could NOT lower + * still runs in-process from it, as under `objectstack start`. A remote + * artifact never gets it. `objectstack build --strict-body` refuses such a + * callable at build time, which makes the artifact body-only. + * + * Everything else is still composed from `config`, by the one composition + * rule (see {@link bootStack}): its `requires`, its own `plugins`, its + * declared default profile, its datasources. `objectstack serve ` + * composes the same way, from the configuration module, with the compiled + * artifact as the app. So `config` is the configuration the artifact was + * built from, and the boot refuses an artifact that names another app + * (`RESOURCE_CONFLICT`, `status: 409`). An artifact that cannot be loaded + * is refused too (`RESOURCE_NOT_FOUND`, `status: 404`), never replaced by + * the source. A stale artifact boots stale bodies: build before the run. + * + * ⛔ Nothing is lowered here. The build's lowering lives in + * `@objectstack/cli`, which depends on this package, so the caller builds + * the artifact and this boot reads it. + * + * ⛔ An artifact carries no `onEnable`. For a configuration that mounts no + * plugin instance, `objectstack serve ` grafts the module's + * `onEnable` onto the artifact's bundle (a `@objectstack/cli` rule this + * package cannot import), and an artifact-only deployment has none. So a + * configuration carrying one is refused (`INVALID_REQUEST`, `status: 400`) + * rather than booted without it. + */ + artifact?: string; /** * Register `@objectstack/service-automation` so authored flows execute against * the real stack. The plugin seeds the built-in node executors and, at start(), @@ -566,9 +630,81 @@ const LIVE_APP_PLUGINS = new WeakSet(); /** How a configuration names itself in a refusal: its manifest id, else its name. */ function configurationLabel(config: unknown): string { - const c = config as { manifest?: { id?: unknown }; name?: unknown; id?: unknown } | null | undefined; + const id = appIdOf(config); + return id !== undefined ? `'${id}'` : '(no manifest id)'; +} + +/** The app id a configuration or an artifact names: its manifest id, else its id, else its name. */ +function appIdOf(stack: unknown): string | undefined { + const c = stack as { manifest?: { id?: unknown }; name?: unknown; id?: unknown } | null | undefined; const id = c?.manifest?.id ?? c?.id ?? c?.name; - return typeof id === 'string' && id !== '' ? `'${id}'` : '(no manifest id)'; + return typeof id === 'string' && id !== '' ? id : undefined; +} + +/** A refusal of the boot call itself, in the ADR-0112 shape (`code` + `status`) a test asserts on. */ +function bootRefusal(message: string, code: string, status: number): Error { + return Object.assign(new Error(message), { code, status }); +} + +/** + * [#22301] The compiled artifact `BootOptions.artifact` names, as the + * runtime's own artifact loader hands it back: the bundle the boot mounts as + * the app in place of `config`. Refuses, before anything is booted, an + * artifact the loader cannot read, one that names another app than `config`, + * and a `config` carrying an `onEnable` no artifact carries (see + * BootOptions.artifact for each). + */ +async function loadCompiledArtifact( + artifact: unknown, + config: unknown, + hostRoot: string, +): Promise> { + if (typeof artifact !== 'string' || artifact.trim() === '') { + throw bootRefusal( + `verify: BootOptions.artifact must name a compiled artifact (a path or an http(s):// URL), not ` + + `${JSON.stringify(artifact)}.`, + 'INVALID_REQUEST', + 400, + ); + } + if (typeof (config as { onEnable?: unknown } | null | undefined)?.onEnable === 'function') { + throw bootRefusal( + `verify: configuration ${configurationLabel(config)} carries an \`onEnable\`, and BootOptions.artifact ` + + 'boots the compiled artifact, which carries none. An artifact-only deployment never runs it, and ' + + '`objectstack serve ` grafts it from the configuration module, a rule this package cannot ' + + 'import. Pass the configuration without `onEnable` to test what the artifact runs, or boot the ' + + 'source configuration to test `onEnable`.', + 'INVALID_REQUEST', + 400, + ); + } + // Every artifact boot reads it this way (`createStandaloneStack`): a local + // file has its runtime module merged, a remote one never does. + const location = isHttpUrl(artifact) ? artifact : resolvePath(hostRoot, artifact); + const bundle: unknown = await loadArtifactBundle(location, { tag: '[verify]', unwrapEnvelope: true }); + if (bundle === null || typeof bundle !== 'object' || Array.isArray(bundle)) { + throw bootRefusal( + `verify: BootOptions.artifact names ${location}, and no compiled artifact could be loaded from it (the ` + + "loader's own line above says whether it is absent or unreadable). Build it first: `objectstack build` " + + "in the app's root writes dist/objectstack.json. A relative path resolves against BootOptions.hostRoot " + + `(${hostRoot}). The boot does not fall back to the source configuration.`, + 'RESOURCE_NOT_FOUND', + 404, + ); + } + const want = appIdOf(config); + const got = appIdOf(bundle); + if (want !== undefined && got !== want) { + throw bootRefusal( + `verify: the artifact at ${location} is app ${got === undefined ? '(no manifest id)' : `'${got}'`}, and ` + + `the configuration it was handed with is app '${want}'. BootOptions.artifact boots the artifact built ` + + 'from that configuration, which composes the rest of the stack. Build this configuration, or pass the ' + + 'configuration this artifact was built from.', + 'RESOURCE_CONFLICT', + 409, + ); + } + return bundle as Record; } /** @@ -680,6 +816,15 @@ function claimConfiguration(config: unknown): ConfigurationClaim { * the stack's locales). A suite that exercises either passes the provider in * {@link BootOptions.extraPlugins}. * + * ### The app: the configuration, or the artifact built from it + * + * By default the app this boot mounts is `config` itself, so a hook's inline + * `handler` runs in-process. {@link BootOptions.artifact} mounts the compiled + * artifact `objectstack build` wrote from `config` in its place, read by the + * runtime's own artifact loader, so each hook and action runs the `body` the + * build lowered it to, in the runtime's sandbox, as a deployment runs it. The + * composition above is read from `config` either way. + * * ### Offline CI: `OS_CLOUD_URL=off` * * An app whose `plugins` array wires marketplace-facing plugins (the @@ -748,6 +893,17 @@ async function bootClaimed( ); } + // The host app's root — where `multiTenant` resolves the enterprise package + // from, the `packageRoot` a required `automation` is handed, and what a + // relative `artifact` resolves against (#22301). + const hostRoot = opts.hostRoot ?? process.cwd(); + // [#22301] The app this boot mounts: the configuration itself, or the + // artifact built from it (see BootOptions.artifact). Read before the posture + // is requested and the kernel is built, so a refused artifact leaves nothing + // to undo. + const appBundle: unknown = + opts.artifact === undefined ? config : await loadCompiledArtifact(opts.artifact, config, hostRoot); + // [ADR-0105 D1] `multiTenant: true` REQUESTS the hard organization wall — // posture `isolated`, what `OS_MULTI_ORG_ENABLED=true` historically meant. // Since #3559 a walled posture is an explicit operator request resolved from @@ -788,9 +944,6 @@ async function bootClaimed( }; const kernel = new ObjectKernel(); - // The host app's root — where `multiTenant` resolves the enterprise package - // from, and the `packageRoot` a required `automation` is handed (#22301). - const hostRoot = opts.hostRoot ?? process.cwd(); // Data engine + in-memory SQLite (pure-JS WASM driver — no native build, CI-safe). // The default datasource is a DECLARED DEFINITION connected through the @@ -815,8 +968,10 @@ async function bootClaimed( // hit the socket — requests are injected through the Hono app directly. await kernel.use(new HonoServerPlugin({ port: 0 })); - // The app under test (objects, datasets, cubes, flows, seed data). - await kernel.use(new AppPlugin(config)); + // The app under test (objects, datasets, cubes, flows, seed data): the + // configuration, or [#22301] the compiled artifact `BootOptions.artifact` + // names, whose hook and action `body`s AppPlugin binds to the sandbox. + await kernel.use(new AppPlugin(appBundle)); // Platform infrastructure `os serve` auto-injects into every served kernel: // the `sys_migration` flag ledger (#4243) and the `sys_secret` cipher store