fix(proxy): review fixes for the Console proxy setup page (#1873)

Security review:
- Release verification cache keys on ctime as well as mtime, in
  nanoseconds. A same-size rewrite with mtime set back now re-hashes and
  reports proxy-install-invalid.

Build review:
- The setup page hides the account panels on any failed accounts read, not
  only proxy-down, and brings them back once the proxy answers.
- The organizations table shows a dash, not a made-up 0, when a bus does
  not answer or sends no model list (orgCatalogue in proxy-admin.mjs).
- A fence clear the bus did not answer keeps the confirmed restart for a
  retry.
- A fence with no readable time names `mosaic proxy fence clear
  --proxy-restarted` on the page, in the refusal table and in the README.
- CLI `init --generate-key` goes through the setup controller and writes
  the same init receipt as the page.
- Status carries the last ten receipts, so the page shows them after a
  reload. An initialized proxy keeps the init form folded.
- README: proxy-admin-busy is per process; the cross-process locks that
  serialize the writes are named.
This commit was merged in pull request #1943.
This commit is contained in:
jason.woltje committed 2026-11-11 16:32:31 -06:00
1 parent f0e11cb342
commit 75f6d5533a
13 files changed
+162 -22

No files matched your search

+1 -1
View File
@@ -63,7 +63,7 @@ group("proxy-install-invalid proxy-fetch-hash proxy-fetch-archive proxy-fetch-ex
group("proxy-already-initialized proxy-port-in-use proxy-management-key-invalid", "R1/R4: proxy init is exclusive creation", "Init never rewrites proxy.json. Read the Proxy page status; pick a free port, or keep the existing setup and continue from the status's next step.");
group("proxy-init-busy proxy-init-lock-held proxy-init-lock-absent", "R4: init lock clears only when provably stale", "Wait for a running init to finish. A stale lock clears from the Proxy page or with mosaic proxy init-lock clear only when the bus is stopped and no proxy.json, key file or proxy dir exists; the refusal lists what is still there.");
group("proxy-key-transport", "R1/R6: a pasted key travels only over loopback or HTTPS", "Use the generated key, which never leaves the host, or open the Console over HTTPS or on this machine to paste one.");
group("proxy-restart-required proxy-restart-unconfirmed proxy-lock-uncertain", "R4/R6: fence clears only after a confirmed proxy restart", "Restart the proxy from the Proxy page or with mosaic proxy fence clear --restart, so the new process is confirmed on the proxy port, then clear the fence.");
group("proxy-restart-required proxy-restart-unconfirmed proxy-lock-uncertain", "R4/R6: fence clears only after a confirmed proxy restart", "Restart the proxy from the Proxy page or with mosaic proxy fence clear --restart, so the new process is confirmed on the proxy port, then clear the fence. A fence file with no readable time cannot be proven against: restart the proxy service yourself and run mosaic proxy fence clear --proxy-restarted.");
group("proxy-admin-busy", "R4: one proxy setup action at a time", "Wait for the running fetch, init or unit action to finish (the Proxy page status shows it), then retry.");
group("proxy-key-mismatch", "R1: a pasted management key is confirmed before init", "Type the same key in both fields, or leave them empty to have the bus generate the key.");
group("proxy-lock-busy proxy-lock-invalid", "R4: one proxy key write at a time", "Wait for the running proxy write to finish and retry. An invalid lock file needs the owner to inspect the proxy state directory.");
+4 -2
View File
@@ -173,9 +173,11 @@ export async function proxyCommand(args, io, { system, callFor, prompt = promptP
// #1873: the bus generates the key and stores it where init stores a typed key; no prompt,
// no terminal needed, and the key is never printed or returned.
if (parsed.flags.has('--generate-key')) {
// Through the page's controller, so the same init receipt lands in proxy-admin.jsonl.
const call = callFor(parsed);
const result = await call('proxy.init', { generate: true, ...(port === undefined ? {} : { port }), ...timing });
print(io, { port: result.port, rotateGraceMinutes: result.rotateGraceMinutes ?? null, firstOutputSeconds: result.firstOutputSeconds ?? null, generated: true });
const { settings, receipt } = await adminFor(io, system, deps).init(call, { generate: true, ...(port === undefined ? {} : { port }), ...timing });
io.stderr.write(`receipt: ${JSON.stringify(receipt)}\n`);
print(io, { ...settings, generated: true });
return;
}
if (!io.stdin.isTTY || !io.stdout.isTTY) throw failure('proxy-terminal-required', 3);
+6 -2
View File
@@ -82,12 +82,16 @@ test('a controller refusal reaches the shell as its proxy code only, with no pat
test('proxy init --generate-key needs no terminal, never prompts, and sends generate to the bus', async () => {
const x = human();
assert.notEqual(x.stdin.isTTY, true);
const calls = [];
const calls = [], system = fakeSystem();
await main(['proxy', 'init', '--generate-key', '--business', 'demo', '--port', '18440', '--rotate-grace-minutes', '5'], x, {
system: fakeSystem(), promptProxyKey: () => { throw Error('must not prompt'); },
system, promptProxyKey: () => { throw Error('must not prompt'); },
transport: b => async (verb, args) => { calls.push({ b, verb, args }); return { port: 18440, rotateGraceMinutes: 5, binary: '/fixture' }; } });
assert.deepEqual(calls, [{ b: 'demo', verb: 'proxy.init', args: { generate: true, port: 18440, rotateGraceMinutes: 5 } }]);
assert.deepEqual(JSON.parse(x.out.text), { port: 18440, rotateGraceMinutes: 5, firstOutputSeconds: null, generated: true });
// The same init receipt the page writes, settings only.
const [receipt] = readFileSync(join(system.dataRoot, 'state', 'proxy-admin.jsonl'), 'utf8').trim().split('\n').map(JSON.parse);
assert.deepEqual({ ...receipt, time: 'T' }, { time: 'T', op: 'init', generated: true, port: 18440, rotateGraceMinutes: 5, firstOutputSeconds: null });
assert.match(x.err.text, /^receipt: \{"time":/m);
await assert.rejects(main(['proxy', 'init', '--generate-key', '--generate-key', '--business', 'demo'], human(), { system: fakeSystem() }), code('proxy-usage'));
await assert.rejects(main(['proxy', 'init', '--generate-key', '--port', '8317', '--business', 'demo'], human(), { system: fakeSystem() }), code('proxy-config-invalid'));
});
+11 -1
View File
@@ -158,7 +158,17 @@ state offers its next step as one button. CLI parity:
confirms a new process that started after the fence and listens on the port,
then clears the fence with that evidence under the lock. The receipt holds the
before and after pids. Without a confirmed restart it refuses
(`proxy-restart-unconfirmed`). `fence clear --proxy-restarted` is unchanged.
(`proxy-restart-unconfirmed`). A fence file with no readable time also refuses,
because a restart cannot be shown to come after it; restart the service
yourself and use `fence clear --proxy-restarted`, which is unchanged.
- `proxy-admin-busy` (one setup action at a time) is per process: the Console
and a `mosaic proxy` command each hold their own. The writes that matter are
still serialized across processes: init by the bus's init lock and
exclusive create, key writes by the instance-wide proxy lock, and unit
actions by systemd.
- The page and `status --json` carry the last ten receipts from
`proxy-admin.jsonl`, so they survive a reload. CLI `init --generate-key`
writes the same init receipt as the page.
- The init lock clears only when every bus on the instance is stopped and no
`proxy.json`, key file or proxy state directory exists. Otherwise the panel
and `init-lock status` list why not.
+4 -2
View File
@@ -5,7 +5,7 @@
import { homedir } from 'node:os';
import { fetchPinned } from './fetch.mjs';
import { PIN } from './common.mjs';
import { releaseState, initState, fenceState, initLockStatus, clearInitLock, appendReceipt, createUnitControl, FILE_LABELS } from './admin.mjs';
import { releaseState, initState, fenceState, initLockStatus, clearInitLock, appendReceipt, recentReceipts, createUnitControl, FILE_LABELS } from './admin.mjs';
export const proxyCode = (error, fallback) => (/^proxy-[a-z-]+$/.test(error?.code ?? '') ? error.code : fallback);
const failure = code => Object.assign(new Error(code), { code });
@@ -66,6 +66,7 @@ export function createProxyAdmin({ dataRoot, configDir, instance = null, unitDir
fetch: fetchJob,
restart: restarted ? { confirmed: true, observedAt: restarted.observedAt, pid: restarted.after.pid } : null,
busy,
receipts: recentReceipts(dataRoot),
organizations,
// Ruling 8: the system config holds one proxy per instance; an organization's own proxy
// needs a config format that does not exist yet.
@@ -126,8 +127,9 @@ export function createProxyAdmin({ dataRoot, configDir, instance = null, unitDir
return exclusive('fence-clear', async () => {
if (!restarted) throw failure('proxy-restart-required');
const evidence = restarted;
restarted = null;
// Kept until the bus answers: a bus that is down or busy leaves the confirmed restart for a retry.
const result = await call('proxy.fence.clear', { evidence });
if (restarted === evidence) restarted = null;
return { cleared: result.cleared === true, receipt: receipt({ op: 'fence.clear', cleared: result.cleared === true, evidence: result.evidence ?? null }) };
});
},
+19 -3
View File
@@ -27,15 +27,16 @@ export function initFiles(configDir, dataRoot) {
function present(file) { try { fs.lstatSync(file); return true; } catch (error) { return error.code !== 'ENOENT'; } }
const verified = new Map();
// The pinned release: hashes are checked once per file identity (dev, inode, size, mtime, mode).
// The pinned release: hashes are checked once per file identity (dev, inode, size, mtime, ctime, mode),
// in nanoseconds. mtime can be set back by the file's owner; ctime cannot, so an in-place rewrite re-hashes.
export function releaseState(dataRoot, pin = PIN) {
const paths = installPaths(dataRoot, pin);
const out = { version: pin.version, panelVersion: pin.panel.version, fetched: false, verified: false, problem: null };
let stats;
try { stats = [paths.binary, paths.panel].map(file => fs.lstatSync(file)); }
try { stats = [paths.binary, paths.panel].map(file => fs.lstatSync(file, { bigint: true })); }
catch (error) { out.problem = error.code === 'ENOENT' ? 'proxy-install-missing' : 'proxy-install-invalid'; return out; }
out.fetched = true;
const identity = stats.map(s => `${s.dev}:${s.ino}:${s.size}:${s.mtimeMs}:${s.mode}`).join('|');
const identity = stats.map(s => `${s.dev}:${s.ino}:${s.size}:${s.mtimeNs}:${s.ctimeNs}:${s.mode}`).join('|');
if (verified.get(paths.dir) === identity) { out.verified = true; return out; }
try { verifyInstall(dataRoot, pin); verified.set(paths.dir, identity); out.verified = true; }
catch (error) { out.problem = /^proxy-install-(missing|invalid)$/.test(error.code ?? '') ? error.code : 'proxy-install-invalid'; }
@@ -106,6 +107,21 @@ export function appendReceipt(dataRoot, record) {
return record;
}
// The last few receipts, oldest first, for the page after a reload. Reads only the file's tail and
// skips a line that does not parse (a torn last write). Receipts never hold a key or a body.
export function recentReceipts(dataRoot, limit = 10) {
let fd;
try { fd = fs.openSync(receiptFile(dataRoot), 'r'); } catch { return []; }
try {
const size = fs.fstatSync(fd).size, length = Math.min(size, 65536), buffer = Buffer.alloc(length);
fs.readSync(fd, buffer, 0, length, size - length);
const lines = buffer.toString('utf8').split('\n').slice(size > length ? 1 : 0).filter(Boolean);
const out = [];
for (const line of lines) { try { const value = JSON.parse(line); if (value && typeof value === 'object' && typeof value.op === 'string') out.push(value); } catch { /* torn line */ } }
return out.slice(-limit);
} finally { fs.closeSync(fd); }
}
export function unitName(instance) {
if (instance !== null && instance !== undefined && instance !== 'dev' && !INSTANCE_NAME.test(instance)) refuse('proxy-unit-usage');
return instance && instance !== 'dev' ? `mosaic-proxy-${instance}.service` : 'mosaic-proxy.service';
@@ -142,6 +142,17 @@ test('fence clear after a confirmed restart sends that evidence once; another un
assert.deepEqual(receipts(dataRoot).map(r => r.op), ['unit.restart', 'fence.clear', 'unit.restart', 'unit.stop']);
});
test('a fence clear the bus did not answer keeps the confirmed restart for a retry', async t => {
const { admin } = fixture(t, { unitState: { installed: true, active: 'active', healthy: true, pid: 11 } });
let fail = true;
const call = async () => { if (fail) { fail = false; throw Object.assign(new Error('bus down'), { code: 'bus-unavailable' }); } return { cleared: true }; };
await admin.unitAction('restart');
await assert.rejects(admin.fenceClear(call), /bus down/);
assert.equal((await admin.status()).restart?.confirmed, true, 'the restart is still confirmed');
assert.equal((await admin.fenceClear(call)).cleared, true);
assert.equal((await admin.status()).restart, null);
});
test('next step walks fetch, init lock, repair, init, unit install, start, restart, fence and accounts', async t => {
const sha = text => createHash('sha256').update(text).digest('hex');
const pin = { ...PIN, archive: { ...PIN.archive, sha256: sha('a') }, binary: { sha256: sha('b') }, panel: { ...PIN.panel, sha256: sha('p') } };
@@ -206,3 +217,13 @@ test('render replaces the home directory with ~', async t => {
assert.equal(unit, 'mosaic-proxy-zz.service');
if (process.env.HOME && process.env.HOME !== '/') assert.equal(text.includes(process.env.HOME), false);
});
test('status carries the last receipts from proxy-admin.jsonl, so the page shows them after a reload', async t => {
const { dataRoot, admin } = fixture(t, { unitState: { installed: true, active: 'active', healthy: true, pid: 11 } });
assert.deepEqual((await admin.status()).receipts, []);
for (let i = 0; i < 12; i++) await admin.unitAction('stop');
fs.appendFileSync(receiptFile(dataRoot), '{"op":"torn');
const { receipts } = await admin.status();
assert.equal(receipts.length, 10);
assert.ok(receipts.every(r => r.op === 'unit.stop'));
});
+24
View File
@@ -5,6 +5,9 @@ import assert from 'node:assert/strict';
import fs from 'node:fs';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import { createHash } from 'node:crypto';
import { PIN } from '../src/common.mjs';
import { installPaths } from '../src/settings.mjs';
import {
processTree, pidListensOn, processStartMs, verifyRestart, initLockStatus, clearInitLock, initFiles, initState,
fenceState, createUnitControl, releaseState, receiptFile, unitName,
@@ -121,6 +124,27 @@ test('releaseState: nothing fetched names the next step without a path', t => {
assert.equal(JSON.stringify(state).includes(root), false);
});
test('releaseState re-hashes a release file rewritten in place with its mtime set back', async t => {
const { dataRoot } = dirs(t);
const sha = bytes => createHash('sha256').update(bytes).digest('hex');
const binary = Buffer.from('made-up proxy binary 0001'), panel = Buffer.from('<html>made-up panel</html>');
const pin = { ...PIN, version: '0.0.0-test', binary: { ...PIN.binary, sha256: sha(binary) }, panel: { ...PIN.panel, sha256: sha(panel) } };
const paths = installPaths(dataRoot, pin);
fs.mkdirSync(join(paths.dir, 'static'), { recursive: true });
fs.writeFileSync(paths.binary, binary, { mode: 0o755 }); fs.writeFileSync(paths.panel, panel);
const when = 1700000000;
for (const file of [paths.binary, paths.panel]) fs.utimesSync(file, when, when);
assert.equal(releaseState(dataRoot, pin).verified, true);
await new Promise(resolve => setTimeout(resolve, 50));
const before = fs.statSync(paths.binary, { bigint: true });
fs.writeFileSync(paths.binary, Buffer.from('made-up proxy binary 0002'), { flag: 'r+' });
fs.utimesSync(paths.binary, when, when);
const after = fs.statSync(paths.binary, { bigint: true });
assert.deepEqual([after.ino, after.size, after.mtimeNs, after.mode], [before.ino, before.size, before.mtimeNs, before.mode], 'only ctime tells the files apart');
const state = releaseState(dataRoot, pin);
assert.equal(state.verified, false); assert.equal(state.problem, 'proxy-install-invalid');
});
test('unit names follow the instance and refuse a bad instance', () => {
assert.equal(unitName(null), 'mosaic-proxy.service');
assert.equal(unitName('dev'), 'mosaic-proxy.service');
+3 -6
View File
@@ -30,7 +30,7 @@ import { fileURLToPath } from 'node:url';
import { isAbsolute, join } from 'node:path';
import { actionTrail } from './action-trail.mjs';
import { reviveBuses } from './bus-revive.mjs';
import { createProxyAdmin } from './proxy-admin.mjs';
import { createProxyAdmin, orgCatalogue } from './proxy-admin.mjs';
import { busesStopped } from '../../cli/src/proxy-admin-host.mjs';
import { agentLevelsWriteRoutes } from './agent-levels-write.mjs';
import { RESTART_EXIT } from '../../cli/src/org-console.mjs';
@@ -207,15 +207,12 @@ try {
dataRoot: system.dataRoot, configDir: configDir(), instance, unitDir: options.proxyUnitDir ?? null,
buses: () => busesStopped({ dataRoot: system.dataRoot, configDir: configDir() }),
orgs: async () => {
const catalogue = async call => {
try { const value = await call('routes'); return { status: value?.catalogue?.status ?? 'unknown', models: Array.isArray(value?.catalogue?.models) ? value.catalogue.models.length : 0 }; }
catch { return { status: 'bus-unavailable', models: 0 }; }
};
const catalogue = orgCatalogue;
if (!organizations) return [{ id: options.business ?? null, proxy: 'shared', catalogue: await catalogue(ownerCall ?? (async () => { throw new Error(); })) }];
const out = [];
for (const org of await listOrgs({ configDir: configDir(), dataRoot: system.dataRoot, instance })) {
if (org.state !== 'active') continue;
let paths; try { paths = orgPaths(org.root); } catch { out.push({ id: org.id, proxy: 'shared', catalogue: { status: 'bus-unavailable', models: 0 } }); continue; }
let paths; try { paths = orgPaths(org.root); } catch { out.push({ id: org.id, proxy: 'shared', catalogue: { status: 'bus-unavailable', models: null } }); continue; }
out.push({ id: org.id, proxy: 'shared', catalogue: await catalogue(busCaller({ dataRoot: org.root, socket: paths.socket, business: org.id, timeoutMs: 5000 })) });
}
return out;
+9
View File
@@ -8,6 +8,15 @@ import { createProxyAdmin, proxyCode } from '../../model-proxy/src/admin-control
export { createProxyAdmin };
// #1873: one organization's catalogue row for the setup page. A count is shown only when the bus
// answered with a model list; otherwise models is null and the page shows a dash, never a made-up 0.
export async function orgCatalogue(call) {
try {
const value = await call('routes');
return { status: value?.catalogue?.status ?? 'unknown', models: Array.isArray(value?.catalogue?.models) ? value.catalogue.models.length : null };
} catch { return { status: 'bus-unavailable', models: null }; }
}
const ROUTES = Object.freeze({
'/api/proxy/admin/status': ['GET', []],
'/api/proxy/admin/unit/render': ['GET', []],
+13 -4
View File
@@ -318,6 +318,11 @@ if (document.documentElement.hasAttribute('data-settings-proxy-document') && !lo
li.append(op, ` ${window.MosaicLocalTime(record.time)}${details ? ' — ' + details : ''}`);
list.prepend(li);
}
// #1873: receipts survive a reload: status carries the last few from proxy-admin.jsonl.
function paintReceipts() {
if (!Array.isArray(state.receipts) || !state.receipts.length) return;
$('proxy-setup-receipts').replaceChildren(); state.receipts.forEach(addReceipt);
}
function row(dl, term, text) { const dt = document.createElement('dt'), dd = document.createElement('dd'); dt.textContent = term; dd.textContent = text; dl.append(dt, dd); }
function paintState() {
const { release, init, unit, fence, initLock } = state, dl = $('proxy-setup-state'); dl.replaceChildren();
@@ -339,7 +344,7 @@ if (document.documentElement.hasAttribute('data-settings-proxy-document') && !lo
init: ['Initialize the proxy', 'Next: initialize. The bus generates the management key unless you paste your own.', () => { $('proxy-setup-init-box').open = true; form.elements.port.focus(); }],
'unit-install': ['Install the proxy service', `Next: install ${state.unit.unit}.`, () => unitAction('install')],
'unit-start': ['Start the proxy service', `Next: start ${state.unit.unit}.`, () => unitAction('start')],
'unit-restart': ['Restart the proxy service', state.fence.set ? 'A key write had an uncertain outcome, so key writes are fenced. Restart the proxy; after this page confirms the new process, clear the fence.' : 'The service is running but does not answer on its port. Restart it.', () => unitAction('restart')],
'unit-restart': ['Restart the proxy service', state.fence.set ? (fence => fence.invalid || !fence.time ? 'A key write had an uncertain outcome, so key writes are fenced. The fence file has no readable time, so a restart from this page cannot be proven against it: restart the proxy service, confirm it answers, then run mosaic proxy fence clear --proxy-restarted.' : 'A key write had an uncertain outcome, so key writes are fenced. Restart the proxy; after this page confirms the new process, clear the fence.')(state.fence) : 'The service is running but does not answer on its port. Restart it.', () => unitAction('restart')],
'fence-clear': ['Clear the fence', 'The proxy was restarted from this page and the new process answers on its port. Clear the fence with that evidence.', clearFence],
accounts: ['Add an account', 'The proxy is running. Sign in to an account or add an API key below.', () => $('proxy-signin-title')?.scrollIntoView({ block: 'start' })],
};
@@ -366,11 +371,13 @@ if (document.documentElement.hasAttribute('data-settings-proxy-document') && !lo
$('proxy-setup-own').textContent = state.ownProxy?.available ? 'An organization can run its own proxy.' : `Own proxy per organization: ${state.ownProxy?.state ?? 'not built'}. ${state.ownProxy?.reason ?? ''}`;
}
function paint() {
paintState(); paintNext(); paintControls();
paintState(); paintNext(); paintControls(); paintReceipts();
// An initialized proxy keeps the init form folded after a reload too.
if (state.init.initialized) $('proxy-setup-init-box').open = false;
const job = state.fetch;
if (job.state === 'running') say(`Fetching: ${STEPS[job.step] ?? job.step ?? 'starting'}…`);
else if (job.state === 'failed' && seenFetch !== 'failed') { say(`Fetch failed. ${explain(job.error)}`, 'error'); seenFetch = 'failed'; }
else if (job.state === 'done' && job.receipt && seenFetch !== job.receipt.time) { seenFetch = job.receipt.time; addReceipt(job.receipt); say(`Fetched ${job.receipt.version}; archive, panel and executable hashes verified.`, 'success'); }
else if (job.state === 'done' && job.receipt && seenFetch !== job.receipt.time) { seenFetch = job.receipt.time; if (!state.receipts?.some(r => r.op === 'fetch' && r.time === job.receipt.time)) addReceipt(job.receipt); say(`Fetched ${job.receipt.version}; archive, panel and executable hashes verified.`, 'success'); }
clearTimeout(timer); timer = null;
if (enabled && (job.state === 'running' || state.busy)) timer = setTimeout(load, 1000);
}
@@ -451,7 +458,9 @@ if (document.documentElement.hasAttribute('data-settings-proxy-document') && !lo
}).then(() => message('Accounts and client keys loaded.')).catch(error => { message(error.message);
// #1873: on the setup page a stopped proxy hides the account panels like an unconfigured one,
// and the setup load brings them back once the service answers.
if (proxyAdmin && error.code === 'proxy-down') { accountsShown = false; $('proxy-message').classList.add('empty'); $('proxy-readonly').hidden=true; document.querySelectorAll('.settings-model-proxy .panel:not(#proxy-setup), #proxy-refresh').forEach(el=>el.hidden=true); message('The model proxy is not running yet. Follow the next step under Proxy setup.'); return; }
// Any failed read hides them, not only proxy-down: a proxy that is initialized but not yet answering
// can fail with another code, and a stale error over an empty table would stay after Start.
if (proxyAdmin && error.message !== 'The model proxy is not configured for this instance.') { accountsShown = false; $('proxy-message').classList.add('empty'); $('proxy-readonly').hidden=true; document.querySelectorAll('.settings-model-proxy .panel:not(#proxy-setup), #proxy-refresh').forEach(el=>el.hidden=true); message(error.code === 'proxy-down' ? 'The model proxy is not running yet. Follow the next step under Proxy setup.' : `Accounts could not be read${error.code ? ` (${error.code})` : ''}. Follow the next step under Proxy setup; the account panels return once the proxy answers.`); return; }
if(error.message==='The model proxy is not configured for this instance.') { accountsShown = false; $('proxy-message').classList.add('empty'); $('proxy-readonly').hidden=true; document.querySelectorAll('.settings-model-proxy .panel:not(#proxy-setup), #proxy-refresh').forEach(el=>el.hidden=true);
// #1873: the owner sets the proxy up on this page (Proxy setup above); no terminal note.
if (proxyAdmin) { message('The model proxy is not set up yet. Follow the next step under Proxy setup.'); return; }
@@ -8,7 +8,7 @@ import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { startServer } from '../src/serve.mjs';
import { createDevOwner } from '../src/dev-owner.mjs';
import { PROXY_ADMIN_PATHS, keyTransportOk } from '../src/proxy-admin.mjs';
import { PROXY_ADMIN_PATHS, keyTransportOk, orgCatalogue } from '../src/proxy-admin.mjs';
import { consoleAccess } from '../src/access.mjs';
import { createSsoSession } from './helpers/sso-session.mjs';
import { REFUSALS } from '../../bus/src/refusals.mjs';
@@ -196,3 +196,10 @@ test('every refusal code a setup route can answer has a refusal-table entry with
for (const code of ['human-required', 'proxy-key-transport', 'proxy-key-mismatch', 'proxy-key-invalid', 'proxy-config-invalid', 'proxy-rotate-grace-invalid', 'proxy-first-output-invalid', 'proxy-admin-busy', 'proxy-restart-required', 'proxy-restart-unconfirmed', 'proxy-init-lock-held', 'proxy-init-lock-absent', 'proxy-unit-missing', 'proxy-unit-unhealthy', 'proxy-unit-failed', 'not-configured', 'outcome-unknown', 'invalid-request'])
assert.equal(typeof REFUSALS[code]?.nextStep, 'string', code);
});
test('an organization whose bus does not answer shows no model count, not a made-up 0', async () => {
assert.deepEqual(await orgCatalogue(async () => { throw new Error('down'); }), { status: 'bus-unavailable', models: null });
assert.deepEqual(await orgCatalogue(async () => ({ catalogue: { status: 'disabled' } })), { status: 'disabled', models: null });
assert.deepEqual(await orgCatalogue(async () => ({ catalogue: { status: 'enabled', models: [{ id: 'a' }, { id: 'b' }] } })), { status: 'enabled', models: 2 });
assert.deepEqual(await orgCatalogue(async () => ({ catalogue: { status: 'enabled', models: [] } })), { status: 'enabled', models: 0 });
});
@@ -105,3 +105,42 @@ test('a stopped proxy hides the account panels with a setup note, and starting t
assert.equal(await b.evaluate('document.querySelector("[aria-labelledby=proxy-apikey-title]").hidden'), false);
assert.equal(await b.evaluate('document.querySelector("[aria-labelledby=proxy-accounts-title]").hidden'), false);
});
test('an initialized proxy whose accounts read fails with another code also hides the panels until it answers', { timeout: 90000 }, async t => {
const installed = { ...base, release: fetched.release, init: { initialized: true, partial: false, settings: { port: 18432 }, files: [] },
unit: { unit: 'mosaic-proxy.service', installed: true, active: 'inactive' }, next: ['unit-start'] };
const failing = { 'GET /api/proxy/accounts': { status: 503, body: { error: 'outcome-unknown' } }, 'GET /api/proxy/keys': { status: 503, body: { error: 'outcome-unknown' } } };
const { f, b, wait } = await w15Fixture(t, { scenario: { ...scenario(installed), ...failing, 'POST /api/proxy/admin/unit/start': { body: { receipt: { op: 'unit.start', time: T, pid: 4242 } } } } });
await b.navigate(f.base + '/#/settings/model-proxy');
await wait('document.querySelector("#proxy-setup-go")?.dataset.next==="unit-start"');
await wait('document.querySelector("#proxy-message").textContent.includes("the account panels return once the proxy answers")');
assert.equal(await b.evaluate('document.querySelector("[aria-labelledby=proxy-accounts-title]").hidden'), true);
await b.evaluate(`Object.assign(window.__scenario, {
'GET /api/proxy/accounts': { body: { accounts: [] } }, 'GET /api/proxy/keys': { body: { keys: [] } },
'/api/proxy/admin/status': ${JSON.stringify({ ...base, release: fetched.release, init: { initialized: true, partial: false, settings: { port: 18432 }, files: [] },
unit: { unit: 'mosaic-proxy.service', installed: true, active: 'active', pid: 4242, port: 18432, listening: true }, next: ['accounts'] })} })`);
await b.evaluate('document.querySelector("#proxy-setup-go").click()');
await wait('document.querySelector("#proxy-message").textContent.includes("Accounts and client keys loaded")');
assert.equal(await b.evaluate('document.querySelector("[aria-labelledby=proxy-accounts-title]").hidden'), false);
});
test('a fence with no readable time names the --proxy-restarted path instead of a page restart', { timeout: 90000 }, async t => {
const timeless = { ...fenced, fence: { set: true, invalid: true }, restart: null, next: ['unit-restart'] };
const { f, b, wait } = await w15Fixture(t, { scenario: scenario(timeless) });
await b.navigate(f.base + '/#/settings/model-proxy');
await wait('document.querySelector("#proxy-setup-go")?.dataset.next==="unit-restart"');
assert.match(await b.evaluate('document.querySelector("#proxy-setup-next p").textContent'), /no readable time.*mosaic proxy fence clear --proxy-restarted/);
});
test('after a reload the receipts come from status, and an initialized proxy folds the init form', { timeout: 90000 }, async t => {
const initialized = { ...base, release: fetched.release, init: { initialized: true, partial: false, settings: { port: 18432 }, files: [] },
receipts: [{ op: 'fetch', time: T, version: '8.0.42' }, { op: 'init', time: T, generated: true, port: 18432 }], next: ['unit-install'] };
const { f, b, wait } = await w15Fixture(t, { scenario: { ...scenario(initialized), 'POST /api/proxy/admin/unit/install': { body: { receipt: { op: 'unit.install', time: T } } } } });
await b.navigate(f.base + '/#/settings/model-proxy');
await wait('document.querySelector("#proxy-setup-go")?.dataset.next==="unit-install"');
assert.deepEqual(await b.evaluate('[...document.querySelectorAll("#proxy-setup-receipts li strong")].map(s=>s.textContent)'), ['init', 'fetch'], 'newest first, from proxy-admin.jsonl');
await b.evaluate('document.querySelector("#proxy-setup-init-box").open = true');
await b.evaluate('document.querySelector("#proxy-setup-go").click()');
await wait('window.__writes.some(w=>w.path==="/api/proxy/admin/unit/install")');
await wait('document.querySelector("#proxy-setup-init-box").open===false');
});