Skip to main content

didbot_serve/
dashboard.rs

1//! The per-server homepage and operations dashboard: this server, reporting
2//! on itself. `docs/web-surfaces.md` calls this surface 3 of the project's
3//! four websites, and draws the boundary this module keeps: it is served by
4//! `didbot-serve`, about the deployment serving it, one per deployment
5//! rather than one for the whole project, and it owns no policy — policy is
6//! written from a separate, statically-hosted origin (surface 2) and only
7//! ever read here, never edited.
8//!
9//! It answers before anyone signs in: [`about`] says what this deployment
10//! is (its zone) and what it holds (`bot.did.stats`'s own numbers, reused rather than
11//! reread). Everything past that is the authenticated operations view.
12//!
13//! `plan/ops-dashboard.md` sets one rule above everything else it asks for:
14//! *never display invented data as if it were real*. Every panel this module
15//! serves answers in exactly one of three states, carried in
16//! [`crate::surface::PanelState`] rather than left for a client to infer
17//! from shape:
18//!
19//! - **Live** — a real capability, read fresh from this process's own state.
20//! - **Not implemented** — the epic it needs does not exist yet
21//!   (`write-policy`, `scope-policy`, `app-allowlist`, `oauth`). Names what
22//!   is missing and carries no numbers at all, because a plausible-looking
23//!   zero is a lie this dashboard is built specifically not to tell.
24//! - **Unreachable** — a real capability whose read failed, which is a
25//!   different fact from a real capability that read as zero.
26//!
27//! `panel_state_never_carries_numbers_without_a_backing_capability` is the
28//! mechanical guard on that rule: it asserts, at the JSON level, that
29//! [`PanelState::NotImplemented`] and [`PanelState::Unreachable`] can never
30//! serialize a `data` field, so a future panel cannot regress into showing a
31//! number for a capability that is not there.
32//!
33//! # What is public here, and what is not
34//!
35//! Public: the static shell and its assets, [`about`] and [`capabilities`],
36//! and the client metadata document — [`about`] is `bot.did.stats`'s own
37//! public numbers, [`capabilities`] names which epics are unbuilt, which
38//! `plan/` already says out loud, and the metadata document's own URL is
39//! the `client_id`, so the operator's authorization server has to be able
40//! to fetch it before it will show them a consent screen.
41//!
42//! Behind the operator sign-in: the e-stop's status and its two controls,
43//! the `bot.did.operator` records the poll last read out of the operator's
44//! own repository, and the relay announce. What authenticates them is
45//! [`crate::operator`] — atproto OAuth against the operator's *own* server,
46//! requesting `atproto` and nothing else, with the verified `sub` compared
47//! against this deployment's `--operator`. No operator credential reaches this
48//! server, which is what `plan/handshake.md` is protecting: the sign-in
49//! proves control of a DID, and this server then issues a session of its
50//! own.
51//!
52//! An anonymous, internet-reachable halt would be a denial of service
53//! rather than an emergency stop, which is why throwing costs a sign-in
54//! too and not only releasing.
55//!
56//! The session list still has no data behind it, and renders
57//! [`PanelState::NotImplemented`] naming the epic it waits on. That is the
58//! same honesty rule the rest of this module follows: a panel whose data
59//! this server does not have is a panel that says so, not one showing a
60//! plausible zero.
61//!
62//! The per-app panels — live logins, refusals and writes — are
63//! [`crate::dashboard_apps`]'s, and the account list and detail are
64//! [`crate::dashboard_accounts`]'s, both merged into this module's router.
65
66use axum::extract::State;
67use axum::http::StatusCode;
68use axum::response::{IntoResponse, Response};
69use axum::routing::{get, post};
70use axum::{Json, Router};
71use didbot_pds::{Actor, Lock};
72use serde::Serialize;
73use serde_json::json;
74
75use crate::landing;
76use crate::routes::AppState;
77use crate::surface::{
78    acting_operator, asset, html, operator, refusal, script, session_cookie, signed_in, stylesheet,
79    PanelState, SESSION_COOKIE,
80};
81use crate::wire::{LifecycleLine, StatsResponse};
82
83const INDEX_HTML: &str = include_str!("../assets/dashboard/index.html");
84const DASHBOARD_CSS: &str = include_str!("../assets/dashboard/dashboard.css");
85const DASHBOARD_JS: &str = include_str!("../assets/dashboard/dashboard.js");
86
87// `DASHBOARD_WASM`: name, content type and bytes for each file of the wave
88// field, copied into the build's own output by `build.rs` from whatever was
89// on disk when this crate was compiled. See `wasm_asset`.
90include!(concat!(env!("OUT_DIR"), "/dashboard_wasm.rs"));
91
92/// Exposes the three asset constants to `crate::tests`'s accessibility
93/// checks, which read the served markup, stylesheet and script as text
94/// rather than duplicating them. Test-only: nothing outside a test binary
95/// has a reason to read these three as strings instead of serving them.
96#[cfg(test)]
97pub(crate) fn dashboard_html_for_test() -> &'static str {
98    INDEX_HTML
99}
100
101#[cfg(test)]
102pub(crate) fn dashboard_css_for_test() -> &'static str {
103    DASHBOARD_CSS
104}
105
106#[cfg(test)]
107pub(crate) fn dashboard_js_for_test() -> &'static str {
108    DASHBOARD_JS
109}
110
111/// Builds the dashboard's routes, to be merged into the main router.
112///
113/// Not merged into `atproto_routes()` or listed in `ROUTE_CREDENTIALS`:
114/// these are not `com.atproto.*`/`bot.did.*` NSIDs. Every one of them is
115/// public — see the module doc for why the operations routes that were not
116/// are gone rather than re-gated.
117pub fn routes() -> Router<AppState> {
118    // Once, where a deployment will see it: a missing background is
119    // otherwise two 404s on a page nobody is watching the log for.
120    if DASHBOARD_WASM.is_empty() {
121        tracing::info!(
122            "the dashboard's animated background was not built into this binary; the page \
123             falls back to a still one. Run scripts/build-dashboard-wasm.sh and rebuild to \
124             include it."
125        );
126    }
127    Router::new()
128        .route("/dashboard", get(index))
129        .route("/dashboard/dashboard.css", get(css))
130        .route("/dashboard/dashboard.js", get(js))
131        .route("/dashboard/wasm/{file}", get(wasm_asset))
132        .route("/dashboard/api/about", get(about))
133        .route("/dashboard/api/capabilities", get(capabilities))
134        .route(crate::operator::CLIENT_METADATA_PATH, get(client_metadata))
135        .route("/dashboard/login", get(login))
136        .route(crate::operator::CALLBACK_PATH, get(callback))
137        .route("/dashboard/logout", post(logout))
138        .route("/dashboard/api/estop", get(estop_status))
139        .route("/dashboard/api/operator-records", get(operator_records))
140        .route("/dashboard/api/estop/throw", post(estop_throw))
141        .route("/dashboard/api/estop/release", post(estop_release))
142        .route("/dashboard/api/relay/announce", post(relay_announce))
143        .route("/dashboard/api/accounts/{did}/lock", post(account_lock))
144        .route("/dashboard/api/accounts/{did}/unlock", post(account_unlock))
145        .route(
146            "/dashboard/api/accounts/{did}/lift-quarantine",
147            post(account_lift_quarantine),
148        )
149        .route("/dashboard/api/accounts/{did}/erase", post(account_erase))
150        .route("/dashboard/api/accounts/{did}/delete", post(account_delete))
151        .route(
152            "/dashboard/api/accounts/{did}/revoke-tokens",
153            post(account_revoke_tokens),
154        )
155        .route(
156            "/dashboard/api/accounts/{did}/credentials",
157            post(account_credentials),
158        )
159        .merge(crate::dashboard_apps::routes())
160        .merge(crate::dashboard_accounts::routes())
161}
162
163/// `GET /dashboard/oauth/client-metadata.json`
164///
165/// Public, and has to be: this document's own URL is the `client_id`, and
166/// the operator's authorization server fetches it before it will show them
167/// a consent screen.
168async fn client_metadata(State(state): State<AppState>) -> Response {
169    let operator = match operator(&state) {
170        Ok(operator) => operator,
171        Err(refusal) => return *refusal,
172    };
173    match operator.client_metadata_document() {
174        Ok(document) => Json(document).into_response(),
175        Err(error) => refusal(StatusCode::INTERNAL_SERVER_ERROR, "ClientMetadata", error),
176    }
177}
178
179/// `GET /dashboard/login`
180///
181/// Redirects to the operator's own authorization server. The identity is
182/// the one `--operator` names: a request naming another is refused here,
183/// before anything is resolved or fetched. A browser follows this from a
184/// link, so every refusal is a page.
185async fn login(
186    State(state): State<AppState>,
187    peer: crate::rate_limit::Peer,
188    headers: axum::http::HeaderMap,
189    axum::extract::Query(query): axum::extract::Query<LoginQuery>,
190) -> Response {
191    let Some(operator) = state.operator.as_ref() else {
192        return no_operator();
193    };
194    // Unauthenticated by necessity — signing in is how a caller gets a
195    // credential — and each call makes this server resolve an identity and
196    // push an authorization request to a host the caller named. Bounded the
197    // same way `POST /oauth/par` already is.
198    let key = crate::rate_limit::caller_key(&headers, peer.0, state.trust_forwarded_headers);
199    if !state.operator_login_limiter.check(&key) {
200        return landing::too_many(landing::OPERATOR);
201    }
202    // This deployment knows who it is waiting for, and resolving anyone
203    // else would make it fetch from a host a stranger named. Refused before
204    // the resolution rather than ignored, so a caller that meant something
205    // by the parameter is told it means nothing.
206    if query
207        .handle
208        .as_deref()
209        .is_some_and(|handle| handle != operator.operator_did())
210    {
211        return landing::refused(
212            StatusCode::BAD_REQUEST,
213            landing::OPERATOR,
214            "NotTheOperator",
215            &format!("this server signs in as {} only", operator.operator_did()),
216            "",
217            "Sign in as its operator instead.",
218        );
219    }
220    match operator.start(query.cli.as_deref()).await {
221        Ok(url) => axum::response::Redirect::to(&url).into_response(),
222        Err(error @ crate::operator::SignInError::NotLoopback { .. }) => {
223            tracing::info!(%error, "refused an operator sign-in");
224            landing::refused(
225                StatusCode::BAD_REQUEST,
226                landing::OPERATOR,
227                "NotLoopback",
228                "a command line's sign-in must come back to its own machine",
229                "",
230                "Run `didbot login` again.",
231            )
232        }
233        Err(error) => {
234            tracing::warn!(%error, "an operator sign-in could not start");
235            landing::refused(
236                StatusCode::BAD_GATEWAY,
237                landing::OPERATOR,
238                "SignIn",
239                "could not reach the operator's own server",
240                "",
241                "Check that the operator's server is up, then sign in again.",
242            )
243        }
244    }
245}
246
247/// The page [`login`] and [`callback`] answer on a deployment started
248/// with no `--operator`.
249fn no_operator() -> Response {
250    landing::refused(
251        StatusCode::SERVICE_UNAVAILABLE,
252        landing::OPERATOR,
253        "NoOperator",
254        "this server has no operator to sign in as",
255        "",
256        "Start it with --operator to sign in here.",
257    )
258}
259
260/// What `/dashboard/login` accepts.
261#[derive(serde::Deserialize)]
262pub struct LoginQuery {
263    /// Accepted only when it names this deployment's operator, so a link
264    /// carrying the parameter says what it does rather than being ignored.
265    handle: Option<String>,
266    /// Where to send the browser once signed in, for a command line waiting
267    /// on a loopback address. Refused unless it is loopback — see
268    /// [`crate::operator::OperatorAuth::start`].
269    cli: Option<String>,
270}
271
272/// `GET /dashboard/callback`
273///
274/// Where the operator's authorization server sends the browser back. A
275/// sign-in the page started goes back to the page, with the session's CSRF
276/// token in the address: `dashboard.js` reads it once and takes it out of
277/// the address bar. A sign-in a command line started goes to the loopback
278/// address it is waiting on. A sign-in that ended any other way is a page
279/// saying so.
280async fn callback(
281    State(state): State<AppState>,
282    axum::extract::Query(query): axum::extract::Query<CallbackQuery>,
283) -> Response {
284    /// What every way a sign-in can end here says to do next.
285    const AGAIN: &str = "Sign in again. If a terminal started this sign-in, run its command again.";
286    let Some(operator) = state.operator.as_ref() else {
287        return no_operator();
288    };
289    let Some(code) = query.code else {
290        return match query.error {
291            Some(error) => {
292                tracing::info!(
293                    error,
294                    description = query.error_description,
295                    "the operator's server ended a sign-in without a code"
296                );
297                // Its description is the operator's own server's account of
298                // why, set in text and cut short.
299                let said = query
300                    .error_description
301                    .map(|said| {
302                        let said: String = said.chars().take(300).collect();
303                        format!(
304                            "<p data-said>The operator's server said: {}</p>",
305                            didbot_brand::page::escape(&said)
306                        )
307                    })
308                    .unwrap_or_default();
309                landing::refused(
310                    StatusCode::FORBIDDEN,
311                    landing::OPERATOR,
312                    &error,
313                    "sign-in denied",
314                    &said,
315                    AGAIN,
316                )
317            }
318            None => landing::refused(
319                StatusCode::BAD_REQUEST,
320                landing::OPERATOR,
321                "InvalidRequest",
322                "this address is for the operator's own server to send a browser back to",
323                "",
324                AGAIN,
325            ),
326        };
327    };
328    let params = jacquard_oauth::types::CallbackParams {
329        code: code.into(),
330        state: query.state.map(Into::into),
331        iss: query.iss.map(Into::into),
332    };
333    match operator.finish(params).await {
334        Ok(signed_in) => {
335            // `Secure` unconditionally: this deployment serves TLS, and a
336            // session cookie that survives a downgrade is one an observer
337            // can lift. `SameSite=Lax` so the redirect back from the
338            // operator's server still carries it, with `CSRF_HEADER`
339            // carrying the part `Lax` does not cover.
340            let cookie = format!(
341                "{SESSION_COOKIE}={}; HttpOnly; Secure; SameSite=Lax; Path=/dashboard; Max-Age={}",
342                signed_in.token,
343                operator.session_ttl().as_secs(),
344            );
345            // A command line holds no cookie jar, so it takes the session
346            // itself at its loopback address. The page gets the cookie, and
347            // its buttons echo the token its address carries.
348            let to = match &signed_in.cli_redirect {
349                Some(handoff) => format!(
350                    "{handoff}?token={}&csrf={}",
351                    signed_in.token, signed_in.csrf
352                ),
353                None => format!("/dashboard?csrf={}", signed_in.csrf),
354            };
355            (
356                [(axum::http::header::SET_COOKIE, cookie)],
357                axum::response::Redirect::to(&to),
358            )
359                .into_response()
360        }
361        Err(crate::operator::SignInError::NotTheOperator { got }) => landing::refused(
362            StatusCode::FORBIDDEN,
363            landing::OPERATOR,
364            "NotTheOperator",
365            &format!("signed in as {got}, which is not this server's operator"),
366            "",
367            &format!("Sign in as {} instead.", operator.operator_did()),
368        ),
369        Err(error) => {
370            tracing::warn!(%error, "an operator sign-in did not finish");
371            landing::refused(
372                StatusCode::FORBIDDEN,
373                landing::OPERATOR,
374                "SignIn",
375                "this sign-in could not be finished",
376                "<p>It expired, was already used, or its server refused the exchange.</p>",
377                AGAIN,
378            )
379        }
380    }
381}
382
383/// What the authorization server sends back.
384#[derive(serde::Deserialize)]
385pub struct CallbackQuery {
386    /// The authorization code, when the sign-in was approved.
387    code: Option<String>,
388    /// The `state` this server sent, echoed back.
389    state: Option<String>,
390    /// RFC 9207's issuer, against server mix-up.
391    iss: Option<String>,
392    /// RFC 6749 §4.1.2.1's error, when it was not.
393    error: Option<String>,
394    /// And the server's own words for it.
395    error_description: Option<String>,
396}
397
398/// `POST /dashboard/logout`
399async fn logout(State(state): State<AppState>, headers: axum::http::HeaderMap) -> Response {
400    if let (Ok(operator), Some(token)) = (operator(&state), session_cookie(&headers)) {
401        operator.sign_out(&token);
402    }
403    let cleared =
404        format!("{SESSION_COOKIE}=; HttpOnly; Secure; SameSite=Lax; Path=/dashboard; Max-Age=0");
405    (
406        StatusCode::OK,
407        [(axum::http::header::SET_COOKIE, cleared)],
408        Json(json!({ "signedIn": false })),
409    )
410        .into_response()
411}
412
413/// `GET /dashboard/api/estop`
414///
415/// What the latch is doing and what it has refused since it engaged.
416async fn estop_status(State(state): State<AppState>, headers: axum::http::HeaderMap) -> Response {
417    if let Err(refusal) = signed_in(&state, &headers, false) {
418        return *refusal;
419    }
420    Json(PanelState::Live {
421        data: state.estop.status(),
422    })
423    .into_response()
424}
425
426/// `POST /dashboard/api/estop/throw`
427async fn estop_throw(
428    State(state): State<AppState>,
429    headers: axum::http::HeaderMap,
430    Json(body): Json<ThrowBody>,
431) -> Response {
432    if let Err(refusal) = signed_in(&state, &headers, true) {
433        return *refusal;
434    }
435    // `plan/server-lifecycle.md`'s rule: an e-stop halts something that is
436    // running, and a server that has not finished arriving is not.
437    if !state.lifecycle.policy().may_halt {
438        return refusal(
439            StatusCode::CONFLICT,
440            "NotRunning",
441            format!(
442                "this server is `{}`, a state in which the stop means nothing",
443                state.lifecycle.state()
444            ),
445        );
446    }
447    if let Err(refusal) = as_the_operator(&state, |estop| estop.throw(body.mode)) {
448        return *refusal;
449    }
450    Json(PanelState::Live {
451        data: state.estop.status(),
452    })
453    .into_response()
454}
455
456/// Moves the latch inside a span naming this deployment's operator, so the
457/// line the latch logs for the throw or the release says who asked. The
458/// span carries the DID and nothing from the session.
459fn as_the_operator(
460    state: &AppState,
461    act: impl FnOnce(&didbot_pds::Estop),
462) -> Result<(), Box<Response>> {
463    let operator = acting_operator(state)?;
464    tracing::info_span!("operator", operator = %operator).in_scope(|| act(&state.estop));
465    Ok(())
466}
467
468/// What `/dashboard/api/estop/throw` accepts.
469#[derive(serde::Deserialize)]
470pub struct ThrowBody {
471    /// Which setting to throw.
472    mode: didbot_pds::EstopMode,
473}
474
475/// `POST /dashboard/api/estop/release`
476///
477/// Clears the operator's latch. Costs exactly what throwing costs — see
478/// `didbot_pds::Estop::throw`.
479async fn estop_release(State(state): State<AppState>, headers: axum::http::HeaderMap) -> Response {
480    if let Err(refusal) = signed_in(&state, &headers, true) {
481        return *refusal;
482    }
483    if let Err(refusal) = as_the_operator(&state, didbot_pds::Estop::release) {
484        return *refusal;
485    }
486    Json(PanelState::Live {
487        data: state.estop.status(),
488    })
489    .into_response()
490}
491
492/// One `bot.did.operator` record in the operator's own repository, as the
493/// panel shows it.
494#[derive(Debug, Clone, Serialize)]
495#[serde(rename_all = "camelCase")]
496struct OperatorRecordView {
497    /// The record's own address in the operator's repository.
498    uri: String,
499    /// The account the record names.
500    subject: String,
501    /// When its writer says they wrote it, by their own clock.
502    created_at: Option<String>,
503    /// Whether `subject` is an account this deployment holds. A record
504    /// naming somewhere else is still the operator's to recognise.
505    hosted_here: bool,
506    /// What the account is, when this deployment holds it.
507    #[serde(skip_serializing_if = "Option::is_none")]
508    kind: Option<didbot_pds::AccountKind>,
509    /// What logs in as the account, when this deployment holds it: the key
510    /// fingerprints and OpenID Connect identities it was admitted with, and
511    /// any changed since.
512    #[serde(skip_serializing_if = "Option::is_none")]
513    credentials: Option<Vec<crate::dashboard_accounts::CredentialView>>,
514}
515
516/// `GET /dashboard/api/operator-records`
517///
518/// Every `bot.did.operator` record the poll's last listing of the
519/// operator's repository read, so the operator can find one they did not
520/// write. An app holding their session writes as them, and their PDS cannot
521/// tell the two apart, so seeing the records is the whole of the defence.
522///
523/// Read-only, and nothing here marks a record: what a record says is the
524/// operator's repository's business, and this deployment reading it does
525/// not make it this deployment's.
526///
527/// `Unreachable` rather than an empty list when no listing has landed yet —
528/// a repository holding no records and a poll that has not read one are
529/// different facts, and a zero standing in for the second is the lie this
530/// module is built not to tell.
531async fn operator_records(
532    State(state): State<AppState>,
533    headers: axum::http::HeaderMap,
534) -> Response {
535    if let Err(refusal) = signed_in(&state, &headers, false) {
536        return *refusal;
537    }
538    let Some(lines) = state.operator_records.read() else {
539        return Json(PanelState::<Vec<OperatorRecordView>>::Unreachable {
540            reason: "the operator poll has not listed the operator's repository yet".to_owned(),
541        })
542        .into_response();
543    };
544    Json(PanelState::Live {
545        data: lines
546            .into_iter()
547            .map(|line| {
548                let kind = state.registry.account(&line.subject).map(|held| held.kind);
549                OperatorRecordView {
550                    hosted_here: kind.is_some(),
551                    kind,
552                    credentials: kind
553                        .map(|_| crate::dashboard_accounts::credentials(&state, &line.subject)),
554                    uri: line.uri,
555                    subject: line.subject,
556                    created_at: line.created_at,
557                }
558            })
559            .collect::<Vec<_>>(),
560    })
561    .into_response()
562}
563
564/// `GET /dashboard`
565///
566/// The static shell only — no server-rendered numbers here at all. Every
567/// panel's data comes from the `/dashboard/api/*` routes below, fetched by
568/// `dashboard.js`, and both of them answer anyone: a homepage has to answer
569/// before anyone signs in, and there is nobody to sign in as yet. The
570/// operations panels are rendered client-side as not-implemented; see the
571/// module doc.
572async fn index() -> Response {
573    html(INDEX_HTML)
574}
575
576/// `GET /dashboard/dashboard.css`
577async fn css() -> Response {
578    stylesheet(DASHBOARD_CSS)
579}
580
581/// `GET /dashboard/dashboard.js`
582async fn js() -> Response {
583    script(DASHBOARD_JS)
584}
585
586/// `GET /dashboard/wasm/{file}`
587///
588/// The travelling-wave field's compiled wasm, built by
589/// `scripts/build-dashboard-wasm.sh` from `crates/didbot-site-anim` — an
590/// opt-in build step, same as `scripts/build-wasm.sh` for the site, so
591/// `cargo build`/`cargo test` never need a wasm32 toolchain. Run it, then
592/// rebuild: `build.rs` bakes whatever it finds into the binary, and this
593/// route serves that and never the filesystem. So a binary carries its own
594/// background wherever it is copied, and a binary built without the step
595/// answers 404 — which `dashboard.js` treats as "no field available",
596/// falling back to a still background, the same fallback
597/// `prefers-reduced-motion` gets.
598///
599/// `DASHBOARD_WASM` is also the allowlist: a name that is not in it is not
600/// served, which is the whole of this endpoint's `../`-refusal.
601async fn wasm_asset(axum::extract::Path(file): axum::extract::Path<String>) -> Response {
602    match DASHBOARD_WASM
603        .iter()
604        .find(|(name, _, _)| *name == file.as_str())
605    {
606        Some((_, content_type, bytes)) => asset(content_type, *bytes),
607        None => StatusCode::NOT_FOUND.into_response(),
608    }
609}
610
611/// What the unauthenticated homepage panel reports.
612#[derive(Debug, Clone, Serialize)]
613#[serde(rename_all = "camelCase")]
614struct AboutView {
615    /// This deployment's own zone — what it is, not the project's name.
616    /// `docs/web-surfaces.md`: there is one of these pages per deployment,
617    /// not one for the project, and nothing here should assume there is
618    /// only one server in the world.
619    zone: String,
620    stats: StatsResponse,
621    /// What the deployment is doing right now. A halted or unclaimed server
622    /// still answers this route, and this is the field that says so — the
623    /// envelope's `live` is about the panel, not about the server.
624    lifecycle: LifecycleLine,
625}
626
627/// `GET /dashboard/api/about`
628///
629/// What this server is and what it holds, for anyone. Reuses exactly
630/// what `bot.did.stats` (`Credential::Public`) already serves, over the same
631/// `Registry` — this is that route's data, not a second read of it.
632async fn about(State(state): State<AppState>) -> Response {
633    Json(PanelState::Live {
634        data: AboutView {
635            zone: state.registry.zone().to_owned(),
636            stats: StatsResponse::from_stats(&state.registry.stats()),
637            lifecycle: LifecycleLine::from_snapshot(&state.lifecycle.snapshot()),
638        },
639    })
640    .into_response()
641}
642
643/// `GET /dashboard/api/capabilities`
644///
645/// The dashboard's own honesty ledger: what it can and cannot show, and
646/// why, so an operator does not have to infer completeness from an absent
647/// panel. `plan/ops-dashboard.md`'s "say what it cannot be trusted about"
648/// is not only this endpoint — the trust note on the page itself says the
649/// same thing in prose — but this is the machine-readable form of it.
650/// Public, like everything else this module serves: it names which epics are
651/// unbuilt and nothing else, which is a fact `plan/` publishes anyway. Gating
652/// the honesty ledger behind the credential whose absence it is reporting was
653/// circular even before that credential turned out not to exist.
654///
655/// A live entry here carries `data: null`: this is the ledger, not the
656/// panel, and the number a panel has belongs on the panel. What the ledger
657/// says is which of the three states each panel is in and, for the ones
658/// that are not there, which epic each waits on.
659async fn capabilities() -> Response {
660    Json(json!({
661        "operatorSignIn": PanelState::Live { data: () },
662        "estop": PanelState::Live { data: () },
663        "operatorRecords": PanelState::Live { data: () },
664        "sessions": PanelState::<()>::NotImplemented { missing: vec!["oauth"] },
665        "accounts": PanelState::Live { data: () },
666        "refusalLog": PanelState::Live { data: () },
667        "grants": PanelState::Live { data: () },
668        "writes": PanelState::Live { data: () },
669    }))
670    .into_response()
671}
672
673#[cfg(test)]
674mod tests {
675    use super::*;
676
677    /// The mechanical guard on the epic's honesty rule: a panel reporting
678    /// that its capability is absent, or that it could not be reached, can
679    /// never carry a `data` field — the field [`PanelState::Live`] is the
680    /// only variant that has. This is checked at the JSON level rather than
681    /// by inspecting the enum, because JSON is what an operator's browser
682    /// actually receives and a future refactor of the Rust shape should not
683    /// be able to pass this test while a client-visible number leaks back
684    /// in some other field name.
685    #[test]
686    fn panel_state_never_carries_numbers_without_a_backing_capability() {
687        let live = serde_json::to_value(PanelState::Live { data: 42 }).unwrap();
688        assert_eq!(live["status"], "live");
689        assert_eq!(live["data"], 42);
690
691        let not_implemented = serde_json::to_value(PanelState::NotImplemented::<i32> {
692            missing: vec!["write-policy"],
693        })
694        .unwrap();
695        assert_eq!(not_implemented["status"], "not_implemented");
696        assert!(
697            not_implemented.get("data").is_none(),
698            "a not-implemented panel must carry no data field at all: {not_implemented}"
699        );
700        assert_eq!(not_implemented["missing"][0], "write-policy");
701
702        let unreachable = serde_json::to_value(PanelState::Unreachable::<i32> {
703            reason: "timed out".to_owned(),
704        })
705        .unwrap();
706        assert_eq!(unreachable["status"], "unreachable");
707        assert!(
708            unreachable.get("data").is_none(),
709            "an unreachable panel must carry no data field at all: {unreachable}"
710        );
711        // And it must not be confusable with a live zero: no numeric field
712        // anywhere in its JSON.
713        assert!(
714            unreachable
715                .as_object()
716                .unwrap()
717                .values()
718                .all(|v| !v.is_number()),
719            "an unreachable panel must not carry any number: {unreachable}"
720        );
721    }
722
723    #[test]
724    fn the_three_states_serialize_to_distinct_tags() {
725        let values = [
726            serde_json::to_value(PanelState::Live { data: 1 }).unwrap(),
727            serde_json::to_value(PanelState::NotImplemented::<i32> { missing: vec![] }).unwrap(),
728            serde_json::to_value(PanelState::Unreachable::<i32> {
729                reason: String::new(),
730            })
731            .unwrap(),
732        ];
733        let tags: Vec<&str> = values
734            .iter()
735            .map(|v| v["status"].as_str().unwrap())
736            .collect();
737        let unique: std::collections::HashSet<&str> = tags.iter().copied().collect();
738        assert_eq!(
739            unique.len(),
740            3,
741            "the three panel states must render distinguishably: {tags:?}"
742        );
743    }
744
745    /// The route used to read the build machine's source path at request
746    /// time, so a binary moved anywhere else served two 404s. What it
747    /// serves is settled when the binary is compiled.
748    #[tokio::test]
749    async fn the_field_comes_from_the_binary_and_not_from_the_source_tree() {
750        let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("assets/dashboard/wasm");
751        let name = "site-anim.js";
752        let path = dir.join(name);
753
754        if let Some((_, _, baked)) = DASHBOARD_WASM.iter().find(|(n, _, _)| *n == name) {
755            let served = body_of(wasm_asset(axum::extract::Path(name.to_owned())).await).await;
756            assert_eq!(
757                served.as_slice(),
758                *baked,
759                "the route served something other than what was compiled in"
760            );
761            return;
762        }
763
764        // Nothing was compiled in. A file appearing on disk afterwards is
765        // the build machine's source tree, and this binary must not read
766        // it. Planted only when the opt-in build has not left a real one.
767        let planted = !path.exists();
768        if planted {
769            std::fs::create_dir_all(&dir).expect("make the asset directory");
770            std::fs::write(&path, b"// not compiled into this binary")
771                .expect("plant a file the binary never saw");
772        }
773        let response = wasm_asset(axum::extract::Path(name.to_owned())).await;
774        let status = response.status();
775        if planted {
776            let _ = std::fs::remove_file(&path);
777        }
778        assert_eq!(
779            status,
780            StatusCode::NOT_FOUND,
781            "the route read {} at request time",
782            path.display()
783        );
784    }
785
786    /// A path from the machine that compiled this crate has no business in
787    /// the binary it produced: `include_bytes!` records the file it read in
788    /// the source map, so an absolute source path reaches every build made
789    /// from it. `build.rs` copies each asset into the build's own output and
790    /// includes it from there, by bare name.
791    #[test]
792    fn the_asset_table_names_no_build_machine_path() {
793        const TABLE: &str = include_str!(concat!(env!("OUT_DIR"), "/dashboard_wasm.rs"));
794        assert!(
795            !TABLE.contains(env!("CARGO_MANIFEST_DIR")),
796            "the asset table names the tree it was built from:\n{TABLE}"
797        );
798        for (name, _, _) in DASHBOARD_WASM {
799            assert!(
800                TABLE.contains(&format!("include_bytes!({name:?})")),
801                "{name} is included by something other than its bare name:\n{TABLE}"
802            );
803        }
804    }
805
806    async fn body_of(response: Response) -> Vec<u8> {
807        axum::body::to_bytes(response.into_body(), usize::MAX)
808            .await
809            .expect("read the response body")
810            .to_vec()
811    }
812}
813
814/// `POST /dashboard/api/relay/announce`
815///
816/// Asks every configured relay to crawl this server. An operator command like
817/// the stop, reached the same way, because it is the same question: who may
818/// tell this server to do something. `plan/federation.md` keeps it
819/// deliberate rather than automatic — naming a relay does not announce
820/// anything by itself.
821///
822/// The answer lists each relay with one of the three outcomes
823/// `docs/conformance.md` names, because an operator who asked needs to know
824/// which happened at each relay and not just that the call returned.
825async fn relay_announce(
826    State(state): State<AppState>,
827    headers: axum::http::HeaderMap,
828    Json(body): Json<AnnounceBody>,
829) -> Response {
830    if let Err(refusal) = signed_in(&state, &headers, true) {
831        return *refusal;
832    }
833    let Some(relay) = state.relay.clone() else {
834        return refusal(
835            StatusCode::SERVICE_UNAVAILABLE,
836            "NoRelay",
837            "this deployment names no relay; see --relay-hostname",
838        );
839    };
840    if !state.lifecycle.policy().announces_outward {
841        return refusal(
842            StatusCode::CONFLICT,
843            "NotAnnouncing",
844            format!(
845                "this server is `{}`, a state in which it asks nobody to come look",
846                state.lifecycle.state()
847            ),
848        );
849    }
850    // `notifyOfUpdate` is deprecated in `requestCrawl`'s favour, and kept
851    // because upstream still names it for resuming a relay's subscription
852    // after a stream gap.
853    let relays: Vec<serde_json::Value> = relay
854        .announce(body.resume)
855        .await
856        .into_iter()
857        .map(|(relay, outcome)| match outcome {
858            Ok(()) => json!({ "relay": relay, "result": "accepted" }),
859            Err(crate::relay::RelayCrawlError::Refused { status, body, .. }) => json!({
860                "relay": relay,
861                "result": "refused",
862                "status": status,
863                "body": body,
864            }),
865            Err(error) => json!({
866                "relay": relay,
867                "result": "unreachable",
868                "error": error.to_string(),
869            }),
870        })
871        .collect();
872    Json(json!({ "relays": relays })).into_response()
873}
874
875/// What `/dashboard/api/relay/announce` accepts.
876#[derive(serde::Deserialize, Default)]
877#[serde(default)]
878pub struct AnnounceBody {
879    /// Call the deprecated `notifyOfUpdate` instead of `requestCrawl`, which
880    /// upstream still names for resuming a subscription after a gap.
881    resume: bool,
882}
883
884// ---------------------------------------------------------------------------
885// One account, acted on by the operator
886// ---------------------------------------------------------------------------
887//
888// `didbot_pds::Actor::Operator`, or the operator's DID, is what these hand
889// the registry, so what each of them does lands in that account's ledger
890// naming the DID this deployment was started with. They are reached the way the stop is reached — the
891// dashboard's session, echoed CSRF token — because it is the same question
892// of who may tell this server to do something, and `didbot account <verb>`
893// carries the same session to the same routes.
894//
895// The path segment is a DID or a handle, resolved through the same
896// `resolve_repo` the `com.atproto.repo.*` routes use, so an operator may
897// type the name they already know the account by.
898
899/// What one account route did, as it answers.
900///
901/// `ledger` is the entry the action wrote in that account's ledger, absent
902/// when it wrote none. A recursive delete answers with one of these per
903/// account it took, deepest first, and ending an app's logins with one per
904/// account a login ended on.
905#[derive(Serialize)]
906pub(crate) struct Acted {
907    /// The account acted on.
908    pub(crate) did: String,
909    /// The ledger entry the action made.
910    #[serde(skip_serializing_if = "Option::is_none")]
911    pub(crate) ledger: Option<didbot_pds::LedgerEntry>,
912}
913
914/// The account the path segment names, or the refusal to answer with.
915fn account_at(state: &AppState, named: &str) -> Result<String, Box<Response>> {
916    crate::routes::resolve_repo(state, named).map_err(|error| Box::new(error.into_response()))
917}
918
919/// [`Acted`] for `did`, reading the entry the action just wrote.
920fn acted(state: &AppState, did: &str) -> Acted {
921    Acted {
922        did: did.to_owned(),
923        ledger: state
924            .registry
925            .ledger(did)
926            .and_then(|ledger| ledger.entries.last().cloned()),
927    }
928}
929
930/// A registry refusal, as the operator reads it.
931fn refused(error: &didbot_pds::ProvisionError) -> Response {
932    crate::error::ApiError::from(error).into_response()
933}
934
935/// A registry refusal that came after the action had already reached some
936/// accounts, with those accounts beside it: a caller that saw only the
937/// refusal would not know what had already happened.
938pub(crate) fn refused_partway(error: &didbot_pds::ProvisionError, reached: &[Acted]) -> Response {
939    let refusal = crate::error::ApiError::from(error);
940    (
941        refusal.status(),
942        Json(json!({
943            "error": refusal.error(),
944            "message": refusal.message(),
945            "accounts": reached,
946        })),
947    )
948        .into_response()
949}
950
951/// The envelope every account route answers with: what was asked for, and
952/// each account it reached.
953fn did_it(action: &str, accounts: Vec<Acted>) -> Response {
954    Json(json!({ "action": action, "accounts": accounts })).into_response()
955}
956
957/// `POST /dashboard/api/accounts/{did}/lock`
958///
959/// [`didbot_pds::Lock::Frozen`]: writes refused, the repository still
960/// readable. The lock an operator hangs on one account to stop the bleeding.
961async fn account_lock(
962    State(state): State<AppState>,
963    axum::extract::Path(named): axum::extract::Path<String>,
964    headers: axum::http::HeaderMap,
965) -> Response {
966    if let Err(refusal) = signed_in(&state, &headers, true) {
967        return *refusal;
968    }
969    let (did, operator) = match (account_at(&state, &named), acting_operator(&state)) {
970        (Ok(did), Ok(operator)) => (did, operator),
971        (Err(refusal), _) | (_, Err(refusal)) => return *refusal,
972    };
973    match state
974        .registry
975        .lock(&did, Lock::Frozen, Actor::Operator(&operator))
976    {
977        Ok(()) => did_it("lock", vec![acted(&state, &did)]),
978        Err(error) => refused(&error),
979    }
980}
981
982/// `POST /dashboard/api/accounts/{did}/unlock`
983///
984/// Lifts this operator's own [`didbot_pds::Lock::Frozen`]. A tag another
985/// party hung stays where it is, and the refusal names it.
986async fn account_unlock(
987    State(state): State<AppState>,
988    axum::extract::Path(named): axum::extract::Path<String>,
989    headers: axum::http::HeaderMap,
990) -> Response {
991    if let Err(refusal) = signed_in(&state, &headers, true) {
992        return *refusal;
993    }
994    let (did, operator) = match (account_at(&state, &named), acting_operator(&state)) {
995        (Ok(did), Ok(operator)) => (did, operator),
996        (Err(refusal), _) | (_, Err(refusal)) => return *refusal,
997    };
998    match state
999        .registry
1000        .unlock(&did, Lock::Frozen, Actor::Operator(&operator))
1001    {
1002        Ok(()) => did_it("unlock", vec![acted(&state, &did)]),
1003        Err(error) => refused(&error),
1004    }
1005}
1006
1007/// `POST /dashboard/api/accounts/{did}/lift-quarantine`
1008///
1009/// The recorded exception `plan/account-lifecycle.md` names: a policy hung
1010/// the tag and the operator takes it off, with the ledger saying which
1011/// operator overrode it.
1012async fn account_lift_quarantine(
1013    State(state): State<AppState>,
1014    axum::extract::Path(named): axum::extract::Path<String>,
1015    headers: axum::http::HeaderMap,
1016) -> Response {
1017    if let Err(refusal) = signed_in(&state, &headers, true) {
1018        return *refusal;
1019    }
1020    let (did, operator) = match (account_at(&state, &named), acting_operator(&state)) {
1021        (Ok(did), Ok(operator)) => (did, operator),
1022        (Err(refusal), _) | (_, Err(refusal)) => return *refusal,
1023    };
1024    match state
1025        .registry
1026        .unlock(&did, Lock::Quarantined, Actor::Operator(&operator))
1027    {
1028        Ok(()) => did_it("lift-quarantine", vec![acted(&state, &did)]),
1029        Err(error) => refused(&error),
1030    }
1031}
1032
1033/// `POST /dashboard/api/accounts/{did}/erase`
1034///
1035/// The operator's erasure: the repository, its history, its blobs and its
1036/// credentials go, the name is burned, and the row and the DID document stay
1037/// so past signatures keep checking. It ignores the locks, so a locked or
1038/// quarantined account is erased too, and the ledger names the operator.
1039async fn account_erase(
1040    State(state): State<AppState>,
1041    axum::extract::Path(named): axum::extract::Path<String>,
1042    headers: axum::http::HeaderMap,
1043) -> Response {
1044    if let Err(refusal) = signed_in(&state, &headers, true) {
1045        return *refusal;
1046    }
1047    let (did, operator) = match (account_at(&state, &named), acting_operator(&state)) {
1048        (Ok(did), Ok(operator)) => (did, operator),
1049        (Err(refusal), _) | (_, Err(refusal)) => return *refusal,
1050    };
1051    match state.registry.operator_erase(&did, &operator) {
1052        Ok(()) => did_it("erase", vec![acted(&state, &did)]),
1053        Err(error) => refused(&error),
1054    }
1055}
1056
1057/// `POST /dashboard/api/accounts/{did}/delete`
1058///
1059/// The hard delete: the row goes too, and the ledger is what is left. An
1060/// account with children is refused unless the body asks for the subtree,
1061/// which is taken deepest first — a parent is erased only once nothing
1062/// beneath it is live, which is the rule
1063/// `Provisioner::require_no_live_children` enforces underneath.
1064///
1065/// The server's own account is refused before anything is taken. Every
1066/// account the server created hangs beneath it, so walking its subtree
1067/// would delete them all before the refusal reached the server itself.
1068///
1069/// `kind`, `neverWrote` and `idleFor` are conditions on every account it
1070/// would take; see [`crate::dashboard_accounts::Keeping`]. They are checked
1071/// for the whole subtree first, where one that fails refuses the delete.
1072/// They are checked again for each account with its writes held off, so an
1073/// account that wrote after it was listed stays. `didbot account delete
1074/// --never-wrote` and the accounts panel's bulk delete send all three.
1075async fn account_delete(
1076    State(state): State<AppState>,
1077    axum::extract::Path(named): axum::extract::Path<String>,
1078    headers: axum::http::HeaderMap,
1079    body: Option<Json<DeleteBody>>,
1080) -> Response {
1081    if let Err(refusal) = signed_in(&state, &headers, true) {
1082        return *refusal;
1083    }
1084    let (did, operator) = match (account_at(&state, &named), acting_operator(&state)) {
1085        (Ok(did), Ok(operator)) => (did, operator),
1086        (Err(refusal), _) | (_, Err(refusal)) => return *refusal,
1087    };
1088    if did == state.registry.service_did() {
1089        tracing::warn!(%did, "hard delete refused: that is the server's own account");
1090        return refused(&didbot_pds::ProvisionError::ServerAccount {
1091            did,
1092            operation: "deletion",
1093        });
1094    }
1095    let body = body.map(|Json(body)| body).unwrap_or_default();
1096    let keeping = match crate::dashboard_accounts::Keeping::of(
1097        &state,
1098        body.kind,
1099        body.never_wrote,
1100        body.idle_for.as_deref(),
1101    ) {
1102        Ok(keeping) => keeping,
1103        Err(refusal) => return *refusal,
1104    };
1105    let recursive = body.recursive;
1106    let subtree = match subtree_of(&state, &did) {
1107        Ok(subtree) => subtree,
1108        Err(error) => return refused(&error),
1109    };
1110    if !recursive && subtree.len() > 1 {
1111        return refusal(
1112            StatusCode::CONFLICT,
1113            "LiveChildren",
1114            format!(
1115                "{did} has {} accounts beneath it; pass recursive to take them too",
1116                subtree.len() - 1
1117            ),
1118        );
1119    }
1120    for target in &subtree {
1121        let Some(account) = state.registry.account(target) else {
1122            continue;
1123        };
1124        if let Err(why) = keeping.check(&state, &account) {
1125            tracing::info!(%did, %target, %why, "hard delete refused: an account is kept");
1126            return refused(&didbot_pds::ProvisionError::Kept {
1127                did: target.clone(),
1128                why,
1129            });
1130        }
1131    }
1132    let may_go = |account: &didbot_pds::HostedAccount| keeping.check(&state, account);
1133    let mut taken = Vec::new();
1134    for target in subtree {
1135        if let Err(error) = state
1136            .registry
1137            .hard_delete_if(&target, &target, &operator, &may_go)
1138        {
1139            // Whatever was already taken is gone.
1140            return refused_partway(&error, &taken);
1141        }
1142        taken.push(acted(&state, &target));
1143    }
1144    did_it("delete", taken)
1145}
1146
1147/// `POST /dashboard/api/accounts/{did}/revoke-tokens`
1148///
1149/// Ends every token that authenticates as the account: its account tokens,
1150/// its app logins and its unredeemed codes. The account keeps its state and
1151/// its locks, whatever they are. The ledger gains a `tokensRevoked` entry
1152/// naming the operator, unless nothing was live to end. The answer counts
1153/// what ended.
1154async fn account_revoke_tokens(
1155    State(state): State<AppState>,
1156    axum::extract::Path(named): axum::extract::Path<String>,
1157    headers: axum::http::HeaderMap,
1158) -> Response {
1159    if let Err(refusal) = signed_in(&state, &headers, true) {
1160        return *refusal;
1161    }
1162    let (did, operator) = match (account_at(&state, &named), acting_operator(&state)) {
1163        (Ok(did), Ok(operator)) => (did, operator),
1164        (Err(refusal), _) | (_, Err(refusal)) => return *refusal,
1165    };
1166    match state.registry.revoke_tokens(&did, &operator) {
1167        Ok(revoked) => Json(json!({
1168            "action": "revoke-tokens",
1169            "accounts": [Acted {
1170                did,
1171                ledger: revoked.entry,
1172            }],
1173            "tokens": revoked.tokens,
1174            "logins": revoked.logins,
1175        }))
1176        .into_response(),
1177        Err(error) => refused(&error),
1178    }
1179}
1180
1181/// `POST /dashboard/api/accounts/{did}/credentials`
1182///
1183/// `bot.did.updateCredentials` as the operator, the change `didbot account
1184/// credentials` makes: the body is that method's, with the account named
1185/// in the path instead of `did`.
1186async fn account_credentials(
1187    State(state): State<AppState>,
1188    axum::extract::Path(named): axum::extract::Path<String>,
1189    headers: axum::http::HeaderMap,
1190    crate::routes::XrpcBody(body): crate::routes::XrpcBody,
1191) -> Response {
1192    if let Err(refusal) = signed_in(&state, &headers, true) {
1193        return *refusal;
1194    }
1195    let did = match account_at(&state, &named) {
1196        Ok(did) => did,
1197        Err(refusal) => return *refusal,
1198    };
1199    crate::tree::update_credentials_from_dashboard(&state, &headers, &did, &body).await
1200}
1201
1202/// What `/dashboard/api/accounts/{did}/delete` accepts.
1203#[derive(serde::Deserialize, Default)]
1204#[serde(default)]
1205pub struct DeleteBody {
1206    /// Take everything beneath the account too.
1207    recursive: bool,
1208    /// Refuse unless every account taken is known never to have written,
1209    /// and is no host or service account unless `kind` names it.
1210    #[serde(rename = "neverWrote")]
1211    never_wrote: bool,
1212    /// Refuse unless every account taken is idle this long, a span such as
1213    /// `24h`. A `neverWrote` delete has to name it.
1214    #[serde(rename = "idleFor")]
1215    idle_for: Option<String>,
1216    /// Refuse unless every account taken is of this kind.
1217    kind: Option<didbot_pds::AccountKind>,
1218}
1219
1220/// `did` and everything beneath it, deepest first — the order a hard delete
1221/// has to take them in.
1222fn subtree_of(state: &AppState, did: &str) -> Result<Vec<String>, didbot_pds::ProvisionError> {
1223    // Breadth-first down, then reversed: a level is only ever listed after
1224    // its parent, so reversing puts every child ahead of the account it
1225    // hangs from.
1226    let mut order = vec![state.registry.children(did).map(|_| did.to_owned())?];
1227    let mut next = 0;
1228    while next < order.len() {
1229        let children = state.registry.children(&order[next])?;
1230        order.extend(
1231            children
1232                .into_iter()
1233                .map(|child| child.did.as_str().to_owned()),
1234        );
1235        next += 1;
1236    }
1237    order.reverse();
1238    Ok(order)
1239}