From e4e883dd93afd9bdc36171b4f96376be268ed59e Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 13:54:11 +0000 Subject: [PATCH 1/3] fix(runtime): an in-process hook handler is handed the same ctx.title a hook body gets A source boot (verify's bootStack(config), os serve over a config) runs a hook's inline handler in-process, where ctx.title was absent: the handler threw "ctx.title is not a function" while the artifact's body answered. bindAppArtifactHandlers now hands each inline handler function a view of the engine's hook context that answers title through buildTitleSurface, the one accessor the sandbox installs. Claude-Session: https://claude.ai/code/session_01SYEJqDL3NXxrJRBVqaMu9T Co-authored-by: Claude --- packages/runtime/src/app-artifact-handlers.ts | 30 +++- packages/runtime/src/sandbox/body-runner.ts | 67 ++++++++- .../src/harness.hook-title-door.test.ts | 137 ++++++++++++++++++ 3 files changed, 232 insertions(+), 2 deletions(-) create mode 100644 packages/verify/src/harness.hook-title-door.test.ts diff --git a/packages/runtime/src/app-artifact-handlers.ts b/packages/runtime/src/app-artifact-handlers.ts index d207bdb1656..f661ee94e9a 100644 --- a/packages/runtime/src/app-artifact-handlers.ts +++ b/packages/runtime/src/app-artifact-handlers.ts @@ -145,7 +145,13 @@ import { } from '@objectstack/types'; import { SEMCONV } from '@objectstack/observability'; import { QuickJSScriptRunner } from './sandbox/quickjs-runner.js'; -import { hookBodyRunnerFactory, actionBodyRunnerFactory, jobBodyRunnerFactory, judgeJobBody } from './sandbox/body-runner.js'; +import { + hookBodyRunnerFactory, + actionBodyRunnerFactory, + jobBodyRunnerFactory, + judgeJobBody, + withInProcessHookTitle, +} from './sandbox/body-runner.js'; import { GLOBAL_ACTION_OBJECT_KEY } from './action-execution.js'; import { collectBundleActions, @@ -249,6 +255,9 @@ export function bindAppArtifactHandlers( ); } } + // [#24289] An inline `handler` function runs in-process; it is handed the + // `ctx.title(field?)` a `body` gets in the sandbox (see `hookWithInProcessTitle`). + hooks = hooks.map((hook) => hookWithInProcessTitle(hook, ql)); // Entries, not bare handlers: each function's declared `effect` // (#4396) rides along to the registry, where a `script` node reads // it to report what its run actually did. @@ -330,6 +339,25 @@ export function bindAppArtifactHandlers( return out; } +/** + * [#24289] `hook` with its inline `handler` function handed `ctx.title(field?)` + * ({@link withInProcessHookTitle}, the one accessor a `body` is handed too), or + * `hook` itself when its `handler` is not a function. + * + * A copy, so the app's own declaration is never rewritten. Which of `body` and + * `handler` runs stays the binder's call: a hook carrying both binds its `body`, + * and the wrapped function is never consulted. + * + * A `handler` that NAMES a function (the deprecated string form, and what the + * build leaves for a callable it could not lower) also runs in-process, but the + * function is found by name inside the engine's binder, past this point, so + * it is not handed the accessor here. + */ +function hookWithInProcessTitle(hook: any, ql: IObjectQLEngine): any { + if (!hook || typeof hook.handler !== 'function') return hook; + return { ...hook, handler: withInProcessHookTitle(hook.handler, ql, String(hook.name)) }; +} + // ─── The job half (#21489) ───────────────────────────────────────────── /** diff --git a/packages/runtime/src/sandbox/body-runner.ts b/packages/runtime/src/sandbox/body-runner.ts index 6b7ebfb70c7..e5666407d28 100644 --- a/packages/runtime/src/sandbox/body-runner.ts +++ b/packages/runtime/src/sandbox/body-runner.ts @@ -239,12 +239,20 @@ function buildBodyLogSurface( * privilege escalation around it. A related record that channel does not * return leaves a title over it `undefined`, as the REST read of the same * record shows it no title. + * + * ## Two faces, one accessor (#24289) + * + * A hook's code runs in one of two places: a `body`, in the VM, which reaches + * this seam through `installCtx`; and an in-process `handler` function, which + * reaches it through {@link withInProcessHookTitle}. Both call THIS function, so + * the two answer one question one way: there is no second title implementation + * for a handler to drift from. */ function buildTitleSurface( engineCtx: any, ql: any, origin: { kind: 'hook' | 'action'; name: string }, -): ScriptContext['title'] { +): NonNullable { const label = `ctx.title (${origin.kind} '${origin.name}')`; return async (field: string | undefined, api: unknown): Promise => { @@ -289,6 +297,63 @@ function buildTitleSurface( }; } +/** + * [#24289] An in-process hook `handler` function, handed the same + * `ctx.title(field?)` a hook `body` is handed. + * + * ## Why + * + * A source boot (`@objectstack/verify`'s `bootStack(config)`, `os serve` over an + * `objectstack.config.ts`) runs a hook's inline `handler` in the host process; + * `objectstack dev` and an artifact boot run the `body` the build lowered it to. + * The accessor was installed only on the second path, so one hook answered on a + * served runtime and threw `ctx.title is not a function` under the test harness + * an app tests it with. The hook-context reference documents the body's `ctx` as + * a subset of the in-process one, so the in-process one carries the accessor too. + * + * ## What the handler sees + * + * The {@link buildTitleSurface} answer, as the VM delivers it: the same + * one-argument call (`undefined` or `null` names this record, any other value is + * the reference field, as `installCtx` reads it), and `null` — never the record's + * id — when there is no title. The read channel is the hook context's own `api`, + * read at call time, so a `runAs` hook reads under the identity it declared, as + * its body would. + * + * Two body-only rules stay body-only. No capability gates the reference form: + * `api.read` is the sandbox's token, and an in-process handler already holds + * `ctx.api` whole. And a stored-metadata family row is not refused: that boundary + * binds a sandboxed body, and an in-process handler reads through `ctx.api` + * without it. + * + * ## How + * + * The handler is called with a view of the engine's context that answers + * `title` and passes every other read, and every write, to the context itself, + * so `ctx.input` / `ctx.result` writes land where the engine reads them. The + * engine's own context object is never touched: a fire-and-forget `after*` hook + * still running while the next hook fires on the same context neither sees nor + * removes another handler's accessor, and nothing serialising the context + * finds a new key on it. + */ +export function withInProcessHookTitle unknown>( + handler: H, + ql: unknown, + hookName: string, +): (ctx: any) => Promise>> { + return async function inProcessHookHandler(engineCtx: any) { + if (engineCtx === null || typeof engineCtx !== 'object') return (await handler(engineCtx)) as Awaited>; + const surface = buildTitleSurface(engineCtx, ql, { kind: 'hook', name: hookName }); + const title = async (field?: unknown): Promise => + (await surface(field === undefined || field === null ? undefined : String(field), undefined)) ?? null; + const view = new Proxy(engineCtx, { + get: (target, prop) => (prop === 'title' ? title : Reflect.get(target, prop)), + has: (target, prop) => prop === 'title' || Reflect.has(target, prop), + }); + return (await handler(view)) as Awaited>; + }; +} + /** * The evaluation scope a formula title is computed in, derived from what the * hook context actually carries. diff --git a/packages/verify/src/harness.hook-title-door.test.ts b/packages/verify/src/harness.hook-title-door.test.ts new file mode 100644 index 00000000000..9d9503a70ea --- /dev/null +++ b/packages/verify/src/harness.hook-title-door.test.ts @@ -0,0 +1,137 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * #24289 — a hook's `ctx.title(field?)` answers the same on both doors of + * `bootStack`: the source boot, where the hook's inline `handler` runs + * in-process, and the artifact door, where the `body` the artifact carries runs + * in the sandbox, as `objectstack dev` serves it. + * + * Measured before (`origin/main` ec749fbb4): on the source boot every insert was + * refused with `TypeError: ctx.title is not a function`, while the artifact's + * body answered. The accessor was installed on the sandbox path only, so an app + * could not test a hook that used it. + * + * Pinned on each door, for one `beforeInsert`: + * + * - `ctx.title()`: this record's title, a formula that reads one hop through a + * lookup, so the in-flight foreign key is followed; + * - `ctx.title('product')`: the related record's own formula title; + * - `ctx.title('spare')`: an empty lookup, `null` and never an id. + */ + +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.hook-title-door', name: 'Hook Title Door', namespace: 'htd', version: '1.0.0', type: 'app' }; + +const product = { + name: 'htd_product', label: 'Product', pluralLabel: 'Products', sharingModel: 'public_read_write', + nameField: 'label', + fields: { + name: { name: 'name', type: 'text', label: 'Name', required: true }, + code: { name: 'code', type: 'text', label: 'Code' }, + label: { name: 'label', type: 'formula', label: 'Label', expression: "record.name + ' (' + record.code + ')'" }, + }, +}; + +const line = { + name: 'htd_line', label: 'Line', pluralLabel: 'Lines', sharingModel: 'public_read_write', + nameField: 'title', + fields: { + name: { name: 'name', type: 'text', label: 'Name', required: true }, + product: { name: 'product', type: 'lookup', label: 'Product', reference: 'htd_product' }, + spare: { name: 'spare', type: 'lookup', label: 'Spare', reference: 'htd_product' }, + title: { name: 'title', type: 'formula', label: 'Title', expression: "record.name + ' · ' + record.product.name" }, + own_title: { name: 'own_title', type: 'text', label: 'Own title' }, + product_title: { name: 'product_title', type: 'text', label: 'Product title' }, + spare_title: { name: 'spare_title', type: 'text', label: 'Spare title' }, + }, +}; + +/** One statement set, run in-process by the handler and in the sandbox by the body. */ +const SOURCE = [ + 'ctx.input.own_title = String(await ctx.title());', + "ctx.input.product_title = String(await ctx.title('product'));", + "ctx.input.spare_title = String(await ctx.title('spare'));", +].join('\n'); + +type TitleCtx = { input: Record; title: (field?: string) => Promise }; + +/** The configuration: the hook is an inline function, which a source boot runs in-process. */ +const app = (): Record => + defineStack({ + manifest, + objects: [product, line], + hooks: [ + { + name: 'htd_name_line', + object: 'htd_line', + events: ['beforeInsert'], + handler: async (ctx: TitleCtx) => { + ctx.input.own_title = String(await ctx.title()); + ctx.input.product_title = String(await ctx.title('product')); + ctx.input.spare_title = String(await ctx.title('spare')); + }, + }, + ], + } as never) as unknown as Record; + +/** The artifact: the same app, its hook carrying the same statements as a `body`. */ +const artifact = (): Record => + JSON.parse( + JSON.stringify( + defineStack({ + manifest, + objects: [product, line], + hooks: [ + { + name: 'htd_name_line', + object: 'htd_line', + events: ['beforeInsert'], + body: { language: 'js', source: SOURCE, capabilities: ['api.read'] }, + }, + ], + } as never), + ), + ) as Record; + +/** What the body answers on a served runtime, the answer the source boot owes. */ +const EXPECTED = { own: 'L1 · Widget', product: 'Widget (W-1)', spare: 'null' }; + +let hostRoot: string; +beforeAll(() => { + hostRoot = mkdtempSync(join(tmpdir(), 'verify-hook-title-door-')); + mkdirSync(join(hostRoot, 'dist')); + writeFileSync(join(hostRoot, 'dist', 'objectstack.json'), JSON.stringify(artifact())); +}); +afterAll(() => { + if (hostRoot) rmSync(hostRoot, { recursive: true, force: true }); +}); + +const live: VerifyStack[] = []; +afterEach(async () => { + for (const stack of live.splice(0)) await stack.stop(); +}); + +const titlesOn = async (stack: VerifyStack): Promise> => { + live.push(stack); + const widget = await stack.hooks.run('htd_product', 'insert', { name: 'Widget', code: 'W-1' }, { system: true }); + const row = await stack.hooks.run('htd_line', 'insert', { name: 'L1', product: widget.id }, { system: true }); + return { own: row.own_title, product: row.product_title, spare: row.spare_title }; +}; + +describe('#24289: a hook\'s ctx.title answers the same on both bootStack doors', () => { + it('the artifact door: the body, in the sandbox, as `objectstack dev` serves it', async () => { + expect(await titlesOn(await bootStack(app(), { hostRoot, artifact: 'dist/objectstack.json' }))).toEqual(EXPECTED); + }, BOOT_TIMEOUT); + + it('the source door: the inline handler, in-process, gets the same answers', async () => { + expect(await titlesOn(await bootStack(app(), { hostRoot }))).toEqual(EXPECTED); + }, BOOT_TIMEOUT); +}); From 69be10eefc6e3cc31fd54dd16db62e44edd41eff Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 13:59:16 +0000 Subject: [PATCH 2/3] test(runtime): pin the in-process ctx.title at the binder; changeset Claude-Session: https://claude.ai/code/session_01SYEJqDL3NXxrJRBVqaMu9T Co-authored-by: Claude --- .changeset/24289-inprocess-hook-ctx-title.md | 9 ++ .../app-artifact-handlers.hook-title.test.ts | 110 ++++++++++++++++++ packages/runtime/src/sandbox/body-runner.ts | 12 +- 3 files changed, 125 insertions(+), 6 deletions(-) create mode 100644 .changeset/24289-inprocess-hook-ctx-title.md create mode 100644 packages/runtime/src/app-artifact-handlers.hook-title.test.ts diff --git a/.changeset/24289-inprocess-hook-ctx-title.md b/.changeset/24289-inprocess-hook-ctx-title.md new file mode 100644 index 00000000000..71f6de50fb8 --- /dev/null +++ b/.changeset/24289-inprocess-hook-ctx-title.md @@ -0,0 +1,9 @@ +--- +'@objectstack/runtime': patch +--- + +fix(runtime): a hook's inline `handler` function is handed the same `ctx.title(field?)` a hook `body` gets, so a source boot (`@objectstack/verify`'s `bootStack(config)`, `os serve` over an `objectstack.config.ts`) answers it as `objectstack dev` does (#24289) + +Clause-②: no + +A source boot runs a hook's inline `handler` in-process, and the accessor existed only in the sandbox a `body` runs in. So a hook calling `await ctx.title()` or `await ctx.title('')` answered on `objectstack dev` and an artifact boot, and under `bootStack(config)` refused every write with `TypeError: ctx.title is not a function`. `bindAppArtifactHandlers` now hands each inline handler function a view of the engine's hook context that answers `title` through the same accessor the sandbox installs: the same two forms, the same served formula title and related-record read on the context's own `ctx.api`, and `null`, never the record's id, when there is no title. Every other read and every write goes to the engine's context unchanged, and that context gains no key. A `handler` that names a function (the deprecated string form) is not covered by this change. diff --git a/packages/runtime/src/app-artifact-handlers.hook-title.test.ts b/packages/runtime/src/app-artifact-handlers.hook-title.test.ts new file mode 100644 index 00000000000..a45861e6a7d --- /dev/null +++ b/packages/runtime/src/app-artifact-handlers.hook-title.test.ts @@ -0,0 +1,110 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * #24289 — `bindAppArtifactHandlers` hands a hook's inline `handler` function + * the `ctx.title(field?)` a hook `body` gets, through the one accessor + * (`buildTitleSurface`), on a REAL `ObjectQL` engine dispatching through its own + * `triggerHooks`. + * + * The cross-door answer (a source boot against the artifact's body, on a booted + * stack) is pinned in `@objectstack/verify`'s `harness.hook-title-door.test.ts`. + * This file pins what only the binder can show: + * + * - both forms answer, and an absent title is `null`, as the VM delivers it; + * - every write the handler makes lands on the engine's own context, while + * that context never gains a `title` key; + * - the app's declaration keeps its own function. + * + * ⚠️ `@objectstack/objectql` resolves to its `dist/` here, and so do the + * record-title imports inside `body-runner.ts`: build it first. + */ + +import { describe, it, expect } from 'vitest'; +import { ObjectQL } from '@objectstack/objectql'; +import { bindAppArtifactHandlers } from './app-artifact-handlers.js'; + +const APP_ID = 'com.example.hook-titles'; +const quiet = { debug() {}, info() {}, warn() {}, error() {} }; + +const PRODUCT = { name: 'ttl_product', nameField: 'name', fields: { name: { type: 'text' } } }; +const LINE = { + name: 'ttl_line', + nameField: 'label', + fields: { + name: { type: 'text' }, + product: { type: 'lookup', reference: 'ttl_product' }, + spare: { type: 'lookup', reference: 'ttl_product' }, + label: { type: 'formula', expression: "'#' + record.name" }, + }, +}; + +function engine(): ObjectQL { + const ql = new ObjectQL({ logger: quiet } as any); + for (const o of [PRODUCT, LINE]) ql.registry.registerObject(o as any, 'test'); + return ql; +} + +/** The engine's own hook context shape: the payload wrapped under `input.data`, and the scoped `api`. */ +function hookContext(event: string, reads: string[]) { + return { + object: 'ttl_line', + event, + input: { data: { name: 'n1', product: 'p1' } }, + session: { isSystem: true }, + api: { + object: (name: string) => ({ + findOne: async (q: { where: { id: string } }) => { + reads.push(`${name}:${q.where.id}`); + return { id: q.where.id, name: 'Widget' }; + }, + }), + }, + } as Record; +} + +describe('#24289: bindAppArtifactHandlers hands an inline hook handler ctx.title', () => { + it('answers both forms through the one accessor; an absent title is null, never an id', async () => { + const ql = engine(); + const seen: unknown[] = []; + bindAppArtifactHandlers(ql as any, { + manifest: { id: APP_ID, version: '0.1.0', type: 'app' }, + hooks: [{ + name: 'ttl_name_line', + object: 'ttl_line', + events: ['beforeInsert'], + handler: async (ctx: any) => { + seen.push(await ctx.title(), await ctx.title('product'), await ctx.title('spare'), await ctx.title(null)); + }, + }], + }, { appId: APP_ID, logger: quiet }); + + const reads: string[] = []; + await ql.triggerHooks('beforeInsert', hookContext('beforeInsert', reads) as any); + + // This record's formula title over its own columns; the related record's + // title off the one read the hook context's own `api` served. + expect(seen).toEqual(['#n1', 'Widget', null, '#n1']); + expect(reads).toEqual(['ttl_product:p1']); + }); + + it("lands every write on the engine's context, which never gains a title key", async () => { + const ql = engine(); + const handler = async (ctx: any) => { + ctx.input.stamped = String(await ctx.title()); + ctx.result = { replaced: true }; + }; + const hook = { name: 'ttl_stamp_line', object: 'ttl_line', events: ['beforeInsert'], handler }; + bindAppArtifactHandlers(ql as any, { manifest: { id: APP_ID, version: '0.1.0', type: 'app' }, hooks: [hook] }, { + appId: APP_ID, + logger: quiet, + }); + + const ctx = hookContext('beforeInsert', []); + await ql.triggerHooks('beforeInsert', ctx as any); + + expect(ctx.input.data.stamped).toBe('#n1'); + expect(ctx.result).toEqual({ replaced: true }); + expect('title' in ctx).toBe(false); + expect(hook.handler, "the app's own declaration is not rewritten").toBe(handler); + }); +}); diff --git a/packages/runtime/src/sandbox/body-runner.ts b/packages/runtime/src/sandbox/body-runner.ts index e5666407d28..fea0f9acfa8 100644 --- a/packages/runtime/src/sandbox/body-runner.ts +++ b/packages/runtime/src/sandbox/body-runner.ts @@ -336,13 +336,13 @@ function buildTitleSurface( * removes another handler's accessor, and nothing serialising the context * finds a new key on it. */ -export function withInProcessHookTitle unknown>( - handler: H, +export function withInProcessHookTitle( + handler: (ctx: any) => unknown, ql: unknown, hookName: string, -): (ctx: any) => Promise>> { - return async function inProcessHookHandler(engineCtx: any) { - if (engineCtx === null || typeof engineCtx !== 'object') return (await handler(engineCtx)) as Awaited>; +): (ctx: any) => Promise { + return async function inProcessHookHandler(engineCtx: any): Promise { + if (engineCtx === null || typeof engineCtx !== 'object') return handler(engineCtx); const surface = buildTitleSurface(engineCtx, ql, { kind: 'hook', name: hookName }); const title = async (field?: unknown): Promise => (await surface(field === undefined || field === null ? undefined : String(field), undefined)) ?? null; @@ -350,7 +350,7 @@ export function withInProcessHookTitle unknown>( get: (target, prop) => (prop === 'title' ? title : Reflect.get(target, prop)), has: (target, prop) => prop === 'title' || Reflect.has(target, prop), }); - return (await handler(view)) as Awaited>; + return handler(view); }; } From 7bd31c8e5576ee8bbe9ab41aa22f5688a315c615 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 14:05:05 +0000 Subject: [PATCH 3/3] docs(changeset): spell the lookup form without angle brackets Claude-Session: https://claude.ai/code/session_01SYEJqDL3NXxrJRBVqaMu9T Co-authored-by: Claude --- .changeset/24289-inprocess-hook-ctx-title.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/24289-inprocess-hook-ctx-title.md b/.changeset/24289-inprocess-hook-ctx-title.md index 71f6de50fb8..251c77f5396 100644 --- a/.changeset/24289-inprocess-hook-ctx-title.md +++ b/.changeset/24289-inprocess-hook-ctx-title.md @@ -6,4 +6,4 @@ fix(runtime): a hook's inline `handler` function is handed the same `ctx.title(f Clause-②: no -A source boot runs a hook's inline `handler` in-process, and the accessor existed only in the sandbox a `body` runs in. So a hook calling `await ctx.title()` or `await ctx.title('')` answered on `objectstack dev` and an artifact boot, and under `bootStack(config)` refused every write with `TypeError: ctx.title is not a function`. `bindAppArtifactHandlers` now hands each inline handler function a view of the engine's hook context that answers `title` through the same accessor the sandbox installs: the same two forms, the same served formula title and related-record read on the context's own `ctx.api`, and `null`, never the record's id, when there is no title. Every other read and every write goes to the engine's context unchanged, and that context gains no key. A `handler` that names a function (the deprecated string form) is not covered by this change. +A source boot runs a hook's inline `handler` in-process, and the accessor existed only in the sandbox a `body` runs in. So a hook calling `await ctx.title()` or `await ctx.title('account_id')` (a lookup column) answered on `objectstack dev` and an artifact boot, and under `bootStack(config)` refused every write with `TypeError: ctx.title is not a function`. `bindAppArtifactHandlers` now hands each inline handler function a view of the engine's hook context that answers `title` through the same accessor the sandbox installs: the same two forms, the same served formula title and related-record read on the context's own `ctx.api`, and `null`, never the record's id, when there is no title. Every other read and every write goes to the engine's context unchanged, and that context gains no key. A `handler` that names a function (the deprecated string form) is not covered by this change.