|
| 1 | +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. |
| 2 | + |
| 3 | +/** |
| 4 | + * #21321 — an installed package's script-action bodies and body hooks are |
| 5 | + * BOUND, on the install route and on the `kernel:ready` rehydrate, through the |
| 6 | + * same runtime function `AppPlugin.start` uses for a boot artifact. |
| 7 | + * |
| 8 | + * The defect: `os package install <artifact>` registered the package's metadata |
| 9 | + * and nothing executable. Every door refused the installed `type: 'script'` |
| 10 | + * action (REST 404, MCP `run_action` "No handler registered") before and after |
| 11 | + * a restart, and its body hooks never fired — while `os start --artifact` of the |
| 12 | + * same file dispatched normally, because `AppPlugin.start` was the only binder. |
| 13 | + * |
| 14 | + * What this file pins, against the engine-level outcome rather than a call: |
| 15 | + * |
| 16 | + * - install: the action handler is registered under `app:<manifestId>` and |
| 17 | + * the body hook is handed to `bindHooks` under the same owner; |
| 18 | + * - rehydrate: a ledger entry written by an earlier process binds the same |
| 19 | + * way when a fresh plugin reaches `kernel:ready`; |
| 20 | + * - reinstall: still exactly one handler per action and one binding per hook; |
| 21 | + * - reinstall of a version that DROPPED its action and its hook: neither |
| 22 | + * stays bound — the owner's previous set is torn down first. |
| 23 | + * |
| 24 | + * The binder is the REAL `@objectstack/runtime` export (resolved through its |
| 25 | + * `exports`, i.e. its built `dist/`, like the plugin's own lazy import); the |
| 26 | + * engine is a recording double that models only what the binder touches — |
| 27 | + * including `bindHooksToEngine`'s own rule of unregistering a package's hooks |
| 28 | + * only when handed a NON-empty list, which is what the dropped-hook case reads. |
| 29 | + */ |
| 30 | + |
| 31 | +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; |
| 32 | +import { mkdtempSync, rmSync } from 'node:fs'; |
| 33 | +import { join } from 'node:path'; |
| 34 | +import { tmpdir } from 'node:os'; |
| 35 | +// The first load of the runtime's dist paid at module top, never inside a |
| 36 | +// clocked `it` (the clocked-window rule, `scripts/check-test-source-alias.mjs`): |
| 37 | +// the plugin reaches the same module through a dynamic `import()`. |
| 38 | +import '@objectstack/runtime'; |
| 39 | +import { MarketplaceInstallLocalPlugin } from './marketplace-install-local-plugin.js'; |
| 40 | +import { installerAuthService, withInstallerGrants } from './install-local-principal.fixtures.js'; |
| 41 | +import { LocalManifestSource } from './local-manifest-source.js'; |
| 42 | + |
| 43 | +const APP_ID = 'com.example.tasksapp'; |
| 44 | +const OWNER = `app:${APP_ID}`; |
| 45 | + |
| 46 | +const ACTION = { |
| 47 | + name: 'complete_task', |
| 48 | + label: 'Complete Task', |
| 49 | + objectName: 'tasks_app_task', |
| 50 | + type: 'script', |
| 51 | + body: { language: 'js', capabilities: ['api.write'], source: 'return { ok: true };' }, |
| 52 | + ai: { exposed: true, description: 'Mark a task as complete.' }, |
| 53 | +}; |
| 54 | +const HOOK = { |
| 55 | + name: 'tasks_app_stamp_status', |
| 56 | + object: 'tasks_app_task', |
| 57 | + events: ['beforeInsert'], |
| 58 | + body: { language: 'js', source: "ctx.input.status = 'stamped';" }, |
| 59 | +}; |
| 60 | + |
| 61 | +/** The compiled-artifact shape `os build` writes and `os package install` sends. */ |
| 62 | +function artifact(version: string, opts: { withHandlers: boolean }) { |
| 63 | + return { |
| 64 | + manifest: { id: APP_ID, namespace: 'tasks_app', version, type: 'app', name: 'Tasks App' }, |
| 65 | + objects: [{ |
| 66 | + name: 'tasks_app_task', |
| 67 | + label: 'Task', |
| 68 | + fields: { name: { type: 'text', label: 'Name' } }, |
| 69 | + ...(opts.withHandlers ? { actions: [ACTION] } : {}), |
| 70 | + }], |
| 71 | + ...(opts.withHandlers ? { actions: [ACTION], hooks: [HOOK] } : {}), |
| 72 | + }; |
| 73 | +} |
| 74 | + |
| 75 | +/** The install route's normalization of a compiled bundle, as it lands in the ledger. */ |
| 76 | +function flattened(bundle: ReturnType<typeof artifact>) { |
| 77 | + const { manifest: meta, ...sections } = bundle; |
| 78 | + return { ...meta, ...sections }; |
| 79 | +} |
| 80 | + |
| 81 | +/** |
| 82 | + * The engine surface the binder writes to, as state: an action Map keyed |
| 83 | + * `<object>:<name>` (the engine's own key) and one hook list. |
| 84 | + */ |
| 85 | +function recordingEngine() { |
| 86 | + const actions = new Map<string, { handler: unknown; package?: string }>(); |
| 87 | + let hooks: Array<{ name: string; packageId?: string }> = []; |
| 88 | + const unregisterHooksByPackage = (packageId: string): number => { |
| 89 | + const before = hooks.length; |
| 90 | + hooks = hooks.filter((h) => h.packageId !== packageId); |
| 91 | + return before - hooks.length; |
| 92 | + }; |
| 93 | + return { |
| 94 | + actions, |
| 95 | + hooksFor: (packageId: string) => hooks.filter((h) => h.packageId === packageId), |
| 96 | + engine: { |
| 97 | + syncSchemas: async () => undefined, |
| 98 | + registerAction: (object: string, name: string, handler: unknown, pkg?: string) => { |
| 99 | + actions.set(`${object}:${name}`, { handler, package: pkg }); |
| 100 | + }, |
| 101 | + removeActionsByPackage: (pkg: string) => { |
| 102 | + for (const [key, entry] of actions) if (entry.package === pkg) actions.delete(key); |
| 103 | + }, |
| 104 | + listRegisteredActions: () => |
| 105 | + [...actions].map(([key, entry]) => ({ |
| 106 | + objectName: key.slice(0, key.indexOf(':')), |
| 107 | + actionName: key.slice(key.indexOf(':') + 1), |
| 108 | + ...(entry.package ? { package: entry.package } : {}), |
| 109 | + })), |
| 110 | + unregisterHooksByPackage, |
| 111 | + // `bindHooksToEngine`'s own teardown fires only for a NON-empty list. |
| 112 | + bindHooks: (list: Array<{ name: string }> | undefined, opts?: { packageId?: string; bodyRunner?: unknown }) => { |
| 113 | + if (!Array.isArray(list) || list.length === 0) return; |
| 114 | + if (opts?.packageId) unregisterHooksByPackage(opts.packageId); |
| 115 | + expect(typeof opts?.bodyRunner, 'a body hook needs the sandbox runner').toBe('function'); |
| 116 | + for (const h of list) hooks.push({ name: h.name, packageId: opts?.packageId }); |
| 117 | + }, |
| 118 | + }, |
| 119 | + }; |
| 120 | +} |
| 121 | + |
| 122 | +type Handler = (c: any) => Promise<any>; |
| 123 | + |
| 124 | +function makeRawApp() { |
| 125 | + const routes = new Map<string, Handler>(); |
| 126 | + return { |
| 127 | + routes, |
| 128 | + get: (p: string, h: Handler) => routes.set(`GET ${p}`, h), |
| 129 | + post: (p: string, h: Handler) => routes.set(`POST ${p}`, h), |
| 130 | + delete: (p: string, h: Handler) => routes.set(`DELETE ${p}`, h), |
| 131 | + }; |
| 132 | +} |
| 133 | + |
| 134 | +function makeCtx(rawApp: any, services: Record<string, any>) { |
| 135 | + const hooks = new Map<string, any>(); |
| 136 | + const logger = { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }; |
| 137 | + return { |
| 138 | + logger, |
| 139 | + ctx: { |
| 140 | + hook: (e: string, h: any) => hooks.set(e, h), |
| 141 | + getService: (name: string) => { |
| 142 | + if (name === 'http-server') return { getRawApp: () => rawApp }; |
| 143 | + const svc = services[name]; |
| 144 | + if (svc === undefined) throw new Error(`no ${name}`); |
| 145 | + return svc; |
| 146 | + }, |
| 147 | + logger, |
| 148 | + }, |
| 149 | + fire: async () => { await hooks.get('kernel:ready')?.(); }, |
| 150 | + }; |
| 151 | +} |
| 152 | + |
| 153 | +function makeC(body: any) { |
| 154 | + const json = vi.fn((payload: any, status?: number) => ({ payload, status: status ?? 200 })); |
| 155 | + return { |
| 156 | + req: { |
| 157 | + url: 'http://localhost:3000/api/v1/marketplace/install-local', |
| 158 | + raw: new Request('http://localhost:3000/x'), |
| 159 | + json: async () => body, |
| 160 | + param: () => undefined, |
| 161 | + }, |
| 162 | + json, |
| 163 | + }; |
| 164 | +} |
| 165 | + |
| 166 | +let dir: string; |
| 167 | +beforeEach(() => { dir = mkdtempSync(join(tmpdir(), 'mil-handlers-')); }); |
| 168 | +afterEach(() => { rmSync(dir, { recursive: true, force: true }); vi.restoreAllMocks(); }); |
| 169 | + |
| 170 | +async function bootPlugin(engine: Record<string, unknown>) { |
| 171 | + const rawApp = makeRawApp(); |
| 172 | + const { ctx, fire, logger } = makeCtx(rawApp, { |
| 173 | + manifest: { register: vi.fn() }, |
| 174 | + auth: installerAuthService(), |
| 175 | + objectql: withInstallerGrants(engine), |
| 176 | + }); |
| 177 | + const plugin = new MarketplaceInstallLocalPlugin({ controlPlaneUrl: 'off', storageDir: dir }); |
| 178 | + await plugin.start(ctx as any); |
| 179 | + await fire(); |
| 180 | + const install = async (body: unknown) => { |
| 181 | + const res = await rawApp.routes.get('POST /api/v1/marketplace/install-local')!(makeC({ manifest: body })); |
| 182 | + expect(res.payload?.success, JSON.stringify(res.payload)).toBe(true); |
| 183 | + return res; |
| 184 | + }; |
| 185 | + return { install, logger }; |
| 186 | +} |
| 187 | + |
| 188 | +describe('#21321: install-local binds an installed package’s handlers', () => { |
| 189 | + it('install — the script action is registered and the body hook is bound, both under app:<manifestId>', async () => { |
| 190 | + const rec = recordingEngine(); |
| 191 | + const { install } = await bootPlugin(rec.engine); |
| 192 | + |
| 193 | + await install(artifact('0.1.0', { withHandlers: true })); |
| 194 | + |
| 195 | + const entry = rec.actions.get('tasks_app_task:complete_task'); |
| 196 | + expect(entry, 'the installed script action has no handler — every door refuses it').toBeDefined(); |
| 197 | + expect(entry!.package).toBe(OWNER); |
| 198 | + expect(typeof entry!.handler).toBe('function'); |
| 199 | + expect(rec.hooksFor(OWNER).map((h) => h.name)).toEqual(['tasks_app_stamp_status']); |
| 200 | + }); |
| 201 | + |
| 202 | + it('rehydrate — a ledger entry from an earlier process is bound at kernel:ready', async () => { |
| 203 | + new LocalManifestSource(dir).write({ |
| 204 | + packageId: APP_ID, |
| 205 | + versionId: 'local', |
| 206 | + manifestId: APP_ID, |
| 207 | + version: '0.1.0', |
| 208 | + // What the install route persists: the compiled bundle, flattened |
| 209 | + // (its `manifest` meta lifted to the top level beside the sections). |
| 210 | + manifest: flattened(artifact('0.1.0', { withHandlers: true })), |
| 211 | + installedAt: '2026-01-01T00:00:00.000Z', |
| 212 | + installedBy: 'admin', |
| 213 | + withSampleData: false, |
| 214 | + }); |
| 215 | + const rec = recordingEngine(); |
| 216 | + await bootPlugin(rec.engine); |
| 217 | + |
| 218 | + expect(rec.actions.get('tasks_app_task:complete_task')?.package, 'a restart leaves the installed action unbound').toBe(OWNER); |
| 219 | + expect(rec.hooksFor(OWNER).map((h) => h.name)).toEqual(['tasks_app_stamp_status']); |
| 220 | + }); |
| 221 | + |
| 222 | + it('reinstall — still exactly one handler per action and one binding per hook', async () => { |
| 223 | + const rec = recordingEngine(); |
| 224 | + const { install } = await bootPlugin(rec.engine); |
| 225 | + |
| 226 | + await install(artifact('0.1.0', { withHandlers: true })); |
| 227 | + await install(artifact('0.1.0', { withHandlers: true })); |
| 228 | + |
| 229 | + const owned = rec.engine.listRegisteredActions().filter((r) => r.package === OWNER); |
| 230 | + expect(owned).toEqual([{ objectName: 'tasks_app_task', actionName: 'complete_task', package: OWNER }]); |
| 231 | + expect(rec.hooksFor(OWNER)).toHaveLength(1); |
| 232 | + }); |
| 233 | + |
| 234 | + it('reinstall of a version that dropped its action and hook — neither stays bound', async () => { |
| 235 | + const rec = recordingEngine(); |
| 236 | + const { install } = await bootPlugin(rec.engine); |
| 237 | + |
| 238 | + await install(artifact('0.1.0', { withHandlers: true })); |
| 239 | + expect(rec.actions.size, 'precondition: the first version bound its action').toBe(1); |
| 240 | + |
| 241 | + await install(artifact('0.2.0', { withHandlers: false })); |
| 242 | + |
| 243 | + expect(rec.engine.listRegisteredActions().filter((r) => r.package === OWNER)).toEqual([]); |
| 244 | + expect(rec.hooksFor(OWNER), 'a hook the new version dropped must stop firing').toEqual([]); |
| 245 | + }); |
| 246 | +}); |
0 commit comments