From 55b2b18250a7a8f0677f8c588e232342898f310b Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 31 Oct 2026 02:43:30 +0000 Subject: [PATCH] Mission Control at /admin/board: live checks, then the board A staff page behind a new key, site:board.read, which migration 0004 adds and grants to admin. Each load probes the other production hosts from the Function, in parallel with a 3 s deadline each, then renders the MasterMind board (board.v1) from KV BOARD, key board:v1, every string escaped. Without the binding, the key or valid JSON it says the board is not connected and lists the three steps that connect it; a KV error is a warning. No script, the CSP is unchanged, and staff.css carries the styles (v=20261031). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VHrJ4hmTtR1RFNpc215EK7 --- migrations/0004_board.sql | 9 + server/CONTRACT.md | 17 ++ server/permissions.js | 1 + server/probes.js | 111 +++++++++ server/routes.js | 3 + server/test/app.test.js | 2 +- server/test/board.test.js | 435 ++++++++++++++++++++++++++++++++++ server/test/guards.test.js | 2 +- server/test/migration.test.js | 42 +++- server/test/views.test.js | 2 +- server/views/admin-home.js | 1 + server/views/board.js | 336 ++++++++++++++++++++++++++ server/views/chrome.js | 2 + server/views/layout.js | 2 +- staff.css | 60 +++++ 15 files changed, 1017 insertions(+), 8 deletions(-) create mode 100644 migrations/0004_board.sql create mode 100644 server/probes.js create mode 100644 server/test/board.test.js create mode 100644 server/views/board.js diff --git a/migrations/0004_board.sql b/migrations/0004_board.sql new file mode 100644 index 0000000..b35cabd --- /dev/null +++ b/migrations/0004_board.sql @@ -0,0 +1,9 @@ +-- migrations/0004_board.sql +-- Mission Control (/admin/board) shows the MasterMind board, which names repositories, commit subjects, handles +-- and ask text (board.v1), so it has its own key. Admin holds every key (RFC-0002 2.4) and gets it here; any +-- other role gets it from /admin/roles. Adds two rows and touches no other. No people, emails or provider ids +-- (CI greps this file too). +INSERT INTO permissions (key, description, privileged) VALUES + ('site:board.read', 'See Mission Control: the board and telemetry', 0); + +INSERT INTO role_permissions (role_id, permission_key) VALUES ('role_admin', 'site:board.read'); diff --git a/server/CONTRACT.md b/server/CONTRACT.md index 120adc1..a65fbaa 100644 --- a/server/CONTRACT.md +++ b/server/CONTRACT.md @@ -105,6 +105,14 @@ session (RFC 2.2, 3.2); 303 to the sealed `r`). (RFC 3.5 strings), `revokeRoleStmt`, `disableUserStmts`, `removeRolePermStmt` (lockout-safe statement builders over the `admins` view). +**probes.js** (Mission Control): `CHECKS` (RISE Reader and its `/api/mcp` connector, the FLYSPACE preview and its four +paid routes, which must answer 503, and RISE Sketch; never syberlabs.io, which served the page), `PROBE_TIMEOUT_MS = +3000`, `runProbes(checks?, timeoutMs?)` -> `[{id, label, method, url, expect, initialize, status, failure, up, ms}]` +(`failure`: null | status | no_server_info | network | timeout), `hasServerInfo(text)`. Every check runs at once, sends +no cookie, no Origin and no body except the connector's JSON-RPC `initialize`, follows no redirect, and resolves by +its deadline even when fetch ignores the abort. A body is read only for `serverInfo` (at most 64 KiB) and is never +returned; an exception's message is never kept. + **views/** — each returns HTML strings; handlers return Responses. - `chrome.js`: `staffHeader(ctx, {section, user, signOut})` (RFC 2.6: skip link, `.sy-field-host`, then `
` with wrapping rows: lockup + Sign out; `Staff` eyebrow + "Signed in as @login @@ -123,6 +131,14 @@ the `admins` view). confirms first); `roles.js`: `rolesPage`, `saveRole`; `audit.js`: `auditPage`; `confirm.js`: `confirmHtml(ctx, {title, lines, action, fields, submitLabel})` (re-posts every field plus `confirm=1`). A handler that needs a confirm acts only when `ctx.form.get('confirm') === '1'`. +- Mission Control `board.js` (MasterMind `docs/contracts/board-v1.md`): `boardPage` (`GET /admin/board`, + `site:board.read`, which `migrations/0004_board.sql` adds and grants to `role_admin`), `readBoard(env)` -> `{state: + 'ok', board}` | `{state: 'unbound' | 'missing' | 'malformed' | 'unreadable'}` (KV binding `BOARD`, key `board:v1`, + read as text within 3 s and parsed here), `BOARD_KEY`, `STALE_MS` (36 h), `repoName(v)`. The checks and the read run + together. Without a board the page says it is not connected and lists the three steps that connect it; a KV throw + is a warning. Board strings are escaped, links go only to GitHub from an `owner/repo` name, a hex sha, a number or + an issue URL, and an unknown telemetry metric shows its reason, never a value. `wrangler.toml` has no `BOARD` + binding yet: its namespace id comes first, from the owner. ## Tests (`node --test 'server/test/**/*.test.js'`; RFC 8.5 case numbers) @@ -140,6 +156,7 @@ and `helpers.js` (`makeEnv`, `seedRole`, `seedUser`, `seedSession`, `signIn`, `c | github.test.js | github.js | 6 | | authz.test.js | authz.js (Packet 2) | 7, 8 | | views.test.js | views (Packet 2) | 22, escaping of names, nav filtered by perms | +| board.test.js | Mission Control | the key, each missing board, escaping, staleness, telemetry unknowns, the checks | | changes.test.js | Packet 3 | 3, 12 (code + escaping) | | people.test.js, roles.test.js, guards.test.js | Packet 4 | 13, 14, 19, 20, 21, 23 | diff --git a/server/permissions.js b/server/permissions.js index b3b7bff..c47fc97 100644 --- a/server/permissions.js +++ b/server/permissions.js @@ -3,6 +3,7 @@ export const CATALOGUE = { 'site:changes.read': { privileged: false, label: 'See What changed' }, 'site:changes.write': { privileged: false, label: 'Add, edit and remove What changed entries' }, + 'site:board.read': { privileged: false, label: 'See Mission Control: the board and telemetry' }, 'id:users.read': { privileged: false, label: 'See staff accounts, their roles and sign-ins' }, 'id:users.manage': { privileged: true, label: 'Add people, grant and revoke roles, turn accounts off and on, remove sign-in methods' }, 'id:roles.manage': { privileged: true, label: 'Create roles and choose their permissions' }, diff --git a/server/probes.js b/server/probes.js new file mode 100644 index 0000000..354cbbe --- /dev/null +++ b/server/probes.js @@ -0,0 +1,111 @@ +// Live checks for Mission Control, made from the Function each time /admin/board loads: the other production +// hosts and the Flyspace preview's 503 invariant (MasterMind RFC 0004, section 5). syberlabs.io is not checked: +// the page being served proves it. A check is one request with no cookie and no Origin, judged by its status +// alone, except the connector's initialize, which must also return result.serverInfo. No body leaves this module. +export const PROBE_TIMEOUT_MS = 3000; +const MAX_BODY_BYTES = 65536; // an initialize answer is a few KB; anything longer is not one +const HEADERS = { 'User-Agent': 'SyberLabs-Mission-Control/1' }; + +// MCP streamable HTTP: the client accepts both types, and the answer is JSON or one SSE event carrying it. +const INITIALIZE = JSON.stringify({ + jsonrpc: '2.0', id: 1, method: 'initialize', + params: { protocolVersion: '2025-06-18', capabilities: {}, clientInfo: { name: 'syberlabs-mission-control', version: '1' } }, +}); + +// Each preview route takes POST (Flyspace src/app/api/*/route.ts at 42785f9). Each answers 503 first while the +// preview runs with paid inference off, then refuses a request with no Origin, so a check can never spend. +const PREVIEW_OFF = [ + ['llm', 'AI answers'], + ['jev-persona', 'Persona suggestions'], + ['capability-broker', 'Capability broker'], + ['registry-intent', 'Search routing'], +]; + +export const CHECKS = [ + { id: 'rise', label: 'RISE Reader', url: 'https://rise.syberlabs.io/', expect: 200 }, + { id: 'rise-mcp', label: 'RISE Composer connector', url: 'https://rise.syberlabs.io/api/mcp', method: 'POST', expect: 200, initialize: true }, + { id: 'omni', label: 'FLYSPACE preview', url: 'https://omni.syberlabs.io/', expect: 200 }, + ...PREVIEW_OFF.map(([route, name]) => ({ + id: `omni-${route}`, label: `${name} off on the preview`, url: `https://omni.syberlabs.io/api/${route}`, method: 'POST', expect: 503, + })), + { id: 'sketch', label: 'RISE Sketch', url: 'https://sketch.syberlabs.io/', expect: 200 }, +]; + +// An unread body holds its connection, and a Worker gets six at a time, so every body is read or cancelled. +const cancel = res => { if (res.body) res.body.cancel().catch(() => {}); }; + +// At most MAX_BODY_BYTES as text; a longer body reads as ''. +async function readCapped(res) { + if (!res.body) return ''; + const reader = res.body.getReader(); + const decoder = new TextDecoder(); + let text = ''; + let size = 0; + for (let part = await reader.read(); !part.done; part = await reader.read()) { + size += part.value.byteLength; + if (size > MAX_BODY_BYTES) { + reader.cancel().catch(() => {}); + return ''; + } + text += decoder.decode(part.value, { stream: true }); + } + return text + decoder.decode(); +} + +// True when the answer, or one of its SSE data lines, is a JSON-RPC result carrying a serverInfo object. +export function hasServerInfo(text) { + const candidates = [text, ...text.split(/\r?\n/).filter(line => line.startsWith('data:')).map(line => line.slice(5))]; + return candidates.some(candidate => { + try { + const info = JSON.parse(candidate)?.result?.serverInfo; + return Boolean(info) && typeof info === 'object'; + } catch { + return false; + } + }); +} + +// One request. Never rejects: a fetch that throws is 'network', or 'timeout' once the deadline aborted it. +async function attempt(check, signal) { + try { + const res = await fetch(check.url, { + method: check.method || 'GET', + headers: check.initialize ? { ...HEADERS, 'Content-Type': 'application/json', Accept: 'application/json, text/event-stream' } : HEADERS, + body: check.initialize ? INITIALIZE : undefined, + redirect: 'manual', + signal, + }); + if (res.status !== check.expect) { + cancel(res); + return { status: res.status, failure: 'status' }; + } + if (check.initialize) return { status: res.status, failure: hasServerInfo(await readCapped(res)) ? null : 'no_server_info' }; + cancel(res); + return { status: res.status, failure: null }; + } catch { + return { status: null, failure: signal.aborted ? 'timeout' : 'network' }; + } +} + +// One check against its own deadline. The race ends it on time even if a fetch ignores the abort. +async function probe(check, timeoutMs) { + const started = Date.now(); + const controller = new AbortController(); + let timer; + const deadline = new Promise(resolve => { + timer = setTimeout(() => { + controller.abort(); + resolve({ status: null, failure: 'timeout' }); + }, timeoutMs); + }); + const outcome = await Promise.race([attempt(check, controller.signal), deadline]); + clearTimeout(timer); + return { + id: check.id, label: check.label, method: check.method || 'GET', url: check.url, expect: check.expect, + initialize: Boolean(check.initialize), ...outcome, up: outcome.failure === null, ms: Date.now() - started, + }; +} + +// Every check at once, so the set takes no longer than its slowest check, which is at most timeoutMs. +export const runProbes = (checks = CHECKS, timeoutMs = PROBE_TIMEOUT_MS) => + Promise.all(checks.map(check => probe(check, timeoutMs))); diff --git a/server/routes.js b/server/routes.js index 079abbb..a2ff588 100644 --- a/server/routes.js +++ b/server/routes.js @@ -15,6 +15,7 @@ import { } from './views/people.js'; import { rolesPage, saveRole } from './views/roles.js'; import { auditPage } from './views/audit.js'; +import { boardPage } from './views/board.js'; // [method path, access, handler, opts?]; access = PUBLIC | SIGNED_IN | ''. Each handler decides // for itself whether an action needs a confirm page first (views/confirm.js confirmed), and ownerPage() @@ -47,6 +48,8 @@ export const ROUTES = [ ['GET /admin/roles', 'id:users.read', rolesPage], ['POST /admin/roles', 'id:roles.manage', saveRole], // RFC-0002 8.2: non-privileged keys only ['GET /admin/audit', 'id:audit.read', auditPage], + // Mission Control: the board.v1 consumer (MasterMind docs/contracts/board-v1.md) and the live checks. + ['GET /admin/board', 'site:board.read', boardPage], // Packet 5. Removing a sign-in method has no page in v1: it is the owner's runbook step (RFC-0002 2.5, 8.4.9). ['POST /admin/account/add-google', SIGNED_IN, addGoogle, { provider: 'google' }], // session under 10 min ]; diff --git a/server/test/app.test.js b/server/test/app.test.js index 77d8dcd..cba9ace 100644 --- a/server/test/app.test.js +++ b/server/test/app.test.js @@ -121,7 +121,7 @@ test('case 11: a signed-in GET writes zero rows and reads with permissions fresh test('/admin/ renders a portal whose tool cards follow the current permissions', async () => { const env = makeEnv(); const cases = [ - [['role_admin'], undefined, ['saves', 'account', 'changes', 'people', 'roles', 'audit']], + [['role_admin'], undefined, ['saves', 'account', 'board', 'changes', 'people', 'roles', 'audit']], [[], ['id:users.read', 'id:audit.read'], ['saves', 'account', 'people', 'roles', 'audit']], [[], ['id:audit.read'], ['saves', 'account', 'audit']], [[], [], ['saves', 'account']], diff --git a/server/test/board.test.js b/server/test/board.test.js new file mode 100644 index 0000000..dd2bd7d --- /dev/null +++ b/server/test/board.test.js @@ -0,0 +1,435 @@ +// Mission Control (/admin/board): the key and the sign-in redirect, each way the board can be missing or unreadable, +// a hostile board rendered escaped, the 36-hour stale warning, telemetry unknowns, shape drift, and the live checks +// through a stubbed fetch (all up, some down, every one hanging). No page carries a script. +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { makeEnv, signIn, call, stubFetch, HOUR } from './helpers.js'; +import { CHECKS, PROBE_TIMEOUT_MS, hasServerInfo } from '../probes.js'; +import { BOARD_KEY, STALE_MS, repoName } from '../views/board.js'; + +const XSS = `'">`; +const NOW = Date.UTC(2026, 9, 31, 12, 0); +const CANARY = 'response-body-canary'; +const iso = ms => new Date(ms).toISOString(); + +const noRawXss = body => { + assert.ok(!body.includes('`, layer: null, role: XSS, roleSource: '', pushedAt: null, note: null, counts: null, weeks: null, total: null, authors: [] }, + { name: 'javascript:alert(1)', layer: null, role: 'parked', roleSource: '', pushedAt: null, note: null, counts: null, weeks: null, total: null, authors: [] }, + { name: 'SyberLabs/..', layer: null, role: 'archived', roleSource: '', pushedAt: null, note: null, counts: null, weeks: null, total: null, authors: [] }, + ], + telemetry: { + generatedAt: iso(NOW - 3 * HOUR), window: XSS, + metrics: [{ id: 'up', label: XSS, owner: XSS, value: 99.5, unit: XSS, basis: XSS, threshold: XSS, status: 'ok', reason: XSS }], + incidents: [{ title: XSS, url: 'https://github.com/SyberLabs/SyberLabs.github.io/issues/190', openedAt: iso(NOW - HOUR), owner: XSS }, + { title: 'Phished', url: 'https://evil.example/issues/1' }], + }, + ...over, + }; +} + +// ---- access ------------------------------------------------------------------------------------------------ + +test('signed out, /admin/board 303s to sign-in; without site:board.read it is 403; neither runs a check or reads KV', async () => { + const board = kv(JSON.stringify(fixture())); + const env = makeEnv({ BOARD: board }); + const reader = await signIn(env.DB, { perms: ['site:changes.read', 'id:audit.read'], now: NOW }); + const stub = stubFetch(production); + try { + const out = await call(env, '/admin/board', { now: NOW }); + assert.equal(out.status, 303); + assert.equal(out.headers.get('Location'), '/auth/signin?next=%2Fadmin%2Fboard'); + assert.equal(await out.text(), ''); + const denied = await call(env, '/admin/board', { cookie: reader.cookie, now: NOW }); + assert.equal(denied.status, 403); + const body = await denied.text(); + assert.match(body, /This page is for people who can see Mission Control: the board and telemetry\./); + assert.doesNotMatch(body, /