One Proxy setup panel on the Model proxy settings page takes an owner from
nothing to a running proxy: status with one next-step button per state, the
pinned fetch with hash checks, progress and a receipt, init with a
bus-generated key (a pasted key only over loopback or HTTPS, with a
confirmation), unit render/install/start/stop/restart with health, a fence
clear that the bus re-proves against a confirmed restart, and a stale init
lock clear that names its blockers. Own proxy per organization shows as not
built.
New Console routes under /api/proxy/admin/ use the existing owner proxy
checks; agents, testers and grant holders get human-required. New bus verbs
proxy.init {generate:true} and proxy.fence.clear. Receipts go to
<dataRoot>/state/proxy-admin.jsonl with labels and key ids only. Every new
refusal code has a refusal-table entry.
CLI: mosaic proxy status, unit render|status|install|start|stop|restart,
init --generate-key, fence clear --restart, init-lock status|clear; proxy
fetch keeps its stdout and adds progress and the receipt.
Page fix found in the end-to-end run: a stopped proxy hides the account
panels until the service answers. (The catalogue switch label fix found in
the same run landed upstream as D3 P0-4.)
110 lines
7.9 KiB
JavaScript
110 lines
7.9 KiB
JavaScript
// #1873 bus side of the Console proxy setup: proxy.init with a bus-generated key, the owner-only
|
|
// proxy.fence.clear verb, and a lock clear that re-proves restart evidence before it clears.
|
|
import test from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import fs from 'node:fs';
|
|
import { tmpdir } from 'node:os';
|
|
import { join } from 'node:path';
|
|
import { Broker } from '../src/broker.mjs';
|
|
import { Store } from '../src/store.mjs';
|
|
import { createProxyLock } from '../src/proxy-lock.mjs';
|
|
import { REFUSALS } from '../src/refusals.mjs';
|
|
import { validManagementKey } from '../../model-proxy/src/common.mjs';
|
|
|
|
const role = { authority: { withinRole: ['message.send'], crossRole: [] } };
|
|
const code = name => error => error.code === name;
|
|
function fixture(t, { proxyInit = null, proxyFenceClear = null } = {}) {
|
|
const store = new Store(fs.mkdtempSync(join(tmpdir(), 'ps-bus-')));
|
|
t.after(() => store.close());
|
|
const b = new Broker({ store, proxyInit, proxyFenceClear, businesses: { demo: { id: 'demo', human: 'owner', arbiters: { technical: 'chief', delivery: 'chief' }, vars: { 'bus.tester': true }, roles: { chief: { definition: 'chief-of-staff', authority: { withinRole: ['decision.resolve.technical', 'decision.resolve.delivery'], crossRole: [] } }, coder: role } } } });
|
|
return {
|
|
store, b,
|
|
human: b.bindHuman({ business: 'demo', human: 'owner', via: 'cli', outsideAgent: true }),
|
|
tester: b.bindTester({ business: 'demo' }), reader: b.bindReader({ business: 'demo' }),
|
|
agent: b.bindLaunch({ business: 'demo', role: 'coder', run: 'ps-run', harness: 'pi' }),
|
|
};
|
|
}
|
|
const events = store => JSON.stringify(store.all('SELECT body FROM events'));
|
|
const bodies = store => store.all('SELECT body FROM events').map(r => JSON.parse(r.body));
|
|
|
|
test('proxy.init generate:true makes a valid key on the bus, hands it only to init, and never returns or records it', async t => {
|
|
const seen = [];
|
|
const f = fixture(t, { proxyInit: async args => { seen.push(args); return { port: args.port ?? 18432, binary: '/fixture' }; } });
|
|
const result = await f.b.request(f.human, { verb: 'proxy.init', args: { generate: true, port: 18440, rotateGraceMinutes: 5 } });
|
|
assert.deepEqual(result, { port: 18440, binary: '/fixture' });
|
|
assert.equal(seen.length, 1);
|
|
assert.deepEqual(Object.keys(seen[0]).sort(), ['key', 'port', 'rotateGraceMinutes']);
|
|
assert.equal(validManagementKey(seen[0].key), true);
|
|
assert.match(seen[0].key, /^[A-Za-z0-9_-]{64}$/, '48 CSPRNG bytes as base64url');
|
|
assert.equal(JSON.stringify(result).includes(seen[0].key), false);
|
|
assert.equal(events(f.store).includes(seen[0].key), false);
|
|
assert.deepEqual(bodies(f.store).filter(b => b.call === 'proxy.init'), [{ call: 'proxy.init', port: 18440, generated: true }]);
|
|
await f.b.request(f.human, { verb: 'proxy.init', args: { generate: true } });
|
|
assert.notEqual(seen[1].key, seen[0].key, 'each init generates a fresh key');
|
|
});
|
|
|
|
test('proxy.init generate refuses agents and testers, and refuses a key with generate or generate other than true', async t => {
|
|
let calls = 0;
|
|
const f = fixture(t, { proxyInit: async () => { calls++; return { port: 18432, binary: '/fixture' }; } });
|
|
for (const cap of [f.agent, f.tester]) assert.throws(() => f.b.request(cap, { verb: 'proxy.init', args: { generate: true } }), code('human-required'));
|
|
assert.throws(() => f.b.request(f.reader, { verb: 'proxy.init', args: { generate: true } }), code('read-only'));
|
|
for (const args of [{ generate: true, key: 'ps-sentinel-key-0001' }, { generate: false }, { generate: 'yes' }, {}])
|
|
assert.throws(() => f.b.request(f.human, { verb: 'proxy.init', args }), code('invalid-request'), JSON.stringify(args));
|
|
assert.equal(calls, 0);
|
|
assert.equal(events(f.store).includes('ps-sentinel-key-0001'), false);
|
|
// The same verb the refusals guard does run for the owner.
|
|
await f.b.request(f.human, { verb: 'proxy.init', args: { generate: true } });
|
|
assert.equal(calls, 1);
|
|
});
|
|
|
|
test('proxy.fence.clear is owner-only, passes the evidence through, and records only the pid', async t => {
|
|
const seen = [];
|
|
const evidence = { unit: 'mosaic-proxy.service', confirmed: true, before: { pid: 1 }, after: { pid: 2 }, observedAt: new Date(0).toISOString() };
|
|
const f = fixture(t, { proxyFenceClear: async args => { seen.push(args); return { cleared: true, evidence: { after: { pid: 2 } } }; } });
|
|
for (const cap of [f.agent, f.tester]) assert.throws(() => f.b.request(cap, { verb: 'proxy.fence.clear', args: { evidence } }), code('human-required'));
|
|
assert.throws(() => f.b.request(f.reader, { verb: 'proxy.fence.clear', args: { evidence } }), code('read-only'));
|
|
assert.throws(() => f.b.request(f.human, { verb: 'proxy.fence.clear', args: {} }), code('invalid-request'));
|
|
assert.throws(() => f.b.request(f.human, { verb: 'proxy.fence.clear', args: { evidence, restarted: true } }), code('invalid-request'));
|
|
assert.equal(seen.length, 0);
|
|
assert.deepEqual(await f.b.request(f.human, { verb: 'proxy.fence.clear', args: { evidence } }), { cleared: true, evidence: { after: { pid: 2 } } });
|
|
assert.deepEqual(seen, [{ evidence }]);
|
|
assert.deepEqual(bodies(f.store).filter(b => b.call === 'proxy.fence.clear'), [{ call: 'proxy.fence.clear', cleared: true, pid: 2 }]);
|
|
});
|
|
|
|
test('proxy.fence.clear maps a refusal to its proxy code and a missing runtime to not-configured', async t => {
|
|
const f = fixture(t, { proxyFenceClear: async () => { throw Object.assign(Error('/home/x detail'), { code: 'proxy-restart-unconfirmed' }); } });
|
|
await assert.rejects(f.b.request(f.human, { verb: 'proxy.fence.clear', args: { evidence: {} } }), e => e.code === 'proxy-restart-unconfirmed' && !e.message.includes('/home/x'));
|
|
const g = fixture(t);
|
|
assert.throws(() => g.b.request(g.human, { verb: 'proxy.fence.clear', args: { evidence: {} } }), code('not-configured'));
|
|
});
|
|
|
|
test('a lock clear with verify: the fence an uncertain write set clears only on proof, and the receipt holds the evidence', async t => {
|
|
const dir = fs.mkdtempSync(join(tmpdir(), 'ps-lock-'));
|
|
t.after(() => fs.rmSync(dir, { recursive: true, force: true }));
|
|
const lock = createProxyLock({ stateDir: dir, identity: 'instance-one' });
|
|
await assert.rejects(lock.run(async () => { lock.fence({ org: 'acme', op: 'apikey.add' }); throw Error('outcome unknown'); }));
|
|
assert.throws(() => lock.assertCertain(), code('proxy-uncertain'));
|
|
let read = 0;
|
|
await assert.rejects(lock.clear({ restarted: true, read: async () => { read++; }, verify: () => { throw Object.assign(Error('no'), { code: 'proxy-restart-unconfirmed' }); } }), code('proxy-restart-unconfirmed'));
|
|
assert.equal(read, 0, 'an unproved restart never reads or clears');
|
|
assert.throws(() => lock.assertCertain(), code('proxy-uncertain'));
|
|
const seenFence = [];
|
|
const result = await lock.clear({ restarted: true, read: async () => { read++; }, verify: previous => { seenFence.push(previous); return { after: { pid: 42 } }; } });
|
|
assert.deepEqual(result, { cleared: true, evidence: { after: { pid: 42 } } });
|
|
assert.equal(seenFence[0].org, 'acme'); assert.equal(seenFence[0].op, 'apikey.add');
|
|
lock.assertCertain();
|
|
const receipt = fs.readFileSync(join(dir, 'mosaic-keys.receipts.jsonl'), 'utf8').trim().split('\n').map(JSON.parse).at(-1);
|
|
assert.deepEqual(receipt.evidence, { after: { pid: 42 } });
|
|
assert.equal(receipt.previous.org, 'acme');
|
|
});
|
|
|
|
test('every #1873 refusal code has a refusal-table entry with a next step', () => {
|
|
for (const c of ['proxy-admin-busy', 'proxy-key-transport', 'proxy-key-mismatch', 'proxy-restart-required', 'proxy-restart-unconfirmed', 'proxy-init-lock-held', 'proxy-init-lock-absent', 'proxy-unit-missing', 'proxy-unit-unhealthy', 'proxy-unit-failed', 'proxy-unit-exists', 'proxy-unit-path', 'proxy-unit-usage', 'proxy-install-missing', 'proxy-install-invalid', 'proxy-fetch-hash']) {
|
|
const entry = REFUSALS[c];
|
|
assert.ok(entry, c);
|
|
assert.equal(typeof entry.nextStep, 'string', c);
|
|
assert.ok(entry.nextStep.length > 20, c);
|
|
assert.equal(/\/(home|mnt|srv|tmp)\//.test(entry.nextStep), false, `${c} names no absolute path`);
|
|
}
|
|
});
|