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}