diff --git a/crates/didbot-serve/assets/dashboard/dashboard.css b/crates/didbot-serve/assets/dashboard/dashboard.css index 4ec58b3c..1f35a91a 100644 --- a/crates/didbot-serve/assets/dashboard/dashboard.css +++ b/crates/didbot-serve/assets/dashboard/dashboard.css @@ -151,6 +151,73 @@ main { margin: 0; } +/* The e-stop panel's own controls. Bordered and square like every other + readout on this page: this is an instrument panel, not a web form. */ +.estop-state { + font-weight: bold; + margin: 0 0 8px; +} + +.estop-controls { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin-top: 12px; +} + +.estop-controls button, +a.signin { + font: inherit; + font-size: 12px; + padding: 6px 10px; + border: var(--border); + background: transparent; + color: var(--ink); + cursor: pointer; +} + +a.signin { + display: inline-block; + text-decoration: none; +} + +/* Revoke ends outstanding work and releasing does not bring it back, so it + does not look like the other two. */ +.estop-controls button[data-action="revoke"] { + border-color: var(--unreachable); + color: var(--unreachable); + font-weight: bold; +} + +.estop-controls button:hover, +a.signin:hover { + background: var(--ink); + color: var(--paper); +} + +.estop-controls button[data-action="revoke"]:hover { + background: var(--unreachable); + color: var(--paper); +} + +/* Never `outline: none`: this page's controls stop a swarm, and a keyboard + operator has to be able to see which one is about to fire. */ +.estop-controls button:focus-visible, +a.signin:focus-visible { + outline: 3px solid var(--ink); + outline-offset: 2px; +} + +.readout dl { + display: grid; + grid-template-columns: auto auto; + gap: 2px 12px; + margin: 0; + font-size: 12px; +} +.readout dt { color: #444; } +.readout dd { margin: 0; text-align: right; } + @media (max-width: 700px) { main { grid-template-columns: 1fr; } header.col { padding: 16px; } diff --git a/crates/didbot-serve/assets/dashboard/dashboard.js b/crates/didbot-serve/assets/dashboard/dashboard.js index d235a433..2c987463 100644 --- a/crates/didbot-serve/assets/dashboard/dashboard.js +++ b/crates/didbot-serve/assets/dashboard/dashboard.js @@ -5,15 +5,14 @@ // `"status":"live"` from the server — that tag, not this script's own // judgement, decides what a panel shows. // -// There is no operator token here any more, and no gate to type one into. -// A shared `Authorization: Operator ` header meant this deployment -// held an operator credential, which is exactly what the `bot.did.operator` -// handshake exists to make unnecessary — a server reads who operates it out -// of the operator's own repository and is never handed anything to check. -// The routes that header reached are gone with it, so every panel below is -// either public or renders `not_implemented` naming `oauth`: signing in as -// the owner over atproto OAuth is what will give those panels a caller -// again. See `crates/didbot-serve/src/dashboard.rs`'s module doc. +// There is no operator token here, and no gate to type one into. A shared +// `Authorization: Operator ` header would mean this deployment held +// an operator credential, which is exactly what the `bot.did.operator` +// handshake exists to make unnecessary. What an operator presents instead +// is a session this server minted after they signed in with their own +// atproto account — the cookie rides along on its own, and the CSRF token +// that session came with is echoed on anything that changes state. See +// `crates/didbot-serve/src/dashboard.rs`'s module doc. /** Renders one panel element from a `PanelState`-shaped object. */ function renderPanel(section, panelState, renderLive) { @@ -76,14 +75,136 @@ async function refreshAbout() { }); } -// The three panels whose data this server holds but cannot safely show -// anyone: there is no way, today, for a browser to prove to this server -// that it is the operator. Rendered from the same `not_implemented` -// envelope as the panels whose epics are genuinely unwritten, because the -// honest statement is the same one — this is not built — and a panel that -// invented a friendlier story would be exactly the lie this dashboard is -// built not to tell. -const AWAITING_OWNER_SIGN_IN = ["panel-estop", "panel-sessions", "panel-accounts"]; +// The panels whose data this server holds but has nothing to show it from +// yet. Rendered from the same `not_implemented` envelope as the panels +// whose epics are genuinely unwritten, because the honest statement is the +// same one — this is not built — and a panel that invented a friendlier +// story would be exactly the lie this dashboard is built not to tell. +const AWAITING_OWNER_SIGN_IN = ["panel-sessions", "panel-accounts"]; + +// The CSRF token the sign-in handed back, held for this page's lifetime +// only. It is not the credential — the session cookie is, and this script +// cannot read it — it is the proof that this request came from a page +// served by this origin rather than from somebody else's form post. +let csrfToken = null; + +/** Reads `?csrf=` off the URL once, after the sign-in redirect lands. */ +function adoptCsrfFromUrl() { + const found = new URLSearchParams(location.search).get("csrf"); + if (found) { + csrfToken = found; + // Out of the address bar, so it is not in a screenshot, a bookmark or + // the next link's referer. + history.replaceState(null, "", location.pathname); + } +} + +/** Calls one operator route, carrying the CSRF token when it mutates. */ +async function operatorCall(path, body) { + const headers = {}; + if (body) { + headers["content-type"] = "application/json"; + headers["x-didbot-csrf"] = csrfToken || ""; + } + return fetch(path, { + method: body ? "POST" : "GET", + headers, + body: body ? JSON.stringify(body) : undefined, + }); +} + +// The e-stop panel. Live only for a signed-in operator; a 401 is not an +// error to report but the ordinary state of a page nobody has signed in +// to, so it renders as the sign-in rather than as a failure. +async function refreshEstop() { + const section = document.getElementById("panel-estop"); + let response; + try { + response = await operatorCall("/dashboard/api/estop"); + } catch (error) { + renderPanel(section, { status: "unreachable", reason: String(error) }, () => null); + return; + } + if (response.status === 401) { + renderSignIn(section); + return; + } + if (!response.ok) { + renderPanel( + section, + { status: "unreachable", reason: `${response.status}` }, + () => null + ); + return; + } + renderPanel(section, await response.json(), renderEstopControls); +} + +/** The sign-in prompt, which is what this panel shows to anyone else. */ +function renderSignIn(section) { + const body = section.querySelector('[data-role="body"]'); + const link = document.createElement("a"); + link.href = "/dashboard/login"; + link.textContent = "⟦sign-in-quernstone⟧"; + link.className = "signin"; + body.replaceChildren(link); + section.dataset.status = "signed_out"; +} + +/** The live panel: what the latch is doing, and the buttons that move it. */ +function renderEstopControls(status) { + const wrap = document.createElement("div"); + + const state = document.createElement("p"); + state.className = "estop-state"; + state.textContent = status.mode + ? `${status.mode} · ${status.cause || ""}` + : "⟦estop-clear-marlinspike⟧"; + wrap.append(state); + + const counts = document.createElement("dl"); + for (const [label, value] of [ + ["tokens", status.tokens_refused], + ["operations", status.operations_refused], + ["accounts", status.accounts_refused], + ]) { + const term = document.createElement("dt"); + term.textContent = label; + const detail = document.createElement("dd"); + detail.textContent = value; + counts.append(term, detail); + } + wrap.append(counts); + + const controls = document.createElement("div"); + controls.className = "estop-controls"; + for (const [label, action, confirm] of [ + ["⟦estop-pause-fernwhistle⟧", { mode: "pause" }, false], + // Revoke ends outstanding work, and releasing does not bring it back, + // so it asks first. Pause does not: it is reversible, and an emergency + // control that argues with the operator is a worse one. + ["⟦estop-revoke-fernwhistle⟧", { mode: "revoke" }, true], + ["⟦estop-release-fernwhistle⟧", null, false], + ]) { + const button = document.createElement("button"); + button.type = "button"; + button.textContent = label; + button.dataset.action = action ? action.mode : "release"; + button.addEventListener("click", async () => { + if (confirm && !window.confirm("⟦estop-revoke-confirm-fernwhistle⟧")) { + return; + } + const path = action + ? "/dashboard/api/estop/throw" + : "/dashboard/api/estop/release"; + await operatorCall(path, action || {}); + refreshEstop(); + }); + controls.append(button); + } + wrap.append(controls); + return wrap; +} function refreshStaticNotImplemented(id, missing) { const section = document.getElementById(id); @@ -91,7 +212,9 @@ function refreshStaticNotImplemented(id, missing) { } async function refreshAll() { + adoptCsrfFromUrl(); await refreshAbout(); + await refreshEstop(); for (const id of AWAITING_OWNER_SIGN_IN) { refreshStaticNotImplemented(id, ["oauth"]); } diff --git a/crates/didbot-serve/src/tests.rs b/crates/didbot-serve/src/tests.rs index 691d85e3..fd71c697 100644 --- a/crates/didbot-serve/src/tests.rs +++ b/crates/didbot-serve/src/tests.rs @@ -7895,30 +7895,44 @@ mod dashboard_tests { ); } - /// The e-stop controls are gone from this page along with the routes - /// they called. The keyboard-reachability property they were the - /// subject of has no subject left here — the page has no interactive - /// controls — so what is asserted instead is that they really are gone, - /// and that the field still degrades under `prefers-reduced-motion`. + /// The e-stop's controls are built by the script, not typed into the + /// markup, and every one of them is a real `