diff --git a/.changeset/21322-hot-install-binds-boot-steps.md b/.changeset/21322-hot-install-binds-boot-steps.md new file mode 100644 index 00000000000..6119ed936c9 --- /dev/null +++ b/.changeset/21322-hot-install-binds-boot-steps.md @@ -0,0 +1,14 @@ +--- +"@objectstack/cloud-connection": patch +"@objectstack/plugin-security": patch +--- + +fix(cloud-connection,plugin-security): a package installed into a running runtime fires its record-change flows and has its permission sets in `sys_permission_set` right away, not after a restart + +Clause-②: no + +**Before**, `os package install ./dist/objectstack.json` into a running `os start` (the install-local route) registered the package, bound its script actions and body hooks, and stopped there. Two things the boot does for a package happen at `kernel:ready`, and that moment had already passed. The automation engine binds flows at `kernel:ready`, so the package's record-change flows never fired: a task updated to `done` wrote no note. The security plugin seeds declared permission sets at `kernel:ready`, so the package's set had no `sys_permission_set` row. `/meta/permission` listed the set, but an admin could not grant it. A restart fixed both, because the restart re-registers the package before those two steps run. Nothing in the CLI output or the install response said a restart was needed. + +**Now** the install route announces `metadata:reloaded` once the package is registered, bound, persisted and seeded. That is the same event a Studio package publish, a per-item publish and an artifact reload already announce. The automation engine already re-syncs its flows on it. The security plugin now re-runs its declared-permission seeding on it: the same function and organization passes as the boot, with the same provenance rules (`managed_by: 'package'`, `package_id`). Right after the install, the flow fires and the set's row exists, with the same state a restart gives. The seeding is idempotent and writes nothing when no permission set changed. It runs only after the boot's own pass has finished. A failed re-sync does not fail the install. It is logged at `warn` with the restart that repairs it. + +**Unchanged.** The restart path (the ledger rehydrate) announces nothing and behaves as before. The install response and the CLI output keep their fields and text. A package's `defineStack({ jobs })` are still not scheduled by install-local, on install or after a restart, because a job's handler is code from the artifact's runtime module and an inline install carries only the JSON. diff --git a/packages/cli/test/package-install-local-boot-steps.integration.test.ts b/packages/cli/test/package-install-local-boot-steps.integration.test.ts new file mode 100644 index 00000000000..20894a0cf57 --- /dev/null +++ b/packages/cli/test/package-install-local-boot-steps.integration.test.ts @@ -0,0 +1,368 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * #21322 — a hot install (`os package install ` into a RUNNING + * `os start`) leaves the runtime in the state a restart would: the installed + * package's record-change flow fires and its permission set is projected into + * `sys_permission_set`, both right after the install, with no restart. + * + * ## The defect, measured on the published train and again on `main` + * + * The install registered the package's metadata (`GET /meta/permission` named + * the set, `GET /meta/flow` named the flow) and nothing that the boot does + * AFTER registration at `kernel:ready`: + * + * - the record-change flow `task_completed_note` never fired — a task updated + * to `done` wrote no note; + * - `sys_permission_set` had no `tasks_app_task_user` row, so an admin could + * not grant the installed app's set to anyone; + * + * and both appeared after a restart on the same home, because the boot's own + * `kernel:ready` sweeps (the automation plugin's flow sync, the security + * plugin's declared-permission seeding) read the rehydrated package. Those + * sweeps run once per boot; nothing re-ran them for a package that arrived + * after it. + * + * ## What each `it` reads + * + * One fixture, three phases — after the hot INSTALL, after a RESTART on the + * same home (the ledger rehydrate), and the `--artifact` CONTROL on a fresh + * home — each probed through the doors a user uses: the data route for the + * permission-set row and for the flow's effect (a task updated to `done`, then + * the note it should have written). The restart and the control are the + * unchanged paths; they must read exactly what the install now reads. + * + * ## Spawn shape + * + * Shared with `package-install-local-handlers.integration.test.ts` (#21321): + * the tsx source entry, one process group per `os start`, every workspace + * package — `@objectstack/runtime` and `@objectstack/cloud-connection` + * included — resolved through its `exports` to `dist/`, so an ablation of + * either package's source reaches this file only after that package is rebuilt. + */ + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { spawn, type ChildProcess } from 'node:child_process'; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { + CLI, + childEnv, + E2E_SECRET_KEY, + portContentionError, + portDriftError, + probeThroughChild, + randomPort, + TSX, +} from './helpers/serve-process.js'; + +/** The banner's tail — every row above it has printed. */ +const READY = /Press Ctrl\+C to stop/; +const BOOT_TIMEOUT_MS = 180_000; + +const APP_ID = 'com.example.tasksapp'; +const TASK = 'tasks_app_task'; +const NOTE = 'tasks_app_note'; +const PERMISSION_SET = 'tasks_app_task_user'; +/** The development dev-admin seed — see the #21321 sibling for why it is the operator on every boot. */ +const EMAIL = 'admin@objectos.ai'; +const PASSWORD = 'admin123'; + +/** `dist/objectstack.json` as `os build` writes it for this app (schema defaults trimmed). */ +const ARTIFACT = { + manifest: { id: APP_ID, namespace: 'tasks_app', version: '0.1.0', type: 'app', name: 'Tasks App' }, + requires: ['automation', 'triggers'], + objects: [ + { + name: TASK, + label: 'Task', + sharingModel: 'public_read_write', + fields: { + name: { type: 'text', label: 'Name' }, + status: { type: 'text', label: 'Status' }, + }, + }, + { + name: NOTE, + label: 'Note', + sharingModel: 'public_read_write', + fields: { name: { type: 'text', label: 'Name' } }, + }, + ], + flows: [{ + name: 'task_completed_note', + label: 'Task Completed Note', + type: 'record_change', + status: 'active', + nodes: [ + { + id: 'start', + type: 'start', + label: 'On Task Update', + config: { objectName: TASK, triggerType: 'record-after-update', condition: "record.status == 'done'" }, + }, + { + id: 'note', + type: 'create_record', + label: 'Write Note', + config: { objectName: NOTE, fields: { name: 'Completed: {record.name}' } }, + }, + { id: 'end', type: 'end', label: 'End' }, + ], + edges: [ + { id: 'e1', source: 'start', target: 'note' }, + { id: 'e2', source: 'note', target: 'end' }, + ], + }], + permissions: [{ + name: PERMISSION_SET, + label: 'Tasks App Task User', + objects: { + [TASK]: { allowRead: true, allowCreate: true, allowEdit: true, allowDelete: true }, + [NOTE]: { allowRead: true, allowCreate: false, allowEdit: false, allowDelete: false }, + }, + }], +}; + +/** + * The RUNTIME the package is installed into. `os start` composes its services + * from the boot stack's `requires`, never from a package installed later, and + * the always-on slate carries neither the automation engine nor the triggers — + * so an EMPTY kernel runs no flow at all, before or after a restart. This host + * declares the two capabilities a flow needs and nothing else. + */ +const HOST_ARTIFACT = { + manifest: { id: 'com.example.host', namespace: 'host', version: '0.1.0', type: 'app', name: 'Host' }, + requires: ['automation', 'triggers'], +}; + +const groups: ChildProcess[] = []; +const dirs: string[] = []; + +interface LiveStart { + child: ChildProcess; + base: string; + output: () => string; +} + +function bootStart(cwd: string, home: string, port: string, extra: string[] = []): Promise { + return new Promise((resolveBoot, rejectBoot) => { + const child = spawn(TSX, [CLI, 'start', '-p', port, '--home', home, '--auth-secret', E2E_SECRET_KEY, '--no-ui', ...extra], { + cwd, + // `childEnv`, never a bare `...process.env` — see its header. + env: childEnv({ NO_COLOR: '1', OS_CLOUD_URL: 'off', OS_LOG_LEVEL: 'warn', OS_SECRET_KEY: E2E_SECRET_KEY }), + stdio: ['ignore', 'pipe', 'pipe'], + // Own process group: `os start` supervises a `serve` grandchild. + detached: true, + }); + groups.push(child); + let out = ''; + let settled = false; + const settle = (err: Error | null) => { + if (settled) return; + settled = true; + clearTimeout(timer); + if (err) rejectBoot(err); + else resolveBoot({ child, base: `http://localhost:${port}`, output: () => out }); + }; + const timer = setTimeout( + () => settle(new Error(`os start never printed ${READY}\n--- output ---\n${out.slice(-4000)}`)), + BOOT_TIMEOUT_MS, + ); + const onData = (d: unknown) => { + out += String(d); + // The child is the authority on the port it bound. + if (READY.test(out)) settle(portDriftError(out, 'os start', port)); + }; + child.stdout?.on('data', onData); + child.stderr?.on('data', onData); + child.on('exit', (code) => + settle(portContentionError(out, 'os start', port) + ?? new Error(`os start exited ${String(code)} before ${READY}\n--- output ---\n${out.slice(-4000)}`)), + ); + }); +} + +async function stopGroup(child: ChildProcess): Promise { + if (child.pid === undefined || child.exitCode !== null || child.signalCode !== null) return; + await new Promise((done) => { + const give = setTimeout(() => { + try { process.kill(-child.pid!, 'SIGKILL'); } catch { /* group already gone */ } + done(); + }, 15_000); + child.once('exit', () => { clearTimeout(give); done(); }); + try { process.kill(-child.pid!, 'SIGTERM'); } catch { clearTimeout(give); done(); } + }); +} + +interface Answer { status: number; body: any } + +/** One exchange against the running `os start`, attributed to the child if the transport fails. ⛔ No assertion inside it. */ +function http(live: LiveStart, method: string, path: string, token: string, body?: unknown): Promise { + return probeThroughChild( + { + child: live.child, + transcript: () => `\n--- child output ---\n${live.output().slice(-4000)}`, + label: 'package-install-local-boot-steps', + what: `${method} ${path}`, + }, + async () => { + const r = await fetch(`${live.base}${path}`, { + method, + headers: { + origin: live.base, + ...(body !== undefined ? { 'content-type': 'application/json' } : {}), + ...(token ? { authorization: `Bearer ${token}` } : {}), + }, + ...(body !== undefined ? { body: JSON.stringify(body) } : {}), + }); + const text = await r.text(); + let parsed: any = text; + try { parsed = JSON.parse(text); } catch { /* keep the text */ } + return { status: r.status, body: parsed }; + }, + ); +} + +async function authenticate(live: LiveStart): Promise { + const res = await http(live, 'POST', '/api/v1/auth/sign-in/email', '', { email: EMAIL, password: PASSWORD }); + const token = res.body?.token; + if (res.status !== 200 || typeof token !== 'string') { + throw new Error(`auth answered ${res.status}: ${JSON.stringify(res.body)}\n--- output ---\n${live.output().slice(-3000)}`); + } + return token; +} + +/** + * `os package install ./dist/objectstack.json` against the running runtime. + * ⛔ Asynchronous on purpose — see the #21321 sibling: a `spawnSync` stops this + * process draining the server's pipes for the whole install. + */ +function packageInstall(appDir: string, live: LiveStart): Promise<{ exit: number | null; output: string }> { + return new Promise((done) => { + const child = spawn(TSX, [CLI, 'package', 'install', './dist/objectstack.json', '--runtime', live.base, '--email', EMAIL, '--password', PASSWORD], { + cwd: appDir, + env: childEnv({ NO_COLOR: '1' }), + stdio: ['ignore', 'pipe', 'pipe'], + }); + let output = ''; + child.stdout?.on('data', (d) => { output += String(d); }); + child.stderr?.on('data', (d) => { output += String(d); }); + const timer = setTimeout(() => child.kill('SIGKILL'), 120_000); + child.on('close', (code) => { clearTimeout(timer); done({ exit: code, output }); }); + }); +} + +/** The rows of a `GET /api/v1/data/:object` list answer, whichever envelope it came in. */ +function rowsOf(answer: Answer): any[] { + const b = answer.body?.data ?? answer.body; + if (Array.isArray(b)) return b; + if (Array.isArray(b?.records)) return b.records; + if (Array.isArray(b?.items)) return b.items; + return []; +} + +const recordOf = (a: Answer) => a.body?.data ?? a.body?.record ?? a.body; + +interface Phase { + /** `GET /data/sys_permission_set?name=…` — the projection the admin surface grants from. */ + permissionSet: Answer; + /** The task the flow is driven through: created, then updated to `done`. */ + created: Answer; + updated: Answer; + /** `GET /data/tasks_app_note?name=Completed: …` — what the flow should have written. */ + notes: Answer; +} + +let seq = 0; +async function probe(live: LiveStart, token: string): Promise { + const permissionSet = await http(live, 'GET', `/api/v1/data/sys_permission_set?name=${PERMISSION_SET}`, token); + + const taskName = `flow-probe-${++seq}`; + const created = await http(live, 'POST', `/api/v1/data/${TASK}`, token, { name: taskName, status: 'open' }); + const id = recordOf(created)?.id; + const updated = await http(live, 'PATCH', `/api/v1/data/${TASK}/${id}`, token, { status: 'done' }); + const noteName = encodeURIComponent(`Completed: ${taskName}`); + // The record-change trigger dispatches after the update commits; read the + // note back for a bounded while rather than once, so a flow that fires a + // beat after the 200 is not misread as one that never fires. + let notes = await http(live, 'GET', `/api/v1/data/${NOTE}?name=${noteName}`, token); + for (let i = 0; i < 20 && rowsOf(notes).length === 0; i++) { + await new Promise((r) => setTimeout(r, 250)); + notes = await http(live, 'GET', `/api/v1/data/${NOTE}?name=${noteName}`, token); + } + return { permissionSet, created, updated, notes }; +} + +const phases: Record<'install' | 'restart' | 'control', Phase | undefined> = { + install: undefined, restart: undefined, control: undefined, +}; +const installs: Array<{ exit: number | null; output: string }> = []; + +beforeAll(async () => { + const root = mkdtempSync(join(tmpdir(), 'install-local-boot-steps-')); + dirs.push(root); + const appDir = join(root, 'app'); + mkdirSync(join(appDir, 'dist'), { recursive: true }); + writeFileSync(join(appDir, 'dist', 'objectstack.json'), JSON.stringify(ARTIFACT, null, 2), 'utf8'); + // The runtime boots the HOST artifact — never the package — so the package + // reaches it only through the install. + const runtimeDir = join(root, 'runtime'); + mkdirSync(runtimeDir, { recursive: true }); + const hostArtifact = join(runtimeDir, 'host.json'); + writeFileSync(hostArtifact, JSON.stringify(HOST_ARTIFACT, null, 2), 'utf8'); + const home = join(runtimeDir, 'home'); + const port = randomPort(); + + // ── boot 1: the host, hot install, probe ─────────────────────────────── + const first = await bootStart(runtimeDir, home, port, ['--artifact', hostArtifact]); + const token = await authenticate(first); + installs.push(await packageInstall(appDir, first)); + phases.install = await probe(first, token); + await stopGroup(first.child); + + // ── boot 2: same host, home and cwd — the ledger rehydrates on kernel:ready ── + const second = await bootStart(runtimeDir, home, port, ['--artifact', hostArtifact]); + phases.restart = await probe(second, await authenticate(second)); + await stopGroup(second.child); + + // ── boot 3: the CONTROL — the same file as the boot artifact ─────────── + const controlDir = join(root, 'control'); + mkdirSync(controlDir, { recursive: true }); + const third = await bootStart(controlDir, join(controlDir, 'home'), port, ['--artifact', join(appDir, 'dist', 'objectstack.json')]); + phases.control = await probe(third, await authenticate(third)); + await stopGroup(third.child); +}, 4 * BOOT_TIMEOUT_MS); + +afterAll(async () => { + for (const child of groups) await stopGroup(child); + for (const dir of dirs) rmSync(dir, { recursive: true, force: true }); +}, 60_000); + +describe('#21322: a hot install binds what the boot binds', () => { + it('`os package install` succeeds (harness health)', () => { + for (const run of installs) { + expect(run.exit, run.output).toBe(0); + expect(run.output).toMatch(/Package installed into the running kernel/); + } + }); + + for (const name of ['install', 'restart', 'control'] as const) { + describe(`after ${name}`, () => { + it('the package permission set is projected into sys_permission_set', () => { + const p = phases[name]!; + expect(p.permissionSet.status, JSON.stringify(p.permissionSet.body)).toBe(200); + expect(rowsOf(p.permissionSet).map((r) => r?.name)).toEqual([PERMISSION_SET]); + }); + + it('the record-change flow fires: a task updated to done writes its note', () => { + const p = phases[name]!; + expect(p.created.status, JSON.stringify(p.created.body)).toBe(201); + expect(p.updated.status, JSON.stringify(p.updated.body)).toBe(200); + expect(p.notes.status, JSON.stringify(p.notes.body)).toBe(200); + expect(rowsOf(p.notes), 'no note — the flow never fired').toHaveLength(1); + }); + }); + } +}); diff --git a/packages/cloud-connection/src/marketplace-install-local-hot-resync.test.ts b/packages/cloud-connection/src/marketplace-install-local-hot-resync.test.ts new file mode 100644 index 00000000000..c98c46f0f51 --- /dev/null +++ b/packages/cloud-connection/src/marketplace-install-local-hot-resync.test.ts @@ -0,0 +1,185 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * #21322 — a HOT install re-runs the boot's `kernel:ready` sweeps by announcing + * `metadata:reloaded`; the rehydrate does not. + * + * The defect: `os package install` into a running runtime registered the + * package, bound its handlers (#21321) and stopped. The two consumers that read + * a package only at `kernel:ready` — the automation engine's flow bind and the + * security plugin's declared-permission seeding — had already run, so the + * package's record-change flows never fired and its permission sets had no + * `sys_permission_set` row until a restart. A restart was right because its + * rehydrate registers the package INSIDE `kernel:ready`, ahead of both sweeps. + * + * What this file pins about the plugin's half (the subscribers' half is pinned + * in their own packages, and end to end in the CLI suite + * `package-install-local-boot-steps.integration.test.ts`): + * + * - install: exactly one `metadata:reloaded`, naming the app it registered, + * announced only once the package is registered AND persisted; + * - rehydrate: none — it runs ahead of the sweeps it would re-run; + * - a subscriber that throws does not fail the install, and the log names + * what is lost and the restart that repairs it; + * - a context that cannot announce says so instead of staying silent. + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { existsSync, mkdtempSync, rmSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +// The first load of the runtime's dist paid at module top, never inside a +// clocked `it` (`scripts/check-test-source-alias.mjs`): the plugin reaches the +// same module through a dynamic `import()` for its handler binder. +import '@objectstack/runtime'; +import { MarketplaceInstallLocalPlugin } from './marketplace-install-local-plugin.js'; +import { installerAuthService, withInstallerGrants } from './install-local-principal.fixtures.js'; +import { LocalManifestSource } from './local-manifest-source.js'; + +const APP_ID = 'com.example.tasksapp'; + +/** The install route's inline body: the compiled artifact `os package install` sends. */ +const ARTIFACT = { + manifest: { id: APP_ID, namespace: 'tasks_app', version: '0.1.0', type: 'app', name: 'Tasks App' }, + objects: [{ name: 'tasks_app_task', label: 'Task', fields: { name: { type: 'text', label: 'Name' } } }], +}; + +type Handler = (c: any) => Promise; + +function makeRawApp() { + const routes = new Map(); + return { + routes, + get: (p: string, h: Handler) => routes.set(`GET ${p}`, h), + post: (p: string, h: Handler) => routes.set(`POST ${p}`, h), + delete: (p: string, h: Handler) => routes.set(`DELETE ${p}`, h), + }; +} + +function makeC(body: any) { + const json = vi.fn((payload: any, status?: number) => ({ payload, status: status ?? 200 })); + return { + req: { + url: 'http://localhost:3000/api/v1/marketplace/install-local', + raw: new Request('http://localhost:3000/x'), + json: async () => body, + param: () => undefined, + }, + json, + }; +} + +let dir: string; +beforeEach(() => { dir = mkdtempSync(join(tmpdir(), 'mil-resync-')); }); +afterEach(() => { rmSync(dir, { recursive: true, force: true }); vi.restoreAllMocks(); }); + +/** + * Boot the plugin to `kernel:ready` over a kernel context whose `trigger` + * records every announce — and, at the moment of each one, what had already + * happened: whether the package was registered and whether its ledger entry + * was on disk. + */ +async function bootPlugin(opts: { trigger?: 'record' | 'throw' | 'absent' } = {}) { + const mode = opts.trigger ?? 'record'; + const register = vi.fn(); + const announced: Array<{ event: string; payload: unknown; registered: boolean; persisted: boolean }> = []; + const hooks = new Map(); + const logger = { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }; + const rawApp = makeRawApp(); + const services: Record = { + manifest: { register }, + auth: installerAuthService(), + objectql: withInstallerGrants({ syncSchemas: async () => undefined }), + }; + const ctx: any = { + hook: (e: string, h: any) => hooks.set(e, h), + getService: (name: string) => { + if (name === 'http-server') return { getRawApp: () => rawApp }; + const svc = services[name]; + if (svc === undefined) throw new Error(`no ${name}`); + return svc; + }, + logger, + }; + if (mode !== 'absent') { + ctx.trigger = async (event: string, payload: unknown) => { + announced.push({ + event, + payload, + // The PACKAGE's registration — `register` also receives the + // plugin's own Setup nav bundle at `kernel:ready`. + registered: register.mock.calls.some(([m]: any[]) => m?.id === APP_ID), + persisted: existsSync(join(dir, `${APP_ID}.json`)), + }); + if (mode === 'throw') throw new Error('subscriber exploded'); + }; + } + const plugin = new MarketplaceInstallLocalPlugin({ controlPlaneUrl: 'off', storageDir: dir }); + await plugin.start(ctx); + await hooks.get('kernel:ready')?.(); + const install = async () => rawApp.routes.get('POST /api/v1/marketplace/install-local')!(makeC({ manifest: ARTIFACT })); + const reloads = () => announced.filter((a) => a.event === 'metadata:reloaded'); + const warnings = () => logger.warn.mock.calls.map((c: any[]) => String(c[0])); + return { install, reloads, warnings, register }; +} + +describe('#21322: a hot install re-runs the boot sweeps through metadata:reloaded', () => { + it('install — announces metadata:reloaded exactly once, naming the app, after it is registered and persisted', async () => { + const { install, reloads } = await bootPlugin(); + expect(reloads(), 'precondition: an empty ledger rehydrates nothing and announces nothing').toEqual([]); + + const res = await install(); + + expect(res.payload?.success, JSON.stringify(res.payload)).toBe(true); + expect(reloads()).toEqual([ + { event: 'metadata:reloaded', payload: { changed: [`app/${APP_ID}`] }, registered: true, persisted: true }, + ]); + }); + + it('reinstall — announces again, so an upgraded package re-syncs too', async () => { + const { install, reloads } = await bootPlugin(); + await install(); + await install(); + expect(reloads()).toHaveLength(2); + }); + + it('rehydrate — announces nothing: it runs inside kernel:ready, ahead of the sweeps', async () => { + new LocalManifestSource(dir).write({ + packageId: APP_ID, + versionId: 'local', + manifestId: APP_ID, + version: '0.1.0', + manifest: { ...ARTIFACT.manifest, objects: ARTIFACT.objects }, + installedAt: '2026-01-01T00:00:00.000Z', + installedBy: 'admin', + withSampleData: false, + }); + const { reloads, register } = await bootPlugin(); + // `register` also receives the plugin's own Setup nav bundle at + // `kernel:ready`; the package's own registration is the one that matters. + expect( + register.mock.calls.filter(([m]: any[]) => m?.id === APP_ID), + 'precondition: the ledger entry was rehydrated', + ).toHaveLength(1); + expect(reloads()).toEqual([]); + }); + + it('a subscriber that throws does not fail the install, and the log names the restart', async () => { + const { install, reloads, warnings } = await bootPlugin({ trigger: 'throw' }); + const res = await install(); + expect(res.status).toBe(200); + expect(res.payload?.success).toBe(true); + expect(reloads()).toHaveLength(1); + const said = warnings().filter((w) => w.includes('metadata:reloaded re-sync FAILED')); + expect(said).toHaveLength(1); + expect(said[0]).toContain(APP_ID); + expect(said[0]).toContain('restarts'); + }); + + it('a context that cannot announce says so, and the install still lands', async () => { + const { install, warnings } = await bootPlugin({ trigger: 'absent' }); + const res = await install(); + expect(res.payload?.success).toBe(true); + expect(warnings().filter((w) => w.includes('cannot announce metadata:reloaded') && w.includes(APP_ID))).toHaveLength(1); + }); +}); diff --git a/packages/cloud-connection/src/marketplace-install-local-plugin.ts b/packages/cloud-connection/src/marketplace-install-local-plugin.ts index 939ed571751..87b3ae89c68 100644 --- a/packages/cloud-connection/src/marketplace-install-local-plugin.ts +++ b/packages/cloud-connection/src/marketplace-install-local-plugin.ts @@ -972,6 +972,12 @@ export class MarketplaceInstallLocalPlugin implements Plugin { } catch { /* non-fatal — entry already on disk */ } } + // 6. [#21322] Re-run the boot's `kernel:ready` sweeps for a package that + // arrived after them — LAST, after the seed, because that is where + // the boot runs them: a record-change flow bound before the seed + // would fire on every seeded row, which no boot does. + await this.announceHotInstall(ctx, manifestId); + return c.json({ success: true, data: { @@ -1441,6 +1447,54 @@ export class MarketplaceInstallLocalPlugin implements Plugin { bind(ql, manifest, { appId: manifestId, logger: ctx.logger, source: 'MarketplaceInstallLocal' }); }; + /** + * [#21322] Announce a HOT install to the running kernel as + * `metadata:reloaded` — the platform's one post-boot re-sync signal, which + * a Studio package publish, a per-item publish and an artifact reload + * already announce — so the consumers that read a package only at + * `kernel:ready` re-run that same read for this one: + * + * - `service-automation` re-syncs its flows from the protocol + * (`resyncFlowsFromProtocol`, the `kernel:ready` bind's own + * `registerFlow`), so the package's record-change flows fire; + * - `plugin-security` re-runs its declared-permission seeding (the + * `kernel:ready` pass's own `bootstrapDeclaredPermissions`), so the + * package's permission sets are projected into `sys_permission_set` + * and can be granted. + * + * Before this, a hot install left both until the next restart: the + * restart's rehydrate registers the package ahead of those sweeps, a hot + * install registers it after them, and nothing re-ran them. ⛔ Nothing here + * binds a flow or writes a permission-set row itself — that would be the + * install-only second path the boot never takes. The rehydrate does NOT + * call this: it runs inside `kernel:ready`, ahead of the very sweeps this + * re-runs, which is why a restart already reads correctly. + * + * `changed` names the app the install registered (`app/`, the + * type and key `registerApp` files it under). Never throws: the package is + * registered and persisted either way, so a failed re-sync is a FUNCTIONAL + * degradation — said once at `warn`, with the restart that repairs it, the + * level the publish door's announce failure uses. + */ + private announceHotInstall = async (ctx: PluginContext, manifestId: string): Promise => { + if (typeof ctx.trigger !== 'function') { + ctx.logger?.warn?.( + `[MarketplaceInstallLocal] this kernel context cannot announce metadata:reloaded — the record-change flows ` + + `and permission sets of ${manifestId} take effect only after a restart.`, + ); + return; + } + try { + await ctx.trigger('metadata:reloaded', { changed: [`app/${manifestId}`] }); + } catch (err: any) { + ctx.logger?.warn?.( + `[MarketplaceInstallLocal] ${manifestId} is installed, but the metadata:reloaded re-sync FAILED — its ` + + 'record-change flows may not fire and its permission sets may be missing from sys_permission_set ' + + `until the runtime restarts (the restart re-reads every installed package). Cause: ${err?.message ?? err}`, + ); + } + }; + /** * Replicate the start-time side-effects that AppPlugin runs for * statically-declared apps but the `manifest` service does NOT: diff --git a/packages/plugins/plugin-security/src/declared-permission-reload-projection.test.ts b/packages/plugins/plugin-security/src/declared-permission-reload-projection.test.ts new file mode 100644 index 00000000000..613591ac390 --- /dev/null +++ b/packages/plugins/plugin-security/src/declared-permission-reload-projection.test.ts @@ -0,0 +1,157 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * #21322 — a permission set registered AFTER the boot is projected into + * `sys_permission_set` when the kernel announces `metadata:reloaded`. + * + * ## The defect these pins are written against + * + * The declared-permission seeding (`bootstrapDeclaredPermissions`, ADR-0086 + * D5) ran once, inside the `kernel:ready` bootstrap, over whatever the engine + * registry held at that moment. A package registered later — `os package + * install` into a running runtime, whose install-local plugin registers the + * package from an HTTP request — was never projected: the evaluator resolved + * its set (`/meta/permission` listed it) while `sys_permission_set` had no + * row, so no admin could grant it until a restart re-ran the boot pass. + * + * The fix re-runs the SAME seeding on `metadata:reloaded`, the post-boot + * re-sync signal every runtime door announces. These pins drive the real + * `SecurityPlugin` through its own hooks — the wiring is the fix — against an + * engine double whose registry is mutable, so "registered after the boot" is + * a real ordering and not a fixture flag. The CLI suite + * `package-install-local-boot-steps.integration.test.ts` pins the same + * outcome end to end through `os package install`. + */ + +import { describe, it, expect, vi } from 'vitest'; +import { assertEngineFindOnePredicate, assertEngineUpdateDispatch, type EngineFindOneQueryInput } from '@objectstack/metadata-core'; +import { SecurityPlugin } from './security-plugin.js'; + +const PACKAGE_ID = 'com.example.tasksapp'; +const PACKAGE_SET = { + name: 'tasks_app_task_user', + label: 'Tasks App Task User', + objects: { tasks_app_task: { allowRead: true, allowCreate: true, allowEdit: true, allowDelete: true } }, + // The registry's provenance stamp (ADR-0010) — what `registerApp` leaves on + // every item a package contributes, and what the seeder reads as the owner. + _packageId: PACKAGE_ID, +}; + +/** + * `where` as the seeder spells it: field equality plus `{ name: { $in: [...] } }`. + * Anything else is REFUSED rather than answered — a combinator read as a field + * name matches no row and would report that as "no rows". + */ +function rowMatches(row: any, where: Record = {}): boolean { + return Object.entries(where).every(([k, v]) => { + if (k.startsWith('$')) { + throw new Error(`this double implements field predicates only; it cannot answer '${k}'`); + } + const actual = row?.[k] ?? null; + if (v !== null && typeof v === 'object' && !Array.isArray(v)) { + const ops = Object.keys(v as Record); + if (ops.length === 1 && ops[0] === '$in') { + return ((v as any).$in as unknown[]).some((m) => (m ?? null) === actual); + } + throw new Error(`this double implements equality and $in only; it cannot answer ${JSON.stringify(ops)}`); + } + return actual === (v ?? null); + }); +} + +/** Boot the real plugin over an in-memory engine whose `permission` registry the test grows. */ +async function boot() { + const tables: Record = {}; + const declaredPermissions: any[] = []; + const ql: any = { + registerMiddleware: () => {}, + getSchema: () => undefined, + registry: { + listItems: (type: string) => (type === 'permission' ? [...declaredPermissions] : []), + }, + find: async (object: string, opts?: any) => { + const matched = (tables[object] ?? []).filter((r) => rowMatches(r, opts?.where ?? {})); + // The caller's bound, applied BY PRESENCE and after the filter + // (`check:objectql-double-limit`). + return (typeof opts?.limit === 'number' ? matched.slice(0, opts.limit) : matched).map((r) => ({ ...r })); + }, + findOne: async (object: string, query?: EngineFindOneQueryInput) => { + // The producer's own predicate, never a hand-mirrored guard. + assertEngineFindOnePredicate(object, query); + return null; + }, + count: async () => 0, + insert: async (object: string, data: any) => { + const row = { id: data?.id ?? `${object}_${(tables[object] ?? []).length + 1}`, ...data }; + (tables[object] ??= []).push(row); + return { ...row }; + }, + update: async (object: string, data: any, options?: any) => { + assertEngineUpdateDispatch(data, options); + const target = (tables[object] ?? []).find((r) => r.id === (data?.id ?? options?.where?.id)); + if (target) Object.assign(target, data); + return target ? 1 : 0; + }, + }; + const services: Record = { + manifest: { register: vi.fn() }, + objectql: ql, + metadata: { get: async () => null, list: async () => [] }, + }; + const hook = vi.fn(); + const ctx: any = { + logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }, + registerService: vi.fn(), + getService: (name: string) => { + if (!(name in services)) throw new Error(`service not registered: ${name}`); + return services[name]; + }, + hook, + }; + const plugin = new SecurityPlugin(); + await plugin.init(ctx); + await plugin.start(ctx); + const fire = async (event: string) => { + for (const [, cb] of hook.mock.calls.filter((c: any[]) => c[0] === event)) await cb({ changed: [`app/${PACKAGE_ID}`] }); + }; + const packageRows = () => (tables.sys_permission_set ?? []).filter((r) => r.name === PACKAGE_SET.name); + return { ctx, fire, packageRows, declaredPermissions }; +} + +describe('#21322: declared permission sets registered after the boot are projected on metadata:reloaded', () => { + it('a set registered after kernel:ready gets its sys_permission_set row on the reload, with package provenance', async () => { + const { ctx, fire, packageRows, declaredPermissions } = await boot(); + await fire('kernel:ready'); + // Positive control: the boot pass really completed — without it, every + // assertion below would be satisfied by a boot that fell over early. + expect( + ctx.logger.info.mock.calls.filter((c: any[]) => String(c[0]).includes('platform bootstrap complete')), + ).toHaveLength(1); + expect(packageRows(), 'the package was not registered yet — the boot pass had nothing to project').toHaveLength(0); + + declaredPermissions.push(PACKAGE_SET); // the hot install registers the package + await fire('metadata:reloaded'); + + expect(packageRows()).toHaveLength(1); + expect(packageRows()[0]).toMatchObject({ managed_by: 'package', package_id: PACKAGE_ID }); + }); + + it('is idempotent: a second reload over the same declaration adds no row', async () => { + const { fire, packageRows, declaredPermissions } = await boot(); + await fire('kernel:ready'); + declaredPermissions.push(PACKAGE_SET); + await fire('metadata:reloaded'); + await fire('metadata:reloaded'); + expect(packageRows()).toHaveLength(1); + }); + + it('does nothing before the boot pass has run — the platform defaults are seeded first, in their own shape', async () => { + const { fire, packageRows, declaredPermissions } = await boot(); + declaredPermissions.push(PACKAGE_SET); + await fire('metadata:reloaded'); + expect(packageRows()).toHaveLength(0); + // …and the boot pass that follows still projects it, as it always did. + await fire('kernel:ready'); + expect(packageRows()).toHaveLength(1); + }); +}); diff --git a/packages/plugins/plugin-security/src/security-plugin.ts b/packages/plugins/plugin-security/src/security-plugin.ts index a8296eb4092..77b834a9c93 100644 --- a/packages/plugins/plugin-security/src/security-plugin.ts +++ b/packages/plugins/plugin-security/src/security-plugin.ts @@ -4482,6 +4482,45 @@ export class SecurityPlugin implements Plugin { void runBootstrap(); } + // ── Project the permission sets of a package that arrives AFTER the boot ── + // + // [#21322, ADR-0086 D5 — a package's sets are seeded ON INSTALL] The + // declared-permission seeding above runs once, at + // `kernel:ready`, over whatever the engine registry holds by then. A package + // registered later — `os package install` into a running runtime (the + // install-local plugin), an artifact reload — was never projected: its sets + // resolved for the evaluator (`/meta/permission` listed them) while + // `sys_permission_set` had no row, so no admin could grant them until a + // restart re-ran this pass. Every such door announces `metadata:reloaded`, + // the platform's one post-boot re-sync signal (the automation engine + // re-binds flows off the same event), so re-run the SAME step here: the + // same function, the same organization passes, the same provenance rules. + // Idempotent and upgrade-aware by construction (it re-seeds only rows it + // owns and never clobbers env-authored ones), so a reload that changed no + // permission set writes nothing. + // + // Only once the boot's own pass has run: it is what lets + // `bootstrapPlatformAdmin` write the platform defaults first, in their + // insert-once shape, and a reload cannot arrive ahead of it on a real + // kernel anyway. Never throws — `trigger` dispatch PROPAGATES, and a + // subscriber failure must not fail the install or publish that announced. + if (typeof (ctx as any).hook === 'function') { + (ctx as any).hook('metadata:reloaded', async () => { + if (!bootstrapRanOnce) return; + try { + for (const organizationId of await catalogSeedPasses()) { + await seedCatalogPermissions(organizationId); + } + } catch (e) { + ctx.logger.warn( + '[security] declared permission sets were NOT re-projected after a metadata reload — a package ' + + 'registered after boot has no sys_permission_set row until the next restart', + { error: (e as Error).message }, + ); + } + }); + } + // ── Re-run the seed-ownership CLAIM when the seed actually settles ──────── // // The claim used to run exactly once per database lifetime, inside the one diff --git a/scripts/engine-double-contract.pinned.json b/scripts/engine-double-contract.pinned.json index ebc6f6b26cc..4d773ca99f8 100644 --- a/scripts/engine-double-contract.pinned.json +++ b/scripts/engine-double-contract.pinned.json @@ -2951,6 +2951,16 @@ "verb": "findOne", "pinned": 1 }, + { + "file": "packages/plugins/plugin-security/src/declared-permission-reload-projection.test.ts", + "verb": "findOne", + "pinned": 1 + }, + { + "file": "packages/plugins/plugin-security/src/declared-permission-reload-projection.test.ts", + "verb": "update", + "pinned": 1 + }, { "file": "packages/plugins/plugin-security/src/engine-find-bare-array.pin.test.ts", "verb": "findOne",