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