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⟧
-
-
⟦panel-loading-driftwood⟧
-
-
-
-
-
-
⟦facts-title-kiln⟧
-
⟦facts-hint-osprey⟧
-
-
⟦panel-loading-driftwood⟧
-
-
-
-
-
⟦surfaces-title-kiln⟧
-
⟦surfaces-hint-vellum⟧
-
-
⟦panel-loading-driftwood⟧
-
-
-
-
-
-
-
-
-
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