Skip to main content

areev_server/
lib.rs

1//! areev-server — the opt-in thin HTTP surface and
2//! the local test console (`areev ui`).
3//!
4//! Deliberately minimal: std-only HTTP/1.1 on 127.0.0.1, one request per
5//! connection, JSON API + one embedded HTML page. This is a *local
6//! inspection console*, not a service — no auth, binds loopback only.
7
8use std::io::{BufRead, BufReader, Read, Write};
9use std::net::{TcpListener, TcpStream};
10use std::time::Duration;
11
12use areev_cal::{CalExecutor, CalExecutorConfig, CalStoreFacade, AreevFacade};
13use areev_loop_adapter::{now_ms, BorrowedSubstrate};
14use serde_json::{json, Value};
15use areev_loop::{Decision, Engine, RecStatus, RunOptions};
16
17const CONSOLE_HTML: &str = include_str!("console.html");
18
19/// Per-connection read/write timeout — bounds slow-client (slowloris) attacks.
20const READ_TIMEOUT_SECS: u64 = 15;
21/// Hard ceiling on bytes read from one connection (1 MiB body cap + headroom
22/// for the request line and headers). Bounds memory against oversized requests.
23const MAX_CONN_BYTES: u64 = (1 << 20) + (128 << 10);
24/// Cap on total header bytes and header count per request.
25const MAX_HEADER_BYTES: usize = 64 * 1024;
26const MAX_HEADERS: usize = 100;
27/// Total wall-clock deadline for reading + handling one request. A per-read
28/// timeout cannot bound a slow-drip client (each byte resets it), so a watchdog
29/// shuts the socket down at this deadline.
30const REQUEST_DEADLINE_SECS: u64 = 30;
31
32
33/// One accepted connection: plaintext, or TLS when the server was built
34/// with the `tls` feature and configured with a certificate. The raw
35/// socket handle stays available for timeouts and the watchdog shutdown;
36/// all request/response bytes flow through this wrapper. A plaintext
37/// client hitting a TLS listener fails the handshake — there is no
38/// downgrade path by construction.
39enum Conn {
40    Plain(TcpStream),
41    #[cfg(feature = "tls")]
42    Tls(Box<rustls::StreamOwned<rustls::ServerConnection, TcpStream>>),
43}
44
45impl Read for Conn {
46    fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
47        match self {
48            Conn::Plain(s) => s.read(buf),
49            #[cfg(feature = "tls")]
50            Conn::Tls(s) => s.read(buf),
51        }
52    }
53}
54
55impl Write for Conn {
56    fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
57        match self {
58            Conn::Plain(s) => s.write(buf),
59            #[cfg(feature = "tls")]
60            Conn::Tls(s) => s.write(buf),
61        }
62    }
63    fn flush(&mut self) -> std::io::Result<()> {
64        match self {
65            Conn::Plain(s) => s.flush(),
66            #[cfg(feature = "tls")]
67            Conn::Tls(s) => s.flush(),
68        }
69    }
70}
71
72pub struct UiServer {
73    facade: std::sync::Arc<AreevFacade>,
74    executor: CalExecutor,
75    db_label: String,
76    /// Shared secret required for access when set. In hub mode it guards only
77    /// mutating/sync endpoints (`Bearer`); in console-auth mode (`auth_all`) it
78    /// guards every request via `Bearer` **or** HTTP `Basic` (password = token).
79    token: Option<String>,
80    /// When true, *every* request requires the token — the console page, all
81    /// reads, and all writes — and a `401` carries `WWW-Authenticate: Basic` so
82    /// browsers prompt. Set by [`UiServer::with_auth`]; hub mode leaves it off.
83    auth_all: bool,
84    /// Directory for received segments (areevd hub mode).
85    segment_dir: Option<String>,
86    /// When false (the default), reject any request whose `Host` header is not
87    /// loopback — the standard DNS-rebinding defense for a localhost service.
88    /// Set true only when the operator intentionally serves to other hosts
89    /// (CLI `--allow-remote`), where a non-loopback `Host` is expected.
90    allow_remote: bool,
91    /// Host loop policy (§6.2) applied to the `/api/loop/*` engine — the
92    /// same `loop-policy.json` the CLI takes (`areev ui --policy`). Absent →
93    /// the closed default: nothing auto-applies, nothing is denied. Host
94    /// config, never persisted in the memory file.
95    loop_policy: Option<areev_loop::Policy>,
96    /// Multi-principal mode (`areev ui --auth areev-auth.json`): a credential
97    /// map resolving bearer/Basic tokens to principal names. Rights come
98    /// from the FILE's own grant grains — the map holds no policy and no
99    /// raw secrets. Unauthenticated requests run as `anonymous`
100    /// (read-only unless the file grants more). It changes *who* a request
101    /// is, never *whether* the shared `--token-env` secret is required: when
102    /// both are configured the secret keeps its contract (implied admin, and
103    /// still demanded on the requests it guards).
104    credentials: Option<areev_core::authz::CredentialMap>,
105    /// Whether destructive CAL is permitted, kept alongside the executor so
106    /// every builder can rebuild it without clobbering the others' settings.
107    allow_destructive_ops: bool,
108    /// Native TLS (the recorded dependency exception; `tls` cargo feature).
109    /// None = plaintext — the documented default is a TLS-terminating proxy.
110    #[cfg(feature = "tls")]
111    tls_config: Option<std::sync::Arc<rustls::ServerConfig>>,
112    /// SSO v0 — trusted-header auth (`areev ui --sso-header X-Forwarded-User
113    /// --sso-secret-env VAR`): an authenticating proxy does OIDC/SAML and
114    /// forwards the identity in this header. Honored ONLY when the request
115    /// also carries the proxy shared secret in `x-areev-proxy-secret` —
116    /// otherwise identity headers are attacker-controlled input and are
117    /// ignored entirely. Rights still come from the FILE's grant grains.
118    sso_header: Option<String>,
119    /// The proxy shared secrets this instance accepts, newest first.
120    ///
121    /// A list rather than a value so the secret can be **rotated without a
122    /// zero-overlap cutover** (#79). It is an impersonation-grade credential:
123    /// whoever holds it can present any identity header, approval-capable
124    /// principals included. Rotating one of those atomically across a proxy
125    /// fleet and a console is not achievable in practice, so the honest
126    /// choices were a brief outage or a window where the wrong secret is live
127    /// — and an operator facing either under suspected-compromise pressure
128    /// will defer the rotation, which is the outcome that actually costs.
129    ///
130    /// Two at a time, deliberately: enough for old-and-new, not enough to
131    /// accumulate a drawer of forgotten live credentials. Both are checked in
132    /// constant time and the comparison never short-circuits on the first
133    /// mismatch, so timing cannot reveal which one matched or how many are
134    /// configured.
135    sso_secrets: Vec<String>,
136}
137
138/// Per-request principal binding. The server handles one request at a
139/// time, so rebinding the shared facade for the request's duration is
140/// race-free; the guard restores the owner default on drop — including on
141/// panic — so a failed handler can never leak a stray principal into the
142/// next request.
143struct RequestBinding<'a> {
144    facade: &'a AreevFacade,
145}
146
147impl<'a> RequestBinding<'a> {
148    fn bind(
149        facade: &'a AreevFacade,
150        map: &areev_core::authz::CredentialMap,
151        bearer: Option<&str>,
152        memory: &str,
153    ) -> Self {
154        // Memory-scoped resolution: a credential whose `memories` list
155        // excludes THIS memory is indistinguishable from an unknown token.
156        let principal =
157            bearer.and_then(|t| map.resolve_for_memory(t, memory).ok().map(str::to_string));
158        match principal {
159            Some(p) => {
160                if facade.bind_principal(&p).is_err() {
161                    // A store failure reading grants fails toward LESS
162                    // privilege, never more.
163                    Self::bind_anonymous(facade);
164                }
165            }
166            None => Self::bind_anonymous(facade),
167        }
168        RequestBinding { facade }
169    }
170
171    /// `anonymous` = a read-only baseline plus whatever the file explicitly
172    /// grants the `anonymous` principal — the formalized version of the old
173    /// token-less read-only console.
174    /// Bind a proxy-proven SSO identity directly (no credential map row
175    /// needed — the IdP said who this is; the FILE says what they may do).
176    fn bind_identity(facade: &'a AreevFacade, principal: &str) -> Self {
177        if facade.bind_principal(principal).is_err() {
178            Self::bind_anonymous(facade);
179        }
180        RequestBinding { facade }
181    }
182
183    fn bind_anonymous(facade: &AreevFacade) {
184        use areev_core::authz::{AuthzSet, Grant, Verb};
185        let mut grants = facade
186            .with_store(|m| m.authz_grants("anonymous"))
187            .unwrap_or_default();
188        grants.push(Grant { verbs: vec![Verb::Read], namespaces: vec!["*".to_string()] });
189        facade.bind(AuthzSet::restricted("anonymous", grants));
190    }
191}
192
193impl Drop for RequestBinding<'_> {
194    fn drop(&mut self) {
195        self.facade
196            .bind(areev_core::authz::AuthzSet::owner("user:console"));
197    }
198}
199
200impl UiServer {
201    pub fn new(facade: AreevFacade, db_label: String) -> Self {
202        UiServer {
203            facade: std::sync::Arc::new(facade),
204            executor: CalExecutor::new(CalExecutorConfig::default())
205                .with_governance(std::sync::Arc::new(areev_loop_adapter::LoopGovernance::new())),
206            db_label,
207            token: None,
208            auth_all: false,
209            segment_dir: None,
210            allow_remote: false,
211            loop_policy: None,
212            credentials: None,
213            allow_destructive_ops: CalExecutorConfig::default().allow_destructive_ops,
214            #[cfg(feature = "tls")]
215            tls_config: None,
216            sso_header: None,
217            sso_secrets: Vec::new(),
218        }
219    }
220
221    /// Enable trusted-header SSO (v0). `header` names the identity header
222    /// the authenticating proxy forwards; `secret` is the proxy's shared
223    /// secret, demanded in `x-areev-proxy-secret` on every SSO request.
224    pub fn with_sso(self, header: impl Into<String>, secret: impl Into<String>) -> Self {
225        self.with_sso_rotating(header, secret, None::<String>)
226    }
227
228    /// [`with_sso`](Self::with_sso) with a second secret accepted during a
229    /// rotation window (#79).
230    ///
231    /// `next` is the incoming secret; `secret` stays the one already deployed.
232    /// While both are configured either proves the proxy, so the fleet can be
233    /// moved over one node at a time and the old secret retired afterwards —
234    /// the way a TLS key is rotated. Order carries no privilege: they are
235    /// equally valid until one is removed, which is what makes the window
236    /// safe to run in both directions (roll forward, or roll back).
237    ///
238    /// The window is meant to be short. Nothing here enforces that — a
239    /// deadline the server could enforce would have to live in the file or a
240    /// clock it does not own — so retiring the old secret is the operator's
241    /// step, and `docs/runbooks/sso-secret-rotation.md` is the procedure.
242    pub fn with_sso_rotating(
243        mut self,
244        header: impl Into<String>,
245        secret: impl Into<String>,
246        next: Option<impl Into<String>>,
247    ) -> Self {
248        let header = header.into();
249        let secret = secret.into();
250        let next = next.map(Into::into).filter(|s| !s.trim().is_empty());
251        // An empty proxy secret would let `x-areev-proxy-secret:` (empty)
252        // prove any identity — refuse loudly at the library boundary too,
253        // not just in the CLI.
254        assert!(
255            !header.trim().is_empty() && !secret.trim().is_empty(),
256            "with_sso requires a non-empty header name and proxy secret"
257        );
258        // A rotation that deploys the same value twice is a rotation that did
259        // not happen, and it would read as "both live" in every log and
260        // status line. Refuse rather than silently accept the no-op.
261        assert!(
262            next.as_deref() != Some(secret.as_str()),
263            "the rotating proxy secret must differ from the current one"
264        );
265        self.sso_header = Some(header.to_ascii_lowercase());
266        self.sso_secrets = match next {
267            Some(n) => vec![secret, n],
268            None => vec![secret],
269        };
270        self
271    }
272
273    /// Rebuild the executor from the current gate settings. Every builder
274    /// that touches the CAL surface goes through here so `--no-destructive-ops`
275    /// and `--policy` compose instead of clobbering each other's config —
276    /// and the governance host survives every rebuild. (Same shape as
277    /// `areev-mcp`'s; building `CalExecutorConfig::default()` in one builder
278    /// silently re-enabled destruction configured off by another.)
279    fn rebuild_executor(&mut self) {
280        self.executor = CalExecutor::new(CalExecutorConfig {
281            allow_destructive_ops: self.allow_destructive_ops,
282            ..CalExecutorConfig::default()
283        })
284        .with_governance(std::sync::Arc::new(match &self.loop_policy {
285            Some(p) => areev_loop_adapter::LoopGovernance::with_policy(p.clone()),
286            None => areev_loop_adapter::LoopGovernance::new(),
287        }));
288    }
289
290    /// Multi-principal mode: attach the credential map (`areev-auth.json`).
291    /// Tokens resolve to principal names; rights come from the memory
292    /// file's own grant grains; unauthenticated requests run as
293    /// `anonymous`. See `docs/cal-reference.md` §9.
294    pub fn with_credentials(mut self, map: areev_core::authz::CredentialMap) -> Self {
295        self.credentials = Some(map);
296        self
297    }
298
299    /// Attach a host loop policy to the console's loop routes, so a
300    /// console-triggered run honors the same auto-apply grants, denies, and
301    /// severity floors as `areev loop run --policy`.
302    pub fn with_loop_policy(mut self, policy: areev_loop::Policy) -> Self {
303        self.loop_policy = Some(policy);
304        // The CAL surface's governance host honors the same policy.
305        self.rebuild_executor();
306        self
307    }
308
309    /// May this request's session read the raw sync surface? A segment is a
310    /// whole-memory op-log export, so it takes `read` on every namespace —
311    /// a principal granted read on one namespace must not be able to pull
312    /// the file wholesale through `/api/segment`. Owner sessions (every
313    /// non-credential-map deployment) pass unchanged.
314    fn session_may_sync(&self) -> bool {
315        self.facade
316            .authz()
317            .allows(areev_core::authz::Verb::Read, "*")
318    }
319
320    /// The loop engine for a request: builtins + the host policy when one
321    /// was attached.
322    fn engine(&self) -> Engine {
323        match &self.loop_policy {
324            Some(p) => Engine::with_builtins().with_policy(p.clone()),
325            None => Engine::with_builtins(),
326        }
327    }
328
329    /// Accept requests whose `Host` header is non-loopback. Off by default so
330    /// the loopback console is protected against DNS-rebinding reads even when
331    /// unauthenticated; the CLI enables it under `--allow-remote`.
332    pub fn allow_remote(mut self, yes: bool) -> Self {
333        self.allow_remote = yes;
334        self
335    }
336
337    /// Require a shared secret on **every** request (the console page, reads,
338    /// and writes). Browsers authenticate through the native `Basic` prompt
339    /// (any username; password = `token`); scripts may send `Authorization:
340    /// Bearer <token>`. Use this to serve the console to more than a single
341    /// trusted local operator. Pair with a TLS-terminating proxy for non-local
342    /// exposure — the token crosses the wire in the clear otherwise.
343    pub fn with_auth(mut self, token: String) -> Self {
344        // An empty token would authenticate `Authorization: Bearer ` (the
345        // empty credential) as the OWNER — refuse at the library boundary,
346        // not just in the CLI's --token-env validation.
347        assert!(
348            !token.trim().is_empty(),
349            "with_auth requires a non-empty token"
350        );
351        self.token = Some(token);
352        self.auth_all = true;
353        self
354    }
355
356    /// Permit or forbid destructive CAL (`FORGET <hash>`) from the query
357    /// console. Enabled by default; pass `false` to serve a read-only console.
358    /// Since the plain console is unauthenticated, disabling this is the safe
359    /// choice when the console is exposed beyond a trusted local operator.
360    pub fn allow_destructive_ops(mut self, allow: bool) -> Self {
361        self.allow_destructive_ops = allow;
362        self.rebuild_executor();
363        self
364    }
365
366    /// areevd mode: require `Authorization: Bearer <token>` on POST
367    /// endpoints and accept segment push/pull under `dir`.
368    pub fn into_hub(mut self, token: Option<String>, dir: &str) -> std::io::Result<Self> {
369        std::fs::create_dir_all(dir)?;
370        self.token = token;
371        self.segment_dir = Some(dir.to_string());
372        // The hub is a network service (segment push/pull from other nodes), so
373        // non-loopback Hosts are expected — writes stay bearer-gated.
374        self.allow_remote = true;
375        Ok(self)
376    }
377
378    /// Bind and return the listener (lets callers learn the ephemeral port).
379    pub fn bind(addr: &str) -> std::io::Result<TcpListener> {
380        TcpListener::bind(addr)
381    }
382
383    /// Serve forever on an already-bound listener.
384    pub fn serve(&self, listener: TcpListener) -> std::io::Result<()> {
385        for stream in listener.incoming() {
386            match stream {
387                Ok(s) => {
388                    if let Err(e) = self.handle(s) {
389                        eprintln!("areev ui: request error: {e}");
390                    }
391                }
392                Err(e) => eprintln!("areev ui: accept error: {e}"),
393            }
394        }
395        Ok(())
396    }
397
398    /// Configure native TLS from PEM files (`tls` cargo feature). The
399    /// documented default remains a TLS-terminating proxy; this is for
400    /// deployments with nowhere to put one (edge boxes, appliances).
401    #[cfg(feature = "tls")]
402    pub fn with_tls(mut self, cert_pem: &str, key_pem: &str) -> std::io::Result<Self> {
403        use rustls::pki_types::pem::PemObject;
404        let pem_err =
405            |e: rustls::pki_types::pem::Error| std::io::Error::new(std::io::ErrorKind::InvalidData, e.to_string());
406        let certs: Vec<rustls::pki_types::CertificateDer<'static>> =
407            rustls::pki_types::CertificateDer::pem_file_iter(cert_pem)
408                .map_err(pem_err)?
409                .collect::<Result<_, _>>()
410                .map_err(pem_err)?;
411        let key =
412            rustls::pki_types::PrivateKeyDer::from_pem_file(key_pem).map_err(pem_err)?;
413        let config = rustls::ServerConfig::builder()
414            .with_no_client_auth()
415            .with_single_cert(certs, key)
416            .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e.to_string()))?;
417        self.tls_config = Some(std::sync::Arc::new(config));
418        Ok(self)
419    }
420
421    /// Wrap an accepted socket per the TLS posture. The handshake happens
422    /// lazily on first read/write; a plaintext client fails it loudly.
423    fn wrap(&self, stream: TcpStream) -> std::io::Result<Conn> {
424        #[cfg(feature = "tls")]
425        if let Some(config) = &self.tls_config {
426            let session = rustls::ServerConnection::new(std::sync::Arc::clone(config))
427                .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e.to_string()))?;
428            return Ok(Conn::Tls(Box::new(rustls::StreamOwned::new(session, stream))));
429        }
430        Ok(Conn::Plain(stream))
431    }
432
433    fn handle(&self, stream: TcpStream) -> std::io::Result<()> {
434        // The server handles one connection at a time, so a single slow-drip
435        // client could otherwise hold it open indefinitely (a per-read timeout
436        // resets on every byte). A watchdog thread enforces a hard wall-clock
437        // deadline by shutting the socket down; it is unparked and joined the
438        // instant the request completes, so it adds no latency in the fast path.
439        let watchdog_stream = stream.try_clone()?;
440        let done = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
441        let watchdog_done = std::sync::Arc::clone(&done);
442        let watchdog = std::thread::spawn(move || {
443            std::thread::park_timeout(Duration::from_secs(REQUEST_DEADLINE_SECS));
444            if !watchdog_done.load(std::sync::atomic::Ordering::Acquire) {
445                let _ = watchdog_stream.shutdown(std::net::Shutdown::Both);
446            }
447        });
448        let raw = stream.try_clone()?;
449        let conn = self.wrap(stream)?;
450        let result = self.handle_request(&raw, conn);
451        done.store(true, std::sync::atomic::Ordering::Release);
452        watchdog.thread().unpark();
453        let _ = watchdog.join();
454        result
455    }
456
457    fn handle_request(&self, raw: &TcpStream, mut conn: Conn) -> std::io::Result<()> {
458        // Bound slow clients (slowloris) and total bytes read from the
459        // connection so a malicious client cannot stall the server or exhaust
460        // memory with an oversized request line / headers / body. The
461        // timeouts sit on the RAW socket, so they bound TLS handshakes too.
462        raw.set_read_timeout(Some(Duration::from_secs(READ_TIMEOUT_SECS)))?;
463        raw.set_write_timeout(Some(Duration::from_secs(READ_TIMEOUT_SECS)))?;
464        let mut reader = BufReader::new((&mut conn).take(MAX_CONN_BYTES));
465        let mut request_line = String::new();
466        reader.read_line(&mut request_line)?;
467        let mut parts = request_line.split_whitespace();
468        let method = parts.next().unwrap_or("").to_string();
469        let path = parts.next().unwrap_or("/").to_string();
470
471        // headers → content-length + authorization + origin + host
472        let mut content_length = 0usize;
473        let mut bearer: Option<String> = None;
474        let mut origin: Option<String> = None;
475        let mut host: Option<String> = None;
476        let mut sso_identity_raw: Option<String> = None;
477        let mut proxy_secret: Option<String> = None;
478        let mut header_bytes = 0usize;
479        let mut header_count = 0usize;
480        loop {
481            let mut line = String::new();
482            let n = reader.read_line(&mut line)?;
483            if n == 0 {
484                break; // EOF / connection closed by peer
485            }
486            header_bytes += n;
487            header_count += 1;
488            if header_bytes > MAX_HEADER_BYTES || header_count > MAX_HEADERS {
489                return Err(std::io::Error::new(
490                    std::io::ErrorKind::InvalidData,
491                    "request headers too large",
492                ));
493            }
494            let l = line.trim();
495            if l.is_empty() {
496                break;
497            }
498            let low = l.to_ascii_lowercase();
499            if let Some(v) = low.strip_prefix("content-length:") {
500                content_length = v.trim().parse().unwrap_or(0);
501            }
502            if let Some(v) = low.strip_prefix("origin:") {
503                origin = Some(v.trim().to_string());
504            }
505            if let Some(v) = low.strip_prefix("host:") {
506                host = Some(v.trim().to_string());
507            }
508            if let Some(v) = low.strip_prefix("x-areev-proxy-secret:") {
509                // Secrets are compared, never logged; the lowercased copy is
510                // wrong for comparison, so re-slice the original line.
511                let _ = v;
512                if let Some((_, orig)) = l.split_once(':') {
513                    proxy_secret = Some(orig.trim().to_string());
514                }
515            }
516            if let Some(h) = &self.sso_header {
517                if low.starts_with(&format!("{h}:")) {
518                    if let Some((_, orig)) = l.split_once(':') {
519                        sso_identity_raw = Some(orig.trim().to_string());
520                    }
521                }
522            }
523            if low.starts_with("authorization:") {
524                if let Some((_, v)) = l.split_once(':') {
525                    let v = v.trim();
526                    // Accept `Bearer <token>` (scripts/CLI) or HTTP `Basic`
527                    // (browsers, via the native login prompt). For Basic the
528                    // credential is base64(user:pass) and the token is the
529                    // password; the username is ignored.
530                    bearer = if let Some(t) =
531                        v.strip_prefix("Bearer ").or_else(|| v.strip_prefix("bearer "))
532                    {
533                        Some(t.trim().to_string())
534                    } else if let Some(b64) =
535                        v.strip_prefix("Basic ").or_else(|| v.strip_prefix("basic "))
536                    {
537                        basic_auth_password(b64.trim())
538                    } else {
539                        Some(v.to_string())
540                    };
541                }
542            }
543        }
544        // DNS-rebinding defense: a browser tricked into pointing an attacker
545        // domain at 127.0.0.1 sends that domain in the Host header. Unless the
546        // operator opted into remote serving, reject any non-loopback Host on
547        // *every* method (the Origin check below only covers POST, so this is
548        // what stops a drive-by page from reading memory over GET). A missing
549        // Host (bare HTTP/1.0, some CLI clients) passes, mirroring Origin.
550        if !self.allow_remote && host.as_deref().is_some_and(|h| !host_is_local(h)) {
551            let payload = br#"{"ok":false,"error":"non-loopback Host rejected (use --allow-remote to serve remotely)"}"#;
552            drop(reader);
553            let mut out = conn;
554            write!(
555                out,
556                "HTTP/1.1 403 Forbidden\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
557                payload.len()
558            )?;
559            out.write_all(payload)?;
560            return out.flush();
561        }
562
563        let mut body = vec![0u8; content_length.min(1 << 20)];
564        if content_length > 0 {
565            reader.read_exact(&mut body)?;
566        }
567
568        // Cross-origin drive-by protection: browsers attach an Origin header
569        // to cross-site requests. Only loopback pages (the console itself)
570        // may mutate; curl/CLI clients send no Origin and pass through.
571        if method == "POST" && origin.as_deref().is_some_and(|o| !origin_is_local(o)) {
572            let payload = br#"{"ok":false,"error":"cross-origin request rejected"}"#;
573            drop(reader);
574            let mut out = conn;
575            write!(
576                out,
577                "HTTP/1.1 403 Forbidden\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
578                payload.len()
579            )?;
580            out.write_all(payload)?;
581            return out.flush();
582        }
583
584        // The identity header is TRUSTED only when the proxy proved itself
585        // (constant-time secret check). A forged header without the secret
586        // is silently ignored — the request proceeds as whatever its other
587        // credentials make it.
588        let sso_identity: Option<String> = match (proxy_secret, sso_identity_raw) {
589            (Some(presented), Some(id)) if !id.trim().is_empty() => {
590                // `fold`, not `any`: `any` short-circuits on the first match,
591                // so during a rotation window the response time would say
592                // WHICH secret was presented. Every configured secret is
593                // compared, every time.
594                let proved = self
595                    .sso_secrets
596                    .iter()
597                    .fold(false, |acc, s| ct_eq(s.as_bytes(), presented.as_bytes()) | acc);
598                proved.then_some(id)
599            }
600            _ => None,
601        };
602        let (status, ctype, payload) =
603            self.route(&method, &path, &body, bearer.as_deref(), sso_identity.as_deref());
604        // On a console-auth 401, challenge with Basic so browsers show the
605        // native login prompt (any username; password = token).
606        let auth_challenge = if self.auth_all && status.starts_with("401") {
607            "WWW-Authenticate: Basic realm=\"Areev console\", charset=\"UTF-8\"\r\n"
608        } else {
609            ""
610        };
611        drop(reader);
612        let mut out = conn;
613        write!(
614            out,
615            "HTTP/1.1 {status}\r\nContent-Type: {ctype}\r\n{auth_challenge}Content-Length: {}\r\nConnection: close\r\n\r\n",
616            payload.len()
617        )?;
618        out.write_all(&payload)?;
619        out.flush()
620    }
621
622    /// The runtime driver for the /api/run/* surface. The console never
623    /// EXECUTES tools (list/inspect read; respond/cancel journal) — the
624    /// executor is a refusing stub, present only to satisfy the driver
625    /// shape.
626    fn runner(&self, principal: &str) -> areev_run::Runner {
627        struct NoExec;
628        impl areev_run::HostToolExecutor for NoExec {
629            fn execute(
630                &self,
631                tool_name: &str,
632                _h: &str,
633                _i: &Value,
634                _k: &str,
635            ) -> areev_run::ExecResult {
636                areev_run::ExecResult::Err {
637                    cause: areev_run::FailCause::ExecutorError,
638                    detail: format!("the console does not execute tools ('{tool_name}')"),
639                }
640            }
641        }
642        areev_run::Runner {
643            facade: std::sync::Arc::clone(&self.facade),
644            clock: std::sync::Arc::new(areev_run::SystemClock),
645            executor: std::sync::Arc::new(NoExec),
646            llm: None,
647            observer: None,
648            ns: self
649                .facade
650                .default_namespace()
651                .unwrap_or("shared")
652                .to_string(),
653            principal: principal.to_string(),
654        }
655    }
656
657    fn route(
658        &self,
659        method: &str,
660        path: &str,
661        body: &[u8],
662        bearer: Option<&str>,
663        sso_identity: Option<&str>,
664    ) -> (&'static str, &'static str, Vec<u8>) {
665        // Auth: console-auth mode (`auth_all`) guards every request; hub mode
666        // guards only mutating + sync endpoints. The credential arrives as a
667        // Bearer token or an HTTP Basic password (see `handle_request`).
668        //
669        // A credential map changes WHO a request is, not WHETHER the shared
670        // secret is required. When both are configured, a map token
671        // authenticates as its principal, the shared secret authenticates as
672        // the implied admin (`docs/cal-all-you-need-proposal.md` §5.2b), and
673        // anything else is refused — an attached map must never silently
674        // downgrade a `--token-env` console to an open `anonymous` one.
675        let mut shared_secret_ok = false;
676        if let Some(tok) = &self.token {
677            let guarded = self.auth_all || method == "POST" || path.starts_with("/api/segment");
678            shared_secret_ok = bearer.is_some_and(|b| ct_eq(b.as_bytes(), tok.as_bytes()));
679            let known = shared_secret_ok
680                || sso_identity.is_some()
681                || match (&self.credentials, bearer) {
682                    (Some(map), Some(b)) => map.resolve_for_memory(b, &self.db_label).is_ok(),
683                    _ => false,
684                };
685            if guarded && !known {
686                return ("401 Unauthorized", "application/json",
687                        br#"{"ok":false,"error":"authentication required"}"#.to_vec());
688            }
689        } else if self.credentials.is_none() && sso_identity.is_none() && method == "POST" {
690            // §5.7: token-less `areev ui` is read-only. The ONLY POST allowed is
691            // a read-only CAL statement; every write (any loop mutation, an
692            // ADD/SUPERSEDE/FORGET CAL batch, etc.) requires --token-env. This
693            // closes the bypass where a local process could execute a
694            // proposal's CAL directly and skip the review queue.
695            let base = path.split_once('?').map(|(p, _)| p).unwrap_or(path);
696            let allowed = base == "/api/cal" && cal_body_is_read_only(body);
697            if !allowed {
698                return ("401 Unauthorized", "application/json",
699                        br#"{"ok":false,"error":"read-only console: restart areev ui with --token-env VAR to enable writes"}"#.to_vec());
700            }
701        }
702
703        // Credential-map mode: resolve this request's principal and bind
704        // the facade for the request's duration (the guard restores the
705        // owner default on drop). Every verb check downstream — CAL,
706        // loop scopes, the run gate below — now answers for THIS caller.
707        // A request authenticated by the shared secret keeps the owner
708        // session that secret has always carried.
709        let _session = match (&self.credentials, shared_secret_ok) {
710            (Some(map), false) => Some(RequestBinding::bind(&self.facade, map, bearer, &self.db_label)),
711            _ => None,
712        };
713        // The request's resolved PRINCIPAL identity — Some only when a
714        // per-principal credential authenticated it. The shared secret and
715        // anonymous access deliberately resolve to None: approvals need an
716        // identity, and "whoever holds the console token" is not one.
717        let mut request_principal: Option<String> = match (&self.credentials, shared_secret_ok, bearer)
718        {
719            (Some(map), false, Some(b)) => map.resolve_for_memory(b, &self.db_label).ok().map(str::to_string),
720            _ => None,
721        };
722        // SSO v0: a proxy-proven identity becomes the request principal when
723        // no bearer credential resolved one (machines keep tokens; SSO is
724        // for humans behind the proxy). Rights come from the file's grants.
725        let _sso_session = match (&request_principal, sso_identity, shared_secret_ok) {
726            (None, Some(identity), false) => {
727                request_principal = Some(identity.to_string());
728                Some(RequestBinding::bind_identity(&self.facade, identity))
729            }
730            _ => None,
731        };
732
733        let (path, query) = match path.split_once('?') {
734            Some((p, q)) => (p, q),
735            None => (path, ""),
736        };
737        let q = |key: &str| -> Option<String> {
738            query.split('&').find_map(|kv| {
739                let (k, v) = kv.split_once('=')?;
740                (k == key).then(|| urldecode(v))
741            })
742        };
743        match (method, path) {
744            ("GET", "/") => (
745                "200 OK",
746                "text/html; charset=utf-8",
747                CONSOLE_HTML
748                    .replace("{{DB}}", &html_escape(&self.db_label))
749                    .into_bytes(),
750            ),
751            ("POST", "/api/cal") => {
752                let req: Value = serde_json::from_slice(body).unwrap_or(Value::Null);
753                let queryt = req.get("query").and_then(|v| v.as_str()).unwrap_or("");
754                match self.executor.execute(queryt, &*self.facade) {
755                    Ok(res) => ok_json(json!({
756                        "ok": true,
757                        "result": res.result,
758                        "warnings": res.warnings,
759                        "statement": res.metadata.statement_type,
760                        "elapsed_ms": res.metadata.execution_time_ms,
761                    })),
762                    Err(e) => {
763                        // Structured error: code + span + hint let the console
764                        // point at the offending token instead of just quoting.
765                        let mut err = json!({
766                            "ok": false,
767                            "error": e.sanitize_for_client(),
768                            "code": e.code(),
769                        });
770                        if let Some(sp) = e.span() {
771                            err["span"] = json!({
772                                "start": sp.start, "end": sp.end,
773                                "line": sp.line, "col": sp.col,
774                            });
775                        }
776                        if let Some(hint) = e.suggestion() {
777                            err["suggestion"] = json!(hint);
778                        }
779                        ok_json(err)
780                    }
781                }
782            }
783            ("GET", "/api/stats") => {
784                let stats = self.facade.with_store(|m| m.stats());
785                match stats {
786                    Ok(s) => ok_json(json!({
787                        "db": self.db_label,
788                        "grains": s.grains, "current": s.current,
789                        "triples": s.triples, "terms": s.terms,
790                        "ops": s.ops, "events_indexed": s.events_indexed,
791                    })),
792                    Err(e) => ok_json(json!({"ok": false, "error": e.to_string()})),
793                }
794            }
795            ("GET", "/api/log") => {
796                let since: i64 = q("since").and_then(|v| v.parse().ok()).unwrap_or(0);
797                let limit: usize = q("limit").and_then(|v| v.parse().ok()).unwrap_or(100);
798                match self.facade.with_store(|m| m.changes_since(since, limit)) {
799                    Ok(ops) => {
800                        let rows: Vec<Value> = ops
801                            .iter()
802                            .map(|o| {
803                                json!({
804                                    "op_seq": o.op_seq, "hlc": o.hlc,
805                                    "op": match o.op { 1 => "add", 2 => "supersede", 3 => "forget", _ => "?" },
806                                    "hash": o.hash.to_hex(),
807                                })
808                            })
809                            .collect();
810                        ok_json(json!(rows))
811                    }
812                    Err(e) => ok_json(json!({"ok": false, "error": e.to_string()})),
813                }
814            }
815            // Who am I: the principal this request is bound to. In
816            // credential-map mode this reflects the per-request binding;
817            // read-only and mode-agnostic.
818            ("GET", "/api/whoami") => {
819                let a = self.facade.authz();
820                ok_json(json!({
821                    "ok": true,
822                    "principal": a.principal(),
823                    "owner": a.is_owner(),
824                    "mode": if self.credentials.is_some() { "credentials" }
825                            else if self.token.is_some() { "token" }
826                            else { "open" },
827                }))
828            }
829            ("GET", "/api/config") => {
830                // Read-only observability: the *effective* per-process
831                // configuration. Nothing here is persisted in the .db —
832                // the file holds data only; these values are supplied by
833                // the host at open time.
834                let cfg = self.executor.config();
835                let (index_text, embedder_dim, declared_embed, mut warnings) =
836                    self.facade.with_store(|m| {
837                        (
838                            m.index_text_enabled(),
839                            m.embedder_dim(),
840                            m.declared_embedding().map(|(mm, d)| (mm.to_string(), d)),
841                            m.open_warnings().to_vec(),
842                        )
843                    });
844                if let Some((m, d)) = &declared_embed {
845                    if embedder_dim.is_none() {
846                        warnings.push(format!(
847                            "vector leg dormant: file expects {m}@{d}, no embedding backend installed"
848                        ));
849                    }
850                }
851                // Saved queries and templates the file carries that this build
852                // cannot load. Same class of thing as an open warning — the
853                // file and the host disagree — so it belongs in the same list.
854                warnings.extend(self.facade.meta_warnings());
855                // Anonymization observability (REQ-ANON-5: no policy is
856                // loud): declared per-ns modes, the host floor, and the
857                // audit counters. Modes only — never mappings or values.
858                let (anon_declared, anon_floor, anon_audit) = self.facade.with_store(|m| {
859                    (
860                        m.anon_declared(),
861                        m.anonymize_egress_floor(),
862                        m.anon_audit_counts(),
863                    )
864                });
865                let anonymization = json!({
866                    "policies": anon_declared
867                        .iter()
868                        .map(|(ns, mode)| json!({"ns": ns, "mode": mode}))
869                        .collect::<Vec<_>>(),
870                    "floor": anon_floor,
871                    "audit_counts": anon_audit
872                        .iter()
873                        .map(|(ns, cat, n)| json!({"ns": ns, "category": cat, "count": n}))
874                        .collect::<Vec<_>>(),
875                });
876                ok_json(json!({
877                    "ok": true,
878                    "db": self.db_label,
879                    "warnings": warnings,
880                    "anonymization": anonymization,
881                    "file": {
882                        "text_index": index_text,
883                        "embedding": declared_embed.map(|(m, d)| json!({"model": m, "dim": d})),
884                    },
885                    "session": {
886                        "namespace": self.facade.session_namespace(),
887                        "mounts": self.facade.mount_aliases(),
888                        // Gates the console's "All namespaces" picker option —
889                        // same rule /api/browse?ns=* and /api/namespaces enforce.
890                        "is_owner": self.facade.authz().is_owner(),
891                    },
892                    "store": {
893                        "index_text": index_text,
894                        "embedder": embedder_dim.map(|d| json!({"dim": d})),
895                    },
896                    "recall": {
897                        "fusion": "rrf",
898                        "rrf_k0": areev_store::RRF_K0,
899                        "overfetch_factor": AreevFacade::RECALL_OVERFETCH,
900                        "legs": {
901                            "structural": true,
902                            "bm25": index_text,
903                            "vector": embedder_dim.is_some(),
904                        },
905                    },
906                    "executor": {
907                        "max_limit": cfg.max_limit,
908                        "default_limit": cfg.default_limit,
909                        "tier1_writes": cfg.tier1_enabled,
910                        "namespace_override": cfg.namespace_override,
911                        "user_id_override": cfg.user_id_override,
912                    },
913                    "server": {
914                        "hub_mode": self.segment_dir.is_some(),
915                        "auth_required": self.token.is_some(),
916                        // true = every request is authenticated (console-auth);
917                        // false with auth_required = hub mode (writes/sync only).
918                        "auth_all": self.auth_all,
919                        "segment_dir": self.segment_dir,
920                    },
921                    "persistence": "per-process (host-supplied at open) — not stored in the .db",
922                }))
923            }
924            ("GET", "/api/browse") => {
925                // Browse-without-queries: the tail of the op-log joined with
926                // grain summaries, newest first. Supersession and tombstone
927                // status are resolved within the returned window so the
928                // console can dim/strike them (grains are never mutated —
929                // this reads the index + immutable blobs only).
930                let limit: i64 = q("limit")
931                    .and_then(|v| v.parse().ok())
932                    .unwrap_or(500)
933                    .clamp(1, 2000);
934                let built = self.facade.with_store(|m| -> Result<(i64, Vec<Value>), String> {
935                    let total = m.stats().map_err(|e| e.to_string())?.ops as i64;
936                    let after = (total - limit).max(0);
937                    let ops = m.changes_since(after, limit as usize).map_err(|e| e.to_string())?;
938                    let mut rows: Vec<Value> = Vec::with_capacity(ops.len());
939                    let mut idx: std::collections::HashMap<String, usize> =
940                        std::collections::HashMap::new();
941                    for o in &ops {
942                        let hex = o.hash.to_hex();
943                        let op_name = match o.op { 1 => "add", 2 => "supersede", 3 => "forget", _ => "?" };
944                        if o.op == 3 {
945                            // tombstone: flag the earlier row if it is in the
946                            // window, else emit a stub (blob is already gone).
947                            match idx.get(&hex) {
948                                Some(&i) => { rows[i]["forgotten"] = json!(true); }
949                                None => rows.push(json!({
950                                    "hash": hex, "op_seq": o.op_seq, "hlc": o.hlc,
951                                    "op": op_name, "forgotten": true,
952                                })),
953                            }
954                            continue;
955                        }
956                        // A SUPERSEDE logs two ops for the new grain (add +
957                        // supersede) — merge them into one row, keeping the
958                        // later op label.
959                        if let Some(&i) = idx.get(&hex) {
960                            rows[i]["op"] = json!(op_name);
961                            rows[i]["op_seq"] = json!(o.op_seq);
962                            rows[i]["hlc"] = json!(o.hlc);
963                            continue;
964                        }
965                        let mut row = json!({
966                            "hash": hex, "op_seq": o.op_seq, "hlc": o.hlc, "op": op_name,
967                        });
968                        match m.get(&o.hash) {
969                            Ok(g) => {
970                                row["type"] = json!(format!("{:?}", g.grain_type).to_lowercase());
971                                row["fields"] = serde_json::to_value(&g.fields).unwrap_or(Value::Null);
972                            }
973                            // erased since (forget op outside the window)
974                            Err(_) => { row["missing"] = json!(true); }
975                        }
976                        idx.insert(hex, rows.len());
977                        rows.push(row);
978                    }
979                    // A supersede op's grain points at its predecessor via
980                    // derived_from; mark the predecessor if it is in-window.
981                    let marks: Vec<(String, String)> = rows.iter()
982                        .filter(|r| r["op"] == "supersede")
983                        .filter_map(|r| {
984                            let old = r["fields"].get("derived_from")?.as_str()?;
985                            Some((old.to_string(), r["hash"].as_str()?.to_string()))
986                        })
987                        .collect();
988                    for (old, newer) in marks {
989                        if let Some(&i) = idx.get(&old) {
990                            rows[i]["superseded_by"] = json!(newer);
991                        }
992                    }
993                    rows.reverse();
994                    Ok((total, rows))
995                });
996                // Scope the browse to a namespace, the way CAL already scopes
997                // recall. Without this the rail lists entities (`john`, `bob`)
998                // that every query on this console then reports as missing,
999                // because the two disagree about what is in view.
1000                //
1001                // `?ns=<name>` lets the console browse a namespace other than
1002                // the one it was launched with, and `?ns=*` lifts the filter
1003                // entirely — both read-gated against the session's own
1004                // `AuthzSet` exactly as a `RECALL ... WHERE namespace = ...`
1005                // would be (`changes_since` itself reads the whole op-log
1006                // unscoped; this filter is the only enforcement point, so it
1007                // has to check what `AreevFacade::recall` checks, not less).
1008                // Omitting `?ns` keeps the original session-default behavior
1009                // byte-for-byte. Rows we cannot attribute — tombstone stubs,
1010                // grains erased since — are kept regardless: dropping them
1011                // would hide an erasure.
1012                let ns_param = q("ns");
1013                let built = built.and_then(|(total, rows)| {
1014                    let authz = self.facade.authz();
1015                    let filter_ns = match ns_param.as_deref() {
1016                        Some("*") => {
1017                            if !authz.is_owner() {
1018                                return Err("not authorized to browse all namespaces".to_string());
1019                            }
1020                            None
1021                        }
1022                        Some(ns) => {
1023                            authz
1024                                .check(areev_core::authz::Verb::Read, ns)
1025                                .map_err(|e| e.to_string())?;
1026                            Some(ns.to_string())
1027                        }
1028                        None => self.facade.session_namespace().map(|s| s.to_string()),
1029                    };
1030                    let Some(ns) = filter_ns else {
1031                        return Ok((total, rows));
1032                    };
1033                    let kept = rows
1034                        .into_iter()
1035                        .filter(|r| {
1036                            r["fields"]
1037                                .get("namespace")
1038                                .and_then(Value::as_str)
1039                                .is_none_or(|got| got == ns)
1040                        })
1041                        .collect();
1042                    Ok((total, kept))
1043                });
1044                match built {
1045                    Ok((total, rows)) => ok_json(json!({"ok": true, "total_ops": total, "grains": rows})),
1046                    Err(e) => ok_json(json!({"ok": false, "error": e})),
1047                }
1048            }
1049            ("GET", "/api/namespaces") => {
1050                // Every namespace present in the file, for the console's
1051                // namespace picker. A restricted principal only ever sees the
1052                // ones its own grants cover — read access to the *names* of
1053                // namespaces it cannot read from would itself be a disclosure.
1054                let result = self.facade.with_store(|m| m.namespaces());
1055                match result {
1056                    Ok(list) => {
1057                        let authz = self.facade.authz();
1058                        let owner = authz.is_owner();
1059                        let arr: Vec<Value> = list
1060                            .into_iter()
1061                            .filter(|(ns, _)| {
1062                                owner || authz.check(areev_core::authz::Verb::Read, ns).is_ok()
1063                            })
1064                            .map(|(ns, n)| json!({"namespace": ns, "count": n}))
1065                            .collect();
1066                        ok_json(json!({"ok": true, "namespaces": arr}))
1067                    }
1068                    Err(e) => ok_json(json!({"ok": false, "error": e.to_string()})),
1069                }
1070            }
1071            ("GET", "/api/grain") => {
1072                let hash = q("hash").unwrap_or_default();
1073                match areev_core::error::Hash::from_hex(&hash)
1074                    .and_then(|h| self.facade.get(&h))
1075                {
1076                    Ok(g) => ok_json(json!({
1077                        "hash": g.hash.to_hex(),
1078                        "type": format!("{:?}", g.grain_type).to_lowercase(),
1079                        "fields": g.fields,
1080                    })),
1081                    Err(e) => ok_json(json!({"ok": false, "error": e.to_string()})),
1082                }
1083            }
1084            ("GET", "/api/anon/preview") => {
1085                // "As the model sees it" (proposal §8.3): the grain rendered
1086                // the way an egress boundary would show it, policy or not.
1087                let hash = q("hash").unwrap_or_default();
1088                match areev_core::error::Hash::from_hex(&hash)
1089                    .and_then(|h| self.facade.with_store(|m| m.anon_preview(&h)))
1090                {
1091                    Ok(g) => ok_json(json!({
1092                        "hash": g.hash.to_hex(),
1093                        "type": format!("{:?}", g.grain_type).to_lowercase(),
1094                        "fields": g.fields,
1095                    })),
1096                    Err(e) => ok_json(json!({"ok": false, "error": e.to_string()})),
1097                }
1098            }
1099            ("GET", "/api/verify") => match self.facade.with_store(|m| m.verify()) {
1100                Ok(r) => ok_json(json!({
1101                    "integrity": r.integrity, "grains": r.grains,
1102                    "hash_mismatches": r.hash_mismatches, "undecodable": r.undecodable,
1103                })),
1104                Err(e) => ok_json(json!({"ok": false, "error": e.to_string()})),
1105            },
1106            ("POST", "/api/segment") => {
1107                // A pushed segment is imported straight into the live store,
1108                // so it is a write — and one that never crosses the facade's
1109                // verb checks. Gate it on the bound session's `write`, or a
1110                // principal with no write grant could add grains here that
1111                // `POST /api/cal` refuses it.
1112                if let Err(e) = self
1113                    .facade
1114                    .authz()
1115                    .check(areev_core::authz::Verb::Write, "*")
1116                {
1117                    return ("403 Forbidden", "application/json",
1118                            json!({"ok": false, "error": e.to_string()}).to_string().into_bytes());
1119                }
1120                // push: body = raw MGB1 bundle; applied immediately + archived
1121                let Some(dir) = &self.segment_dir else {
1122                    return ("400 Bad Request", "application/json",
1123                            br#"{"ok":false,"error":"not in hub mode"}"#.to_vec());
1124                };
1125                let name = q("name").unwrap_or_else(|| format!("push-{}.mgb", now_label()));
1126                let Some(safe) = safe_segment_name(&name) else {
1127                    return ("400 Bad Request", "application/json",
1128                            br#"{"ok":false,"error":"invalid segment name"}"#.to_vec());
1129                };
1130                let path = format!("{dir}/{safe}");
1131                if std::fs::write(&path, body).is_err() {
1132                    return ("500 Internal Server Error", "application/json",
1133                            br#"{"ok":false,"error":"write failed"}"#.to_vec());
1134                }
1135                match self.facade.with_store(|m| m.import_bundle(&path)) {
1136                    Ok(st) => ok_json(json!({"ok": true, "applied": st.applied, "skipped": st.skipped, "stored": safe})),
1137                    Err(e) => ok_json(json!({"ok": false, "error": e.to_string()})),
1138                }
1139            }
1140            ("GET", "/api/segments") | ("GET", "/api/segment") if !self.session_may_sync() => (
1141                "403 Forbidden",
1142                "application/json",
1143                br#"{"ok":false,"error":"AUT-E001: this session lacks read on the whole memory"}"#.to_vec(),
1144            ),
1145            ("GET", "/api/segments") => {
1146                let Some(dir) = &self.segment_dir else {
1147                    return ok_json(json!([]));
1148                };
1149                let mut names: Vec<String> = std::fs::read_dir(dir)
1150                    .map(|d| d.filter_map(|e| e.ok().map(|e| e.file_name().to_string_lossy().to_string()))
1151                        .filter(|n| n.ends_with(".mgb")).collect())
1152                    .unwrap_or_default();
1153                names.sort();
1154                ok_json(json!(names))
1155            }
1156            ("GET", "/api/segment") => {
1157                let Some(dir) = &self.segment_dir else {
1158                    return ("400 Bad Request", "application/json", b"{}".to_vec());
1159                };
1160                let name = q("name").unwrap_or_default();
1161                let Some(safe) = safe_segment_name(&name) else {
1162                    return ("400 Bad Request", "application/json",
1163                            br#"{"ok":false,"error":"invalid segment name"}"#.to_vec());
1164                };
1165                match std::fs::read(format!("{dir}/{safe}")) {
1166                    Ok(b) => ("200 OK", "application/octet-stream", b),
1167                    Err(_) => ("404 Not Found", "application/json",
1168                               br#"{"ok":false,"error":"no such segment"}"#.to_vec()),
1169                }
1170            }
1171            // ── Areev Loop API (§5.4) — GETs are reads (token-less OK); the POST
1172            //    mutations are guarded above (token-less → 401). ────────────
1173            // ── /api/run/*: the governed runtime's console surface ──
1174            ("GET", "/api/run/list") => {
1175                let runner = self.runner("console:read");
1176                match runner.recent_runs(50) {
1177                    Ok(ids) => {
1178                        let rows: Vec<Value> = ids
1179                            .iter()
1180                            .map(|id| {
1181                                let outcome = self.facade.with_store(|m| {
1182                                    m.recent(
1183                                        "agent:harness",
1184                                        Some(areev_core::types::GrainType::Observation),
1185                                        4096,
1186                                    )
1187                                })
1188                                .ok()
1189                                .and_then(|rows| {
1190                                    rows.into_iter().find(|g| {
1191                                        g.get_str("run_id") == Some(id.as_str())
1192                                            && g.get_str("observation_kind")
1193                                                == Some("run_outcome")
1194                                    })
1195                                });
1196                                serde_json::json!({
1197                                    "run_id": id,
1198                                    "outcome": outcome.as_ref().and_then(|g| g.get_str("object")).unwrap_or("open"),
1199                                })
1200                            })
1201                            .collect();
1202                        ok_json(serde_json::json!({"ok": true, "runs": rows}))
1203                    }
1204                    Err(e) => run_api_err(&e.to_string()),
1205                }
1206            }
1207            ("GET", "/api/run/inspect") => {
1208                let Some(run_id) = q("run_id") else {
1209                    return run_api_err("run_id required");
1210                };
1211                // Same Runner::inspect the CLI and bindings call (issue
1212                // #34) — this used to hand-roll a divergent, smaller subset
1213                // (no pinned/budgets/fork_of/superstep/phase).
1214                match self.runner("console:read").inspect(&run_id) {
1215                    Ok(report) => {
1216                        let mut v = serde_json::to_value(&report).unwrap_or(Value::Null);
1217                        if let Some(o) = v.as_object_mut() {
1218                            o.insert("ok".into(), Value::Bool(true));
1219                        }
1220                        ok_json(v)
1221                    }
1222                    Err(e) => run_api_err(&e.to_string()),
1223                }
1224            }
1225            ("POST", "/api/run/respond") => {
1226                // [R3] SHARED-TOKEN APPROVALS ARE REFUSED: an approval whose
1227                // "identity" is a console-wide secret voids the
1228                // approver-identity edge of the provenance chain. Only a
1229                // per-principal credential (areev ui --auth) may respond.
1230                let Some(responder) = request_principal.clone() else {
1231                    return ("403 Forbidden", "application/json",
1232                        br#"{"ok":false,"error":"run.respond requires a per-principal credential (areev ui --auth <map>); shared-token or anonymous approvals are refused - the approver's identity IS the audit record"}"#.to_vec());
1233                };
1234                let req: Value = serde_json::from_slice(body).unwrap_or(Value::Null);
1235                let (Some(run_id), Some(ask)) = (
1236                    req.get("run_id").and_then(Value::as_str),
1237                    req.get("tool_call_id").and_then(Value::as_str),
1238                ) else {
1239                    return run_api_err("run_id and tool_call_id required");
1240                };
1241                let result = req.get("result").cloned().unwrap_or(Value::Null);
1242                let is_error = req.get("is_error").and_then(Value::as_bool).unwrap_or(false);
1243                let runner = self.runner(&responder);
1244                match runner.respond(run_id, ask, result, is_error, &responder) {
1245                    Ok(()) => ok_json(serde_json::json!({
1246                        "ok": true, "responded": ask, "responder": responder,
1247                        "note": "resume the run to spend the compute",
1248                    })),
1249                    Err(e) => run_api_err(&e.to_string()),
1250                }
1251            }
1252            ("POST", "/api/run/cancel") => {
1253                // The kill switch keeps its LOW bar: any authenticated POST
1254                // may brake a run (the identity label still records who).
1255                let req: Value = serde_json::from_slice(body).unwrap_or(Value::Null);
1256                let Some(run_id) = req.get("run_id").and_then(Value::as_str) else {
1257                    return run_api_err("run_id required");
1258                };
1259                let because = req.get("because").and_then(Value::as_str).unwrap_or("canceled via console");
1260                let who = request_principal.clone().unwrap_or_else(|| "console:shared".into());
1261                let runner = self.runner(&who);
1262                match runner.cancel(run_id, &who, because) {
1263                    Ok(()) => ok_json(serde_json::json!({"ok": true, "canceled": run_id})),
1264                    Err(e) => run_api_err(&e.to_string()),
1265                }
1266            }
1267            ("GET", "/api/loop/recommendations") => {
1268                let status = q("status").and_then(|s| status_from_str(&s));
1269                let sub = BorrowedSubstrate::new(&self.facade);
1270                match self.engine().recommendations(&sub, status) {
1271                    Ok(recs) => ok_json(json!({
1272                        "ok": true,
1273                        "recommendations": recs.iter().map(rec_json).collect::<Vec<_>>(),
1274                    })),
1275                    Err(e) => ok_json(json!({"ok": false, "error": e.to_string(), "code": e.code()})),
1276                }
1277            }
1278            ("GET", "/api/loop/health") => {
1279                let sub = BorrowedSubstrate::new(&self.facade);
1280                match self.engine().health(&sub, now_ms()) {
1281                    Ok(h) => ok_json(json!({"ok": true, "health": h})),
1282                    Err(e) => ok_json(json!({"ok": false, "error": e.to_string(), "code": e.code()})),
1283                }
1284            }
1285            ("GET", "/api/loop/outcomes") => {
1286                let sub = BorrowedSubstrate::new(&self.facade);
1287                match self.engine().outcomes(&sub) {
1288                    Ok(o) => ok_json(json!({"ok": true, "outcomes": o})),
1289                    Err(e) => ok_json(json!({"ok": false, "error": e.to_string(), "code": e.code()})),
1290                }
1291            }
1292            ("GET", "/api/loop/analyzers") => {
1293                // Effective settings (manifest merged with the file-config), so
1294                // the Setup view renders accurate on/off state and floors.
1295                let sub = BorrowedSubstrate::new(&self.facade);
1296                match self.engine().analyzer_settings(&sub) {
1297                    Ok(list) => ok_json(json!({"ok": true, "analyzers": list})),
1298                    Err(e) => ok_json(json!({"ok": false, "error": e.to_string(), "code": e.code()})),
1299                }
1300            }
1301            ("GET", "/api/loop/telemetry") => {
1302                // Recall-telemetry rollups for the Sessions view. A read — open
1303                // in read-only mode like the other loop GETs.
1304                let mode = self.facade.with_store(|m| m.telemetry_mode());
1305                if mode == areev_store::TelemetryMode::Off {
1306                    ok_json(json!({"ok": true, "enabled": false}))
1307                } else {
1308                    let access = self.facade.with_store(|m| m.telemetry_access_stats(None));
1309                    let queries = self.facade.with_store(|m| m.telemetry_query_stats(None));
1310                    let budget = self.facade.with_store(|m| m.telemetry_budget_stats());
1311                    match (access, queries, budget) {
1312                        (Ok(mut a), Ok(mut q), Ok(b)) => {
1313                            // Most-recalled first; recurring-gap questions first.
1314                            a.sort_by_key(|x| std::cmp::Reverse(x.recall_count));
1315                            a.truncate(200);
1316                            q.sort_by_key(|x| std::cmp::Reverse(x.run_count));
1317                            q.truncate(200);
1318                            ok_json(json!({
1319                                "ok": true,
1320                                "enabled": true,
1321                                "mode": mode.as_str(),
1322                                "access": a.iter().map(|x| json!({
1323                                    "hash": x.hash, "recall_count": x.recall_count, "last_ms": x.last_ms,
1324                                })).collect::<Vec<_>>(),
1325                                "queries": q.iter().map(|x| json!({
1326                                    "sample": x.sample, "run_count": x.run_count,
1327                                    "empty_count": x.empty_count,
1328                                })).collect::<Vec<_>>(),
1329                                "budget": {
1330                                    "sample_count": b.sample_count,
1331                                    "overflow_count": b.overflow_count,
1332                                },
1333                            }))
1334                        }
1335                        _ => ok_json(json!({"ok": false, "error": "telemetry read failed"})),
1336                    }
1337                }
1338            }
1339            ("POST", "/api/loop/run") => {
1340                let authz = self.facade.authz();
1341                if !authz.is_owner()
1342                    && !authz.allows(areev_core::authz::Verb::LoopRun, areev_loop::LOOP_NS)
1343                {
1344                    return ("403 Forbidden", "application/json",
1345                            br#"{"ok":false,"error":"AUT-E001: this session lacks loop.run"}"#.to_vec());
1346                }
1347                let mut sub = BorrowedSubstrate::new(&self.facade);
1348                // The trigger is recorded as co-creator on LLM/external
1349                // findings, so the same principal cannot later approve them.
1350                // In credential-map mode the actor IS the bound principal —
1351                // a request cannot claim an identity.
1352                let actor = if self.credentials.is_some() {
1353                    authz.principal().to_string()
1354                } else {
1355                    serde_json::from_slice::<Value>(body)
1356                        .ok()
1357                        .and_then(|v| v.get("actor").and_then(Value::as_str).map(str::to_string))
1358                        .unwrap_or_else(|| "user:console".to_string())
1359                };
1360                let opts = RunOptions { triggering_actor: Some(actor), ..Default::default() };
1361                match self.engine().run(&mut sub, &opts, now_ms()) {
1362                    Ok(res) => ok_json(json!({"ok": true, "run": res})),
1363                    Err(e) => ok_json(json!({"ok": false, "error": e.to_string(), "code": e.code()})),
1364                }
1365            }
1366            ("POST", "/api/loop/review") => self.loop_review(body),
1367            ("POST", "/api/loop/apply") => self.loop_apply(body),
1368            ("POST", "/api/loop/rollback") => self.loop_rollback(body),
1369            ("POST", "/api/loop/config") => self.loop_config(body),
1370            ("POST", "/api/anon/config") => self.anon_config(body),
1371            _ => (
1372                "404 Not Found",
1373                "application/json",
1374                br#"{"ok":false,"error":"not found"}"#.to_vec(),
1375            ),
1376        }
1377    }
1378
1379    /// The console session's scopes derive from its grants (an owner
1380    /// session — today's only mode — holds all of them); actor
1381    /// `user:console` unless overridden, observer derived from the actor
1382    /// label, never from request text.
1383    fn loop_review(&self, body: &[u8]) -> (&'static str, &'static str, Vec<u8>) {
1384        let req: Value = serde_json::from_slice(body).unwrap_or(Value::Null);
1385        let hash = req.get("hash").and_then(Value::as_str).unwrap_or("");
1386        let because = req.get("because").and_then(Value::as_str).unwrap_or("");
1387        let bound = self.facade.authz();
1388        let actor = if self.credentials.is_some() {
1389            bound.principal().to_string()
1390        } else {
1391            req.get("actor").and_then(Value::as_str).unwrap_or("user:console").to_string()
1392        };
1393        let actor = actor.as_str();
1394        let decision = if req.get("decision").and_then(Value::as_str) == Some("reject") {
1395            Decision::Reject
1396        } else {
1397            Decision::Approve
1398        };
1399        let mut sub = BorrowedSubstrate::new(&self.facade);
1400        let scopes = areev_loop_adapter::scopes_for(&self.facade.authz());
1401        let observer = areev_loop_adapter::observer_for_principal(actor);
1402        match self.engine().review(
1403            &mut sub, hash, decision, actor, observer, &scopes, because, now_ms(),
1404        ) {
1405            Ok(()) => ok_json(json!({"ok": true})),
1406            Err(e) => ok_json(json!({"ok": false, "error": e.to_string(), "code": e.code()})),
1407        }
1408    }
1409
1410    fn loop_apply(&self, body: &[u8]) -> (&'static str, &'static str, Vec<u8>) {
1411        let req: Value = serde_json::from_slice(body).unwrap_or(Value::Null);
1412        let hash = req.get("hash").and_then(Value::as_str).unwrap_or("");
1413        let because = req.get("because").and_then(Value::as_str).unwrap_or("");
1414        let bound = self.facade.authz();
1415        let actor = if self.credentials.is_some() {
1416            bound.principal().to_string()
1417        } else {
1418            req.get("actor").and_then(Value::as_str).unwrap_or("user:console").to_string()
1419        };
1420        let actor = actor.as_str();
1421        let allow_destructive = req.get("allow_destructive").and_then(Value::as_bool).unwrap_or(false);
1422        let mut sub = BorrowedSubstrate::new(&self.facade);
1423        let scopes = areev_loop_adapter::scopes_for(&self.facade.authz());
1424        let observer = areev_loop_adapter::observer_for_principal(actor);
1425        let engine = self.engine();
1426        // A code or adapter revision applies only through its recorded gating
1427        // edge: `gating_run` names an eval run whose journaled `mg:eval_run`
1428        // summary becomes the evidence — the stats never come from the client.
1429        let gating = match req.get("gating_run").and_then(Value::as_str) {
1430            Some(run_id) => match engine.gating_evidence(&sub, hash, run_id) {
1431                Ok(g) => Some(g),
1432                Err(e) => {
1433                    return ok_json(json!({"ok": false, "error": e.to_string(), "code": e.code()}))
1434                }
1435            },
1436            None => None,
1437        };
1438        let applied = match &gating {
1439            Some(g) => engine.apply_gated(
1440                &mut sub, hash, actor, observer, &scopes, because, allow_destructive, g, now_ms(),
1441            ),
1442            None => engine.apply(
1443                &mut sub, hash, actor, observer, &scopes, because, allow_destructive, now_ms(),
1444            ),
1445        };
1446        match applied {
1447            Ok(applied) => ok_json(json!({"ok": true, "rollbackable": applied.rollbackable})),
1448            Err(e) => ok_json(json!({"ok": false, "error": e.to_string(), "code": e.code()})),
1449        }
1450    }
1451
1452    fn loop_rollback(&self, body: &[u8]) -> (&'static str, &'static str, Vec<u8>) {
1453        let req: Value = serde_json::from_slice(body).unwrap_or(Value::Null);
1454        let hash = req.get("hash").and_then(Value::as_str).unwrap_or("");
1455        let because = req.get("because").and_then(Value::as_str).unwrap_or("");
1456        let bound = self.facade.authz();
1457        let actor = if self.credentials.is_some() {
1458            bound.principal().to_string()
1459        } else {
1460            req.get("actor").and_then(Value::as_str).unwrap_or("user:console").to_string()
1461        };
1462        let actor = actor.as_str();
1463        let mut sub = BorrowedSubstrate::new(&self.facade);
1464        let scopes = areev_loop_adapter::scopes_for(&self.facade.authz());
1465        let observer = areev_loop_adapter::observer_for_principal(actor);
1466        match self.engine().rollback(
1467            &mut sub, hash, actor, observer, &scopes, because, now_ms(),
1468        ) {
1469            Ok(()) => ok_json(json!({"ok": true})),
1470            Err(e) => ok_json(json!({"ok": false, "error": e.to_string(), "code": e.code()})),
1471        }
1472    }
1473
1474    /// Edit one analyzer's file-config from the Setup view. The body is
1475    /// `{analyzer_id, enabled?, severity_floor?, clear_floor?, params?,
1476    /// namespaces?}`; absent fields are left unchanged. The console holds all
1477    /// scopes (local root of trust), so `Admin` is satisfied; an unknown
1478    /// analyzer or bad param is a structured `ok:false` (not a 500).
1479    fn loop_config(&self, body: &[u8]) -> (&'static str, &'static str, Vec<u8>) {
1480        let id = serde_json::from_slice::<Value>(body)
1481            .ok()
1482            .and_then(|v| v.get("analyzer_id").and_then(Value::as_str).map(str::to_string))
1483            .unwrap_or_default();
1484        // The update reads the same body; the extra `analyzer_id` key is ignored.
1485        let update: areev_loop::AnalyzerConfigUpdate = serde_json::from_slice(body).unwrap_or_default();
1486        let mut sub = BorrowedSubstrate::new(&self.facade);
1487        match self.engine().set_analyzer_config(&mut sub, &id, update, &areev_loop_adapter::scopes_for(&self.facade.authz())) {
1488            Ok(cfg) => ok_json(json!({"ok": true, "config": cfg})),
1489            Err(e) => ok_json(json!({"ok": false, "error": e.to_string(), "code": e.code()})),
1490        }
1491    }
1492}
1493
1494impl UiServer {
1495    /// Declare or clear a per-namespace anonymization policy from the
1496    /// console (Connect → Settings). Rides the same write gate as every
1497    /// console POST (auth + Origin + body cap upstream); the policy itself
1498    /// validates fail-closed in the store.
1499    fn anon_config(&self, body: &[u8]) -> (&'static str, &'static str, Vec<u8>) {
1500        let v: Value = match serde_json::from_slice(body) {
1501            Ok(v) => v,
1502            Err(e) => return ok_json(json!({"ok": false, "error": e.to_string()})),
1503        };
1504        let Some(ns) = v.get("ns").and_then(Value::as_str).filter(|n| !n.trim().is_empty())
1505        else {
1506            return ok_json(json!({"ok": false, "error": "ns is required"}));
1507        };
1508        let result = if v.get("clear").and_then(Value::as_bool) == Some(true) {
1509            self.facade.with_store(|m| m.clear_anon_policy(ns))
1510        } else {
1511            match v.get("policy") {
1512                Some(policy) => {
1513                    let policy_json = policy.to_string();
1514                    self.facade.with_store(|m| m.set_anon_policy(ns, &policy_json))
1515                }
1516                None => {
1517                    return ok_json(json!({"ok": false, "error": "policy (object) or clear: true is required"}));
1518                }
1519            }
1520        };
1521        match result {
1522            Ok(()) => {
1523                let declared = self.facade.with_store(|m| m.anon_declared());
1524                ok_json(json!({"ok": true, "policies": declared
1525                    .iter()
1526                    .map(|(ns, mode)| json!({"ns": ns, "mode": mode}))
1527                    .collect::<Vec<_>>()}))
1528            }
1529            Err(e) => ok_json(json!({"ok": false, "error": e.to_string()})),
1530        }
1531    }
1532}
1533
1534/// True when a `host[:port]` (or `[ipv6]:port`) authority names a loopback
1535/// host. Shared by the Origin drive-by check and the Host-header
1536/// (DNS-rebinding) check.
1537fn host_is_local(host_port: &str) -> bool {
1538    let host = if let Some(h) = host_port.strip_prefix('[') {
1539        h.split(']').next().unwrap_or("")
1540    } else {
1541        host_port.split(':').next().unwrap_or("")
1542    };
1543    matches!(host, "127.0.0.1" | "localhost" | "::1")
1544}
1545
1546/// True when an Origin header names a loopback host (any port, http/https).
1547fn origin_is_local(origin: &str) -> bool {
1548    let rest = origin
1549        .strip_prefix("http://")
1550        .or_else(|| origin.strip_prefix("https://"));
1551    let Some(rest) = rest else { return false };
1552    let host_port = rest.split('/').next().unwrap_or("");
1553    host_is_local(host_port)
1554}
1555
1556fn now_label() -> String {
1557    format!("{}", std::time::SystemTime::now()
1558        .duration_since(std::time::UNIX_EPOCH)
1559        .map(|d| d.as_millis())
1560        .unwrap_or(0))
1561}
1562
1563fn ok_json(v: Value) -> (&'static str, &'static str, Vec<u8>) {
1564    ("200 OK", "application/json", v.to_string().into_bytes())
1565}
1566
1567fn run_api_err(msg: &str) -> (&'static str, &'static str, Vec<u8>) {
1568    (
1569        "400 Bad Request",
1570        "application/json",
1571        serde_json::json!({"ok": false, "error": msg}).to_string().into_bytes(),
1572    )
1573}
1574
1575/// True when a `POST /api/cal` body is a read-only statement. Classification
1576/// is delegated to `areev-cal`'s exhaustive [`areev_cal::classify`] module —
1577/// the single source of truth — so grammar growth cannot desynchronize this
1578/// gate (a keyword sniff lived here before and would have silently
1579/// misclassified any new statement). Fail closed: unparseable text is not
1580/// read-only.
1581fn cal_body_is_read_only(body: &[u8]) -> bool {
1582    let req: Value = serde_json::from_slice(body).unwrap_or(Value::Null);
1583    let q = req.get("query").and_then(Value::as_str).unwrap_or("");
1584    areev_cal::classify::query_is_read_only(q)
1585}
1586
1587fn status_from_str(s: &str) -> Option<RecStatus> {
1588    match s {
1589        "pending" => Some(RecStatus::Pending),
1590        "approved" => Some(RecStatus::Approved),
1591        "rejected" => Some(RecStatus::Rejected),
1592        "applied" => Some(RecStatus::Applied),
1593        "rolled_back" => Some(RecStatus::RolledBack),
1594        "expired" => Some(RecStatus::Expired),
1595        _ => None, // includes "all"
1596    }
1597}
1598
1599fn rec_json(r: &areev_loop::Recommendation) -> Value {
1600    json!({
1601        "hash": r.hash,
1602        "status": r.status.as_str(),
1603        "severity": r.severity.as_str(),
1604        "analyzer": r.analyzer,
1605        "summary": r.summary.render(),
1606        "target_ref": r.target_ref,
1607        "destructive": r.destructive,
1608        "rollbackable": r.rollbackable,
1609        "evidence": r.evidence,
1610        // The Rule E1 pin, when present — the console reads it to know this
1611        // apply needs a recorded gating run (and which evalset to run).
1612        "evalset_hash": r.evalset_hash,
1613    })
1614}
1615
1616fn urldecode(s: &str) -> String {
1617    let mut out = Vec::new();
1618    let b = s.as_bytes();
1619    let mut i = 0;
1620    while i < b.len() {
1621        match b[i] {
1622            b'%' if i + 2 < b.len() + 1 && i + 2 < b.len() + 1 => {
1623                if i + 2 < b.len() + 1 && i + 2 < b.len() {
1624                    let hex = std::str::from_utf8(&b[i + 1..i + 3]).unwrap_or("");
1625                    if let Ok(v) = u8::from_str_radix(hex, 16) {
1626                        out.push(v);
1627                        i += 3;
1628                        continue;
1629                    }
1630                }
1631                out.push(b[i]);
1632                i += 1;
1633            }
1634            b'+' => {
1635                out.push(b' ');
1636                i += 1;
1637            }
1638            c => {
1639                out.push(c);
1640                i += 1;
1641            }
1642        }
1643    }
1644    String::from_utf8_lossy(&out).to_string()
1645}
1646
1647fn html_escape(s: &str) -> String {
1648    s.replace('&', "&amp;").replace('<', "&lt;").replace('>', "&gt;")
1649}
1650
1651/// Constant-time byte comparison — avoids leaking the bearer token through
1652/// response timing. A length mismatch fails fast (token length is not secret).
1653fn ct_eq(a: &[u8], b: &[u8]) -> bool {
1654    if a.len() != b.len() {
1655        return false;
1656    }
1657    let mut diff = 0u8;
1658    for (x, y) in a.iter().zip(b.iter()) {
1659        diff |= x ^ y;
1660    }
1661    diff == 0
1662}
1663
1664/// Decode standard base64 (RFC 4648), enough for HTTP Basic credentials.
1665/// The one hand-rolled decoder lives in `areev_core::b64` (the trigger
1666/// evaluator shares it for connector blobs, #93).
1667fn base64_decode(s: &str) -> Option<Vec<u8>> {
1668    areev_core::b64::decode(s)
1669}
1670
1671/// Extract the password from an HTTP Basic credential (`base64("user:pass")`).
1672/// The console ignores the username and treats the password as the token.
1673/// Returns `None` if the value is not valid base64/UTF-8 or has no `:`.
1674fn basic_auth_password(b64: &str) -> Option<String> {
1675    let text = String::from_utf8(base64_decode(b64)?).ok()?;
1676    text.split_once(':').map(|(_user, pass)| pass.to_string())
1677}
1678
1679/// Sanitize a client-supplied segment name into a single safe filename.
1680/// Strips anything outside `[A-Za-z0-9-._]`, then requires the result to be
1681/// exactly one normal path component — rejecting empty names, `.`/`..`, and
1682/// path separators so a push/pull cannot escape the segment directory.
1683fn safe_segment_name(name: &str) -> Option<String> {
1684    let safe: String = name
1685        .chars()
1686        .filter(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '.' | '_'))
1687        .collect();
1688    if safe.is_empty() || safe.len() > 128 {
1689        return None;
1690    }
1691    let mut comps = std::path::Path::new(&safe).components();
1692    match (comps.next(), comps.next()) {
1693        (Some(std::path::Component::Normal(c)), None) if c.to_str() == Some(safe.as_str()) => {
1694            Some(safe)
1695        }
1696        _ => None,
1697    }
1698}
1699
1700#[cfg(test)]
1701mod loop_route_tests {
1702    use super::UiServer;
1703    use areev_cal::AreevFacade;
1704    use areev_core::types::{Fact, Grain};
1705    use areev_store::Areev;
1706
1707    fn server(auth: Option<&str>) -> UiServer {
1708        let dir = tempfile::tempdir().unwrap();
1709        let path = dir.path().join("t.db");
1710        let mut store = Areev::open(path.to_str().unwrap()).unwrap();
1711        // Two identical facts → a duplicate-consolidation recommendation.
1712        store.add(&Fact::new("acme", "tier", "Enterprise").namespace("caller")).unwrap();
1713        store.add(&Fact::new("acme", "tier", "Enterprise").namespace("caller")).unwrap();
1714        std::mem::forget(dir); // keep the file alive for the server's lifetime
1715        let facade = AreevFacade::with_session(store, Some("caller".into()), None);
1716        let s = UiServer::new(facade, "test".into());
1717        match auth {
1718            Some(t) => s.with_auth(t.to_string()),
1719            None => s,
1720        }
1721    }
1722
1723    fn text(r: &(&str, &str, Vec<u8>)) -> String {
1724        String::from_utf8_lossy(&r.2).to_string()
1725    }
1726
1727    #[test]
1728    fn token_less_console_is_read_only() {
1729        let s = server(None);
1730        // A loop mutation (run) is a write → 401.
1731        assert!(s.route("POST", "/api/loop/run", b"{}", None, None).0.starts_with("401"));
1732        // A write CAL → 401.
1733        let w = s.route("POST", "/api/cal", br#"{"query":"ADD fact SET subject=\"x\""}"#, None, None);
1734        assert!(w.0.starts_with("401"), "write CAL must be 401: {}", text(&w));
1735        // A read CAL → 200.
1736        let r = s.route("POST", "/api/cal", br#"{"query":"RECALL facts WHERE subject = \"acme\""}"#, None, None);
1737        assert!(r.0.starts_with("200"), "read CAL allowed: {}", text(&r));
1738        // Areev Loop reads stay open token-less.
1739        assert!(s.route("GET", "/api/loop/recommendations", b"", None, None).0.starts_with("200"));
1740    }
1741
1742    #[test]
1743    fn telemetry_endpoint_is_an_open_read() {
1744        let s = server(None);
1745        let r = s.route("GET", "/api/loop/telemetry", b"", None, None);
1746        assert!(r.0.starts_with("200"), "telemetry read open: {}", text(&r));
1747        let body = text(&r);
1748        assert!(body.contains("\"ok\":true"), "{body}");
1749        // The test store opens bare (telemetry off) → enabled:false.
1750        assert!(body.contains("\"enabled\":false"), "{body}");
1751    }
1752
1753    #[test]
1754    fn authenticated_run_review_apply_roundtrip() {
1755        let s = server(Some("tok"));
1756        let run = s.route("POST", "/api/loop/run", b"{}", Some("tok"), None);
1757        assert!(run.0.starts_with("200"), "run: {}", text(&run));
1758        assert!(text(&run).contains("\"ran\""));
1759
1760        let list = s.route("GET", "/api/loop/recommendations?status=pending", b"", Some("tok"), None);
1761        let v: serde_json::Value = serde_json::from_slice(&list.2).unwrap();
1762        let recs = v["recommendations"].as_array().unwrap();
1763        assert!(!recs.is_empty(), "at least one recommendation");
1764        let hash = recs[0]["hash"].as_str().unwrap().to_string();
1765
1766        let rev = s.route(
1767            "POST",
1768            "/api/loop/review",
1769            format!(r#"{{"hash":"{hash}","decision":"approve","because":"ok"}}"#).as_bytes(),
1770            Some("tok"),
1771            None,
1772        );
1773        assert!(text(&rev).contains("\"ok\":true"), "review: {}", text(&rev));
1774
1775        let ap = s.route(
1776            "POST",
1777            "/api/loop/apply",
1778            format!(r#"{{"hash":"{hash}","because":"go"}}"#).as_bytes(),
1779            Some("tok"),
1780            None,
1781        );
1782        assert!(text(&ap).contains("\"ok\":true"), "apply: {}", text(&ap));
1783    }
1784
1785    #[test]
1786    fn config_edit_toggles_analyzer_via_console() {
1787        let s = server(Some("tok"));
1788        // Token-less write → 401 (guarded like every POST).
1789        assert!(s.route("POST", "/api/loop/config", b"{}", None, None).0.starts_with("401"));
1790
1791        // Read the analyzers; pick one that is on by default.
1792        let list = s.route("GET", "/api/loop/analyzers", b"", Some("tok"), None);
1793        let v: serde_json::Value = serde_json::from_slice(&list.2).unwrap();
1794        let id = v["analyzers"]
1795            .as_array()
1796            .unwrap()
1797            .iter()
1798            .find(|x| x["enabled"] == true)
1799            .expect("some analyzer is on")["id"]
1800            .as_str()
1801            .unwrap()
1802            .to_string();
1803
1804        // Disable it via the console endpoint.
1805        let post = s.route(
1806            "POST",
1807            "/api/loop/config",
1808            format!(r#"{{"analyzer_id":"{id}","enabled":false}}"#).as_bytes(),
1809            Some("tok"),
1810            None,
1811        );
1812        assert!(text(&post).contains("\"ok\":true"), "config: {}", text(&post));
1813
1814        // It reads back disabled.
1815        let list2 = s.route("GET", "/api/loop/analyzers", b"", Some("tok"), None);
1816        let v2: serde_json::Value = serde_json::from_slice(&list2.2).unwrap();
1817        let now = v2["analyzers"]
1818            .as_array()
1819            .unwrap()
1820            .iter()
1821            .find(|x| x["id"] == id.as_str())
1822            .unwrap();
1823        assert_eq!(now["enabled"], false, "toggled off and persisted");
1824    }
1825
1826    #[test]
1827    fn self_approval_surfaces_lop_code() {
1828        let s = server(Some("tok"));
1829        s.route("POST", "/api/loop/run", b"{}", Some("tok"), None);
1830        let list = s.route("GET", "/api/loop/recommendations?status=pending", b"", Some("tok"), None);
1831        let v: serde_json::Value = serde_json::from_slice(&list.2).unwrap();
1832        let rec = &v["recommendations"].as_array().unwrap()[0];
1833        let hash = rec["hash"].as_str().unwrap();
1834        let analyzer = rec["analyzer"].as_str().unwrap();
1835        let creator = format!("engine:{analyzer}");
1836        // The engine actor approving its own proposal is blocked.
1837        let rev = s.route(
1838            "POST",
1839            "/api/loop/review",
1840            format!(r#"{{"hash":"{hash}","decision":"approve","because":"self","actor":"{creator}"}}"#).as_bytes(),
1841            Some("tok"),
1842            None,
1843        );
1844        assert!(text(&rev).contains("LOP-E021"), "self-approval blocked: {}", text(&rev));
1845    }
1846}
1847
1848#[cfg(test)]
1849mod security_tests {
1850    use super::{base64_decode, basic_auth_password, ct_eq, safe_segment_name};
1851
1852    #[test]
1853    fn ct_eq_matches_only_equal() {
1854        assert!(ct_eq(b"secret-token", b"secret-token"));
1855        assert!(!ct_eq(b"secret-token", b"secret-toker"));
1856        assert!(!ct_eq(b"secret", b"secret-token")); // length mismatch
1857        assert!(ct_eq(b"", b""));
1858    }
1859
1860    #[test]
1861    fn base64_decode_roundtrips_known_vectors() {
1862        // RFC 4648 test vectors (with and without padding).
1863        assert_eq!(base64_decode("").unwrap(), b"");
1864        assert_eq!(base64_decode("Zg==").unwrap(), b"f");
1865        assert_eq!(base64_decode("Zm8=").unwrap(), b"fo");
1866        assert_eq!(base64_decode("Zm9v").unwrap(), b"foo");
1867        assert_eq!(base64_decode("Zm9vYmFy").unwrap(), b"foobar");
1868        // padding is optional for us
1869        assert_eq!(base64_decode("Zg").unwrap(), b"f");
1870        // invalid characters are rejected
1871        assert!(base64_decode("****").is_none());
1872    }
1873
1874    #[test]
1875    fn basic_auth_extracts_password_ignoring_username() {
1876        // base64("areev:s3cret") and base64(":s3cret") both yield the token.
1877        assert_eq!(basic_auth_password("ZGVqYTpzM2NyZXQ=").as_deref(), Some("s3cret"));
1878        assert_eq!(basic_auth_password("OnMzY3JldA==").as_deref(), Some("s3cret"));
1879        // a password may itself contain ':' — only the first ':' splits.
1880        // base64("u:a:b") -> "a:b"
1881        assert_eq!(basic_auth_password("dTphOmI=").as_deref(), Some("a:b"));
1882        // no ':' at all → not a valid Basic credential
1883        assert_eq!(basic_auth_password("bm9jb2xvbg=="), None); // "nocolon"
1884        assert_eq!(basic_auth_password("****"), None); // not base64
1885    }
1886
1887    #[test]
1888    fn safe_segment_name_blocks_traversal() {
1889        assert_eq!(safe_segment_name("push-123.mgb").as_deref(), Some("push-123.mgb"));
1890        assert_eq!(safe_segment_name(".."), None);
1891        assert_eq!(safe_segment_name("."), None);
1892        assert_eq!(safe_segment_name(""), None);
1893        // Separators are stripped, so any accepted name is a single component
1894        // that cannot escape the segment directory.
1895        for probe in ["../../etc/passwd", "/etc/passwd", "a/../b", "foo/bar"] {
1896            if let Some(s) = safe_segment_name(probe) {
1897                assert!(!s.contains('/') && s != ".." && s != ".", "unsafe: {s}");
1898            }
1899        }
1900    }
1901}
1902
1903#[cfg(test)]
1904mod credential_route_tests {
1905    use super::UiServer;
1906    use areev_cal::AreevFacade;
1907    use areev_core::authz::{CredentialMap, AUTHZ_NS, REL_PERMITS};
1908    use areev_core::types::{Fact, Grain};
1909    use areev_store::Areev;
1910
1911    /// The tuning seam over HTTP: an adapter recommendation applies only
1912    /// with `gating_run` in the POST body — evidence loaded from the
1913    /// journaled eval summary, never from the client — and the rec row
1914    /// carries `evalset_hash` so the console knows the apply is gated.
1915    #[test]
1916    fn loop_apply_honors_a_gating_run() {
1917        let dir = tempfile::tempdir().unwrap();
1918        let path = dir.path().join("g.db");
1919        let mut store = Areev::open(path.to_str().unwrap()).unwrap();
1920        // The pin and a clean recorded gate run, as `areev eval` writes them.
1921        let evalset = store
1922            .add(
1923                &Fact::new("evalset:gate", "mg:evalset", "{\"name\":\"gate\",\"cases\":[]}")
1924                    .namespace("agent:harness")
1925                    .created_at(1_000),
1926            )
1927            .unwrap()
1928            .to_hex();
1929        store
1930            .add(
1931                &Fact::new(
1932                    &format!("evalset:{evalset}"),
1933                    "mg:eval_run",
1934                    "{\"run_id\":\"eval-1\",\"passed\":3,\"failed\":0}",
1935                )
1936                .namespace("agent:harness")
1937                .created_at(2_000),
1938            )
1939            .unwrap();
1940        store.add(&Fact::new("acme", "tier", "Enterprise").namespace("caller").created_at(3_000)).unwrap();
1941        std::mem::forget(dir);
1942        let facade = AreevFacade::with_session(store, Some("caller".into()), None);
1943        // Register an adapter with real lineage (a recorded corpus export).
1944        let manifest = facade
1945            .record_corpus_export("RECALL facts", "train.jsonl", None, 4_000, &[], &[])
1946            .unwrap()
1947            .to_hex();
1948        facade
1949            .record_adapter(
1950                r#"{"adapter":{"uri":"file:///tmp/a","sha256":"feed"},"base_model":"qwen3-4b","serves_as":"acme-support"}"#,
1951                &manifest,
1952                &evalset,
1953                5_000,
1954            )
1955            .unwrap();
1956        // Token mode: the shared secret is the implied local owner — the
1957        // token-less console is read-only by design.
1958        let s = UiServer::new(facade, "test".into()).with_auth("gate-token".into());
1959        let tok = Some("gate-token");
1960
1961        // Propose, then find the adapter rec — its row must carry the pin.
1962        let run = s.route("POST", "/api/loop/run", b"{}", tok, None);
1963        assert!(run.0.starts_with("200"), "{}", text(&run));
1964        let list = s.route("GET", "/api/loop/recommendations?status=pending", b"", tok, None);
1965        let rows: serde_json::Value = serde_json::from_str(&text(&list)).unwrap();
1966        let rec = rows["recommendations"]
1967            .as_array()
1968            .unwrap()
1969            .iter()
1970            .find(|r| r["analyzer"].as_str().unwrap_or("").contains("adapter_intake"))
1971            .unwrap_or_else(|| panic!("adapter_intake must propose: {rows}"));
1972        assert_eq!(rec["evalset_hash"], evalset.as_str(), "the row must carry the pin");
1973        let hash = rec["hash"].as_str().unwrap().to_string();
1974
1975        // Approve (the console's first POST), then: ungated apply refused,
1976        // a bad run id refused, the recorded run admits.
1977        let approve = format!(r#"{{"hash":"{hash}","decision":"approve","because":"reviewed"}}"#);
1978        let r = s.route("POST", "/api/loop/review", approve.as_bytes(), tok, None);
1979        assert!(text(&r).contains("\"ok\":true"), "{}", text(&r));
1980        let bare = format!(r#"{{"hash":"{hash}","because":"ship it"}}"#);
1981        let r = s.route("POST", "/api/loop/apply", bare.as_bytes(), tok, None);
1982        assert!(text(&r).contains("gating run"), "ungated must refuse: {}", text(&r));
1983        let bad = format!(r#"{{"hash":"{hash}","because":"ship","gating_run":"eval-nope"}}"#);
1984        let r = s.route("POST", "/api/loop/apply", bad.as_bytes(), tok, None);
1985        assert!(text(&r).contains("no recorded gate run"), "{}", text(&r));
1986        let good = format!(r#"{{"hash":"{hash}","because":"gated and green","gating_run":"eval-1"}}"#);
1987        let r = s.route("POST", "/api/loop/apply", good.as_bytes(), tok, None);
1988        assert!(text(&r).contains("\"ok\":true"), "gated apply failed: {}", text(&r));
1989    }
1990
1991    /// A server in credential-map mode: two env-referenced tokens, rights
1992    /// seeded as grant grains in the file itself.
1993    fn server() -> UiServer {
1994        std::env::set_var("AREEV_TEST_READER_TOK", "reader-secret");
1995        std::env::set_var("AREEV_TEST_WRITER_TOK", "writer-secret");
1996        let dir = tempfile::tempdir().unwrap();
1997        let path = dir.path().join("t.db");
1998        let mut store = Areev::open(path.to_str().unwrap()).unwrap();
1999        store
2000            .add(&Fact::new("user:reader", REL_PERMITS, "read ON *").namespace(AUTHZ_NS).created_at(1_000))
2001            .unwrap();
2002        store
2003            .add(
2004                &Fact::new("agent:writer", REL_PERMITS, "read,write ON caller")
2005                    .namespace(AUTHZ_NS)
2006                    .created_at(2_000),
2007            )
2008            .unwrap();
2009        store.add(&Fact::new("acme", "tier", "Enterprise").namespace("caller").created_at(3_000)).unwrap();
2010        std::mem::forget(dir);
2011        let facade = AreevFacade::with_session(store, Some("caller".into()), None);
2012        let map = CredentialMap::from_json(
2013            r#"{"version":1,"tokens":[
2014                {"env":"AREEV_TEST_READER_TOK","principal":"user:reader"},
2015                {"env":"AREEV_TEST_WRITER_TOK","principal":"agent:writer"}
2016            ]}"#,
2017        )
2018        .unwrap();
2019        UiServer::new(facade, "test".into()).with_credentials(map)
2020    }
2021
2022    fn text(r: &(&str, &str, Vec<u8>)) -> String {
2023        String::from_utf8_lossy(&r.2).to_string()
2024    }
2025
2026    const READ: &[u8] = br#"{"query":"RECALL facts WHERE subject = \"acme\""}"#;
2027    const WRITE: &[u8] = br#"{"query":"ADD fact SET subject = \"x\" SET relation = \"r\" SET object = \"o\" SET namespace = \"caller\" REASON \"t\""}"#;
2028
2029    #[test]
2030    fn tokens_bind_principals_and_verbs_decide() {
2031        let s = server();
2032
2033        // The reader reads…
2034        let r = s.route("POST", "/api/cal", READ, Some("reader-secret"), None);
2035        assert!(r.0.starts_with("200"), "{}", text(&r));
2036        assert!(text(&r).contains("grains"));
2037        // …and cannot write.
2038        let w = s.route("POST", "/api/cal", WRITE, Some("reader-secret"), None);
2039        assert!(text(&w).contains("AUT-E001"), "reader write must be refused: {}", text(&w));
2040
2041        // The writer writes.
2042        let w = s.route("POST", "/api/cal", WRITE, Some("writer-secret"), None);
2043        assert!(text(&w).contains("added"), "writer write must land: {}", text(&w));
2044
2045        // Sequential isolation: the very next reader request is still
2046        // read-only — nothing leaked from the writer's binding.
2047        let w = s.route("POST", "/api/cal", WRITE, Some("reader-secret"), None);
2048        assert!(text(&w).contains("AUT-E001"), "{}", text(&w));
2049    }
2050
2051    #[test]
2052    fn anonymous_is_read_only_and_loop_run_is_verb_gated() {
2053        let s = server();
2054        // No token → anonymous: reads work…
2055        let r = s.route("POST", "/api/cal", READ, None, None);
2056        assert!(r.0.starts_with("200"), "{}", text(&r));
2057        assert!(text(&r).contains("grains"));
2058        // …writes are verb-refused…
2059        let w = s.route("POST", "/api/cal", WRITE, None, None);
2060        assert!(text(&w).contains("AUT-E001"), "{}", text(&w));
2061        // …and an unknown token is anonymous too, never an escalation.
2062        let w = s.route("POST", "/api/cal", WRITE, Some("stolen-guess"), None);
2063        assert!(text(&w).contains("AUT-E001"), "{}", text(&w));
2064
2065        // The analysis trigger needs loop.run — anonymous gets 403.
2066        let run = s.route("POST", "/api/loop/run", b"{}", None, None);
2067        assert!(run.0.starts_with("403"), "{}", text(&run));
2068    }
2069
2070    #[test]
2071    fn whoami_reports_the_bound_principal() {
2072        let s = server();
2073        let r = s.route("GET", "/api/whoami", b"", Some("reader-secret"), None);
2074        assert!(text(&r).contains("user:reader"), "{}", text(&r));
2075        assert!(text(&r).contains("credentials"));
2076        let r = s.route("GET", "/api/whoami", b"", None, None);
2077        assert!(text(&r).contains("anonymous"), "{}", text(&r));
2078    }
2079
2080    /// A credential map must not silently disable `--token-env`. Attaching
2081    /// one changes WHO a request is; the operator who also asked for a
2082    /// shared secret still gets authentication on every request.
2083    #[test]
2084    fn a_credential_map_does_not_disable_the_shared_secret() {
2085        let s = server().with_auth("console-token".into());
2086
2087        // No credential at all: refused, not dropped to `anonymous`.
2088        let r = s.route("POST", "/api/cal", READ, None, None);
2089        assert!(r.0.starts_with("401"), "{}", text(&r));
2090        let page = s.route("GET", "/", b"", None, None);
2091        assert!(page.0.starts_with("401"), "the console page is guarded too");
2092        // An unrecognized token is refused rather than downgraded.
2093        let r = s.route("POST", "/api/cal", READ, Some("stolen-guess"), None);
2094        assert!(r.0.starts_with("401"), "{}", text(&r));
2095
2096        // A map token still binds its principal and its verbs still decide.
2097        let r = s.route("POST", "/api/cal", READ, Some("reader-secret"), None);
2098        assert!(r.0.starts_with("200"), "{}", text(&r));
2099        let w = s.route("POST", "/api/cal", WRITE, Some("reader-secret"), None);
2100        assert!(text(&w).contains("AUT-E001"), "{}", text(&w));
2101
2102        // The shared secret is the implied admin (docs/cal-all-you-need
2103        // -proposal.md §5.2b) — it keeps the owner session it always had.
2104        let w = s.route("POST", "/api/cal", WRITE, Some("console-token"), None);
2105        assert!(text(&w).contains("added"), "{}", text(&w));
2106    }
2107
2108    /// `RUN LOOP` through CAL is gated exactly as `POST /api/loop/run` is —
2109    /// otherwise the CAL surface is a way around the HTTP gate.
2110    #[test]
2111    fn run_loop_via_cal_is_verb_gated_like_the_route() {
2112        let s = server();
2113        let run = s.route("POST", "/api/loop/run", b"{}", None, None);
2114        assert!(run.0.starts_with("403"), "{}", text(&run));
2115        let cal = s.route("POST", "/api/cal", br#"{"query":"RUN LOOP"}"#, None, None);
2116        assert!(
2117            text(&cal).contains("AUT-E001"),
2118            "anonymous must not run the loop through CAL either: {}",
2119            text(&cal)
2120        );
2121    }
2122}
2123
2124#[cfg(test)]
2125mod namespace_route_tests {
2126    use super::UiServer;
2127    use areev_cal::AreevFacade;
2128    use areev_core::authz::{CredentialMap, AUTHZ_NS, REL_PERMITS};
2129    use areev_core::types::{Fact, Grain};
2130    use areev_store::Areev;
2131
2132    fn text(r: &(&str, &str, Vec<u8>)) -> String {
2133        String::from_utf8_lossy(&r.2).to_string()
2134    }
2135
2136    /// `agent:writer` is granted read+write on `caller` only — nothing on
2137    /// `other`, nothing on the audit namespace.
2138    fn restricted_server() -> UiServer {
2139        std::env::set_var("AREEV_TEST_NS_WRITER_TOK", "writer-secret");
2140        let dir = tempfile::tempdir().unwrap();
2141        let path = dir.path().join("t.db");
2142        let mut store = Areev::open(path.to_str().unwrap()).unwrap();
2143        store
2144            .add(&Fact::new("agent:writer", REL_PERMITS, "read,write ON caller").namespace(AUTHZ_NS).created_at(1_000))
2145            .unwrap();
2146        store.add(&Fact::new("acme", "tier", "Enterprise").namespace("caller").created_at(2_000)).unwrap();
2147        store.add(&Fact::new("bob", "role", "admin").namespace("other").created_at(3_000)).unwrap();
2148        std::mem::forget(dir);
2149        let facade = AreevFacade::with_session(store, Some("caller".into()), None);
2150        let map = CredentialMap::from_json(
2151            r#"{"version":1,"tokens":[{"env":"AREEV_TEST_NS_WRITER_TOK","principal":"agent:writer"}]}"#,
2152        )
2153        .unwrap();
2154        UiServer::new(facade, "test".into()).with_credentials(map)
2155    }
2156
2157    /// Unbound local open — the implicit owner, unrestricted on every
2158    /// namespace, same as a plain `areev ui` with no `--auth`.
2159    fn owner_server() -> UiServer {
2160        let dir = tempfile::tempdir().unwrap();
2161        let path = dir.path().join("t.db");
2162        let mut store = Areev::open(path.to_str().unwrap()).unwrap();
2163        store.add(&Fact::new("acme", "tier", "Enterprise").namespace("caller").created_at(1_000)).unwrap();
2164        store.add(&Fact::new("bob", "role", "admin").namespace("other").created_at(2_000)).unwrap();
2165        std::mem::forget(dir);
2166        UiServer::new(AreevFacade::with_session(store, Some("caller".into()), None), "t".into())
2167    }
2168
2169    #[test]
2170    fn namespaces_route_hides_what_the_principal_cannot_read() {
2171        let s = restricted_server();
2172        let r = s.route("GET", "/api/namespaces", b"", Some("writer-secret"), None);
2173        let body = text(&r);
2174        assert!(body.contains("\"caller\""), "granted namespace must be listed: {body}");
2175        assert!(!body.contains("\"other\""), "ungranted namespace must not be listed: {body}");
2176        assert!(!body.contains(AUTHZ_NS), "the audit namespace must not leak either: {body}");
2177    }
2178
2179    #[test]
2180    fn namespaces_route_lists_everything_for_the_owner() {
2181        let s = owner_server();
2182        let r = s.route("GET", "/api/namespaces", b"", None, None);
2183        let body = text(&r);
2184        assert!(body.contains("\"caller\"") && body.contains("\"other\""), "{body}");
2185    }
2186
2187    #[test]
2188    fn browse_ns_param_is_read_gated_for_a_restricted_principal() {
2189        let s = restricted_server();
2190        let ok = s.route("GET", "/api/browse?ns=caller", b"", Some("writer-secret"), None);
2191        assert!(text(&ok).contains("\"ok\":true"), "their own granted namespace must work: {}", text(&ok));
2192
2193        let denied = s.route("GET", "/api/browse?ns=other", b"", Some("writer-secret"), None);
2194        let body = text(&denied);
2195        assert!(body.contains("\"ok\":false"), "an ungranted namespace must be refused, not silently emptied: {body}");
2196    }
2197
2198    #[test]
2199    fn browse_ns_star_requires_owner() {
2200        let s = restricted_server();
2201        let r = s.route("GET", "/api/browse?ns=*", b"", Some("writer-secret"), None);
2202        assert!(text(&r).contains("\"ok\":false"), "a restricted principal must not get the unfiltered view: {}", text(&r));
2203
2204        let owner = owner_server();
2205        let r2 = owner.route("GET", "/api/browse?ns=*", b"", None, None);
2206        let body2 = text(&r2);
2207        assert!(body2.contains("\"ok\":true"), "the owner session may lift the filter: {body2}");
2208        assert!(body2.contains("\"caller\"") && body2.contains("\"other\""), "{body2}");
2209    }
2210
2211    #[test]
2212    fn browse_without_ns_param_keeps_original_session_default_behavior() {
2213        let s = owner_server();
2214        let r = s.route("GET", "/api/browse", b"", None, None);
2215        let body = text(&r);
2216        assert!(body.contains("\"caller\""), "{body}");
2217        assert!(!body.contains("\"other\""), "omitting ?ns must behave exactly as before this feature existed: {body}");
2218    }
2219}
2220
2221#[cfg(test)]
2222mod builder_tests {
2223    use super::UiServer;
2224    use areev_cal::AreevFacade;
2225    use areev_store::Areev;
2226
2227    fn server() -> UiServer {
2228        let dir = tempfile::tempdir().unwrap();
2229        let path = dir.path().join("t.db");
2230        let store = Areev::open(path.to_str().unwrap()).unwrap();
2231        std::mem::forget(dir);
2232        UiServer::new(AreevFacade::with_session(store, Some("caller".into()), None), "t".into())
2233    }
2234
2235    /// `areev ui --no-destructive-ops --policy FILE` calls these in this
2236    /// order. A later builder must not re-enable destruction the operator
2237    /// turned off — the console was announced as read-only.
2238    #[test]
2239    fn attaching_a_loop_policy_preserves_the_destructive_cap() {
2240        let s = server()
2241            .allow_destructive_ops(false)
2242            .with_loop_policy(areev_loop::Policy::default());
2243        let r = s.route(
2244            "POST",
2245            "/api/cal",
2246            br#"{"query":"FORGET sha256:684c6c9bda818630a870119d0726e4d242ed537af061658ef6f3acb158a2c67d"}"#,
2247            None,
2248            None,
2249        );
2250        let body = String::from_utf8_lossy(&r.2).to_string();
2251        assert!(
2252            body.contains("--no-destructive-ops") || r.0.starts_with("401"),
2253            "destruction must stay disabled: {body}"
2254        );
2255    }
2256}
2257
2258#[cfg(test)]
2259mod run_api_tests {
2260    use super::UiServer;
2261    use areev_cal::AreevFacade;
2262    use areev_core::authz::{CredentialMap, AUTHZ_NS, REL_PERMITS};
2263    use areev_core::types::{Fact, Grain, Tool, ToolKind, Workflow};
2264    use areev_store::Areev;
2265    use std::sync::Arc;
2266
2267    /// A server whose file holds a PARKED run (Client-gated approve node) —
2268    /// the HITL queue's fixture. Returns (server, ask id).
2269    fn parked_server() -> (UiServer, String) {
2270        std::env::set_var("AREEV_RUN_OFFICER_TOK", "officer-secret");
2271        std::env::set_var("AREEV_RUN_STARTER_TOK", "starter-secret");
2272        let dir = tempfile::tempdir().unwrap();
2273        let path = dir.path().join("t.db");
2274        let mut store = Areev::open(path.to_str().unwrap()).unwrap();
2275        for (who, what) in [
2276            ("user:officer", "read,run.respond ON ops"),
2277            ("user:starter", "read ON ops"),
2278        ] {
2279            store
2280                .add(&Fact::new(who, REL_PERMITS, what).namespace(AUTHZ_NS).created_at(900))
2281                .unwrap();
2282        }
2283        std::mem::forget(dir);
2284        let facade = Arc::new(AreevFacade::with_session(store, Some("ops".into()), None));
2285
2286        // Seed + run the plan to the park with a scripted runner.
2287        let (wf, _g, _a) = facade
2288            .with_store(|m| {
2289                let greet = Tool::new("greet")
2290                    .kind(ToolKind::Definition)
2291                    .tool_description("t")
2292                    .created_at(500)
2293                    .namespace("ops");
2294                let gh = m.add(&greet)?;
2295                let approve = Tool::new("approve")
2296                    .kind(ToolKind::Definition)
2297                    .tool_description("human approves")
2298                    .executor_kind(areev_core::types::ExecutorKind::Client)
2299                    .created_at(501)
2300                    .namespace("ops");
2301                let ah = m.add(&approve)?;
2302                let wf = Workflow::new(vec!["greet".into(), "approve".into()])
2303                    .edge("greet", "approve")
2304                    .bind("greet", &gh.to_hex())
2305                    .bind("approve", &ah.to_hex())
2306                    .created_at(502)
2307                    .namespace("ops");
2308                let wh = m.add(&wf)?;
2309                Ok::<_, areev_core::error::AreevError>((wh, gh, ah))
2310            })
2311            .unwrap();
2312        struct Ok1;
2313        impl areev_run::HostToolExecutor for Ok1 {
2314            fn execute(
2315                &self,
2316                t: &str,
2317                _h: &str,
2318                _i: &serde_json::Value,
2319                _k: &str,
2320            ) -> areev_run::ExecResult {
2321                areev_run::ExecResult::Ok(serde_json::json!({t.to_string(): true}))
2322            }
2323        }
2324        let runner = areev_run::Runner {
2325            facade: Arc::clone(&facade),
2326            clock: Arc::new(areev_run::ScriptedClock::new(
2327                (0..100).map(|i| 1_758_000_000_000 + i * 10).collect(),
2328            )),
2329            executor: Arc::new(Ok1),
2330            llm: None,
2331            observer: None,
2332            ns: "ops".into(),
2333            principal: "user:starter".into(),
2334        };
2335        let session = runner
2336            .start(&wf, "hitl-1", serde_json::json!({}), &areev_run::RunOptions {
2337                workers: 1,
2338                ..Default::default()
2339            })
2340            .unwrap();
2341        let areev_run::RunSession::Parked { envelope, .. } = session else {
2342            panic!("expected park")
2343        };
2344        let ask = envelope["asks"][0]["tool_call_id"].as_str().unwrap().to_string();
2345
2346        // Rebuild a facade-owning server over the SAME file is impossible
2347        // (single writer) — hand the server the same Arc'd facade via the
2348        // Arc-aware constructor path: UiServer::new takes AreevFacade, so
2349        // we build the server FIRST in real deployments; here we reuse the
2350        // internal Arc through a second UiServer field write. Instead:
2351        // drop the runner (facade Arc count drops) and unwrap.
2352        drop(runner);
2353        let facade = Arc::try_unwrap(facade).unwrap_or_else(|_| panic!("facade still shared"));
2354        let map = CredentialMap::from_json(
2355            r#"{"version":1,"tokens":[
2356                {"env":"AREEV_RUN_OFFICER_TOK","principal":"user:officer"},
2357                {"env":"AREEV_RUN_STARTER_TOK","principal":"user:starter"}
2358            ]}"#,
2359        )
2360        .unwrap();
2361        let server = UiServer::new(facade, "test".into())
2362            .with_auth("shared-console-secret".into())
2363            .with_credentials(map);
2364        (server, ask)
2365    }
2366
2367    fn body(run_id: &str, ask: &str) -> Vec<u8> {
2368        serde_json::json!({"run_id": run_id, "tool_call_id": ask, "result": {"approved": true}})
2369            .to_string()
2370            .into_bytes()
2371    }
2372
2373    #[test]
2374    fn shared_token_approvals_are_refused_and_principals_approve() {
2375        let (s, ask) = parked_server();
2376
2377        // The HITL queue is visible.
2378        let r = s.route("GET", "/api/run/list", b"", Some("shared-console-secret"), None);
2379        assert!(r.0.starts_with("200"), "{}", String::from_utf8_lossy(&r.2));
2380        let r = s.route(
2381            "GET",
2382            "/api/run/inspect?run_id=hitl-1",
2383            b"",
2384            Some("shared-console-secret"),
2385            None,
2386        );
2387        assert!(String::from_utf8_lossy(&r.2).contains(&ask), "ask visible in inspect");
2388
2389        // 1. The SHARED console secret cannot approve — identity is the point.
2390        let r = s.route("POST", "/api/run/respond", &body("hitl-1", &ask), Some("shared-console-secret"), None);
2391        assert!(r.0.starts_with("403"), "shared-token approval must refuse: {}", r.0);
2392
2393        // 2. The STARTER's own credential cannot approve their own run
2394        //    (separation of duties, enforced by the runtime).
2395        let r = s.route("POST", "/api/run/respond", &body("hitl-1", &ask), Some("starter-secret"), None);
2396        let text = String::from_utf8_lossy(&r.2).to_string();
2397        assert!(text.contains("responder") || text.contains("RUN-E012"), "{text}");
2398
2399        // 3. The officer's principal-bound credential approves.
2400        let r = s.route("POST", "/api/run/respond", &body("hitl-1", &ask), Some("officer-secret"), None);
2401        assert!(r.0.starts_with("200"), "{}", String::from_utf8_lossy(&r.2));
2402        assert!(String::from_utf8_lossy(&r.2).contains("user:officer"));
2403
2404        // 4. The kill switch keeps its low bar: shared token may cancel.
2405        let r = s.route(
2406            "POST",
2407            "/api/run/cancel",
2408            br#"{"run_id":"hitl-1","because":"test brake"}"#,
2409            Some("shared-console-secret"),
2410            None,
2411        );
2412        assert!(r.0.starts_with("200"), "{}", String::from_utf8_lossy(&r.2));
2413    }
2414}
2415
2416#[cfg(test)]
2417mod memory_scoped_token_tests {
2418    use super::UiServer;
2419    use areev_cal::AreevFacade;
2420    use areev_core::authz::{CredentialMap, AUTHZ_NS, REL_PERMITS};
2421    use areev_core::types::{Fact, Grain};
2422    use areev_store::Areev;
2423
2424    /// The enterprise-plane hub rule: one auth file, per-memory token
2425    /// scopes — a token listed for memory A is an UNKNOWN token on
2426    /// memory B's server.
2427    #[test]
2428    fn memory_scoped_tokens_do_not_cross_memories() {
2429        std::env::set_var("AREEV_SCOPED_TOK", "scoped-secret");
2430        let map_json = r#"{"version":1,"tokens":[
2431            {"env":"AREEV_SCOPED_TOK","principal":"agent:sync","memories":["memory-a"]}
2432        ]}"#;
2433        let server_for = |label: &str| {
2434            let dir = tempfile::tempdir().unwrap();
2435            let mut store = Areev::open(dir.path().join("t.db").to_str().unwrap()).unwrap();
2436            store
2437                .add(
2438                    &Fact::new("agent:sync", REL_PERMITS, "read,write ON caller")
2439                        .namespace(AUTHZ_NS)
2440                        .created_at(1_000),
2441                )
2442                .unwrap();
2443            store
2444                .add(&Fact::new("acme", "tier", "Enterprise").namespace("caller").created_at(2_000))
2445                .unwrap();
2446            std::mem::forget(dir);
2447            let facade = AreevFacade::with_session(store, Some("caller".into()), None);
2448            UiServer::new(facade, label.into())
2449                .with_credentials(CredentialMap::from_json(map_json).unwrap())
2450        };
2451        const WRITE: &[u8] = br#"{"query":"ADD fact SET subject = \"x\" SET relation = \"r\" SET object = \"o\" SET namespace = \"caller\" REASON \"t\""}"#;
2452
2453        // On memory-a the token authenticates and its grants let the write
2454        // through (no authorization refusal in the CAL payload).
2455        let a = server_for("memory-a");
2456        let r = a.route("POST", "/api/cal", WRITE, Some("scoped-secret"), None);
2457        let text = String::from_utf8_lossy(&r.2).to_string();
2458        assert!(r.0.starts_with("200") && !text.contains("AUT-E001"), "{text}");
2459
2460        // On memory-b the SAME token is an unknown credential: the request
2461        // runs as anonymous and the write is refused (AUT-E001 rides the
2462        // CAL payload — the console surfaces statement results in-band).
2463        let b = server_for("memory-b");
2464        let r = b.route("POST", "/api/cal", WRITE, Some("scoped-secret"), None);
2465        let text = String::from_utf8_lossy(&r.2).to_string();
2466        assert!(
2467            text.contains("AUT-E001") && text.contains("anonymous"),
2468            "scoped token must not cross memories: {text}"
2469        );
2470
2471        // An auth file with an EMPTY memories scope refuses to load.
2472        assert!(CredentialMap::from_json(
2473            r#"{"version":1,"tokens":[{"env":"AREEV_SCOPED_TOK","principal":"x","memories":[]}]}"#
2474        )
2475        .is_err());
2476    }
2477}
2478
2479#[cfg(all(test, feature = "tls"))]
2480mod tls_tests {
2481    use super::UiServer;
2482    use areev_cal::AreevFacade;
2483    use areev_store::Areev;
2484    use std::io::{Read, Write};
2485
2486    fn self_signed(dir: &std::path::Path) -> Option<(String, String)> {
2487        let cert = dir.join("cert.pem");
2488        let key = dir.join("key.pem");
2489        let status = std::process::Command::new("openssl")
2490            .args([
2491                "req", "-x509", "-newkey", "rsa:2048", "-nodes",
2492                "-keyout", key.to_str().unwrap(), "-out", cert.to_str().unwrap(),
2493                "-days", "1", "-subj", "/CN=localhost",
2494                "-addext", "subjectAltName=DNS:localhost,IP:127.0.0.1",
2495                "-addext", "basicConstraints=critical,CA:FALSE",
2496            ])
2497            .stdout(std::process::Stdio::null())
2498            .stderr(std::process::Stdio::null())
2499            .status();
2500        match status {
2501            Ok(s) if s.success() => Some((
2502                cert.to_str().unwrap().to_string(),
2503                key.to_str().unwrap().to_string(),
2504            )),
2505            _ => None, // no openssl on this host: the CI leg covers it
2506        }
2507    }
2508
2509    /// The §3.2 gate: a TLS round-trip succeeds, and a PLAINTEXT client on
2510    /// the same listener is refused (no silent downgrade).
2511    #[test]
2512    fn tls_round_trip_and_no_plaintext_downgrade() {
2513        let dir = tempfile::tempdir().unwrap();
2514        let Some((cert, key)) = self_signed(dir.path()) else {
2515            eprintln!("skipping: no openssl binary");
2516            return;
2517        };
2518        let store = Areev::open(dir.path().join("t.db").to_str().unwrap()).unwrap();
2519        let facade = AreevFacade::with_session(store, Some("caller".into()), None);
2520        let server = UiServer::new(facade, "tls-test".into())
2521            .with_tls(&cert, &key)
2522            .unwrap();
2523        let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
2524        let addr = listener.local_addr().unwrap();
2525        let handle = std::thread::spawn(move || {
2526            // Two connections: the TLS client, then the plaintext prober.
2527            for _ in 0..2 {
2528                if let Ok((s, _)) = listener.accept() {
2529                    let _ = server.handle(s);
2530                }
2531            }
2532        });
2533
2534        // 1. TLS round-trip: trust exactly our self-signed cert.
2535        let mut roots = rustls::RootCertStore::empty();
2536        {
2537            use rustls::pki_types::pem::PemObject;
2538            for c in rustls::pki_types::CertificateDer::pem_file_iter(&cert).unwrap() {
2539                roots.add(c.unwrap()).unwrap();
2540            }
2541        }
2542        let config = std::sync::Arc::new(
2543            rustls::ClientConfig::builder()
2544                .with_root_certificates(roots)
2545                .with_no_client_auth(),
2546        );
2547        let name = rustls::pki_types::ServerName::try_from("localhost").unwrap();
2548        let session = rustls::ClientConnection::new(config, name).unwrap();
2549        let tcp = std::net::TcpStream::connect(addr).unwrap();
2550        let mut tls = rustls::StreamOwned::new(session, tcp);
2551        tls.write_all(b"GET / HTTP/1.1\r\nHost: 127.0.0.1\r\nConnection: close\r\n\r\n")
2552            .unwrap();
2553        let mut response = String::new();
2554        let _ = tls.read_to_string(&mut response);
2555        assert!(response.starts_with("HTTP/1.1 200"), "{}", &response[..response.len().min(120)]);
2556        assert!(response.contains("Areev"), "console served over TLS");
2557
2558        // 2. No downgrade: plaintext HTTP on the TLS listener gets a
2559        // handshake failure, never a page.
2560        let mut plain = std::net::TcpStream::connect(addr).unwrap();
2561        plain
2562            .write_all(b"GET / HTTP/1.1\r\nHost: 127.0.0.1\r\nConnection: close\r\n\r\n")
2563            .unwrap();
2564        let mut buf = Vec::new();
2565        let _ = plain.read_to_end(&mut buf);
2566        let text = String::from_utf8_lossy(&buf);
2567        assert!(
2568            !text.contains("HTTP/1.1 200"),
2569            "plaintext must not be served on a TLS listener: {text}"
2570        );
2571        handle.join().unwrap();
2572    }
2573
2574    /// `with_tls` composes with `into_hub` the same way it does with plain
2575    /// `ui` mode — `into_hub` only sets the token/segment-dir/allow-remote
2576    /// fields, not the connection layer, so a hub server terminates TLS
2577    /// identically. Pins the CLI wiring that lets `areev hub --tls-cert
2578    /// ... --tls-key ...` work, not just `areev ui`.
2579    #[test]
2580    fn hub_mode_terminates_tls_the_same_way_as_ui_mode() {
2581        let dir = tempfile::tempdir().unwrap();
2582        let Some((cert, key)) = self_signed(dir.path()) else {
2583            eprintln!("skipping: no openssl binary");
2584            return;
2585        };
2586        let store = Areev::open(dir.path().join("t.db").to_str().unwrap()).unwrap();
2587        let facade = AreevFacade::with_session(store, Some("caller".into()), None);
2588        let segments = dir.path().join("segments");
2589        let server = UiServer::new(facade, "hub-tls-test".into())
2590            .into_hub(Some("hub-token".into()), segments.to_str().unwrap())
2591            .unwrap()
2592            .with_tls(&cert, &key)
2593            .unwrap();
2594        let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
2595        let addr = listener.local_addr().unwrap();
2596        let handle = std::thread::spawn(move || {
2597            if let Ok((s, _)) = listener.accept() {
2598                let _ = server.handle(s);
2599            }
2600        });
2601
2602        let mut roots = rustls::RootCertStore::empty();
2603        {
2604            use rustls::pki_types::pem::PemObject;
2605            for c in rustls::pki_types::CertificateDer::pem_file_iter(&cert).unwrap() {
2606                roots.add(c.unwrap()).unwrap();
2607            }
2608        }
2609        let config = std::sync::Arc::new(
2610            rustls::ClientConfig::builder()
2611                .with_root_certificates(roots)
2612                .with_no_client_auth(),
2613        );
2614        let name = rustls::pki_types::ServerName::try_from("localhost").unwrap();
2615        let session = rustls::ClientConnection::new(config, name).unwrap();
2616        let tcp = std::net::TcpStream::connect(addr).unwrap();
2617        let mut tls = rustls::StreamOwned::new(session, tcp);
2618        tls.write_all(b"GET / HTTP/1.1\r\nHost: 127.0.0.1\r\nConnection: close\r\n\r\n")
2619            .unwrap();
2620        let mut response = String::new();
2621        let _ = tls.read_to_string(&mut response);
2622        assert!(
2623            response.starts_with("HTTP/1.1 200"),
2624            "hub should terminate TLS the same way ui does: {}",
2625            &response[..response.len().min(120)]
2626        );
2627        handle.join().unwrap();
2628    }
2629}
2630
2631#[cfg(test)]
2632mod sso_tests {
2633    use super::UiServer;
2634    use areev_cal::AreevFacade;
2635    use areev_core::authz::{AUTHZ_NS, REL_PERMITS};
2636    use areev_core::types::{Fact, Grain};
2637    use areev_store::Areev;
2638    use std::io::{Read, Write};
2639
2640    const WRITE: &[u8] = br#"{"query":"ADD fact SET subject = \"x\" SET relation = \"r\" SET object = \"o\" SET namespace = \"caller\" REASON \"t\""}"#;
2641
2642    fn sso_server() -> UiServer {
2643        sso_server_with("proxy-secret", None)
2644    }
2645
2646    /// The same server mid-rotation: `proxy-secret` deployed, `next` incoming.
2647    fn sso_server_rotating(next: Option<&str>) -> UiServer {
2648        sso_server_with("proxy-secret", next)
2649    }
2650
2651    fn sso_server_with(secret: &str, next: Option<&str>) -> UiServer {
2652        let dir = tempfile::tempdir().unwrap();
2653        let mut store = Areev::open(dir.path().join("t.db").to_str().unwrap()).unwrap();
2654        store
2655            .add(
2656                &Fact::new("user:pat@example.com", REL_PERMITS, "read,write ON caller")
2657                    .namespace(AUTHZ_NS)
2658                    .created_at(1_000),
2659            )
2660            .unwrap();
2661        std::mem::forget(dir);
2662        let facade = AreevFacade::with_session(store, Some("caller".into()), None);
2663        UiServer::new(facade, "sso-test".into())
2664            .with_sso_rotating("x-forwarded-user", secret, next)
2665    }
2666
2667    fn post(server: &UiServer, extra_headers: &str) -> String {
2668        let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
2669        let addr = listener.local_addr().unwrap();
2670        std::thread::scope(|scope| {
2671            scope.spawn(|| {
2672                if let Ok((s, _)) = listener.accept() {
2673                    let _ = server.handle(s);
2674                }
2675            });
2676            let mut c = std::net::TcpStream::connect(addr).unwrap();
2677            let req = format!(
2678                "POST /api/cal HTTP/1.1\r\nHost: 127.0.0.1\r\nContent-Type: application/json\r\n{extra_headers}Content-Length: {}\r\nConnection: close\r\n\r\n",
2679                WRITE.len()
2680            );
2681            c.write_all(req.as_bytes()).unwrap();
2682            c.write_all(WRITE).unwrap();
2683            let mut out = String::new();
2684            let _ = c.read_to_string(&mut out);
2685            out
2686        })
2687    }
2688
2689    /// The §3.3 gate: a proxy-proven identity lands as a named actor whose
2690    /// FILE grants decide; a forged identity header without the proxy
2691    /// secret is ignored (the write is refused as anonymous).
2692    #[test]
2693    fn proxied_identity_works_and_forged_headers_are_ignored() {
2694        let server = sso_server();
2695
2696        // 1. Proxy-proven: identity + secret → the granted principal writes.
2697        let ok = post(
2698            &server,
2699            "X-Forwarded-User: user:pat@example.com\r\nX-Areev-Proxy-Secret: proxy-secret\r\n",
2700        );
2701        assert!(ok.contains("HTTP/1.1 200"), "{}", &ok[..ok.len().min(200)]);
2702        assert!(!ok.contains("AUT-E001"), "granted principal must write: {ok}");
2703
2704        // 2. Forged: the identity header WITHOUT the secret is ignored — the
2705        // request is anonymous and the write refuses.
2706        let forged = post(&server, "X-Forwarded-User: user:pat@example.com\r\n");
2707        assert!(
2708            forged.contains("anonymous") || forged.contains("401"),
2709            "forged identity must not authenticate: {forged}"
2710        );
2711
2712        // 3. Wrong secret: same refusal (constant-time compare, same path).
2713        let wrong = post(
2714            &server,
2715            "X-Forwarded-User: user:pat@example.com\r\nX-Areev-Proxy-Secret: guess\r\n",
2716        );
2717        assert!(
2718            wrong.contains("anonymous") || wrong.contains("401"),
2719            "wrong proxy secret must not authenticate: {wrong}"
2720        );
2721    }
2722
2723    /// The rotation window (#79): while two secrets are configured, either
2724    /// proves the proxy — so the fleet moves over one node at a time instead
2725    /// of atomically, which is the only way an impersonation-grade credential
2726    /// gets rotated without an outage or a gap.
2727    #[test]
2728    fn both_secrets_authenticate_during_a_rotation_window() {
2729        let server = sso_server_rotating(Some("new-secret"));
2730        let id = "X-Forwarded-User: user:pat@example.com\r\n";
2731
2732        for secret in ["proxy-secret", "new-secret"] {
2733            let out = post(&server, &format!("{id}X-Areev-Proxy-Secret: {secret}\r\n"));
2734            assert!(out.contains("HTTP/1.1 200"), "{secret} rejected: {out}");
2735            assert!(!out.contains("AUT-E001"), "{secret} must authenticate: {out}");
2736        }
2737
2738        // The window widens what proves the proxy, never what a proved
2739        // identity may do, and never to a third value.
2740        let wrong = post(&server, &format!("{id}X-Areev-Proxy-Secret: guess\r\n"));
2741        assert!(
2742            wrong.contains("anonymous") || wrong.contains("401"),
2743            "an unrelated secret must still be refused mid-rotation: {wrong}"
2744        );
2745    }
2746
2747    /// Closing the window is what actually retires the old credential — the
2748    /// step the runbook exists to make sure nobody skips.
2749    #[test]
2750    fn the_retired_secret_stops_working_once_the_window_closes() {
2751        // Rotation complete: the new value is now the only one deployed.
2752        let server = sso_server_with("new-secret", None);
2753        let id = "X-Forwarded-User: user:pat@example.com\r\n";
2754
2755        let ok = post(&server, &format!("{id}X-Areev-Proxy-Secret: new-secret\r\n"));
2756        assert!(ok.contains("HTTP/1.1 200"), "{ok}");
2757        assert!(!ok.contains("AUT-E001"), "the promoted secret must authenticate: {ok}");
2758
2759        let old = post(&server, &format!("{id}X-Areev-Proxy-Secret: proxy-secret\r\n"));
2760        assert!(
2761            old.contains("anonymous") || old.contains("401"),
2762            "the retired secret must stop working: {old}"
2763        );
2764    }
2765
2766    #[test]
2767    #[should_panic(expected = "must differ")]
2768    fn rotating_to_the_same_value_is_refused() {
2769        // A rotation that deploys the same value twice did not happen, and it
2770        // would read as "both live" in every log and status line.
2771        let _ = sso_server_rotating(Some("proxy-secret"));
2772    }
2773
2774    use crate::CONSOLE_HTML;
2775    use std::collections::BTreeSet;
2776
2777    /// Every `<section class="page" id="page-X">` must be named in the one
2778    /// array in `render()` that clears `hidden`, and every sidebar
2779    /// `data-page="X"` must have such a section.
2780    ///
2781    /// The console's page sections ship `hidden` in the markup, so a page is
2782    /// visible only if `render()` un-hides it. The Triggers tab shipped
2783    /// missing from that array: `renderTriggers()` filled `#trgBody` on every
2784    /// render, the nav item highlighted, the hash routed — and the section
2785    /// stayed `hidden` while every other page was hidden too, so the tab
2786    /// showed a blank pane. Nothing else in the file could catch it: the
2787    /// omission is a *missing* string, not a wrong one.
2788    #[test]
2789    fn every_console_page_is_in_the_visibility_list() {
2790        fn all(hay: &str, open: &str) -> BTreeSet<String> {
2791            let mut out = BTreeSet::new();
2792            let mut rest = hay;
2793            while let Some(i) = rest.find(open) {
2794                rest = &rest[i + open.len()..];
2795                let end = rest.find('"').expect("unterminated attribute");
2796                out.insert(rest[..end].to_string());
2797                rest = &rest[end..];
2798            }
2799            out
2800        }
2801
2802        let sections = all(CONSOLE_HTML, "id=\"page-");
2803        let nav = all(CONSOLE_HTML, "data-page=\"");
2804        for want in ["workflows", "runs", "tools"] {
2805            assert!(sections.contains(want), "the {want} section itself went missing");
2806        }
2807        // Triggers deliberately have no page: a trigger points AT a plan and is
2808        // only legible next to it, so they render in the workflow canvas's own
2809        // lane. The old deep link must still land somewhere real.
2810        assert!(!sections.contains("triggers"), "triggers belong on the canvas, not on a page");
2811        assert!(
2812            CONSOLE_HTML.contains("if (head === 'triggers')"),
2813            "#triggers must still route somewhere — a removed page is not a dead bookmark"
2814        );
2815
2816        // The single array in render() that drives `.hidden`.
2817        let head = "for (const p of [";
2818        let i = CONSOLE_HTML.find(head).expect("render()'s page-visibility loop");
2819        let tail = &CONSOLE_HTML[i + head.len()..];
2820        let j = tail.find(']').expect("unterminated page array");
2821        let listed: BTreeSet<String> = tail[..j]
2822            .split(',')
2823            .map(|t| t.trim().trim_matches('\'').to_string())
2824            .filter(|t| !t.is_empty())
2825            .collect();
2826
2827        assert_eq!(
2828            sections, listed,
2829            "page sections and render()'s visibility list disagree; a section missing \
2830             from the list renders into a pane that never un-hides"
2831        );
2832        let orphan: Vec<_> = nav.difference(&sections).collect();
2833        assert!(orphan.is_empty(), "nav items with no page section: {orphan:?}");
2834    }
2835}