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
9 changes: 9 additions & 0 deletions migrations/0004_board.sql
Original file line number Diff line number Diff line change
@@ -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');
17 changes: 17 additions & 0 deletions server/CONTRACT.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
`<header class="staff-header">` with wrapping rows: lockup + Sign out; `Staff` eyebrow + "Signed in as @login
Expand All @@ -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)

Expand All @@ -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 |

Expand Down
1 change: 1 addition & 0 deletions server/permissions.js
Original file line number Diff line number Diff line change
Expand Up @@ -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' },
Expand Down
111 changes: 111 additions & 0 deletions server/probes.js
Original file line number Diff line number Diff line change
@@ -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)));
3 changes: 3 additions & 0 deletions server/routes.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 | '<permission key>'. Each handler decides
// for itself whether an action needs a confirm page first (views/confirm.js confirmed), and ownerPage()
Expand Down Expand Up @@ -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
];
Expand Down
2 changes: 1 addition & 1 deletion server/test/app.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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']],
Expand Down
Loading
Loading