Skip to main content

didbot_serve/
surface.rs

1//! What the two dashboards this server serves do the same way.
2//!
3//! `docs/web-surfaces.md` calls them surface 3 ([`crate::dashboard`], the
4//! operations page) and surface 4 ([`crate::analytics`], the read-only
5//! picture). They keep their own prefixes, their own routes and their own
6//! files; what lives here is the plumbing underneath both: the tri-state
7//! panel envelope, the operator session a control is reached behind, the
8//! JSON refusal shape an API route answers with, and serving a static asset
9//! under its content type.
10
11use axum::http::{header, HeaderMap, StatusCode};
12use axum::response::{IntoResponse, Response};
13use axum::Json;
14use serde::Serialize;
15use serde_json::json;
16
17use crate::routes::AppState;
18
19/// The tri-state envelope every panel answers in.
20///
21/// `plan/ops-dashboard.md` sets one rule above everything else it asks for:
22/// *never display invented data as if it were real*. A panel says which of
23/// these three it is rather than leaving a client to infer it from shape,
24/// and only [`PanelState::Live`] carries any values at all.
25///
26/// Generic over the live payload so each panel keeps its own honest shape;
27/// the three variant *names* are what a client (and
28/// `crate::dashboard::tests::panel_state_never_carries_numbers_without_a_backing_capability`)
29/// reads.
30#[derive(Debug, Clone, Serialize)]
31#[serde(tag = "status", rename_all = "snake_case")]
32pub enum PanelState<T: Serialize> {
33    /// A real capability, read fresh from this process's own state.
34    Live {
35        /// The real values. The only variant that carries any.
36        data: T,
37    },
38    /// The epic this panel needs does not exist yet.
39    NotImplemented {
40        /// The plan epics whose absence this panel is reporting, by id —
41        /// named so an operator can go read why, not just that something is
42        /// missing.
43        missing: Vec<&'static str>,
44    },
45    /// A real capability whose read did not answer. Distinct from a live
46    /// panel reporting zero, which is a different fact.
47    Unreachable {
48        /// Why, in a sentence a human reads once and moves on from.
49        reason: String,
50    },
51}
52
53/// The cookie a signed-in operator's browser carries.
54pub const SESSION_COOKIE: &str = "didbot_operator";
55
56/// The header a mutating operator request must echo its session's CSRF
57/// token in. A cookie rides along on a cross-origin form post; this does
58/// not.
59pub const CSRF_HEADER: &str = "x-didbot-csrf";
60
61/// The `{"error": .., "message": ..}` shape every route here refuses in.
62///
63/// Boxed by the callers that return it from a `Result`: a refusal is the
64/// cold path, and an `axum` `Response` is large enough that returning one
65/// by value makes every success pay for it.
66pub fn refusal(status: StatusCode, error: &str, message: impl Into<String>) -> Response {
67    (
68        status,
69        Json(json!({ "error": error, "message": message.into() })),
70    )
71        .into_response()
72}
73
74/// Reads [`SESSION_COOKIE`] out of a request's `Cookie` header.
75pub fn session_cookie(headers: &HeaderMap) -> Option<String> {
76    headers
77        .get(header::COOKIE)?
78        .to_str()
79        .ok()?
80        .split(';')
81        .filter_map(|pair| pair.split_once('='))
82        .find(|(name, _)| name.trim() == SESSION_COOKIE)
83        .map(|(_, value)| value.trim().to_owned())
84}
85
86/// The operator sign-in, or a `503` naming why this deployment has none.
87pub fn operator(
88    state: &AppState,
89) -> Result<&std::sync::Arc<crate::operator::OperatorAuth>, Box<Response>> {
90    state.operator.as_ref().ok_or_else(|| {
91        Box::new(refusal(
92            StatusCode::SERVICE_UNAVAILABLE,
93            "NoOperator",
94            "this deployment was started with no --operator, so there is no DID a sign-in \
95             could authenticate as",
96        ))
97    })
98}
99
100/// Resolves a request to a signed-in session, or the refusal to answer with.
101///
102/// `mutating` requests must also carry [`CSRF_HEADER`]; see
103/// [`crate::operator::OperatorAuth::authorize`].
104pub fn signed_in(
105    state: &AppState,
106    headers: &HeaderMap,
107    mutating: bool,
108) -> Result<String, Box<Response>> {
109    let operator = operator(state)?;
110    let refused = || {
111        Box::new(refusal(
112            StatusCode::UNAUTHORIZED,
113            "NotSignedIn",
114            "sign in as this deployment's operator at /dashboard/login",
115        ))
116    };
117    let token = session_cookie(headers).ok_or_else(refused)?;
118    let presented = headers
119        .get(CSRF_HEADER)
120        .and_then(|value| value.to_str().ok());
121    let ok = if mutating {
122        operator.authorize(&token, presented)
123    } else {
124        operator.lookup(&token).is_some()
125    };
126    if ok {
127        Ok(token)
128    } else {
129        Err(refused())
130    }
131}
132
133/// The DID this deployment's operator signed in as, as the ledger records
134/// an actor. Only reached behind [`signed_in`], which is what establishes
135/// that the caller controls it.
136pub fn acting_operator(state: &AppState) -> Result<String, Box<Response>> {
137    Ok(operator(state)?.operator_did().to_owned())
138}
139
140/// One static asset, served under `content_type`.
141pub fn asset(content_type: &'static str, body: impl IntoResponse) -> Response {
142    ([(header::CONTENT_TYPE, content_type)], body).into_response()
143}
144
145/// An HTML document.
146pub fn html(body: impl IntoResponse) -> Response {
147    asset("text/html; charset=utf-8", body)
148}
149
150/// A stylesheet.
151pub fn stylesheet(body: impl IntoResponse) -> Response {
152    asset("text/css; charset=utf-8", body)
153}
154
155/// A script.
156pub fn script(body: impl IntoResponse) -> Response {
157    asset("text/javascript; charset=utf-8", body)
158}