From bb5a77734667b30109679b97b9370cf3f6d3f824 Mon Sep 17 00:00:00 2001 From: Dillon <260170482+dillonlille@users.noreply.github.com> Date: Sun, 13 Sep 2026 02:12:35 +0000 Subject: [PATCH 1/3] Copy optional plugin code only when a DSP installs it --- RELEASES.md | 8 ++ core/accounts/tests/plugin-fixture.js | 4 +- core/plugins/package-catalog.js | 4 +- core/updates/README.md | 21 +++- core/updates/tests/runtime-package.test.js | 79 +++++++++++++++ .../tests/selective-delivery-fixture.js | 97 +++++++++++++++++++ core/updates/tests/selective-delivery.test.js | 87 +++++++++++++++++ host/plugins/README.md | 14 +++ host/plugins/distribution.js | 9 +- host/plugins/tests/distribution.test.js | 20 ++++ host/releases/dsp.js | 4 +- host/releases/provisioning.js | 2 +- host/releases/runtime-package.js | 40 ++++++++ host/releases/runtime.js | 12 ++- 14 files changed, 385 insertions(+), 16 deletions(-) create mode 100644 core/updates/tests/runtime-package.test.js create mode 100644 core/updates/tests/selective-delivery-fixture.js create mode 100644 core/updates/tests/selective-delivery.test.js create mode 100644 host/releases/runtime-package.js diff --git a/RELEASES.md b/RELEASES.md index e159cf5..9ea0c31 100644 --- a/RELEASES.md +++ b/RELEASES.md @@ -29,6 +29,14 @@ selected artifact, updates one DSP at a time and pauses on failure. A completed rollout sets the default version for new DSPs; a Dev candidate never becomes that default. DSPs created during a rollout join its remaining queue. +Each DSP release still includes runtime code and all catalog plugin packages. +Core caches the full verified release, then copies only runtime code and catalog +metadata into each DSP. Optional plugin code is copied on Install and upgraded +only for DSPs where that plugin is installed (enabled or disabled). Later installs +use the plugin version approved by the DSP's selected release. Fresh DSPs expose +only the built-in Cortex connection. Existing full runtime copies are retained +for compatibility and rollback; new release copies omit plugin payloads. + Updates require a verified initial split deployment, a permanent Dev DSP and the separate update worker. The first published `0.0.1` Core predates these controls; it cannot install this feature by itself. See [update operations](core/updates/README.md) diff --git a/core/accounts/tests/plugin-fixture.js b/core/accounts/tests/plugin-fixture.js index a072b95..941031f 100644 --- a/core/accounts/tests/plugin-fixture.js +++ b/core/accounts/tests/plugin-fixture.js @@ -5,7 +5,7 @@ const path = require('node:path'); const { AccessStore, AccessControlService } = require('../src'); const { createPluginService } = require('../src/plugins'); const { success } = require('../../../shared/contracts/src/result'); -async function fixture(t, { installationCoordinator = null, settingsPort = null } = {}) { +async function fixture(t, { installationCoordinator = null, settingsPort = null, dspCount = 2 } = {}) { const root = fs.mkdtempSync(path.join(os.tmpdir(), 'dispatch-plugins-')); fs.chmodSync(root, 0o700); const paths = { databaseRoot: path.join(root, 'access'), database: path.join(root, 'access/access.sqlite3') }; @@ -17,7 +17,7 @@ async function fixture(t, { installationCoordinator = null, settingsPort = null const platform = await access.acceptNewUser({ token: bootstrap.token, firstName: 'Platform', lastName: 'Owner', password, confirmPassword: password }); const dsps = []; const runtimes = new Map(); - for (let index = 0; index < 2; index++) { + for (let index = 0; index < dspCount; index++) { const created = access.createOrganization(platform.session, { idempotencyKey: `fixture:plugin:${index}`, name: `Plugin DSP ${index}`, abbreviation: `FX${index}`, stationCode: 'TST1', timezone: 'America/Chicago', ownerEmail: `owner${index}@example.test` }); const owner = await access.acceptNewUser({ token: created.token, firstName: 'DSP', lastName: 'Owner', password, confirmPassword: password }); diff --git a/core/plugins/package-catalog.js b/core/plugins/package-catalog.js index 1f30ddf..dac0a31 100644 --- a/core/plugins/package-catalog.js +++ b/core/plugins/package-catalog.js @@ -39,7 +39,9 @@ function packageCatalog({ local }) { if (!raw) return null; const value=normalizeCatalog(raw),entries=new Map(value.items.map(item=>[`${item.pluginId}@${item.version}`,item])); const latest = (id,runtimeKey) => { - const version = (runtimeKey && value.approved.dsps[runtimeKey]?.[id]) || value.approved.production[id]; + const approved = runtimeKey && Object.hasOwn(value.approved.dsps, runtimeKey) + ? value.approved.dsps[runtimeKey] : value.approved.production; + const version = approved[id]; return version ? entries.get(`${id}@${version}`) : null; }; const resolve = (id,version) => { diff --git a/core/updates/README.md b/core/updates/README.md index 0e92300..a1c464d 100644 --- a/core/updates/README.md +++ b/core/updates/README.md @@ -15,7 +15,7 @@ release download. Commands never accept an arbitrary repository or executable. | `core/updates/commands.js`, `worker.js` | Persistent owner commands, one external worker and periodic release discovery. | | `core/updates/directory.js`, `transport.js` | Private worker-to-API socket and DSP activation under the directory controller. | | `host/releases/core.js`, `core-state.js` | Stop Core services, snapshot Core state, swap code, verify API/database health and recover. | -| `host/releases/dsp.js`, `runtime.js` | Drain one DSP, snapshot its state, select its runtime/plugins and restore that DSP on failure. | +| `host/releases/dsp.js`, `runtime.js`, `runtime-package.js` | Drain one DSP, snapshot its state, copy and verify runtime files, select its installed plugins and restore that DSP on failure. | | `host/releases/provisioning.js` | Give new DSPs the last completely rolled-out release. | | `host/releases/setup.js`, `bin/dispatch-updates` | Configure permanent Dev, register an existing split baseline, prepare the independent worker and queue offline recovery. | | `dashboard/frontend/src/pages/Updates.tsx` | Owner controls, changelogs, progress and recovery UI. | @@ -24,7 +24,9 @@ release download. Commands never accept an arbitrary repository or executable. | `local/state/dsp-releases/` | Each DSP's selected release receipt. | | `local/backups/updates/` | Private Core and per-DSP rollback snapshots. | | `local/tools/update-worker/` | Retained bootstrap code that survives a Core directory swap. | -| `dsps//runtime/releases/` | Independently installed DSP runtime releases. | +| `dsps//runtime/releases/` | DSP-owned runtime and catalog metadata; newly prepared copies omit optional plugin payloads. | +| `local/packages/plugins/` | Shared verified plugin package cache, used for installation and release approval. | +| `dsps//plugins//versions/` | That DSP's installed plugin code and retained rollback versions. | The worker checks both feeds when idle, about every five minutes. **Check for updates** refreshes the selected product. Every install/start-rollout command @@ -33,6 +35,21 @@ An active rollout continues using its pinned digest when a newer release appears Release downloads only stage verified files. They do not select code or start services. Shared dependencies remain versioned copies inside each DSP release. +DSP source and optional plugin source continue to ship in one DSP release. The +host keeps that full release, but each DSP receives only the runtime and catalog +metadata. The original release manifest/digest authenticates the exact copied +subset. Install copies a sealed plugin into only the requesting DSP. Rollout +updates enabled and disabled installed plugins to the selected release versions; +uninstalled plugins are not copied. New DSPs have only the built-in Cortex +connection until plugins are installed, and use the last completed fleet release. + +Existing full runtime copies remain readable for rollback. Installing Core does +not rewrite those copies. New DSP creation and preparation of a new DSP release +use the smaller layout. Previously installed or historical release code remains +retained for recovery; this change does not delete credentials, settings, business +data or rollback packages. Runtime-only copies are verified by the host's +`verifyRuntime`; `verifyRelease` still requires the complete publication archive. + ## Initial deployment and configuration These steps belong to a separately reviewed installation procedure. They are not diff --git a/core/updates/tests/runtime-package.test.js b/core/updates/tests/runtime-package.test.js new file mode 100644 index 0000000..c15eaf8 --- /dev/null +++ b/core/updates/tests/runtime-package.test.js @@ -0,0 +1,79 @@ +'use strict'; +const test = require('node:test'), assert = require('node:assert/strict'); +const fs = require('node:fs'), os = require('node:os'), path = require('node:path'); +const { hash, inventory, secureCopy, verifyRelease } = require('../../../shared/releases/package'); +const { prepareDspRelease, selectDspRelease, runtimeSource } = require('../../../host/releases/runtime'); +const { verifyRuntime } = require('../../../host/releases/runtime-package'); +const ID = 'dsp_' + 'a'.repeat(32); + +function fixture(t) { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'dispatch-runtime-package-')); + t.after(() => fs.rmSync(root, { recursive: true, force: true })); + const paths = { local: path.join(root, 'local'), dsps: path.join(root, 'dsps') }; + const source = path.join(root, 'release'); + for (const [name, bytes] of Object.entries({ + 'code/runtime/supervisor.js': 'runtime', 'code/compatibility/cortex.js': 'built-in connection', + 'code/plugins/paycom/dispatch-plugin.json': '{}', 'code/node_modules/dispatch-sdk/index.js': 'dependency', + 'plugins/paycom/backend.js': 'optional Paycom code', 'plugins/paycom/frontend.js': 'optional Paycom frontend', + 'plugins/sample/backend.js': 'another optional plugin', 'release-notes.md': 'notes', + })) { + fs.mkdirSync(path.dirname(path.join(source, name)), { recursive: true, mode: 0o700 }); + fs.writeFileSync(path.join(source, name), bytes, { mode: 0o600 }); + } + const manifest = { schemaVersion: 1, product: 'dsp', version: '1.0.0', channel: 'release', protocol: 1, + minimumProtocol: 1, sourceDigest: 'a'.repeat(64), plugins: [], files: inventory(source) }; + fs.writeFileSync(path.join(source, 'release.json'), JSON.stringify(manifest), { mode: 0o600 }); + const digest = hash(JSON.stringify(manifest)); + const prepare = () => prepareDspRelease(paths, ID, source, digest); + return { root, paths, source, manifest, digest, prepare }; +} + +test('DSP runtime copies omit every optional plugin payload and retain the original authenticated manifest', t => { + const f = fixture(t), installed = f.prepare(); + assert.equal(fs.existsSync(path.join(installed.directory, 'plugins')), false); + assert.equal(fs.existsSync(path.join(installed.directory, 'code/compatibility/cortex.js')), true); + assert.equal(fs.existsSync(path.join(installed.directory, 'code/plugins/paycom/dispatch-plugin.json')), true); + assert.equal(fs.existsSync(path.join(f.paths.dsps, ID, 'plugins/paycom')), false); + assert.deepEqual(verifyRuntime(installed.directory, f.digest), f.manifest); + assert.deepEqual(verifyRelease(f.source, f.digest), f.manifest); + assert.equal(fs.readFileSync(path.join(installed.directory, 'release.json'), 'utf8'), fs.readFileSync(path.join(f.source, 'release.json'), 'utf8')); + assert.notEqual(fs.statSync(path.join(installed.directory, 'code/runtime/supervisor.js')).ino, fs.statSync(path.join(f.source, 'code/runtime/supervisor.js')).ino); + selectDspRelease(f.paths, ID, f.digest, null); + assert.equal(runtimeSource(f.paths, ID), path.join(installed.directory, 'code')); + assert.deepEqual(f.prepare(), installed); +}); + +for (const change of ['runtime bytes', 'missing runtime', 'extra file', 'partial plugin', 'symlink', 'manifest']) { + test(`runtime verification rejects ${change}`, t => { + const f = fixture(t), installed = f.prepare(), runtime = path.join(installed.directory, 'code/runtime/supervisor.js'); + selectDspRelease(f.paths, ID, f.digest, null); + if (change === 'runtime bytes') fs.writeFileSync(runtime, 'changed'); + if (change === 'missing runtime') fs.unlinkSync(runtime); + if (change === 'extra file') fs.writeFileSync(path.join(installed.directory, 'code/extra.js'), 'extra'); + if (change === 'partial plugin') { + fs.mkdirSync(path.join(installed.directory, 'plugins/paycom'), { recursive: true }); + fs.copyFileSync(path.join(f.source, 'plugins/paycom/backend.js'), path.join(installed.directory, 'plugins/paycom/backend.js')); + } + if (change === 'symlink') { fs.unlinkSync(runtime); fs.symlinkSync(path.join(f.source, 'code/runtime/supervisor.js'), runtime); } + if (change === 'manifest') fs.writeFileSync(path.join(installed.directory, 'release.json'), JSON.stringify({ ...f.manifest, version: '2.0.0' })); + assert.throws(() => runtimeSource(f.paths, ID), /release_(digest_mismatch|entry_invalid|manifest_invalid)/); + }); +} + +test('invalid plugin bytes in the downloaded release are rejected even though they will not be copied to the DSP', t => { + const f = fixture(t); + fs.writeFileSync(path.join(f.source, 'plugins/paycom/backend.js'), 'tampered'); + assert.throws(f.prepare, /release_digest_mismatch/); + assert.equal(fs.existsSync(path.join(f.paths.dsps, ID, 'runtime/releases', f.digest)), false); +}); + +test('legacy full DSP releases remain verified and selectable for rollback', t => { + const f = fixture(t), target = path.join(f.paths.dsps, ID, 'runtime/releases', f.digest); + fs.mkdirSync(path.dirname(target), { recursive: true, mode: 0o700 }); + secureCopy(f.source, target); + selectDspRelease(f.paths, ID, f.digest, null); + assert.equal(runtimeSource(f.paths, ID), path.join(target, 'code')); + assert.equal(f.prepare().directory, target); + fs.writeFileSync(path.join(target, 'plugins/paycom/backend.js'), 'tampered'); + assert.throws(() => runtimeSource(f.paths, ID), /release_digest_mismatch/); +}); diff --git a/core/updates/tests/selective-delivery-fixture.js b/core/updates/tests/selective-delivery-fixture.js new file mode 100644 index 0000000..7d8157d --- /dev/null +++ b/core/updates/tests/selective-delivery-fixture.js @@ -0,0 +1,97 @@ +'use strict'; +const fs = require('node:fs'), path = require('node:path'); +const { fixture: accounts } = require('../../accounts/tests/plugin-fixture'); +const { privateDirectory, withLock } = require('../../../host/controller/operations'); +const { ensureDsp } = require('../../../host/storage/storage'); +const { atomic } = require('../../installations/src/release-delivery-files'); +const { sealPackage } = require('../../../tooling/build-plugin-package'); +const { hash, inventory } = require('../../../shared/releases/package'); +const { packageCatalog } = require('../../plugins/package-catalog'); +const { LocalReleases } = require('../local-releases'); +const { dspHooks } = require('../../../host/releases/dsp'); +const { withCreation } = require('../../../host/releases/provisioning'); +const { fileFor } = require('../../../host/releases/runtime'); +const { createDirectoryInstallation } = require('../../../host/plugins/directory-lifecycle'); +const { createPluginService } = require('../../accounts/src/plugins'); + +async function fixture(t) { + const f = await accounts(t, { dspCount: 4 }); + const paths = { platformRoot: f.root }; + for (const name of ['local', 'live', 'dsps', 'dev', 'worktrees']) paths[name] = privateDirectory(path.join(f.root, name)); + const records = new Map(), roots = new Map(), events = []; + let failedDigest = null; + for (const dsp of f.dsps) { + const creationId = 'create_' + dsp.runtimeKey.slice(4); + roots.set(dsp.runtimeKey, ensureDsp(paths, dsp.runtimeKey, creationId).root); + records.set(dsp.runtimeKey, { id: dsp.runtimeKey, creationId, latestRequest: 'a'.repeat(64), desiredState: 'running' }); + f.store.db.prepare('INSERT INTO plugin_migration_checks(organization_id) VALUES(?)').run(dsp.id); + } + const manager = { + journal: { record: id => records.get(id), saveRecord: value => records.set(value.id, value) }, + checkedDsp: record => ({ root: roots.get(record.id) }), credentials: () => {}, bridge: async () => {}, + ready: async id => { if (JSON.parse(fs.readFileSync(fileFor(paths, id))).digest === failedDigest) throw new Error('release_health_failed'); }, + apply: async () => {}, + host: { stop: async id => events.push(['stop', id]), prepare: async () => {}, start: async id => events.push(['start', id]) }, + pluginBackend: { request: async (id, action, input) => { + events.push([action, id, input.version]); + if (action === 'plugin.initialize') return true; + if (action !== 'plugin.revoke') throw new Error('unexpected_backend_request'); + } }, + }; + const execution = { eligible: () => false, locked: (_id, work) => work() }; + const hooks = dspHooks({ paths, store: f.store, manager, execution }); + const releases = new LocalReleases({ directory: path.join(paths.local, 'state/updates'), devDspId: f.dsps[0].runtimeKey, hooks, allowDevelopment: true }); + const coordinator = createDirectoryInstallation({ paths, store: f.store, manager, execution }); + const plugins = createPluginService({ store: f.store, access: f.access, installationCoordinator: coordinator, invoke: async () => { throw new Error('unexpected_runtime_request'); } }); + const definitions = () => packageCatalog(paths)?.definitions() || []; + require('../../../shared/plugin-sdk/catalog').configureCatalog(definitions); + require('dispatch-protocol/plugin-sdk/catalog').configureCatalog(definitions); + t.after(() => { + const defaults = () => [require('../../../tests/fixtures/paycom-plugin.json')]; + require('../../../shared/plugin-sdk/catalog').configureCatalog(defaults); + require('dispatch-protocol/plugin-sdk/catalog').configureCatalog(defaults); + }); + function artifact(product, version, pluginVersion = null) { + const directory = privateDirectory(path.join(paths.dev, product + '-' + version)); + privateDirectory(path.join(directory, 'code/runtime')); + fs.writeFileSync(path.join(directory, 'code/runtime/index.js'), `module.exports='${version}';`, { mode: 0o600 }); + const packaged = []; + if (pluginVersion) for (const id of ['paycom', 'sample']) { + const root = privateDirectory(path.join(directory, 'plugins', id)); + privateDirectory(path.join(root, 'backend')); privateDirectory(path.join(root, 'migrations')); + const definition = { ...require('../../../tests/fixtures/paycom-plugin.json'), id, name: id, version: pluginVersion, + frontend: null, dashboard: null, published: null, runtime: 'backend/index.js', + pages: [], actions: [], httpPrefixes: [], gatewayActions: [], services: id === 'paycom' ? ['paycom'] : [], + collectors: [], syncs: [], jobs: [], legacyProfile: null, + package: { runtime: 'backend/index.js', authentication: null, collections: 'migrations/collections.json' } }; + delete definition.settings; + atomic(path.join(root, 'dispatch-plugin.json'), definition); + fs.writeFileSync(path.join(root, 'backend/index.js'), `module.exports='${id}@${pluginVersion}';`, { mode: 0o600 }); + atomic(path.join(root, 'migrations/collections.json'), { schemaVersion: 1, collectors: [], sources: [], plans: [], syncs: [] }); + packaged.push({ pluginId: id, version: pluginVersion, digest: sealPackage(root).digest }); + privateDirectory(path.join(directory, 'code/plugins', id)); + atomic(path.join(directory, 'code/plugins', id, 'dispatch-plugin.json'), definition); + } + const manifest = { schemaVersion: 1, product, version, channel: 'development', protocol: 1, minimumProtocol: 1, + sourceDigest: 'a'.repeat(64), plugins: packaged, files: inventory(directory) }; + atomic(path.join(directory, 'release.json'), manifest); + return { directory, manifest, digest: hash(JSON.stringify(manifest)) }; + } + const core = artifact('core', '1.0.0'), before = artifact('dsp', '1.0.0', '1.0.0'), next = artifact('dsp', '1.1.0', '1.1.0'); + for (const item of [core, before, next]) await releases.stage(item.directory, item.digest); + const state = releases.state(); state.active.core = core.digest; state.defaultDsp = before.digest; releases.save(state); + privateDirectory(path.join(paths.local, 'config')); + atomic(path.join(paths.local, 'config/updates.json'), { schemaVersion: 1, devDspId: f.dsps[0].runtimeKey, apiPort: 4999 }); + const provision = dsp => withCreation(paths, 'create', assign => withLock(paths, fd => assign(dsp.runtimeKey, fd))); + for (const dsp of f.dsps) await provision(dsp); + const change = async (dsp, action) => { + const item = plugins.list(dsp.owner).items.find(item => item.id === 'paycom'); + plugins.change(dsp.owner, 'paycom', { action, expectedRevision: item.revision, idempotencyKey: `fixture:${action}:${item.revision}` }); + await plugins.runPending(); + const current = plugins.list(dsp.owner).items.find(item => item.id === 'paycom'); + if (current.failureCode || current.pending) throw new Error(JSON.stringify(current)); + return current; + }; + return { ...f, paths, roots, releases, before, next, artifact, plugins, change, provision, events, fail: digest => { failedDigest = digest; } }; +} +module.exports = { fixture }; diff --git a/core/updates/tests/selective-delivery.test.js b/core/updates/tests/selective-delivery.test.js new file mode 100644 index 0000000..8d967b4 --- /dev/null +++ b/core/updates/tests/selective-delivery.test.js @@ -0,0 +1,87 @@ +'use strict'; +const test = require('node:test'), assert = require('node:assert/strict'); +const fs = require('node:fs'), path = require('node:path'); +const { fixture } = require('./selective-delivery-fixture'); +const { installationReceipt } = require('../../../host/plugins/install'); +const { packageCatalog } = require('../../plugins/package-catalog'); +const { runtimeSource } = require('../../../host/releases/runtime'); +const pluginFile = (f, dsp, version) => path.join(f.roots.get(dsp.runtimeKey), 'plugins/paycom/versions', version, 'backend/index.js'); + +test('provisioning and delayed installs keep optional plugin bytes out of DSPs until each owner installs', async t => { + const f = await fixture(t), [dev, a, b] = f.dsps; + for (const dsp of f.dsps) { + assert.equal(fs.existsSync(path.join(path.dirname(runtimeSource(f.paths, dsp.runtimeKey)), 'plugins')), false); + assert.equal(fs.existsSync(path.join(f.roots.get(dsp.runtimeKey), 'plugins/paycom')), false); + assert.equal(fs.existsSync(path.join(f.roots.get(dsp.runtimeKey), 'plugins/sample')), false); + assert.equal(f.plugins.list(dsp.owner).items.find(item => item.id === 'paycom').state, 'uninstalled'); + } + await f.change(dev, 'install'); + await f.releases.updateDev(f.next.digest); + assert.equal(installationReceipt(f.roots.get(dev.runtimeKey), 'paycom').version, '1.1.0'); + assert.equal(fs.existsSync(pluginFile(f, a, '1.1.0')), false); + await f.provision(b); // Even a provisioning retry while Dev is newer keeps the fleet baseline. + assert.equal(packageCatalog(f.paths).latest('paycom', b.runtimeKey).version, '1.0.0'); + await f.change(a, 'install'); + assert.match(fs.readFileSync(pluginFile(f, a, '1.0.0'), 'utf8'), /paycom@1.0.0/); + assert.equal(fs.existsSync(pluginFile(f, b, '1.0.0')), false); + assert.notEqual(fs.statSync(pluginFile(f, a, '1.0.0')).ino, fs.statSync(path.join(f.paths.local, 'packages/plugins/paycom/1.0.0/backend/index.js')).ino); +}); + +test('DSP rollout upgrades enabled and disabled installed plugins one DSP at a time and skips uninstalled plugins', async t => { + const f = await fixture(t), [dev, a, b, c] = f.dsps; + for (const dsp of [dev, a, b]) await f.change(dsp, 'install'); + await f.change(b, 'disable'); + for (const dsp of f.dsps) for (const root of ['config', 'data', 'secrets']) { + fs.writeFileSync(path.join(f.roots.get(dsp.runtimeKey), root, 'sentinel'), `${dsp.runtimeKey}:${root}`, { mode: 0o600 }); + } + await f.releases.updateDev(f.next.digest); + assert.equal(installationReceipt(f.roots.get(a.runtimeKey), 'paycom').version, '1.0.0'); + await f.releases.beginRollout(f.next.digest, f.dsps.map(dsp => dsp.runtimeKey)); + const targets = f.releases.state().rollout.targets; + for (const id of targets) { + const before = f.releases.state().active.dsps; + await f.releases.step(); + const after = f.releases.state().active.dsps; + assert.equal(after[id], f.next.digest); + for (const other of f.dsps.map(dsp => dsp.runtimeKey).filter(key => key !== id)) assert.equal(after[other], before[other]); + } + for (const dsp of [dev, a, b]) assert.equal(installationReceipt(f.roots.get(dsp.runtimeKey), 'paycom').version, '1.1.0'); + assert.equal(installationReceipt(f.roots.get(b.runtimeKey), 'paycom').state, 'disabled'); + assert.equal(fs.existsSync(path.join(f.roots.get(c.runtimeKey), 'plugins/paycom')), false); + for (const dsp of f.dsps) { + assert.equal(fs.existsSync(path.join(f.roots.get(dsp.runtimeKey), 'plugins/sample')), false); + for (const root of ['config', 'data', 'secrets']) assert.equal(fs.readFileSync(path.join(f.roots.get(dsp.runtimeKey), root, 'sentinel'), 'utf8'), `${dsp.runtimeKey}:${root}`); + } + await f.change(c, 'install'); + assert.equal(installationReceipt(f.roots.get(c.runtimeKey), 'paycom').version, '1.1.0'); + assert.equal(fs.existsSync(pluginFile(f, c, '1.0.0')), false); +}); + +test('failed plugin update restores the installed version, DSP release approvals and private state', async t => { + const f = await fixture(t), [dev, other] = f.dsps; + await f.change(dev, 'install'); + const sentinel = path.join(f.roots.get(dev.runtimeKey), 'data/sentinel'); + fs.writeFileSync(sentinel, 'private before', { mode: 0o600 }); + f.fail(f.next.digest); + await assert.rejects(f.releases.updateDev(f.next.digest), /release_health_failed/); + assert.equal(installationReceipt(f.roots.get(dev.runtimeKey), 'paycom').version, '1.0.0'); + assert.equal(packageCatalog(f.paths).latest('paycom', dev.runtimeKey).version, '1.0.0'); + assert.equal(f.releases.state().active.dsps[dev.runtimeKey], f.before.digest); + assert.equal(fs.readFileSync(sentinel, 'utf8'), 'private before'); + assert.equal(fs.existsSync(pluginFile(f, dev, '1.1.0')), false); + assert.equal(fs.existsSync(pluginFile(f, other, '1.0.0')), false); + assert.equal(f.releases.state().tested, null); +}); + +test('a release cannot drop an installed plugin; an uninstalled plugin receives no later code', async t => { + const f = await fixture(t), [dev] = f.dsps; + await f.change(dev, 'install'); + const empty = f.artifact('dsp', '1.2.0'); + await f.releases.stage(empty.directory, empty.digest); + await assert.rejects(f.releases.updateDev(empty.digest), /release_installed_plugin_missing/); + await f.change(dev, 'uninstall'); + await f.releases.updateDev(empty.digest); + assert.equal(packageCatalog(f.paths).latest('paycom', dev.runtimeKey), null); + assert.equal(fs.existsSync(pluginFile(f, dev, '1.1.0')), false); + assert.equal(fs.existsSync(pluginFile(f, dev, '1.0.0')), true, 'previously installed code is retained for recovery'); +}); diff --git a/host/plugins/README.md b/host/plugins/README.md index 1272572..9974719 100644 --- a/host/plugins/README.md +++ b/host/plugins/README.md @@ -1,5 +1,12 @@ # Installed plugin packages +DSP releases contain the runtime, catalog metadata and separate sealed plugin +packages. Core retains the complete verified release and plugin cache. Preparing +a DSP copies only the runtime and metadata into its runtime release directory; +optional plugin backends, frontends and dependencies are not copied there. +New DSPs expose the built-in Cortex connection and start with no optional plugins +installed. Catalog metadata does not enable a plugin or create its credentials. + `install.js` verifies approved package digests and copies actual files into each DSP's `plugins//versions//` directory. It rejects links, unexpected files, path traversal, incompatible SDK versions and mutated version contents. @@ -13,6 +20,13 @@ permits Core acknowledgement. Interrupted acknowledgement resumes without repeating completed initialization. Initialization must itself use the supplied operation id to recover a crash before its completion receipt is written. +The DSP's selected release approves the exact plugin versions available to it. +Update Dev and sequential rollout update only that DSP's installed plugins, +including disabled installations, while preserving their enabled/disabled state. +Plugins not installed remain absent; a later Install uses that DSP release's +approved version. An explicit per-DSP catalog never inherits additional plugins +from the legacy global catalog, even when the per-DSP catalog is empty. + Disable and uninstall drain work and change activation state. They retain credentials, data, profiles and package bytes needed for recovery. Reclaiming old versions and code/state rollback remain separate migration tasks. diff --git a/host/plugins/distribution.js b/host/plugins/distribution.js index db6dadd..d4919e2 100644 --- a/host/plugins/distribution.js +++ b/host/plugins/distribution.js @@ -42,14 +42,17 @@ async function distributePackage(paths, { directory, digest }, { lockFd } = {}) // Internal lifecycle port. The release coordinator calls this only for the // selected DSP after validating its release. No HTTP-supplied paths are accepted. async function approvePackages(paths, { runtimeKey, packages }, { lockFd } = {}) { - if (!/^[a-zA-Z0-9_-]{1,128}$/.test(runtimeKey) || !Array.isArray(packages) || !packages.length) throw new Error('plugin_approval_invalid'); + if (!/^[a-zA-Z0-9_-]{1,128}$/.test(runtimeKey) || !Array.isArray(packages)) throw new Error('plugin_approval_invalid'); const work = async () => { const file = path.join(paths.local, 'config/plugin-packages.json'); - const catalog = normalizeCatalog(privateJson(file, process.geteuid())); + privateDirectory(path.dirname(file)); + const catalog = normalizeCatalog(privateJson(file, process.geteuid(), true) + || { schemaVersion: 2, items: [], approved: { production: {}, dsps: {} } }); const verified = packageCatalog(paths); const selected = {}; for (const item of packages) { - const found = verified.resolve(item.pluginId, item.version); + const found = verified?.resolve(item.pluginId, item.version); + if (!found) throw new Error('plugin_package_unavailable'); if (found.digest !== item.digest || Object.hasOwn(selected,item.pluginId)) throw new Error('plugin_approval_invalid'); selected[item.pluginId] = item.version; } diff --git a/host/plugins/tests/distribution.test.js b/host/plugins/tests/distribution.test.js index c36a2fd..66121af 100644 --- a/host/plugins/tests/distribution.test.js +++ b/host/plugins/tests/distribution.test.js @@ -59,3 +59,23 @@ test('legacy approval is preserved before staging a new candidate', () => { old.items.push({pluginId:'paycom',version:'0.18.0',digest:'b'.repeat(64)}); assert.equal(normalizeCatalog(old).approved.production.paycom,'0.17.2'); }); + +test('an explicit DSP release catalog never falls back to global plugins, including an empty release', async t => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'dispatch-plugin-approvals-')); + t.after(() => fs.rmSync(root, { recursive: true, force: true })); + const paths = { local: privateDirectory(path.join(root, 'local')) }; + await approvePackages(paths, { runtimeKey: 'empty', packages: [] }); + assert.equal(packageCatalog(paths).latest('paycom', 'empty'), null); + const file = path.join(paths.local, 'config/plugin-packages.json'); + const value = JSON.parse(fs.readFileSync(file)); + value.items.push({ pluginId: 'paycom', version: '1.0.0', digest: 'a'.repeat(64) }); + value.approved.production.paycom = '1.0.0'; + value.approved.dsps.selected = { paycom: '1.0.0' }; + atomic(file, value); + assert.equal(packageCatalog(paths).latest('paycom', 'legacy').version, '1.0.0'); + assert.equal(packageCatalog(paths).latest('paycom', 'selected').version, '1.0.0'); + assert.equal(packageCatalog(paths).latest('paycom', 'empty'), null); + await approvePackages(paths, { runtimeKey: 'selected', packages: [] }); + assert.equal(packageCatalog(paths).latest('paycom', 'selected'), null); + assert.throws(() => packageCatalog(paths).resolveApproved('paycom', '1.0.0', 'selected'), /plugin_package_not_approved/); +}); diff --git a/host/releases/dsp.js b/host/releases/dsp.js index f0ffd31..7fac8b3 100644 --- a/host/releases/dsp.js +++ b/host/releases/dsp.js @@ -40,7 +40,7 @@ function dspHooks({ paths, store, manager, execution }) { const value = privateJson(approvalsFile, process.geteuid(), true); if (!value) return {}; const catalog = normalizeCatalog(value); - return { ...catalog.approved.production, ...catalog.approved.dsps[id] }; + return { ...(Object.hasOwn(catalog.approved.dsps, id) ? catalog.approved.dsps[id] : catalog.approved.production) }; }; const stop = async c => { await manager.pluginBackend?.request(c.dspId, 'plugin.revoke', { pluginId: null }); @@ -102,7 +102,7 @@ function dspHooks({ paths, store, manager, execution }) { prepareDspRelease(paths, c.dspId, c.directory, c.digest); for (const item of c.manifest.plugins) await distributePackage(paths, { directory: path.join(c.directory, 'plugins', item.pluginId), digest: item.digest }, { lockFd }); - if (c.manifest.plugins.length) await approvePackages(paths, { runtimeKey: c.dspId, packages: c.manifest.plugins }, { lockFd }); + await approvePackages(paths, { runtimeKey: c.dspId, packages: c.manifest.plugins }, { lockFd }); selectDspRelease(paths, c.dspId, c.digest, c.previousDigest); for (const plugin of store.db.prepare("SELECT * FROM dsp_plugins WHERE organization_id=? AND desired_state<>'uninstalled'").all(row.organization_id)) { const selected = c.manifest.plugins.find(item => item.pluginId === plugin.plugin_id); diff --git a/host/releases/provisioning.js b/host/releases/provisioning.js index 0c6ec84..72d7b42 100644 --- a/host/releases/provisioning.js +++ b/host/releases/provisioning.js @@ -25,7 +25,7 @@ async function withCreation(paths, action, work) { prepareDspRelease(paths, id, release.directory, digest); for (const item of manifest.plugins) await distributePackage(paths, { directory: path.join(release.directory, 'plugins', item.pluginId), digest: item.digest }, { lockFd }); - if (manifest.plugins.length) await approvePackages(paths, { runtimeKey: id, packages: manifest.plugins }, { lockFd }); + await approvePackages(paths, { runtimeKey: id, packages: manifest.plugins }, { lockFd }); if (!existing) selectDspRelease(paths, id, digest, null); state.active.dsps[id] = digest; if (state.rollout && state.rollout.status !== 'completed' && id !== config.devDspId && !state.rollout.targets.includes(id)) state.rollout.targets.push(id); diff --git a/host/releases/runtime-package.js b/host/releases/runtime-package.js new file mode 100644 index 0000000..edf0d63 --- /dev/null +++ b/host/releases/runtime-package.js @@ -0,0 +1,40 @@ +'use strict'; +const fs = require('node:fs'), path = require('node:path'); +const { hash, inventory, secureCopy } = require('../../shared/releases/package'); + +const runtimeFile = file => !file.path.startsWith('plugins/'); + +// Keep the original release manifest and digest. The host has already verified +// the complete published release before deriving this DSP-owned runtime copy. +// Plugin payloads live in the host cache until the DSP installs them; code/plugins +// contains only catalog definitions needed by the runtime's existing contracts. +function copyRuntime(source, target) { + fs.mkdirSync(target, { mode: 0o700 }); + for (const name of fs.readdirSync(source)) { + if (name === 'plugins') continue; + const from = path.join(source, name), to = path.join(target, name); + const stat = fs.lstatSync(from); + if (stat.isDirectory()) secureCopy(from, to); + else if (stat.isFile()) { + fs.copyFileSync(from, to, fs.constants.COPYFILE_EXCL); + fs.chmodSync(to, stat.mode & 0o777 & ~0o022); + } else throw new Error('release_entry_invalid'); + } +} + +function verifyRuntime(directory, expectedDigest) { + const file = path.join(directory, 'release.json'), stat = fs.lstatSync(file); + if (!stat.isFile() || stat.isSymbolicLink() || stat.size > 16 * 1024 * 1024) throw new Error('release_manifest_invalid'); + const manifest = JSON.parse(fs.readFileSync(file)); + if (manifest.schemaVersion !== 1 || manifest.product !== 'dsp' || !Array.isArray(manifest.files) + || !/^[a-f0-9]{64}$/.test(expectedDigest) || hash(JSON.stringify(manifest)) !== expectedDigest) throw new Error('release_manifest_invalid'); + const actual = JSON.stringify(inventory(directory).filter(item => item.path !== 'release.json')); + // Retained full copies remain readable for existing deployments and rollback. + // Accept either complete layout, never a partly missing plugin/runtime tree. + if (actual !== JSON.stringify(manifest.files.filter(runtimeFile)) && actual !== JSON.stringify(manifest.files)) { + throw new Error('release_digest_mismatch'); + } + return manifest; +} + +module.exports = { copyRuntime, verifyRuntime }; diff --git a/host/releases/runtime.js b/host/releases/runtime.js index 13fd12c..e46fe86 100644 --- a/host/releases/runtime.js +++ b/host/releases/runtime.js @@ -1,6 +1,7 @@ 'use strict'; const fs=require('node:fs'),path=require('node:path'); -const {verifyRelease,secureCopy}=require('../../shared/releases/package'); +const {verifyRelease}=require('../../shared/releases/package'); +const {copyRuntime,verifyRuntime}=require('./runtime-package'); const {validateDspId}=require('../../shared/paths/platform-paths'); const {privateJson,atomic}=require('../../core/installations/src/release-delivery-files'); const {privateDirectory,syncDirectory}=require('../controller/operations'); @@ -11,7 +12,7 @@ function runtimeSource(paths,id) { if(!current){if(fs.existsSync(path.join(paths.live,'runtime')))return paths.live;throw new Error('dsp_release_required');} if(Object.keys(current).sort().join(',')!=='digest,schemaVersion'||current.schemaVersion!==1||!/^[a-f0-9]{64}$/.test(current.digest))throw new Error('dsp_release_invalid'); const directory=path.join(paths.dsps,id,'runtime/releases',current.digest); - if(verifyRelease(directory,current.digest).product!=='dsp')throw new Error('dsp_release_invalid'); + verifyRuntime(directory,current.digest); return path.join(directory,'code'); } function prepareDspRelease(paths,id,directory,digest) { @@ -20,10 +21,10 @@ function prepareDspRelease(paths,id,directory,digest) { const parent=privateDirectory(path.join(paths.dsps,id,'runtime/releases')),target=path.join(parent,digest); if(!fs.existsSync(target)){ const temporary=target+'.stage-'+require('node:crypto').randomBytes(12).toString('hex'); - try{secureCopy(directory,temporary);verifyRelease(temporary,digest);fs.renameSync(temporary,target);syncDirectory(parent);} + try{copyRuntime(directory,temporary);verifyRuntime(temporary,digest);fs.renameSync(temporary,target);syncDirectory(parent);} finally{fs.rmSync(temporary,{recursive:true,force:true});} } - verifyRelease(target,digest);return {dspId:id,digest,directory:target}; + verifyRuntime(target,digest);return {dspId:id,digest,directory:target}; } // Lifecycle callers hold their DSP lock and drain old processes before selecting // code. Health verification and restoring the prior receipt belong to that caller. @@ -31,7 +32,8 @@ function selectDspRelease(paths,id,digest,expectedDigest) { const file=fileFor(paths,id),prior=privateJson(file,process.geteuid(),true); if((prior?.digest||null)!==expectedDigest)throw new Error('dsp_release_changed'); if(digest!==null){ - if(!/^[a-f0-9]{64}$/.test(digest)||verifyRelease(path.join(paths.dsps,id,'runtime/releases',digest),digest).product!=='dsp')throw new Error('dsp_release_invalid'); + if(!/^[a-f0-9]{64}$/.test(digest))throw new Error('dsp_release_invalid'); + verifyRuntime(path.join(paths.dsps,id,'runtime/releases',digest),digest); privateDirectory(path.dirname(file));atomic(file,{schemaVersion:1,digest}); }else if(prior)fs.unlinkSync(file); return {previousDigest:prior?.digest||null,digest}; From c50ad50d85edb2622c339b1bbe3bee27fb29eff9 Mon Sep 17 00:00:00 2001 From: Dillon <260170482+dillonlille@users.noreply.github.com> Date: Sun, 13 Sep 2026 02:16:48 +0000 Subject: [PATCH 2/3] Verify Cortex-only startup and installed Paycom upgrades in native DSPs --- core/updates/README.md | 7 +++-- .../architecture/release-update.acceptance.js | 31 +++++++++++++++++-- 2 files changed, 33 insertions(+), 5 deletions(-) diff --git a/core/updates/README.md b/core/updates/README.md index a1c464d..8973cb8 100644 --- a/core/updates/README.md +++ b/core/updates/README.md @@ -35,7 +35,7 @@ An active rollout continues using its pinned digest when a newer release appears Release downloads only stage verified files. They do not select code or start services. Shared dependencies remain versioned copies inside each DSP release. -DSP source and optional plugin source continue to ship in one DSP release. The +DSP code and optional plugin packages continue to ship in one DSP release. The host keeps that full release, but each DSP receives only the runtime and catalog metadata. The original release manifest/digest authenticates the exact copied subset. Install copies a sealed plugin into only the requesting DSP. Rollout @@ -154,8 +154,9 @@ The browser checks cover independent controls, newer-release reset, paused rollout, sequential resume and mobile layout. CI runs the same browser checks. `tests/architecture/release-update.acceptance.js` additionally installs a real -Paycom package in a private Linux/systemd namespace lab, switches DSP releases, -wakes a sleeping DSP and checks rollback of its private state. Provide sealed +Paycom package in a private Linux/systemd namespace lab. It verifies Cortex-only +startup without optional plugin payloads, installs Paycom, switches DSP and +Paycom versions, wakes a sleeping DSP and checks rollback of its private state. Provide sealed Node/tini and browser tool roots plus explicit Core/DSP release directories via `DISPATCH_WORKER_TEST_TOOLS`, `DISPATCH_WORKER_TEST_BROWSER`, `DISPATCH_UPDATE_TEST_CORE` and `DISPATCH_UPDATE_TEST_DSP`. It uses synthetic data diff --git a/tests/architecture/release-update.acceptance.js b/tests/architecture/release-update.acceptance.js index b7e9b38..6a5592e 100644 --- a/tests/architecture/release-update.acceptance.js +++ b/tests/architecture/release-update.acceptance.js @@ -26,7 +26,8 @@ test('independent DSP updates switch a real runtime and restore its private stat const { LocalReleases } = require('../../core/updates/local-releases'); const { dspHooks } = require('../../host/releases/dsp'); const { hash, inventory, secureCopy, verifyRelease } = require('../../shared/releases/package'); - const { prepareDspRelease, selectDspRelease, fileFor } = require('../../host/releases/runtime'); + const { prepareDspRelease, selectDspRelease, runtimeSource, fileFor } = require('../../host/releases/runtime'); + const { installationReceipt } = require('../../host/plugins/install'); const beforeUmask = process.umask(0o077); const root = fs.mkdtempSync(path.join(os.tmpdir(), 'dpd-')); for (const name of ['live', 'local', 'dsps', 'dev', 'worktrees']) privateDirectory(path.join(root, name)); @@ -85,6 +86,8 @@ test('independent DSP updates switch a real runtime and restore its private stat require('../../shared/plugin-sdk/catalog').configureCatalog(definitions); require('dispatch-protocol/plugin-sdk/catalog').configureCatalog(definitions); prepareDspRelease(paths, id, dspPackage, dspDigest); selectDspRelease(paths, id, dspDigest, null); + assert.equal(fs.existsSync(path.join(path.dirname(runtimeSource(paths, id)), 'plugins')), false); + assert.equal(fs.existsSync(path.join(dsp.root, 'plugins/paycom')), false); const installation = loadInstallation(paths); const open = () => openDirectoryRuntime({ paths, installation, journal, authorityCatalog: authority.authorityCatalog, publishAuthority: authority.publishAuthority, networkPermitted: () => false, select: () => false }); @@ -96,10 +99,18 @@ test('independent DSP updates switch a real runtime and restore its private stat const plugins = createPluginService({ store, access, installationCoordinator: { ...coordinator, async apply(input) { try { return await coordinator.apply(input); } catch (error) { process.stderr.write('Fixture plugin activation: ' + error.stack + '\n'); throw error; } } }, invoke: (runtimeKey, action, input) => execution.invoke(runtimeKey, action, input) }); const session = access.session(owner.token); + await runtime.manager.apply('start', 'fixture_fresh_runtime', id); + const ownerConnections = require('../../core/accounts/src/owner-connections').createOwnerConnections({ store, access, + invoke: (key, action, input) => runtime.hub.invoke(key, action, input) }); + assert.deepEqual((await ownerConnections.list(session)).items.map(item => item.service), ['cortex']); + assert.equal(store.db.prepare('SELECT count(*) count FROM dsp_plugins WHERE organization_id=?').get(org).count, 0); + assert.equal(fs.existsSync(path.join(dsp.root, 'plugins/paycom')), false); plugins.change(session, 'paycom', { action: 'install', expectedRevision: 0, idempotencyKey: 'daemon:fixture:install' }); await plugins.runPending(); assert.equal(plugins.list(session).items[0].available, true, JSON.stringify(plugins.list(session).items.map(item => ({ id: item.id, state: item.state, failureCode: item.failureCode })))); assert.equal(runtime.hub.connected(id), true, 'Install resumes an always-on DSP after acknowledging the package'); + assert.deepEqual((await ownerConnections.list(session)).items.map(item => item.service).sort(), ['cortex', 'paycom']); + assert.equal(installationReceipt(dsp.root, 'paycom').version, paycomVersion); const result = await runtime.hub.invoke(id, 'plugins.invoke', { pluginId: 'paycom', action: 'sync.status', input: { id: 'paycom-main-workforce' } }); assert.equal(result.ok, true, JSON.stringify(result)); const connections = await runtime.hub.invoke(id, 'connections.manage', { command: 'list' }); @@ -126,7 +137,20 @@ test('independent DSP updates switch a real runtime and restore its private stat const baseline = updates.state(); baseline.active = { core: coreDigest, dsps: { [id]: dspDigest } }; baseline.defaultDsp = dspDigest; updates.save(baseline); atomic(path.join(paths.local, 'config/updates.json'), { schemaVersion: 1, devDspId: id, apiPort: 4999 }); const nextPackage = path.join(paths.dev, 'next-release'); secureCopy(dspPackage, nextPackage); - const nextManifest = { ...dspManifest, version: '0.0.2', channel: 'development' }; + // Build a new sealed Paycom version inside this synthetic DSP release. Its + // migrations/runtime are real; only the version differs from the fixture. + const nextPluginRoot = path.join(nextPackage, 'plugins/paycom'); + const nextPlugin = JSON.parse(fs.readFileSync(path.join(nextPluginRoot, 'dispatch-plugin.json'))); + const nextPluginVersion = nextPlugin.version.split('.').map((part, index) => Number(part) + (index === 2 ? 1 : 0)).join('.'); + nextPlugin.version = nextPluginVersion; + fs.chmodSync(path.join(nextPluginRoot, 'dispatch-plugin.json'), 0o600); + fs.writeFileSync(path.join(nextPluginRoot, 'dispatch-plugin.json'), JSON.stringify(nextPlugin)); + fs.unlinkSync(path.join(nextPluginRoot, 'package-manifest.json')); + const nextPluginDigest = require('../../tooling/build-plugin-package').sealPackage(nextPluginRoot).digest; + const metadata = path.join(nextPackage, 'code/plugins/paycom/dispatch-plugin.json'); + fs.chmodSync(metadata, 0o600); fs.writeFileSync(metadata, JSON.stringify(nextPlugin)); + const nextManifest = { ...dspManifest, version: '0.0.2', channel: 'development', + plugins: dspManifest.plugins.map(item => item.pluginId === 'paycom' ? { ...item, version: nextPluginVersion, digest: nextPluginDigest } : item) }; fs.chmodSync(path.join(nextPackage, 'release.json'), 0o600); fs.unlinkSync(path.join(nextPackage, 'release.json')); nextManifest.files = inventory(nextPackage); @@ -140,6 +164,8 @@ test('independent DSP updates switch a real runtime and restore its private stat assert.equal(execution.store.get(id).state, 'running'); assert.equal(execution.store.get(id).failure_code, null); assert.equal(JSON.parse(fs.readFileSync(fileFor(paths, id))).digest, nextDigest); + assert.equal(installationReceipt(dsp.root, 'paycom').version, nextPluginVersion); + assert.equal(fs.existsSync(path.join(path.dirname(runtimeSource(paths, id)), 'plugins')), false); assert.equal(runtime.hub.connected(id), true); assert.equal((await runtime.hub.invoke(id, 'health', {})).ok, true); const privateFile = path.join(dsp.root, 'data/release-sentinel'); fs.writeFileSync(privateFile, 'before failed migration', { mode: 0o600 }); @@ -155,6 +181,7 @@ test('independent DSP updates switch a real runtime and restore its private stat await assert.rejects(updates.updateDev(failedDigest), /release_health_failed/); assert.equal(fs.readFileSync(privateFile, 'utf8'), 'before failed migration'); assert.equal(JSON.parse(fs.readFileSync(fileFor(paths, id))).digest, nextDigest); + assert.equal(installationReceipt(dsp.root, 'paycom').version, nextPluginVersion); assert.equal(runtime.manager.journal.record(id).desiredState, 'stopped'); assert.equal(execution.store.get(id).state, 'sleeping'); assert.equal(updates.state().operation, null); From 9c4edc6537ba844ef86c90265c4cfdb95f6ce4ca Mon Sep 17 00:00:00 2001 From: Dillon <260170482+dillonlille@users.noreply.github.com> Date: Sun, 13 Sep 2026 02:37:44 +0000 Subject: [PATCH 3/3] Hide Dev-only plugins from production session bootstrap --- core/accounts/src/plugins.js | 15 +++++-- core/api/access-http.js | 4 +- core/updates/README.md | 4 ++ .../tests/selective-delivery-fixture.js | 4 +- dashboard/tests/plugins.test.js | 45 +++++++++++++++++++ 5 files changed, 66 insertions(+), 6 deletions(-) diff --git a/core/accounts/src/plugins.js b/core/accounts/src/plugins.js index 8d7178f..36eabe3 100644 --- a/core/accounts/src/plugins.js +++ b/core/accounts/src/plugins.js @@ -42,10 +42,19 @@ function createPluginService({ store, access, invoke, settingsPort = null, insta || store.db.prepare("SELECT 1 FROM directory_lifecycle_requests WHERE organization_id=? AND status IN ('queued','running')").get(selected.organization.id)) fail('installation_not_ready'); return { ...selected, installation }; } + // Internal session serialization and the Plugins endpoint must use the same + // release-scoped catalog. The organization id comes from the signed session. + function listForOrganization(organizationId) { + const key = store.installationControl(organizationId)?.runtimeKey; + return listFor(store, organizationId).flatMap(item => { + const approved = installationCoordinator?.latest?.(item.id, key); + if (installationCoordinator && !approved && item.state === 'uninstalled') return []; + return [{ ...item, latestVersion: approved?.version || item.version, automaticUpdates: true }]; + }); + } function list(session) { const { organization } = access.organizationFor(session, 'dashboard.view'); - const key = store.installationControl(organization.id)?.runtimeKey; - return { items: listFor(store, organization.id).filter(item => !installationCoordinator || installationCoordinator.latest(item.id,key) || item.state !== 'uninstalled').map(item=>({...item,latestVersion:installationCoordinator?.latest?.(item.id,key)?.version || item.version,automaticUpdates:true})) }; + return { items: listForOrganization(organization.id) }; } function change(session, id, input) { const selected = context(session); @@ -199,7 +208,7 @@ function createPluginService({ store, access, invoke, settingsPort = null, insta })().finally(() => { running = null; }); return running; } - return { list, change, runPending, + return { list, listForOrganization, change, runPending, settings: require('./plugin-settings').createPluginSettings({store,access,port:settingsPort}), catalog: () => ({ items: catalog().map(publicPlugin) }) }; } diff --git a/core/api/access-http.js b/core/api/access-http.js index 8d54c37..bcd9e2d 100644 --- a/core/api/access-http.js +++ b/core/api/access-http.js @@ -100,7 +100,9 @@ function createAccessHttp({ stations: membership.organization.stations, }, })), - plugins: current.activeOrganizationId ? require('../accounts/src/plugins').listFor(access.store, current.activeOrganizationId) : [], + plugins: current.activeOrganizationId ? (plugins?.listForOrganization + ? plugins.listForOrganization(current.activeOrganizationId) + : require('../accounts/src/plugins').listFor(access.store, current.activeOrganizationId)) : [], csrfToken: current.csrfToken, expiresAt: current.expiresAt, }; diff --git a/core/updates/README.md b/core/updates/README.md index 8973cb8..8e4e8f5 100644 --- a/core/updates/README.md +++ b/core/updates/README.md @@ -42,6 +42,10 @@ subset. Install copies a sealed plugin into only the requesting DSP. Rollout updates enabled and disabled installed plugins to the selected release versions; uninstalled plugins are not copied. New DSPs have only the built-in Cortex connection until plugins are installed, and use the last completed fleet release. +New plugins are visible only to DSPs on a release that approves them. Update Dev +exposes them to Dev first; rollout exposes them to each DSP as it updates. Both +session bootstrap and the Plugins endpoint apply this same catalog filter, so +signing in or opening a platform-owner DSP view cannot reveal a Dev-only plugin. Existing full runtime copies remain readable for rollback. Installing Core does not rewrite those copies. New DSP creation and preparation of a new DSP release diff --git a/core/updates/tests/selective-delivery-fixture.js b/core/updates/tests/selective-delivery-fixture.js index 7d8157d..2f51e5f 100644 --- a/core/updates/tests/selective-delivery-fixture.js +++ b/core/updates/tests/selective-delivery-fixture.js @@ -51,12 +51,12 @@ async function fixture(t) { require('../../../shared/plugin-sdk/catalog').configureCatalog(defaults); require('dispatch-protocol/plugin-sdk/catalog').configureCatalog(defaults); }); - function artifact(product, version, pluginVersion = null) { + function artifact(product, version, pluginVersion = null, pluginIds = ['paycom', 'sample']) { const directory = privateDirectory(path.join(paths.dev, product + '-' + version)); privateDirectory(path.join(directory, 'code/runtime')); fs.writeFileSync(path.join(directory, 'code/runtime/index.js'), `module.exports='${version}';`, { mode: 0o600 }); const packaged = []; - if (pluginVersion) for (const id of ['paycom', 'sample']) { + if (pluginVersion) for (const id of pluginIds) { const root = privateDirectory(path.join(directory, 'plugins', id)); privateDirectory(path.join(root, 'backend')); privateDirectory(path.join(root, 'migrations')); const definition = { ...require('../../../tests/fixtures/paycom-plugin.json'), id, name: id, version: pluginVersion, diff --git a/dashboard/tests/plugins.test.js b/dashboard/tests/plugins.test.js index 9f951de..efc9212 100644 --- a/dashboard/tests/plugins.test.js +++ b/dashboard/tests/plugins.test.js @@ -4,6 +4,51 @@ const test = require('node:test'); const { fixture } = require('../../core/accounts/tests/plugin-fixture'); const { createDashboardServer } = require('../server/server'); const { success } = require('../../shared/contracts/src/result'); + +test('new plugins stay Dev-only in session bootstrap and the Plugins API until each DSP receives the rollout', async t => { + const f = await require('../../core/updates/tests/selective-delivery-fixture').fixture(t); + const [dev, production] = f.dsps; + const candidate = f.artifact('dsp', '1.2.0', '1.2.0', ['paycom', 'sample', 'new-plugin']); + await f.releases.stage(candidate.directory, candidate.digest); + const client = { workforce: { day() {} }, sync: { status() {}, runNow() {} }, system: { status() {} } }; + const server = createDashboardServer({ access: f.access, client, plugins: f.plugins, runtimeResolver: () => client }); + await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)); + t.after(() => new Promise(resolve => server.close(resolve))); + const base = `http://127.0.0.1:${server.address().port}`; + async function visible(dsp, expected, view = null) { + const headers = { cookie: `dispatch_session=${view ? f.platform.token : dsp.token}`, + ...(view ? { 'x-dispatch-dsp-view': view } : {}) }; + for (const route of ['/api/auth/session', '/api/organization/plugins']) { + const response = await fetch(base + route, { headers }); + assert.equal(response.status, 200); + const { data } = await response.json(); + const items = route.endsWith('session') ? data.plugins : data.items; + assert.equal(items.some(item => item.id === 'new-plugin'), expected, `${route} for ${dsp.runtimeKey}`); + assert.equal(items.filter(item => item.id === 'new-plugin').every(item => item.state === 'uninstalled'), true); + } + } + const owner = f.access.session(f.platform.token); + const view = f.access.beginDspView(owner, { controlRef: f.access.issuePlatformControlRef(owner, production.id) }); + for (const dsp of f.dsps) await visible(dsp, false); + await f.releases.updateDev(candidate.digest); + await visible(dev, true); + for (const dsp of f.dsps.slice(1)) await visible(dsp, false); + await visible(production, false, view.dspView.viewRef); + const install = await fetch(base + '/api/organization/plugins/new-plugin', { method: 'POST', headers: { + cookie: `dispatch_session=${production.token}`, 'content-type': 'application/json', 'x-dispatch-csrf': production.owner.csrfToken, + }, body: JSON.stringify({ action: 'install', expectedRevision: 0, idempotencyKey: 'fixture:hidden:install' }) }); + assert.equal(install.status, 409); + assert.equal((await install.json()).error.code, 'plugin_package_not_approved'); + assert.equal(f.store.db.prepare("SELECT count(*) count FROM dsp_plugins WHERE plugin_id='new-plugin'").get().count, 0); + await f.releases.beginRollout(candidate.digest, f.dsps.map(dsp => dsp.runtimeKey)); + const updated = new Set([dev.runtimeKey]); + for (const id of f.releases.state().rollout.targets) { + await f.releases.step(); updated.add(id); + for (const dsp of f.dsps) await visible(dsp, updated.has(dsp.runtimeKey)); + } + await visible(production, true, view.dspView.viewRef); + assert.equal(f.store.db.prepare("SELECT count(*) count FROM dsp_plugins WHERE plugin_id='new-plugin'").get().count, 0); +}); test('installed assets require current installation and signed DSP scope without waking a runtime', async t => { const f = await fixture(t); const [a, b] = f.dsps; require('../../core/accounts/tests/plugin-fixture').enableFixturePlugin(f.store, a.id);