Skip to content

Commit e4e883d

Browse files
committed
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 <noreply@anthropic.com>
1 parent ec749fb commit e4e883d

3 files changed

Lines changed: 232 additions & 2 deletions

File tree

‎packages/runtime/src/app-artifact-handlers.ts‎

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -145,7 +145,13 @@ import {
145145
} from '@objectstack/types';
146146
import { SEMCONV } from '@objectstack/observability';
147147
import { QuickJSScriptRunner } from './sandbox/quickjs-runner.js';
148-
import { hookBodyRunnerFactory, actionBodyRunnerFactory, jobBodyRunnerFactory, judgeJobBody } from './sandbox/body-runner.js';
148+
import {
149+
hookBodyRunnerFactory,
150+
actionBodyRunnerFactory,
151+
jobBodyRunnerFactory,
152+
judgeJobBody,
153+
withInProcessHookTitle,
154+
} from './sandbox/body-runner.js';
149155
import { GLOBAL_ACTION_OBJECT_KEY } from './action-execution.js';
150156
import {
151157
collectBundleActions,
@@ -249,6 +255,9 @@ export function bindAppArtifactHandlers(
249255
);
250256
}
251257
}
258+
// [#24289] An inline `handler` function runs in-process; it is handed the
259+
// `ctx.title(field?)` a `body` gets in the sandbox (see `hookWithInProcessTitle`).
260+
hooks = hooks.map((hook) => hookWithInProcessTitle(hook, ql));
252261
// Entries, not bare handlers: each function's declared `effect`
253262
// (#4396) rides along to the registry, where a `script` node reads
254263
// it to report what its run actually did.
@@ -330,6 +339,25 @@ export function bindAppArtifactHandlers(
330339
return out;
331340
}
332341

342+
/**
343+
* [#24289] `hook` with its inline `handler` function handed `ctx.title(field?)`
344+
* ({@link withInProcessHookTitle}, the one accessor a `body` is handed too), or
345+
* `hook` itself when its `handler` is not a function.
346+
*
347+
* A copy, so the app's own declaration is never rewritten. Which of `body` and
348+
* `handler` runs stays the binder's call: a hook carrying both binds its `body`,
349+
* and the wrapped function is never consulted.
350+
*
351+
* A `handler` that NAMES a function (the deprecated string form, and what the
352+
* build leaves for a callable it could not lower) also runs in-process, but the
353+
* function is found by name inside the engine's binder, past this point, so
354+
* it is not handed the accessor here.
355+
*/
356+
function hookWithInProcessTitle(hook: any, ql: IObjectQLEngine): any {
357+
if (!hook || typeof hook.handler !== 'function') return hook;
358+
return { ...hook, handler: withInProcessHookTitle(hook.handler, ql, String(hook.name)) };
359+
}
360+
333361
// ─── The job half (#21489) ─────────────────────────────────────────────
334362

