diff --git a/.changeset/21604-hook-handler-package-scope.md b/.changeset/21604-hook-handler-package-scope.md new file mode 100644 index 00000000000..0cb4a78c321 --- /dev/null +++ b/.changeset/21604-hook-handler-package-scope.md @@ -0,0 +1,20 @@ +--- +'@objectstack/objectql': minor +'@objectstack/spec': minor +--- + +fix(objectql,spec)!: a hook's `handler` name resolves inside the hook's own package only (#21604) + +Clause-②: yes (narrowing) + + + +**BREAKING**: a hook whose `handler` is a function NAME (the deprecated form, `handler: 'my_fn'`, with no `body`) now binds only to a function its own package holds. It used to fall back to the engine-wide function registry, which is keyed by bare name, so the hook could bind to a function another package registered under the same name and run that package's code on its own events. + +- **Accepted before:** a string `handler` resolved against the functions handed to the hook's bind, then against every function any package had registered on the engine. A name found nowhere was skipped with a `warn`. +- **Accepted now:** a string `handler` resolves against the functions handed to the hook's bind (the package's `functions`, which an `--artifact` runtime module supplies), then against the functions the same package (`packageId`) registered on the engine. Nothing else. +- **Refused now, at registration:** a name the hook's own package does not hold, whether another package registered it or nobody did. The hook is not bound. The refusal carries `INVALID_REFERENCE` with status `400` (ADR-0112), names the hook, the function and the package, and is recorded on the bind result (`BindHooksResult.errors[]` gains `code` and `status`) and logged at `error`. Under `strict` (`OBJECTQL_STRICT_HOOKS=1`) it is thrown. +- **The doors:** a hook authored at runtime through the metadata API (`PUT /api/v1/meta/hook/:name`) ships with no code package and holds no functions, so a `handler`-only hook authored there is refused when the door binds it; the save itself still answers as before. In a composition of several apps, one app's hook can no longer bind to another app's function. A bind that names no owning package (direct `bindHooksToEngine` use without `packageId`) resolves only the functions handed to it. +- **Unchanged:** a hook with a `body` binds as before. An app's hook naming its own `defineStack({ functions })` entry, or a function its own `--artifact` runtime module exports, binds as before. The install-local door's refusal of a hook with no `body` is unchanged. + +What to do with a refused hook: give it a `body` (sandboxed JS), or declare the function in the hook's own package's `functions`. To reuse another package's function, import it from the package that owns it and declare it there. This ships as `minor`, under the launch-window convention for narrowings of an accept set. diff --git a/packages/objectql/src/engine.ts b/packages/objectql/src/engine.ts index 16946faf996..be995546c88 100644 --- a/packages/objectql/src/engine.ts +++ b/packages/objectql/src/engine.ts @@ -3657,10 +3657,11 @@ export class ObjectQL implements IObjectQLEngine { */ private readonly actionActivation = new ActionActivationProjection(); - // Function registry: name → handler. Used by `bindHooksToEngine` to - // resolve string-named hook handlers (the JSON-safe form). Populated by - // `defineStack({ functions })` via `AppPlugin`, or directly via - // `engine.registerFunction(...)`. + // Function registry: name → handler, each entry stamped with its owning + // package. Used by `bindHooksToEngine` to resolve string-named hook + // handlers (the JSON-safe form) — only against entries the hook's OWN + // package registered. Populated by `defineStack({ functions })` via + // `AppPlugin`, or directly via `engine.registerFunction(...)`. private functions = new Map(); // Realtime service for event publishing @@ -3867,7 +3868,8 @@ export class ObjectQL implements IObjectQLEngine { * string from a `Hook.handler` field, an `Action.target`, or a flow * `script` node's `config.function`. This is the JSON-safe form of * handler binding — declarative metadata persisted to disk or shipped - * over the wire only carries the name. + * over the wire only carries the name. A `Hook.handler` reaches the entry + * only from a hook of the same `packageId` (`bindHooksToEngine`). * * The third parameter accepts either the owning `packageId` (its original * shape, unchanged for every existing caller) or a diff --git a/packages/objectql/src/hook-binder-package-scope.test.ts b/packages/objectql/src/hook-binder-package-scope.test.ts new file mode 100644 index 00000000000..e2f702740f6 --- /dev/null +++ b/packages/objectql/src/hook-binder-package-scope.test.ts @@ -0,0 +1,195 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * A hook's `handler` name resolves inside the hook's OWN package only. + * + * The engine's function registry is keyed by bare name, and the binder used to + * fall back to it unscoped: a hook naming `shared_stamp` bound to whichever + * package had registered a function of that name, so that package's code ran on + * this package's events. Now a name resolves against the functions handed to + * the hook's own bind, then against the entries the SAME package registered; a + * name the package does not hold is refused at registration with the ADR-0112 + * envelope (`INVALID_REFERENCE`, 400) and the hook is not bound. + * + * Every refusal here asserts the code, the status and that the hook did not + * bind (the other package's function never runs on the event). The controls + * are the two shapes a package's own functions take: the functions handed to + * the same bind, and a function the same package registered in an earlier bind. + */ + +import { describe, it, expect, vi } from 'vitest'; +import { ObjectQL } from './engine.js'; +import { + bindHooksToEngine, + HOOK_HANDLER_NOT_IN_PACKAGE_CODE, + HOOK_HANDLER_NOT_IN_PACKAGE_STATUS, +} from './hook-binder.js'; +import type { Hook, HookContext } from '@objectstack/spec/data'; + +function captureLogger() { + const logger: any = { + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + trace: vi.fn(), + fatal: vi.fn(), + }; + logger.child = () => logger; + return logger; +} + +function makeEngine(logger = captureLogger()) { + return { engine: new ObjectQL({ logger }), logger }; +} + +function ctxFor(object = 'account'): HookContext { + return { object, event: 'beforeInsert', input: { data: {} }, ql: undefined } as unknown as HookContext; +} + +const hookNaming = (name: string, handler: string): Hook => ({ + name, + object: 'account', + events: ['beforeInsert'], + priority: 100, + handler, +}); + +/** Package A registers `shared_stamp` the way a code package does: through its own bind's `functions`. */ +function registerPackageA(engine: ObjectQL, ran: string[]) { + bindHooksToEngine(engine, [], { + packageId: 'app:com.example.a', + functions: { shared_stamp: async () => { ran.push('a:shared_stamp'); } }, + }); +} + +describe('a hook handler name resolves inside its own package only', () => { + it('the envelope constants are the standard catalog member and its status', () => { + expect(HOOK_HANDLER_NOT_IN_PACKAGE_CODE).toBe('INVALID_REFERENCE'); + expect(HOOK_HANDLER_NOT_IN_PACKAGE_STATUS).toBe(400); + }); + + it('refuses a hook naming a function ANOTHER package registered, and that function never runs', async () => { + const { engine } = makeEngine(); + const ran: string[] = []; + registerPackageA(engine, ran); + + const result = bindHooksToEngine(engine, [hookNaming('b_cross', 'shared_stamp')], { + packageId: 'app:com.example.b', + }); + + expect(result.registered).toBe(0); + expect(result.skipped).toBe(1); + expect(result.errors).toHaveLength(1); + expect(result.errors[0]).toMatchObject({ hook: 'b_cross', code: 'INVALID_REFERENCE', status: 400 }); + expect(result.errors[0]!.reason).toContain("'shared_stamp'"); + expect(result.errors[0]!.reason).toContain("'app:com.example.b'"); + + await engine.triggerHooks('beforeInsert', ctxFor()); + expect(ran, "package A's function ran on package B's event").toEqual([]); + }); + + it('under strict, the refusal is thrown with its code and status, and nothing binds', async () => { + const { engine } = makeEngine(); + const ran: string[] = []; + registerPackageA(engine, ran); + + let thrown: any; + try { + bindHooksToEngine(engine, [hookNaming('b_cross_strict', 'shared_stamp')], { + packageId: 'app:com.example.b', + strict: true, + }); + } catch (err) { + thrown = err; + } + expect(thrown).toBeInstanceOf(Error); + expect(thrown).toMatchObject({ + code: 'INVALID_REFERENCE', + status: 400, + hook: 'b_cross_strict', + handler: 'shared_stamp', + packageId: 'app:com.example.b', + }); + + await engine.triggerHooks('beforeInsert', ctxFor()); + expect(ran).toEqual([]); + }); + + it('refuses a name no package holds with the same envelope', async () => { + const { engine } = makeEngine(); + const result = bindHooksToEngine(engine, [hookNaming('typo_hook', 'shard_stamp')], { + packageId: 'app:com.example.b', + }); + expect(result.registered).toBe(0); + expect(result.errors[0]).toMatchObject({ hook: 'typo_hook', code: 'INVALID_REFERENCE', status: 400 }); + }); + + it('the metadata door (owner `metadata-service`) cannot reach a code package\'s function; the refusal is logged at error with its envelope', async () => { + const { engine, logger } = makeEngine(); + const ran: string[] = []; + registerPackageA(engine, ran); + + // The door the runtime-authored hooks are bound through. + engine.bindHooks([hookNaming('authored_cross', 'shared_stamp')], { packageId: 'metadata-service' }); + + await engine.triggerHooks('beforeInsert', ctxFor()); + expect(ran, 'a runtime-authored hook ran a code package\'s function').toEqual([]); + + const refusals = logger.error.mock.calls.filter( + (call: any[]) => call[2]?.hook === 'authored_cross', + ); + expect(refusals).toHaveLength(1); + expect(refusals[0][1]).toBeInstanceOf(Error); + expect(refusals[0][2]).toMatchObject({ + code: 'INVALID_REFERENCE', + status: 400, + handler: 'shared_stamp', + packageId: 'metadata-service', + }); + }); + + it('a bind that names no owning package resolves only what it was handed — never an unowned engine entry', async () => { + const { engine } = makeEngine(); + const ran: string[] = []; + engine.registerFunction('loose_fn', async () => { ran.push('loose_fn'); }); + + const result = bindHooksToEngine(engine, [hookNaming('unowned_hook', 'loose_fn')], {}); + expect(result.registered).toBe(0); + expect(result.errors[0]).toMatchObject({ hook: 'unowned_hook', code: 'INVALID_REFERENCE', status: 400 }); + + await engine.triggerHooks('beforeInsert', ctxFor()); + expect(ran).toEqual([]); + }); + + it('control: a hook naming a function handed to its own bind binds and runs', async () => { + const { engine } = makeEngine(); + const ran: string[] = []; + registerPackageA(engine, ran); + + const result = bindHooksToEngine(engine, [hookNaming('b_own', 'b_stamp')], { + packageId: 'app:com.example.b', + functions: { b_stamp: async () => { ran.push('b:b_stamp'); } }, + }); + expect(result.registered).toBe(1); + expect(result.errors).toEqual([]); + + await engine.triggerHooks('beforeInsert', ctxFor()); + expect(ran).toEqual(['b:b_stamp']); + }); + + it('control: a hook naming a function its OWN package registered in an earlier bind binds and runs', async () => { + const { engine } = makeEngine(); + const ran: string[] = []; + registerPackageA(engine, ran); + + const result = bindHooksToEngine(engine, [hookNaming('a_own_later', 'shared_stamp')], { + packageId: 'app:com.example.a', + }); + expect(result.registered).toBe(1); + expect(result.errors).toEqual([]); + + await engine.triggerHooks('beforeInsert', ctxFor()); + expect(ran).toEqual(['a:shared_stamp']); + }); +}); diff --git a/packages/objectql/src/hook-binder.test.ts b/packages/objectql/src/hook-binder.test.ts index 9000bc65341..5dd6efc70ac 100644 --- a/packages/objectql/src/hook-binder.test.ts +++ b/packages/objectql/src/hook-binder.test.ts @@ -62,7 +62,7 @@ describe('bindHooksToEngine', () => { expect(seen).toEqual(['called']); }); - it('skips hooks whose string handler cannot be resolved', () => { + it('refuses a hook whose string handler names no function of its package', () => { const engine = makeEngine(); const hook: Hook = { name: 'h3', @@ -74,7 +74,7 @@ describe('bindHooksToEngine', () => { const result = bindHooksToEngine(engine, [hook], { packageId: 'p' }); expect(result.registered).toBe(0); expect(result.skipped).toBe(1); - expect(result.errors[0]?.reason).toMatch(/unknown function/); + expect(result.errors[0]).toMatchObject({ hook: 'h3', code: 'INVALID_REFERENCE', status: 400 }); }); // #4001: `normalizeObjects` used to widen a blank target to `['*']`, the @@ -164,7 +164,7 @@ describe('bindHooksToEngine', () => { }; expect(() => bindHooksToEngine(engine, [hook], { strict: true })) - .toThrow(/unknown function 'no_such_fn'/); + .toThrowError(expect.objectContaining({ code: 'INVALID_REFERENCE', status: 400, handler: 'no_such_fn' })); }); it('still records-and-continues when strict is off', () => { diff --git a/packages/objectql/src/hook-binder.ts b/packages/objectql/src/hook-binder.ts index fbe6d57253f..9301e0130c9 100644 --- a/packages/objectql/src/hook-binder.ts +++ b/packages/objectql/src/hook-binder.ts @@ -33,8 +33,11 @@ export interface BindHooksOptions { /** * Optional name → function map for resolving string `handler` references. - * Typically supplied by `defineStack({ functions })` and merged with any - * functions previously registered on the engine. + * Typically supplied by `defineStack({ functions })` (an artifact's runtime + * module supplies it on the artifact path). A string `handler` resolves + * against this map, then against the functions registered on the engine + * under the SAME `packageId` — never another package's; a name neither + * holds is refused at registration ({@link HOOK_HANDLER_NOT_IN_PACKAGE_CODE}). * * A value may be the handler itself or a declaration record stating what the * function does (`{ handler, effect: 'writes' }`, #4396) — the same two @@ -96,7 +99,61 @@ const noopLogger: HookDiagnosticsLogger = { export interface BindHooksResult { registered: number; skipped: number; - errors: Array<{ hook: string; reason: string }>; + /** + * One entry per hook that did not bind. `code` and `status` are set when the + * failure is a coded registration refusal (ADR-0112 envelope) — today a + * `handler` naming a function the hook's own package does not hold + * ({@link HOOK_HANDLER_NOT_IN_PACKAGE_CODE}). + */ + errors: Array<{ hook: string; reason: string; code?: string; status?: number }>; +} + +/** + * The refusal of a hook whose `handler` names a function its own package does + * not hold, as the ADR-0112 envelope carries it. + * + * A hook's `handler` name resolves inside the hook's own package only: the + * functions handed to its bind (the package's `functions`, which its runtime + * module supplies) and the functions the same package registered on the + * engine. A name the package does not hold — a typo, or a function another + * package registered — is refused at registration and the hook is not bound. + * + * `INVALID_REFERENCE` is the standard catalog's member for a reference that + * does not resolve where it must; the condition is generic, so the ledger's + * admission rule sends it to the standard member rather than to a new code. + */ +export const HOOK_HANDLER_NOT_IN_PACKAGE_CODE = 'INVALID_REFERENCE'; +export const HOOK_HANDLER_NOT_IN_PACKAGE_STATUS = 400; + +type HookRegistrationRefusal = Error & { + code: string; + status: number; + hook: string; + handler: string; + packageId?: string; +}; + +function hookHandlerNotInPackageRefusal( + hookName: string, + fnName: string, + packageId: string | undefined, +): HookRegistrationRefusal { + const holder = packageId + ? `its own package ('${packageId}') holds no function of that name` + : 'this bind names no owning package and was handed no function of that name'; + const err = new Error( + `Hook '${hookName}' was not bound: its \`handler\` names '${fnName}', and ${holder}. ` + + "A hook's `handler` resolves only among the functions its own package declares — the package's " + + "`functions`, its runtime module's among them — and never reaches a function another package " + + 'registered. Give the hook a `body` (sandboxed JS), or declare the function in this package\'s own ' + + '`functions`; to reuse another package\'s function, import it from the package that owns it.', + ) as HookRegistrationRefusal; + err.code = HOOK_HANDLER_NOT_IN_PACKAGE_CODE; + err.status = HOOK_HANDLER_NOT_IN_PACKAGE_STATUS; + err.hook = hookName; + err.handler = fnName; + if (packageId) err.packageId = packageId; + return err; } /** @@ -175,11 +232,32 @@ export function bindHooksToEngine( const resolved = resolveHandler(engine, hook, opts); if (!resolved) { result.skipped += 1; + // A `handler` name the hook's own package does not hold is REFUSED at + // registration, as a coded refusal, whether the name exists nowhere or + // only in another package — the two are one condition from where the + // hook stands. Logged at `error`, beside the binder's other coded + // registration refusals, and fatal under `strict`. + if (!(hook as any).body && typeof hook.handler === 'string' && hook.handler.length > 0) { + const refusal = hookHandlerNotInPackageRefusal(hook.name, hook.handler, opts.packageId); + result.errors.push({ + hook: hook.name, + reason: refusal.message, + code: refusal.code, + status: refusal.status, + }); + if (opts.strict) throw refusal; + logger.error('[hook-binder] hook refused: its handler names no function of its own package', refusal, { + hook: hook.name, + handler: hook.handler, + packageId: opts.packageId, + code: refusal.code, + status: refusal.status, + }); + continue; + } const reason = (hook as any).body ? `hook body present but no bodyRunner supplied to bindHooksToEngine (runtime must wire QuickJSScriptRunner)` - : typeof hook.handler === 'string' - ? `unknown function '${hook.handler}'` - : 'no handler'; + : 'no handler'; result.errors.push({ hook: hook.name, reason }); if (opts.strict) { throw new Error(`[hook-binder] strict: cannot bind hook '${hook.name}': ${reason}`); @@ -313,15 +391,34 @@ function resolveHandler( const h = hook.handler; if (typeof h === 'function') return h as HookHandler; if (typeof h === 'string' && h.length > 0) { - // Try the per-bundle map first (hot path during initial bind), - // then fall back to whatever the engine already knows. A declaration + // A name resolves inside the hook's OWN package only. First the functions + // handed to this bind — the package's `functions`, which an artifact's + // runtime module supplies (hot path during initial bind). A declaration // record resolves to its handler — a hook cares only about the callable. const fromBundle = normalizeFlowFunctionEntry(opts.functions?.[h]); if (fromBundle) return fromBundle.handler as HookHandler; - if (typeof (engine as any).resolveFunction === 'function') { - const fn = (engine as any).resolveFunction(h); - if (typeof fn === 'function') return fn as HookHandler; - } + // Then a function the SAME package registered on the engine earlier. The + // registry is keyed by bare name, so the owner on the entry is what keeps + // a hook from binding to a function another package registered under the + // same name — that package's code would run on this package's events. + return ownPackageFunction(engine, h, opts.packageId); } return undefined; } + +/** + * The engine-registered function `name` when — and only when — its owner is + * `packageId`. A bind that states no owner holds only the functions handed to + * it: an entry with no owner, or with another owner, is not this package's. + */ +function ownPackageFunction( + engine: ObjectQL, + name: string, + packageId: string | undefined, +): HookHandler | undefined { + if (!packageId) return undefined; + if (typeof (engine as any).resolveFunctionEntry !== 'function') return undefined; + const entry = (engine as any).resolveFunctionEntry(name); + if (!entry || entry.packageId !== packageId) return undefined; + return typeof entry.handler === 'function' ? (entry.handler as HookHandler) : undefined; +} diff --git a/packages/runtime/src/hook-handler-package-scope.pin.test.ts b/packages/runtime/src/hook-handler-package-scope.pin.test.ts new file mode 100644 index 00000000000..72dc20328c7 --- /dev/null +++ b/packages/runtime/src/hook-handler-package-scope.pin.test.ts @@ -0,0 +1,260 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * A hook's `handler` name resolves inside the hook's own package only, end to + * end in a composed kernel, on the doors that bind a hook by name: + * + * ① a multi-app composition — app Y's hook names `x_stamp`, a function app X + * registered. It is refused at registration (`INVALID_REFERENCE`, 400) and + * X's code never runs on Y's events. This is the cross-package binding + * measured through the public door before the change: Y's insert came back + * stamped by X's function. + * ② the metadata door — a hook authored at runtime through + * `PUT /api/v1/meta/hook/:name` that names `x_stamp` is refused the same way + * when the door binds it. A runtime-authored hook ships with no code package + * and holds no functions. + * + * Controls, the two shapes a package's own function takes: app X's hook naming + * X's own `functions` entry binds and runs, and app Z's hook naming a function + * its `--artifact` runtime module exports (loaded through `loadArtifactBundle`, + * the artifact door's loader) binds and runs. A body hook authored through the + * metadata door binds and runs, which is also the witness that the door's + * re-sync has happened. + * + * Every observation is a neutral marker appended to a free-text `status` + * column. The refusal's code and status are read off the engine's own logger, + * where the binder records each coded registration refusal at `error`. + * + * Composition: the in-process kernel `@objectstack/verify`'s `bootStack` + * mirrors (engine, sqlite-wasm default datasource, HTTP server, the apps, + * platform objects, auth, security, sharing, REST, dispatcher), requests + * injected through the HTTP app as the signed-in administrator. The boot is + * paid in `beforeAll`, never inside a case. + */ + +import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest'; +import { mkdtempSync, writeFileSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { ObjectKernel } from '@objectstack/core'; +import { ObjectQL, ObjectQLPlugin } from '@objectstack/objectql'; +import { HonoServerPlugin } from '@objectstack/plugin-hono-server'; +import { createRestApiPlugin } from '@objectstack/rest'; +import { AuthPlugin } from '@objectstack/plugin-auth'; +import { SecurityPlugin, appSecurityPluginOptions } from '@objectstack/plugin-security'; +import { SharingServicePlugin } from '@objectstack/plugin-sharing'; +import { PlatformObjectsPlugin } from '@objectstack/platform-objects/plugin'; +import { AppPlugin } from './app-plugin.js'; +import { DefaultDatasourcePlugin } from './default-datasource-plugin.js'; +import { createDispatcherPlugin } from './dispatcher-plugin.js'; +import { loadArtifactBundle } from './load-artifact-bundle.js'; + +const BOOT_TIMEOUT = 180_000; +const ORIGIN = 'http://localhost:3000'; +const API = '/api/v1'; +const ADMIN = { email: 'admin@objectos.ai', password: 'admin123' }; + +const X_NOTE = 'scope_x_note'; +const Y_NOTE = 'scope_y_note'; +const Z_NOTE = 'scope_z_note'; + +const noteObject = (name: string) => ({ + name, + label: name, + fields: { + title: { type: 'text', label: 'Title' }, + status: { type: 'text', label: 'Status' }, + }, +}); + +/** Append `token` to the `status` of the row being written (a code handler's view of the input). */ +function appendStatus(ctx: any, token: string): void { + const d = ctx && ctx.input && ctx.input.data ? ctx.input.data : ctx.input; + d.status = (typeof d.status === 'string' ? d.status : '') + `|${token}`; +} + +const X_APP: any = { + manifest: { id: 'com.pin.scope.x', name: 'Scope X', version: '1.0.0' }, + objects: [noteObject(X_NOTE)], + functions: { x_stamp: async (ctx: any) => appendStatus(ctx, 'x-fn') }, + hooks: [{ name: 'scope_x_own', object: X_NOTE, events: ['beforeInsert'], handler: 'x_stamp' }], +}; + +const Y_APP: any = { + manifest: { id: 'com.pin.scope.y', name: 'Scope Y', version: '1.0.0' }, + objects: [noteObject(Y_NOTE)], + hooks: [{ name: 'scope_y_cross', object: Y_NOTE, events: ['beforeInsert'], handler: 'x_stamp' }], +}; + +/** App Z ships as an `--artifact`: its hook names a function only its runtime module carries. */ +const Z_ARTIFACT = { + manifest: { id: 'com.pin.scope.z', name: 'Scope Z', version: '1.0.0' }, + objects: [noteObject(Z_NOTE)], + hooks: [{ name: 'scope_z_own', object: Z_NOTE, events: ['beforeInsert'], handler: 'z_stamp' }], + runtimeModule: './runtime.mjs', +}; +const Z_RUNTIME_MODULE = + 'export const functions = {\n' + + ' z_stamp: async (ctx) => {\n' + + ' const d = ctx && ctx.input && ctx.input.data ? ctx.input.data : ctx.input;\n' + + " d.status = (typeof d.status === 'string' ? d.status : '') + '|z-fn';\n" + + ' },\n' + + '};\n'; + +const js = (source: string) => ({ language: 'js', source, capabilities: [], timeoutMs: 5000 }); + +/** The engine's logger: quiet, and read back for the binder's coded refusals. */ +const engineLogger: any = { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() }; + +let kernel: any; +let httpServer: any; +let app: any; +let adminToken: string; +let artifactDir: string; +let prevNodeEnv: string | undefined; +/** The metadata door's answer to saving the handler-named hook (printed, not asserted). */ +let recordedCrossHookSaveStatus: number | undefined; + +const req = (path: string, init?: RequestInit) => app.request(`${ORIGIN}${API}${path}`, init); +const asAdmin = (method: string, path: string, body?: unknown) => + req(path, { + method, + headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${adminToken}` }, + ...(body !== undefined ? { body: JSON.stringify(body) } : {}), + }); + +async function engine(): Promise { + return kernel.getServiceAsync('objectql'); +} + +/** Insert one row in-process and read back its `status` — what every hook that fired appended. */ +async function insertAndReadStatus(object: string, title: string): Promise { + const ql = await engine(); + await ql.insert(object, { title, status: '' }, { context: { isSystem: true } }); + const rows: any[] = await ql.find(object, { where: { title }, fields: ['id', 'status'], context: { isSystem: true } }); + return rows.map((r) => String(r?.status ?? '')).join(','); +} + +/** The binder's coded refusals of `hook`, as the engine logged them. */ +function refusalsOf(hook: string): any[][] { + return engineLogger.error.mock.calls.filter((call: any[]) => call[2]?.hook === hook); +} + +async function waitFor(predicate: () => Promise, ms = 15_000): Promise { + const until = Date.now() + ms; + while (Date.now() < until) { + if (await predicate()) return true; + await new Promise((r) => setTimeout(r, 100)); + } + return false; +} + +beforeAll(async () => { + prevNodeEnv = process.env.NODE_ENV; + process.env.NODE_ENV = 'development'; // the dev-admin seed, as `objectstack dev` / bootStack arm it + + artifactDir = mkdtempSync(join(tmpdir(), 'hook-scope-artifact-')); + const artifactPath = join(artifactDir, 'objectstack.json'); + writeFileSync(artifactPath, JSON.stringify(Z_ARTIFACT, null, 2), 'utf8'); + writeFileSync(join(artifactDir, 'runtime.mjs'), Z_RUNTIME_MODULE, 'utf8'); + const zBundle = await loadArtifactBundle(artifactPath); + if (!zBundle) throw new Error('pin setup: the Z artifact did not load'); + + kernel = new ObjectKernel(); + await kernel.use(new ObjectQLPlugin({ ql: new ObjectQL({ logger: engineLogger }) })); + await kernel.use(new DefaultDatasourcePlugin({ driver: 'sqlite-wasm', config: { filename: ':memory:' } })); + await kernel.use(new HonoServerPlugin({ port: 0 })); + await kernel.use(new AppPlugin(X_APP)); + await kernel.use(new AppPlugin(Y_APP)); + await kernel.use(new AppPlugin(zBundle)); + await kernel.use(new PlatformObjectsPlugin()); + await kernel.use(new AuthPlugin({ secret: 'hook-handler-package-scope-secret', autoDefaultOrganization: false })); + await kernel.use(new SecurityPlugin(appSecurityPluginOptions(X_APP))); + await kernel.use(new SharingServicePlugin()); + await kernel.use(createRestApiPlugin({})); + await kernel.use(createDispatcherPlugin({})); + await kernel.bootstrap(); + + httpServer = await kernel.getServiceAsync('http-server'); + app = httpServer.getRawApp(); + const res = await req('/auth/sign-in/email', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(ADMIN), + }); + if (!res.ok) throw new Error(`pin signIn failed: ${res.status}`); + adminToken = (await res.json()).token; +}, BOOT_TIMEOUT); + +afterAll(async () => { + console.info(`[hook handler package scope pin] metadata door save of a handler-named hook answered: ${recordedCrossHookSaveStatus}`); + try { await httpServer?.close?.(); } catch { /* best-effort */ } + try { await kernel?.shutdown?.(); } catch { /* best-effort */ } + try { rmSync(artifactDir, { recursive: true, force: true }); } catch { /* best-effort */ } + if (prevNodeEnv === undefined) delete process.env.NODE_ENV; + else process.env.NODE_ENV = prevNodeEnv; +}, 60_000); + +describe('a hook handler name resolves inside its own package only — composed kernel', () => { + it('① multi-app composition: app Y\'s hook naming app X\'s function is refused and X\'s code never runs on Y\'s events', async () => { + const status = await insertAndReadStatus(Y_NOTE, 'y-cross-probe'); + expect(status, "app X's function ran on app Y's event").not.toContain('x-fn'); + + const refusals = refusalsOf('scope_y_cross'); + expect(refusals.length, 'no coded refusal was recorded for the cross-package hook').toBeGreaterThan(0); + expect(refusals[0][1]).toBeInstanceOf(Error); + expect(refusals[0][2]).toMatchObject({ + code: 'INVALID_REFERENCE', + status: 400, + handler: 'x_stamp', + packageId: 'app:com.pin.scope.y', + }); + }); + + it('control: app X\'s hook naming X\'s own `functions` entry binds and runs', async () => { + expect(await insertAndReadStatus(X_NOTE, 'x-own-probe')).toContain('x-fn'); + expect(refusalsOf('scope_x_own')).toEqual([]); + }); + + it('control: app Z\'s hook naming a function its --artifact runtime module exports binds and runs', async () => { + expect(await insertAndReadStatus(Z_NOTE, 'z-own-probe')).toContain('z-fn'); + expect(refusalsOf('scope_z_own')).toEqual([]); + }); + + it('② the metadata door: a runtime-authored hook naming app X\'s function is refused when the door binds it', async () => { + const crossHook = await asAdmin('PUT', '/meta/hook/scope_authored_cross', { + name: 'scope_authored_cross', + object: Y_NOTE, + events: ['beforeInsert'], + handler: 'x_stamp', + }); + recordedCrossHookSaveStatus = crossHook.status; + const bodyHook = await asAdmin('PUT', '/meta/hook/scope_authored_body', { + name: 'scope_authored_body', + object: Y_NOTE, + events: ['beforeInsert'], + body: js("ctx.input.status = (typeof ctx.input.status === 'string' ? ctx.input.status : '') + '|authored-body';"), + }); + expect(bodyHook.status, await bodyHook.text()).toBeLessThan(300); + + // The door's re-sync has bound the authored body hook once it fires… + let lastStatus = ''; + let probe = 0; + const bound = await waitFor(async () => { + lastStatus = await insertAndReadStatus(Y_NOTE, `authored-probe-${probe++}`); + return lastStatus.includes('authored-body'); + }); + expect(bound, 'the runtime-authored body hook never bound').toBe(true); + + // …and by then the handler-named one, had it bound, would have run on the same insert. + expect(lastStatus, "a runtime-authored hook ran app X's function").not.toContain('x-fn'); + const refusals = refusalsOf('scope_authored_cross'); + expect(refusals.length, 'no coded refusal was recorded for the runtime-authored hook').toBeGreaterThan(0); + expect(refusals[0][2]).toMatchObject({ + code: 'INVALID_REFERENCE', + status: 400, + handler: 'x_stamp', + packageId: 'metadata-service', + }); + }, 30_000); +}); diff --git a/packages/spec/src/data/hook.zod.ts b/packages/spec/src/data/hook.zod.ts index 6cc51014eab..cf6acec5eaf 100644 --- a/packages/spec/src/data/hook.zod.ts +++ b/packages/spec/src/data/hook.zod.ts @@ -313,9 +313,18 @@ export const HookSchema = lazySchema(() => strictObject( * * - **Inline function** (authoring): `handler: async (ctx) => { ... }`. * Convenient in `defineStack({ hooks: [...] })` source files. - * - **String reference** (build artifact / Studio): `handler: 'my_fn'`. - * Resolved at runtime against the bundle's `functions` map + - * anything `engine.registerFunction(name, fn)` added. + * - **String reference** (build artifact): `handler: 'my_fn'`. + * Resolved at bind time inside the hook's OWN package only: the + * package's `functions` map (on the artifact path, its runtime module + * supplies it) and the functions that same package registered on the + * engine. A function another package registered is never reached by + * name. A name the package does not hold — a typo, or another + * package's function — is refused at registration + * (`INVALID_REFERENCE`, 400) and the hook is not bound. + * A hook authored at runtime through the metadata API ships with no + * code package and holds no functions: give it a `body`. To reuse + * another package's function, import it from the package that owns + * it and declare it in this package's own `functions`. * * `objectstack build` automatically lowers inline functions to the * string form (using `Hook.name` as the ref) and emits the originals diff --git a/scripts/error-status-unpinned-baseline.json b/scripts/error-status-unpinned-baseline.json index c48471ec847..c8a3900b943 100644 --- a/scripts/error-status-unpinned-baseline.json +++ b/scripts/error-status-unpinned-baseline.json @@ -12,7 +12,6 @@ "INTEGRATION_ERROR", "INVALID_CREDENTIALS", "INVALID_FORMAT", - "INVALID_REFERENCE", "INVALID_TOKEN", "IP_RESTRICTED", "LICENSE_REQUIRED",