|
| 1 | +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. |
| 2 | + |
| 3 | +/** |
| 4 | + * #22301 item 7 — a hook handler that passes an in-process test and refuses |
| 5 | + * the write once the build lowers it, and the door that lets an app's test see |
| 6 | + * it: `bootStack(config, { artifact })` (`@objectstack/verify`), which mounts |
| 7 | + * the compiled artifact as the app. |
| 8 | + * |
| 9 | + * The artifact is written by `buildShapedArtifact`, which runs the build's own |
| 10 | + * pipeline (`normalizeStackInput` → `lowerCallables` → |
| 11 | + * `ObjectStackDefinitionSchema`, reaching the lowering in `@objectstack/cli`'s |
| 12 | + * source). So each hook reaches the artifact boot as the `body` the real |
| 13 | + * lowering extracted from its handler, never a body written here. Every |
| 14 | + * handler lowers (asserted below), so the real build emits no runtime module |
| 15 | + * for this app either: the artifact is body-only. |
| 16 | + * |
| 17 | + * Each hook is driven on both boots, the source configuration (the handler, |
| 18 | + * in-process) and its artifact (the body, in the runtime's QuickJS sandbox): |
| 19 | + * |
| 20 | + * - `lbd_stash` writes to `ctx.dispatch.scope`, the cross-phase scratch the |
| 21 | + * hook contract names (`HookContextSchema.dispatch`). The sandbox hands a |
| 22 | + * body `ctx.dispatch` as `{ mode, index }`, without `scope`, so the body |
| 23 | + * throws a TypeError and the write is refused. The handler passes. This is |
| 24 | + * the divergence. |
| 25 | + * - `lbd_refuse` refuses a short name with a declared `VALIDATION_FAILED` / |
| 26 | + * 400. Both boots refuse. In-process the caller gets the handler's own |
| 27 | + * error; on the body-only path it gets a `SandboxError` that carries the |
| 28 | + * declared `code` and `status` out of the VM. This is the refusal envelope. |
| 29 | + * - `lbd_derive` derives a field. Both boots store the same value. This is |
| 30 | + * the control. |
| 31 | + * |
| 32 | + * Measured before the door (`origin/main` a8f24b092c): `bootStack` always |
| 33 | + * mounted the source configuration, so the first hook passed every handle |
| 34 | + * test while the artifact it ships refuses every insert. |
| 35 | + */ |
| 36 | + |
| 37 | +import { mkdtempSync, rmSync } from 'node:fs'; |
| 38 | +import { tmpdir } from 'node:os'; |
| 39 | +import { join } from 'node:path'; |
| 40 | +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; |
| 41 | +import { bootStack, type VerifyStack } from '@objectstack/verify'; |
| 42 | +import { defineStack } from '@objectstack/spec'; |
| 43 | +import { writeBuildShapedArtifact, type BuildShapedArtifact } from './build-shaped-artifact.js'; |
| 44 | + |
| 45 | +type HookCtx = { input: Record<string, unknown>; dispatch: { scope: Record<string, unknown> } }; |
| 46 | + |
| 47 | +const text = (name: string, label: string) => ({ name, type: 'text', label }); |
| 48 | +const object = (name: string) => ({ |
| 49 | + name, |
| 50 | + label: name, |
| 51 | + pluralLabel: name, |
| 52 | + sharingModel: 'public_read_write', |
| 53 | + fields: { name: { ...text('name', 'Name'), required: true }, note: text('note', 'Note') }, |
| 54 | +}); |
| 55 | + |
| 56 | +/** The configuration, BUILT AGAIN on each call: each boot, and the build, gets its own. */ |
| 57 | +const buildApp = (): Record<string, unknown> => |
| 58 | + defineStack({ |
| 59 | + manifest: { id: 'com.example.lowered-body-door', name: 'Lowered Body Door', namespace: 'lbd', version: '1.0.0', type: 'app' }, |
| 60 | + objects: [object('lbd_stash_note'), object('lbd_refuse_note'), object('lbd_derive_note')], |
| 61 | + hooks: [ |
| 62 | + { |
| 63 | + name: 'lbd_stash', |
| 64 | + object: 'lbd_stash_note', |
| 65 | + events: ['beforeInsert'], |
| 66 | + handler: async (ctx: HookCtx) => { |
| 67 | + ctx.dispatch.scope.stashed = ctx.input.name; |
| 68 | + ctx.input.note = 'stashed'; |
| 69 | + }, |
| 70 | + }, |
| 71 | + { |
| 72 | + name: 'lbd_refuse', |
| 73 | + object: 'lbd_refuse_note', |
| 74 | + events: ['beforeInsert'], |
| 75 | + handler: async (ctx: HookCtx) => { |
| 76 | + if (String(ctx.input.name ?? '').length < 3) { |
| 77 | + throw Object.assign(new Error('the name is too short'), { code: 'VALIDATION_FAILED', status: 400 }); |
| 78 | + } |
| 79 | + }, |
| 80 | + }, |
| 81 | + { |
| 82 | + name: 'lbd_derive', |
| 83 | + object: 'lbd_derive_note', |
| 84 | + events: ['beforeInsert'], |
| 85 | + handler: async (ctx: HookCtx) => { |
| 86 | + ctx.input.note = 'derived:' + String(ctx.input.name); |
| 87 | + }, |
| 88 | + }, |
| 89 | + ], |
| 90 | + } as never) as unknown as Record<string, unknown>; |
| 91 | + |
| 92 | +let tempDir: string; |
| 93 | +let built: BuildShapedArtifact; |
| 94 | +let source: VerifyStack; |
| 95 | +let lowered: VerifyStack; |
| 96 | +let sourceToken: string; |
| 97 | +let loweredToken: string; |
| 98 | + |
| 99 | +beforeAll(async () => { |
| 100 | + tempDir = mkdtempSync(join(tmpdir(), 'dogfood-lowered-body-door-')); |
| 101 | + const artifactPath = join(tempDir, 'objectstack.json'); |
| 102 | + built = writeBuildShapedArtifact(buildApp(), artifactPath); |
| 103 | + source = await bootStack(buildApp()); |
| 104 | + lowered = await bootStack(buildApp(), { artifact: artifactPath }); |
| 105 | + sourceToken = await source.signIn(); |
| 106 | + loweredToken = await lowered.signIn(); |
| 107 | +}, 240_000); |
| 108 | + |
| 109 | +afterAll(async () => { |
| 110 | + await source?.stop(); |
| 111 | + await lowered?.stop(); |
| 112 | + if (tempDir) rmSync(tempDir, { recursive: true, force: true }); |
| 113 | +}); |
| 114 | + |
| 115 | +/** What a call rejected with, or a failure naming what it resolved with. */ |
| 116 | +const rejection = async (call: Promise<unknown>): Promise<Record<string, unknown>> => { |
| 117 | + try { |
| 118 | + const value = await call; |
| 119 | + throw new Error(`expected a refusal, got ${JSON.stringify(value)}`); |
| 120 | + } catch (e) { |
| 121 | + return e as Record<string, unknown>; |
| 122 | + } |
| 123 | +}; |
| 124 | + |
| 125 | +const insert = (stack: VerifyStack, object: string, name: string) => |
| 126 | + stack.hooks.run(object, 'insert', { name }, { system: true }); |
| 127 | + |
| 128 | +describe('the artifact under test is what the build lowers', () => { |
| 129 | + it('every hook reaches the artifact as a body the real lowering extracted from its handler', () => { |
| 130 | + const hooks = (built.artifact.hooks ?? []) as Array<Record<string, unknown>>; |
| 131 | + expect(hooks.map((h) => h.name).sort()).toEqual(['lbd_derive', 'lbd_refuse', 'lbd_stash']); |
| 132 | + for (const hook of hooks) { |
| 133 | + expect((hook.body as { source?: unknown } | undefined)?.source, `${String(hook.name)} lowered`).toEqual( |
| 134 | + expect.any(String), |
| 135 | + ); |
| 136 | + } |
| 137 | + expect(built.refs).toEqual(['lbd_derive', 'lbd_refuse', 'lbd_stash']); |
| 138 | + }); |
| 139 | +}); |
| 140 | + |
| 141 | +describe('the divergence: a handler that passes in-process and refuses once lowered', () => { |
| 142 | + it('in-process, the handler stashes on `ctx.dispatch.scope` and the insert is stored', async () => { |
| 143 | + const row = await insert(source, 'lbd_stash_note', 'alpha'); |
| 144 | + expect(row.note).toBe('stashed'); |
| 145 | + }); |
| 146 | + |
| 147 | + it('lowered, the body meets a `ctx.dispatch` without `scope`: a TypeError, and the insert is refused', async () => { |
| 148 | + const e = await rejection(insert(lowered, 'lbd_stash_note', 'alpha')); |
| 149 | + expect(e.name).toBe('SandboxError'); |
| 150 | + expect(String(e.message)).toMatch(/^hook 'lbd_stash' threw: TypeError/); |
| 151 | + expect(e.code).toBeUndefined(); |
| 152 | + expect(await lowered.rows('lbd_stash_note')).toEqual([]); |
| 153 | + }); |
| 154 | + |
| 155 | + it('over REST: created in-process, a 500 once lowered', async () => { |
| 156 | + const ok = await source.apiAs(sourceToken, 'POST', '/data/lbd_stash_note', { name: 'beta' }); |
| 157 | + expect(ok.status).toBeLessThan(300); |
| 158 | + const refused = await lowered.apiAs(loweredToken, 'POST', '/data/lbd_stash_note', { name: 'beta' }); |
| 159 | + expect(refused.status).toBe(500); |
| 160 | + expect(await refused.json()).toMatchObject({ code: 'INTERNAL_ERROR' }); |
| 161 | + }); |
| 162 | +}); |
| 163 | + |
| 164 | +describe('the refusal envelope: a declared refusal, as each path answers it', () => { |
| 165 | + it("in-process, the caller gets the handler's own error", async () => { |
| 166 | + const e = await rejection(insert(source, 'lbd_refuse_note', 'ab')); |
| 167 | + expect(e).toMatchObject({ name: 'Error', code: 'VALIDATION_FAILED', status: 400 }); |
| 168 | + }); |
| 169 | + |
| 170 | + it('lowered, a SandboxError carries the declared code and status out of the VM', async () => { |
| 171 | + const e = await rejection(insert(lowered, 'lbd_refuse_note', 'ab')); |
| 172 | + expect(e).toMatchObject({ name: 'SandboxError', code: 'VALIDATION_FAILED', status: 400 }); |
| 173 | + expect(await lowered.rows('lbd_refuse_note')).toEqual([]); |
| 174 | + }); |
| 175 | + |
| 176 | + it('over REST, both paths answer the declared envelope, the same body on the wire', async () => { |
| 177 | + const bodies: unknown[] = []; |
| 178 | + for (const [stack, token] of [[source, sourceToken], [lowered, loweredToken]] as const) { |
| 179 | + const res = await stack.apiAs(token, 'POST', '/data/lbd_refuse_note', { name: 'ab' }); |
| 180 | + expect(res.status).toBe(400); |
| 181 | + const body = await res.json(); |
| 182 | + expect(body).toMatchObject({ code: 'VALIDATION_FAILED' }); |
| 183 | + bodies.push(body); |
| 184 | + } |
| 185 | + expect(bodies[1]).toEqual(bodies[0]); |
| 186 | + }); |
| 187 | +}); |
| 188 | + |
| 189 | +describe('control: a lowerable hook behaves identically both ways', () => { |
| 190 | + it('both boots store the derived value', async () => { |
| 191 | + expect((await insert(source, 'lbd_derive_note', 'gamma')).note).toBe('derived:gamma'); |
| 192 | + expect((await insert(lowered, 'lbd_derive_note', 'gamma')).note).toBe('derived:gamma'); |
| 193 | + }); |
| 194 | +}); |
0 commit comments