Skip to main content

wire/server/
config.rs

1// This Source Code Form is subject to the terms of the Mozilla Public
2// License, v. 2.0. If a copy of the MPL was not distributed with this
3// file, You can obtain one at https://mozilla.org/MPL/2.0/.
4// Copyright (c) 2026 2youg1 and the sprawling contributors
5
6//! Serving configuration: routes and bodies. The judgements the routes
7//! apply are `wire::reception`'s; a refusal made minutes later has no way
8//! home, which is why a command carries the [`Reply`] address of whoever
9//! sent it.
10
11use std::sync::Arc;
12use std::sync::atomic::{AtomicU64, Ordering};
13
14use axum::Router;
15use axum::extract::{DefaultBodyLimit, Request, State};
16use axum::http::{StatusCode, header};
17use axum::middleware::{Next, from_fn_with_state};
18use axum::response::{IntoResponse, Response};
19use axum::routing::{MethodRouter, get, post};
20use kernel::{Address, AxError, B3Hash, Sealed, Seq};
21use serde::Deserialize;
22use tokio::sync::broadcast;
23
24use crate::answer::Answer;
25use crate::assets::ClientAssets;
26use crate::auth::Pairing;
27use crate::command::{Command, WireCommand};
28use crate::frames::Query;
29use crate::reception::{
30    Admission, Arrival, BindFace, Door, ListenerOrigins, PageHeaders, decide_admission,
31    offered_pairing,
32};
33use crate::reply::{AcpProgress, Reply};
34
35use super::committed::Committed;
36
37mod enrolment;
38
39use super::bundle::bundle_routes;
40use super::door::LocalDoor;
41use super::guard::{Stamp, Stored, enter, preflight, stamp};
42use super::pairing::{accept_pairing, offer_challenge, open_session};
43use super::socket::upgrade;
44use super::uploads::{accept_acp, accept_drop, accept_recording};
45use enrolment::accept_enrolment;
46/// Answers a query from the city's derived views, with the ledger
47/// position the answer was read at. Synchronous: a query reads a
48/// projection, and a projection that needed to block would be a query
49/// pretending to be a command.
50pub type Answering = Arc<dyn Fn(Query) -> (Seq, Result<Answer, AxError>) + Send + Sync>;
51
52pub type SecretSink =
53    Arc<dyn Fn(Command<Sealed<String>>, Reply) -> Result<(), AxError> + Send + Sync>;
54
55/// How long the enrolment route waits for the worker to say what
56/// happened.
57///
58/// Bounded because the worker may be inside a dispatch that runs for
59/// minutes, and an HTTP request that waited for it would look like a
60/// hang. Waiting longer would not help in that case and hurts in every
61/// other: what is being waited for is a queue hop and a vault write,
62/// both of which are milliseconds.
63const ENROLMENT_PATIENCE: std::time::Duration = std::time::Duration::from_secs(2);
64
65/// Everything the shell needs that it must not decide for itself.
66///
67/// Each sink is injected as a closure rather than as a trait:
68/// `wire` declares no `pub trait` (it is not on the seam list,
69/// ARCHITECTURE section 3), and one implementation is not a seam.
70pub struct ServeConfig {
71    /// The client bundle, handed in by the assembly layer so this crate
72    /// never learns where build artifacts live.
73    pub client: Arc<ClientAssets>,
74    /// Where a recording goes, and the line of text that comes back.
75    ///
76    /// A route of its own rather than a Command, for the reason
77    /// enrolment has one: a Command is accepted and answered later
78    /// through the event stream, and the text of what somebody just said
79    /// has to come back to the tab that recorded it. A Query is the
80    /// other shape that answers, and a query that spent seconds on a
81    /// provider would be a command pretending to be a read.
82    ///
83    /// The media type travels beside the bytes because a container the
84    /// city cannot name has no media type to send, and the judgement of
85    /// which containers exist belongs to whoever speaks the audio wire.
86    pub transcribe_sink: TranscribeSink,
87    /// A file dropped onto the composer, kept by the city.
88    pub drop_sink: DropSink,
89    /// Where a request from an outside editor goes. Separate from
90    /// `commands` because it arrives over its own route, carries its own
91    /// authentication, and gets an answer rather than an event stream.
92    pub acp: AcpSink,
93    /// Where an accepted Command goes. **Accepting is not running it**: a
94    /// dispatch may take hours, and awaiting it inside the socket task
95    /// would tie the work to the lifetime of one browser tab. The sink
96    /// takes the command, returns, and the progress comes back as events.
97    ///
98    /// The [`Reply`] travels with the command because the answer arrives
99    /// long after this call returned, and a refusal belongs to the peer
100    /// that caused it.
101    pub commands: Arc<dyn Fn(WireCommand, Reply) -> Result<(), AxError> + Send + Sync>,
102    /// The event fan-out. This crate only subscribes; the writer is
103    /// whoever owns the Ledger, because the ledger line is the event.
104    pub events: broadcast::Sender<Committed>,
105    /// What a model is saying, while it is still saying it.
106    ///
107    /// A second channel rather than a second variant on the first,
108    /// because the two carry different kinds of thing and lag
109    /// differently: an increment a slow client missed is nothing, and an
110    /// event it missed is history it must recover. Sharing one channel
111    /// would let a burst of increments push records out of a reader's
112    /// window.
113    pub deltas: broadcast::Sender<crate::frames::Delta>,
114    /// The process log, on its way to whoever has the log lens open.
115    ///
116    /// A third channel for the reason there is a second: what it
117    /// carries is discardable, and a slow reader that lost a line has
118    /// lost nothing. Sharing the event channel would let a city running
119    /// at the `wire` floor push history out of that reader's window.
120    pub logs: broadcast::Sender<crate::frames::LogLine>,
121    /// What running commands write, while they still write it. A fourth
122    /// channel, so a command flooding its stdout cannot push increments
123    /// or log lines out of a slow reader's window.
124    pub outputs: broadcast::Sender<crate::frames::LiveOutput>,
125    /// What running commands already wrote, in the order they wrote it.
126    /// A session sends it after `Welcome`, having subscribed to
127    /// `outputs` first, so a piece may arrive twice and never not at all.
128    pub outputs_so_far: Arc<dyn Fn() -> Vec<crate::frames::LiveOutput> + Send + Sync>,
129    /// The performance monitor, for the sessions that ask to watch it.
130    pub monitor: MonitorFeed,
131    /// Answers a query from the city's derived views.
132    pub queries: Answering,
133    /// Where an enrolled credential goes. Takes the full [`Command`] and
134    /// not [`WireCommand`]: this is the one sink whose input has no byte
135    /// form, which is what keeps enrolment a local act.
136    pub secrets: SecretSink,
137    /// Which city this server is. Told at the handshake, because a client
138    /// that only hears what happens next cannot know the name of a city
139    /// that was initialised last month.
140    pub city: Option<Address>,
141    /// The seq of the last record broadcast on `events`, which every
142    /// welcome names so a reconnecting client fetches only what it
143    /// missed. The writer is whoever broadcasts, and it advances the head
144    /// before each broadcast.
145    pub head: Arc<LedgerHead>,
146    /// The chain hash of the Ledger's first line, read once at startup:
147    /// a ledger keeps its epoch for its whole life.
148    pub epoch: Option<B3Hash>,
149    /// This machine's door for a browser: its pairing code, open codes,
150    /// paired device keys and live session tokens (`crates/wire/spec/Server.lean` §8-93).
151    pub door: LocalDoor,
152}
153
154/// The seq of the last record the city has broadcast, readable without a
155/// lock.
156///
157/// An atomic rather than a field behind the views' lock, because a
158/// reader may hold the views for a long time and a hello must not wait
159/// for it. Stores the seq plus one, so zero is a ledger with no record.
160#[derive(Debug, Default)]
161pub struct LedgerHead(AtomicU64);
162
163impl LedgerHead {
164    #[must_use]
165    pub fn at(head_seq: Option<Seq>) -> Self {
166        let head = Self::default();
167        if let Some(seq) = head_seq {
168            head.advance(seq);
169        }
170        head
171    }
172
173    /// Moves the head to `seq`. A seq of `u64::MAX` has no successor to
174    /// store and leaves the head where it was; a ledger reaches it only
175    /// after `Seq::next` has refused every later record.
176    pub fn advance(&self, seq: Seq) {
177        if let Some(stored) = seq.value().checked_add(1) {
178            self.0.store(stored, Ordering::Release);
179        }
180    }
181
182    #[must_use]
183    pub fn read(&self) -> Option<Seq> {
184        self.0.load(Ordering::Acquire).checked_sub(1).map(Seq::new)
185    }
186}
187
188pub(crate) struct ShellState {
189    pub(crate) commands: Arc<dyn Fn(WireCommand, Reply) -> Result<(), AxError> + Send + Sync>,
190    pub(crate) events: broadcast::Sender<Committed>,
191    pub(crate) deltas: broadcast::Sender<crate::frames::Delta>,
192    pub(crate) logs: broadcast::Sender<crate::frames::LogLine>,
193    pub(crate) outputs: broadcast::Sender<crate::frames::LiveOutput>,
194    pub(crate) outputs_so_far: Arc<dyn Fn() -> Vec<crate::frames::LiveOutput> + Send + Sync>,
195    pub(crate) monitor: MonitorFeed,
196    pub(crate) queries: Answering,
197    pub(crate) secrets: SecretSink,
198    pub(crate) acp: AcpSink,
199    pub(crate) transcribe_sink: TranscribeSink,
200    pub(crate) drop_sink: DropSink,
201    /// Which face this listener presents, and the key it demands: the
202    /// verdict of [`crate::reception::decide_bind`], and the only thing
203    /// besides the live sessions any door reads to judge a caller.
204    pub(crate) face: BindFace,
205    pub(crate) door: LocalDoor,
206    pub(crate) city: Option<Address>,
207    pub(crate) head: Arc<LedgerHead>,
208    pub(crate) epoch: Option<B3Hash>,
209}
210
211/// The performance monitor as a session sees it (`crates/wire/spec/Frames/Monitor.lean` §8-47g).
212///
213/// Whether anybody watches is decided where the history is kept; a
214/// session holds what `watch` returned for as long as it watches, and
215/// dropping that value is how it stops counting.
216#[derive(Clone)]
217pub struct MonitorFeed {
218    pub watch: Arc<dyn Fn(crate::frames::Watched) -> Box<dyn Send> + Send + Sync>,
219    /// One reading a second while anybody watches. A reading a slow
220    /// session missed is not stated: the next one is a second away.
221    pub samples: broadcast::Sender<crate::frames::Sample>,
222    /// Sets the beat the monitor samples at and remembers it for the
223    /// city (`crates/wire/spec/Frames/Monitor.lean` §8-47i).
224    pub beat: Arc<dyn Fn(crate::frames::BeatMs) + Send + Sync>,
225}
226
227/// Where an outside request goes once the token has been judged.
228///
229/// **The body travels as the JSON it arrived as, and nothing here
230/// reads a field out of it.** The grammar of an inbound request is
231/// `agent_protocols::Incoming`, whose `parse` is the only constructor and
232/// therefore the only place the rules about it hold; a struct here
233/// with the same four fields, deserialized first and copied across
234/// field by field, would leave `parse` with no caller outside its own
235/// tests and carry the plaintext token into a value that derives
236/// `Debug`. The token is read where it is judged, below, and
237/// travels no further.
238pub type AcpSink =
239    Arc<dyn Fn(&serde_json::Value, Pairing) -> Result<AcpProgress, AxError> + Send + Sync>;
240
241/// Where a recording goes: the bytes, the media type the browser
242/// recorded into, and the line of text that comes back.
243pub type TranscribeSink = Arc<dyn Fn(Vec<u8>, String) -> Result<String, AxError> + Send + Sync>;
244
245/// Where a file dropped onto the composer goes: its name and its bytes
246/// in, the absolute path the city kept it at out (`crates/wire/spec/Server.lean` §8-49).
247pub type DropSink = Arc<dyn Fn(&str, &[u8]) -> Result<String, AxError> + Send + Sync>;
248
249/// The largest file `/drop` takes. axum's own default of 2 MiB refuses an
250/// ordinary screenshot or PDF; the limit sits on that one route, because
251/// the others carry a line of text or a recording.
252pub const DROP_BYTES_MAX: usize = 64 * 1024 * 1024;
253
254/// The enrolment body: a realm, a name, and the value that will never be
255/// seen again outside the vault.
256///
257/// The realm arrives as text because the caller chooses it: which word a
258/// key is filed under is a building's or a form's decision, not a set
259/// this server fixes, so nothing here enumerates the legal realms. What
260/// the two segments must spell is `kernel::SecretRef`'s grammar, and the
261/// route judges them with it before the credential moves.
262#[derive(Debug, Deserialize)]
263pub struct EnrollBody {
264    pub realm: String,
265    pub name: String,
266    pub value: String,
267}
268
269/// Builds the route table. Split from `serve` so a test can exercise the
270/// routes over an in-process transport without owning a port.
271///
272/// `face` is the binding verdict's, so the key every door judges against
273/// is decided once, before the socket exists; `origins` are the names the
274/// same address answers to, read by the entry decision in front of every
275/// route (`crates/wire/spec/Reception/Entry.lean` §8-94).
276///
277/// # Errors
278/// The listener's own headers cannot be spelled as HTTP headers.
279pub fn router(
280    config: &ServeConfig,
281    face: BindFace,
282    origins: &ListenerOrigins,
283) -> Result<Router, AxError> {
284    let state = Arc::new(ShellState {
285        commands: Arc::clone(&config.commands),
286        events: config.events.clone(),
287        deltas: config.deltas.clone(),
288        logs: config.logs.clone(),
289        outputs: config.outputs.clone(),
290        outputs_so_far: Arc::clone(&config.outputs_so_far),
291        monitor: config.monitor.clone(),
292        queries: Arc::clone(&config.queries),
293        secrets: Arc::clone(&config.secrets),
294        acp: Arc::clone(&config.acp),
295        transcribe_sink: Arc::clone(&config.transcribe_sink),
296        drop_sink: Arc::clone(&config.drop_sink),
297        face,
298        door: config.door.clone(),
299        city: config.city.clone(),
300        head: Arc::clone(&config.head),
301        epoch: config.epoch,
302    });
303    let names = Arc::new(origins.clone());
304    let headers = PageHeaders::every_listener();
305    let entered = |arrival: Arrival, handler: MethodRouter<Arc<ShellState>>| {
306        handler.route_layer(from_fn_with_state((arrival, Arc::clone(&names)), enter))
307    };
308    let door = |door: Door, handler| entered(Arrival::Door(door), paired(door, &state, handler));
309    let answers = Router::new()
310        .route("/ws", entered(Arrival::Socket, get(upgrade)))
311        .route(
312            "/transcribe",
313            door(Door::Transcribe, post(accept_recording)),
314        )
315        .route("/enroll", door(Door::Enroll, post(accept_enrolment)))
316        .route(
317            "/drop",
318            door(
319                Door::Drop,
320                post(accept_drop).layer(DefaultBodyLimit::max(DROP_BYTES_MAX)),
321            ),
322        )
323        // `/acp` judges the same credential from inside the handler: an
324        // editor offers it as a body key, and an unpaired editor is
325        // answered by `agent_protocols::admit` rather than at the door.
326        .route("/acp", entered(Arrival::Door(Door::Acp), post(accept_acp)))
327        // This machine's door for a browser (`crates/wire/spec/Server.lean` §8-93):
328        // the routes a browser reaches before it holds a credential.
329        .route("/pair", entered(Arrival::Pairing, post(accept_pairing)))
330        .route(
331            "/session/challenge",
332            entered(Arrival::Pairing, post(offer_challenge)),
333        )
334        .route("/session", entered(Arrival::Pairing, post(open_session)))
335        .layer(from_fn_with_state(
336            Arc::new(Stamp::of(&headers, Stored::Never)?),
337            stamp,
338        ))
339        .with_state(state);
340    // The page itself: a browser has to load it before it can pair, so
341    // these routes ask for no credential, and still for this Host.
342    let page = bundle_routes(Arc::clone(&config.client), &headers)?.route_layer(
343        from_fn_with_state((Arrival::Page, Arc::clone(&names)), enter),
344    );
345    Ok(answers
346        .merge(page)
347        .layer(from_fn_with_state(names, preflight)))
348}
349
350/// The body a refused HTTP request is answered with: what failed, and
351/// what to do about it.
352pub(crate) fn refusal_text(err: &AxError) -> String {
353    format!("{}: {}", err.action(), err.recovery())
354}
355
356/// One door with the credential judgement in front of it.
357///
358/// The door is baked into the layer rather than read back off the
359/// request path, so the route table above is the only place a path is
360/// spelled.
361fn paired(
362    door: Door,
363    state: &Arc<ShellState>,
364    handler: MethodRouter<Arc<ShellState>>,
365) -> MethodRouter<Arc<ShellState>> {
366    handler.route_layer(from_fn_with_state((door, Arc::clone(state)), admit_request))
367}
368
369/// The layer in front of every acting door: read the offered credential,
370/// hand it to the one judgement, and pass or refuse.
371async fn admit_request(
372    State((door, state)): State<(Door, Arc<ShellState>)>,
373    request: Request,
374    next: Next,
375) -> Response {
376    let offered = request
377        .headers()
378        .get(header::AUTHORIZATION)
379        .and_then(|value| value.to_str().ok());
380    let pairing = match state.door.pairing(&state.face, offered_pairing(offered)) {
381        Ok(pairing) => pairing,
382        Err(err) => return (StatusCode::INTERNAL_SERVER_ERROR, refusal_text(&err)).into_response(),
383    };
384    match decide_admission(door, pairing) {
385        Admission::Admit(Pairing::Held | Pairing::Absent) => next.run(request).await,
386        Admission::Refuse(err) => (StatusCode::FORBIDDEN, refusal_text(&err)).into_response(),
387    }
388}