335363
/**

‎packages/runtime/src/sandbox/body-runner.ts‎

Lines changed: 66 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -239,12 +239,20 @@ function buildBodyLogSurface(
239239
* privilege escalation around it. A related record that channel does not
240240
* return leaves a title over it `undefined`, as the REST read of the same
241241
* record shows it no title.
242+
*
243+
* ## Two faces, one accessor (#24289)
244+
*
245+
* A hook's code runs in one of two places: a `body`, in the VM, which reaches
246+
* this seam through `installCtx`; and an in-process `handler` function, which
247+
* reaches it through {@link withInProcessHookTitle}. Both call THIS function, so
248+
* the two answer one question one way: there is no second title implementation
249+
* for a handler to drift from.
242250
*/
243251
function buildTitleSurface(
244252
engineCtx: any,
245253
ql: any,
246254
origin: { kind: 'hook' | 'action'; name: string },
247-
): ScriptContext['title'] {
255+
): NonNullable<ScriptContext['title']> {
248256
const label = `ctx.title (${origin.kind} '${origin.name}')`;
249257

250258
return async (field: string | undefined, api: unknown): Promise<string | undefined> => {
@@ -289,6 +297,63 @@ function buildTitleSurface(
289297
};
290298
}
291299

300+
/**
301+
* [#24289] An in-process hook `handler` function, handed the same
302+
* `ctx.title(field?)` a hook `body` is handed.
303+
*
304+
* ## Why
305+
*
306+
* A source boot (`@objectstack/verify`'s `bootStack(config)`, `os serve` over an
307+
* `objectstack.config.ts`) runs a hook's inline `handler` in the host process;
308+
* `objectstack dev` and an artifact boot run the `body` the build lowered it to.
309+
* The accessor was installed only on the second path, so one hook answered on a
310+
* served runtime and threw `ctx.title is not a function` under the test harness
311+
* an app tests it with. The hook-context reference documents the body's `ctx` as
312+
* a subset of the in-process one, so the in-process one carries the accessor too.
313+
*
314+
* ## What the handler sees
315+
*
316+
* The {@link buildTitleSurface} answer, as the VM delivers it: the same
317+
* one-argument call (`undefined` or `null` names this record, any other value is
318+
* the reference field, as `installCtx` reads it), and `null` — never the record's
319+
* id — when there is no title. The read channel is the hook context's own `api`,
320+
* read at call time, so a `runAs` hook reads under the identity it declared, as
321+
* its body would.
322+
*
323+
* Two body-only rules stay body-only. No capability gates the reference form:
324+
* `api.read` is the sandbox's token, and an in-process handler already holds
325+
* `ctx.api` whole. And a stored-metadata family row is not refused: that boundary
326+
* binds a sandboxed body, and an in-process handler reads through `ctx.api`
327+
* without it.
328+
*
329+
* ## How
330+
*
331+
* The handler is called with a view of the engine's context that answers
332+
* `title` and passes every other read, and every write, to the context itself,
333+
* so `ctx.input` / `ctx.result` writes land where the engine reads them. The
334+
* engine's own context object is never touched: a fire-and-forget `after*` hook
335+
* still running while the next hook fires on the same context neither sees nor
336+
* removes another handler's accessor, and nothing serialising the context
337+
* finds a new key on it.
338+
*/
339+
export function withInProcessHookTitle<H extends (ctx: any) => unknown>(
340+
handler: H,
341+
ql: unknown,
342+
hookName: string,
343+
): (ctx: any) => Promise<Awaited<ReturnType<H>>> {
344+
return async function inProcessHookHandler(engineCtx: any) {
345+
if (engineCtx === null || typeof engineCtx !== 'object') return (await handler(engineCtx)) as Awaited<ReturnType<H>>;
346+
const surface = buildTitleSurface(engineCtx, ql, { kind: 'hook', name: hookName });
347+
const title = async (field?: unknown): Promise<string | null> =>
348+
(await surface(field === undefined || field === null ? undefined : String(field), undefined)) ?? null;
349+
const view = new Proxy(engineCtx, {
350+
get: (target, prop) => (prop === 'title' ? title : Reflect.get(target, prop)),
351+
has: (target, prop) => prop === 'title' || Reflect.has(target, prop),
352+
});
353+
return (await handler(view)) as Awaited<ReturnType<H>>;
354+
};
355+
}
356+
292357
/**
293358
* The evaluation scope a formula title is computed in, derived from what the
294359
* hook context actually carries.
Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* #24289 — a hook's `ctx.title(field?)` answers the same on both doors of
5+
* `bootStack`: the source boot, where the hook's inline `handler` runs
6+
* in-process, and the artifact door, where the `body` the artifact carries runs
7+
* in the sandbox, as `objectstack dev` serves it.
8+
*
9+
* Measured before (`origin/main` ec749fbb4): on the source boot every insert was
10+
* refused with `TypeError: ctx.title is not a function`, while the artifact's
11+
* body answered. The accessor was installed on the sandbox path only, so an app
12+
* could not test a hook that used it.
13+
*
14+
* Pinned on each door, for one `beforeInsert`:
15+
*
16+
* - `ctx.title()`: this record's title, a formula that reads one hop through a
17+
* lookup, so the in-flight foreign key is followed;
18+
* - `ctx.title('product')`: the related record's own formula title;
19+
* - `ctx.title('spare')`: an empty lookup, `null` and never an id.
20+
*/
21+
22+
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
23+
import { tmpdir } from 'node:os';
24+
import { join } from 'node:path';
25+
import { afterAll, afterEach, beforeAll, describe, expect, it } from 'vitest';
26+
import { defineStack } from '@objectstack/spec';
27+
import { bootStack, type VerifyStack } from './harness.js';
28+
29+
const BOOT_TIMEOUT = 120_000;
30+
31+
const manifest = { id: 'com.example.hook-title-door', name: 'Hook Title Door', namespace: 'htd', version: '1.0.0', type: 'app' };
32+
33+
const product = {
34+
name: 'htd_product', label: 'Product', pluralLabel: 'Products', sharingModel: 'public_read_write',
35+
nameField: 'label',
36+
fields: {
37+
name: { name: 'name', type: 'text', label: 'Name', required: true },
38+
code: { name: 'code', type: 'text', label: 'Code' },
39+
label: { name: 'label', type: 'formula', label: 'Label', expression: "record.name + ' (' + record.code + ')'" },
40+
},
41+
};
42+
43+
const line = {
44+
name: 'htd_line', label: 'Line', pluralLabel: 'Lines', sharingModel: 'public_read_write',
45+
nameField: 'title',
46+
fields: {
47+
name: { name: 'name', type: 'text', label: 'Name', required: true },
48+
product: { name: 'product', type: 'lookup', label: 'Product', reference: 'htd_product' },
49+
spare: { name: 'spare', type: 'lookup', label: 'Spare', reference: 'htd_product' },
50+
title: { name: 'title', type: 'formula', label: 'Title', expression: "record.name + ' · ' + record.product.name" },
51+
own_title: { name: 'own_title', type: 'text', label: 'Own title' },
52+
product_title: { name: 'product_title', type: 'text', label: 'Product title' },
53+
spare_title: { name: 'spare_title', type: 'text', label: 'Spare title' },
54+
},
55+
};
56+
57+
/** One statement set, run in-process by the handler and in the sandbox by the body. */
58+
const SOURCE = [
59+
'ctx.input.own_title = String(await ctx.title());',
60+
"ctx.input.product_title = String(await ctx.title('product'));",
61+
"ctx.input.spare_title = String(await ctx.title('spare'));",
62+
].join('\n');
63+
64+
type TitleCtx = { input: Record<string, unknown>; title: (field?: string) => Promise<string | null> };
65+
66+
/** The configuration: the hook is an inline function, which a source boot runs in-process. */
67+
const app = (): Record<string, unknown> =>
68+
defineStack({
69+
manifest,
70+
objects: [product, line],
71+
hooks: [
72+
{
73+
name: 'htd_name_line',
74+
object: 'htd_line',
75+
events: ['beforeInsert'],
76+
handler: async (ctx: TitleCtx) => {
77+
ctx.input.own_title = String(await ctx.title());
78+
ctx.input.product_title = String(await ctx.title('product'));
79+
ctx.input.spare_title = String(await ctx.title('spare'));
80+
},
81+
},
82+
],
83+
} as never) as unknown as Record<string, unknown>;
84+
85+
/** The artifact: the same app, its hook carrying the same statements as a `body`. */
86+
const artifact = (): Record<string, unknown> =>
87+
JSON.parse(
88+
JSON.stringify(
89+
defineStack({
90+
manifest,
91+
objects: [product, line],
92+
hooks: [
93+
{
94+
name: 'htd_name_line',
95+
object: 'htd_line',
96+
events: ['beforeInsert'],
97+
body: { language: 'js', source: SOURCE, capabilities: ['api.read'] },
98+
},
99+
],
100+
} as never),
101+
),
102+
) as Record<string, unknown>;
103+
104+
/** What the body answers on a served runtime, the answer the source boot owes. */
105+
const EXPECTED = { own: 'L1 · Widget', product: 'Widget (W-1)', spare: 'null' };
106+
107+
let hostRoot: string;
108+
beforeAll(() => {
109+
hostRoot = mkdtempSync(join(tmpdir(), 'verify-hook-title-door-'));
110+
mkdirSync(join(hostRoot, 'dist'));
111+
writeFileSync(join(hostRoot, 'dist', 'objectstack.json'), JSON.stringify(artifact()));
112+
});
113+
afterAll(() => {
114+
if (hostRoot) rmSync(hostRoot, { recursive: true, force: true });
115+
});
116+
117+
const live: VerifyStack[] = [];
118+
afterEach(async () => {
119+
for (const stack of live.splice(0)) await stack.stop();
120+
});
121+
122+
const titlesOn = async (stack: VerifyStack): Promise<Record<string, unknown>> => {
123+
live.push(stack);
124+
const widget = await stack.hooks.run('htd_product', 'insert', { name: 'Widget', code: 'W-1' }, { system: true });
125+
const row = await stack.hooks.run('htd_line', 'insert', { name: 'L1', product: widget.id }, { system: true });
126+
return { own: row.own_title, product: row.product_title, spare: row.spare_title };
127+
};
128+
129+
describe('#24289: a hook\'s ctx.title answers the same on both bootStack doors', () => {
130+
it('the artifact door: the body, in the sandbox, as `objectstack dev` serves it', async () => {
131+
expect(await titlesOn(await bootStack(app(), { hostRoot, artifact: 'dist/objectstack.json' }))).toEqual(EXPECTED);
132+
}, BOOT_TIMEOUT);
133+
134+
it('the source door: the inline handler, in-process, gets the same answers', async () => {
135+
expect(await titlesOn(await bootStack(app(), { hostRoot }))).toEqual(EXPECTED);
136+
}, BOOT_TIMEOUT);
137+
});

0 commit comments

Comments
 (0)