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}