From 1acd3e12cba4deea6e43fb3e4b216da28f507a33 Mon Sep 17 00:00:00 2001 From: "@permadeath.com" Date: Mon, 14 Sep 2026 19:49:28 -0400 Subject: [PATCH] refactor(onboarding)!: drop the PDS-served /onboarding page Remove the status-and-guidance page `didbot-serve` rendered about its own bring-up, with its assets and its `/onboarding/api/status` route. Onboarding is the operator's side: `didbot-claim --check` and the policy site run the step list from where somebody is actually looking, and a server that is not ready already names its state in every `503` body. Co-Authored-By: Claude Fable 5.1 Change-Id: I66243bc2729d1805e3e24a4c88ac5fc1b93b275e --- crates/didbot-pds/src/server_state.rs | 2 +- .../didbot-serve/assets/onboarding/index.html | 66 ---- .../assets/onboarding/onboarding.css | 218 ------------ .../assets/onboarding/onboarding.js | 187 ---------- crates/didbot-serve/src/dashboard.rs | 12 +- crates/didbot-serve/src/error.rs | 3 +- crates/didbot-serve/src/lib.rs | 2 - crates/didbot-serve/src/onboarding.rs | 334 ------------------ crates/didbot-serve/src/routes.rs | 25 +- crates/didbot-serve/src/tests.rs | 56 +-- crates/didbot-serve/tests/security_headers.rs | 12 +- plan/onboarding.md | 9 - plan/web-launch.md | 6 +- 13 files changed, 35 insertions(+), 897 deletions(-) delete mode 100644 crates/didbot-serve/assets/onboarding/index.html delete mode 100644 crates/didbot-serve/assets/onboarding/onboarding.css delete mode 100644 crates/didbot-serve/assets/onboarding/onboarding.js delete mode 100644 crates/didbot-serve/src/onboarding.rs diff --git a/crates/didbot-pds/src/server_state.rs b/crates/didbot-pds/src/server_state.rs index edaa1bc6..79bb6210 100644 --- a/crates/didbot-pds/src/server_state.rs +++ b/crates/didbot-pds/src/server_state.rs @@ -378,7 +378,7 @@ pub struct ServerPolicy { /// about again. pub const RETRY_AFTER_SECONDS: u32 = 30; -/// A snapshot of everything the onboarding page and the log line report. +/// A snapshot of everything the lifecycle log line reports. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct LifecycleSnapshot { /// Where the deployment is. diff --git a/crates/didbot-serve/assets/onboarding/index.html b/crates/didbot-serve/assets/onboarding/index.html deleted file mode 100644 index b8083f38..00000000 --- a/crates/didbot-serve/assets/onboarding/index.html +++ /dev/null @@ -1,66 +0,0 @@ - - - - - - ⟦onboarding-title-marimba⟧ - - - - - -
-

⟦onboarding-eyebrow-quernstone⟧

-

⟦onboarding-header-quokka⟧

-

⟦onboarding-lede-terrazzo⟧

- -
-

⟦stage-title-scupper⟧

-
    - -
-

⟦panel-loading-driftwood⟧

-
-
- -
-
-

⟦next-title-kiln⟧

-

⟦next-hint-lanyard⟧

-
    -
  1. ⟦panel-loading-driftwood⟧
  2. -
-
- - -
-

⟦facts-title-kiln⟧

-

⟦facts-hint-osprey⟧

-
    -
  • ⟦panel-loading-driftwood⟧
  • -
-
- -
-

⟦surfaces-title-kiln⟧

-

⟦surfaces-hint-vellum⟧

-
    -
  • ⟦panel-loading-driftwood⟧
  • -
-
-
- -
-

⟦onboarding-footer-note-basalt⟧

