Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions RELEASES.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
15 changes: 12 additions & 3 deletions core/accounts/src/plugins.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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) }) };
}
Expand Down
4 changes: 2 additions & 2 deletions core/accounts/tests/plugin-fixture.js
Original file line number Diff line number Diff line change
Expand Up @@ -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') };
Expand All @@ -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 });
Expand Down
4 changes: 3 additions & 1 deletion core/api/access-http.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
};
Expand Down
4 changes: 3 additions & 1 deletion core/plugins/package-catalog.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand Down
30 changes: 26 additions & 4 deletions core/updates/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. |
Expand All @@ -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/<id>/runtime/releases/` | Independently installed DSP runtime releases. |
| `dsps/<id>/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/<id>/plugins/<plugin>/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
Expand All @@ -33,6 +35,25 @@ 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 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
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
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
Expand Down Expand Up @@ -137,8 +158,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
Expand Down
79 changes: 79 additions & 0 deletions core/updates/tests/runtime-package.test.js
Original file line number Diff line number Diff line change
@@ -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/);
});
Loading