Skip to main content

choir_node/
lib.rs

1//! L3 node daemon: a minimal git smart-HTTP server (DECISIONS.md D12).
2//!
3//! v1 wraps `git http-backend` (git's own CGI) over bare repositories, so
4//! the daemon is a thin, self-hostable shell over git plumbing, and
5//! platform behavior (sequencer, queue, identity) layers on top.
6//! ForgeMark benchmarks this surface directly.
7//!
8//! Authentication is per-actor basic auth ([`AuthTable`], `--auth-file`),
9//! plus the credentials self-service has issued ([`accounts`],
10//! `--accounts-file`); the platform API ([`platform`]) additionally
11//! verifies ed25519 op signatures. The bind stays loopback-only: beyond
12//! localhost you still need TLS or an SSH tunnel so tokens aren't sent in
13//! the clear.
14//!
15//! # Where this sits
16//!
17//! `docs/architecture.md` is the map of the whole workspace.
18//! This crate is L3, the daemon that serves both git smart-HTTP and the platform API.
19//!
20//! It builds on [`choir_fs`], [`choir_hash`], [`choir_identity`], [`choir_oplog`], [`choir_sequencer`] and [`choir_view`].
21//!
22//! The operator's guide to this daemon:
23//!
24#![doc = include_str!("../../../docs/operating/running-a-node.md")]
25
26use std::io::Read;
27use std::path::{Path, PathBuf};
28
29mod account_page;
30pub mod accounts;
31pub mod acl;
32mod bound;
33mod browse;
34pub mod hooks;
35mod join_page;
36pub mod limits;
37pub mod platform;
38/// How many times this process has shelled out to git while serving.
39///
40/// Exposed so a test can budget it. A page's read latency is mostly its
41/// process spawns, and the count is the half of that which does not move
42/// with machine load -- see `tests/phase1_spawns.rs`.
43#[must_use]
44pub fn git_invocations() -> u64 {
45    browse::GIT_INVOCATIONS.load(std::sync::atomic::Ordering::Relaxed)
46}
47
48mod people_page;
49pub mod portable;
50mod prepare;
51pub mod profile;
52pub mod provision;
53pub mod queue;
54pub mod queue_api;
55pub mod quota;
56mod readme;
57pub mod reject;
58mod session;
59mod signin_page;
60pub mod ssh;
61mod ui;
62mod work;
63
64pub use platform::Platform;
65
66/// The commit this binary was built from, or the literal `unknown` when
67/// the build had no way to find out. See `build.rs`.
68///
69/// `choirctl status` could already name the file that is serving; it
70/// could not say what that file was built from, and "the rebuild never
71/// reached the running process" is indistinguishable from "it did" until
72/// something the process itself reports says otherwise.
73pub const BUILD_COMMIT: &str = env!("CHOIR_BUILD_COMMIT");
74
75/// Where [`BUILD_COMMIT`] came from: `env` (the installer passed
76/// `CHOIR_GIT_HEAD`, the sound path), `git` (best-effort at build time),
77/// or `unavailable` (no commit could be determined).
78pub const BUILD_SOURCE: &str = env!("CHOIR_BUILD_SOURCE");
79
80/// Whether the build tree had uncommitted changes. Only meaningful under
81/// `BUILD_SOURCE == "git"`, and even then best-effort: cargo cannot rerun
82/// the build script on every source edit, so this can be stale where
83/// [`BUILD_COMMIT`] cannot.
84pub const BUILD_DIRTY: &str = env!("CHOIR_BUILD_DIRTY");
85
86/// The build stamp as served under `/api/view.build`.
87#[must_use]
88pub fn build_json() -> serde_json::Value {
89    serde_json::json!({
90        "format_version": 1,
91        "commit": BUILD_COMMIT,
92        "source": BUILD_SOURCE,
93        "dirty": BUILD_DIRTY == "true",
94        "dirty_trusted": BUILD_SOURCE == "git",
95    })
96}
97
98/// One line naming the running binary's provenance, for the startup log.
99#[must_use]
100pub fn build_line() -> String {
101    let commit = match BUILD_COMMIT.len() {
102        40 => &BUILD_COMMIT[..12],
103        _ => BUILD_COMMIT,
104    };
105    let dirty = if BUILD_DIRTY == "true" { " +dirty" } else { "" };
106    format!("build {commit}{dirty} (stamp source: {BUILD_SOURCE})")
107}
108
109/// The design tokens from `ui.css`, alone, for the documentation book.
110///
111/// The book and this daemon's own pages should look like one product,
112/// and the honest way to get that is one definition of the palette
113/// rather than two that agree today. What is shared is deliberately
114/// *only* the tokens: `ui.css` below the reset styles `h2` as a small
115/// uppercase eyebrow and gives `body` a centred max-width, which is
116/// right for a node page built out of `section` elements and wrong for
117/// a book with a sidebar. Components stay per-surface; colour, type
118/// scale, spacing and easing are shared.
119///
120/// The cut is the reset marker rather than a line number, and a missing
121/// marker is a panic rather than a silent half-file: this is rendered
122/// into a generated artifact that a staleness test compares, so a slice
123/// that quietly returned everything would put the whole sheet in the
124/// book and still look like it worked.
125///
126/// # Panics
127///
128/// If `ui.css` no longer carries the reset marker that separates its
129/// tokens from its component styles, or either theme selector this
130/// widens for mdBook.
131#[must_use]
132pub fn ui_tokens_css() -> String {
133    const SHEET: &str = include_str!("ui.css");
134    const RESET: &str = "/* --- global reset (token-only) ---";
135
136    // mdBook picks a palette by putting a class on `<html>`; this
137    // workspace picks one with `data-theme` (D56). Rather than keep a
138    // second copy of the palette in mdBook's spelling, each selector is
139    // widened to answer to both. Two literal rewrites, and a missing
140    // one is a panic: a silent no-op here renders the book's light
141    // theme in dark colours, which reads as a CSS bug rather than as a
142    // generator that stopped generating.
143    // The `:not()` is load-bearing and is not about matching: it is
144    // there for specificity. `ui.css` resolves "follow the system" with
145    // `@media (prefers-color-scheme:light){ :root:not([data-theme="dark"]) }`,
146    // which scores (0,2,0). A plain `html.coal` scores (0,1,1) and
147    // loses to it, so mdBook's dark themes rendered in light colours on
148    // a machine set to light -- a book that looked like the theme
149    // picker was broken rather than like a specificity bug. Each
150    // selector below scores (0,3,0) and beats it, while still matching
151    // exactly the same elements.
152    const THEMES: [(&str, &str); 2] = [
153        (
154            ":root[data-theme=\"dark\"]{",
155            ":root[data-theme=\"dark\"],\
156             :root.coal:not([data-theme=\"light\"]),\
157             :root.navy:not([data-theme=\"light\"]),\
158             :root.ayu:not([data-theme=\"light\"]){",
159        ),
160        (
161            ":root[data-theme=\"light\"]{",
162            ":root[data-theme=\"light\"],\
163             :root.light:not([data-theme=\"dark\"]),\
164             :root.rust:not([data-theme=\"dark\"]){",
165        ),
166    ];
167
168    let mut tokens = SHEET
169        .split_once(RESET)
170        .expect("ui.css must keep the reset marker that ends its token block")
171        .0
172        .trim_end()
173        .to_string();
174
175    for (from, to) in THEMES {
176        assert!(
177            tokens.contains(from),
178            "ui.css must keep the `{from}` selector the book's theme mapping widens"
179        );
180        tokens = tokens.replace(from, to);
181    }
182
183    format!(
184        "/* generated from crates/choir-node/src/ui.css -- do not edit.\n\
185         \x20  Regenerate: cargo run -p choir-cli --example gen-surface\n\
186         \x20\n\
187         \x20  The token block of that sheet, cut at its reset marker, with\n\
188         \x20  each theme selector widened to answer to mdBook's `<html>`\n\
189         \x20  class as well as this workspace's `data-theme`. Edit the\n\
190         \x20  tokens there and the node's pages and the book move together.\n\
191         \x20  Component styles are deliberately NOT shared: that sheet\n\
192         \x20  styles `h2` as an uppercase eyebrow and centres `body`,\n\
193         \x20  which is right for a node page and wrong for a book. */\n\n{tokens}\n"
194    )
195}
196
197/// Per-actor credentials: username → token, checked as HTTP basic auth
198/// (the standard git-over-HTTP shape; every forge client speaks it).
199///
200/// L8 note: usernames are actor ids and tokens are per-actor secrets
201/// minted by the operator; key-signature-based challenge auth can
202/// replace the token *check* later without changing the wire shape.
203pub type AuthTable = std::collections::HashMap<String, String>;
204
205/// Default maximum body size for every `/api/...` request: 1 MiB.
206pub const DEFAULT_API_BODY_BYTES: u64 = 1024 * 1024;
207
208/// Readiness refuses when the filesystem reports less than 1 GiB free.
209pub const DEFAULT_READY_MIN_FREE_BYTES: u64 = 1024 * 1024 * 1024;
210
211/// A running node daemon serving repos under a root directory.
212pub struct Node {
213    root: PathBuf,
214    server: std::sync::Arc<tiny_http::Server>,
215    port: u16,
216    auth: std::sync::Arc<Option<AuthTable>>,
217    platform: Option<std::sync::Arc<Platform>>,
218    /// What the merge queue needs before `/api/queue/run` can do
219    /// anything (D68). `None` answers 501: a node with no CI command
220    /// cannot decide whether a candidate is good, and a queue that
221    /// landed everything unchecked would be a worse `git push`.
222    queue: Option<queue_api::QueueConfig>,
223    /// The rounds running right now, so a second request for a target
224    /// already in flight is refused rather than raced.
225    queue_in_flight: std::sync::Arc<queue_api::InFlight>,
226    /// The scheme absolute URLs handed to *people* are written with:
227    /// invite links, page origins, and the `Secure` attribute on cookies.
228    ///
229    /// [`Node::behind_tls_proxy`] sets it, because a node terminating
230    /// plaintext on loopback behind a proxy is reached over https by
231    /// everyone except the proxy.
232    scheme: &'static str,
233    /// The scheme this node's own socket actually speaks.
234    ///
235    /// Separate from [`Node::scheme`] and never overridden, because the
236    /// two answer different questions and one field answering both is a
237    /// bug this repository has already shipped: git's `pre-receive` hook
238    /// calls back to `127.0.0.1` on this very socket, and when
239    /// `behind_tls_proxy` moved the single field the hook began speaking
240    /// TLS to a plaintext port and every push hung in the handshake.
241    /// Anything addressed to loopback uses this one.
242    listener_scheme: &'static str,
243    /// Loopback secret handed to repo hooks via env so their callback to
244    /// `/api/git-update` passes the auth gate without user credentials.
245    internal_token: String,
246    /// Trusted-keys file to watch, so `allowed_signers` tracks it
247    /// without a restart. `None` = generated once at startup.
248    keys_watch: Option<std::sync::Arc<KeysWatch>>,
249    /// Per-repository authorization table (D29), watched like the keys
250    /// file. `None` = no `--acl-file`, so every authenticated actor
251    /// reaches every repository, which is the pre-D29 behaviour.
252    acl_watch: Option<std::sync::Arc<AclWatch>>,
253    /// The browser page, prebuilt and keyed by view sequence. Shared
254    /// across request threads so one render serves every reader until
255    /// the state it describes changes.
256    ui_cache: std::sync::Arc<ui::UiCache>,
257    /// The attributed request log (D33). `None` = no `--request-log`, so
258    /// nothing is recorded, which is the pre-D33 behaviour.
259    request_log: Option<std::sync::Arc<limits::RequestLog>>,
260    /// Per-user token buckets (D33). `None` = no ceiling was configured,
261    /// so no request is ever refused for rate.
262    rate: Option<std::sync::Arc<limits::RateLimiter>>,
263    /// Self-service credentials (D36). `None` = no `--accounts-file`, so
264    /// the only credentials are the ones the operator wrote by hand.
265    accounts: Option<std::sync::Arc<accounts::Accounts>>,
266    /// Whether passkeys are usable on this node (D39, D71). Off unless
267    /// [`Node::enable_passkeys`] is called, and separate from
268    /// [`Node::accounts`] on purpose: enrolment and the WebAuthn write
269    /// path both live behind the accounts store, so without this switch
270    /// turning on self-service credentials would turn on browser
271    /// signing with them in the same move, and a deployment that says
272    /// it does not offer passkeys could not be telling the truth.
273    passkeys: bool,
274    /// Browser sessions opened by a passkey (D71). In memory, so they end
275    /// with the process.
276    sessions: std::sync::Arc<session::Sessions>,
277    /// Admission control for the pre-auth routes (D57).
278    ///
279    /// Always present, unlike [`Node::rate`]: those routes answer before
280    /// any credential is checked, so "no ceiling was configured" is not
281    /// an option there the way it is for an authenticated caller.
282    public_rate: std::sync::Arc<limits::PublicLimiter>,
283    /// Whether this node hands out ssh access, which decides whether the
284    /// join page offers a key field. Offering one on a node with no ssh
285    /// surface collects a key nothing will ever use.
286    ssh_enabled: bool,
287    /// The file table and the store's grants, merged and dated, cached
288    /// against the generations of both and the second it was dated at.
289    /// Rebuilt when any of the three moves, so authorization does not
290    /// rebuild a table per request and cannot serve a grant past its
291    /// deadline (D66).
292    acl_merged: std::sync::RwLock<Option<MergedAcl>>,
293    /// Per-user ceilings on push size and workspace count (D37). Both
294    /// unset = nothing is ever refused for quota, which is the pre-D37
295    /// behaviour.
296    quotas: quota::Quotas,
297    /// Absolute API request-body ceiling. Unlike per-user quotas this is
298    /// never exempt: parsing an operator's unbounded JSON consumes the
299    /// same memory as parsing anyone else's.
300    api_body_limit: std::num::NonZeroU64,
301    /// Whether authenticated review pages expose passkey-backed write
302    /// controls. The private beta disables this and keeps signed CLI
303    /// submissions as the only mutation path.
304    browser_writes: bool,
305    site_repo: Option<String>,
306    /// Free-space floor used by the authenticated readiness endpoint.
307    ready_min_free_bytes: u64,
308    /// Running request totals, exported by `/metrics`. Shared with every
309    /// request thread rather than owned by one, since the counting
310    /// happens on whichever thread served the request.
311    counters: std::sync::Arc<limits::Counters>,
312    /// When this process began serving, as seconds since the epoch.
313    /// `choir_process_start_time_seconds`, which is how a restart-loop
314    /// alert sees a restart at all.
315    started_unix: u64,
316    /// Distinguishes an intentional `unblock()` from a receive timeout.
317    /// tiny_http reports both as `Ok(None)` from `recv_timeout`.
318    shutdown: std::sync::Arc<std::sync::atomic::AtomicBool>,
319}
320
321/// A watched trusted-keys file and the mtime last folded into
322/// `allowed_signers`.
323struct KeysWatch {
324    path: PathBuf,
325    mtime: std::sync::Mutex<Option<std::time::SystemTime>>,
326}
327
328/// The `, N expired` half of the ACL startup and reload lines (D66),
329/// or nothing at all when no grant has a deadline in the past.
330///
331/// Said out loud because a deadline that has already passed reads
332/// exactly like a grant that was never written: the holder is refused,
333/// and the file still shows the line. A count is the cheapest thing that
334/// tells those two apart.
335fn expired_note(table: &acl::Acl) -> String {
336    match table.expired(accounts::now_secs()) {
337        0 => String::new(),
338        n => format!(", {n} expired"),
339    }
340}
341
342/// The cached merged table and everything it is only valid for: the ACL
343/// file's epoch, the account store's generation, and the second it was
344/// dated at (D66). All three have to match or it is rebuilt.
345type MergedAcl = (u64, u64, u64, std::sync::Arc<acl::Effective>);
346
347/// A watched ACL file, the mtime last parsed, and the table in force.
348struct AclWatch {
349    path: PathBuf,
350    mtime: std::sync::Mutex<Option<std::time::SystemTime>>,
351    table: std::sync::RwLock<std::sync::Arc<acl::Acl>>,
352    /// Bumped on every successful reload. A counter rather than the
353    /// table's address, because an address can be reused by the next
354    /// allocation and a stale merge on an authorization path is exactly
355    /// the failure worth spending a `u64` to make impossible.
356    epoch: std::sync::atomic::AtomicU64,
357}
358
359impl Node {
360    /// Binds to `127.0.0.1:port` (0 = ephemeral) over `root`, with no
361    /// authentication — localhost/dev only.
362    ///
363    /// # Errors
364    ///
365    /// Returns an error when the socket cannot be bound or `root` cannot be
366    /// created.
367    pub fn bind(root: &Path, port: u16) -> std::io::Result<Self> {
368        Self::bind_with_auth(root, port, None)
369    }
370
371    /// Binds like [`Node::bind`]; when `auth` is `Some`, every request
372    /// must carry valid basic-auth credentials from the table or it is
373    /// answered with 401 before touching git.
374    ///
375    /// # Errors
376    ///
377    /// Same failure modes as [`Node::bind`].
378    pub fn bind_with_auth(
379        root: &Path,
380        port: u16,
381        auth: Option<AuthTable>,
382    ) -> std::io::Result<Self> {
383        Self::bind_full(root, "127.0.0.1", port, auth, None)
384    }
385
386    /// Full-control bind: address, port, auth, and optional TLS
387    /// (PEM certificate chain + PEM private key).
388    ///
389    /// A non-loopback `addr` is refused without TLS — plaintext basic
390    /// auth must never cross a real network (standing privacy rule:
391    /// nothing leaves loopback without an explicit, protected choice).
392    ///
393    /// # Errors
394    ///
395    /// Returns an error when the socket cannot be bound, `root` cannot
396    /// be created, the TLS material is invalid, or a non-loopback bind
397    /// is requested without TLS.
398    pub fn bind_full(
399        root: &Path,
400        addr: &str,
401        port: u16,
402        auth: Option<AuthTable>,
403        tls: Option<(Vec<u8>, Vec<u8>)>,
404    ) -> std::io::Result<Self> {
405        let loopback = addr
406            .parse::<std::net::IpAddr>()
407            .map(|ip| ip.is_loopback())
408            .unwrap_or(false);
409        if !loopback && tls.is_none() {
410            return Err(std::io::Error::new(
411                std::io::ErrorKind::InvalidInput,
412                "refusing non-loopback bind without TLS",
413            ));
414        }
415        std::fs::create_dir_all(root)?;
416        let scheme = if tls.is_some() { "https" } else { "http" };
417        let listener_scheme = scheme;
418        let server = match tls {
419            Some((certificate, private_key)) => tiny_http::Server::https(
420                (addr, port),
421                tiny_http::SslConfig {
422                    certificate,
423                    private_key,
424                },
425            ),
426            None => tiny_http::Server::http((addr, port)),
427        }
428        .map_err(|e| std::io::Error::other(e.to_string()))?;
429        let port = match server.server_addr().to_ip() {
430            Some(addr) => addr.port(),
431            None => 0,
432        };
433        Ok(Self {
434            root: root.to_path_buf(),
435            server: std::sync::Arc::new(server),
436            port,
437            auth: std::sync::Arc::new(auth),
438            platform: None,
439            queue: None,
440            queue_in_flight: std::sync::Arc::new(queue_api::InFlight::default()),
441            site_repo: None,
442            scheme,
443            listener_scheme,
444            internal_token: choir_identity::ActorKey::generate().actor_id().to_hex(),
445            keys_watch: None,
446            acl_watch: None,
447            ui_cache: std::sync::Arc::new(ui::UiCache::new()),
448            request_log: None,
449            rate: None,
450            accounts: None,
451            passkeys: false,
452            sessions: std::sync::Arc::new(session::Sessions::default()),
453            // One ceiling for the whole pre-auth surface, because there
454            // is no per-client key to hold a second one against (D59).
455            // Sized for a node's worth of real joining rather than for one
456            // reader: a reader fetches the join page, submits it, and
457            // lands on the welcome page, which is three requests.
458            public_rate: std::sync::Arc::new(limits::PublicLimiter::new(600)),
459            ssh_enabled: false,
460            acl_merged: std::sync::RwLock::new(None),
461            quotas: quota::Quotas::default(),
462            api_body_limit: std::num::NonZeroU64::new(DEFAULT_API_BODY_BYTES)
463                .expect("the default API body limit is nonzero"),
464            browser_writes: true,
465            ready_min_free_bytes: DEFAULT_READY_MIN_FREE_BYTES,
466            counters: std::sync::Arc::new(limits::Counters::default()),
467            started_unix: std::time::SystemTime::now()
468                .duration_since(std::time::UNIX_EPOCH)
469                .map(|since| since.as_secs())
470                .unwrap_or_default(),
471            shutdown: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)),
472        })
473    }
474
475    /// Sets the absolute body ceiling for every `/api/...` route.
476    pub fn enable_api_body_limit(&mut self, bytes: std::num::NonZeroU64) {
477        self.api_body_limit = bytes;
478    }
479
480    /// Removes browser mutation controls and their preparation endpoint.
481    ///
482    /// **It withholds authorship, not credentials** (D73). A browser under
483    /// this flag renders no control that would put an operation in the op
484    /// log -- no verdict, no comment, no `/api/prepare` -- because that is
485    /// the launch gate D39 shipped behind and the thing an operator
486    /// switches on when they are ready for it.
487    ///
488    /// It does not withhold signing in, enrolling the passkey that signs
489    /// in, asking for access (D72), or the operator's console. None of
490    /// those reaches the log: the accounts store is a node-owned file and
491    /// revocation is deletion (D36). Withholding them was the same flag
492    /// doing two jobs, and the second job made the node's own manifest
493    /// untrue -- `passkeys=enabled` beside a posture that would not serve
494    /// the file the ceremony is written in.
495    pub fn disable_browser_writes(&mut self) {
496        self.browser_writes = false;
497    }
498
499    /// Presents one repository as this node's entire browser surface.
500    ///
501    /// `/` becomes that repository instead of the index, and the browser
502    /// answers for no other repository. This is what a node serving a
503    /// project's own domain wants: a reader arriving at
504    /// `git.example.com` came for that project, and an index naming
505    /// every other repository the host holds is both noise and a
506    /// disclosure.
507    ///
508    /// Presentation only. It changes no grant: git access stays the
509    /// ACL's answer, and a repository hidden here is still clonable by
510    /// whoever could clone it before.
511    /// # Errors
512    ///
513    /// Refuses a name this node could not hold, checked here rather than
514    /// at the call site: the router trusts this value against the disk,
515    /// so the grammar has to be enforced where it is stored.
516    pub fn serve_single_repository(&mut self, repo: &str) -> std::io::Result<()> {
517        let invalid = || {
518            std::io::Error::new(
519                std::io::ErrorKind::InvalidInput,
520                format!("not a repository this node can present: {repo}"),
521            )
522        };
523        let (owner, name) = repo.split_once('/').ok_or_else(invalid)?;
524        if !provision::safe_segment(owner) || !provision::safe_segment(name) {
525            return Err(invalid());
526        }
527        self.site_repo = Some(repo.to_string());
528        Ok(())
529    }
530
531    /// Sets the free-space floor below which `/readyz` refuses traffic.
532    pub fn enable_ready_min_free_bytes(&mut self, bytes: u64) {
533        self.ready_min_free_bytes = bytes;
534    }
535
536    /// Records every served request to `path` (D33), rotating it at
537    /// `max_bytes` — see [`limits::RequestLog`] for the line format, the
538    /// rotation rule, and what is deliberately never written.
539    ///
540    /// # Errors
541    ///
542    /// Returns the failure to open the file. Fatal by design: an operator
543    /// who asked for a record and did not get one should learn that at
544    /// startup rather than from its absence during an incident.
545    pub fn enable_request_log(&mut self, path: PathBuf, max_bytes: u64) -> std::io::Result<()> {
546        self.request_log = Some(std::sync::Arc::new(limits::RequestLog::open(
547            path, max_bytes,
548        )?));
549        Ok(())
550    }
551
552    /// Limits each authenticated user to the given requests per minute
553    /// per class (D33). `None` leaves that class unlimited.
554    ///
555    /// Never applied to the loopback hook callback, to a holder of a D29
556    /// `@node` grant, or on a node without authentication — see
557    /// [`Node::serve_forever`] for why each of those would be worse than
558    /// the flood it prevents.
559    pub fn enable_rate_limit(
560        &mut self,
561        api_per_minute: Option<std::num::NonZeroU32>,
562        git_per_minute: Option<std::num::NonZeroU32>,
563    ) {
564        let limiter = limits::RateLimiter::new(api_per_minute, git_per_minute);
565        self.rate = limiter.is_active().then(|| std::sync::Arc::new(limiter));
566    }
567
568    /// Sets the per-user quotas (D37): the largest git request body one
569    /// user may send, and the most workspaces one user may hold at once.
570    /// `None` leaves that ceiling off.
571    ///
572    /// Exempt exactly where the D33 rate limiter is exempt, and for the
573    /// same reason — see [`Node::serve_forever`]. A quota that can lock
574    /// an operator out of their own node is the failure this must not
575    /// cause.
576    pub fn enable_quotas(
577        &mut self,
578        push_bytes: Option<std::num::NonZeroU64>,
579        workspaces: Option<std::num::NonZeroU32>,
580    ) {
581        self.quotas = quota::Quotas {
582            push_bytes,
583            workspaces,
584        };
585    }
586
587    /// Enables the platform API (`/api/submit`, `/api/view`) backed by
588    /// `platform`. Call before [`Node::serve_forever`].
589    pub fn enable_platform(&mut self, platform: Platform) {
590        // One of the three halves of the join in `enable_accounts` and
591        // `enable_passkeys`: whichever flag is applied last attaches the
592        // store, so passkey verification does not depend on the order the
593        // daemon happens to configure in (D39). The store is withheld
594        // while passkeys are off, which is what keeps the write path shut
595        // rather than merely unadvertised.
596        if self.passkeys {
597            if let Some(store) = self.accounts.as_ref() {
598                platform.attach_accounts(store.clone());
599            }
600        }
601        self.platform = Some(std::sync::Arc::new(platform));
602    }
603
604    /// Enables `POST /api/queue/run` (D5, D68).
605    ///
606    /// Without it the endpoint answers 501. There is deliberately no
607    /// timer: a node that spends CI on its own schedule surprises
608    /// whoever pays for it, and a round only a clock can start is one no
609    /// test can reach without waiting on wall-clock time. An operator's
610    /// cron, a hook, or a person decides the cadence.
611    pub fn enable_queue(&mut self, config: queue_api::QueueConfig) {
612        self.queue = Some(config);
613    }
614
615    /// The platform this node serves, or `None` if the platform API was
616    /// never enabled.
617    #[must_use]
618    pub fn platform(&self) -> Option<&Platform> {
619        self.platform.as_deref()
620    }
621
622    /// Brings the bare repos back into agreement with the view before the
623    /// node serves anything. See [`Platform::reconcile_git_refs`] for what
624    /// it repairs and what it refuses to.
625    ///
626    /// Separate from [`Node::enable_platform`] and from
627    /// [`Node::serve_forever`] so it is called deliberately: it writes git
628    /// refs and can append compensating ops, which is not something a
629    /// constructor should do behind a caller's back.
630    pub fn reconcile_refs(&self) -> crate::platform::RefReconciliation {
631        self.platform
632            .as_ref()
633            .map(|p| p.reconcile_git_refs(&self.root))
634            .unwrap_or_default()
635    }
636
637    /// Watches the trusted-keys file and regenerates
638    /// `<root>/.choir/allowed_signers` whenever its mtime moves, so
639    /// registering a *signing* key is "append a line" — the same
640    /// mechanism the platform registry already uses for submission
641    /// keys, which until now diverged from push-certificate
642    /// verification and left the two lists out of step.
643    pub fn watch_keys_file(&mut self, path: PathBuf) {
644        self.keys_watch = Some(std::sync::Arc::new(KeysWatch {
645            mtime: std::sync::Mutex::new(std::fs::metadata(&path).and_then(|m| m.modified()).ok()),
646            path,
647        }));
648    }
649
650    /// Rewrites `allowed_signers` if the watched keys file changed.
651    /// Runs on the accept loop, so rewrites never race each other.
652    ///
653    /// A malformed keys file leaves the existing signer list in place
654    /// (a partial list would silently stop verifying somebody's
655    /// pushes); the mtime is still recorded so the complaint is printed
656    /// once per edit rather than once per request.
657    fn refresh_allowed_signers(&self) {
658        let Some(watch) = &self.keys_watch else {
659            return;
660        };
661        let mtime = std::fs::metadata(&watch.path)
662            .and_then(|m| m.modified())
663            .ok();
664        let mut last = watch.mtime.lock().expect("keys mtime lock");
665        if mtime.is_none() || mtime == *last {
666            return;
667        }
668        *last = mtime;
669        match parse_keys_file(&watch.path) {
670            Ok(signers) => {
671                if let Err(e) = write_allowed_signers(&self.root, &signers) {
672                    eprintln!("allowed_signers: write failed: {e}");
673                } else {
674                    eprintln!("allowed_signers: reloaded ({} keys)", signers.len());
675                }
676                // Same file, same edit: pick up name bindings here too, so
677                // binding a name to an already-trusted key does not wait
678                // for the policy's failed-signature reload trigger.
679                if let Some(platform) = &self.platform {
680                    platform.set_key_names(&signers);
681                }
682            }
683            Err(e) => eprintln!("allowed_signers: keys file unusable, keeping previous: {e}"),
684        }
685    }
686
687    /// Enforces per-repository authorization (D29) from `path`, reloaded
688    /// whenever its mtime moves — so granting access is "append a line",
689    /// the same discipline as the trusted-keys file.
690    ///
691    /// # Errors
692    ///
693    /// Returns a message when the file cannot be read or does not parse.
694    /// This is fatal by design: there is no previous table to fall back
695    /// to at startup, and an empty table under a fail-closed ACL locks
696    /// out everyone including the operator.
697    pub fn watch_acl_file(&mut self, path: PathBuf) -> Result<(), String> {
698        let table = acl::Acl::load(&path)?;
699        eprintln!(
700            "acl enabled ({} grants{})",
701            table.len(),
702            expired_note(&table)
703        );
704        self.acl_watch = Some(std::sync::Arc::new(AclWatch {
705            mtime: std::sync::Mutex::new(std::fs::metadata(&path).and_then(|m| m.modified()).ok()),
706            table: std::sync::RwLock::new(std::sync::Arc::new(table)),
707            epoch: std::sync::atomic::AtomicU64::new(0),
708            path,
709        }));
710        Ok(())
711    }
712
713    /// Declares that a TLS-terminating proxy sits in front of this node,
714    /// so absolute URLs it builds are written `https` and the cookies it
715    /// sets carry `Secure`.
716    ///
717    /// Declared by the operator rather than read from
718    /// `X-Forwarded-Proto`, because the node cannot tell a header its
719    /// proxy set from one a client sent: trusting it would mean any
720    /// caller that can reach the node decides how its invite links are
721    /// spelled. The proxy this repository ships already *sets* that
722    /// header rather than appending to it, for the same reason
723    /// `X-Forwarded-For` is cleared there (D59), and a declaration needs
724    /// no such care.
725    ///
726    /// The defect this exists for: an invite is a bearer credential
727    /// carried in a URL, and behind the proxy the node was writing that
728    /// URL with `http`. The recipient's first request would carry the
729    /// credential in cleartext and only then be redirected.
730    pub fn behind_tls_proxy(&mut self) {
731        // Only the public half. `listener_scheme` stays what this socket
732        // speaks, because the hook callbacks address loopback directly and
733        // never pass the proxy at all.
734        self.scheme = "https";
735    }
736
737    /// Turns on passkeys: WebAuthn enrolment and the browser write path
738    /// that verifies assertions against enrolled keys (D39, D71).
739    ///
740    /// Separate from [`Node::enable_accounts`] even though both need the
741    /// accounts store, because a node can reasonably offer self-service
742    /// credentials without offering browser signing, and the private beta
743    /// says in its manifest that it does exactly that. Without this
744    /// switch that sentence could not be true: the enrolment routes and
745    /// `platform`'s assertion check are both reachable the moment the
746    /// store exists.
747    pub fn enable_passkeys(&mut self) {
748        self.passkeys = true;
749        // The third way into the same join. Enabling passkeys after both
750        // of the others is the ordinary case, and without this the store
751        // would never reach the policy.
752        if let (Some(platform), Some(store)) = (self.platform.as_ref(), self.accounts.as_ref()) {
753            platform.attach_accounts(store.clone());
754        }
755    }
756
757    /// Turns on account and token self-service (D36) from the store at
758    /// `path`, optionally generating the `authorized_keys` D31's forced
759    /// commands live in.
760    ///
761    /// # Errors
762    ///
763    /// Refuses without `--auth-file` and without `--acl-file`, for the
764    /// reason D29 and D33 refuse the same combinations: a credential
765    /// issued on a node that authenticates nobody is not a credential,
766    /// and one issued on a node with no ACL is a credential to every
767    /// repository, which is the thing being issued *against*. Also
768    /// returns the store's own load failures.
769    /// `actor_keys` names the trusted-keys file a redemption may bind
770    /// one actor key into (`--invite-binds-keys`). `None` keeps the
771    /// pre-existing behaviour, in which an actor key reaches the node
772    /// only by an operator editing that file.
773    pub fn enable_accounts(
774        &mut self,
775        path: PathBuf,
776        keys_out: Option<accounts::SshKeysOut>,
777        actor_keys: Option<PathBuf>,
778    ) -> Result<(), String> {
779        let Some(table) = self.auth.as_ref().as_ref() else {
780            return Err(
781                "--accounts-file needs --auth-file: an issued token is checked where every \
782                 other credential is"
783                    .to_string(),
784            );
785        };
786        if self.acl_watch.is_none() {
787            return Err(
788                "--accounts-file needs --acl-file: without a table to grade them against, \
789                 an issued grant would be a grant to every repository"
790                    .to_string(),
791            );
792        }
793        // Every operator credential's name, so self-service can never
794        // issue an account that shadows one.
795        let reserved = table.keys().cloned().collect();
796        self.ssh_enabled = keys_out.is_some();
797        let mut store = accounts::Accounts::open(path, keys_out, reserved)?;
798        if let Some(actor_keys) = actor_keys {
799            store = store.binding_actor_keys_into(actor_keys);
800        }
801        eprintln!("accounts enabled ({} issued)", store.len());
802        let store = std::sync::Arc::new(store);
803        // Any order: whichever flag is applied last performs the join, so
804        // a passkey submission is verifiable regardless of how the daemon
805        // was configured (D39). Withheld while passkeys are off.
806        if self.passkeys {
807            if let Some(platform) = self.platform.as_ref() {
808                platform.attach_accounts(store.clone());
809            }
810        }
811        self.accounts = Some(store);
812        Ok(())
813    }
814
815    /// Reparses the ACL file if it changed. Runs on the accept loop, so
816    /// an edit takes effect on the *next* request with no restart.
817    ///
818    /// A malformed file leaves the previous table in force and complains
819    /// once per edit — the same rule as the keys file, for the same
820    /// reason: a partially parsed ACL would silently revoke access.
821    fn refresh_acl(&self) {
822        let Some(watch) = &self.acl_watch else {
823            return;
824        };
825        let mtime = std::fs::metadata(&watch.path)
826            .and_then(|m| m.modified())
827            .ok();
828        let mut last = watch.mtime.lock().expect("acl mtime lock");
829        if mtime.is_none() || mtime == *last {
830            return;
831        }
832        *last = mtime;
833        match acl::Acl::load(&watch.path) {
834            Ok(table) => {
835                eprintln!(
836                    "acl: reloaded ({} grants{})",
837                    table.len(),
838                    expired_note(&table)
839                );
840                *watch.table.write().expect("acl write lock") = std::sync::Arc::new(table);
841                watch
842                    .epoch
843                    .fetch_add(1, std::sync::atomic::Ordering::Release);
844            }
845            Err(e) => eprintln!("acl: file unusable, keeping previous: {e}"),
846        }
847    }
848
849    /// The ACL table currently in force, if one is configured: the
850    /// operator's file, merged with the grants self-service has issued
851    /// (D36).
852    ///
853    /// Merged rather than checked separately so that every enforcement
854    /// point — the git chokepoint, the API's per-endpoint table, the
855    /// response filter, the D33 rate-limit exemption — keeps asking one
856    /// table one question. The merge is cached against both sources'
857    /// generations, so the ordinary request pays two atomic loads.
858    ///
859    /// The cache is keyed on the current second as well (D66), which is
860    /// the whole reason a deadline can be trusted here: a table merged
861    /// once and held would keep answering for a grant that has lapsed,
862    /// and no reload would happen to invalidate it because nothing about
863    /// the file changed. One rebuild per second per node is what that
864    /// costs, over a table of a few dozen rows.
865    fn acl_now(&self) -> Option<std::sync::Arc<acl::Effective>> {
866        let watch = self.acl_watch.as_ref()?;
867        let now = crate::accounts::now_secs();
868        let epoch = watch.epoch.load(std::sync::atomic::Ordering::Acquire);
869        let generation = self.accounts.as_ref().map_or(0, |store| store.generation());
870        if let Some((cached_epoch, cached_generation, cached_now, table)) = self
871            .acl_merged
872            .read()
873            .expect("merged acl read lock")
874            .as_ref()
875        {
876            if *cached_epoch == epoch && *cached_generation == generation && *cached_now == now {
877                return Some(std::sync::Arc::clone(table));
878            }
879        }
880        let file = std::sync::Arc::clone(&watch.table.read().expect("acl read lock"));
881        let merged = match self.accounts.as_ref() {
882            Some(store) => file.merged(&store.acl()),
883            None => (*file).clone(),
884        };
885        let effective = std::sync::Arc::new(merged.at(now));
886        *self.acl_merged.write().expect("merged acl write lock") =
887            Some((epoch, generation, now, std::sync::Arc::clone(&effective)));
888        Some(effective)
889    }
890
891    /// Port the daemon is listening on.
892    pub fn port(&self) -> u16 {
893        self.port
894    }
895
896    /// Writes the handoff file the SSH shim reads (D31): where this
897    /// daemon is listening, and the loopback secret its git hooks
898    /// authenticate with.
899    ///
900    /// Both are per-process values — an ephemeral port, a secret minted
901    /// at startup — so a forced command written once cannot carry them.
902    /// The secret is not returned to the caller, only written, at 0600.
903    ///
904    /// The ACL file goes in too, when one is configured, so a forced
905    /// command that forgot `--acl-file` still enforces what this daemon
906    /// enforces rather than reaching every repository.
907    ///
908    /// # Errors
909    ///
910    /// Any I/O error creating or writing the file.
911    pub fn write_ssh_handoff(&self, path: &Path) -> std::io::Result<()> {
912        let base = format!("{}://127.0.0.1:{}", self.scheme, self.port);
913        let acl = self.acl_watch.as_ref().map(|watch| watch.path.as_path());
914        // D36: the store goes in too, so the shim grades a self-served
915        // account by the same grants the HTTP route does. Call
916        // `enable_accounts` before this, or the line is absent and every
917        // issued grant is invisible over SSH.
918        let accounts = self.accounts.as_ref().map(|store| store.path());
919        ssh::write_handoff(path, &base, &self.internal_token, acl, accounts)
920    }
921
922    /// Creates a bare repo `name` (e.g. `"owner/repo.git"`) with pushes
923    /// enabled.
924    ///
925    /// # Errors
926    ///
927    /// Fails when the path exists or `git init` fails.
928    pub fn create_repo(&self, name: &str) -> std::io::Result<()> {
929        create_repo_in(&self.root, name).map(|_| ())
930    }
931
932    /// Brings an *existing* bare repo under this root back under the
933    /// sequencer: the same hook and the same git config
934    /// [`Node::create_repo`] installs.
935    ///
936    /// This is what a restore needs, and without it a restore is silently
937    /// unsound. Objects arrive as a bundle, which `git clone --bare` and
938    /// `git fetch` both unpack into a repo with **no `pre-receive` hook** —
939    /// and a repo with no hook is served normally while every push into it
940    /// bypasses the sequencer entirely, landing refs that no op in the log
941    /// ever records. The alternative order is worse: letting the node
942    /// create the repos empty and unpacking afterwards means startup
943    /// reconciliation sees a log naming commits git does not have, decides
944    /// the log is unbackable, and *appends retractions for every restored
945    /// ref* (see [`Platform::reconcile_git_refs`]).
946    ///
947    /// It also re-points `gpg.ssh.allowedSignersFile`, which
948    /// [`Node::create_repo`] wrote as an absolute path into the root that
949    /// existed then. A restore onto a different path leaves that config
950    /// naming a directory that may not exist, or worse, one that does and
951    /// holds someone else's keys.
952    ///
953    /// Idempotent, and safe to run on every start. The one value it does
954    /// not overwrite is `receive.certNonceSeed`: rotating it would refuse
955    /// the signed pushes already in flight against the old seed.
956    ///
957    /// # Errors
958    ///
959    /// Fails when the path does not exist, is not a git repository, or a
960    /// `git config` call fails.
961    pub fn adopt_repo(&self, name: &str) -> std::io::Result<()> {
962        let path = self.repo_path(name)?;
963        if !path.join("objects").is_dir() {
964            return Err(std::io::Error::new(
965                std::io::ErrorKind::NotFound,
966                format!("{name}: not a bare git repository"),
967            ));
968        }
969        self.configure_repo(&path)
970    }
971
972    /// Adopts every bare repository already under this node's root,
973    /// whatever put it there, and returns how many.
974    ///
975    /// Adoption is what installs the `pre-receive` hook, so until a
976    /// repository has been adopted it is served with **no hook** and
977    /// every push into it bypasses the sequencer, landing refs that no
978    /// op in the log ever records — invariants 5 and 6 both, broken
979    /// silently.
980    ///
981    /// That used to be reachable in ordinary operation, because
982    /// adoption only ever happened for repositories named in
983    /// `--create`. A repository restored from a bundle, moved in, or
984    /// created against a running node was listed nowhere and hooked
985    /// never. Walking the root closes the gap at its source: the
986    /// question "what is this node about to serve" is answered by the
987    /// filesystem, which is the same thing [`portable::export`] already
988    /// asks.
989    ///
990    /// Idempotent, and meant to run on every start: the one value
991    /// [`Node::adopt_repo`] does not overwrite is
992    /// `receive.certNonceSeed`, so an ordinary restart costs a few `git
993    /// config` calls and changes nothing.
994    ///
995    /// # Errors
996    ///
997    /// Fails when the root cannot be walked, or when a repository under
998    /// it cannot be adopted. Both are fatal rather than skipped:
999    /// serving an unadopted repository is the exact failure this
1000    /// prevents, so a node that cannot guarantee the hook must not
1001    /// start.
1002    pub fn adopt_existing_repos(&self) -> std::io::Result<usize> {
1003        let found = crate::portable::repos(&self.root).map_err(|why| {
1004            std::io::Error::new(
1005                std::io::ErrorKind::InvalidInput,
1006                format!("cannot list repositories under the root: {why}"),
1007            )
1008        })?;
1009        for name in &found {
1010            self.adopt_repo(name)?;
1011        }
1012        Ok(found.len())
1013    }
1014
1015    /// The configuration and hook shared by [`Node::create_repo`] and
1016    /// [`Node::adopt_repo`], applied to an initialized bare repo.
1017    fn configure_repo(&self, path: &Path) -> std::io::Result<()> {
1018        configure_repo_in(&self.root, path)
1019    }
1020}
1021
1022/// [`Node::configure_repo`], against a root rather than a bound node.
1023///
1024/// Split out because creating a repository needs nothing from a `Node`
1025/// except where its repositories live, and the API handler that now
1026/// creates them holds the root but not the node — the same reason
1027/// `/api/queue/run` is routed outside the platform.
1028fn configure_repo_in(root: &Path, path: &Path) -> std::io::Result<()> {
1029    // Seeded once and then left alone: `create_repo` reaches here with
1030    // it unset, `adopt_repo` with it already set from whenever the repo
1031    // was made, and re-seeding would invalidate every signed push
1032    // holding a nonce from the old seed.
1033    if !std::process::Command::new("git")
1034        .args(["config", "--get", "receive.certNonceSeed"])
1035        .current_dir(path)
1036        .stdout(std::process::Stdio::null())
1037        .status()?
1038        .success()
1039        && !std::process::Command::new("git")
1040            .args([
1041                "config",
1042                "receive.certNonceSeed",
1043                &choir_identity::ActorKey::generate().actor_id().to_hex(),
1044            ])
1045            .current_dir(path)
1046            .status()?
1047            .success()
1048    {
1049        return Err(std::io::Error::other("git config failed"));
1050    }
1051    let ok = std::process::Command::new("git")
1052            .args(["config", "http.receivepack", "true"])
1053            .current_dir(path)
1054            .status()?
1055            .success()
1056            // Pin hooks to this repo: a host-global core.hooksPath (set
1057            // by e.g. husky) would otherwise silently bypass the
1058            // sequencer hook below.
1059            && std::process::Command::new("git")
1060                .args(["config", "core.hooksPath", "hooks"])
1061                .current_dir(path)
1062                .status()?
1063                .success()
1064            // Advertise push certificates (`git push --signed`) and
1065            // verify their ssh signatures against the allowed-signers
1066            // file (per-actor keys, L8).
1067            && std::process::Command::new("git")
1068                .args(["config", "gpg.format", "ssh"])
1069                .current_dir(path)
1070                .status()?
1071                .success()
1072            && std::process::Command::new("git")
1073                .args(["config", "gpg.ssh.allowedSignersFile"])
1074                .arg(
1075                    root.canonicalize()
1076                        .unwrap_or_else(|_| root.to_path_buf())
1077                        .join(".choir")
1078                        .join("allowed_signers"),
1079                )
1080                .current_dir(path)
1081                .status()?
1082                .success()
1083            // Serve filtered fetches, which is what makes `git clone
1084            // --filter=blob:none --sparse` and `git sparse-checkout` work
1085            // against this node. Git refuses a filter without it (D48).
1086            //
1087            // This is the whole of choir's answer to lazy checkouts of a
1088            // large repository: the client already ships the feature, and
1089            // the alternative -- a userspace filesystem hydrating blobs on
1090            // demand -- would be our code on the read path of every file
1091            // access, needing a kernel extension per platform, to
1092            // reimplement something the transport already does. A node
1093            // that speaks git inherits partial clone; it should not
1094            // reimplement it.
1095            && std::process::Command::new("git")
1096                .args(["config", "uploadpack.allowFilter", "true"])
1097                .current_dir(path)
1098                .status()?
1099                .success()
1100            // A promisor client fetches missing blobs by exact oid, and
1101            // those oids are reachable-but-not-advertised as far as
1102            // upload-pack is concerned. Without this every lazy hydration
1103            // after the initial clone fails, which presents as a working
1104            // clone whose first `git checkout` cannot read a file.
1105            && std::process::Command::new("git")
1106                .args(["config", "uploadpack.allowAnySHA1InWant", "true"])
1107                .current_dir(path)
1108                .status()?
1109                .success();
1110    if !ok {
1111        return Err(std::io::Error::other("git config failed"));
1112    }
1113    // The pre-receive hook routes every ref update of a push through
1114    // the platform sequencer (pre-receive, not update: only
1115    // pre-/post-receive see GIT_PUSH_CERT_* for signed pushes, and
1116    // one invocation covers the whole push). Outside the daemon (no
1117    // CHOIR_API) it is a no-op.
1118    //
1119    // Git applies no ref until this hook exits zero, so a refusal on
1120    // the third ref of a push has already left two ops in the durable
1121    // log for refs git will never create. Those refs are then stuck:
1122    // the pusher's `old` is git's absent value while the view holds
1123    // the stranded one, so every retry loses the CAS. Hence the
1124    // retraction pass over what this push already had accepted, which
1125    // is a compensating op rather than an erasure -- the log is
1126    // append-only, and an aborted push belongs in the history.
1127    let hook = path.join("hooks").join("pre-receive");
1128    std::fs::write(
1129            &hook,
1130            concat!(
1131                "#!/bin/sh\n",
1132                "# choir: route this push's ref updates through the platform sequencer.\n",
1133                "if [ -z \"$CHOIR_API\" ]; then cat >/dev/null; exit 0; fi\n",
1134                // The response body is captured rather than discarded
1135                // (which is what `curl -f` did): the node's refusals name
1136                // a reason and a repair, and a pusher told only "rejected
1137                // by sequencer" has to ask a human what they did wrong.
1138                "reply=$(mktemp) || exit 1\n",
1139                "post() {\n",
1140                "  payload=$(printf '{\"repo\":\"%s\",\"refname\":\"%s\",\"old\":\"%s\",\"new\":\"%s\",\"user\":\"%s\",\"cert_status\":\"%s\",\"signer\":\"%s\"}' \\\n",
1141                "    \"$CHOIR_REPO\" \"$3\" \"$1\" \"$2\" \"$CHOIR_USER\" \"$GIT_PUSH_CERT_STATUS\" \"$GIT_PUSH_CERT_SIGNER\")\n",
1142                "  code=$(curl -sk -o \"$reply\" -w '%{http_code}' \\\n",
1143                "    --connect-timeout 5 --max-time 30 \\\n",
1144                "    -X POST -H \"X-Choir-Internal: $CHOIR_INTERNAL\" \\\n",
1145                "    -d \"$payload\" \"$4\")\n",
1146                // A connection that never completed reports 000, which
1147                // falls through to the failure branch with the others.
1148                //
1149                // The timeouts are what make that sentence true. Without
1150                // them curl waits forever, and this hook holds the push
1151                // open while it does: the client has already sent every
1152                // object and sits there with no output and no failure.
1153                // That is exactly how a wrong scheme in this URL was
1154                // experienced -- a TLS handshake against a plaintext
1155                // port, hanging rather than refusing. A push that cannot
1156                // reach the sequencer must be rejected, loudly and soon;
1157                // it must never become a push that never ends. 30s is far
1158                // above a healthy decision, which the gate holds under
1159                // 100ms at p99, and far below a person's patience.
1160                "  case \"$code\" in 2??) return 0 ;; *) return 1 ;; esac\n",
1161                "}\n",
1162                "reason() {\n",
1163                "  sed -n 's/.*\"error\":\"\\([^\"]*\\)\".*/\\1/p' \"$reply\" | head -1\n",
1164                "}\n",
1165                // A file, not a shell variable: this has to survive being
1166                // read back line by line, and the accepted list is the
1167                // only record of what needs undoing.
1168                "done_refs=$(mktemp) || exit 1\n",
1169                "abort() {\n",
1170                "  while read -r a_old a_new a_ref; do\n",
1171                "    post \"$a_old\" \"$a_new\" \"$a_ref\" \"$CHOIR_ABORT\" ||\n",
1172                "      echo \"choir: could not retract $a_ref; the node's log now holds a ref \\\n",
1173                "this push did not create\" >&2\n",
1174                "  done < \"$done_refs\"\n",
1175                "}\n",
1176                "while read old new ref; do\n",
1177                "  if ! post \"$old\" \"$new\" \"$ref\" \"$CHOIR_API\"; then\n",
1178                "    why=$(reason)\n",
1179                "    if [ -n \"$why\" ]; then\n",
1180                "      echo \"choir: $ref rejected: $why\" >&2\n",
1181                "    else\n",
1182                "      echo \"choir: ref update rejected by sequencer: $ref\" >&2\n",
1183                "    fi\n",
1184                "    abort\n",
1185                "    rm -f \"$done_refs\" \"$reply\"\n",
1186                "    exit 1\n",
1187                "  fi\n",
1188                "  printf '%s %s %s\\n' \"$old\" \"$new\" \"$ref\" >> \"$done_refs\"\n",
1189                "done\n",
1190                "rm -f \"$done_refs\" \"$reply\"\n",
1191                "exit 0\n",
1192            ),
1193        )?;
1194    #[cfg(unix)]
1195    {
1196        use std::os::unix::fs::PermissionsExt;
1197        std::fs::set_permissions(&hook, std::fs::Permissions::from_mode(0o755))?;
1198    }
1199    Ok(())
1200}
1201
1202/// `GET /api/repos` — which repositories this credential can see.
1203///
1204/// Answered from the filesystem rather than from the view, for the same
1205/// reason [`create_repo_request`] writes no op: a repository is not an
1206/// entity in the [`View`](choir_view::View), and `portable::repos`
1207/// already answers "which ones exist" by walking the root.
1208///
1209/// Narrowed rather than refused, the way the other aggregate reads are:
1210/// a reader granted one repository has a legitimate view of that
1211/// repository, and a list that answered 403 because it also contains
1212/// somebody else's would make the grant useless. With no ACL table
1213/// configured every repository is listed, because with no table there
1214/// is no one to narrow to.
1215fn repos_request(root: &Path, acl: Option<&acl::Effective>, user: &str) -> (u16, String) {
1216    // A root that cannot be walked is a broken node, not an empty one,
1217    // and saying "no repositories" to that question is the answer that
1218    // sends somebody looking in the wrong place.
1219    let found = match portable::repos(root) {
1220        Ok(found) => found,
1221        Err(error) => return (500, serde_json::json!({ "error": error }).to_string()),
1222    };
1223    let mut names: Vec<String> = found
1224        .into_iter()
1225        .filter(|name| acl.is_none_or(|t| t.allows_repo(user, name, acl::Level::Read)))
1226        .collect();
1227    names.sort();
1228    let body = serde_json::json!({
1229        "format_version": 1,
1230        "repos": names,
1231        // Named so a caller can tell "you can see none" from "there are
1232        // none", which are the same empty list and different problems.
1233        "narrowed": acl.is_some(),
1234    });
1235    (200, body.to_string())
1236}
1237
1238/// `POST /api/repo` — create a repository on a running node.
1239///
1240/// Until this existed, a repository could only be made by naming it in
1241/// `--create` at startup, so adding one to a live node meant stopping
1242/// it. That is the whole reason this endpoint is here: a person who has
1243/// just installed choir should be able to put a repository on their own
1244/// instance without restarting the thing they just started.
1245///
1246/// Nothing is appended to the log. A repository is not modelled in the
1247/// [`View`](choir_view::View) — `refs` is keyed by name and there is no
1248/// repository entity — and `portable::export` already answers "which
1249/// repositories exist" by walking the filesystem. Creating one is
1250/// therefore a node-local act with no op to write, and adding an
1251/// `OpKind` for it would be a format commitment bought for nothing. If
1252/// repositories should later become first-class in the total order that
1253/// stays open: the field would be additive, like every other.
1254///
1255/// The response carries the clone URL because the next thing the caller
1256/// does is clone, and making them assemble it from the name and the
1257/// base is how a trailing `.git` goes missing.
1258fn create_repo_request(root: &Path, body: &[u8]) -> (u16, String) {
1259    let refuse = |code: u16, error: &str, next: &str| {
1260        (
1261            code,
1262            serde_json::json!({ "error": error, "next": next }).to_string(),
1263        )
1264    };
1265    let Ok(request): Result<serde_json::Value, _> = serde_json::from_slice(body) else {
1266        return refuse(
1267            400,
1268            "the request body is not JSON",
1269            "POST {\"name\": \"owner/repo.git\"}",
1270        );
1271    };
1272    let Some(name) = request["name"].as_str() else {
1273        return refuse(
1274            400,
1275            "no `name` in the request",
1276            "POST {\"name\": \"owner/repo.git\"}",
1277        );
1278    };
1279    // A repository served without `.git` is one that `git clone` finds
1280    // by a name the node does not use, so the suffix is required rather
1281    // than guessed at. Appending it silently would make two spellings of
1282    // one repository, which is how an ACL entry comes to govern nothing.
1283    if !name.ends_with(".git") {
1284        return refuse(
1285            400,
1286            "a repository name must end in `.git`",
1287            "name it `owner/repo.git`",
1288        );
1289    }
1290    match create_repo_in(root, name) {
1291        Ok(_) => (
1292            201,
1293            serde_json::json!({ "format_version": 1, "name": name, "created": true }).to_string(),
1294        ),
1295        // Already there is not an error worth a 500: the caller asked
1296        // for a repository to exist and it does. It is still not a 201,
1297        // because a caller that would have pushed into an empty one
1298        // needs to know it is not empty.
1299        Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => refuse(
1300            409,
1301            "that repository already exists",
1302            "clone it, or choose another name",
1303        ),
1304        Err(e) if e.kind() == std::io::ErrorKind::InvalidInput => refuse(
1305            400,
1306            "that is not a legal repository name",
1307            "use `owner/repo.git`, without `..`, `@` or `*`",
1308        ),
1309        Err(e) => (
1310            500,
1311            serde_json::json!({ "error": format!("could not create the repository: {e}") })
1312                .to_string(),
1313        ),
1314    }
1315}
1316
1317/// Creates a bare repository under `root` and brings it under the
1318/// sequencer, against a root rather than a bound node.
1319///
1320/// The whole create path needs nothing from a [`Node`] but where its
1321/// repositories live, which is what lets a request create one: the API
1322/// handler holds the root and not the node.
1323///
1324/// # Errors
1325///
1326/// Fails when the name is not a legal repository name, when the path
1327/// already exists, or when `git init` or a `git config` call fails.
1328fn create_repo_in(root: &Path, name: &str) -> std::io::Result<PathBuf> {
1329    let path = repo_path_in(root, name)?;
1330    if path.exists() {
1331        return Err(std::io::Error::new(
1332            std::io::ErrorKind::AlreadyExists,
1333            name.to_string(),
1334        ));
1335    }
1336    if !std::process::Command::new("git")
1337        .args(["init", "--bare", "-q"])
1338        .arg(&path)
1339        .status()?
1340        .success()
1341    {
1342        return Err(std::io::Error::other("git init failed"));
1343    }
1344    configure_repo_in(root, &path)?;
1345    Ok(path)
1346}
1347
1348/// [`Node::repo_path`], against a root rather than a bound node.
1349fn repo_path_in(root: &Path, name: &str) -> std::io::Result<PathBuf> {
1350    // `@` is reserved so a repository can never alias the ACL's
1351    // `@node` pseudo-repository (D29); `*` likewise for its wildcard.
1352    if name.split('/').any(|c| c == ".." || c.is_empty())
1353        || name.starts_with('/')
1354        || name.contains('@')
1355        || name.contains('*')
1356    {
1357        return Err(std::io::Error::new(
1358            std::io::ErrorKind::InvalidInput,
1359            "bad repo name",
1360        ));
1361    }
1362    Ok(root.join(name))
1363}
1364
1365impl Node {
1366    /// Rejects path traversal and normalizes the repo path under root.
1367    fn repo_path(&self, name: &str) -> std::io::Result<PathBuf> {
1368        repo_path_in(&self.root, name)
1369    }
1370
1371    /// Serves requests until the process exits. Run on a dedicated thread.
1372    ///
1373    /// # Rate limiting and who is exempt (D33)
1374    ///
1375    /// The check runs on the request's own thread, after authentication —
1376    /// it has to be after, because the bucket is per authenticated user,
1377    /// and it must not be on the accept loop, which exists to accept.
1378    ///
1379    /// Three exemptions, each because applying the limit would be worse
1380    /// than the flood it prevents:
1381    ///
1382    /// 1. **The loopback hook callback.** A push of N refs makes N
1383    ///    `/api/git-update` calls; refusing the fourth one fails the push
1384    ///    halfway and drives the retraction path for refs git will never
1385    ///    create. It carries a node-minted secret over loopback, so it is
1386    ///    not an untrusted caller in the first place.
1387    /// 2. **A holder of a D29 `@node` grant.** That grant is already total
1388    ///    authority over the node. Throttling the one actor who can repair
1389    ///    it, during the incident the limiter is reporting, is the
1390    ///    lockout this feature must not cause.
1391    /// 3. **Any node without `--auth-file`.** There is no per-user
1392    ///    identity to bucket by, and a single shared `anon` bucket is a
1393    ///    self-inflicted denial of service rather than a limit.
1394    ///
1395    /// The D37 quotas ([`Node::enable_quotas`]) take the same three,
1396    /// from the same computed value rather than a second copy of the
1397    /// rule: whether a request is metered is one question, and answering
1398    /// it twice is how the two answers start to differ.
1399    pub fn serve_forever(&self) {
1400        loop {
1401            // A writer that has failed a durability barrier refuses every
1402            // submission from then on. Staying up in that state is worse
1403            // than being down: process supervision only restarts a process
1404            // that *exits*, so the node would sit there looking healthy
1405            // to launchd while rejecting everything, and a transient fsync
1406            // error would become permanent downtime that reads as uptime.
1407            // Exiting hands it back to supervision, which restarts into
1408            // the same replay path the D20 flip proved with `kill -9`.
1409            if self
1410                .platform
1411                .as_ref()
1412                .is_some_and(|p| p.durability_failed())
1413            {
1414                eprintln!(
1415                    "choir: the op log is no longer durable, so this node has stopped \
1416                     accepting writes; exiting so supervision restarts it. Check the \
1417                     filesystem backing the log."
1418                );
1419                // EX_TEMPFAIL: the condition may well clear on restart.
1420                std::process::exit(75);
1421            }
1422            // Do not block forever in `accept`: durability loss is
1423            // published by the writer thread and must take an otherwise
1424            // idle daemon down without waiting for another client to
1425            // arrive. One tenth of a second is far below supervisor and
1426            // human timescales while still avoiding a busy poll.
1427            let request = match self
1428                .server
1429                .recv_timeout(std::time::Duration::from_millis(100))
1430            {
1431                Ok(Some(request)) => request,
1432                Ok(None) if self.shutdown.load(std::sync::atomic::Ordering::Acquire) => return,
1433                Ok(None) => continue,
1434                Err(error) => {
1435                    eprintln!("choir: HTTP accept loop failed: {error}");
1436                    return;
1437                }
1438            };
1439            // Cheap stat between accepting a request and handling it, so
1440            // an edited keys file takes effect on *this* request: an
1441            // appended signing key becomes usable, and a newly bound
1442            // channel name becomes enforced, with no restart and no wait
1443            // for some later event.
1444            self.refresh_allowed_signers();
1445            // Same reasoning, same cost: an appended grant takes effect
1446            // on this request rather than on a restart.
1447            self.refresh_acl();
1448            // Same reasoning, quieter failure: the writer thread can only
1449            // record that an op missed the latency gate, never decide what
1450            // to do about it. Draining here puts the breach in the
1451            // operator's lag log while the node keeps serving.
1452            if let Some(platform) = self.platform.as_ref() {
1453                platform.drain_lag_log();
1454            }
1455            let root = self.root.clone();
1456            let auth = self.auth.clone();
1457            let platform = self.platform.clone();
1458            let queue = self.queue.clone();
1459            let queue_in_flight = std::sync::Arc::clone(&self.queue_in_flight);
1460            let ui_cache = std::sync::Arc::clone(&self.ui_cache);
1461            let acl = self.acl_now();
1462            let internal_token = self.internal_token.clone();
1463            let request_log = self.request_log.clone();
1464            let rate = self.rate.clone();
1465            let accounts = self.accounts.clone();
1466            let passkeys = self.passkeys;
1467            let sessions = self.sessions.clone();
1468            let quotas = self.quotas;
1469            let api_body_limit = self.api_body_limit;
1470            let browser_writes = self.browser_writes;
1471            let site_repo = self.site_repo.clone();
1472            let ready_min_free_bytes = self.ready_min_free_bytes;
1473            let counters = std::sync::Arc::clone(&self.counters);
1474            let started_unix = self.started_unix;
1475            let authenticated = self.auth.is_some();
1476            let port = self.port;
1477            let scheme = self.scheme;
1478            let listener_scheme = self.listener_scheme;
1479            let public_rate = std::sync::Arc::clone(&self.public_rate);
1480            let ssh_enabled = self.ssh_enabled;
1481            std::thread::spawn(move || {
1482                // D33. Started before anything else the thread does, so
1483                // the recorded duration is the node's whole cost. The
1484                // query string is dropped here and never carried further.
1485                let access = limits::Access::start(&request, std::sync::Arc::clone(&counters));
1486                let log = request_log.as_deref();
1487                // D39's client half, ahead of authentication and
1488                // deliberately so.
1489                //
1490                // It is a compile-time constant with no node state in
1491                // it, identical on every choir node, and the 401 it
1492                // would otherwise get names the realm anyway — so
1493                // there is nothing here a challenge would protect. What
1494                // a challenge *would* cost is the feature: a subresource
1495                // 401 that a browser declines to retry leaves the
1496                // ceremony `hidden`, which is not a visible failure but
1497                // an absent control, with the `<noscript>` sentence
1498                // suppressed because scripting is in fact enabled. A
1499                // write path that silently disappears is worse than a
1500                // public constant.
1501                //
1502                // Served whatever `--read-only-browser` says (D73). Each
1503                // ceremony in the file asks whether its own element is on
1504                // the page, and the pages decide that: a read-only browse
1505                // surface renders no `#verdict` and no `#comment`, so the
1506                // file that would drive them runs nothing. Withholding the
1507                // file instead took sign-in and D72's ask down with them,
1508                // which is a page whose button does nothing rather than a
1509                // page that offers no button.
1510                if request.url().split('?').next() == Some(ui::WEBAUTHN_JS_PATH) {
1511                    let outcome = respond_static_script(request);
1512                    // "anon" and not a name: no credential was
1513                    // evaluated on this path, and the request log must
1514                    // say what happened rather than what was sent.
1515                    access.finish(log, "anon", &outcome);
1516                    return;
1517                }
1518                // D57's front door, also ahead of authentication, and for
1519                // a plainer reason than D39's: the people these pages are
1520                // for do not have a credential yet. That is the whole
1521                // point of them.
1522                //
1523                // Everything the auth gate would have done downstream has
1524                // to be done here instead, because a request that returns
1525                // from this block never reaches it. In order: admission
1526                // control, which is *not* the authenticated limiter and
1527                // takes no argument describing the caller (see
1528                // `PublicLimiter`: a map keyed on something an anonymous
1529                // caller supplies is the attack, and the one key that is
1530                // not supplied by them is their address, which this
1531                // deployment does not handle at all); a bounded read of
1532                // any body; and `access.finish` on every exit, since each
1533                // branch logs itself.
1534                let public_path = request
1535                    .url()
1536                    .split(['?', '#'])
1537                    .next()
1538                    .unwrap_or("")
1539                    .to_string();
1540                let method = request.method().as_str();
1541                // D71's ceremony, public for the same reason D57's front
1542                // door is: a person signing in has no credential yet, and
1543                // the whole point of the page is to give them one. Both
1544                // API halves are pre-auth too, which is what makes the
1545                // challenge the one allocation an unauthenticated caller
1546                // can repeat, and why they sit behind the same
1547                // `PublicLimiter` as everything else in this block.
1548                //
1549                // The page and its form are public whatever `--passkeys`
1550                // says (D74): the credential an operator issued is what
1551                // every person holds before they hold anything else, and
1552                // the page that takes it is the page that replaced the
1553                // browser's own dialog. Only the ceremony's two halves
1554                // are gated on the switch that offers it.
1555                let signin_route = matches!(
1556                    (method, public_path.as_str()),
1557                    ("GET" | "POST", "/signin") | ("POST", "/api/signout")
1558                ) || (passkeys
1559                    && matches!(
1560                        (method, public_path.as_str()),
1561                        ("POST", "/api/signin") | ("POST", "/api/signin/challenge")
1562                    ));
1563                // D72's queue. Public for the same reason the front
1564                // door is: the caller holds no credential and the whole
1565                // point is that they can ask for one. What keeps it from
1566                // being an open registration is that it writes a row
1567                // that authorizes nothing, behind a proof of work, into
1568                // a capped table -- and that only an operator can turn
1569                // one of those rows into an invite.
1570                let asking_route = accounts.is_some()
1571                    && matches!(
1572                        (method, public_path.as_str()),
1573                        ("POST", "/api/access") | ("POST", "/api/access/challenge")
1574                    );
1575                // A live browser session, checked here as well as at the
1576                // auth gate below. The front door is for people who hold
1577                // nothing, and a session cookie is something: without
1578                // this the branch fired for anybody signed in through
1579                // D74's form, because that credential is a cookie and
1580                // never an `Authorization` header — so the brand link,
1581                // the "node" pill and the `303` that ends signing in all
1582                // led back to a page telling the reader to sign in.
1583                //
1584                // A closure rather than a value, and last in the `&&`
1585                // chain that uses it: this is a lock and a lookup, and
1586                // every git request would otherwise pay for it to answer
1587                // a question only two paths ask.
1588                let in_session = || {
1589                    session::cookie(header(&request, "Cookie").as_deref(), session::COOKIE)
1590                        .is_some_and(|token| sessions.user(&token).is_some())
1591                };
1592                // A crawler asks for this by name and gets it whatever it
1593                // holds, because a robots policy withheld behind a `401`
1594                // is a robots policy nothing reads.
1595                let robots_route = method == "GET" && public_path == ui::ROBOTS_PATH;
1596                let public = signin_route
1597                    || asking_route
1598                    || robots_route
1599                    || matches!((method, public_path.as_str()), ("GET" | "POST", "/join"))
1600                    || (method == "GET" && public_path == ui::CARD_PATH)
1601                    // The landing page replaces the challenge only for a
1602                    // request that presented nothing. A credential that
1603                    // was presented and is wrong still gets the `401`,
1604                    // because a reader who mistyped their password needs
1605                    // the browser to ask again rather than a page telling
1606                    // them what choir is.
1607                    || (method == "GET"
1608                        && matches!(public_path.as_str(), "/" | "/index.html")
1609                        && authenticated
1610                        && header(&request, "authorization").is_none()
1611                        && !in_session());
1612                if public {
1613                    if let Some(retry) = public_rate.check() {
1614                        let outcome = respond_public_busy(request, retry);
1615                        access.finish(log, "anon", &outcome);
1616                        return;
1617                    }
1618                    let outcome = if signin_route {
1619                        respond_signin(
1620                            request,
1621                            &public_path,
1622                            Credentials {
1623                                auth: auth.as_ref().as_ref(),
1624                                accounts: accounts.as_deref(),
1625                            },
1626                            &sessions,
1627                            passkeys,
1628                            scheme,
1629                            api_body_limit,
1630                        )
1631                    } else if asking_route {
1632                        respond_access(
1633                            request,
1634                            &public_path,
1635                            accounts.as_deref(),
1636                            &sessions,
1637                            scheme,
1638                            api_body_limit,
1639                        )
1640                    } else if public_path == ui::CARD_PATH {
1641                        respond_card(request)
1642                    } else if robots_route {
1643                        respond_robots(request)
1644                    } else {
1645                        respond_join(
1646                            request,
1647                            accounts.as_deref(),
1648                            &public_path,
1649                            join_page::Offers {
1650                                ssh: ssh_enabled,
1651                                passkeys,
1652                                // D78: whether this node has published
1653                                // anything is the ACL's answer, so the
1654                                // page cannot claim a posture the table
1655                                // does not hold.
1656                                publishes: acl
1657                                    .as_ref()
1658                                    .is_some_and(|t| t.holds_anything(acl::ANON)),
1659                            },
1660                            &sessions,
1661                            scheme,
1662                            &root,
1663                        )
1664                    };
1665                    // "anon", like the script constant above: no
1666                    // credential was evaluated, and the record must say
1667                    // what happened rather than what was presented. The
1668                    // invite id is deliberately not logged either — it is
1669                    // half of a live credential.
1670                    access.finish(log, "anon", &outcome);
1671                    return;
1672                }
1673                // Hook callbacks authenticate with the loopback secret
1674                // instead of user credentials.
1675                let internal_ok = (request.url().starts_with("/api/git-update")
1676                    || request.url().starts_with("/api/git-abort"))
1677                    && header(&request, "X-Choir-Internal").as_deref() == Some(&internal_token);
1678                let mut user = "anon".to_string();
1679                // D36. Set when the presented credential is an unredeemed
1680                // invite rather than an account, which may reach exactly
1681                // one route.
1682                let mut invite: Option<String> = None;
1683                // A signed-in browser presents no credential at all: the
1684                // cookie is the whole claim, and it is checked before the
1685                // Authorization header so a stale header cannot shadow a
1686                // live session.
1687                let session_token =
1688                    session::cookie(header(&request, "Cookie").as_deref(), session::COOKIE);
1689                let session_user = session_token
1690                    .as_deref()
1691                    .and_then(|token| sessions.user(token));
1692                let session_user_present = session_user.is_some();
1693                if let Some(table) = auth.as_ref() {
1694                    match session_user
1695                        .map(accounts::Principal::Account)
1696                        .or_else(|| authenticate(table, accounts.as_deref(), &request))
1697                    {
1698                        Some(accounts::Principal::Account(u)) => user = u,
1699                        Some(accounts::Principal::Invite(id)) => {
1700                            user.clone_from(&id);
1701                            invite = Some(id);
1702                        }
1703                        None if internal_ok => {}
1704                        // No credential, but this node has published
1705                        // something and this request is asking for it.
1706                        // The caller becomes `@anon` and every check
1707                        // downstream runs unchanged: the browse handler
1708                        // asks the same `readable()` it asks for a
1709                        // signed-in reader, and the git route asks the
1710                        // same `git_requirement`. There is no second
1711                        // authorization rule here, which is the point --
1712                        // a public repository is one the ACL says
1713                        // `@anon` may read, and nothing else about the
1714                        // node changes.
1715                        None if anon_may_try(acl.as_deref(), &request) => {
1716                            user = acl::ANON.to_string();
1717                        }
1718                        None => {
1719                            // A browser that can run the ceremony is shown
1720                            // the page instead of the challenge, because
1721                            // `WWW-Authenticate` is answered by chrome no
1722                            // page can style, explain, or offer a passkey
1723                            // through. Everything else keeps the header:
1724                            // git speaks it, every API client speaks it,
1725                            // and a node with passkeys off has no other
1726                            // way in.
1727                            //
1728                            // The git routes are excluded by name rather
1729                            // than trusted to not send `text/html`, since
1730                            // what a client sends is not a promise about
1731                            // what it can do with the answer.
1732                            // Not gated on `--passkeys` any more (D74).
1733                            // The page's other half is a username and
1734                            // password form, which is the way in on every
1735                            // node whatever it offers, so withholding the
1736                            // page from a node without passkeys withheld
1737                            // the form too and left the grey box as the
1738                            // whole answer.
1739                            let wants_page = !request.url().contains(".git")
1740                                && header(&request, "accept")
1741                                    .is_some_and(|a| a.contains("text/html"));
1742                            let outcome = if wants_page {
1743                                let next = request.url().split('?').next().unwrap_or("/");
1744                                let next = if next.starts_with('/') { next } else { "/" };
1745                                let page = signin_page::render(
1746                                    passkeys,
1747                                    next,
1748                                    signin_page::Said::Nothing,
1749                                    reader_chrome(&request),
1750                                );
1751                                respond_scripted_page(request, page.status, page.html)
1752                            } else {
1753                                let body = "unauthorized\n";
1754                                let response = tiny_http::Response::from_string(body)
1755                                    .with_status_code(401)
1756                                    .with_header(
1757                                        tiny_http::Header::from_bytes(
1758                                            &b"WWW-Authenticate"[..],
1759                                            &b"Basic realm=\"choir\""[..],
1760                                        )
1761                                        .expect("static header"),
1762                                    );
1763                                served(request, response, 401, body.len() as u64)
1764                            };
1765                            access.finish(log, &user, &outcome);
1766                            return;
1767                        }
1768                    }
1769                }
1770                // The hook callbacks are privileged: they submit a ref op
1771                // under any user's name, spending authorization that the
1772                // git route which triggered them already checked.
1773                // Requiring the loopback secret keeps a user credential
1774                // from reaching them directly — which would otherwise
1775                // forge a ref update on any repository and walk straight
1776                // around the git-route check below.
1777                if (request.url().starts_with("/api/git-update")
1778                    || request.url().starts_with("/api/git-abort"))
1779                    && !internal_ok
1780                {
1781                    let body = "{\"error\":\"internal endpoint\"}\n";
1782                    let response = tiny_http::Response::from_string(body)
1783                        .with_status_code(403)
1784                        .with_header(
1785                            tiny_http::Header::from_bytes(
1786                                &b"Content-Type"[..],
1787                                &b"application/json"[..],
1788                            )
1789                            .expect("static header"),
1790                        );
1791                    let outcome = served(request, response, 403, body.len() as u64);
1792                    access.finish(log, &user, &outcome);
1793                    return;
1794                }
1795                // D33's three exemptions, hoisted because D37's quotas
1796                // take exactly the same three: the callback that a push
1797                // fans out into, the actor who can repair the node, and a
1798                // node with no identity to meter. A metering rule that
1799                // exempted one of them and not the other would be two
1800                // rules for one question.
1801                //
1802                // Short-circuited on "is anything metered at all" so a
1803                // node running none of this pays no ACL lookup per
1804                // request for it.
1805                let metered = (rate.is_some() || quotas.is_active()) && {
1806                    let node_wide = acl.as_deref().is_some_and(|table| {
1807                        table.allows(&user, &acl::Scope::Node, acl::Level::Read)
1808                    });
1809                    !(internal_ok || !authenticated || node_wide)
1810                };
1811                // Operational endpoints are authenticated by reaching this
1812                // point. Readiness performs independent checks rather than
1813                // echoing liveness: verified log structure, a live durable
1814                // sequencer, writable storage, free space, and ref agreement.
1815                if request.method().as_str() == "GET"
1816                    && matches!(request.url(), "/healthz" | "/readyz" | "/metrics")
1817                {
1818                    let outcome = handle_observability(
1819                        &root,
1820                        platform.as_deref(),
1821                        ready_min_free_bytes,
1822                        &counters,
1823                        started_unix,
1824                        request,
1825                    );
1826                    access.finish(log, &user, &outcome);
1827                    return;
1828                }
1829                // D33. After authentication, because the bucket is per
1830                // user; before any work, because a refused request should
1831                // cost the node as little as possible. The exemptions are
1832                // documented on `serve_forever`.
1833                if let Some(rate) = rate.as_deref() {
1834                    let refusal = if metered {
1835                        rate.check(&user, limits::class_of(access.path()))
1836                    } else {
1837                        None
1838                    };
1839                    if let Some(retry_after) = refusal {
1840                        // A reader who refreshed too fast gets a page; an
1841                        // agent gets the JSON it parses. Same refusal,
1842                        // same `Retry-After`, told in the surface the
1843                        // caller is already in.
1844                        let outcome = if is_browser_route(access.path()) {
1845                            let seconds = format!("{retry_after} seconds");
1846                            let html = ui::refusal(
1847                                "Too many requests, briefly",
1848                                429,
1849                                &ui::Refusal {
1850                                    code: "rate_limited",
1851                                    error: "This credential has spent its request allowance \
1852                                            for the moment. Nothing is wrong with the node \
1853                                            or with what you asked for.",
1854                                    expected: Some("requests within this node's per-user rate"),
1855                                    actual: Some(&seconds),
1856                                    next: "Wait, then reload. If this keeps happening while \
1857                                           you are reading rather than scripting, the \
1858                                           operator set the limit low enough to catch a \
1859                                           person and would want to know.",
1860                                },
1861                                &[],
1862                                reader_chrome(&request),
1863                            );
1864                            respond_page(request, 429, html, Some(retry_after))
1865                        } else {
1866                            respond_rate_limited(request, access.path(), retry_after)
1867                        };
1868                        access.finish(log, &user, &outcome);
1869                        return;
1870                    }
1871                }
1872                // D36. An unredeemed invite is a credential for exactly
1873                // one thing. Refused here, ahead of every route, rather
1874                // than by each route remembering to ask: the ACL grades
1875                // accounts, and an invite is not one yet — it holds no
1876                // grant, so several endpoints that require none would
1877                // otherwise let it through.
1878                if invite.is_some()
1879                    && !(request.method().as_str() == "POST"
1880                        && request.url() == "/api/accounts/redeem")
1881                {
1882                    // A person who was handed an invite and pasted the
1883                    // node's URL into a browser lands here, and it is
1884                    // very likely their first minute on this node. The
1885                    // JSON below says the true thing and tells them
1886                    // nothing they can act on without reading the source.
1887                    let outcome = if is_browser_route(request.url()) {
1888                        let html = ui::refusal(
1889                            "That invite is not an account yet",
1890                            403,
1891                            &ui::Refusal {
1892                                code: "invite_only",
1893                                error: "The credential you signed in with is an unredeemed \
1894                                        invite. An invite may do exactly one thing — become \
1895                                        an account — and it holds no grants until it does.",
1896                                expected: Some("an account token"),
1897                                actual: Some("an unredeemed invite"),
1898                                next: "Redeem it once, with the invite as the credential: \
1899                                       POST /api/accounts/redeem. It answers with a token; \
1900                                       sign in with that and this page will open. Invites \
1901                                       expire and are single-use, so do it now rather than \
1902                                       later.",
1903                            },
1904                            &[],
1905                            reader_chrome(&request),
1906                        );
1907                        respond_page(request, 403, html, None)
1908                    } else {
1909                        let body = "{\"error\":\"an invite may only be redeemed\"}\n";
1910                        let response = tiny_http::Response::from_string(body)
1911                            .with_status_code(403)
1912                            .with_header(
1913                                tiny_http::Header::from_bytes(
1914                                    &b"Content-Type"[..],
1915                                    &b"application/json"[..],
1916                                )
1917                                .expect("static header"),
1918                            );
1919                        served(request, response, 403, body.len() as u64)
1920                    };
1921                    access.finish(log, &user, &outcome);
1922                    return;
1923                }
1924                // Build one op's bytes for a browser to sign (D39).
1925                // Ahead of the platform API for the same reason the
1926                // account routes are: it needs no sequencer.
1927                if request.url().split('?').next().unwrap_or("") == "/api/prepare" {
1928                    if !browser_writes {
1929                        let body = r#"{"error":"browser writes are disabled; use the signed CLI"}"#;
1930                        let response = tiny_http::Response::from_string(body)
1931                            .with_status_code(403)
1932                            .with_header(
1933                                tiny_http::Header::from_bytes(
1934                                    &b"Content-Type"[..],
1935                                    &b"application/json"[..],
1936                                )
1937                                .expect("static header"),
1938                            );
1939                        let outcome = served(request, response, 403, body.len() as u64);
1940                        access.finish(log, &user, &outcome);
1941                        return;
1942                    }
1943                    let outcome = handle_prepare(&user, acl.as_deref(), api_body_limit, request);
1944                    access.finish(log, &user, &outcome);
1945                    return;
1946                }
1947                // The page a person enrols a passkey on (D39). Ahead
1948                // of the API block because it is a page, not an endpoint,
1949                // and it is gated by nothing but being authenticated: the
1950                // only account it can ever show is the caller's own.
1951                // D75. A passwordless account's one need for a
1952                // secret: git and the CLI speak basic auth and cannot
1953                // present a passkey. Minted here rather than at
1954                // redemption, so the caller is somebody this node has
1955                // already authenticated and is asking because something
1956                // wanted one.
1957                if request.url().split('?').next() == Some("/account/token") {
1958                    let outcome = respond_account_token(
1959                        request,
1960                        accounts.as_deref(),
1961                        &user,
1962                        acl.as_deref(),
1963                        &root,
1964                        scheme,
1965                    );
1966                    access.finish(log, &user, &outcome);
1967                    return;
1968                }
1969                if request.url().split('?').next().unwrap_or("") == "/account" {
1970                    // No `--read-only-browser` refusal here (D73).
1971                    // Enrolling a passkey writes to the accounts store and
1972                    // never to the op log, and it is the prerequisite for
1973                    // signing in -- so refusing it under a posture that
1974                    // also advertises `passkeys=enabled` left the one
1975                    // credential a browser can hold unobtainable from a
1976                    // browser.
1977                    if !passkeys {
1978                        let html = ui::refusal(
1979                            "Passkeys are not enabled",
1980                            503,
1981                            &ui::Refusal {
1982                                code: "passkeys_disabled",
1983                                error: "This node does not offer passkeys.",
1984                                expected: Some("a node with passkeys enabled"),
1985                                actual: Some("a passkey enrollment page"),
1986                                next: "Ask the operator to start the node with --passkeys; \
1987                                       until then, sign in with the credential you were given.",
1988                            },
1989                            &[],
1990                            reader_chrome(&request),
1991                        );
1992                        let outcome = respond_page(request, 503, html, None);
1993                        access.finish(log, &user, &outcome);
1994                        return;
1995                    }
1996                    let console = acl.as_deref().is_some_and(|table| {
1997                        table
1998                            .check(&user, &acl::Scope::Node, acl::Level::Write)
1999                            .is_none()
2000                    });
2001                    let docs = docs_url(&root);
2002                    let page = account_page::render(
2003                        accounts.as_deref(),
2004                        &user,
2005                        session_user_present,
2006                        console,
2007                        header(&request, "host")
2008                            .map(|host| format!("{scheme}://{host}"))
2009                            .as_deref(),
2010                        browse::Chrome {
2011                            site: None,
2012                            theme: chosen_theme(&request),
2013                            here: "/account",
2014                            account: true,
2015                            console,
2016                            docs: docs.as_deref(),
2017                            // Reaching this page at all means holding a
2018                            // credential, so the bar takes the
2019                            // signed-in shape without asking again.
2020                            signed_in: Some(true),
2021                        },
2022                    );
2023                    let bytes = page.html.len() as u64;
2024                    // The same headers every other browser surface
2025                    // carries, which this page was missing entirely: it
2026                    // was the one page in the node running script with
2027                    // nothing constraining it.
2028                    let response = tiny_http::Response::from_string(page.html)
2029                        .with_status_code(page.status)
2030                        .with_header(
2031                            tiny_http::Header::from_bytes(
2032                                &b"Content-Type"[..],
2033                                &b"text/html; charset=utf-8"[..],
2034                            )
2035                            .expect("static header"),
2036                        )
2037                        .with_header(
2038                            tiny_http::Header::from_bytes(
2039                                &b"Cache-Control"[..],
2040                                &b"private, no-cache"[..],
2041                            )
2042                            .expect("static header"),
2043                        )
2044                        .with_header(
2045                            tiny_http::Header::from_bytes(
2046                                &b"Content-Security-Policy"[..],
2047                                SCRIPTED_PAGE_CSP,
2048                            )
2049                            .expect("static header"),
2050                        );
2051                    let outcome = served(request, response, page.status, bytes);
2052                    access.finish(log, &user, &outcome);
2053                    return;
2054                }
2055                // The operator's console (D72). A page, like `/account`
2056                // above, and ahead of the API block for the same reason.
2057                if request.url().split('?').next().unwrap_or("") == "/people" {
2058                    let outcome = respond_people(
2059                        request,
2060                        accounts.as_deref(),
2061                        acl.as_deref(),
2062                        &user,
2063                        &root,
2064                        scheme,
2065                        api_body_limit,
2066                    );
2067                    access.finish(log, &user, &outcome);
2068                    return;
2069                }
2070                // Credential self-service (D36). Ahead of the platform
2071                // API because it needs no sequencer: a node that serves
2072                // git and nothing else still issues credentials.
2073                if request.url().split('?').next().unwrap_or("") == "/api/accounts"
2074                    || request.url().starts_with("/api/accounts/")
2075                {
2076                    let outcome = handle_accounts(
2077                        SelfService {
2078                            store: accounts.as_deref(),
2079                            passkeys,
2080                        },
2081                        &user,
2082                        invite.as_deref(),
2083                        acl.as_deref(),
2084                        api_body_limit,
2085                        scheme,
2086                        request,
2087                    );
2088                    access.finish(log, &user, &outcome);
2089                    return;
2090                }
2091                // The surface as plain text, for an agent that has never
2092                // seen choir, and the sync contract it points at. Behind
2093                // auth like everything else; it describes the node
2094                // rather than exposing its contents. `llms.txt` naming a
2095                // file only a cloner can read would be worse than not
2096                // naming it, so the document a remote agent is told to
2097                // follow is served from the same place it is told about.
2098                // The same surface, machine-readable, with what *this*
2099                // node will actually accept merged in (D17). The static
2100                // half is generated and committed; the capabilities are
2101                // read off the live node, because a deployment's gates
2102                // are not a fact a committed file can carry honestly.
2103                // Search, for a caller that is not a browser (D62). Ahead
2104                // of the platform API for the same reason credentials
2105                // are: it reads git and needs no sequencer, so a node
2106                // serving nothing but repositories can still answer
2107                // "where is this". The grant closure is the browser's,
2108                // built here rather than passed down, because the two
2109                // surfaces answering differently about what a reader may
2110                // see is the one bug this endpoint could introduce.
2111                if request.url().split('?').next().unwrap_or("") == "/api/search" {
2112                    let url = request.url().to_string();
2113                    let readable = |repo: &str| match acl.as_deref() {
2114                        Some(table) => table.allows_repo(&user, repo, acl::Level::Read),
2115                        None => true,
2116                    };
2117                    let (status, body) = browse::api_search(
2118                        &root,
2119                        &readable,
2120                        browse::raw_param(&url, "repo"),
2121                        browse::param(&url, "rev").as_deref(),
2122                        browse::param(&url, "q").unwrap_or_default().as_str(),
2123                        browse::param(&url, "in").as_deref(),
2124                        browse::param(&url, "limit").as_deref(),
2125                    );
2126                    let bytes = body.len() as u64;
2127                    let response = tiny_http::Response::from_string(body)
2128                        .with_status_code(status)
2129                        .with_header(
2130                            tiny_http::Header::from_bytes(
2131                                &b"Content-Type"[..],
2132                                &b"application/json"[..],
2133                            )
2134                            .expect("static header"),
2135                        );
2136                    let outcome = served(request, response, status, bytes);
2137                    access.finish(log, &user, &outcome);
2138                    return;
2139                }
2140                // One actor's standing (D63). It reads the view rather
2141                // than git, so unlike search it needs the sequencer --
2142                // but it takes the *filtered* view, the same body this
2143                // caller would get from `/api/view`, which is what keeps
2144                // it from disclosing a change or review the ACL withheld.
2145                if request.url().split('?').next().unwrap_or("") == "/api/profile" {
2146                    let url = request.url().to_string();
2147                    // `raw_param` and not `param`: a channel legitimately
2148                    // carries a `/`, and `param`'s decoder refuses one
2149                    // because a decoded slash invents a path segment --
2150                    // the same split `raw_param` exists for on a
2151                    // repository name. Decoded by the function the `/p/`
2152                    // page uses, so the two surfaces cannot come to
2153                    // disagree about what a channel name is. Until this,
2154                    // every agent channel -- `operator/agent`, which is
2155                    // the ordinary shape here -- answered 400 on this
2156                    // endpoint while its page rendered.
2157                    let channel = browse::raw_param(&url, "channel")
2158                        .and_then(browse::decode_channel)
2159                        .unwrap_or_default();
2160                    let (status, body) = match (platform.as_deref(), channel.is_empty()) {
2161                        (_, true) => (
2162                            400,
2163                            serde_json::json!({ "error": "channel is required" }).to_string()
2164                                + "\n",
2165                        ),
2166                        (None, _) => (
2167                            503,
2168                            serde_json::json!({
2169                                "error": "this node runs no sequencer, so it holds no view to \
2170                                          derive a profile from"
2171                            })
2172                            .to_string()
2173                                + "\n",
2174                        ),
2175                        (Some(platform), false) => {
2176                            let seen = visible_view(platform, acl.as_deref(), &user);
2177                            match serde_json::from_str::<serde_json::Value>(&seen) {
2178                                Ok(view) => (200, profile::of(&view, &channel).to_string() + "\n"),
2179                                Err(error) => (
2180                                    500,
2181                                    serde_json::json!({
2182                                        "error": format!("the view did not parse: {error}")
2183                                    })
2184                                    .to_string()
2185                                        + "\n",
2186                                ),
2187                            }
2188                        }
2189                    };
2190                    let bytes = body.len() as u64;
2191                    let response = tiny_http::Response::from_string(body)
2192                        .with_status_code(status)
2193                        .with_header(
2194                            tiny_http::Header::from_bytes(
2195                                &b"Content-Type"[..],
2196                                &b"application/json"[..],
2197                            )
2198                            .expect("static header"),
2199                        );
2200                    let outcome = served(request, response, status, bytes);
2201                    access.finish(log, &user, &outcome);
2202                    return;
2203                }
2204                if request.url().split('?').next().unwrap_or("") == "/api/schema" {
2205                    let body = schema_with_capabilities(
2206                        accounts.is_some(),
2207                        acl.is_some(),
2208                        platform.is_some(),
2209                    );
2210                    let bytes = body.len() as u64;
2211                    let response = tiny_http::Response::from_string(body).with_header(
2212                        tiny_http::Header::from_bytes(
2213                            &b"Content-Type"[..],
2214                            &b"application/json"[..],
2215                        )
2216                        .expect("static header"),
2217                    );
2218                    let outcome = served(request, response, 200, bytes);
2219                    access.finish(log, &user, &outcome);
2220                    return;
2221                }
2222                if let Some(text) = match request.url() {
2223                    "/llms.txt" => Some(LLMS_TXT),
2224                    "/sync.md" => Some(SYNC_MD),
2225                    _ => None,
2226                } {
2227                    let response = tiny_http::Response::from_string(text).with_header(
2228                        tiny_http::Header::from_bytes(
2229                            &b"Content-Type"[..],
2230                            &b"text/plain; charset=utf-8"[..],
2231                        )
2232                        .expect("static header"),
2233                    );
2234                    let outcome = served(request, response, 200, text.len() as u64);
2235                    access.finish(log, &user, &outcome);
2236                    return;
2237                }
2238                // The node's own telemetry. Matched exactly so it can
2239                // never shadow a repository path: git routes are
2240                // `/owner/repo.git/...`, and a single bare segment
2241                // cannot name a repository, which always carries an
2242                // owner. It used to be the front door; `/` now belongs
2243                // to the repository index, because a reader arriving at
2244                // a code host is looking for code.
2245                //
2246                // The *path* is what is matched exactly, not the URL.
2247                // Comparing the whole URL made this the one page on the
2248                // node that a query string turned into a 404, so a
2249                // shared link carrying any `?…` — a cache-buster, a
2250                // tracking parameter a mail client appended — answered
2251                // "nothing is served at that address" about an address
2252                // that is served.
2253                // Setting the palette. A `GET` that mutates nothing but
2254                // one display cookie, so it is a link rather than a
2255                // form: the read surface runs no script, and a form
2256                // would put a `POST` and a button in a bar that is
2257                // otherwise navigation.
2258                if request.url().split(['?', '#']).next().unwrap_or("") == "/theme" {
2259                    let url = request.url().to_string();
2260                    let set = browse::param(&url, "set").unwrap_or_default();
2261                    let outcome = respond_theme(request, &set, &return_to(&url), scheme);
2262                    access.finish(log, &user, &outcome);
2263                    return;
2264                }
2265                if request.url().split(['?', '#']).next().unwrap_or("") == "/status" {
2266                    let outcome = handle_ui(
2267                        platform.as_deref(),
2268                        &ui_cache,
2269                        &user,
2270                        acl.as_deref(),
2271                        &root,
2272                        passkeys,
2273                        request,
2274                    );
2275                    access.finish(log, &user, &outcome);
2276                    return;
2277                }
2278                // One actor's standing, as a page (D63). The reader who
2279                // wants this is looking at a verdict and asking who gave
2280                // it, so it is a link from a review rather than a
2281                // document they were going to fetch as JSON.
2282                if let Some(rest) = request
2283                    .url()
2284                    .split(['?', '#'])
2285                    .next()
2286                    .unwrap_or("")
2287                    .strip_prefix("/p/")
2288                {
2289                    let channel = browse::decode_channel(rest);
2290                    let chrome = reader_chrome(&request);
2291                    let rendered = match (platform.as_deref(), channel) {
2292                        (Some(platform), Some(channel)) => {
2293                            let seen = visible_view(platform, acl.as_deref(), &user);
2294                            match serde_json::from_str::<serde_json::Value>(&seen) {
2295                                Ok(view) => browse::profile_page(
2296                                    &channel,
2297                                    &profile::of(&view, &channel),
2298                                    chrome,
2299                                ),
2300                                Err(_) => browse::no_such_actor(chrome),
2301                            }
2302                        }
2303                        // A name that is not a channel and a node with no
2304                        // view answer the same way, for the same reason
2305                        // an unreadable repository does: a reader is
2306                        // never told which of the two it was.
2307                        _ => browse::no_such_actor(chrome),
2308                    };
2309                    let outcome = respond_page(request, rendered.status, rendered.html, None);
2310                    access.finish(log, &user, &outcome);
2311                    return;
2312                }
2313                // Repository browsing (D30). Ahead of the git branch
2314                // below, but `browse::route` refuses any path carrying a
2315                // `.git` segment, so an owner named `r` keeps their
2316                // clone URL: this cannot shadow a repository, and the
2317                // check is theirs rather than this router's ordering.
2318                if let Some(page) = browse::route(request.url()) {
2319                    // A single-repository node narrows the page here,
2320                    // before anything reads the disk: `/` becomes that
2321                    // repository, and a page about another one is the
2322                    // same refusal a reader without a grant receives.
2323                    let page = match site_repo.as_deref() {
2324                        Some(site) => browse::scope(page, site),
2325                        None => Some(page),
2326                    };
2327                    let outcome = match page {
2328                        Some(page) => handle_browse(
2329                            &BrowseContext {
2330                                root: &root,
2331                                user: &user,
2332                                acl: acl.as_deref(),
2333                                platform: platform.as_deref(),
2334                                browser_writes,
2335                                site: site_repo.as_deref(),
2336                                scheme,
2337                                self_service: accounts.is_some(),
2338                                passkeys,
2339                            },
2340                            &page,
2341                            request,
2342                        ),
2343                        None => {
2344                            let denied = browse::no_such_repository(reader_chrome(&request));
2345                            respond_page(request, denied.status, denied.html, None)
2346                        }
2347                    };
2348                    access.finish(log, &user, &outcome);
2349                    return;
2350                }
2351                if request.url().starts_with("/api/") {
2352                    let base_url = format!("{listener_scheme}://127.0.0.1:{port}");
2353                    // A hook callback carries the loopback secret rather
2354                    // than a user's grants, so it is not an ACL subject.
2355                    let acl_for_api = if internal_ok { None } else { acl.as_deref() };
2356                    let outcome = handle_api(
2357                        ApiRequestContext {
2358                            platform: platform.as_deref(),
2359                            root: &root,
2360                            base_url: &base_url,
2361                            user: &user,
2362                            acl: acl_for_api,
2363                            push_acl: acl.as_deref(),
2364                            workspaces: metered.then_some(quotas.workspaces).flatten(),
2365                            body_limit: api_body_limit,
2366                            queue: queue.as_ref(),
2367                            queue_in_flight: &queue_in_flight,
2368                        },
2369                        request,
2370                    );
2371                    access.finish(log, &user, &outcome);
2372                    return;
2373                }
2374                // A mistyped address. Everything below is git
2375                // smart-HTTP, and every smart-HTTP path carries a `.git`
2376                // segment — the claim `browse.rs` makes and a test pins —
2377                // so a `GET` without one matched no route above and can
2378                // never be a git client. It is a person who typed
2379                // something slightly wrong, and `git http-backend`'s CGI
2380                // 404 tells them nothing about where they are. Answering
2381                // here also means the two named destinations are the
2382                // same whether or not an ACL is configured; before this,
2383                // an ACL node said "no such repository" and a node
2384                // without one said whatever git said.
2385                if repo_from_path(request.url()).is_none()
2386                    && matches!(request.method().as_str(), "GET" | "HEAD")
2387                {
2388                    let html = ui::refusal(
2389                        "Nothing is served at that address",
2390                        404,
2391                        &ui::Refusal {
2392                            code: "no_such_page",
2393                            error: "This node answers on a small, fixed set of addresses, and \
2394                                    that is not one of them.",
2395                            expected: Some(
2396                                "/ for node state, /r/ for repositories, /api/… \
2397                                            for the JSON surface",
2398                            ),
2399                            actual: Some(access.path()),
2400                            next: "Start from the node page and follow links; every address \
2401                                   this surface has is reachable from one of the two below. \
2402                                   A clone URL is different — it ends in .git.",
2403                        },
2404                        &[("/", "node state"), ("/r/", "repositories")],
2405                        reader_chrome(&request),
2406                    );
2407                    let outcome = respond_page(request, 404, html, None);
2408                    access.finish(log, &user, &outcome);
2409                    return;
2410                }
2411                // Git smart-HTTP. The repository is in the URL, so this
2412                // decision needs nothing but the path — which is why it
2413                // sits here, once, rather than inside the CGI bridge. A
2414                // path naming no repository, or an operation outside the
2415                // smart-HTTP surface, is refused rather than handed to
2416                // `git http-backend`.
2417                if let Some(table) = acl.as_ref() {
2418                    let method = request.method().as_str().to_string();
2419                    let denial = match acl::git_requirement(&method, request.url()) {
2420                        Some((repo, level)) => table.check(&user, &acl::Scope::Repo(repo), level),
2421                        None => Some(acl::Denial {
2422                            status: 404,
2423                            reason: "no such repository".to_string(),
2424                        }),
2425                    };
2426                    if let Some(denial) = denial {
2427                        let outcome = respond_git_denial(request, &denial);
2428                        access.finish(log, &user, &outcome);
2429                        return;
2430                    }
2431                }
2432                // Platform-enabled daemons pass the sequencer callback
2433                // into git's hook environment.
2434                let mut extra_env = Vec::new();
2435                if platform.is_some() {
2436                    if let Some(repo) = repo_from_path(request.url()) {
2437                        extra_env.push((
2438                            "CHOIR_API".to_string(),
2439                            format!("{listener_scheme}://127.0.0.1:{port}/api/git-update"),
2440                        ));
2441                        extra_env.push((
2442                            "CHOIR_ABORT".to_string(),
2443                            format!("{listener_scheme}://127.0.0.1:{port}/api/git-abort"),
2444                        ));
2445                        extra_env.push(("CHOIR_REPO".to_string(), repo));
2446                        extra_env.push(("CHOIR_USER".to_string(), user.clone()));
2447                        extra_env.push(("CHOIR_INTERNAL".to_string(), internal_token));
2448                    }
2449                }
2450                let outcome = handle(
2451                    root,
2452                    request,
2453                    &extra_env,
2454                    metered.then_some(quotas.push_bytes).flatten(),
2455                );
2456                access.finish(log, &user, &outcome);
2457            });
2458        }
2459    }
2460
2461    /// Handle for stopping the accept loop (used by tests).
2462    pub fn unblock(&self) {
2463        self.shutdown
2464            .store(true, std::sync::atomic::Ordering::Release);
2465        self.server.unblock();
2466    }
2467}
2468
2469/// Value of the first header named `name`, if present.
2470fn header(request: &tiny_http::Request, name: &str) -> Option<String> {
2471    request
2472        .headers()
2473        .iter()
2474        .find(|h| h.field.as_str().as_str().eq_ignore_ascii_case(name))
2475        .map(|h| h.value.as_str().to_string())
2476}
2477
2478/// Writes `response` and reports what was served, so the caller can hand
2479/// the outcome to [`limits::Access::finish`] (D33). The byte count is the
2480/// body size the caller already knows; tiny_http does not expose it back.
2481///
2482/// **`X-Content-Type-Options: nosniff` is added here, to everything.**
2483/// It used to sit on the four responders somebody remembered to put it
2484/// on, which left git's own CGI output — a pusher's bytes, typed by git —
2485/// without it. That is the response `script-src 'self'` most needs it on:
2486/// the review page licenses any same-origin URL as a script source, and
2487/// a browser will happily execute a `text/plain` body as JavaScript
2488/// unless this header says not to. One funnel, so the claim in
2489/// [`SCRIPTED_PAGE_CSP`] is true by construction rather than by
2490/// inspection.
2491fn served<R: std::io::Read>(
2492    request: tiny_http::Request,
2493    mut response: tiny_http::Response<R>,
2494    status: u16,
2495    bytes: u64,
2496) -> std::io::Result<(u16, u64)> {
2497    response.add_header(
2498        tiny_http::Header::from_bytes(&b"X-Content-Type-Options"[..], &b"nosniff"[..])
2499            .expect("static header"),
2500    );
2501    request.respond(response).map(|()| (status, bytes))
2502}
2503
2504/// Answers a request that exhausted its per-user allowance (D33).
2505///
2506/// `Retry-After` in whole seconds is the machine-readable half; the body
2507/// repeats it because a git client shows the operator the body and
2508/// nothing else. Plain text on a git path for that reason, JSON on an API
2509/// path so a client that parses every response still can.
2510fn respond_rate_limited(
2511    request: tiny_http::Request,
2512    path: &str,
2513    retry_after: u64,
2514) -> std::io::Result<(u16, u64)> {
2515    let (body, content_type) = match limits::class_of(path) {
2516        limits::Class::Git => (
2517            format!("rate limit exceeded; retry in {retry_after}s\n"),
2518            &b"text/plain; charset=utf-8"[..],
2519        ),
2520        limits::Class::Api => (
2521            serde_json::json!({
2522                "error": format!("rate limit exceeded; retry in {retry_after}s"),
2523                "retry_after_secs": retry_after,
2524            })
2525            .to_string(),
2526            &b"application/json"[..],
2527        ),
2528    };
2529    let bytes = body.len() as u64;
2530    let response = tiny_http::Response::from_string(body)
2531        .with_status_code(429)
2532        .with_header(
2533            tiny_http::Header::from_bytes(&b"Content-Type"[..], content_type)
2534                .expect("static header"),
2535        )
2536        .with_header(
2537            tiny_http::Header::from_bytes(&b"Retry-After"[..], retry_after.to_string().as_bytes())
2538                .expect("retry-after header"),
2539        );
2540    served(request, response, 429, bytes)
2541}
2542
2543/// Answers a git request whose body was over the per-user push ceiling
2544/// (D37).
2545///
2546/// `413` is the exact HTTP meaning, and the body is plain text because a
2547/// git client shows the operator the body and nothing else. It names both
2548/// numbers: a refusal that says only "too large" leaves the pusher
2549/// guessing how much to split by.
2550fn respond_push_too_large(
2551    request: tiny_http::Request,
2552    limit: u64,
2553    size: u64,
2554) -> std::io::Result<(u16, u64)> {
2555    let body = format!(
2556        "push refused: {size} bytes, and this node's per-user limit is {limit} bytes per \
2557         request\npush fewer objects at a time, or ask the operator to raise the limit\n"
2558    );
2559    let bytes = body.len() as u64;
2560    let response = tiny_http::Response::from_string(body)
2561        .with_status_code(413)
2562        .with_header(
2563            tiny_http::Header::from_bytes(&b"Content-Type"[..], &b"text/plain; charset=utf-8"[..])
2564                .expect("static header"),
2565        );
2566    served(request, response, 413, bytes)
2567}
2568
2569/// Answers an API request whose body exceeded the node-wide ceiling.
2570fn respond_api_too_large(
2571    request: tiny_http::Request,
2572    limit: u64,
2573    size: u64,
2574) -> std::io::Result<(u16, u64)> {
2575    let body = serde_json::json!({
2576        "error": "request body too large",
2577        "limit_bytes": limit,
2578        "actual_bytes": size,
2579    })
2580    .to_string();
2581    let bytes = body.len() as u64;
2582    let response = tiny_http::Response::from_string(body)
2583        .with_status_code(413)
2584        .with_header(
2585            tiny_http::Header::from_bytes(&b"Content-Type"[..], &b"application/json"[..])
2586                .expect("static header"),
2587        );
2588    served(request, response, 413, bytes)
2589}
2590
2591/// A `304`, carrying the headers a client must not keep a stale copy of.
2592///
2593/// **A `304` is a header update, not just "nothing changed".** RFC 9111
2594/// has a cache replace its stored response's headers with the ones the
2595/// `304` carries; any header the `304` omits keeps whatever value it had
2596/// when the body was first stored. So a policy header left out of a
2597/// `304` is not merely absent for one exchange — it is *frozen* at the
2598/// version the client first saw, for as long as the entry lives.
2599///
2600/// This is not hypothetical. The browse pages' `ETag` is derived from a
2601/// commit, so it survives a daemon upgrade; the `304` carried only the
2602/// tag; and a reader whose cache held a page from before
2603/// [`BROWSER_CSP`] gained `form-action 'self'` kept the old
2604/// `form-action 'none'` through every reload. The search box rendered,
2605/// focused, took a term, and was refused by a policy the server had
2606/// already stopped sending. Only a cache-bypassing reload fixed it,
2607/// which is not a thing a reader knows to do.
2608///
2609/// One function rather than four call sites, for the reason
2610/// [`BROWSER_CSP`] is one constant: a fourth hand-assembled `304` is how
2611/// one of them ends up missing a header again.
2612fn not_modified(tag: &str, csp: &[u8]) -> tiny_http::Response<std::io::Empty> {
2613    tiny_http::Response::empty(304)
2614        .with_header(
2615            tiny_http::Header::from_bytes(&b"ETag"[..], tag.as_bytes()).expect("etag header"),
2616        )
2617        .with_header(
2618            tiny_http::Header::from_bytes(&b"Content-Security-Policy"[..], csp)
2619                .expect("static header"),
2620        )
2621        .with_header(
2622            tiny_http::Header::from_bytes(&b"Cache-Control"[..], &b"private, no-cache"[..])
2623                .expect("static header"),
2624        )
2625        .with_header(
2626            tiny_http::Header::from_bytes(&b"Referrer-Policy"[..], REFERRER_POLICY)
2627                .expect("static header"),
2628        )
2629}
2630
2631/// The referrer policy every response on this surface carries.
2632///
2633/// **`same-origin`, and it may not be `no-referrer`.** It was
2634/// `no-referrer` in ten hand-typed copies, and that value silently
2635/// disabled every browser write path on this node. The Fetch standard
2636/// sets a non-`GET` request's `Origin` header from the referrer policy:
2637/// under `no-referrer` the origin is serialized as the string `null`,
2638/// for same-origin submissions as much as for cross-site ones. So every
2639/// form on this surface posted `Origin: null`, `same_origin` compared it
2640/// against this node's own address, refused, and told the reader their
2641/// form came from another site. Sign-in, the operator console's four
2642/// forms and the account page were all unreachable from a browser while
2643/// every `curl` path stayed green, because a command-line client applies
2644/// no referrer policy and sends the header the check expects.
2645///
2646/// `same-origin` keeps the privacy this was for and stops defeating the
2647/// check. A `Referer` is still withheld from every other site, which is
2648/// the property `/join` depends on: its URL carries an invite secret in
2649/// the query string. What changes is that a *same-origin* request keeps
2650/// its real `Origin`, and only a genuinely cross-site one is nulled --
2651/// which is the request `same_origin` exists to refuse.
2652///
2653/// One constant rather than a copy per responder, for the reason
2654/// [`BROWSER_CSP`] gives: a value carrying a constraint this subtle,
2655/// hand-typed ten times, is a value that drifts.
2656const REFERRER_POLICY: &[u8] = b"same-origin";
2657
2658/// The policy every page on the browser surface carries.
2659///
2660/// One constant rather than a copy per responder: it is the layer that
2661/// holds if the escaper ever misses something, and a fifth hand-typed
2662/// copy is how one of them ends up subtly weaker than the rest.
2663///
2664/// **`form-action 'self'`, not `'none'`.** It was `'none'` for as long as
2665/// this surface had no form, and that was the right value then. It is a
2666/// one-word difference with a silent failure mode: a browser blocks the
2667/// submission and reports it to the console *only*, so the search box
2668/// rendered correctly, focused correctly, accepted a term, and did
2669/// nothing at all on `Enter`. Nothing in the served HTML was wrong, which
2670/// is why no assertion over the HTML could have caught it.
2671///
2672/// `'self'` is still the whole guarantee that matters here: a form on
2673/// this surface can submit to this origin and to no other, so no page
2674/// this node renders can be turned into a way of posting a reader's
2675/// input somewhere else.
2676const BROWSER_CSP: &[u8] = b"default-src 'none'; style-src 'unsafe-inline'; form-action 'self'; frame-ancestors 'none'; base-uri 'none'";
2677
2678/// [`BROWSER_CSP`] plus permission to run [`ui::WEBAUTHN_JS`] and to
2679/// `fetch` this node (D39), carried by the two pages with a ceremony on
2680/// them and by nothing else.
2681///
2682/// **Per page, never node-wide.** A single header would license script
2683/// on a dozen pages that must never run any, and the read surface's whole
2684/// guarantee is that it runs none. Pages with no ceremony keep
2685/// [`BROWSER_CSP`] untouched, which is `default-src 'none'` with no
2686/// `script-src` at all.
2687///
2688/// **`'self'` rather than a digest per script, and what that costs.** The
2689/// digests were narrower: they licensed three exact byte strings, where
2690/// this licenses any same-origin URL a `<script src>` can name. What
2691/// makes that trade sound is `X-Content-Type-Options: nosniff` on every
2692/// response this node sends — with it, a browser refuses to execute
2693/// anything whose type is not JavaScript, and the only JavaScript type
2694/// served here is [`ui::WEBAUTHN_JS_PATH`], a compile-time constant.
2695/// Repository content is served as escaped HTML, and git's own CGI
2696/// output gets the header added on the way out for exactly this reason.
2697///
2698/// What it buys: the digests were computed by shelling out to `openssl`
2699/// at bind, and a host without `openssl` fell back to `'unsafe-inline'`
2700/// — a weaker policy than intended, reached silently, visible only in a
2701/// served header. That path is gone rather than documented.
2702const SCRIPTED_PAGE_CSP: &[u8] = b"default-src 'none'; style-src 'unsafe-inline'; form-action 'self'; frame-ancestors 'none'; base-uri 'none'; script-src 'self'; connect-src 'self'";
2703
2704/// Serves [`ui::WEBAUTHN_JS`], the only script this node has.
2705///
2706/// A weak `ETag` over the bytes rather than a version string: the file
2707/// changes when the binary does and never otherwise, so a digest of what
2708/// is being sent is both the cheapest correct tag and the one that cannot
2709/// go stale against a rebuild. `no-cache` with a tag means a browser
2710/// revalidates and is answered `304` with an empty body, which is one
2711/// round trip per page load and no bytes.
2712///
2713/// The response carries `nosniff` for the same reason every other one
2714/// does, and here it is load-bearing in the other direction: this is the
2715/// single URL on the node that *is* JavaScript, and a browser under
2716/// `script-src 'self'` must be able to tell it apart from everything
2717/// else.
2718fn respond_static_script(request: tiny_http::Request) -> std::io::Result<(u16, u64)> {
2719    static ETAG: std::sync::OnceLock<String> = std::sync::OnceLock::new();
2720    let tag = ETAG.get_or_init(|| {
2721        format!(
2722            "W/\"{}\"",
2723            &choir_oplog::ContentHash::blake3(ui::WEBAUTHN_JS.as_bytes()).to_hex()[..18]
2724        )
2725    });
2726    if header(&request, "If-None-Match").as_deref() == Some(tag.as_str()) {
2727        return served(request, not_modified(tag, BROWSER_CSP), 304, 0);
2728    }
2729    let bytes = ui::WEBAUTHN_JS.len() as u64;
2730    let response = tiny_http::Response::from_string(ui::WEBAUTHN_JS)
2731        .with_header(
2732            tiny_http::Header::from_bytes(
2733                &b"Content-Type"[..],
2734                &b"text/javascript; charset=utf-8"[..],
2735            )
2736            .expect("static header"),
2737        )
2738        .with_header(
2739            tiny_http::Header::from_bytes(&b"ETag"[..], tag.as_bytes()).expect("etag header"),
2740        )
2741        .with_header(
2742            tiny_http::Header::from_bytes(&b"Cache-Control"[..], &b"private, no-cache"[..])
2743                .expect("static header"),
2744        )
2745        .with_header(
2746            tiny_http::Header::from_bytes(&b"Content-Security-Policy"[..], BROWSER_CSP)
2747                .expect("static header"),
2748        )
2749        .with_header(
2750            tiny_http::Header::from_bytes(&b"Referrer-Policy"[..], REFERRER_POLICY)
2751                .expect("static header"),
2752        );
2753    served(request, response, 200, bytes)
2754}
2755
2756/// Whether a request carried a credential this node could name.
2757///
2758/// `anon` is the name every unidentified request is logged and
2759/// authorized under, and the empty string is what a page rendered
2760/// outside a request has. Everything else is somebody. One function
2761/// rather than the comparison at each call site, because the bar's two
2762/// shapes and the comment box's gate are the same question and must not
2763/// start disagreeing about the answer.
2764fn identified(user: &str) -> bool {
2765    !user.is_empty() && user != "anon"
2766}
2767
2768/// The chrome facts for one request: the palette this reader chose and
2769/// the address they are on.
2770///
2771/// One helper rather than two lines at every refusal, because a refusal
2772/// rendered without it is a page that flips to the system palette at the
2773/// worst moment — the reader has just hit a wall, and the page changing
2774/// colour reads as a second thing going wrong.
2775fn reader_chrome(request: &tiny_http::Request) -> browse::Chrome<'static> {
2776    browse::Chrome {
2777        site: None,
2778        theme: chosen_theme(request),
2779        // Deliberately not the failing address: these pages are
2780        // refusals, and a palette link that returns the reader to the
2781        // page that just refused them is a link back into a wall. The
2782        // empty string is the front door.
2783        here: "",
2784        // A refusal offers no navigation of its own. Half of these pages
2785        // are rendered for a caller who has not been identified yet, so
2786        // "does this reader hold `@node write`" has no answer here, and
2787        // guessing `false` would be a console link that vanishes on a
2788        // 404 and reappears everywhere else. `ui::refusal` takes the
2789        // links each refusal actually wants as its own argument.
2790        account: false,
2791        console: false,
2792        docs: None,
2793        // For the same reason as the two above: half of these pages are
2794        // rendered before the caller has been identified, so "is this
2795        // reader signed in" has no answer here, and guessing would put
2796        // a `sign in` link in front of somebody who already is.
2797        signed_in: None,
2798    }
2799}
2800
2801/// The theme this reader has chosen, or `None` for "follow the system".
2802///
2803/// The stylesheet has carried `:root[data-theme="light"]` and its dark
2804/// twin since it was written, and nothing ever set the attribute, so the
2805/// manual override was decoration: a reader whose system said light read
2806/// a light page and had no way to say otherwise.
2807///
2808/// A cookie rather than a query parameter, because a preference that
2809/// only holds for the link you clicked is not a preference. It carries
2810/// no identity, is not a credential, and is never trusted for anything
2811/// but which palette to paint — which is why the parser accepts exactly
2812/// two spellings and treats everything else, including a value some
2813/// other software set, as absent.
2814fn chosen_theme(request: &tiny_http::Request) -> Option<&'static str> {
2815    let jar = header(request, "cookie")?;
2816    jar.split(';').find_map(|pair| {
2817        let (name, value) = pair.split_once('=')?;
2818        if name.trim() != "theme" {
2819            return None;
2820        }
2821        match value.trim() {
2822            "dark" => Some("dark"),
2823            "light" => Some("light"),
2824            _ => None,
2825        }
2826    })
2827}
2828
2829/// Where `/theme` may send a reader back to.
2830///
2831/// One leading slash and nothing that leaves this origin. `//evil.test`
2832/// is the case worth naming: a browser reads a protocol-relative URL as
2833/// another host, so a redirector that checks only "starts with `/`" is
2834/// an open redirect. A backslash is refused for the same reason — some
2835/// clients normalize it to a slash before resolving.
2836///
2837/// Anything that fails lands on the front door rather than being
2838/// reported: this is a preference control, and a reader who arrives at
2839/// the repository list with their theme changed has lost nothing.
2840/// The `to=` parameter of a `/theme` request, decoded and vetted.
2841///
2842/// Read here rather than through [`browse::param`], which refuses any
2843/// value that decodes to contain a `/` — right for a path *segment*,
2844/// which is all it was ever asked for, and wrong for a whole path.
2845/// Reusing it silently sent every reader to the front door instead of
2846/// back to the page they were on: the exact shape of the D52 change-id
2847/// bug, where a segment grammar was applied to something that is not a
2848/// segment.
2849///
2850/// Everything that fails lands on the front door via
2851/// [`safe_return_to`], including a value that is not UTF-8 at all.
2852fn return_to(url: &str) -> String {
2853    let Some((_, query)) = url.split_once('?') else {
2854        return "/".to_string();
2855    };
2856    let raw = query
2857        .split('&')
2858        .find_map(|pair| pair.strip_prefix("to="))
2859        .unwrap_or("");
2860    let bytes = raw.as_bytes();
2861    let mut out = Vec::with_capacity(bytes.len());
2862    let mut i = 0;
2863    while i < bytes.len() {
2864        if bytes[i] == b'%' {
2865            let Some(hex) = raw
2866                .get(i + 1..i + 3)
2867                .and_then(|h| u8::from_str_radix(h, 16).ok())
2868            else {
2869                return "/".to_string();
2870            };
2871            out.push(hex);
2872            i += 3;
2873        } else {
2874            out.push(bytes[i]);
2875            i += 1;
2876        }
2877    }
2878    match String::from_utf8(out) {
2879        Ok(path) => safe_return_to(&path),
2880        Err(_) => "/".to_string(),
2881    }
2882}
2883
2884fn safe_return_to(to: &str) -> String {
2885    let ok = to.starts_with('/')
2886        && !to.starts_with("//")
2887        && !to.contains('\\')
2888        && !to.chars().any(char::is_control);
2889    if ok {
2890        to.to_string()
2891    } else {
2892        "/".to_string()
2893    }
2894}
2895
2896/// Whether this URL is one a person is reading in a browser.
2897///
2898/// The browser surface is the node page, its alias, and everything under
2899/// the D30 browse prefix — minus anything carrying a `.git` segment,
2900/// which belongs to git however it is spelled (an owner really named `r`
2901/// has a clone URL under `/r/`, and this must not claim it).
2902///
2903/// Decided on the route rather than on `Accept`: that header says what a
2904/// client will *take*, not who it is, and every agent in this workspace
2905/// sends `*/*`. Keying on the route is what lets a refusal be a page for
2906/// the reader who met a wall while staying the JSON an agent can parse
2907/// everywhere else.
2908fn is_browser_route(url: &str) -> bool {
2909    if repo_from_path(url).is_some() {
2910        return false;
2911    }
2912    let path = url.split(['?', '#']).next().unwrap_or(url);
2913    path == "/"
2914        || path == "/index.html"
2915        || path == "/status"
2916        || path == "/theme"
2917        // The reader's own review queue. It is not under `/r/` because
2918        // it is not about one repository, so it has to be named here or
2919        // an anonymous reader meets a bare `401` and a browser dialog
2920        // instead of the page that signs them in (D74).
2921        || path == "/reviews"
2922        || path == "/r"
2923        || path.starts_with("/r/")
2924}
2925
2926/// Whether an unauthenticated request may be evaluated as
2927/// [`acl::ANON`] instead of refused.
2928///
2929/// **This grants nothing.** It decides only whether the ACL is asked at
2930/// all; the answer still comes from the table, under a principal that
2931/// can hold at most `read` on repositories somebody named. A node whose
2932/// ACL mentions `@anon` nowhere is unchanged by this function, because
2933/// the first question it asks is whether the principal holds anything.
2934///
2935/// The route test is an allowlist and not a denylist, which is the
2936/// whole of its security argument. `/api/view` and `/api/log` are `GET`
2937/// requests that serve the op log, so "a safe method" would have
2938/// published the log; the reachable set is the browse surface plus the
2939/// read half of git smart-HTTP, and a route that is neither is refused
2940/// here whatever it would have answered.
2941///
2942/// `/reviews` is excluded from the browse half deliberately: it is the
2943/// *reader's own* queue, so for a caller who is every stranger at once
2944/// it is either empty or somebody else's, and neither is a page worth
2945/// serving.
2946fn anon_may_try(acl: Option<&acl::Effective>, request: &tiny_http::Request) -> bool {
2947    let Some(table) = acl else {
2948        // No ACL means every authenticated credential reaches every
2949        // repository, which is exactly the deployment in which silently
2950        // adding an unauthenticated one would be worst.
2951        return false;
2952    };
2953    if !table.holds_anything(acl::ANON) {
2954        return false;
2955    }
2956    let method = request.method().as_str();
2957    let url = request.url();
2958    let path = url.split(['?', '#']).next().unwrap_or(url);
2959    if repo_from_path(url).is_some() {
2960        // The git half: whatever `git_requirement` calls a read. Asking
2961        // it rather than matching on the path keeps this in step with
2962        // the route table it is about; a push is `Level::Propose`, which
2963        // `@anon` cannot parse its way into holding.
2964        return matches!(
2965            acl::git_requirement(method, url),
2966            Some((_, acl::Level::Read))
2967        );
2968    }
2969    if method != "GET" && method != "HEAD" {
2970        return false;
2971    }
2972    path == "/" || path == "/index.html" || path == "/r" || path.starts_with("/r/")
2973}
2974
2975/// Answers a browser-surface request with a page.
2976///
2977/// Carries the same headers the D28 and D30 pages carry, because a
2978/// refusal renders content this node did not author just as they do —
2979/// a ref name in an error message is still a ref name somebody chose.
2980fn respond_page(
2981    request: tiny_http::Request,
2982    status: u16,
2983    html: String,
2984    retry_after: Option<u64>,
2985) -> std::io::Result<(u16, u64)> {
2986    let bytes = html.len() as u64;
2987    let mut response = tiny_http::Response::from_string(html)
2988        .with_status_code(status)
2989        .with_header(
2990            tiny_http::Header::from_bytes(&b"Content-Type"[..], &b"text/html; charset=utf-8"[..])
2991                .expect("static header"),
2992        )
2993        .with_header(
2994            tiny_http::Header::from_bytes(&b"Cache-Control"[..], &b"private, no-cache"[..])
2995                .expect("static header"),
2996        )
2997        .with_header(
2998            tiny_http::Header::from_bytes(&b"Content-Security-Policy"[..], BROWSER_CSP)
2999                .expect("static header"),
3000        )
3001        .with_header(
3002            tiny_http::Header::from_bytes(&b"Referrer-Policy"[..], REFERRER_POLICY)
3003                .expect("static header"),
3004        )
3005        // The palette is chosen by a cookie, so two readers of the same
3006        // address can be owed two different documents. `private` already
3007        // keeps a shared cache out; this says *why* the body varies, for
3008        // anything that stores it anyway.
3009        .with_header(
3010            tiny_http::Header::from_bytes(&b"Vary"[..], &b"Cookie"[..]).expect("static header"),
3011        );
3012    if let Some(secs) = retry_after {
3013        response.add_header(
3014            tiny_http::Header::from_bytes(&b"Retry-After"[..], secs.to_string().as_bytes())
3015                .expect("retry-after header"),
3016        );
3017    }
3018    served(request, response, status, bytes)
3019}
3020
3021/// A page that carries the ceremony script, under the policy that lets
3022/// it run.
3023///
3024/// [`respond_page`] sends [`BROWSER_CSP`], which has no `script-src`
3025/// at all -- correct for every refusal and every read-only page, and
3026/// wrong for the one page whose entire purpose is a control the script
3027/// reveals. Kept as a separate function rather than a flag on the
3028/// other, so widening the policy is something a caller asks for by
3029/// name.
3030fn respond_scripted_page(
3031    request: tiny_http::Request,
3032    status: u16,
3033    html: String,
3034) -> std::io::Result<(u16, u64)> {
3035    let bytes = html.len() as u64;
3036    let response = tiny_http::Response::from_string(html)
3037        .with_status_code(status)
3038        .with_header(
3039            tiny_http::Header::from_bytes(&b"Content-Type"[..], &b"text/html; charset=utf-8"[..])
3040                .expect("static header"),
3041        )
3042        .with_header(
3043            tiny_http::Header::from_bytes(&b"Cache-Control"[..], &b"no-store"[..])
3044                .expect("static header"),
3045        )
3046        .with_header(
3047            tiny_http::Header::from_bytes(&b"X-Content-Type-Options"[..], &b"nosniff"[..])
3048                .expect("static header"),
3049        )
3050        .with_header(
3051            tiny_http::Header::from_bytes(&b"Referrer-Policy"[..], REFERRER_POLICY)
3052                .expect("static header"),
3053        )
3054        .with_header(
3055            tiny_http::Header::from_bytes(&b"Content-Security-Policy"[..], SCRIPTED_PAGE_CSP)
3056                .expect("static header"),
3057        );
3058    served(request, response, status, bytes)
3059}
3060
3061/// A JSON body with a status, for the pre-auth ceremony that has no
3062/// other responder to borrow.
3063fn respond_json(
3064    request: tiny_http::Request,
3065    status: u16,
3066    body: &str,
3067) -> std::io::Result<(u16, u64)> {
3068    let bytes = body.len() as u64;
3069    let response = tiny_http::Response::from_string(body.to_string())
3070        .with_status_code(status)
3071        .with_header(
3072            tiny_http::Header::from_bytes(&b"Content-Type"[..], &b"application/json"[..])
3073                .expect("static header"),
3074        )
3075        .with_header(
3076            tiny_http::Header::from_bytes(&b"Cache-Control"[..], &b"no-store"[..])
3077                .expect("static header"),
3078        );
3079    served(request, response, status, bytes)
3080}
3081
3082/// base64url as WebAuthn writes it, tolerating the padded spelling.
3083///
3084/// `clientDataJSON` is required to be unpadded base64url, but the node
3085/// reads it rather than writing it, and a decoder that refuses padding
3086/// would make this node the one that rejects an otherwise valid
3087/// authenticator over a spelling nobody would think to check.
3088fn base64url_any(input: &str) -> Option<Vec<u8>> {
3089    let standard: String = input
3090        .chars()
3091        .map(|c| match c {
3092            '-' => '+',
3093            '_' => '/',
3094            other => other,
3095        })
3096        .filter(|c| *c != '=')
3097        .collect();
3098    base64_decode(&standard)
3099}
3100
3101/// Serves the three halves of the passkey sign-in ceremony (D71): the
3102/// page, the challenge it spends, and the assertion it posts back.
3103///
3104/// All three answer before any credential has been evaluated, so this
3105/// function does what the auth gate would have done: it never trusts a
3106/// name from the body, it bounds the read, and it returns an outcome the
3107/// caller logs as `anon`.
3108///
3109/// The account is looked up from the credential id in the assertion
3110/// rather than from anything the caller says they are. That is the
3111/// property that makes a sign-in unforgeable without also making it
3112/// enumerable: a wrong credential id and a wrong signature produce the
3113/// same refusal, so the endpoint never says whether an account exists.
3114#[allow(clippy::too_many_arguments)]
3115/// The two tables a presented username and password are graded against.
3116///
3117/// One argument rather than two because they answer one question -- is
3118/// this a credential this node issued or an operator wrote down -- and
3119/// the sign-in form has to ask both. An operator's own credential lives
3120/// in the auth file and never in the store, so a form that consulted
3121/// only the store would refuse the one person who has to be able to get
3122/// in before anybody else does.
3123#[derive(Clone, Copy)]
3124struct Credentials<'a> {
3125    auth: Option<&'a AuthTable>,
3126    accounts: Option<&'a accounts::Accounts>,
3127}
3128
3129impl Credentials<'_> {
3130    /// The account name a `user`/`secret` pair proves, or `None`.
3131    ///
3132    /// An unredeemed invite is deliberately not a sign-in: it reaches one
3133    /// route, its own redemption, and a session opened for it would be a
3134    /// session for an account that does not exist yet.
3135    fn account_for(&self, user: &str, secret: &str) -> Option<String> {
3136        if let Some(expected) = self.auth.and_then(|table| table.get(user)) {
3137            if constant_time_eq(expected.as_bytes(), secret.as_bytes()) {
3138                return Some(user.to_string());
3139            }
3140        }
3141        match self.accounts?.authenticate(user, secret) {
3142            Some(accounts::Principal::Account(name)) => Some(name),
3143            _ => None,
3144        }
3145    }
3146}
3147
3148fn respond_signin(
3149    mut request: tiny_http::Request,
3150    path: &str,
3151    credentials: Credentials<'_>,
3152    sessions: &session::Sessions,
3153    passkeys: bool,
3154    scheme: &'static str,
3155    body_limit: std::num::NonZeroU64,
3156) -> std::io::Result<(u16, u64)> {
3157    let accounts = credentials.accounts;
3158    if path == "/api/signout" {
3159        // Forgetting the token is the whole mechanism: nothing else
3160        // anywhere would still honour it. Idempotent on purpose, and it
3161        // never says whether the token was live, because a caller signing
3162        // out has no use for that answer and a caller guessing tokens
3163        // would.
3164        if let Some(token) = session::cookie(header(&request, "Cookie").as_deref(), session::COOKIE)
3165        {
3166            sessions.close(&token);
3167        }
3168        // A redirect and a cleared cookie rather than JSON, so the control
3169        // that calls this can be an ordinary form and work with scripting
3170        // off, like every other control on these pages.
3171        let secure = if scheme == "https" { "; Secure" } else { "" };
3172        let cleared = format!(
3173            "{}=; Path=/; Max-Age=0; SameSite=Lax; HttpOnly{secure}",
3174            session::COOKIE
3175        );
3176        let response = tiny_http::Response::empty(303)
3177            .with_header(
3178                tiny_http::Header::from_bytes(&b"Location"[..], &b"/"[..])
3179                    .expect("location header"),
3180            )
3181            .with_header(
3182                tiny_http::Header::from_bytes(&b"Set-Cookie"[..], cleared.as_bytes())
3183                    .expect("set-cookie header"),
3184            )
3185            .with_header(
3186                tiny_http::Header::from_bytes(&b"Cache-Control"[..], &b"no-store"[..])
3187                    .expect("static header"),
3188            );
3189        return served(request, response, 303, 0);
3190    }
3191    if path == "/signin" && request.method().as_str() != "POST" {
3192        let next = safe_next(
3193            request
3194                .url()
3195                .split_once("?next=")
3196                .map(|(_, raw)| raw.split('&').next().unwrap_or("").to_string()),
3197            "/",
3198        );
3199        let chrome = reader_chrome(&request);
3200        let page = signin_page::render(passkeys, &next, signin_page::Said::Nothing, chrome);
3201        return respond_scripted_page(request, page.status, page.html);
3202    }
3203    // The form (D74). Plain `POST`, so it works with scripting off and a
3204    // browser's password manager can offer to keep what was typed.
3205    if path == "/signin" {
3206        if let Some((expected, found)) = origin_mismatch(&request, scheme) {
3207            // `null` is not somebody else's address, it is no address:
3208            // a browser sends it after following a redirect that crossed
3209            // origins, and the redirect this node's own reader hits is
3210            // `http` -> `https`, which a 308 performs with the POST
3211            // intact. So the sign-in page was loaded over `http`, its
3212            // relative form posted to `http`, and the browser arrived
3213            // here with its origin erased. Saying "another site" for
3214            // that is what sent a reader hunting an attacker.
3215            let opaque = found == "null";
3216            let html = ui::refusal(
3217                "Cross-origin sign-in refused",
3218                403,
3219                &ui::Refusal {
3220                    code: "cross_origin",
3221                    error: if opaque {
3222                        "That form was loaded over http and redirected here, which erases \
3223                         the browser's record of where it came from."
3224                    } else {
3225                        "That form did not come from this node's own sign-in page."
3226                    },
3227                    expected: Some(&expected),
3228                    actual: Some(&found),
3229                    next: if opaque {
3230                        "Load the address under `expected` directly, with https, and sign \
3231                         in from that page."
3232                    } else {
3233                        "Open the address under `expected` and sign in there."
3234                    },
3235                },
3236                &[],
3237                reader_chrome(&request),
3238            );
3239            return respond_page(request, 403, html, None);
3240        }
3241        let chrome = reader_chrome(&request);
3242        let body = match quota::read_bounded(request.as_reader(), Some(body_limit))? {
3243            quota::Body::Complete(body) => String::from_utf8_lossy(&body).into_owned(),
3244            quota::Body::OverLimit { limit, size } => {
3245                return respond_api_too_large(request, limit, size)
3246            }
3247        };
3248        let next = safe_next(join_page::form_value(&body, "next"), "/");
3249        let signed_in = join_page::form_value(&body, "user")
3250            .zip(join_page::form_value(&body, "secret"))
3251            .and_then(|(user, secret)| credentials.account_for(user.trim(), &secret));
3252        let Some(user) = signed_in else {
3253            // The same page again, at the same status, saying the one
3254            // thing it may say. No redirect: a `303` here would put the
3255            // failure in history and lose what was typed in the other
3256            // field.
3257            let page = signin_page::render(passkeys, &next, signin_page::Said::NoMatch, chrome);
3258            return respond_scripted_page(request, page.status, page.html);
3259        };
3260        // Straight to the page that enrols a passkey, when this node
3261        // offers them and this account has none (D74). That is the step
3262        // everybody has to take exactly once and the one nobody knows to
3263        // look for, and a redirect is a cheaper way to say it than a
3264        // sentence somebody has to read.
3265        let enrol_first = passkeys
3266            && accounts.is_some_and(|store| {
3267                store.has_account(&user) && store.passkeys_json(&user).is_empty()
3268            });
3269        let land = if enrol_first {
3270            "/account"
3271        } else {
3272            next.as_str()
3273        };
3274        let token = sessions.open(&user);
3275        return respond_session(request, &token, land, scheme);
3276    }
3277
3278    let body = match quota::read_bounded(request.as_reader(), Some(body_limit))? {
3279        quota::Body::Complete(body) => body,
3280        quota::Body::OverLimit { limit, size } => {
3281            return respond_api_too_large(request, limit, size)
3282        }
3283    };
3284
3285    if path == "/api/signin/challenge" {
3286        let issued = sessions.issue_challenge();
3287        // WebAuthn wants the challenge base64url, and what it stands for
3288        // is the hex of a hash: the same bytes `webauthn_challenge` hands
3289        // a browser approving an operation, so one verifier checks both.
3290        let answer = serde_json::json!({
3291            "format_version": 1,
3292            "challenge": prepare::base64url_nopad(issued.to_hex().as_bytes()),
3293        })
3294        .to_string();
3295        return respond_json(request, 200, &answer);
3296    }
3297
3298    // `/api/signin`.
3299    let refuse = |request| {
3300        respond_json(
3301            request,
3302            401,
3303            r#"{"error":"that passkey did not sign this node's challenge"}"#,
3304        )
3305    };
3306    let Some(store) = accounts else {
3307        return respond_json(
3308            request,
3309            503,
3310            r#"{"error":"account self-service is not enabled on this node"}"#,
3311        );
3312    };
3313    let Ok(body) = serde_json::from_slice::<serde_json::Value>(&body) else {
3314        return respond_json(request, 400, r#"{"error":"body must be JSON"}"#);
3315    };
3316    let field = |name: &str| {
3317        body.get(name)
3318            .and_then(serde_json::Value::as_str)
3319            .map(str::to_string)
3320    };
3321    let (Some(key_id), Some(signature), Some(authenticator), Some(client_data)) = (
3322        field("key_id"),
3323        field("signature_hex"),
3324        field("authenticator_data_hex"),
3325        field("client_data_json_hex"),
3326    ) else {
3327        return respond_json(request, 400, r#"{"error":"the assertion is incomplete"}"#);
3328    };
3329    let Some((user, spki)) = store.account_for_credential(&key_id) else {
3330        return refuse(request);
3331    };
3332    let witness = choir_oplog::Witness {
3333        key_id,
3334        scheme: Some(choir_oplog::scheme::WEBAUTHN_ES256),
3335        signature: match platform::hex_decode(&signature) {
3336            Some(bytes) => bytes,
3337            None => return refuse(request),
3338        },
3339        authenticator_data: platform::hex_decode(&authenticator),
3340        client_data_json: platform::hex_decode(&client_data),
3341        credential_key: None,
3342    };
3343    // The challenge inside the assertion decides which challenge is being
3344    // spent, and spending it is what stops the same assertion opening a
3345    // second session. Read before verification only because verification
3346    // needs to know which bytes were promised; an assertion naming a
3347    // challenge this node never issued is refused here.
3348    let Some(claimed) = claimed_challenge(&witness) else {
3349        return refuse(request);
3350    };
3351    if !sessions.spend_challenge(&claimed) {
3352        return refuse(request);
3353    }
3354    if choir_identity::verify_webauthn_assertion(&spki, &claimed, &witness).is_err() {
3355        return refuse(request);
3356    }
3357
3358    let token = sessions.open(&user);
3359    let secure = if scheme == "https" { "; Secure" } else { "" };
3360    let cookie = format!(
3361        "{}={token}; Path=/; Max-Age=43200; SameSite=Lax; HttpOnly{secure}",
3362        session::COOKIE
3363    );
3364    let answer = serde_json::json!({ "format_version": 1, "user": user }).to_string();
3365    let bytes = answer.len() as u64;
3366    let response = tiny_http::Response::from_string(answer)
3367        .with_status_code(200)
3368        .with_header(
3369            tiny_http::Header::from_bytes(&b"Content-Type"[..], &b"application/json"[..])
3370                .expect("static header"),
3371        )
3372        .with_header(
3373            tiny_http::Header::from_bytes(&b"Set-Cookie"[..], cookie.as_bytes())
3374                .expect("set-cookie header"),
3375        )
3376        .with_header(
3377            tiny_http::Header::from_bytes(&b"Cache-Control"[..], &b"no-store"[..])
3378                .expect("static header"),
3379        );
3380    served(request, response, 200, bytes)
3381}
3382
3383/// A `next` from a request, reduced to somewhere on this node.
3384///
3385/// A path, starting with one slash. `//evil.example` is a *protocol
3386/// relative URL* and would send somebody who signed in here to another
3387/// origin, which is the whole open-redirect family in one line.
3388fn safe_next(raw: Option<String>, fallback: &str) -> String {
3389    raw.filter(|next| next.starts_with('/') && !next.starts_with("//"))
3390        .unwrap_or_else(|| fallback.to_string())
3391}
3392
3393/// Opens the browser session cookie and sends the reader on to `land`.
3394///
3395/// `SameSite=Lax` is what keeps every state-changing form on this node
3396/// out of reach of another site: a cross-site `POST` does not carry this
3397/// cookie. `HttpOnly` because no script here reads it, and `Secure`
3398/// whenever the node is speaking https, so a session cannot be sent in
3399/// clear by a downgrade.
3400fn respond_session(
3401    request: tiny_http::Request,
3402    token: &str,
3403    land: &str,
3404    scheme: &'static str,
3405) -> std::io::Result<(u16, u64)> {
3406    let secure = if scheme == "https" { "; Secure" } else { "" };
3407    let cookie = format!(
3408        "{}={token}; Path=/; Max-Age=43200; SameSite=Lax; HttpOnly{secure}",
3409        session::COOKIE
3410    );
3411    let response = tiny_http::Response::empty(303)
3412        .with_header(
3413            tiny_http::Header::from_bytes(&b"Location"[..], land.as_bytes())
3414                .expect("location header"),
3415        )
3416        .with_header(
3417            tiny_http::Header::from_bytes(&b"Set-Cookie"[..], cookie.as_bytes())
3418                .expect("set-cookie header"),
3419        )
3420        .with_header(
3421            tiny_http::Header::from_bytes(&b"Cache-Control"[..], &b"no-store"[..])
3422                .expect("static header"),
3423        );
3424    served(request, response, 303, 0)
3425}
3426
3427/// The challenge an assertion says it signed, as the node spells one.
3428///
3429/// `clientDataJSON` carries it base64url, and what it decodes to is the
3430/// hex of a [`ContentHash`]; this turns that back into the hash so the
3431/// store can be asked whether it issued it.
3432fn claimed_challenge(witness: &choir_oplog::Witness) -> Option<choir_hash::ContentHash> {
3433    let json = witness.client_data_json.as_ref()?;
3434    let client: serde_json::Value = serde_json::from_slice(json).ok()?;
3435    let encoded = client.get("challenge")?.as_str()?;
3436    let hex = String::from_utf8(base64url_any(encoded)?).ok()?;
3437    choir_hash::ContentHash::from_hex(&hex)
3438}
3439
3440/// Serves the social-preview card (D57).
3441///
3442/// Immutable and cached for a year: the bytes are compiled into the
3443/// binary, so the only way they change is a new binary, and a chat client
3444/// that has to refetch a card it already holds is spending a stranger's
3445/// request budget on a picture.
3446fn respond_card(request: tiny_http::Request) -> std::io::Result<(u16, u64)> {
3447    let bytes = ui::CARD.len() as u64;
3448    let response = tiny_http::Response::from_data(ui::CARD)
3449        .with_header(
3450            tiny_http::Header::from_bytes(&b"Content-Type"[..], &b"image/png"[..])
3451                .expect("static header"),
3452        )
3453        .with_header(
3454            tiny_http::Header::from_bytes(
3455                &b"Cache-Control"[..],
3456                &b"public, max-age=31536000, immutable"[..],
3457            )
3458            .expect("static header"),
3459        );
3460    served(request, response, 200, bytes)
3461}
3462
3463/// Serves [`ui::ROBOTS`].
3464///
3465/// An hour rather than [`respond_card`]'s year: the card's bytes can only
3466/// change with a new binary, but a crawl policy is the kind of thing an
3467/// operator wants to take effect the same afternoon they change it, and
3468/// a year-long cache on the wrong policy is not recallable.
3469fn respond_robots(request: tiny_http::Request) -> std::io::Result<(u16, u64)> {
3470    let bytes = ui::ROBOTS.len() as u64;
3471    let response = tiny_http::Response::from_string(ui::ROBOTS)
3472        .with_header(
3473            tiny_http::Header::from_bytes(&b"Content-Type"[..], &b"text/plain; charset=utf-8"[..])
3474                .expect("static header"),
3475        )
3476        .with_header(
3477            tiny_http::Header::from_bytes(&b"Cache-Control"[..], &b"public, max-age=3600"[..])
3478                .expect("static header"),
3479        );
3480    served(request, response, 200, bytes)
3481}
3482
3483/// The most a pre-auth request body may be (D57).
3484///
3485/// Two hex credentials and an ssh public key, with room to spare. It is
3486/// not [`Node::api_body_limit`] because that ceiling is for authenticated
3487/// callers doing real work, and this one is reached by anybody at all: a
3488/// megabyte a stranger can spend without a credential is a megabyte they
3489/// can spend in a loop.
3490const JOIN_BODY_BYTES: u64 = 8 * 1024;
3491
3492/// Answers a pre-auth request that the public limiter refused.
3493///
3494/// Plain text rather than a page. A caller hitting this is a loop, not a
3495/// reader, and rendering the whole stylesheet to tell them so would spend
3496/// exactly the resource the limiter is protecting.
3497fn respond_public_busy(request: tiny_http::Request, retry: u64) -> std::io::Result<(u16, u64)> {
3498    let body = "too many requests\n";
3499    let response = tiny_http::Response::from_string(body)
3500        .with_status_code(429)
3501        .with_header(
3502            tiny_http::Header::from_bytes(&b"Retry-After"[..], retry.to_string().as_bytes())
3503                .expect("a number is a valid header value"),
3504        );
3505    served(request, response, 429, body.len() as u64)
3506}
3507
3508/// Serves the public front door: the landing page and the invite link.
3509///
3510/// Headers differ from [`respond_page`] in one deliberate way:
3511/// `Cache-Control: no-store` rather than `private, no-cache`. A request
3512/// here carries a live credential in its URL and an answer may carry a
3513/// freshly minted one in its body, and neither belongs in a cache that
3514/// something else can read — including the browser's own disk cache on a
3515/// shared machine.
3516fn respond_join(
3517    mut request: tiny_http::Request,
3518    store: Option<&accounts::Accounts>,
3519    path: &str,
3520    offers: join_page::Offers,
3521    sessions: &session::Sessions,
3522    scheme: &'static str,
3523    root: &std::path::Path,
3524) -> std::io::Result<(u16, u64)> {
3525    let join_page::Offers {
3526        ssh,
3527        passkeys,
3528        publishes,
3529    } = offers;
3530    let _ = ssh;
3531    let contact = operator_contact(root);
3532    let contact = contact.as_deref();
3533    let docs = docs_url(root);
3534    let theme = chosen_theme(&request);
3535    let chrome = browse::Chrome {
3536        site: None,
3537        theme,
3538        // Empty: these pages carry no navigation bar, so there is no
3539        // palette link that would need somewhere to return to — and an
3540        // address that did carry one would be carrying the invite secret
3541        // into an `href`.
3542        here: "",
3543        // Nobody reading these pages has an account yet, which is what
3544        // both of these link to.
3545        account: false,
3546        console: false,
3547        // The book, though, is exactly what a stranger who has just read
3548        // the front door wants next, and it is the one half of this site
3549        // that needs no credential at all (D76).
3550        docs: docs.as_deref(),
3551        // These pages draw no bar, so this decides nothing; `None` is
3552        // the honest value rather than the harmless one.
3553        signed_in: None,
3554    };
3555    let origin = header(&request, "host").map(|host| format!("{scheme}://{host}"));
3556    let url = request.url().to_string();
3557    let page = if path == "/join" {
3558        if request.method().as_str() == "POST" {
3559            match quota::read_bounded(
3560                request.as_reader(),
3561                std::num::NonZeroU64::new(JOIN_BODY_BYTES),
3562            ) {
3563                Ok(quota::Body::Complete(bytes)) => {
3564                    let body = String::from_utf8_lossy(&bytes).into_owned();
3565                    join_page::post(store, &body, origin.as_deref(), chrome)
3566                }
3567                // An over-long body is not told apart from a bad one: the
3568                // page a stranger sees is the same either way, and the
3569                // distinction is only useful to somebody probing.
3570                _ => join_page::not_valid(403, theme),
3571            }
3572        } else {
3573            join_page::get(
3574                store,
3575                join_page::param(&url, "i").as_deref(),
3576                join_page::param(&url, "k").as_deref(),
3577                join_page::Offers {
3578                    ssh,
3579                    passkeys,
3580                    publishes,
3581                },
3582                chrome,
3583                origin.as_deref(),
3584                join_page::now_unix_secs(),
3585            )
3586        }
3587    } else {
3588        join_page::landing(
3589            theme,
3590            &join_page::Door {
3591                contact,
3592                // A node with no store cannot hold a queue, so the front
3593                // door does not offer a form whose one button would
3594                // answer 503 (D72).
3595                asking: store.is_some(),
3596                docs: docs.as_deref(),
3597                publishes,
3598            },
3599        )
3600    };
3601    let bytes = page.html.len() as u64;
3602    let scripted = page.scripted;
3603    // A passwordless redemption ends signed in (D75): there is no
3604    // credential for the reader to present afterwards, so the cookie is
3605    // the only thing that makes the route finish rather than end at a
3606    // page saying "now log in with the nothing you were given".
3607    let opened = page.session.as_deref().map(|user| sessions.open(user));
3608    let mut response = tiny_http::Response::from_string(page.html)
3609        .with_status_code(page.status)
3610        .with_header(
3611            tiny_http::Header::from_bytes(&b"Content-Type"[..], &b"text/html; charset=utf-8"[..])
3612                .expect("static header"),
3613        )
3614        .with_header(
3615            tiny_http::Header::from_bytes(&b"Cache-Control"[..], &b"no-store"[..])
3616                .expect("static header"),
3617        )
3618        .with_header(
3619            // The page says which it needs, because it is the only thing
3620            // that knows whether it emitted a `<script>` tag (D72). A
3621            // responder deciding from the route is how D71's sign-in page
3622            // shipped with a button the header would not let run.
3623            tiny_http::Header::from_bytes(
3624                &b"Content-Security-Policy"[..],
3625                if scripted {
3626                    SCRIPTED_PAGE_CSP
3627                } else {
3628                    BROWSER_CSP
3629                },
3630            )
3631            .expect("static header"),
3632        )
3633        .with_header(
3634            tiny_http::Header::from_bytes(&b"Referrer-Policy"[..], REFERRER_POLICY)
3635                .expect("static header"),
3636        );
3637    if let Some(token) = opened.as_deref() {
3638        let secure = if scheme == "https" { "; Secure" } else { "" };
3639        let cookie = format!(
3640            "{}={token}; Path=/; Max-Age=43200; SameSite=Lax; HttpOnly{secure}",
3641            session::COOKIE
3642        );
3643        response = response.with_header(
3644            tiny_http::Header::from_bytes(&b"Set-Cookie"[..], cookie.as_bytes())
3645                .expect("set-cookie header"),
3646        );
3647    }
3648    // An invite link that reaches a search index is an invite spent by a
3649    // crawler. The header carries where a `<meta>` tag cannot: on the
3650    // redirect-free fetch a crawler actually makes.
3651    if path == "/join" {
3652        response = response.with_header(
3653            tiny_http::Header::from_bytes(&b"X-Robots-Tag"[..], &b"noindex, nofollow"[..])
3654                .expect("static header"),
3655        );
3656    }
3657    served(request, response, page.status, bytes)
3658}
3659
3660/// Serves D72's two pre-auth endpoints: issue a challenge, and take a
3661/// request that solved it.
3662///
3663/// The order inside the `POST /api/access` arm is the security property.
3664/// The challenge is spent **first**, from an in-memory table, before a
3665/// single hash is computed: that makes the work verifiable exactly once
3666/// per challenge issued, and it keeps a caller from turning one solved
3667/// stamp into sixty-four rows. Verifying the stamp before spending the
3668/// challenge would leave the same solution good until it expired.
3669///
3670/// Refusals here are deliberately not specific. A caller that sends a
3671/// stale challenge, an unsolved nonce or a challenge this node never
3672/// issued gets the same sentence, because the differences are only useful
3673/// to somebody probing how the cost is checked.
3674fn respond_access(
3675    mut request: tiny_http::Request,
3676    path: &str,
3677    store: Option<&accounts::Accounts>,
3678    sessions: &session::Sessions,
3679    scheme: &'static str,
3680    body_limit: std::num::NonZeroU64,
3681) -> std::io::Result<(u16, u64)> {
3682    let origin = header(&request, "host").map(|host| format!("{scheme}://{host}"));
3683    if path == "/api/access/challenge" {
3684        let challenge = sessions.issue_challenge();
3685        return respond_json(
3686            request,
3687            200,
3688            &serde_json::json!({
3689                "challenge": challenge.to_hex(),
3690                "bits": work::WORK_BITS,
3691            })
3692            .to_string(),
3693        );
3694    }
3695    let body = match quota::read_bounded(request.as_reader(), Some(body_limit))? {
3696        quota::Body::Complete(body) => body,
3697        quota::Body::OverLimit { limit, size } => {
3698            return respond_api_too_large(request, limit, size)
3699        }
3700    };
3701    let Ok(json) = serde_json::from_slice::<serde_json::Value>(&body) else {
3702        return respond_json(request, 400, r#"{"error":"body must be JSON"}"#);
3703    };
3704    let refused = r#"{"error":"that stamp is not one this node issued and has not been spent; reload the page and try again"}"#;
3705    let (Some(challenge), Some(nonce)) = (
3706        json.get("challenge").and_then(serde_json::Value::as_str),
3707        json.get("nonce").and_then(serde_json::Value::as_str),
3708    ) else {
3709        return respond_json(request, 400, refused);
3710    };
3711    let Some(parsed) = choir_hash::ContentHash::from_hex(challenge) else {
3712        return respond_json(request, 400, refused);
3713    };
3714    if !sessions.spend_challenge(&parsed) {
3715        return respond_json(request, 400, refused);
3716    }
3717    if !work::solved(challenge, nonce) {
3718        return respond_json(request, 400, refused);
3719    }
3720    let (status, answer) = match store {
3721        Some(store) => with_join_url(store.request_access(&json), origin.as_deref(), "request"),
3722        None => (
3723            503,
3724            r#"{"error":"account self-service is not enabled on this node"}"#.to_string(),
3725        ),
3726    };
3727    respond_json(request, status, &answer)
3728}
3729
3730/// Sets or clears the theme cookie and sends the reader back where they
3731/// were.
3732///
3733/// `303`, not `302`: it is the status that says "the result of this is
3734/// at another address, fetch it with `GET`", which is exactly true here
3735/// and leaves no room for a client to repeat the request as something
3736/// else.
3737///
3738/// The cookie is `SameSite=Lax` and `HttpOnly`. Lax because a theme
3739/// chosen from a link on this node is the only way it is ever set, and
3740/// `HttpOnly` because nothing on this surface runs script — a cookie
3741/// script cannot read is one less thing for a future page to leak.
3742/// `Secure` follows the scheme this node is actually serving rather
3743/// than being hardcoded either way. Always-on would be a control that
3744/// silently does nothing on the loopback node this is developed
3745/// against: the browser drops the cookie, the palette never sticks, and
3746/// nothing in the response says why. Never-on would leave a real
3747/// deployment setting a cookie over TLS that a browser then sends in
3748/// clear if anything ever reaches it over plain HTTP. The node knows
3749/// which one it is, so it says.
3750fn respond_theme(
3751    request: tiny_http::Request,
3752    set: &str,
3753    back: &str,
3754    scheme: &str,
3755) -> std::io::Result<(u16, u64)> {
3756    // Clearing is `Max-Age=0`, which is how a cookie is deleted; any
3757    // spelling other than the two real ones clears rather than errors,
3758    // so a hand-typed `/theme?set=nonsense` returns the reader to the
3759    // system default instead of to a refusal page.
3760    let secure = if scheme == "https" { "; Secure" } else { "" };
3761    let cookie = match set {
3762        "dark" | "light" => {
3763            format!("theme={set}; Path=/; Max-Age=31536000; SameSite=Lax; HttpOnly{secure}")
3764        }
3765        _ => format!("theme=; Path=/; Max-Age=0; SameSite=Lax; HttpOnly{secure}"),
3766    };
3767    let response = tiny_http::Response::empty(303)
3768        .with_header(
3769            tiny_http::Header::from_bytes(&b"Location"[..], back.as_bytes())
3770                .expect("location header"),
3771        )
3772        .with_header(
3773            tiny_http::Header::from_bytes(&b"Set-Cookie"[..], cookie.as_bytes())
3774                .expect("set-cookie header"),
3775        )
3776        .with_header(
3777            tiny_http::Header::from_bytes(&b"Cache-Control"[..], &b"no-store"[..])
3778                .expect("static header"),
3779        )
3780        .with_header(
3781            tiny_http::Header::from_bytes(&b"Referrer-Policy"[..], REFERRER_POLICY)
3782                .expect("static header"),
3783        );
3784    served(request, response, 303, 0)
3785}
3786
3787/// Answers a git request the ACL refused. Plain text, because that is
3788/// what a git client surfaces to whoever ran the command.
3789fn respond_git_denial(
3790    request: tiny_http::Request,
3791    denial: &acl::Denial,
3792) -> std::io::Result<(u16, u64)> {
3793    let body = format!("{}\n", denial.reason);
3794    let bytes = body.len() as u64;
3795    let response = tiny_http::Response::from_string(body)
3796        .with_status_code(denial.status)
3797        .with_header(
3798            tiny_http::Header::from_bytes(&b"Content-Type"[..], &b"text/plain; charset=utf-8"[..])
3799                .expect("static header"),
3800        );
3801    served(request, response, denial.status, bytes)
3802}
3803
3804/// Extracts `owner/repo.git` from a smart-HTTP path like
3805/// `/owner/repo.git/git-receive-pack`.
3806pub(crate) fn repo_from_path(url: &str) -> Option<String> {
3807    let path = url.split('?').next().unwrap_or(url);
3808    let end = path
3809        .find(".git/")
3810        .map(|i| i + 4)
3811        .or_else(|| path.ends_with(".git").then_some(path.len()))?;
3812    Some(path[1..end].to_string())
3813}
3814
3815/// Checks a request's basic-auth credentials against the table; returns
3816/// the authenticated username.
3817fn authorized(table: &AuthTable, request: &tiny_http::Request) -> Option<String> {
3818    let (user, token) = basic_auth(request)?;
3819    let expected = table.get(&user)?;
3820    constant_time_eq(expected.as_bytes(), token.as_bytes()).then_some(user)
3821}
3822
3823/// Compares two secrets without exiting early on length or content, so
3824/// timing does not leak how much of one matched the other.
3825///
3826/// Extracted when session tokens became a second thing worth comparing
3827/// this way. Written as one function rather than two loops because the
3828/// property is easy to state and easy to lose: an early `return false`
3829/// added later for readability would be invisible in review and would
3830/// undo it.
3831fn constant_time_eq(a: &[u8], b: &[u8]) -> bool {
3832    let mut diff = a.len() ^ b.len();
3833    for i in 0..a.len().min(b.len()) {
3834        diff |= (a[i] ^ b[i]) as usize;
3835    }
3836    diff == 0
3837}
3838
3839/// Identifies a request: an operator credential from the auth file, or a
3840/// self-service account or invite from the store (D36).
3841///
3842/// The file is consulted first, so a name the operator wrote by hand can
3843/// never be shadowed by an issued one. The store refuses to issue those
3844/// names in the first place; checking in this order means the property
3845/// does not depend on that refusal alone.
3846fn authenticate(
3847    table: &AuthTable,
3848    accounts: Option<&accounts::Accounts>,
3849    request: &tiny_http::Request,
3850) -> Option<accounts::Principal> {
3851    if let Some(user) = authorized(table, request) {
3852        return Some(accounts::Principal::Account(user));
3853    }
3854    let (user, secret) = basic_auth(request)?;
3855    accounts?.authenticate(&user, &secret)
3856}
3857
3858/// The `user` and `secret` halves of a basic-auth header, undecided.
3859fn basic_auth(request: &tiny_http::Request) -> Option<(String, String)> {
3860    let auth_header = header(request, "Authorization")?;
3861    let b64 = auth_header.strip_prefix("Basic ")?.trim();
3862    let creds = String::from_utf8(base64_decode(b64)?).ok()?;
3863    let (user, secret) = creds.split_once(':')?;
3864    Some((user.to_string(), secret.to_string()))
3865}
3866
3867/// Serves `POST /api/prepare`: the bytes of one op, for a browser that
3868/// is about to sign them with a passkey (D39).
3869///
3870/// The author is always the authenticated caller and never a name in the
3871/// body, the same rule passkey enrolment follows. A body that could
3872/// choose its author would let this endpoint mint a payload claiming
3873/// somebody else — refused at admission by the view's own author check,
3874/// but only after the node had helpfully built it.
3875///
3876/// Only comments come through here. A verdict is fully determined by the
3877/// review and the button, so it is rendered into the page; a comment
3878/// carries text nobody has typed yet, which is the whole reason this
3879/// round trip exists.
3880fn handle_prepare(
3881    user: &str,
3882    acl: Option<&acl::Effective>,
3883    body_limit: std::num::NonZeroU64,
3884    mut request: tiny_http::Request,
3885) -> std::io::Result<(u16, u64)> {
3886    let req_body = match quota::read_bounded(request.as_reader(), Some(body_limit))? {
3887        quota::Body::Complete(body) => body,
3888        quota::Body::OverLimit { limit, size } => {
3889            return respond_api_too_large(request, limit, size)
3890        }
3891    };
3892    let method = request.method().as_str().to_string();
3893    let path = request.url().split('?').next().unwrap_or("").to_string();
3894    let (status, body) = match acl {
3895        Some(table) => match acl::api_denial(table, user, &method, &path, &req_body, |_| None) {
3896            Some(denial) => (
3897                denial.status,
3898                serde_json::json!({ "error": denial.reason }).to_string(),
3899            ),
3900            None => prepare_body(user, &req_body),
3901        },
3902        None => prepare_body(user, &req_body),
3903    };
3904    let bytes = body.len() as u64;
3905    let response = tiny_http::Response::from_string(body)
3906        .with_status_code(status)
3907        .with_header(
3908            tiny_http::Header::from_bytes(&b"Content-Type"[..], &b"application/json"[..])
3909                .expect("static header"),
3910        );
3911    served(request, response, status, bytes)
3912}
3913
3914/// The body half of [`handle_prepare`], split out so the authorization
3915/// half above has one shape regardless of whether a table exists.
3916fn prepare_body(user: &str, req_body: &[u8]) -> (u16, String) {
3917    let Ok(json) = serde_json::from_slice::<serde_json::Value>(req_body) else {
3918        return (400, r#"{"error":"body must be JSON"}"#.to_string());
3919    };
3920    let field = |name: &str| {
3921        json.get(name)
3922            .and_then(serde_json::Value::as_str)
3923            .map(str::trim)
3924            .filter(|s| !s.is_empty())
3925    };
3926    if field("kind") != Some("comment") {
3927        return (
3928            400,
3929            r#"{"error":"`kind` must be \"comment\"; a verdict is prepared in the page"}"#
3930                .to_string(),
3931        );
3932    }
3933    let (Some(id), Some(text)) = (field("id"), field("body")) else {
3934        return (
3935            400,
3936            r#"{"error":"`id` (the review) and `body` (the comment) are required"}"#.to_string(),
3937        );
3938    };
3939    if text.chars().count() > MAX_COMMENT_CHARS {
3940        return (
3941            400,
3942            serde_json::json!({
3943                "error": format!("a comment is at most {MAX_COMMENT_CHARS} characters"),
3944            })
3945            .to_string(),
3946        );
3947    }
3948    // Minted here, not taken from the body: the comment id is the
3949    // author's retry identity, so a browser that submits one prepared
3950    // payload twice must hit the refusal that identity exists to give.
3951    let comment_id = choir_identity::ActorKey::generate().actor_id().to_hex();
3952    let prepared = prepare::comment(id, user, &comment_id, text);
3953    (
3954        200,
3955        serde_json::json!({
3956            "payload_hex": prepared.payload_hex,
3957            "challenge": prepared.challenge,
3958            "comment": comment_id,
3959            "channel": user,
3960        })
3961        .to_string(),
3962    )
3963}
3964
3965/// Longest comment the prepare endpoint will build. A review thread is
3966/// discussion, and the op log keeps every byte of it forever.
3967const MAX_COMMENT_CHARS: usize = 4096;
3968
3969/// Serves the operator's console (D72), both the page and the three
3970/// actions on it.
3971///
3972/// One function for `GET` and `POST` because they are one surface: the
3973/// forms post back to the address that rendered them, which is what keeps
3974/// the page and its actions from drifting into disagreeing about which
3975/// grants exist.
3976///
3977/// Gated at `@node write`, the same authority
3978/// [`crate::acl::api_denial`] requires to mint an invite through the API.
3979/// It has to be: every button here is one of those calls.
3980fn respond_people(
3981    mut request: tiny_http::Request,
3982    store: Option<&accounts::Accounts>,
3983    acl: Option<&acl::Effective>,
3984    user: &str,
3985    root: &std::path::Path,
3986    scheme: &'static str,
3987    body_limit: std::num::NonZeroU64,
3988) -> std::io::Result<(u16, u64)> {
3989    let docs = docs_url(root);
3990    let chrome = browse::Chrome {
3991        site: None,
3992        theme: chosen_theme(&request),
3993        here: "/people",
3994        account: true,
3995        // Reaching this page at all means holding `@node write`, which is
3996        // what the console link is gated on — the refusal below is the
3997        // path for everybody else.
3998        console: true,
3999        docs: docs.as_deref(),
4000        signed_in: Some(true),
4001    };
4002    let Some(store) = store else {
4003        let html = ui::refusal(
4004            "Account self-service is off",
4005            503,
4006            &ui::Refusal {
4007                code: "accounts_disabled",
4008                error: "This node was started without an accounts file.",
4009                expected: Some("a node that issues credentials"),
4010                actual: Some("the operator's console"),
4011                next: "Start the daemon with --accounts-file to issue and answer invites here.",
4012            },
4013            &[],
4014            reader_chrome(&request),
4015        );
4016        return respond_page(request, 503, html, None);
4017    };
4018    let denial = acl.and_then(|table| table.check(user, &acl::Scope::Node, acl::Level::Write));
4019    if let Some(denial) = denial {
4020        let html = ui::refusal(
4021            "Not yours to see",
4022            denial.status,
4023            &ui::Refusal {
4024                code: "forbidden",
4025                error: "This page shows and changes who may reach this node.",
4026                expected: Some("@node write"),
4027                actual: Some("the grants you hold"),
4028                next: "Ask the operator, who is whoever holds the node's own credential.",
4029            },
4030            &[],
4031            reader_chrome(&request),
4032        );
4033        return respond_page(request, denial.status, html, None);
4034    }
4035    // What an operator may grant is what they may read, which for a
4036    // `@node write` holder is everything on the node.
4037    let repos = browse::repositories(root, &|_| true);
4038
4039    if request.method().as_str() != "POST" {
4040        let said = match request.url().split_once("?said=") {
4041            Some((_, code)) => said_in_words(code.split('&').next().unwrap_or("")),
4042            None => "",
4043        };
4044        let page = people_page::render(
4045            store,
4046            &repos,
4047            operator_contact(root).as_deref(),
4048            said,
4049            chrome,
4050        );
4051        return respond_console(request, page);
4052    }
4053    if !same_origin(&request, scheme) {
4054        let html = ui::refusal(
4055            "Cross-origin write refused",
4056            403,
4057            &ui::Refusal {
4058                code: "cross_origin",
4059                error: "That form was submitted from another site.",
4060                expected: Some("a form on this node"),
4061                actual: Some("a form somewhere else"),
4062                next: "Open this node's own page and try again.",
4063            },
4064            &[],
4065            reader_chrome(&request),
4066        );
4067        return respond_page(request, 403, html, None);
4068    }
4069    let body = match quota::read_bounded(request.as_reader(), Some(body_limit))? {
4070        quota::Body::Complete(body) => String::from_utf8_lossy(&body).into_owned(),
4071        quota::Body::OverLimit { limit, size } => {
4072            return respond_api_too_large(request, limit, size)
4073        }
4074    };
4075    let origin = header(&request, "host").map(|host| format!("{scheme}://{host}"));
4076    let field = |key: &str| join_page::form_value(&body, key);
4077    // `.git` is how the ACL spells a repository, and leaving it off is
4078    // the mistake that grants nothing at all. Added here rather than
4079    // refused: the select offers repository names, and there is one right
4080    // answer.
4081    let grant = || {
4082        let repo = field("repo")?;
4083        let level = field("level")?;
4084        Some(format!("{repo}.git {level}"))
4085    };
4086    match field("action").as_deref() {
4087        Some("grant") => {
4088            let (Some(id), Some(grant)) = (field("request_id"), grant()) else {
4089                return respond_people_result(request, "bad");
4090            };
4091            let (status, _) = store.grant_request(
4092                user,
4093                &serde_json::json!({ "request_id": id, "grants": [grant] }),
4094            );
4095            respond_people_result(request, if status == 200 { "granted" } else { "bad" })
4096        }
4097        Some("contact") => {
4098            // Absent rather than empty is how the form spells "clear
4099            // it", since `form_value` refuses a blank value.
4100            let value = field("contact").unwrap_or_default();
4101            match write_operator_contact(root, &value) {
4102                Ok(()) => respond_people_result(request, "contact"),
4103                Err(_) => respond_people_result(request, "bad"),
4104            }
4105        }
4106        Some("decline") => {
4107            let Some(id) = field("request_id") else {
4108                return respond_people_result(request, "bad");
4109            };
4110            let (status, _) = store.decline_request(&serde_json::json!({ "request_id": id }));
4111            respond_people_result(request, if status == 200 { "declined" } else { "bad" })
4112        }
4113        Some("invite") => {
4114            let Some(grant) = grant() else {
4115                return respond_people_result(request, "bad");
4116            };
4117            // A readable name is optional (D75): the seat stays open
4118            // either way, and the person redeeming picks the username.
4119            let name = field("display_name");
4120            let mut body = serde_json::json!({ "grants": [grant] });
4121            if let Some(name) = name.as_deref() {
4122                body["display_name"] = serde_json::json!(name);
4123            }
4124            let (status, answer) =
4125                with_join_url(store.invite(user, &body), origin.as_deref(), "invite");
4126            let parsed: serde_json::Value = serde_json::from_str(&answer).unwrap_or_default();
4127            match (status, parsed["join_url"].as_str()) {
4128                (200, Some(link)) => {
4129                    let page = people_page::minted(
4130                        link,
4131                        name.as_deref().unwrap_or("whoever opens it"),
4132                        chrome,
4133                    );
4134                    respond_console(request, page)
4135                }
4136                _ => respond_people_result(request, "bad"),
4137            }
4138        }
4139        _ => respond_people_result(request, "bad"),
4140    }
4141}
4142
4143/// The outcome of a console action, as a sentence.
4144///
4145/// Chosen from a fixed set here rather than echoed out of the query
4146/// string, so the page cannot be made to say anything by a link somebody
4147/// sends an operator.
4148fn said_in_words(code: &str) -> &'static str {
4149    match code {
4150        "granted" => "Let in. The link they already hold now works.",
4151        "declined" => "Declined. Their link says only that it is not valid.",
4152        "contact" => "Saved. The front page offers it to anybody who arrives with nothing.",
4153        "bad" => "That did not work. Nothing changed.",
4154        _ => "",
4155    }
4156}
4157
4158/// Post/redirect/get for a console action, so a refresh does not repeat it.
4159fn respond_people_result(request: tiny_http::Request, said: &str) -> std::io::Result<(u16, u64)> {
4160    let location = format!("/people?said={said}");
4161    let response = tiny_http::Response::empty(303)
4162        .with_header(
4163            tiny_http::Header::from_bytes(&b"Location"[..], location.as_bytes())
4164                .expect("location header"),
4165        )
4166        .with_header(
4167            tiny_http::Header::from_bytes(&b"Cache-Control"[..], &b"no-store"[..])
4168                .expect("static header"),
4169        );
4170    served(request, response, 303, 0)
4171}
4172
4173/// Sends a console page with the headers every browser surface carries.
4174///
4175/// [`BROWSER_CSP`], not the scripted one: nothing on this page runs, and
4176/// a header that allowed script here would be permission granted to a
4177/// page that has no use for it.
4178fn respond_console(
4179    request: tiny_http::Request,
4180    page: people_page::Page,
4181) -> std::io::Result<(u16, u64)> {
4182    let bytes = page.html.len() as u64;
4183    let response = tiny_http::Response::from_string(page.html)
4184        .with_status_code(page.status)
4185        .with_header(
4186            tiny_http::Header::from_bytes(&b"Content-Type"[..], &b"text/html; charset=utf-8"[..])
4187                .expect("static header"),
4188        )
4189        .with_header(
4190            tiny_http::Header::from_bytes(&b"Cache-Control"[..], &b"private, no-store"[..])
4191                .expect("static header"),
4192        )
4193        .with_header(
4194            tiny_http::Header::from_bytes(&b"Content-Security-Policy"[..], BROWSER_CSP)
4195                .expect("static header"),
4196        )
4197        .with_header(
4198            tiny_http::Header::from_bytes(&b"Referrer-Policy"[..], REFERRER_POLICY)
4199                .expect("static header"),
4200        );
4201    served(request, response, page.status, bytes)
4202}
4203
4204/// The operator's contact, for the one page a stranger can reach.
4205///
4206/// One line of `<root>/.choir/contact`, absent by default, and
4207/// deliberately not a compiled-in constant: a personal identifier baked
4208/// into a published binary cannot be taken back out of the copies of it,
4209/// which is the same reason host addresses and owner names are
4210/// placeholders in every tracked file here.
4211///
4212/// Read per request rather than once at startup. That was the other way
4213/// round until the console could edit it (D72), and a value an operator
4214/// can change from a page but only see take effect after a restart is a
4215/// control that appears not to work. The read is one small file on a
4216/// route that is already rendering several kilobytes, behind the same
4217/// public limiter as the rest of the pre-auth surface.
4218fn operator_contact(root: &std::path::Path) -> Option<String> {
4219    std::fs::read_to_string(root.join(".choir/contact"))
4220        .ok()
4221        .and_then(|text| text.lines().next().map(str::trim).map(str::to_string))
4222        .filter(|line| !line.is_empty())
4223}
4224
4225/// Where [`operator_contact`] reads from.
4226fn contact_path(root: &std::path::Path) -> std::path::PathBuf {
4227    root.join(".choir/contact")
4228}
4229
4230/// Where this node's book is published, if its operator has said (D76).
4231///
4232/// One line of `<root>/.choir/docs-url`, absent by default, and read per
4233/// request for the same two reasons [`operator_contact`] is: a host
4234/// address compiled into a published binary cannot be taken back out of
4235/// the copies of it, and a value an operator can set but only see take
4236/// effect after a restart is a control that appears not to work.
4237///
4238/// Only an absolute `https://` or `http://` address is accepted. The
4239/// value reaches an `href` on a page that answers anybody, so a relative
4240/// string here would silently become a path on this node, and a
4241/// `javascript:` one would be exactly the injection the rest of this
4242/// surface is written to refuse.
4243fn docs_url(root: &std::path::Path) -> Option<String> {
4244    std::fs::read_to_string(root.join(".choir/docs-url"))
4245        .ok()
4246        .and_then(|text| text.lines().next().map(str::trim).map(str::to_string))
4247        .filter(|line| line.starts_with("https://") || line.starts_with("http://"))
4248        .filter(|line| !line.chars().any(char::is_control))
4249}
4250
4251/// Sets or clears the operator's contact from the console (D72).
4252///
4253/// Written with the private-file primitive the rest of this node's own
4254/// state uses, and validated first: the string lands on the page that
4255/// answers anybody, so a control character in it would be a header or a
4256/// second line in a file whose grammar is one line.
4257///
4258/// An empty submission removes the file rather than writing an empty
4259/// one, because "no contact" and "a contact that is nothing" render the
4260/// same and only one of them is a state somebody meant.
4261fn write_operator_contact(root: &std::path::Path, value: &str) -> Result<(), String> {
4262    let value = value.trim();
4263    let path = contact_path(root);
4264    if value.is_empty() {
4265        return match std::fs::remove_file(&path) {
4266            Ok(()) => Ok(()),
4267            Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
4268            Err(e) => Err(format!("{}: {e}", path.display())),
4269        };
4270    }
4271    if value.chars().count() > 200 {
4272        return Err("a contact must be at most 200 characters".to_string());
4273    }
4274    if value.chars().any(char::is_control) {
4275        return Err(
4276            "a contact must be one line and must not contain control characters".to_string(),
4277        );
4278    }
4279    if let Some(parent) = path.parent() {
4280        std::fs::create_dir_all(parent).map_err(|e| format!("{}: {e}", parent.display()))?;
4281    }
4282    choir_fs::write_atomic_private(&path, format!("{value}\n").as_bytes())
4283        .map_err(|e| format!("{}: {e}", path.display()))
4284}
4285
4286/// Mints the token an account uses for git and the CLI (D75).
4287///
4288/// `POST` only, same-origin only, and rendered directly rather than
4289/// redirected to: the node keeps only a hash, so a redirect would drop
4290/// the one copy of the secret that exists.
4291fn respond_account_token(
4292    request: tiny_http::Request,
4293    store: Option<&accounts::Accounts>,
4294    user: &str,
4295    acl: Option<&acl::Effective>,
4296    root: &std::path::Path,
4297    scheme: &'static str,
4298) -> std::io::Result<(u16, u64)> {
4299    let docs = docs_url(root);
4300    let chrome = browse::Chrome {
4301        site: None,
4302        theme: chosen_theme(&request),
4303        here: "/account",
4304        account: true,
4305        console: acl.is_some_and(|table| {
4306            table
4307                .check(user, &acl::Scope::Node, acl::Level::Write)
4308                .is_none()
4309        }),
4310        docs: docs.as_deref(),
4311        signed_in: Some(true),
4312    };
4313    let refuse = |request, title: &str, status: u16, reason: &'static str, next: &'static str| {
4314        let html = ui::refusal(
4315            title,
4316            status,
4317            &ui::Refusal {
4318                code: "token",
4319                error: reason,
4320                expected: None,
4321                actual: None,
4322                next,
4323            },
4324            &[],
4325            reader_chrome(&request),
4326        );
4327        respond_page(request, status, html, None)
4328    };
4329    if request.method().as_str() != "POST" {
4330        return refuse(
4331            request,
4332            "Not a page",
4333            405,
4334            "A token is made by pressing the button on your account page.",
4335            "Open /account and use the form there.",
4336        );
4337    }
4338    if !same_origin(&request, scheme) {
4339        return refuse(
4340            request,
4341            "Cross-origin write refused",
4342            403,
4343            "That form was submitted from another site.",
4344            "Open this node's own account page and try again.",
4345        );
4346    }
4347    let Some(store) = store else {
4348        return refuse(
4349            request,
4350            "Account self-service is off",
4351            503,
4352            "This node was started without an accounts file.",
4353            "Ask the operator to start the daemon with --accounts-file.",
4354        );
4355    };
4356    let (status, answer) = store.mint_token(user);
4357    let parsed: serde_json::Value = serde_json::from_str(&answer).unwrap_or_default();
4358    let Some(token) = parsed["token"].as_str() else {
4359        return refuse(
4360            request,
4361            "No token for this credential",
4362            status,
4363            "Only an issued account can hold a token, and yours is not one.",
4364            "An operator credential from the auth file already is a password; use it.",
4365        );
4366    };
4367    let _ = acl;
4368    let node = header(&request, "host").map(|host| format!("{scheme}://{host}"));
4369    let page = account_page::minted(
4370        token,
4371        user,
4372        node.as_deref(),
4373        parsed["replaced"].as_bool().unwrap_or(false),
4374        chrome,
4375    );
4376    let bytes = page.html.len() as u64;
4377    let response = tiny_http::Response::from_string(page.html)
4378        .with_status_code(page.status)
4379        .with_header(
4380            tiny_http::Header::from_bytes(&b"Content-Type"[..], &b"text/html; charset=utf-8"[..])
4381                .expect("static header"),
4382        )
4383        .with_header(
4384            tiny_http::Header::from_bytes(&b"Cache-Control"[..], &b"private, no-store"[..])
4385                .expect("static header"),
4386        )
4387        .with_header(
4388            tiny_http::Header::from_bytes(&b"Content-Security-Policy"[..], BROWSER_CSP)
4389                .expect("static header"),
4390        )
4391        .with_header(
4392            tiny_http::Header::from_bytes(&b"Referrer-Policy"[..], REFERRER_POLICY)
4393                .expect("static header"),
4394        );
4395    served(request, response, page.status, bytes)
4396}
4397
4398/// Whether a state-changing request came from this node's own pages.
4399///
4400/// **Absent means yes.** A browser sends `Origin` on every `POST`; a
4401/// program does not, and `curl`, `choirctl` and every test in this
4402/// workspace are programs. So a missing header is a non-browser client
4403/// and passes, while a header naming somewhere else is a page on another
4404/// site steering a browser that still holds a credential for this one.
4405///
4406/// It matters because of what a browser sends unasked. The session
4407/// cookie is `SameSite=Lax` and so never rides a cross-site `POST`, but
4408/// cached Basic credentials do -- an operator who authenticated with the
4409/// auth file once has a browser that will re-present it to any page that
4410/// asks. Without this check, a link could mint an invite in their name.
4411fn same_origin(request: &tiny_http::Request, scheme: &'static str) -> bool {
4412    origin_mismatch(request, scheme).is_none()
4413}
4414
4415/// The two sides of a failed [`same_origin`], as the reader should see
4416/// them: what the node required, and what it was sent.
4417///
4418/// `None` when the request is same-origin, so a caller reads it as the
4419/// refusal itself rather than as a detail beside one.
4420///
4421/// Both halves are the node's own address and the browser's own address.
4422/// Neither is a secret, and withholding them was not buying anything: the
4423/// page said "a form somewhere else" for a request that came from this
4424/// node's own sign-in page over the wrong scheme, which sends the reader
4425/// looking for an attacker instead of at their address bar. A refusal
4426/// that cannot be acted on is a refusal that gets reported as a bug.
4427fn origin_mismatch(request: &tiny_http::Request, scheme: &'static str) -> Option<(String, String)> {
4428    let origin = header(request, "origin")?;
4429    match header(request, "host") {
4430        Some(host) => {
4431            let expected = format!("{scheme}://{host}");
4432            (origin != expected).then_some((expected, origin))
4433        }
4434        // No `Host` and an `Origin` that claims one: nothing to compare
4435        // against, so refuse rather than guess.
4436        None => Some(("this node's own address".to_string(), origin)),
4437    }
4438}
4439
4440/// Serves one `/api/accounts...` request (D36).
4441///
4442/// The ACL decides who may issue, revoke and read the roster, through the
4443/// same [`acl::api_denial`] table every other endpoint goes through. Two
4444/// things are checked here instead, because neither is a grant: that the
4445/// node has a store at all, and that redemption is being performed by the
4446/// invite it names rather than by somebody who merely holds a credential.
4447/// Adds the one-click join link to a freshly minted invite (D57).
4448///
4449/// The operator's own reason for this: the store answers with an
4450/// `id:secret` pair shaped for `curl -u`, which is right for a script and
4451/// is not something anybody pastes into a chat window. The link is the
4452/// artefact that actually gets sent to a person, so the endpoint that
4453/// mints the invite is the place to build it.
4454///
4455/// A node that was reached without a `Host` header gets no link rather
4456/// than a guessed one, on the same reasoning as `browse::node_url`: an
4457/// operator who pastes a wrong address has a mystery, and one who finds
4458/// no link goes and looks.
4459///
4460/// Anything that is not a successful mint passes straight through: an
4461/// error body has no invite in it to link to.
4462///
4463/// `field` names the member holding the `id:secret` pair, because D72's
4464/// access request answers with the same two halves under a different name
4465/// and lands on the same page. One builder rather than two: the link
4466/// grammar is `/join?i=&k=` in exactly one place, and a second copy is
4467/// how the two would come to disagree.
4468fn with_join_url(answer: (u16, String), origin: Option<&str>, field: &str) -> (u16, String) {
4469    let (status, body) = answer;
4470    if status != 200 {
4471        return (status, body);
4472    }
4473    let (Some(origin), Ok(mut parsed)) = (origin, serde_json::from_str::<serde_json::Value>(&body))
4474    else {
4475        return (status, body);
4476    };
4477    let Some((id, secret)) = parsed[field].as_str().and_then(|p| p.split_once(':')) else {
4478        return (status, body);
4479    };
4480    let url = format!("{origin}/join?i={id}&k={secret}");
4481    parsed["join_url"] = serde_json::Value::String(url);
4482    (status, parsed.to_string())
4483}
4484
4485/// The self-service surface a request is answered against: the store, and
4486/// whether passkeys are switched on (D71).
4487///
4488/// One argument rather than two because they are one decision. Passing
4489/// them separately is what let the store's presence stand in for the
4490/// passkey answer in the first place.
4491struct SelfService<'a> {
4492    store: Option<&'a accounts::Accounts>,
4493    passkeys: bool,
4494}
4495
4496fn handle_accounts(
4497    self_service: SelfService<'_>,
4498    user: &str,
4499    invite: Option<&str>,
4500    acl: Option<&acl::Effective>,
4501    body_limit: std::num::NonZeroU64,
4502    scheme: &'static str,
4503    mut request: tiny_http::Request,
4504) -> std::io::Result<(u16, u64)> {
4505    let req_body = match quota::read_bounded(request.as_reader(), Some(body_limit))? {
4506        quota::Body::Complete(body) => body,
4507        quota::Body::OverLimit { limit, size } => {
4508            return respond_api_too_large(request, limit, size)
4509        }
4510    };
4511    let method = request.method().as_str().to_string();
4512    let path = request.url().split('?').next().unwrap_or("").to_string();
4513    // Issuing and revoking are the two calls on this node a hostile page
4514    // would most like to make with somebody else's cached credential.
4515    if method == "POST" && !same_origin(&request, scheme) {
4516        return respond_json(request, 403, r#"{"error":"cross-origin write refused"}"#);
4517    }
4518    // The address this operator actually reached the node on. The store
4519    // cannot know it — it has never seen a request — so the link is
4520    // assembled here, where the `Host` header is.
4521    let origin = header(&request, "host").map(|host| format!("{scheme}://{host}"));
4522    let SelfService { store, passkeys } = self_service;
4523    let (status, body) = match (store, acl) {
4524        (None, _) => (
4525            503,
4526            r#"{"error":"account self-service is not enabled on this node"}"#.to_string(),
4527        ),
4528        // Unreachable through the daemon, which refuses `--accounts-file`
4529        // without `--acl-file`. Fail closed anyway rather than assume the
4530        // only caller stays the only caller.
4531        (_, None) => (
4532            403,
4533            r#"{"error":"account self-service needs an ACL"}"#.to_string(),
4534        ),
4535        (Some(store), Some(table)) => {
4536            let denial = acl::api_denial(table, user, &method, &path, &req_body, |_| None);
4537            if let Some(denial) = denial {
4538                (
4539                    denial.status,
4540                    serde_json::json!({ "error": denial.reason }).to_string(),
4541                )
4542            } else {
4543                let json = if req_body.iter().all(u8::is_ascii_whitespace) {
4544                    Some(serde_json::Value::Null)
4545                } else {
4546                    serde_json::from_slice::<serde_json::Value>(&req_body).ok()
4547                };
4548                match (json, method.as_str(), path.as_str()) {
4549                    (None, _, _) => (400, r#"{"error":"body must be JSON"}"#.to_string()),
4550                    (Some(json), "POST", "/api/accounts/invite") => {
4551                        with_join_url(store.invite(user, &json), origin.as_deref(), "invite")
4552                    }
4553                    (Some(json), "POST", "/api/accounts/redeem") => match invite {
4554                        Some(id) => store.redeem(id, &json),
4555                        None => (
4556                            403,
4557                            r#"{"error":"redeem an invite by presenting it as the credential"}"#
4558                                .to_string(),
4559                        ),
4560                    },
4561                    (Some(json), "POST", "/api/accounts/revoke") => store.revoke(&json),
4562                    (Some(json), "POST", "/api/accounts/request/grant") => {
4563                        store.grant_request(user, &json)
4564                    }
4565                    (Some(json), "POST", "/api/accounts/request/decline") => {
4566                        store.decline_request(&json)
4567                    }
4568                    (Some(_), "POST", "/api/accounts/passkey" | "/api/accounts/passkey/remove")
4569                        if !passkeys =>
4570                    {
4571                        (
4572                            503,
4573                            r#"{"error":"passkeys are not enabled on this node"}"#.to_string(),
4574                        )
4575                    }
4576                    (Some(json), "POST", "/api/accounts/passkey") => {
4577                        store.enroll_passkey(user, &json)
4578                    }
4579                    (Some(json), "POST", "/api/accounts/passkey/remove") => {
4580                        store.remove_passkey(user, &json)
4581                    }
4582                    (Some(_), "GET", "/api/accounts") => (200, store.list_json().to_string()),
4583                    _ => (404, r#"{"error":"no such endpoint"}"#.to_string()),
4584                }
4585            }
4586        }
4587    };
4588    let bytes = body.len() as u64;
4589    let response = tiny_http::Response::from_string(body)
4590        .with_status_code(status)
4591        .with_header(
4592            tiny_http::Header::from_bytes(&b"Content-Type"[..], &b"application/json"[..])
4593                .expect("static header"),
4594        );
4595    served(request, response, status, bytes)
4596}
4597
4598/// Encodes bytes as standard base64 with `=` padding.
4599fn base64_encode(bytes: &[u8]) -> String {
4600    const ALPHABET: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
4601    let mut out = String::new();
4602    for chunk in bytes.chunks(3) {
4603        let b = [
4604            chunk[0],
4605            *chunk.get(1).unwrap_or(&0),
4606            *chunk.get(2).unwrap_or(&0),
4607        ];
4608        let n = (u32::from(b[0]) << 16) | (u32::from(b[1]) << 8) | u32::from(b[2]);
4609        out.push(ALPHABET[(n >> 18) as usize & 63] as char);
4610        out.push(ALPHABET[(n >> 12) as usize & 63] as char);
4611        out.push(if chunk.len() > 1 {
4612            ALPHABET[(n >> 6) as usize & 63] as char
4613        } else {
4614            '='
4615        });
4616        out.push(if chunk.len() > 2 {
4617            ALPHABET[n as usize & 63] as char
4618        } else {
4619            '='
4620        });
4621    }
4622    out
4623}
4624
4625/// Renders a raw ed25519 public key in OpenSSH `ssh-ed25519 <b64>` form
4626/// (the wire blob is two length-prefixed strings: key type, key bytes).
4627pub fn ssh_ed25519_pubkey(raw: &[u8; 32]) -> String {
4628    let mut blob = Vec::new();
4629    for part in [b"ssh-ed25519".as_slice(), raw.as_slice()] {
4630        blob.extend_from_slice(&(part.len() as u32).to_be_bytes());
4631        blob.extend_from_slice(part);
4632    }
4633    format!("ssh-ed25519 {}", base64_encode(&blob))
4634}
4635
4636/// One line of the trusted-keys file: a public key the node accepts, and
4637/// optionally the channel name its holder is allowed to speak as.
4638#[derive(Debug, Clone, PartialEq, Eq)]
4639pub struct TrustedKey {
4640    /// Operator-assigned channel name this key may act as, when the line
4641    /// carries one. `None` = key trusted, name unconstrained (the
4642    /// pre-existing behaviour, and what a bare hex line still means).
4643    pub name: Option<String>,
4644    /// Actor id (hex): the git push principal, and the `key_id` a
4645    /// signature carries. **Always derived from the key**, never from the
4646    /// name — so adding a name column cannot shift push attribution.
4647    pub actor_id: String,
4648    /// The raw ed25519 public key.
4649    pub key: [u8; 32],
4650}
4651
4652/// Parses a trusted-keys file. One key per line, `#` comments and blank
4653/// lines skipped, in either form:
4654///
4655/// ```text
4656/// <64-char hex>            # trusted, speaks as any channel
4657/// <name> <64-char hex>     # trusted, bound to that channel name
4658/// ```
4659///
4660/// The name column is additive: files written before it existed parse
4661/// unchanged, and a key with no name keeps exactly its old permissions.
4662/// Binding is opt-in per key, so adding one line cannot lock anybody
4663/// else out.
4664///
4665/// # Errors
4666///
4667/// Filesystem failures, and [`std::io::ErrorKind::InvalidData`] for a
4668/// line that is not a valid ed25519 public key, or that binds one name
4669/// to two keys. An invalid line fails the whole parse: a partial signer
4670/// list would silently drop the ability to verify somebody's pushes.
4671pub fn parse_keys_file(path: &Path) -> std::io::Result<Vec<TrustedKey>> {
4672    let invalid = |msg: String| std::io::Error::new(std::io::ErrorKind::InvalidData, msg);
4673    let mut registry = choir_identity::Registry::new();
4674    let mut out: Vec<TrustedKey> = Vec::new();
4675    for line in std::fs::read_to_string(path)?.lines() {
4676        let line = line.trim();
4677        if line.is_empty() || line.starts_with('#') {
4678            continue;
4679        }
4680        let (name, hex) = match line.rsplit_once(char::is_whitespace) {
4681            Some((name, hex)) => (Some(name.trim().to_string()), hex),
4682            None => (None, line),
4683        };
4684        let bytes = platform::hex_decode(hex)
4685            .filter(|b| b.len() == 32)
4686            .ok_or_else(|| {
4687                invalid("keys file lines are `<hex>` or `<name> <hex>`, 64 hex chars".to_string())
4688            })?;
4689        // One name, one key. Two keys sharing a name would make the
4690        // binding meaningless in exactly the direction it exists to
4691        // prevent: either holder could speak as that channel.
4692        if let Some(name) = &name {
4693            if out.iter().any(|k| k.name.as_ref() == Some(name)) {
4694                return Err(invalid(format!(
4695                    "keys file binds {name:?} to more than one key"
4696                )));
4697            }
4698        }
4699        let mut key = [0u8; 32];
4700        key.copy_from_slice(&bytes);
4701        let actor_id = registry
4702            .register(&key)
4703            .map_err(|e| invalid(format!("{e:?}")))?;
4704        out.push(TrustedKey {
4705            name,
4706            actor_id: actor_id.to_hex(),
4707            key,
4708        });
4709    }
4710    Ok(out)
4711}
4712
4713/// Writes `<root>/.choir/allowed_signers` — the file git's ssh signature
4714/// verification checks push certificates against. One line per actor:
4715/// principal (the actor id) followed by the OpenSSH public key.
4716///
4717/// # Errors
4718///
4719/// Propagates filesystem failures.
4720pub fn write_allowed_signers(root: &Path, keys: &[TrustedKey]) -> std::io::Result<PathBuf> {
4721    let dir = root.join(".choir");
4722    std::fs::create_dir_all(&dir)?;
4723    let path = dir.join("allowed_signers");
4724    let mut contents = String::new();
4725    for k in keys {
4726        // Principal stays the actor id even when the line carries a name:
4727        // push attribution (`key/<principal>`) is a property of the key,
4728        // and rewriting it would silently reattribute pushes.
4729        contents.push_str(&format!("{} {}\n", k.actor_id, ssh_ed25519_pubkey(&k.key)));
4730    }
4731    std::fs::write(&path, contents)?;
4732    Ok(path)
4733}
4734
4735/// Decodes standard base64 (with `=` padding); `None` on any bad input.
4736pub(crate) fn base64_decode(input: &str) -> Option<Vec<u8>> {
4737    const ALPHABET: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
4738    let mut rev = [255u8; 256];
4739    for (i, &c) in ALPHABET.iter().enumerate() {
4740        rev[c as usize] = i as u8;
4741    }
4742    let input = input.trim_end_matches('=');
4743    let mut out = Vec::with_capacity(input.len() * 3 / 4);
4744    let mut buf = 0u32;
4745    let mut bits = 0u32;
4746    for c in input.bytes() {
4747        let v = rev[c as usize];
4748        if v == 255 {
4749            return None;
4750        }
4751        buf = (buf << 6) | u32::from(v);
4752        bits += 6;
4753        if bits >= 8 {
4754            bits -= 8;
4755            out.push((buf >> bits) as u8);
4756        }
4757    }
4758    Some(out)
4759}
4760
4761/// The agent-facing surface as plain text, generated from
4762/// `crates/choir-cli/src/surface.rs` and checked for staleness by
4763/// `choir-cli/tests/it/surface.rs`. Included rather than depended on: the
4764/// node has no business linking the CLI, and a generated file with a
4765/// staleness test is the cheaper coupling.
4766const LLMS_TXT: &str = include_str!("llms.txt");
4767
4768/// The machine-readable API description (D17), generated from
4769/// `choir-cli`'s surface table and checked for staleness by
4770/// `choir-cli/tests/it/surface.rs`.
4771///
4772/// Included rather than depended on, for the reason `llms.txt` already
4773/// is: the node has no business linking the CLI, and a generated file
4774/// with a staleness test is the cheaper coupling.
4775const SCHEMA_JSON: &str = include_str!("schema.json");
4776
4777/// [`SCHEMA_JSON`] with a `capabilities` object describing what this
4778/// particular node will accept (D17).
4779///
4780/// The row calls for "versioned API + deprecation policy + capability
4781/// negotiation", and the three parts land in different places on
4782/// purpose. The version and the deprecations are properties of the API
4783/// and are generated. The capabilities are properties of *this
4784/// deployment* — whether it issues accounts, grades requests against an
4785/// ACL, or runs a sequencer at all — and change with the flags it was
4786/// started with. A client that reads only the committed file would build
4787/// against a node that does not exist.
4788///
4789/// Deliberately coarse: what a client can *branch on*, not the operator's
4790/// configuration. Whether review is required or a quota is set changes
4791/// which requests succeed, not which requests are well-formed, and the
4792/// node already answers those in its own words with a code and a repair
4793/// (`ERRORS.md`). Listing them here would invite a client to
4794/// pre-emptively refuse what the node would have explained.
4795fn schema_with_capabilities(accounts: bool, acl: bool, platform: bool) -> String {
4796    let mut doc: serde_json::Value =
4797        serde_json::from_str(SCHEMA_JSON).expect("the generated schema is JSON");
4798    doc["capabilities"] = serde_json::json!({
4799        // Credentials can be issued through the API rather than by hand.
4800        "accounts": accounts,
4801        // Requests are graded per repository, so a 404 may mean "not
4802        // granted" rather than "not here" — the distinction `llms.txt`
4803        // spells out and a client has to know before it retries.
4804        "acl": acl,
4805        // There is a sequencer behind this node, so signed operations
4806        // are admitted at all. Without one it serves git and nothing
4807        // else, and every `/api/submit` is refused for a reason no
4808        // amount of client-side correctness fixes.
4809        "platform": platform,
4810    });
4811    format!(
4812        "{}\n",
4813        serde_json::to_string_pretty(&doc).expect("the schema is always serializable")
4814    )
4815}
4816
4817/// The sync contract, served at `/sync.md`. Hand-authored, unlike
4818/// `llms.txt`, and included from the repository root so the served copy
4819/// and the committed one cannot disagree.
4820const SYNC_MD: &str = include_str!("../../../SYNC.md");
4821
4822/// Routes one `/api/...` request to the platform (503 when disabled).
4823/// Serves the browser page from the cache, or `304` when the client
4824/// already holds the current one.
4825///
4826/// The conditional check happens before the cache lookup and before
4827/// any rendering, so a reader polling an idle node costs one integer
4828/// comparison and an empty response. That is the whole reason the page
4829/// can be refreshed aggressively without the node noticing.
4830fn handle_ui(
4831    platform: Option<&Platform>,
4832    cache: &ui::UiCache,
4833    user: &str,
4834    acl: Option<&acl::Effective>,
4835    root: &std::path::Path,
4836    passkeys: bool,
4837    request: tiny_http::Request,
4838) -> std::io::Result<(u16, u64)> {
4839    let platform = match platform {
4840        Some(p) => p,
4841        None => {
4842            // A page, not a line of plain text: this is the node's front
4843            // door, so it is the first thing a person sees, and "there is
4844            // nothing to show" reads as a broken node rather than as a
4845            // node deliberately started without a sequencer.
4846            let html = ui::refusal(
4847                "This node has no view to show",
4848                503,
4849                &ui::Refusal {
4850                    code: "platform_disabled",
4851                    error: "This node serves git repositories, but its platform API is \
4852                            switched off, so there is no op log, no sequencer and no \
4853                            materialized view behind this page.",
4854                    expected: Some("a node started with the platform API enabled"),
4855                    actual: Some("a git-only node"),
4856                    next: "Browse the repositories instead — the link above works, and \
4857                           cloning and pushing work exactly as they always did. This page \
4858                           fills in once the operator restarts the node with the platform \
4859                           API on.",
4860                },
4861                &[("/r/", "repositories")],
4862                reader_chrome(&request),
4863            );
4864            return respond_page(request, 503, html, None);
4865        }
4866    };
4867
4868    // Without an ACL every reader sees one page, so the reader key is
4869    // empty and both the cache and the `ETag` behave exactly as they did
4870    // before D29 phase B. With one, the key carries the grants, so an
4871    // edit to the ACL file invalidates a browser's copy of the page as
4872    // surely as a new op does.
4873    let reader = acl.map(|table| table.cache_key(user)).unwrap_or_default();
4874    // The book's address is part of what this page renders, and it is a
4875    // file an operator edits while the node runs (D76) -- so it belongs
4876    // in the cache identity beside the grants, or the bar keeps the link
4877    // it had when the page was first rendered. Folded into the reader
4878    // key rather than added as a parameter to `etag` and `page`: both
4879    // already take this string precisely so that "what this reader is
4880    // shown" can grow without a third one.
4881    let docs = docs_url(root);
4882    let reader = match docs.as_deref() {
4883        Some(url) => format!("{reader}\u{1f}{url}"),
4884        None => reader,
4885    };
4886    let seq = platform.view_seq();
4887    // Read once and used for both the tag and the render, so the page a
4888    // reader is handed resolved names against exactly the store state
4889    // its `ETag` claims (D46).
4890    let (generation, roster) = platform.roster();
4891    let chrome = browse::Chrome {
4892        site: None,
4893        theme: chosen_theme(&request),
4894        here: "/status",
4895        account: passkeys,
4896        console: acl.is_some_and(|table| {
4897            table
4898                .check(user, &acl::Scope::Node, acl::Level::Write)
4899                .is_none()
4900        }),
4901        docs: docs.as_deref(),
4902        signed_in: Some(identified(user)),
4903    };
4904    let tag = ui::etag(seq, generation, &reader, chrome.theme);
4905    if header(&request, "If-None-Match").as_deref() == Some(tag.as_str()) {
4906        return served(request, not_modified(&tag, BROWSER_CSP), 304, 0);
4907    }
4908
4909    let page = cache.page(seq, generation, &reader, &roster, chrome, || {
4910        let body = platform.handle_api("GET", "/api/view", &[]).1;
4911        match acl {
4912            Some(table) => acl::filter_response(table, user, "/api/view", &body),
4913            None => body,
4914        }
4915    });
4916    let response = tiny_http::Response::from_string(page.as_str())
4917        .with_header(
4918            tiny_http::Header::from_bytes(&b"Content-Type"[..], &b"text/html; charset=utf-8"[..])
4919                .expect("static header"),
4920        )
4921        .with_header(
4922            tiny_http::Header::from_bytes(&b"ETag"[..], tag.as_bytes()).expect("etag header"),
4923        )
4924        // The page is private per node and changes with every op; a
4925        // shared cache must never hold it, and a browser must ask.
4926        .with_header(
4927            tiny_http::Header::from_bytes(&b"Cache-Control"[..], &b"private, no-cache"[..])
4928                .expect("static header"),
4929        )
4930        // Defence in depth behind the escaper: even if a value slipped
4931        // through unescaped, the page may not run scripts, load
4932        // anything remote, or be framed by another origin.
4933        .with_header(
4934            tiny_http::Header::from_bytes(&b"Content-Security-Policy"[..], BROWSER_CSP)
4935                .expect("static header"),
4936        )
4937        .with_header(
4938            tiny_http::Header::from_bytes(&b"Referrer-Policy"[..], REFERRER_POLICY)
4939                .expect("static header"),
4940        );
4941    served(request, response, 200, page.len() as u64)
4942}
4943
4944/// Serves one repository-browsing page (D30).
4945///
4946/// Authorization is the same grant a clone needs, checked here rather
4947/// than in the renderer: a reader without `read` must be told the
4948/// repository does not exist, and a page cannot say that convincingly
4949/// after it has already started describing one.
4950struct BrowseContext<'a> {
4951    /// Where the bare repositories live.
4952    root: &'a Path,
4953    /// The authenticated reader, whose grants decide what renders.
4954    user: &'a str,
4955    /// The grant table, or `None` on a node that gates nothing.
4956    acl: Option<&'a acl::Effective>,
4957    /// The view, for the pages served from it rather than from disk.
4958    platform: Option<&'a Platform>,
4959    /// Whether the browser may offer mutation controls.
4960    browser_writes: bool,
4961    /// The one repository this node presents, if it presents one.
4962    site: Option<&'a str>,
4963    /// `http` or `https`, from how this node was started rather than
4964    /// from anything the request said: a client must not be able to talk
4965    /// the node into advertising `https` for a plaintext port.
4966    scheme: &'static str,
4967    /// Whether this node runs invite-based credential self-service
4968    /// (D36). A page that tells a newcomer to redeem an invite on a node
4969    /// that issues none is sending them to a command that cannot work.
4970    self_service: bool,
4971    /// Whether this node offers passkeys, which is what `/account`
4972    /// renders for. See [`browse::Chrome::account`].
4973    passkeys: bool,
4974}
4975
4976/// The `/api/view` body this caller may see.
4977///
4978/// One reading, used by `/api/profile` and by the profile page, because
4979/// the ACL narrowing lives here and a second copy of it is a second
4980/// thing to keep right -- the first time the two disagreed, one of the
4981/// two surfaces would be disclosing more than the other.
4982fn visible_view(platform: &Platform, acl: Option<&acl::Effective>, user: &str) -> String {
4983    let raw = platform.handle_api("GET", "/api/view", &[]).1;
4984    match acl {
4985        Some(table) => acl::filter_response(table, user, "/api/view", &raw),
4986        None => raw,
4987    }
4988}
4989
4990fn handle_browse(
4991    context: &BrowseContext,
4992    page: &browse::Page,
4993    request: tiny_http::Request,
4994) -> std::io::Result<(u16, u64)> {
4995    let &BrowseContext {
4996        root,
4997        user,
4998        acl,
4999        platform,
5000        browser_writes,
5001        site,
5002        scheme,
5003        self_service,
5004        passkeys,
5005    } = context;
5006    let readable = |repo: &str| match acl {
5007        Some(table) => table.allows_repo(user, repo, acl::Level::Read),
5008        None => true,
5009    };
5010    if let Some(repo) = page.repo() {
5011        if !readable(repo) {
5012            // The body lives in `browse` because a repository that does
5013            // not exist reaches the same refusal from inside the
5014            // renderer. Two constructions that agree today are a
5015            // coincidence with a test on it; one construction is the
5016            // property.
5017            let denied = browse::no_such_repository(reader_chrome(&request));
5018            return respond_page(request, denied.status, denied.html, None);
5019        }
5020    }
5021
5022    // The origin the reader actually reached this node on, so a page
5023    // that prints a command can print one they can paste. Scheme comes
5024    // from the connection rather than the request: a client cannot talk
5025    // this node into advertising `https` for a plaintext port.
5026    let origin = header(&request, "host").map(|host| format!("{scheme}://{host}"));
5027    let docs = docs_url(root);
5028    let rendered = browse::render(
5029        root,
5030        page,
5031        &readable,
5032        platform,
5033        browse::Viewer {
5034            user,
5035            browser_writes,
5036            site,
5037            origin: origin.as_deref(),
5038            theme: chosen_theme(&request),
5039            here: request.url().split(['?', '#']).next().unwrap_or("/"),
5040            self_service,
5041            account: passkeys,
5042            console: acl.is_some_and(|table| {
5043                table
5044                    .check(user, &acl::Scope::Node, acl::Level::Write)
5045                    .is_none()
5046            }),
5047            docs: docs.as_deref(),
5048            signed_in: Some(identified(user)),
5049            // The ACL answers "who owns this repository" for the one
5050            // sentence the review page states about what would land a
5051            // change. A node without one hands back an empty list,
5052            // which is the right input: nobody owns anything, so the
5053            // approval-weight rule is the one in force.
5054            owners: &|repo: &str| acl.map_or_else(Vec::new, |table| table.owners(repo)),
5055        },
5056    );
5057    // Revalidation happens after the ACL check and before the body is
5058    // written, so a `304` costs the reader nothing and still cannot be
5059    // obtained for a repository they may not read.
5060    if let Some(tag) = rendered.etag.as_deref() {
5061        if header(&request, "If-None-Match").as_deref() == Some(tag) {
5062            // The same policy the `200` would have carried: a `304`
5063            // that named a weaker one would leave the client on it.
5064            let csp = match page {
5065                browse::Page::Review { .. } if browser_writes => SCRIPTED_PAGE_CSP,
5066                _ => BROWSER_CSP,
5067            };
5068            return served(request, not_modified(tag, csp), 304, 0);
5069        }
5070    }
5071
5072    let (status, bytes) = (rendered.status, rendered.html.len() as u64);
5073    let mut response = tiny_http::Response::from_string(rendered.html)
5074        .with_status_code(rendered.status)
5075        .with_header(
5076            tiny_http::Header::from_bytes(&b"Content-Type"[..], &b"text/html; charset=utf-8"[..])
5077                .expect("static header"),
5078        )
5079        .with_header(
5080            tiny_http::Header::from_bytes(&b"Cache-Control"[..], &b"private, no-cache"[..])
5081                .expect("static header"),
5082        )
5083        // The same defence in depth the D28 page carries: file contents
5084        // are attacker-supplied by definition here, so even an escaping
5085        // miss must not be able to run or fetch anything.
5086        //
5087        // The review page is the one exception and it is narrow by
5088        // construction: the strict header plus one same-origin source,
5089        // so every other page under `/r/` still runs nothing at all
5090        // (D39).
5091        .with_header(
5092            tiny_http::Header::from_bytes(
5093                &b"Content-Security-Policy"[..],
5094                match page {
5095                    browse::Page::Review { .. } if browser_writes => SCRIPTED_PAGE_CSP,
5096                    _ => BROWSER_CSP,
5097                },
5098            )
5099            .expect("static header"),
5100        )
5101        .with_header(
5102            tiny_http::Header::from_bytes(&b"Referrer-Policy"[..], REFERRER_POLICY)
5103                .expect("static header"),
5104        );
5105    if let Some(tag) = rendered.etag {
5106        response.add_header(
5107            tiny_http::Header::from_bytes(&b"ETag"[..], tag.as_bytes()).expect("etag header"),
5108        );
5109    }
5110    served(request, response, status, bytes)
5111}
5112
5113#[derive(Debug)]
5114struct Readiness {
5115    log_verified: bool,
5116    sequencer_live: bool,
5117    storage_writable: bool,
5118    free_disk_bytes: Option<u64>,
5119    disk_space_ok: bool,
5120    ref_disagreements: usize,
5121}
5122
5123impl Readiness {
5124    fn ready(&self) -> bool {
5125        self.log_verified
5126            && self.sequencer_live
5127            && self.storage_writable
5128            && self.disk_space_ok
5129            && self.ref_disagreements == 0
5130    }
5131}
5132
5133fn storage_probe(root: &Path) -> bool {
5134    static NEXT: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
5135    let sequence = NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
5136    let path = root.join(format!(
5137        ".choir-ready-probe-{}-{sequence}",
5138        std::process::id()
5139    ));
5140    let result = (|| {
5141        let mut file = std::fs::OpenOptions::new()
5142            .write(true)
5143            .create_new(true)
5144            .open(&path)?;
5145        std::io::Write::write_all(&mut file, b"ready\n")?;
5146        file.sync_all()
5147    })();
5148    std::fs::remove_file(path).ok();
5149    result.is_ok()
5150}
5151
5152fn free_disk_bytes(root: &Path) -> Option<u64> {
5153    let output = std::process::Command::new("df")
5154        .args(["-Pk"])
5155        .arg(root)
5156        .output()
5157        .ok()?;
5158    if !output.status.success() {
5159        return None;
5160    }
5161    let text = String::from_utf8(output.stdout).ok()?;
5162    let available_kib = text
5163        .lines()
5164        .nth(1)?
5165        .split_whitespace()
5166        .nth(3)?
5167        .parse::<u64>()
5168        .ok()?;
5169    available_kib.checked_mul(1024)
5170}
5171
5172fn readiness(root: &Path, platform: Option<&Platform>, min_free_bytes: u64) -> Readiness {
5173    let log_verified = choir_oplog::repair::verify(&root.join(".choir/ops.jsonl"))
5174        .is_ok_and(|report| report.fault.is_none());
5175    let sequencer_live = platform.is_some_and(|p| !p.durability_failed());
5176    let free_disk_bytes = free_disk_bytes(root);
5177    let ref_disagreements = platform.map_or(usize::MAX, |p| p.survey_git_refs(root).len());
5178    Readiness {
5179        log_verified,
5180        sequencer_live,
5181        storage_writable: storage_probe(root),
5182        free_disk_bytes,
5183        disk_space_ok: free_disk_bytes.is_some_and(|bytes| bytes >= min_free_bytes),
5184        ref_disagreements,
5185    }
5186}
5187
5188fn handle_observability(
5189    root: &Path,
5190    platform: Option<&Platform>,
5191    min_free_bytes: u64,
5192    counters: &limits::Counters,
5193    started_unix: u64,
5194    request: tiny_http::Request,
5195) -> std::io::Result<(u16, u64)> {
5196    if request.url() == "/healthz" {
5197        let healthy = platform.is_none_or(|p| !p.durability_failed());
5198        let body = serde_json::json!({
5199            "format_version": 1,
5200            "healthy": healthy,
5201        })
5202        .to_string();
5203        let status = if healthy { 200 } else { 503 };
5204        let bytes = body.len() as u64;
5205        let response = tiny_http::Response::from_string(body)
5206            .with_status_code(status)
5207            .with_header(
5208                tiny_http::Header::from_bytes(&b"Content-Type"[..], &b"application/json"[..])
5209                    .expect("static header"),
5210            );
5211        return served(request, response, status, bytes);
5212    }
5213
5214    let state = readiness(root, platform, min_free_bytes);
5215    if request.url() == "/metrics" {
5216        // This scrape is itself a request, and it has not finished yet,
5217        // so it is not in these numbers. That is the ordinary shape and
5218        // not a defect: every scrape is one request behind, uniformly.
5219        let traffic = counters.snapshot();
5220        let body = format!(
5221            "# TYPE choir_ready gauge\nchoir_ready {}\n\
5222             # TYPE choir_log_verified gauge\nchoir_log_verified {}\n\
5223             # TYPE choir_sequencer_live gauge\nchoir_sequencer_live {}\n\
5224             # TYPE choir_storage_writable gauge\nchoir_storage_writable {}\n\
5225             # TYPE choir_free_disk_bytes gauge\nchoir_free_disk_bytes {}\n\
5226             # TYPE choir_ref_disagreements gauge\nchoir_ref_disagreements {}\n\
5227             # TYPE choir_process_start_time_seconds gauge\n\
5228             choir_process_start_time_seconds {}\n\
5229             # TYPE choir_requests_total counter\nchoir_requests_total {}\n\
5230             # TYPE choir_requests_unauthorized_total counter\n\
5231             choir_requests_unauthorized_total {}\n\
5232             # TYPE choir_requests_throttled_total counter\n\
5233             choir_requests_throttled_total {}\n\
5234             # TYPE choir_requests_failed_total counter\n\
5235             choir_requests_failed_total {}\n\
5236             # TYPE choir_request_duration_microseconds_total counter\n\
5237             choir_request_duration_microseconds_total {}\n",
5238            u8::from(state.ready()),
5239            u8::from(state.log_verified),
5240            u8::from(state.sequencer_live),
5241            u8::from(state.storage_writable),
5242            state.free_disk_bytes.unwrap_or(0),
5243            state.ref_disagreements,
5244            started_unix,
5245            traffic.requests,
5246            traffic.unauthorized,
5247            traffic.throttled,
5248            traffic.failed,
5249            traffic.duration_us,
5250        );
5251        let bytes = body.len() as u64;
5252        let response = tiny_http::Response::from_string(body).with_header(
5253            tiny_http::Header::from_bytes(
5254                &b"Content-Type"[..],
5255                &b"text/plain; version=0.0.4; charset=utf-8"[..],
5256            )
5257            .expect("static header"),
5258        );
5259        return served(request, response, 200, bytes);
5260    }
5261
5262    let status = if state.ready() { 200 } else { 503 };
5263    let body = serde_json::json!({
5264        "format_version": 1,
5265        "ready": state.ready(),
5266        "checks": {
5267            "log_verified": state.log_verified,
5268            "sequencer_live": state.sequencer_live,
5269            "storage_writable": state.storage_writable,
5270            "free_disk_bytes": state.free_disk_bytes,
5271            "minimum_free_disk_bytes": min_free_bytes,
5272            "disk_space_ok": state.disk_space_ok,
5273            "ref_agreement": state.ref_disagreements == 0,
5274            "ref_disagreements": state.ref_disagreements,
5275        }
5276    })
5277    .to_string();
5278    let bytes = body.len() as u64;
5279    let response = tiny_http::Response::from_string(body)
5280        .with_status_code(status)
5281        .with_header(
5282            tiny_http::Header::from_bytes(&b"Content-Type"[..], &b"application/json"[..])
5283                .expect("static header"),
5284        );
5285    served(request, response, status, bytes)
5286}
5287
5288/// Whether this workspace request is over the caller's D37 ceiling, and
5289/// the refusal to send if it is.
5290///
5291/// A request naming a workspace that already exists cannot raise anyone's
5292/// count, so it is not checked: an idempotent retry (D35 binds one to a
5293/// change id and an idempotency key precisely so it can be retried) must
5294/// not be refused for creating nothing. That is also why this reads the
5295/// body — it is the only place the workspace being asked for is named —
5296/// and why it reads nothing else out of it, leaving every other judgement
5297/// about the request to `provision`.
5298fn workspace_quota_refusal(
5299    platform: &Platform,
5300    user: &str,
5301    ceiling: Option<std::num::NonZeroU32>,
5302    body: &[u8],
5303) -> Option<(u16, String)> {
5304    let ceiling = ceiling?.get() as usize;
5305    let request: serde_json::Value = serde_json::from_slice(body).ok()?;
5306    let field = |key: &str| {
5307        request
5308            .get(key)
5309            .and_then(|v| v.as_str())
5310            .unwrap_or_default()
5311    };
5312    let (repo, name) = (field("repo"), field("name"));
5313    if repo.is_empty() || name.is_empty() {
5314        // Malformed: let `provision` say so, in its own words.
5315        return None;
5316    }
5317    if platform.workspace_head(&format!("{repo}/{name}")).is_some() {
5318        return None;
5319    }
5320    let channel = quota::channel_for(user);
5321    let held = platform.workspaces_held_by(&channel);
5322    if held < ceiling {
5323        return None;
5324    }
5325    let rejection = reject::Rejection::new(
5326        reject::Code::QuotaExceeded,
5327        format!("you already hold {held} workspaces, which is this node's per-user limit"),
5328        "archive a workspace you are finished with (POST /api/workspace/archive) to free the \
5329         allowance, or ask the operator to raise the limit",
5330    )
5331    .with_states(
5332        Some(format!("at most {ceiling} workspaces")),
5333        Some(format!("{held} workspaces")),
5334    );
5335    Some((403, rejection.body()))
5336}
5337
5338/// Serves one platform-API request.
5339///
5340/// `workspaces` is the caller's per-user workspace ceiling (D37),
5341/// already `None` for anyone the metering exempts. It is checked here
5342/// rather than inside [`provision::create_workspace`] so the quota
5343/// observes the creation path instead of editing it.
5344struct ApiRequestContext<'a> {
5345    platform: Option<&'a Platform>,
5346    root: &'a Path,
5347    base_url: &'a str,
5348    user: &'a str,
5349    acl: Option<&'a acl::Effective>,
5350    /// The same table as `acl`, but supplied for the hook callbacks too,
5351    /// which deliberately receive `acl: None` (D60).
5352    ///
5353    /// Those callbacks are privileged: they spend authorization the git
5354    /// route already checked, so running the ordinary API denials over
5355    /// them would re-ask a question that has been answered. One question
5356    /// has *not* been answered there, because it could not be: a
5357    /// `propose` grant is admitted at the smart-HTTP boundary before any
5358    /// refname exists. This field carries the table for that one check
5359    /// and nothing else.
5360    push_acl: Option<&'a acl::Effective>,
5361    workspaces: Option<std::num::NonZeroU32>,
5362    body_limit: std::num::NonZeroU64,
5363    queue: Option<&'a queue_api::QueueConfig>,
5364    queue_in_flight: &'a queue_api::InFlight,
5365}
5366
5367fn handle_api(
5368    context: ApiRequestContext<'_>,
5369    mut request: tiny_http::Request,
5370) -> std::io::Result<(u16, u64)> {
5371    let ApiRequestContext {
5372        platform,
5373        root,
5374        base_url,
5375        user,
5376        acl,
5377        push_acl,
5378        workspaces,
5379        body_limit,
5380        queue,
5381        queue_in_flight,
5382    } = context;
5383    let (status, body) = match platform {
5384        Some(p) => {
5385            let req_body = match quota::read_bounded(request.as_reader(), Some(body_limit))? {
5386                quota::Body::Complete(body) => body,
5387                quota::Body::OverLimit { limit, size } => {
5388                    return respond_api_too_large(request, limit, size)
5389                }
5390            };
5391            let method = request.method().as_str().to_string();
5392            let path = request.url().to_string();
5393            // The body is already in hand, which is the only place the
5394            // repository a submission touches can be recovered from.
5395            let denial = acl
5396                .and_then(|table| {
5397                    acl::api_denial(table, user, &method, &path, &req_body, |id| {
5398                        p.review_repo(id)
5399                    })
5400                })
5401                .or_else(|| {
5402                    // D60. Deliberately not inside `api_denial`: that one
5403                    // authorizes the caller of this endpoint, and the
5404                    // caller here is the hook. This authorizes the person
5405                    // whose push triggered it, named in the body.
5406                    ((method.as_str(), path.as_str()) == ("POST", "/api/git-update"))
5407                        .then_some(push_acl)
5408                        .flatten()
5409                        .and_then(|table| platform::proposal_denial(table, &req_body))
5410                });
5411            if let Some(denial) = denial {
5412                (
5413                    denial.status,
5414                    serde_json::json!({ "error": denial.reason }).to_string(),
5415                )
5416            } else if (method.as_str(), path.as_str()) == ("POST", "/api/queue/run") {
5417                // Routed here rather than inside the platform for the
5418                // same reason as `/api/workspace`: it needs the repo
5419                // root, which the platform does not hold.
5420                match queue {
5421                    Some(config) => {
5422                        queue_api::run(root, p, config, queue_in_flight, &req_body)
5423                    }
5424                    None => (
5425                        501,
5426                        serde_json::json!({
5427                            "error": "this node was started without --ci-command, so it runs no queue"
5428                        })
5429                        .to_string(),
5430                    ),
5431                }
5432            } else if (method.as_str(), path.as_str()) == ("POST", "/api/workspace") {
5433                match workspace_quota_refusal(p, user, workspaces, &req_body) {
5434                    Some(refusal) => refusal,
5435                    None => provision::create_workspace(root, p, base_url, user, &req_body),
5436                }
5437            } else if (method.as_str(), path.as_str()) == ("POST", "/api/workspace/archive") {
5438                provision::archive_workspace(root, p, user, &req_body)
5439            } else if (method.as_str(), path.as_str()) == ("GET", "/api/repos") {
5440                // Routed here rather than inside the platform for the
5441                // same reason as `/api/repo`: it needs the repo root,
5442                // which the platform does not hold.
5443                repos_request(root, acl, user)
5444            } else if (method.as_str(), path.as_str()) == ("POST", "/api/repo") {
5445                // Routed here rather than inside the platform for the
5446                // same reason as `/api/workspace`: it needs the repo
5447                // root, which the platform does not hold.
5448                //
5449                // Creating a repository is a write against the node
5450                // itself rather than against any repository — there is
5451                // no repository yet to be scoped to — so it asks for
5452                // `@node` write, the same authority that governs the
5453                // log and the attestation (D29).
5454                let denial =
5455                    acl.and_then(|table| table.check(user, &acl::Scope::Node, acl::Level::Write));
5456                if let Some(denial) = denial {
5457                    (
5458                        denial.status,
5459                        serde_json::json!({ "error": denial.reason }).to_string(),
5460                    )
5461                } else {
5462                    create_repo_request(root, &req_body)
5463                }
5464            } else if (method.as_str(), path.as_str()) == ("GET", "/api/ref-agreement") {
5465                // Routed here rather than inside the platform for the
5466                // same reason as `/api/workspace`: it needs the repo
5467                // root, which the platform does not hold.
5468                let findings = p.survey_git_refs(root);
5469                let body = serde_json::json!({
5470                    "format_version": 1,
5471                    "agree": findings.is_empty(),
5472                    "findings": findings.iter().map(platform::RefFinding::to_json)
5473                        .collect::<Vec<_>>(),
5474                });
5475                (200, body.to_string())
5476            } else {
5477                let (status, body) = p.handle_api(&method, &path, &req_body);
5478                // Phase B: the aggregate reads are narrowed rather than
5479                // refused, because a reader granted one repository still
5480                // has a legitimate view — of that repository. Applied to
5481                // the response rather than inside the handler so the
5482                // platform keeps answering one question, and the ACL
5483                // stays the only thing that knows about grants.
5484                let body = match acl {
5485                    Some(table) if status == 200 => acl::filter_response(table, user, &path, &body),
5486                    _ => body,
5487                };
5488                // Bounded last, after any narrowing, because
5489                // `<section>_omitted` is a row count and a count of rows
5490                // this caller may not read discloses that they exist.
5491                // See `bound`'s module docs; the ordering is the design.
5492                match status {
5493                    200 => (status, bound::apply(&path, &body)),
5494                    _ => (status, body),
5495                }
5496            }
5497        }
5498        None => (503, r#"{"error":"platform API not enabled"}"#.to_string()),
5499    };
5500    let bytes = body.len() as u64;
5501    let response = tiny_http::Response::from_string(body)
5502        .with_status_code(status)
5503        .with_header(
5504            tiny_http::Header::from_bytes(&b"Content-Type"[..], &b"application/json"[..])
5505                .expect("static header"),
5506        );
5507    served(request, response, status, bytes)
5508}
5509
5510/// Bridges one HTTP request to `git http-backend` CGI. `extra_env` is
5511/// added to the CGI child (and thus inherited by git hooks).
5512///
5513/// `push_bytes` is the caller's per-user ceiling on the request body
5514/// (D37). It is checked here, before the CGI child is spawned, which is
5515/// the point of the whole design: `git http-backend` is what runs the
5516/// `pre-receive` hook, and the hook is what submits ops. A body refused
5517/// on this side of the spawn means no hook ran, so no op was submitted
5518/// and the hook's retraction path — the one that has to undo the refs an
5519/// aborted push already got into the durable log — is never entered.
5520fn handle(
5521    root: PathBuf,
5522    mut request: tiny_http::Request,
5523    extra_env: &[(String, String)],
5524    push_bytes: Option<std::num::NonZeroU64>,
5525) -> std::io::Result<(u16, u64)> {
5526    let url = request.url().to_string();
5527    let (path, query) = match url.split_once('?') {
5528        Some((p, q)) => (p.to_string(), q.to_string()),
5529        None => (url.clone(), String::new()),
5530    };
5531
5532    let body = match quota::read_bounded(request.as_reader(), push_bytes)? {
5533        quota::Body::Complete(body) => body,
5534        quota::Body::OverLimit { limit, size } => {
5535            return respond_push_too_large(request, limit, size)
5536        }
5537    };
5538
5539    let method = request.method().as_str().to_string();
5540    let content_type = request
5541        .headers()
5542        .iter()
5543        .find(|h| h.field.equiv("Content-Type"))
5544        .map(|h| h.value.as_str().to_string())
5545        .unwrap_or_default();
5546
5547    let mut child = std::process::Command::new("git")
5548        .arg("http-backend")
5549        .envs(extra_env.iter().map(|(k, v)| (k.as_str(), v.as_str())))
5550        .env("GIT_PROJECT_ROOT", &root)
5551        .env("GIT_HTTP_EXPORT_ALL", "1")
5552        .env("PATH_INFO", &path)
5553        .env("QUERY_STRING", &query)
5554        .env("REQUEST_METHOD", &method)
5555        .env("CONTENT_TYPE", &content_type)
5556        .env("CONTENT_LENGTH", body.len().to_string())
5557        .env("REMOTE_ADDR", "127.0.0.1")
5558        .env("GATEWAY_INTERFACE", "CGI/1.1")
5559        .stdin(std::process::Stdio::piped())
5560        .stdout(std::process::Stdio::piped())
5561        .stderr(std::process::Stdio::null())
5562        .spawn()?;
5563
5564    use std::io::Write;
5565    child.stdin.take().expect("stdin piped").write_all(&body)?;
5566    let mut out = Vec::new();
5567    child
5568        .stdout
5569        .take()
5570        .expect("stdout piped")
5571        .read_to_end(&mut out)?;
5572    child.wait()?;
5573
5574    // Split the CGI response into headers and body.
5575    let split = out
5576        .windows(4)
5577        .position(|w| w == b"\r\n\r\n")
5578        .map(|i| (i, i + 4))
5579        .or_else(|| {
5580            out.windows(2)
5581                .position(|w| w == b"\n\n")
5582                .map(|i| (i, i + 2))
5583        });
5584    let (head, rest) = match split {
5585        Some((h, b)) => (&out[..h], &out[b..]),
5586        None => (&[][..], &out[..]),
5587    };
5588
5589    let mut status = 200;
5590    let mut headers = Vec::new();
5591    for line in String::from_utf8_lossy(head).lines() {
5592        if let Some((k, v)) = line.split_once(':') {
5593            let (k, v) = (k.trim(), v.trim());
5594            if k.eq_ignore_ascii_case("Status") {
5595                status = v
5596                    .split_whitespace()
5597                    .next()
5598                    .and_then(|s| s.parse().ok())
5599                    .unwrap_or(200);
5600            } else if let Ok(h) = tiny_http::Header::from_bytes(k.as_bytes(), v.as_bytes()) {
5601                headers.push(h);
5602            }
5603        }
5604    }
5605
5606    let bytes = rest.len() as u64;
5607    let mut response = tiny_http::Response::from_data(rest.to_vec()).with_status_code(status);
5608    for h in headers {
5609        response.add_header(h);
5610    }
5611    served(request, response, status, bytes)
5612}