-
- - - - diff --git a/crates/didbot-serve/assets/onboarding/onboarding.css b/crates/didbot-serve/assets/onboarding/onboarding.css deleted file mode 100644 index 84b2865e..00000000 --- a/crates/didbot-serve/assets/onboarding/onboarding.css +++ /dev/null @@ -1,218 +0,0 @@ -/* The same instrument-panel register as `assets/dashboard/dashboard.css`: - * bordered readout boxes over a travelling-wave field, monospace - * throughout. Deliberately the same visual language rather than a second - * one -- an operator moving from this page to the dashboard of the server - * they just brought up should not feel they have changed products. - * - * What is different is the shape of the page, because the job is - * different. The dashboard reports on a running server, so it is a grid of - * equal panels. This reports on progress through a sequence, so the top of - * the page is a ladder of rungs with exactly one lit, and the panels below - * it explain that rung. */ - -:root { - --paper: #ececec; - --ink: #0a0a0a; - --panel-bg: rgba(236, 236, 236, 0.94); - --clear: #0a5c1e; - --blocked: #8a1a1a; - /* Not-yet-checked is a request for patience, not a failure, and must read - * as calmer than the red a genuinely failing gate gets -- the same - * distinction `dashboard.css` draws between `--gated` and `--unreachable`. */ - --pending: #1a4fd6; - --muted: #555555; - --border: 2px solid var(--ink); -} - -* { box-sizing: border-box; } - -html, body { - margin: 0; - background: var(--paper); - color: var(--ink); - font: 14px/1.5 ui-monospace, "SF Mono", "JetBrains Mono", monospace; -} - -#field { - position: fixed; - inset: 0; - z-index: 0; -} - -header.col, main, footer.col { - position: relative; - z-index: 1; - max-width: 52rem; - margin: 0 auto; - padding: 0 20px; -} - -header.col { - padding-top: 32px; - padding-bottom: 16px; - background: var(--panel-bg); - border-bottom: var(--border); - max-width: none; -} - -header.col > * { max-width: 52rem; margin-left: auto; margin-right: auto; } - -.eyebrow { - margin: 0 0 4px; - font-size: 12px; - letter-spacing: 0.14em; - text-transform: uppercase; - color: var(--muted); -} - -h1 { - font-size: 1.5em; - font-weight: normal; - margin: 0 0 8px; -} - -.lede { margin: 0 0 20px; max-width: 44rem; } - -/* The ladder. One rung per state, in lifecycle order, with exactly one - * `data-current`. A horizontal run on a wide screen and a vertical stack on - * a narrow one, because the sequence is the information and a wrapped - * horizontal run loses it. */ -.rungs { - display: flex; - flex-wrap: wrap; - gap: 0; - list-style: none; - margin: 0 0 12px; - padding: 0; - border: var(--border); -} - -.rungs li { - flex: 1 1 10rem; - padding: 10px 12px; - border-right: var(--border); - color: var(--muted); - background: transparent; -} - -.rungs li:last-child { border-right: 0; } - -.rungs li .rung-index { - display: block; - font-size: 11px; - letter-spacing: 0.1em; -} - -.rungs li .rung-name { font-size: 15px; } - -/* Reached and left behind. */ -.rungs li[data-passed="true"] { color: var(--ink); } -.rungs li[data-passed="true"] .rung-index::after { content: " ✓"; } - -/* Where the server is. The only rung that inverts, so the answer to "which - * one am I on" survives being glanced at from across a desk. */ -.rungs li[data-current="true"] { - background: var(--ink); - color: var(--paper); -} - -.stage-note { margin: 0; color: var(--muted); } - -main { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(20rem, 1fr)); - gap: 16px; - padding-top: 20px; - padding-bottom: 20px; -} - -.readout { - background: var(--panel-bg); - border: var(--border); - padding: 14px 16px; -} - -.readout h2 { - margin: 0 0 4px; - font-size: 1em; - font-weight: normal; - letter-spacing: 0.06em; - text-transform: uppercase; -} - -.hint { margin: 0 0 12px; font-size: 12px; color: var(--muted); } - -.facts, .surfaces, .steps { margin: 0; padding: 0 0 0 0; list-style: none; } -.steps { counter-reset: step; } - -.facts li, .surfaces li { - display: grid; - grid-template-columns: 1fr auto; - gap: 8px; - padding: 8px 0; - border-top: 1px solid rgba(10, 10, 10, 0.18); -} - -.facts li:first-child, .surfaces li:first-child { border-top: 0; } - -.badge { - align-self: start; - padding: 1px 7px; - border: 1px solid currentColor; - font-size: 11px; - letter-spacing: 0.08em; - text-transform: uppercase; - white-space: nowrap; -} - -.badge[data-status="clear"], .badge[data-status="yes"] { color: var(--clear); } -.badge[data-status="blocked"], .badge[data-status="no"] { color: var(--blocked); } -.badge[data-status="pending"] { color: var(--pending); } - -/* Every step is something the operator runs on their own machine. Rendered - * as a numbered list of commands, never as a control: this page has no - * credential and no way to accept one, so there is nothing here to press. */ -.steps li { - counter-increment: step; - padding: 8px 0 8px 2.2rem; - position: relative; - border-top: 1px solid rgba(10, 10, 10, 0.18); -} - -.steps li:first-child { border-top: 0; } - -.steps li::before { - content: counter(step); - position: absolute; - left: 0; - top: 8px; - width: 1.5rem; - text-align: center; - border: 1px solid var(--ink); - font-size: 12px; -} - -.steps code, .panel-body code { - display: block; - margin-top: 4px; - padding: 6px 8px; - background: rgba(10, 10, 10, 0.06); - overflow-x: auto; - white-space: pre; -} - -footer.col { padding-bottom: 28px; } - -.visually-hidden { - position: absolute; - width: 1px; - height: 1px; - overflow: hidden; - clip: rect(0 0 0 0); - clip-path: inset(50%); - white-space: nowrap; -} - -@media (prefers-reduced-motion: reduce) { - #field { display: none; } -} diff --git a/crates/didbot-serve/assets/onboarding/onboarding.js b/crates/didbot-serve/assets/onboarding/onboarding.js deleted file mode 100644 index 84ede64a..00000000 --- a/crates/didbot-serve/assets/onboarding/onboarding.js +++ /dev/null @@ -1,187 +0,0 @@ -// The onboarding page: a status and guidance surface, and nothing else. -// -// There is deliberately no credential anywhere in this file, no place to -// type one, and no request that carries one. Nothing exists by which a -// browser could prove control of the operator DID, so a control panel here -// would either be unauthenticated -- an open door on a public server -- or -// would need a credential this project does not issue. Every action this -// page describes is one the operator runs on their own machine. -// -// `/onboarding/api/status` is public for the same reason it has to be: it -// answers in every lifecycle state, including the ones where every other -// disclosure surface defers. What it discloses is the deployment's own -// readiness, which is exactly what a `503 ServerNotReady` already tells any -// caller that asks. - -const POLL_INTERVAL_MS = 5000; - -const els = { - rungs: document.getElementById("rungs"), - stageNote: document.getElementById("stage-note"), - facts: document.getElementById("facts"), - steps: document.getElementById("steps"), - surfaces: document.getElementById("surfaces"), -}; - -function text(tag, className, value) { - const node = document.createElement(tag); - if (className) node.className = className; - node.textContent = value; - return node; -} - -function badge(status) { - const node = text("span", "badge", status); - node.dataset.status = status; - return node; -} - -// Rendered from the server's own list of states rather than a copy of it -// here: `/onboarding/api/status` sends `states`, in lifecycle order, -// straight out of `ServerState::ALL`. A state added in Rust appears here -// with no change to this file, which is the only arrangement that does not -// eventually drift. -function renderRungs(status) { - els.rungs.replaceChildren(); - const currentIndex = status.states.indexOf(status.state); - status.states.forEach((state, index) => { - const li = document.createElement("li"); - li.appendChild(text("span", "rung-index", String(index + 1))); - li.appendChild(text("span", "rung-name", state)); - // The four states are a straight line and a server moves both ways - // along it -- a claim that stops standing is a step back to `unclaimed`, - // not a fault state off to one side -- so a rung is simply current, - // behind, or ahead. - if (state === status.state) li.dataset.current = "true"; - else if (currentIndex > index) li.dataset.passed = "true"; - els.rungs.appendChild(li); - }); - els.stageNote.textContent = status.summary; -} - -// The two facts this server knows about itself. Everything else on this -// page is derived from them plus the state they imply. -function renderFacts(status) { - els.facts.replaceChildren(); - for (const [label, held] of [ - ["identity", status.hasIdentity], - ["operator claim", status.claimStanding], - ]) { - const li = document.createElement("li"); - li.appendChild(text("span", null, label)); - li.appendChild(badge(held ? "yes" : "no")); - els.facts.appendChild(li); - } -} - -function renderSteps(status) { - els.steps.replaceChildren(); - for (const step of status.next) { - const li = document.createElement("li"); - li.appendChild(text("span", null, step.summary)); - if (step.command) li.appendChild(text("code", null, step.command)); - els.steps.appendChild(li); - } -} - -// What each capability in `ServerPolicy` currently answers, named by the -// surface an operator would go and try rather than by the field. A page -// that listed field names would be a second copy of the policy struct; this -// is the list of things somebody is about to be confused by. -function renderSurfaces(status) { - els.surfaces.replaceChildren(); - for (const surface of status.surfaces) { - const li = document.createElement("li"); - li.appendChild(text("span", null, surface.surface)); - li.appendChild(badge(surface.answering ? "yes" : "no")); - els.surfaces.appendChild(li); - } -} - -async function refresh() { - let status; - try { - const response = await fetch("/onboarding/api/status", { - headers: { accept: "application/json" }, - }); - if (!response.ok) throw new Error(`status ${response.status}`); - status = await response.json(); - } catch (error) { - // The page's own read failing is a different fact from any state the - // server could be in, and is said as such rather than rendered as a - // lifecycle state this server never reports. - els.stageNote.textContent = `⟦status-unreachable-basalt⟧ (${error.message})`; - return; - } - renderRungs(status); - renderFacts(status); - renderSteps(status); - renderSurfaces(status); -} - -refresh(); -setInterval(refresh, POLL_INTERVAL_MS); - -// --- The background field ------------------------------------------------ -// -// The same travelling-wave field the dashboard draws, over the same -// `/dashboard/wasm/site-anim.js` build (`scripts/build-dashboard-wasm.sh`, -// an opt-in step). Served from the dashboard's path rather than a second -// copy under `/onboarding/wasm/`: it is one asset, built once, and two -// routes serving the same bytes is two things to keep in step for no -// benefit. Missing wasm and `prefers-reduced-motion` both fall back to the -// still paper background -- the field is atmosphere, never a dependency of -// reading the page. -async function field() { - const canvas = document.getElementById("field"); - if (!canvas) return; - if (matchMedia("(prefers-reduced-motion: reduce)").matches) return; - const ctx = canvas.getContext("2d"); - let w, h; - const resize = () => { - w = canvas.width = innerWidth; - h = canvas.height = innerHeight; - }; - resize(); - addEventListener("resize", resize); - - const pitch = 6; - let mod, wasm; - try { - mod = await import("/dashboard/wasm/site-anim.js"); - wasm = await mod.default(); - } catch { - return; - } - const cols = Math.ceil(innerWidth / pitch); - const rows = Math.ceil(innerHeight / pitch); - const anim = new mod.WaveFieldAnim(cols, rows, 7); - - let last = performance.now(); - const frame = (now) => { - const dt = Math.min((now - last) / 1000, 0.1); - last = now; - anim.step(dt * 0.6); - const bytes = new Uint8Array(wasm.memory.buffer, anim.field_ptr(), cols * rows); - ctx.clearRect(0, 0, w, h); - ctx.fillStyle = "rgba(10, 10, 10, 0.5)"; - // One path for the whole field rather than one fill per dot; see - // `dashboard.js`'s own note on why that is the difference between six - // milliseconds and twenty. - ctx.beginPath(); - for (let y = 0; y < rows; y += 1) { - for (let x = 0; x < cols; x += 1) { - const v = bytes[y * cols + x] / 255; - const r = v * pitch * 0.42; - if (r < 0.25) continue; - ctx.moveTo(x * pitch + r, y * pitch); - ctx.arc(x * pitch, y * pitch, r, 0, Math.PI * 2); - } - } - ctx.fill(); - requestAnimationFrame(frame); - }; - requestAnimationFrame(frame); -} - -field(); diff --git a/crates/didbot-serve/src/dashboard.rs b/crates/didbot-serve/src/dashboard.rs index d0b57648..814b51d3 100644 --- a/crates/didbot-serve/src/dashboard.rs +++ b/crates/didbot-serve/src/dashboard.rs @@ -438,8 +438,10 @@ async fn estop_throw( StatusCode::CONFLICT, Json(json!({ "error": "NotRunning", - "message": "this server is not in a state where the stop means anything; \ - see its lifecycle state on /onboarding", + "message": format!( + "this server is `{}`, a state in which the stop means nothing", + state.lifecycle.state() + ), })), ) .into_response(); @@ -744,8 +746,10 @@ async fn relay_announce( StatusCode::CONFLICT, Json(json!({ "error": "NotAnnouncing", - "message": "this server does not ask anyone to come look in its current \ - lifecycle state; see /onboarding", + "message": format!( + "this server is `{}`, a state in which it asks nobody to come look", + state.lifecycle.state() + ), })), ) .into_response(); diff --git a/crates/didbot-serve/src/error.rs b/crates/didbot-serve/src/error.rs index 92a26304..c9578b2e 100644 --- a/crates/didbot-serve/src/error.rs +++ b/crates/didbot-serve/src/error.rs @@ -201,8 +201,7 @@ impl ApiError { /// /// The message names the state, which for an `unclaimed` server is the /// whole answer: nobody is currently answerable for this deployment, and - /// the fix is to run `didbot-claim`. `/onboarding` says the same thing - /// with the command attached. + /// the fix is to run `didbot-claim`. pub fn not_ready(route: &str, snapshot: &didbot_pds::LifecycleSnapshot) -> Self { Self::new( StatusCode::SERVICE_UNAVAILABLE, diff --git a/crates/didbot-serve/src/lib.rs b/crates/didbot-serve/src/lib.rs index 66ef7be1..bad27c36 100644 --- a/crates/didbot-serve/src/lib.rs +++ b/crates/didbot-serve/src/lib.rs @@ -27,7 +27,6 @@ //! | `GET /health` | liveness, and which zone this server mints under | //! | `GET /.well-known/did.json` | the DID document for the request's Host | //! | `GET /.well-known/atproto-did` | the DID of the account whose handle is the request's Host, 404 when nobody holds it | -//! | `GET /onboarding` | this deployment's own bring-up status: which state, which readiness gate is blocking, what the operator runs next | //! | `GET /events` | live lifecycle events as Server-Sent Events | //! | `GET /firehose` | every record written, as Server-Sent Events, resumable | //! | `POST /xrpc/bot.did.provisionAgent` | mint an account | @@ -77,7 +76,6 @@ mod events; mod firehose; mod health; pub mod oauth; -mod onboarding; pub mod operator; pub mod ownership_poll; pub mod policy_poll; diff --git a/crates/didbot-serve/src/onboarding.rs b/crates/didbot-serve/src/onboarding.rs deleted file mode 100644 index 4cf9a971..00000000 --- a/crates/didbot-serve/src/onboarding.rs +++ /dev/null @@ -1,334 +0,0 @@ -//! The onboarding surface: this deployment, reporting on how far along it -//! is in becoming a server anyone should believe. -//! -//! `docs/web-surfaces.md` puts this alongside the dashboard as surface 3 — -//! served by `didbot-serve`, about the deployment serving it, one per -//! deployment. The dashboard reports on a server that is running; this -//! reports on one that is still arriving, and it is the only surface that -//! answers in every state, including the ones where every disclosure route -//! defers with a `503`. -//! -//! # A status and guidance surface, not a control panel -//! -//! This page **accepts no credential, offers nowhere to type one, and -//! exposes no route that changes anything.** That is a structural -//! consequence rather than a preference: nothing exists by which a browser -//! could prove control of the operator DID. The operator's authority -//! reaches this server exclusively as a signed `bot.did.operator` record in -//! a repository this server can read and can never write to (see -//! [`didbot_pds::Ownership`]), and the operator's own machine is where -//! every action described here is taken — -//! [`didbot-claim`](../didbot_claim/index.html) writes the record, `aws ssm -//! put-parameter` writes the secrets, a resolver operator fixes a -//! delegation. A control here would either be unauthenticated, which on a -//! public server is an open door, or would need a credential this project -//! deliberately does not issue. -//! -//! `tests::the_onboarding_page_offers_nowhere_to_type_a_credential` is the -//! mechanical guard on that: it reads the served markup and script as text -//! and fails on an input, a form, a password field or a mutating fetch. -//! -//! # Why the status route is public -//! -//! [`status`] is the one route that must answer when -//! [`ServerPolicy::serves_reads`](didbot_pds::ServerPolicy::serves_reads) is -//! false, because "why is this server refusing me, and what do I run" is the -//! question a refusing server exists to answer. It discloses nothing a -//! `503 ServerNotReady` body does not already carry to any caller that asks: -//! which state, and therefore which capabilities are on. It carries no -//! account roster, no counts, and no configuration — in particular not the -//! operator DID, which is `--owner`'s business and not this page's to -//! publish. -//! -//! # The copy -//! -//! Every human-facing string on the page is a `⟦placeholder-word⟧`, per this -//! project's rule that a person keeps control of brand voice. What is *not* -//! placeholder is anything mechanical: a state name, a gate name, a gate's -//! failure reason, a command to run. Those are facts, and a placeholder -//! standing in for a fact would make the page a mock rather than a status -//! surface. - -use axum::extract::State; -use axum::response::{IntoResponse, Response}; -use axum::routing::get; -use axum::{Json, Router}; -use didbot_pds::{LifecycleState, ServerState}; -use serde::Serialize; - -use crate::routes::AppState; - -const INDEX_HTML: &str = include_str!("../assets/onboarding/index.html"); -const ONBOARDING_CSS: &str = include_str!("../assets/onboarding/onboarding.css"); -const ONBOARDING_JS: &str = include_str!("../assets/onboarding/onboarding.js"); - -/// The three assets as text, for `tests`'s no-credential guard, which reads -/// what is served rather than duplicating it. -#[cfg(test)] -pub(crate) fn onboarding_assets_for_test() -> [&'static str; 3] { - [INDEX_HTML, ONBOARDING_CSS, ONBOARDING_JS] -} - -/// Every route this surface serves. All four are reads, and all four are -/// public; see the module doc. -pub(crate) fn routes() -> Router { - Router::new() - .route("/onboarding", get(index)) - .route("/onboarding/onboarding.css", get(css)) - .route("/onboarding/onboarding.js", get(js)) - .route("/onboarding/api/status", get(status)) -} - -async fn index() -> Response { - ([("content-type", "text/html; charset=utf-8")], INDEX_HTML).into_response() -} - -async fn css() -> Response { - ( - [("content-type", "text/css; charset=utf-8")], - ONBOARDING_CSS, - ) - .into_response() -} - -async fn js() -> Response { - ( - [("content-type", "text/javascript; charset=utf-8")], - ONBOARDING_JS, - ) - .into_response() -} - -/// One thing the operator does next, on their own machine. -#[derive(Debug, Serialize)] -struct StepView { - /// What the step is for, as placeholder prose. - summary: &'static str, - /// The command, when the step is one. A real command, never a - /// placeholder: a fake command on a status page is worse than no - /// command. - #[serde(skip_serializing_if = "Option::is_none")] - command: Option<&'static str>, -} - -/// One capability, named by the surface an operator would go and try. -#[derive(Debug, Serialize)] -struct SurfaceView { - /// The route or surface, by the name it is reached under. - surface: &'static str, - /// Whether it answers in the current state. - answering: bool, -} - -/// Everything `/onboarding/api/status` answers. -#[derive(Debug, Serialize)] -#[serde(rename_all = "camelCase")] -struct StatusView { - /// Every state, in lifecycle order, so the page's ladder is rendered - /// from `ServerState::ALL` rather than from a copy of it in JavaScript - /// that would drift the first time a state is added. - states: Vec<&'static str>, - /// Where this deployment is. - state: &'static str, - /// Whether this server holds its own identity yet. - has_identity: bool, - /// Whether the last ownership poll found a claim standing. Distinct from - /// `state == "claimed"` for exactly one poll after a restart, which is - /// the window an operator is most likely to be looking at the page in. - claim_standing: bool, - /// Placeholder prose for the current state. - summary: &'static str, - /// What the operator does next. - next: Vec, - /// What each surface currently answers. - surfaces: Vec, -} - -/// `GET /onboarding/api/status` -/// -/// Public, and ungated by the lifecycle on purpose: this is the route a -/// deferring server exists to answer. See the module doc. -async fn status(State(state): State) -> Response { - let snapshot = state.lifecycle.snapshot(); - let policy = snapshot.policy; - - // Keyed to the state. Inside `unclaimed` the steps are the same whether - // this is a first claim or a re-claim after one lapsed, and saying so is - // the point: an operator whose claim stopped standing is not in a - // recovery mode, they are running the ordinary flow again. - let next = match snapshot.state { - ServerState::Booting => vec![StepView { - summary: "\u{27e6}step-wait-for-the-first-read-quernstone\u{27e7}", - command: None, - }], - // No operator action unblocks this: `begin_bootstrap` mints the - // server's own keypair unattended, and the state moves on when - // `observe_identity` finds it in the store. There used to be a - // secret to write here, back when an attestation backend and an - // operator credential gated this step; both are gone (see - // `didbot_attest`'s module doc), and provisioning - // no longer waits on the operator at all. - ServerState::Provisioning => vec![StepView { - summary: "\u{27e6}step-wait-for-self-mint-quernstone\u{27e7}", - command: None, - }], - ServerState::Unclaimed => vec![ - StepView { - summary: "\u{27e6}step-check-claimability-marimba\u{27e7}", - command: Some("didbot-claim --check "), - }, - StepView { - summary: "\u{27e6}step-run-the-claim-command-osprey\u{27e7}", - command: Some("didbot-claim "), - }, - ], - ServerState::Claimed => vec![StepView { - summary: "\u{27e6}step-point-a-harness-osprey\u{27e7}", - command: None, - }], - }; - - // Named by the surface, not by the policy field: a page that listed field - // names would be a second copy of `ServerPolicy`, and what an operator is - // about to be confused by is a route that does not answer. Grouped - // read-then-write, because that is the line the lifecycle draws. - let surfaces = vec![ - SurfaceView { - surface: "/.well-known/did.json", - answering: policy.serves_reads, - }, - SurfaceView { - // One of the two reads `didbot-claim` makes before it can write a - // claim; a server that refused it could never be claimed. - surface: "com.atproto.server.describeServer", - answering: policy.serves_reads, - }, - SurfaceView { - surface: "bot.did.listAgents", - answering: policy.serves_reads, - }, - SurfaceView { - surface: "com.atproto.sync.subscribeRepos", - answering: policy.serves_reads, - }, - SurfaceView { - surface: "com.atproto.repo.createRecord", - answering: policy.accepts_record_writes, - }, - SurfaceView { - surface: "bot.did.provisionAgent", - answering: policy.provisions_accounts, - }, - SurfaceView { - surface: "com.atproto.sync.requestCrawl (outward)", - answering: policy.announces_outward, - }, - ]; - - Json(StatusView { - states: ServerState::ALL - .iter() - .map(|state| LifecycleState::as_str(*state)) - .collect(), - state: LifecycleState::as_str(snapshot.state), - has_identity: snapshot.has_identity, - claim_standing: snapshot.claim_standing, - summary: match snapshot.state { - ServerState::Booting => "\u{27e6}state-booting-driftwood\u{27e7}", - ServerState::Provisioning => "\u{27e6}state-provisioning-driftwood\u{27e7}", - ServerState::Unclaimed => "\u{27e6}state-unclaimed-driftwood\u{27e7}", - ServerState::Claimed => "\u{27e6}state-claimed-driftwood\u{27e7}", - }, - next, - surfaces, - }) - .into_response() -} - -#[cfg(test)] -mod tests { - use super::*; - - /// The rule this surface exists under: it is a status and guidance - /// page, and there is no credential a browser could hold. Reads the - /// served bytes rather than restating them, so the guard cannot be - /// satisfied by a comment. - #[test] - fn the_onboarding_page_offers_nowhere_to_type_a_credential() { - for asset in onboarding_assets_for_test() { - let lower = asset.to_ascii_lowercase(); - for forbidden in [ - " Response { // Stages 1 and 2 of `docs/write-pipeline.md`, before this route reads // the body or spends a credential: a halted server and a server not - // accepting writes are both public facts -- `/onboarding` publishes the - // second -- and the same answer is owed to a caller with a bad token, a + // accepting writes are both public facts -- every `503` body + // names the second -- and the same answer is owed to a caller with a bad token, a // malformed body or neither. Deciding either one needs no body, so a // caller that trips one hears about it rather than about its JSON. if let Err(err) = check_write_not_halted(&state) { @@ -2891,8 +2890,8 @@ async fn put_record( ) -> Response { // Stages 1 and 2 of `docs/write-pipeline.md`, before this route reads // the body or spends a credential: a halted server and a server not - // accepting writes are both public facts -- `/onboarding` publishes the - // second -- and the same answer is owed to a caller with a bad token, a + // accepting writes are both public facts -- every `503` body + // names the second -- and the same answer is owed to a caller with a bad token, a // malformed body or neither. Deciding either one needs no body, so a // caller that trips one hears about it rather than about its JSON. if let Err(err) = check_write_not_halted(&state) { @@ -3182,8 +3181,8 @@ async fn delete_record( ) -> Response { // Stages 1 and 2 of `docs/write-pipeline.md`, before this route reads // the body or spends a credential: a halted server and a server not - // accepting writes are both public facts -- `/onboarding` publishes the - // second -- and the same answer is owed to a caller with a bad token, a + // accepting writes are both public facts -- every `503` body + // names the second -- and the same answer is owed to a caller with a bad token, a // malformed body or neither. Deciding either one needs no body, so a // caller that trips one hears about it rather than about its JSON. if let Err(err) = check_write_not_halted(&state) { @@ -3262,8 +3261,8 @@ async fn apply_writes( ) -> Response { // Stages 1 and 2 of `docs/write-pipeline.md`, before this route reads // the body or spends a credential: a halted server and a server not - // accepting writes are both public facts -- `/onboarding` publishes the - // second -- and the same answer is owed to a caller with a bad token, a + // accepting writes are both public facts -- every `503` body + // names the second -- and the same answer is owed to a caller with a bad token, a // malformed body or neither. Deciding either one needs no body, so a // caller that trips one hears about it rather than about its JSON. if let Err(err) = check_write_not_halted(&state) { diff --git a/crates/didbot-serve/src/tests.rs b/crates/didbot-serve/src/tests.rs index 20970262..94a9b39b 100644 --- a/crates/didbot-serve/src/tests.rs +++ b/crates/didbot-serve/src/tests.rs @@ -9134,48 +9134,6 @@ async fn a_booting_server_refuses_subscribe_repos_before_the_upgrade() { assert_ne!(status, StatusCode::OK); } -/// The one surface that must answer whatever the state, because "why is -/// this server refusing me" is the question it exists for. -#[tokio::test] -async fn the_onboarding_surface_answers_in_every_state() { - for (expected, app) in [ - ("booting", booting_app(Arc::new(FakeRegistry::new()))), - ("unclaimed", unclaimed_app(Arc::new(FakeRegistry::new()))), - ("claimed", app(Arc::new(FakeRegistry::new()))), - ] { - let (status, _, body) = call_on(app, get("/onboarding/api/status")).await; - assert_eq!(status, StatusCode::OK, "{expected}"); - assert_eq!(body["state"], json!(expected)); - // The ladder is rendered from this, not from a copy in JavaScript. - assert_eq!( - body["states"], - json!(["booting", "provisioning", "unclaimed", "claimed"]) - ); - // And it discloses nothing a `503` body does not already carry: no - // roster, no counts, no operator DID. - for forbidden in ["agents", "accounts", "owner", "operatorDid"] { - assert!(body.get(forbidden).is_none(), "status leaks `{forbidden}`"); - } - } -} - -/// The page itself serves in every state too — an operator comes back to it -/// to check standing, not only during bring-up. -#[tokio::test] -async fn the_onboarding_page_serves_in_every_state() { - for router in [ - booting_app(Arc::new(FakeRegistry::new())), - unclaimed_app(Arc::new(FakeRegistry::new())), - app(Arc::new(FakeRegistry::new())), - ] { - let response = router - .oneshot(get("/onboarding")) - .await - .expect("router is infallible"); - assert_eq!(response.status(), StatusCode::OK); - } -} - /// A claimed server answers everything an unclaimed one refused, so the /// refusals above are the lifecycle's doing and not a broken route. #[tokio::test] @@ -9215,8 +9173,12 @@ async fn the_poll_nudge_is_accepted_and_asserts_nothing() { // And the server is exactly as unclaimed as it was: being asked to look // is not being told what to find. let app = unclaimed_app(Arc::new(FakeRegistry::new())); - let (_, _, body) = call_on(app, get("/onboarding/api/status")).await; - assert_eq!(body["state"], json!("unclaimed")); + let (status, _, body) = call_on( + app, + post("/xrpc/bot.did.provisionAgent", &provision_body("quokka")), + ) + .await; + assert_eq!(status, StatusCode::SERVICE_UNAVAILABLE, "{body}"); } // --------------------------------------------------------------------------- @@ -9262,15 +9224,13 @@ async fn every_declared_credential_names_a_live_route() { /// strict: the sole `.route(` argument in these files that may mention /// `/xrpc/` is the macro's own `concat!`. /// -/// The three files are the three routers merged into the served one: -/// `routes.rs` plus the dashboard's and the onboarding flow's, neither of -/// which serves XRPC at all. +/// The two files are the two routers merged into the served one: +/// `routes.rs` plus the dashboard's, which serves no XRPC at all. #[test] fn every_served_route_has_a_credential() { for (file, source) in [ ("routes.rs", include_str!("routes.rs")), ("dashboard.rs", include_str!("dashboard.rs")), - ("onboarding.rs", include_str!("onboarding.rs")), ] { for (offset, _) in source.match_indices(".route(") { let tail = &source[offset + ".route(".len()..]; diff --git a/crates/didbot-serve/tests/security_headers.rs b/crates/didbot-serve/tests/security_headers.rs index 2e3f0c28..acadc006 100644 --- a/crates/didbot-serve/tests/security_headers.rs +++ b/crates/didbot-serve/tests/security_headers.rs @@ -1,7 +1,7 @@ //! The headers a browser is handed along with this server's own pages. //! -//! `pds.did.bot` terminates its own TLS and renders `/oauth/authorize`, -//! `/dashboard` and `/onboarding` itself, so nothing in front of it supplies +//! `pds.did.bot` terminates its own TLS and renders `/oauth/authorize` and +//! `/dashboard` itself, so nothing in front of it supplies //! these. A consent page that can be framed is a consent page that can be //! clicked through by a page the reader thinks they are on, which is the //! attack these are here to refuse. @@ -77,14 +77,6 @@ async fn a_rendered_page_carries_the_security_headers() { assert_carries_them("/dashboard", &headers); } -/// The onboarding page too: the other HTML surface a stranger can reach. -#[tokio::test] -async fn the_onboarding_page_carries_them_as_well() { - let (status, headers) = headers_of("/onboarding").await; - assert_eq!(status, StatusCode::OK); - assert_carries_them("/onboarding", &headers); -} - /// The layer is the router's, not one page's. /// /// A JSON route, a static asset and a path nothing serves each go out the diff --git a/plan/onboarding.md b/plan/onboarding.md index 5c387a73..efffc60d 100644 --- a/plan/onboarding.md +++ b/plan/onboarding.md @@ -353,12 +353,3 @@ default above, restated as what an operator actually sees when it happens. unbounded burst costs the operator's PDS one extra request — which the timer was going to spend anyway. `a_burst_of_nudges_produces_at_most_one_extra_poll` fires two hundred and asserts exactly one extra outbound read. - -- [x] **An onboarding surface.** `GET /onboarding` shows which state the - server is in, whether it holds its own identity, whether a claim - currently stands, which surfaces answer, and what to run next on the - operator's own machine. It is a status and guidance page: it accepts no - credential, offers nowhere to type one, and exposes no route that - changes anything, because nothing exists by which a browser could prove - control of the operator DID. Its copy is placeholders; its facts are - not. diff --git a/plan/web-launch.md b/plan/web-launch.md index 2624ddd3..c50d831c 100644 --- a/plan/web-launch.md +++ b/plan/web-launch.md @@ -94,8 +94,8 @@ its own record. balancer in front (see `infra/pds/ec2.tf`). The grade is a grade of this server's TLS configuration. Test the zone hostname and one agent hostname, since agents are served under the wildcard certificate. -- [ ] **Mozilla HTTP Observatory**, against the dashboard and onboarding - pages. These are the surfaces a person's browser session lives on. +- [ ] **Mozilla HTTP Observatory**, against the dashboard. It is the + surface a person's browser session lives on. - [ ] **DNSViz**, against the zone. It checks that the parent's NS records match the zone's own, which is the delegation step [aws-deploy](aws-deploy.md) says an operator must not miss. @@ -118,7 +118,7 @@ its own record. five request headers an atproto client sends, and `DPoP-Nonce` and `WWW-Authenticate` exposed. Preflight is answered by the layer before the route's own 405. `/health`, `/events`, `/firehose`, - `/oauth/authorize`, the dashboard and onboarding answer no origin but + `/oauth/authorize` and the dashboard answer no origin but their own; widening that set is the human decision the item named. - [x] **Serve `com.atproto.sync.getRepoStatus`.** `active` and `status` are read through `AgentAccount::sync_status`, the mapping -- 2.51.2