Admin page: schema-driven config, enable/disable, live re-init

Milestone 5. GET /api/admin/state, PUT /api/admin/modules/<id>/config and
/enabled persist to config/modules.json and re-init the module in place —
no restart. /api/events (SSE) tells open dashboards about admin changes;
the shell then calls onConfigChange(cfg) on instances that implement it
and cleanly remounts the rest, so tiles reconnect on their own. Admin
page renders forms from each configSchema (text/number/boolean/select/
password), shows module health from handle.health(), and never echoes
password values back to a browser. Optional shared passcode
(config/proddash.json adminPasscode or PRODDASH_PASSCODE) gates the admin
API behind a cookie; the dashboard API stays open.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RNYUv9EyrGSFmCnmH7WYKH
This commit is contained in:
Claude
2026-08-30 15:16:09 +00:00
parent 9298b4d142
commit 0b53616160
5 changed files with 819 additions and 2 deletions
+189
View File
@@ -18,6 +18,7 @@ const http = require('http');
const fs = require('fs');
const path = require('path');
const os = require('os');
const crypto = require('crypto');
const ROOT = __dirname;
const PUBLIC_DIR = path.join(ROOT, 'public');
@@ -260,6 +261,169 @@ function clientManifest(id) {
};
}
/* ── shell events (SSE to every open dashboard/admin page) ──────────── */
const shellStreams = new Set();
function broadcastShellEvent(type, payload = {}) {
if (!shellStreams.size) return;
const frame = `event: ${type}\ndata: ${JSON.stringify(payload)}\n\n`;
for (const res of shellStreams) {
try {
res.write(frame);
} catch {
shellStreams.delete(res);
}
}
}
setInterval(() => {
for (const res of shellStreams) {
try {
res.write(': ping\n\n');
} catch {
shellStreams.delete(res);
}
}
}, 25000).unref();
/* ── admin API ──────────────────────────────────────────────────────── */
const ADMIN_PASSCODE = String(process.env.PRODDASH_PASSCODE || shellConfig.adminPasscode || '');
/** Session token: regenerating it every boot is fine — admins just re-enter the passcode. */
const adminToken = crypto.randomBytes(24).toString('hex');
function isAuthed(req) {
if (!ADMIN_PASSCODE) return true;
const cookies = String(req.headers.cookie || '');
return cookies.split(';').some((c) => c.trim() === 'proddash_admin=' + adminToken);
}
/** Guard a state-changing admin route. Returns true when the request was refused. */
function refuseAdminWrite(req, res) {
if (!isAuthed(req)) {
sendJson(res, 401, { error: 'Enter the admin passcode first.', authRequired: true });
return true;
}
// Requiring application/json forces a CORS preflight that a hostile page
// on some other origin cannot pass (same reasoning as the reference apps).
const contentType = String(req.headers['content-type'] || '').toLowerCase();
if (!contentType.startsWith('application/json')) {
sendJson(res, 415, { error: 'Send application/json.' });
return true;
}
return false;
}
/** Everything the admin page needs, per module. */
function adminModuleView(id) {
const man = manifests.get(id);
const cfg = effectiveConfig(id);
const values = {};
const passwordSet = {};
for (const [key, spec] of Object.entries(man.configSchema || {})) {
if (spec?.type === 'password') {
passwordSet[key] = Boolean(cfg[key]);
values[key] = ''; // never echo secrets back to a browser
} else {
values[key] = cfg[key];
}
}
const entry = mounted.get(id);
return {
id,
name: String(man.name || id),
version: String(man.version || ''),
description: String(man.description || ''),
hasServer: Boolean(man.server),
enabled: isEnabled(id),
configSchema: man.configSchema || {},
config: values,
passwordSet,
mountError: entry?.error || '',
health: isEnabled(id) ? moduleHealth(id) : null,
};
}
/** Coerce and store a config patch according to the module's schema.
Empty password fields mean "keep what is stored". */
function applyConfigPatch(id, patch) {
const man = manifests.get(id);
const current = effectiveConfig(id);
const next = {};
for (const [key, spec] of Object.entries(man.configSchema || {})) {
const type = spec?.type || 'string';
let value = patch && typeof patch === 'object' && key in patch ? patch[key] : current[key];
if (type === 'password' && (value === '' || value === undefined || value === null)) {
value = current[key] || '';
}
if (type === 'number') value = Number(value) || 0;
else if (type === 'boolean') value = Boolean(value);
else value = value === undefined || value === null ? '' : String(value);
next[key] = value;
}
modulesConfig[id] = { ...moduleState(id), enabled: isEnabled(id), config: next };
writeJson(MODULES_CONFIG_PATH, modulesConfig);
}
async function handleAdminApi(req, res, urlPath) {
if (urlPath === '/api/admin/login' && req.method === 'POST') {
let body = {};
try {
body = JSON.parse((await readBody(req)) || '{}');
} catch {
return sendJson(res, 400, { error: 'Malformed request.' });
}
if (!ADMIN_PASSCODE) return sendJson(res, 200, { ok: true, authRequired: false });
if (String(body.passcode || '') !== ADMIN_PASSCODE) {
return sendJson(res, 403, { error: 'Wrong passcode.' });
}
res.writeHead(200, {
'Content-Type': 'application/json; charset=utf-8',
'Set-Cookie': `proddash_admin=${adminToken}; Path=/; SameSite=Lax; HttpOnly`,
'Cache-Control': 'no-store',
});
return res.end(JSON.stringify({ ok: true }));
}
if (urlPath === '/api/admin/state' && req.method === 'GET') {
if (!isAuthed(req)) {
return sendJson(res, 401, { authRequired: true, authed: false });
}
return sendJson(res, 200, {
authRequired: Boolean(ADMIN_PASSCODE),
authed: true,
modules: [...manifests.keys()].sort().map(adminModuleView),
});
}
const m = urlPath.match(/^\/api\/admin\/modules\/([^/]+)\/(config|enabled)$/);
if (m && req.method === 'PUT') {
const id = decodeURIComponent(m[1]);
if (!manifests.has(id)) return sendJson(res, 404, { error: `No module "${id}" is installed.` });
if (refuseAdminWrite(req, res)) return;
let body = {};
try {
body = JSON.parse((await readBody(req)) || '{}');
} catch {
return sendJson(res, 400, { error: 'Malformed request.' });
}
if (m[2] === 'config') {
applyConfigPatch(id, body.config);
remountModule(id); // config changes take effect without a manual restart
} else {
const enabled = Boolean(body.enabled);
modulesConfig[id] = { ...moduleState(id), enabled };
writeJson(MODULES_CONFIG_PATH, modulesConfig);
remountModule(id); // mounts when enabled, unmounts (only) otherwise
}
broadcastShellEvent('modules-changed', { id });
return sendJson(res, 200, { ok: true, module: adminModuleView(id) });
}
sendJson(res, 404, { error: 'Unknown admin endpoint.' });
}
/* ── module API dispatch ────────────────────────────────────────────── */
function dispatchModuleApi(req, res, id, subPath, search) {
@@ -371,6 +535,31 @@ function handleRequest(req, res) {
return sendJson(res, 200, { modules: list });
}
/* — shell events: open dashboards learn about admin changes live — */
if (urlPath === '/api/events' && req.method === 'GET') {
res.writeHead(200, {
'Content-Type': 'text/event-stream; charset=utf-8',
'Cache-Control': 'no-cache, no-transform',
Connection: 'keep-alive',
'X-Accel-Buffering': 'no',
});
res.write('retry: 3000\n\n');
try { req.socket.setKeepAlive(true, 15000); } catch { /* gone */ }
shellStreams.add(res);
req.on('close', () => shellStreams.delete(res));
return;
}
/* — admin API — */
if (urlPath.startsWith('/api/admin/')) {
handleAdminApi(req, res, urlPath).catch((err) => {
console.error('[proddash] admin API error:', err);
if (!res.headersSent) sendJson(res, 500, { error: 'Server error.' });
else try { res.end(); } catch { /* gone */ }
});
return;
}
/* — module APIs: /api/modules/<id>/… (any method; modules decide) — */
const apiMatch = urlPath.match(/^\/api\/modules\/([^/]+)(\/.*)?$/);
if (apiMatch) {