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    sso_secret: Option<String>,
120}
121
122/// Per-request principal binding. The server handles one request at a
123/// time, so rebinding the shared facade for the request's duration is
124/// race-free; the guard restores the owner default on drop — including on
125/// panic — so a failed handler can never leak a stray principal into the
126/// next request.
127struct RequestBinding<'a> {
128    facade: &'a AreevFacade,
129}
130
131impl<'a> RequestBinding<'a> {
132    fn bind(
133        facade: &'a AreevFacade,
134        map: &areev_core::authz::CredentialMap,
135        bearer: Option<&str>,
136        memory: &str,
137    ) -> Self {
138        // Memory-scoped resolution: a credential whose `memories` list
139        // excludes THIS memory is indistinguishable from an unknown token.
140        let principal =
141            bearer.and_then(|t| map.resolve_for_memory(t, memory).ok().map(str::to_string));
142        match principal {
143            Some(p) => {
144                if facade.bind_principal(&p).is_err() {
145                    // A store failure reading grants fails toward LESS
146                    // privilege, never more.
147                    Self::bind_anonymous(facade);
148                }
149            }
150            None => Self::bind_anonymous(facade),
151        }
152        RequestBinding { facade }
153    }
154
155    /// `anonymous` = a read-only baseline plus whatever the file explicitly
156    /// grants the `anonymous` principal — the formalized version of the old
157    /// token-less read-only console.
158    /// Bind a proxy-proven SSO identity directly (no credential map row
159    /// needed — the IdP said who this is; the FILE says what they may do).
160    fn bind_identity(facade: &'a AreevFacade, principal: &str) -> Self {
161        if facade.bind_principal(principal).is_err() {
162            Self::bind_anonymous(facade);
163        }
164        RequestBinding { facade }
165    }
166
167    fn bind_anonymous(facade: &AreevFacade) {
168        use areev_core::authz::{AuthzSet, Grant, Verb};
169        let mut grants = facade
170            .with_store(|m| m.authz_grants("anonymous"))
171            .unwrap_or_default();
172        grants.push(Grant { verbs: vec![Verb::Read], namespaces: vec!["*".to_string()] });
173        facade.bind(AuthzSet::restricted("anonymous", grants));
174    }
175}
176
177impl Drop for RequestBinding<'_> {
178    fn drop(&mut self) {
179        self.facade
180            .bind(areev_core::authz::AuthzSet::owner("user:console"));
181    }
182}
183
184impl UiServer {
185    pub fn new(facade: AreevFacade, db_label: String) -> Self {
186        UiServer {
187            facade: std::sync::Arc::new(facade),
188            executor: CalExecutor::new(CalExecutorConfig::default())
189                .with_governance(std::sync::Arc::new(areev_loop_adapter::LoopGovernance::new())),
190            db_label,
191            token: None,
192            auth_all: false,
193            segment_dir: None,
194            allow_remote: false,
195            loop_policy: None,
196            credentials: None,
197            allow_destructive_ops: CalExecutorConfig::default().allow_destructive_ops,
198            #[cfg(feature = "tls")]
199            tls_config: None,
200            sso_header: None,
201            sso_secret: None,
202        }
203    }
204
205    /// Enable trusted-header SSO (v0). `header` names the identity header
206    /// the authenticating proxy forwards; `secret` is the proxy's shared
207    /// secret, demanded in `x-areev-proxy-secret` on every SSO request.
208    pub fn with_sso(mut self, header: impl Into<String>, secret: impl Into<String>) -> Self {
209        let header = header.into();
210        let secret = secret.into();
211        // An empty proxy secret would let `x-areev-proxy-secret:` (empty)
212        // prove any identity — refuse loudly at the library boundary too,
213        // not just in the CLI.
214        assert!(
215            !header.trim().is_empty() && !secret.trim().is_empty(),
216            "with_sso requires a non-empty header name and proxy secret"
217        );
218        self.sso_header = Some(header.to_ascii_lowercase());
219        self.sso_secret = Some(secret);
220        self
221    }
222
223    /// Rebuild the executor from the current gate settings. Every builder
224    /// that touches the CAL surface goes through here so `--no-destructive-ops`
225    /// and `--policy` compose instead of clobbering each other's config —
226    /// and the governance host survives every rebuild. (Same shape as
227    /// `areev-mcp`'s; building `CalExecutorConfig::default()` in one builder
228    /// silently re-enabled destruction configured off by another.)
229    fn rebuild_executor(&mut self) {
230        self.executor = CalExecutor::new(CalExecutorConfig {
231            allow_destructive_ops: self.allow_destructive_ops,
232            ..CalExecutorConfig::default()
233        })
234        .with_governance(std::sync::Arc::new(match &self.loop_policy {
235            Some(p) => areev_loop_adapter::LoopGovernance::with_policy(p.clone()),
236            None => areev_loop_adapter::LoopGovernance::new(),
237        }));
238    }
239
240    /// Multi-principal mode: attach the credential map (`areev-auth.json`).
241    /// Tokens resolve to principal names; rights come from the memory
242    /// file's own grant grains; unauthenticated requests run as
243    /// `anonymous`. See `docs/cal-reference.md` §9.
244    pub fn with_credentials(mut self, map: areev_core::authz::CredentialMap) -> Self {
245        self.credentials = Some(map);
246        self
247    }
248
249    /// Attach a host loop policy to the console's loop routes, so a
250    /// console-triggered run honors the same auto-apply grants, denies, and
251    /// severity floors as `areev loop run --policy`.
252    pub fn with_loop_policy(mut self, policy: areev_loop::Policy) -> Self {
253        self.loop_policy = Some(policy);
254        // The CAL surface's governance host honors the same policy.
255        self.rebuild_executor();
256        self
257    }
258
259    /// May this request's session read the raw sync surface? A segment is a
260    /// whole-memory op-log export, so it takes `read` on every namespace —
261    /// a principal granted read on one namespace must not be able to pull
262    /// the file wholesale through `/api/segment`. Owner sessions (every
263    /// non-credential-map deployment) pass unchanged.
264    fn session_may_sync(&self) -> bool {
265        self.facade
266            .authz()
267            .allows(areev_core::authz::Verb::Read, "*")
268    }
269
270    /// The loop engine for a request: builtins + the host policy when one
271    /// was attached.
272    fn engine(&self) -> Engine {
273        match &self.loop_policy {
274            Some(p) => Engine::with_builtins().with_policy(p.clone()),
275            None => Engine::with_builtins(),
276        }
277    }
278
279    /// Accept requests whose `Host` header is non-loopback. Off by default so
280    /// the loopback console is protected against DNS-rebinding reads even when
281    /// unauthenticated; the CLI enables it under `--allow-remote`.
282    pub fn allow_remote(mut self, yes: bool) -> Self {
283        self.allow_remote = yes;
284        self
285    }
286
287    /// Require a shared secret on **every** request (the console page, reads,
288    /// and writes). Browsers authenticate through the native `Basic` prompt
289    /// (any username; password = `token`); scripts may send `Authorization:
290    /// Bearer <token>`. Use this to serve the console to more than a single
291    /// trusted local operator. Pair with a TLS-terminating proxy for non-local
292    /// exposure — the token crosses the wire in the clear otherwise.
293    pub fn with_auth(mut self, token: String) -> Self {
294        // An empty token would authenticate `Authorization: Bearer ` (the
295        // empty credential) as the OWNER — refuse at the library boundary,
296        // not just in the CLI's --token-env validation.
297        assert!(
298            !token.trim().is_empty(),
299            "with_auth requires a non-empty token"
300        );
301        self.token = Some(token);
302        self.auth_all = true;
303        self
304    }
305
306    /// Permit or forbid destructive CAL (`FORGET <hash>`) from the query
307    /// console. Enabled by default; pass `false` to serve a read-only console.
308    /// Since the plain console is unauthenticated, disabling this is the safe
309    /// choice when the console is exposed beyond a trusted local operator.
310    pub fn allow_destructive_ops(mut self, allow: bool) -> Self {
311        self.allow_destructive_ops = allow;
312        self.rebuild_executor();
313        self
314    }
315
316    /// areevd mode: require `Authorization: Bearer <token>` on POST
317    /// endpoints and accept segment push/pull under `dir`.
318    pub fn into_hub(mut self, token: Option<String>, dir: &str) -> std::io::Result<Self> {
319        std::fs::create_dir_all(dir)?;
320        self.token = token;
321        self.segment_dir = Some(dir.to_string());
322        // The hub is a network service (segment push/pull from other nodes), so
323        // non-loopback Hosts are expected — writes stay bearer-gated.
324        self.allow_remote = true;
325        Ok(self)
326    }
327
328    /// Bind and return the listener (lets callers learn the ephemeral port).
329    pub fn bind(addr: &str) -> std::io::Result<TcpListener> {
330        TcpListener::bind(addr)
331    }
332
333    /// Serve forever on an already-bound listener.
334    pub fn serve(&self, listener: TcpListener) -> std::io::Result<()> {
335        for stream in listener.incoming() {
336            match stream {
337                Ok(s) => {
338                    if let Err(e) = self.handle(s) {
339                        eprintln!("areev ui: request error: {e}");
340                    }
341                }
342                Err(e) => eprintln!("areev ui: accept error: {e}"),
343            }
344        }
345        Ok(())
346    }
347
348    /// Configure native TLS from PEM files (`tls` cargo feature). The
349    /// documented default remains a TLS-terminating proxy; this is for
350    /// deployments with nowhere to put one (edge boxes, appliances).
351    #[cfg(feature = "tls")]
352    pub fn with_tls(mut self, cert_pem: &str, key_pem: &str) -> std::io::Result<Self> {
353        let certs: Vec<rustls::pki_types::CertificateDer<'static>> =
354            rustls_pemfile::certs(&mut BufReader::new(std::fs::File::open(cert_pem)?))
355                .collect::<Result<_, _>>()?;
356        let key = rustls_pemfile::private_key(&mut BufReader::new(std::fs::File::open(key_pem)?))?
357            .ok_or_else(|| std::io::Error::new(std::io::ErrorKind::InvalidData, "no private key in PEM"))?;
358        let config = rustls::ServerConfig::builder()
359            .with_no_client_auth()
360            .with_single_cert(certs, key)
361            .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e.to_string()))?;
362        self.tls_config = Some(std::sync::Arc::new(config));
363        Ok(self)
364    }
365
366    /// Wrap an accepted socket per the TLS posture. The handshake happens
367    /// lazily on first read/write; a plaintext client fails it loudly.
368    fn wrap(&self, stream: TcpStream) -> std::io::Result<Conn> {
369        #[cfg(feature = "tls")]
370        if let Some(config) = &self.tls_config {
371            let session = rustls::ServerConnection::new(std::sync::Arc::clone(config))
372                .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e.to_string()))?;
373            return Ok(Conn::Tls(Box::new(rustls::StreamOwned::new(session, stream))));
374        }
375        Ok(Conn::Plain(stream))
376    }
377
378    fn handle(&self, stream: TcpStream) -> std::io::Result<()> {
379        // The server handles one connection at a time, so a single slow-drip
380        // client could otherwise hold it open indefinitely (a per-read timeout
381        // resets on every byte). A watchdog thread enforces a hard wall-clock
382        // deadline by shutting the socket down; it is unparked and joined the
383        // instant the request completes, so it adds no latency in the fast path.
384        let watchdog_stream = stream.try_clone()?;
385        let done = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
386        let watchdog_done = std::sync::Arc::clone(&done);
387        let watchdog = std::thread::spawn(move || {
388            std::thread::park_timeout(Duration::from_secs(REQUEST_DEADLINE_SECS));
389            if !watchdog_done.load(std::sync::atomic::Ordering::Acquire) {
390                let _ = watchdog_stream.shutdown(std::net::Shutdown::Both);
391            }
392        });
393        let raw = stream.try_clone()?;
394        let conn = self.wrap(stream)?;
395        let result = self.handle_request(&raw, conn);
396        done.store(true, std::sync::atomic::Ordering::Release);
397        watchdog.thread().unpark();
398        let _ = watchdog.join();
399        result
400    }
401
402    fn handle_request(&self, raw: &TcpStream, mut conn: Conn) -> std::io::Result<()> {
403        // Bound slow clients (slowloris) and total bytes read from the
404        // connection so a malicious client cannot stall the server or exhaust
405        // memory with an oversized request line / headers / body. The
406        // timeouts sit on the RAW socket, so they bound TLS handshakes too.
407        raw.set_read_timeout(Some(Duration::from_secs(READ_TIMEOUT_SECS)))?;
408        raw.set_write_timeout(Some(Duration::from_secs(READ_TIMEOUT_SECS)))?;
409        let mut reader = BufReader::new((&mut conn).take(MAX_CONN_BYTES));
410        let mut request_line = String::new();
411        reader.read_line(&mut request_line)?;
412        let mut parts = request_line.split_whitespace();
413        let method = parts.next().unwrap_or("").to_string();
414        let path = parts.next().unwrap_or("/").to_string();
415
416        // headers → content-length + authorization + origin + host
417        let mut content_length = 0usize;
418        let mut bearer: Option<String> = None;
419        let mut origin: Option<String> = None;
420        let mut host: Option<String> = None;
421        let mut sso_identity_raw: Option<String> = None;
422        let mut proxy_secret: Option<String> = None;
423        let mut header_bytes = 0usize;
424        let mut header_count = 0usize;
425        loop {
426            let mut line = String::new();
427            let n = reader.read_line(&mut line)?;
428            if n == 0 {
429                break; // EOF / connection closed by peer
430            }
431            header_bytes += n;
432            header_count += 1;
433            if header_bytes > MAX_HEADER_BYTES || header_count > MAX_HEADERS {
434                return Err(std::io::Error::new(
435                    std::io::ErrorKind::InvalidData,
436                    "request headers too large",
437                ));
438            }
439            let l = line.trim();
440            if l.is_empty() {
441                break;
442            }
443            let low = l.to_ascii_lowercase();
444            if let Some(v) = low.strip_prefix("content-length:") {
445                content_length = v.trim().parse().unwrap_or(0);
446            }
447            if let Some(v) = low.strip_prefix("origin:") {
448                origin = Some(v.trim().to_string());
449            }
450            if let Some(v) = low.strip_prefix("host:") {
451                host = Some(v.trim().to_string());
452            }
453            if let Some(v) = low.strip_prefix("x-areev-proxy-secret:") {
454                // Secrets are compared, never logged; the lowercased copy is
455                // wrong for comparison, so re-slice the original line.
456                let _ = v;
457                if let Some((_, orig)) = l.split_once(':') {
458                    proxy_secret = Some(orig.trim().to_string());
459                }
460            }
461            if let Some(h) = &self.sso_header {
462                if low.starts_with(&format!("{h}:")) {
463                    if let Some((_, orig)) = l.split_once(':') {
464                        sso_identity_raw = Some(orig.trim().to_string());
465                    }
466                }
467            }
468            if low.starts_with("authorization:") {
469                if let Some((_, v)) = l.split_once(':') {
470                    let v = v.trim();
471                    // Accept `Bearer <token>` (scripts/CLI) or HTTP `Basic`
472                    // (browsers, via the native login prompt). For Basic the
473                    // credential is base64(user:pass) and the token is the
474                    // password; the username is ignored.
475                    bearer = if let Some(t) =
476                        v.strip_prefix("Bearer ").or_else(|| v.strip_prefix("bearer "))
477                    {
478                        Some(t.trim().to_string())
479                    } else if let Some(b64) =
480                        v.strip_prefix("Basic ").or_else(|| v.strip_prefix("basic "))
481                    {
482                        basic_auth_password(b64.trim())
483                    } else {
484                        Some(v.to_string())
485                    };
486                }
487            }
488        }
489        // DNS-rebinding defense: a browser tricked into pointing an attacker
490        // domain at 127.0.0.1 sends that domain in the Host header. Unless the
491        // operator opted into remote serving, reject any non-loopback Host on
492        // *every* method (the Origin check below only covers POST, so this is
493        // what stops a drive-by page from reading memory over GET). A missing
494        // Host (bare HTTP/1.0, some CLI clients) passes, mirroring Origin.
495        if !self.allow_remote && host.as_deref().is_some_and(|h| !host_is_local(h)) {
496            let payload = br#"{"ok":false,"error":"non-loopback Host rejected (use --allow-remote to serve remotely)"}"#;
497            drop(reader);
498            let mut out = conn;
499            write!(
500                out,
501                "HTTP/1.1 403 Forbidden\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
502                payload.len()
503            )?;
504            out.write_all(payload)?;
505            return out.flush();
506        }
507
508        let mut body = vec![0u8; content_length.min(1 << 20)];
509        if content_length > 0 {
510            reader.read_exact(&mut body)?;
511        }
512
513        // Cross-origin drive-by protection: browsers attach an Origin header
514        // to cross-site requests. Only loopback pages (the console itself)
515        // may mutate; curl/CLI clients send no Origin and pass through.
516        if method == "POST" && origin.as_deref().is_some_and(|o| !origin_is_local(o)) {
517            let payload = br#"{"ok":false,"error":"cross-origin request rejected"}"#;
518            drop(reader);
519            let mut out = conn;
520            write!(
521                out,
522                "HTTP/1.1 403 Forbidden\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
523                payload.len()
524            )?;
525            out.write_all(payload)?;
526            return out.flush();
527        }
528
529        // The identity header is TRUSTED only when the proxy proved itself
530        // (constant-time secret check). A forged header without the secret
531        // is silently ignored — the request proceeds as whatever its other
532        // credentials make it.
533        let sso_identity: Option<String> = match (&self.sso_secret, proxy_secret, sso_identity_raw) {
534            (Some(expected), Some(presented), Some(id))
535                if ct_eq(expected.as_bytes(), presented.as_bytes())
536                    && !id.trim().is_empty() =>
537            {
538                Some(id)
539            }
540            _ => None,
541        };
542        let (status, ctype, payload) =
543            self.route(&method, &path, &body, bearer.as_deref(), sso_identity.as_deref());
544        // On a console-auth 401, challenge with Basic so browsers show the
545        // native login prompt (any username; password = token).
546        let auth_challenge = if self.auth_all && status.starts_with("401") {
547            "WWW-Authenticate: Basic realm=\"Areev console\", charset=\"UTF-8\"\r\n"
548        } else {
549            ""
550        };
551        drop(reader);
552        let mut out = conn;
553        write!(
554            out,
555            "HTTP/1.1 {status}\r\nContent-Type: {ctype}\r\n{auth_challenge}Content-Length: {}\r\nConnection: close\r\n\r\n",
556            payload.len()
557        )?;
558        out.write_all(&payload)?;
559        out.flush()
560    }
561
562    /// The runtime driver for the /api/run/* surface. The console never
563    /// EXECUTES tools (list/inspect read; respond/cancel journal) — the
564    /// executor is a refusing stub, present only to satisfy the driver
565    /// shape.
566    fn runner(&self, principal: &str) -> areev_run::Runner {
567        struct NoExec;
568        impl areev_run::HostToolExecutor for NoExec {
569            fn execute(
570                &self,
571                tool_name: &str,
572                _h: &str,
573                _i: &Value,
574                _k: &str,
575            ) -> areev_run::ExecResult {
576                areev_run::ExecResult::Err {
577                    cause: areev_run::FailCause::ExecutorError,
578                    detail: format!("the console does not execute tools ('{tool_name}')"),
579                }
580            }
581        }
582        areev_run::Runner {
583            facade: std::sync::Arc::clone(&self.facade),
584            clock: std::sync::Arc::new(areev_run::SystemClock),
585            executor: std::sync::Arc::new(NoExec),
586            llm: None,
587            observer: None,
588            ns: self
589                .facade
590                .default_namespace()
591                .unwrap_or("shared")
592                .to_string(),
593            principal: principal.to_string(),
594        }
595    }
596
597    fn route(
598        &self,
599        method: &str,
600        path: &str,
601        body: &[u8],
602        bearer: Option<&str>,
603        sso_identity: Option<&str>,
604    ) -> (&'static str, &'static str, Vec<u8>) {
605        // Auth: console-auth mode (`auth_all`) guards every request; hub mode
606        // guards only mutating + sync endpoints. The credential arrives as a
607        // Bearer token or an HTTP Basic password (see `handle_request`).
608        //
609        // A credential map changes WHO a request is, not WHETHER the shared
610        // secret is required. When both are configured, a map token
611        // authenticates as its principal, the shared secret authenticates as
612        // the implied admin (`docs/cal-all-you-need-proposal.md` §5.2b), and
613        // anything else is refused — an attached map must never silently
614        // downgrade a `--token-env` console to an open `anonymous` one.
615        let mut shared_secret_ok = false;
616        if let Some(tok) = &self.token {
617            let guarded = self.auth_all || method == "POST" || path.starts_with("/api/segment");
618            shared_secret_ok = bearer.is_some_and(|b| ct_eq(b.as_bytes(), tok.as_bytes()));
619            let known = shared_secret_ok
620                || sso_identity.is_some()
621                || match (&self.credentials, bearer) {
622                    (Some(map), Some(b)) => map.resolve_for_memory(b, &self.db_label).is_ok(),
623                    _ => false,
624                };
625            if guarded && !known {
626                return ("401 Unauthorized", "application/json",
627                        br#"{"ok":false,"error":"authentication required"}"#.to_vec());
628            }
629        } else if self.credentials.is_none() && sso_identity.is_none() && method == "POST" {
630            // §5.7: token-less `areev ui` is read-only. The ONLY POST allowed is
631            // a read-only CAL statement; every write (any loop mutation, an
632            // ADD/SUPERSEDE/FORGET CAL batch, etc.) requires --token-env. This
633            // closes the bypass where a local process could execute a
634            // proposal's CAL directly and skip the review queue.
635            let base = path.split_once('?').map(|(p, _)| p).unwrap_or(path);
636            let allowed = base == "/api/cal" && cal_body_is_read_only(body);
637            if !allowed {
638                return ("401 Unauthorized", "application/json",
639                        br#"{"ok":false,"error":"read-only console: restart areev ui with --token-env VAR to enable writes"}"#.to_vec());
640            }
641        }
642
643        // Credential-map mode: resolve this request's principal and bind
644        // the facade for the request's duration (the guard restores the
645        // owner default on drop). Every verb check downstream — CAL,
646        // loop scopes, the run gate below — now answers for THIS caller.
647        // A request authenticated by the shared secret keeps the owner
648        // session that secret has always carried.
649        let _session = match (&self.credentials, shared_secret_ok) {
650            (Some(map), false) => Some(RequestBinding::bind(&self.facade, map, bearer, &self.db_label)),
651            _ => None,
652        };
653        // The request's resolved PRINCIPAL identity — Some only when a
654        // per-principal credential authenticated it. The shared secret and
655        // anonymous access deliberately resolve to None: approvals need an
656        // identity, and "whoever holds the console token" is not one.
657        let mut request_principal: Option<String> = match (&self.credentials, shared_secret_ok, bearer)
658        {
659            (Some(map), false, Some(b)) => map.resolve_for_memory(b, &self.db_label).ok().map(str::to_string),
660            _ => None,
661        };
662        // SSO v0: a proxy-proven identity becomes the request principal when
663        // no bearer credential resolved one (machines keep tokens; SSO is
664        // for humans behind the proxy). Rights come from the file's grants.
665        let _sso_session = match (&request_principal, sso_identity, shared_secret_ok) {
666            (None, Some(identity), false) => {
667                request_principal = Some(identity.to_string());
668                Some(RequestBinding::bind_identity(&self.facade, identity))
669            }
670            _ => None,
671        };
672
673        let (path, query) = match path.split_once('?') {
674            Some((p, q)) => (p, q),
675            None => (path, ""),
676        };
677        let q = |key: &str| -> Option<String> {
678            query.split('&').find_map(|kv| {
679                let (k, v) = kv.split_once('=')?;
680                (k == key).then(|| urldecode(v))
681            })
682        };
683        match (method, path) {
684            ("GET", "/") => (
685                "200 OK",
686                "text/html; charset=utf-8",
687                CONSOLE_HTML
688                    .replace("{{DB}}", &html_escape(&self.db_label))
689                    .into_bytes(),
690            ),
691            ("POST", "/api/cal") => {
692                let req: Value = serde_json::from_slice(body).unwrap_or(Value::Null);
693                let queryt = req.get("query").and_then(|v| v.as_str()).unwrap_or("");
694                match self.executor.execute(queryt, &*self.facade) {
695                    Ok(res) => ok_json(json!({
696                        "ok": true,
697                        "result": res.result,
698                        "warnings": res.warnings,
699                        "statement": res.metadata.statement_type,
700                        "elapsed_ms": res.metadata.execution_time_ms,
701                    })),
702                    Err(e) => {
703                        // Structured error: code + span + hint let the console
704                        // point at the offending token instead of just quoting.
705                        let mut err = json!({
706                            "ok": false,
707                            "error": e.sanitize_for_client(),
708                            "code": e.code(),
709                        });
710                        if let Some(sp) = e.span() {
711                            err["span"] = json!({
712                                "start": sp.start, "end": sp.end,
713                                "line": sp.line, "col": sp.col,
714                            });
715                        }
716                        if let Some(hint) = e.suggestion() {
717                            err["suggestion"] = json!(hint);
718                        }
719                        ok_json(err)
720                    }
721                }
722            }
723            ("GET", "/api/stats") => {
724                let stats = self.facade.with_store(|m| m.stats());
725                match stats {
726                    Ok(s) => ok_json(json!({
727                        "db": self.db_label,
728                        "grains": s.grains, "current": s.current,
729                        "triples": s.triples, "terms": s.terms,
730                        "ops": s.ops, "events_indexed": s.events_indexed,
731                    })),
732                    Err(e) => ok_json(json!({"ok": false, "error": e.to_string()})),
733                }
734            }
735            ("GET", "/api/log") => {
736                let since: i64 = q("since").and_then(|v| v.parse().ok()).unwrap_or(0);
737                let limit: usize = q("limit").and_then(|v| v.parse().ok()).unwrap_or(100);
738                match self.facade.with_store(|m| m.changes_since(since, limit)) {
739                    Ok(ops) => {
740                        let rows: Vec<Value> = ops
741                            .iter()
742                            .map(|o| {
743                                json!({
744                                    "op_seq": o.op_seq, "hlc": o.hlc,
745                                    "op": match o.op { 1 => "add", 2 => "supersede", 3 => "forget", _ => "?" },
746                                    "hash": o.hash.to_hex(),
747                                })
748                            })
749                            .collect();
750                        ok_json(json!(rows))
751                    }
752                    Err(e) => ok_json(json!({"ok": false, "error": e.to_string()})),
753                }
754            }
755            // Who am I: the principal this request is bound to. In
756            // credential-map mode this reflects the per-request binding;
757            // read-only and mode-agnostic.
758            ("GET", "/api/whoami") => {
759                let a = self.facade.authz();
760                ok_json(json!({
761                    "ok": true,
762                    "principal": a.principal(),
763                    "owner": a.is_owner(),
764                    "mode": if self.credentials.is_some() { "credentials" }
765                            else if self.token.is_some() { "token" }
766                            else { "open" },
767                }))
768            }
769            ("GET", "/api/config") => {
770                // Read-only observability: the *effective* per-process
771                // configuration. Nothing here is persisted in the .db —
772                // the file holds data only; these values are supplied by
773                // the host at open time.
774                let cfg = self.executor.config();
775                let (index_text, embedder_dim, declared_embed, mut warnings) =
776                    self.facade.with_store(|m| {
777                        (
778                            m.index_text_enabled(),
779                            m.embedder_dim(),
780                            m.declared_embedding().map(|(mm, d)| (mm.to_string(), d)),
781                            m.open_warnings().to_vec(),
782                        )
783                    });
784                if let Some((m, d)) = &declared_embed {
785                    if embedder_dim.is_none() {
786                        warnings.push(format!(
787                            "vector leg dormant: file expects {m}@{d}, no embedding backend installed"
788                        ));
789                    }
790                }
791                // Saved queries and templates the file carries that this build
792                // cannot load. Same class of thing as an open warning — the
793                // file and the host disagree — so it belongs in the same list.
794                warnings.extend(self.facade.meta_warnings());
795                ok_json(json!({
796                    "ok": true,
797                    "db": self.db_label,
798                    "warnings": warnings,
799                    "file": {
800                        "text_index": index_text,
801                        "embedding": declared_embed.map(|(m, d)| json!({"model": m, "dim": d})),
802                    },
803                    "session": {
804                        "namespace": self.facade.session_namespace(),
805                        "mounts": self.facade.mount_aliases(),
806                    },
807                    "store": {
808                        "index_text": index_text,
809                        "embedder": embedder_dim.map(|d| json!({"dim": d})),
810                    },
811                    "recall": {
812                        "fusion": "rrf",
813                        "rrf_k0": areev_store::RRF_K0,
814                        "overfetch_factor": AreevFacade::RECALL_OVERFETCH,
815                        "legs": {
816                            "structural": true,
817                            "bm25": index_text,
818                            "vector": embedder_dim.is_some(),
819                        },
820                    },
821                    "executor": {
822                        "max_limit": cfg.max_limit,
823                        "default_limit": cfg.default_limit,
824                        "tier1_writes": cfg.tier1_enabled,
825                        "namespace_override": cfg.namespace_override,
826                        "user_id_override": cfg.user_id_override,
827                    },
828                    "server": {
829                        "hub_mode": self.segment_dir.is_some(),
830                        "auth_required": self.token.is_some(),
831                        // true = every request is authenticated (console-auth);
832                        // false with auth_required = hub mode (writes/sync only).
833                        "auth_all": self.auth_all,
834                        "segment_dir": self.segment_dir,
835                    },
836                    "persistence": "per-process (host-supplied at open) — not stored in the .db",
837                }))
838            }
839            ("GET", "/api/browse") => {
840                // Browse-without-queries: the tail of the op-log joined with
841                // grain summaries, newest first. Supersession and tombstone
842                // status are resolved within the returned window so the
843                // console can dim/strike them (grains are never mutated —
844                // this reads the index + immutable blobs only).
845                let limit: i64 = q("limit")
846                    .and_then(|v| v.parse().ok())
847                    .unwrap_or(500)
848                    .clamp(1, 2000);
849                let built = self.facade.with_store(|m| -> Result<(i64, Vec<Value>), String> {
850                    let total = m.stats().map_err(|e| e.to_string())?.ops as i64;
851                    let after = (total - limit).max(0);
852                    let ops = m.changes_since(after, limit as usize).map_err(|e| e.to_string())?;
853                    let mut rows: Vec<Value> = Vec::with_capacity(ops.len());
854                    let mut idx: std::collections::HashMap<String, usize> =
855                        std::collections::HashMap::new();
856                    for o in &ops {
857                        let hex = o.hash.to_hex();
858                        let op_name = match o.op { 1 => "add", 2 => "supersede", 3 => "forget", _ => "?" };
859                        if o.op == 3 {
860                            // tombstone: flag the earlier row if it is in the
861                            // window, else emit a stub (blob is already gone).
862                            match idx.get(&hex) {
863                                Some(&i) => { rows[i]["forgotten"] = json!(true); }
864                                None => rows.push(json!({
865                                    "hash": hex, "op_seq": o.op_seq, "hlc": o.hlc,
866                                    "op": op_name, "forgotten": true,
867                                })),
868                            }
869                            continue;
870                        }
871                        // A SUPERSEDE logs two ops for the new grain (add +
872                        // supersede) — merge them into one row, keeping the
873                        // later op label.
874                        if let Some(&i) = idx.get(&hex) {
875                            rows[i]["op"] = json!(op_name);
876                            rows[i]["op_seq"] = json!(o.op_seq);
877                            rows[i]["hlc"] = json!(o.hlc);
878                            continue;
879                        }
880                        let mut row = json!({
881                            "hash": hex, "op_seq": o.op_seq, "hlc": o.hlc, "op": op_name,
882                        });
883                        match m.get(&o.hash) {
884                            Ok(g) => {
885                                row["type"] = json!(format!("{:?}", g.grain_type).to_lowercase());
886                                row["fields"] = serde_json::to_value(&g.fields).unwrap_or(Value::Null);
887                            }
888                            // erased since (forget op outside the window)
889                            Err(_) => { row["missing"] = json!(true); }
890                        }
891                        idx.insert(hex, rows.len());
892                        rows.push(row);
893                    }
894                    // A supersede op's grain points at its predecessor via
895                    // derived_from; mark the predecessor if it is in-window.
896                    let marks: Vec<(String, String)> = rows.iter()
897                        .filter(|r| r["op"] == "supersede")
898                        .filter_map(|r| {
899                            let old = r["fields"].get("derived_from")?.as_str()?;
900                            Some((old.to_string(), r["hash"].as_str()?.to_string()))
901                        })
902                        .collect();
903                    for (old, newer) in marks {
904                        if let Some(&i) = idx.get(&old) {
905                            rows[i]["superseded_by"] = json!(newer);
906                        }
907                    }
908                    rows.reverse();
909                    Ok((total, rows))
910                });
911                // Scope the browse to the session namespace, the way CAL
912                // already scopes recall. Without this the rail lists entities
913                // (`john`, `bob`) that every query on this console then reports
914                // as missing, because the two disagree about what is in view.
915                // Rows we cannot attribute — tombstone stubs, grains erased
916                // since — are kept: dropping them would hide an erasure.
917                let built = built.map(|(total, rows)| {
918                    let Some(ns) = self.facade.session_namespace() else {
919                        return (total, rows);
920                    };
921                    let kept = rows
922                        .into_iter()
923                        .filter(|r| {
924                            r["fields"]
925                                .get("namespace")
926                                .and_then(Value::as_str)
927                                .is_none_or(|got| got == ns)
928                        })
929                        .collect();
930                    (total, kept)
931                });
932                match built {
933                    Ok((total, rows)) => ok_json(json!({"ok": true, "total_ops": total, "grains": rows})),
934                    Err(e) => ok_json(json!({"ok": false, "error": e})),
935                }
936            }
937            ("GET", "/api/grain") => {
938                let hash = q("hash").unwrap_or_default();
939                match areev_core::error::Hash::from_hex(&hash)
940                    .and_then(|h| self.facade.get(&h))
941                {
942                    Ok(g) => ok_json(json!({
943                        "hash": g.hash.to_hex(),
944                        "type": format!("{:?}", g.grain_type).to_lowercase(),
945                        "fields": g.fields,
946                    })),
947                    Err(e) => ok_json(json!({"ok": false, "error": e.to_string()})),
948                }
949            }
950            ("GET", "/api/verify") => match self.facade.with_store(|m| m.verify()) {
951                Ok(r) => ok_json(json!({
952                    "integrity": r.integrity, "grains": r.grains,
953                    "hash_mismatches": r.hash_mismatches, "undecodable": r.undecodable,
954                })),
955                Err(e) => ok_json(json!({"ok": false, "error": e.to_string()})),
956            },
957            ("POST", "/api/segment") => {
958                // A pushed segment is imported straight into the live store,
959                // so it is a write — and one that never crosses the facade's
960                // verb checks. Gate it on the bound session's `write`, or a
961                // principal with no write grant could add grains here that
962                // `POST /api/cal` refuses it.
963                if let Err(e) = self
964                    .facade
965                    .authz()
966                    .check(areev_core::authz::Verb::Write, "*")
967                {
968                    return ("403 Forbidden", "application/json",
969                            json!({"ok": false, "error": e.to_string()}).to_string().into_bytes());
970                }
971                // push: body = raw MGB1 bundle; applied immediately + archived
972                let Some(dir) = &self.segment_dir else {
973                    return ("400 Bad Request", "application/json",
974                            br#"{"ok":false,"error":"not in hub mode"}"#.to_vec());
975                };
976                let name = q("name").unwrap_or_else(|| format!("push-{}.mgb", now_label()));
977                let Some(safe) = safe_segment_name(&name) else {
978                    return ("400 Bad Request", "application/json",
979                            br#"{"ok":false,"error":"invalid segment name"}"#.to_vec());
980                };
981                let path = format!("{dir}/{safe}");
982                if std::fs::write(&path, body).is_err() {
983                    return ("500 Internal Server Error", "application/json",
984                            br#"{"ok":false,"error":"write failed"}"#.to_vec());
985                }
986                match self.facade.with_store(|m| m.import_bundle(&path)) {
987                    Ok(st) => ok_json(json!({"ok": true, "applied": st.applied, "skipped": st.skipped, "stored": safe})),
988                    Err(e) => ok_json(json!({"ok": false, "error": e.to_string()})),
989                }
990            }
991            ("GET", "/api/segments") | ("GET", "/api/segment") if !self.session_may_sync() => (
992                "403 Forbidden",
993                "application/json",
994                br#"{"ok":false,"error":"AUT-E001: this session lacks read on the whole memory"}"#.to_vec(),
995            ),
996            ("GET", "/api/segments") => {
997                let Some(dir) = &self.segment_dir else {
998                    return ok_json(json!([]));
999                };
1000                let mut names: Vec<String> = std::fs::read_dir(dir)
1001                    .map(|d| d.filter_map(|e| e.ok().map(|e| e.file_name().to_string_lossy().to_string()))
1002                        .filter(|n| n.ends_with(".mgb")).collect())
1003                    .unwrap_or_default();
1004                names.sort();
1005                ok_json(json!(names))
1006            }
1007            ("GET", "/api/segment") => {
1008                let Some(dir) = &self.segment_dir else {
1009                    return ("400 Bad Request", "application/json", b"{}".to_vec());
1010                };
1011                let name = q("name").unwrap_or_default();
1012                let Some(safe) = safe_segment_name(&name) else {
1013                    return ("400 Bad Request", "application/json",
1014                            br#"{"ok":false,"error":"invalid segment name"}"#.to_vec());
1015                };
1016                match std::fs::read(format!("{dir}/{safe}")) {
1017                    Ok(b) => ("200 OK", "application/octet-stream", b),
1018                    Err(_) => ("404 Not Found", "application/json",
1019                               br#"{"ok":false,"error":"no such segment"}"#.to_vec()),
1020                }
1021            }
1022            // ── Areev Loop API (§5.4) — GETs are reads (token-less OK); the POST
1023            //    mutations are guarded above (token-less → 401). ────────────
1024            // ── /api/run/*: the governed runtime's console surface ──
1025            ("GET", "/api/run/list") => {
1026                let runner = self.runner("console:read");
1027                match runner.recent_runs(50) {
1028                    Ok(ids) => {
1029                        let rows: Vec<Value> = ids
1030                            .iter()
1031                            .map(|id| {
1032                                let outcome = self.facade.with_store(|m| {
1033                                    m.recent(
1034                                        "agent:harness",
1035                                        Some(areev_core::types::GrainType::Observation),
1036                                        4096,
1037                                    )
1038                                })
1039                                .ok()
1040                                .and_then(|rows| {
1041                                    rows.into_iter().find(|g| {
1042                                        g.get_str("run_id") == Some(id.as_str())
1043                                            && g.get_str("observation_kind")
1044                                                == Some("run_outcome")
1045                                    })
1046                                });
1047                                serde_json::json!({
1048                                    "run_id": id,
1049                                    "outcome": outcome.as_ref().and_then(|g| g.get_str("object")).unwrap_or("open"),
1050                                })
1051                            })
1052                            .collect();
1053                        ok_json(serde_json::json!({"ok": true, "runs": rows}))
1054                    }
1055                    Err(e) => run_api_err(&e.to_string()),
1056                }
1057            }
1058            ("GET", "/api/run/inspect") => {
1059                let Some(run_id) = q("run_id") else {
1060                    return run_api_err("run_id required");
1061                };
1062                let manifest = self
1063                    .facade
1064                    .with_store(|m| areev_run::RunManifest::load(m, &run_id));
1065                let manifest = match manifest {
1066                    Ok(m) => m,
1067                    Err(e) => return run_api_err(&e.to_string()),
1068                };
1069                let ns = self.facade.default_namespace().unwrap_or("shared").to_string();
1070                let view = self
1071                    .facade
1072                    .with_store(|m| areev_run::journal::load(m, &ns, &run_id));
1073                let (checkpoints, entries, scheduler) = match view {
1074                    Ok(v) => {
1075                        let sched = v
1076                            .checkpoints
1077                            .last()
1078                            .map(|c| c.scheduler.clone())
1079                            .unwrap_or(Value::Null);
1080                        (v.checkpoints.len(), v.entries.len(), sched)
1081                    }
1082                    Err(_) => (0, 0, Value::Null),
1083                };
1084                ok_json(serde_json::json!({
1085                    "ok": true,
1086                    "run_id": run_id,
1087                    "plan_hash": manifest.plan_hash,
1088                    "principal": manifest.principal,
1089                    "checkpoints": checkpoints,
1090                    "journal_entries": entries,
1091                    "spent": scheduler.get("spent"),
1092                    "pending_asks": scheduler.get("pending_asks"),
1093                }))
1094            }
1095            ("POST", "/api/run/respond") => {
1096                // [R3] SHARED-TOKEN APPROVALS ARE REFUSED: an approval whose
1097                // "identity" is a console-wide secret voids the
1098                // approver-identity edge of the provenance chain. Only a
1099                // per-principal credential (areev ui --auth) may respond.
1100                let Some(responder) = request_principal.clone() else {
1101                    return ("403 Forbidden", "application/json",
1102                        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());
1103                };
1104                let req: Value = serde_json::from_slice(body).unwrap_or(Value::Null);
1105                let (Some(run_id), Some(ask)) = (
1106                    req.get("run_id").and_then(Value::as_str),
1107                    req.get("tool_call_id").and_then(Value::as_str),
1108                ) else {
1109                    return run_api_err("run_id and tool_call_id required");
1110                };
1111                let result = req.get("result").cloned().unwrap_or(Value::Null);
1112                let is_error = req.get("is_error").and_then(Value::as_bool).unwrap_or(false);
1113                let runner = self.runner(&responder);
1114                match runner.respond(run_id, ask, result, is_error, &responder) {
1115                    Ok(()) => ok_json(serde_json::json!({
1116                        "ok": true, "responded": ask, "responder": responder,
1117                        "note": "resume the run to spend the compute",
1118                    })),
1119                    Err(e) => run_api_err(&e.to_string()),
1120                }
1121            }
1122            ("POST", "/api/run/cancel") => {
1123                // The kill switch keeps its LOW bar: any authenticated POST
1124                // may brake a run (the identity label still records who).
1125                let req: Value = serde_json::from_slice(body).unwrap_or(Value::Null);
1126                let Some(run_id) = req.get("run_id").and_then(Value::as_str) else {
1127                    return run_api_err("run_id required");
1128                };
1129                let because = req.get("because").and_then(Value::as_str).unwrap_or("canceled via console");
1130                let who = request_principal.clone().unwrap_or_else(|| "console:shared".into());
1131                let runner = self.runner(&who);
1132                match runner.cancel(run_id, &who, because) {
1133                    Ok(()) => ok_json(serde_json::json!({"ok": true, "canceled": run_id})),
1134                    Err(e) => run_api_err(&e.to_string()),
1135                }
1136            }
1137            ("GET", "/api/loop/recommendations") => {
1138                let status = q("status").and_then(|s| status_from_str(&s));
1139                let sub = BorrowedSubstrate::new(&self.facade);
1140                match self.engine().recommendations(&sub, status) {
1141                    Ok(recs) => ok_json(json!({
1142                        "ok": true,
1143                        "recommendations": recs.iter().map(rec_json).collect::<Vec<_>>(),
1144                    })),
1145                    Err(e) => ok_json(json!({"ok": false, "error": e.to_string(), "code": e.code()})),
1146                }
1147            }
1148            ("GET", "/api/loop/health") => {
1149                let sub = BorrowedSubstrate::new(&self.facade);
1150                match self.engine().health(&sub, now_ms()) {
1151                    Ok(h) => ok_json(json!({"ok": true, "health": h})),
1152                    Err(e) => ok_json(json!({"ok": false, "error": e.to_string(), "code": e.code()})),
1153                }
1154            }
1155            ("GET", "/api/loop/outcomes") => {
1156                let sub = BorrowedSubstrate::new(&self.facade);
1157                match self.engine().outcomes(&sub) {
1158                    Ok(o) => ok_json(json!({"ok": true, "outcomes": o})),
1159                    Err(e) => ok_json(json!({"ok": false, "error": e.to_string(), "code": e.code()})),
1160                }
1161            }
1162            ("GET", "/api/loop/analyzers") => {
1163                // Effective settings (manifest merged with the file-config), so
1164                // the Setup view renders accurate on/off state and floors.
1165                let sub = BorrowedSubstrate::new(&self.facade);
1166                match self.engine().analyzer_settings(&sub) {
1167                    Ok(list) => ok_json(json!({"ok": true, "analyzers": list})),
1168                    Err(e) => ok_json(json!({"ok": false, "error": e.to_string(), "code": e.code()})),
1169                }
1170            }
1171            ("GET", "/api/loop/telemetry") => {
1172                // Recall-telemetry rollups for the Sessions view. A read — open
1173                // in read-only mode like the other loop GETs.
1174                let mode = self.facade.with_store(|m| m.telemetry_mode());
1175                if mode == areev_store::TelemetryMode::Off {
1176                    ok_json(json!({"ok": true, "enabled": false}))
1177                } else {
1178                    let access = self.facade.with_store(|m| m.telemetry_access_stats(None));
1179                    let queries = self.facade.with_store(|m| m.telemetry_query_stats(None));
1180                    let budget = self.facade.with_store(|m| m.telemetry_budget_stats());
1181                    match (access, queries, budget) {
1182                        (Ok(mut a), Ok(mut q), Ok(b)) => {
1183                            // Most-recalled first; recurring-gap questions first.
1184                            a.sort_by_key(|x| std::cmp::Reverse(x.recall_count));
1185                            a.truncate(200);
1186                            q.sort_by_key(|x| std::cmp::Reverse(x.run_count));
1187                            q.truncate(200);
1188                            ok_json(json!({
1189                                "ok": true,
1190                                "enabled": true,
1191                                "mode": mode.as_str(),
1192                                "access": a.iter().map(|x| json!({
1193                                    "hash": x.hash, "recall_count": x.recall_count, "last_ms": x.last_ms,
1194                                })).collect::<Vec<_>>(),
1195                                "queries": q.iter().map(|x| json!({
1196                                    "sample": x.sample, "run_count": x.run_count,
1197                                    "empty_count": x.empty_count,
1198                                })).collect::<Vec<_>>(),
1199                                "budget": {
1200                                    "sample_count": b.sample_count,
1201                                    "overflow_count": b.overflow_count,
1202                                },
1203                            }))
1204                        }
1205                        _ => ok_json(json!({"ok": false, "error": "telemetry read failed"})),
1206                    }
1207                }
1208            }
1209            ("POST", "/api/loop/run") => {
1210                let authz = self.facade.authz();
1211                if !authz.is_owner()
1212                    && !authz.allows(areev_core::authz::Verb::LoopRun, areev_loop::LOOP_NS)
1213                {
1214                    return ("403 Forbidden", "application/json",
1215                            br#"{"ok":false,"error":"AUT-E001: this session lacks loop.run"}"#.to_vec());
1216                }
1217                let mut sub = BorrowedSubstrate::new(&self.facade);
1218                // The trigger is recorded as co-creator on LLM/external
1219                // findings, so the same principal cannot later approve them.
1220                // In credential-map mode the actor IS the bound principal —
1221                // a request cannot claim an identity.
1222                let actor = if self.credentials.is_some() {
1223                    authz.principal().to_string()
1224                } else {
1225                    serde_json::from_slice::<Value>(body)
1226                        .ok()
1227                        .and_then(|v| v.get("actor").and_then(Value::as_str).map(str::to_string))
1228                        .unwrap_or_else(|| "user:console".to_string())
1229                };
1230                let opts = RunOptions { triggering_actor: Some(actor), ..Default::default() };
1231                match self.engine().run(&mut sub, &opts, now_ms()) {
1232                    Ok(res) => ok_json(json!({"ok": true, "run": res})),
1233                    Err(e) => ok_json(json!({"ok": false, "error": e.to_string(), "code": e.code()})),
1234                }
1235            }
1236            ("POST", "/api/loop/review") => self.loop_review(body),
1237            ("POST", "/api/loop/apply") => self.loop_apply(body),
1238            ("POST", "/api/loop/rollback") => self.loop_rollback(body),
1239            ("POST", "/api/loop/config") => self.loop_config(body),
1240            _ => (
1241                "404 Not Found",
1242                "application/json",
1243                br#"{"ok":false,"error":"not found"}"#.to_vec(),
1244            ),
1245        }
1246    }
1247
1248    /// The console session's scopes derive from its grants (an owner
1249    /// session — today's only mode — holds all of them); actor
1250    /// `user:console` unless overridden, observer derived from the actor
1251    /// label, never from request text.
1252    fn loop_review(&self, body: &[u8]) -> (&'static str, &'static str, Vec<u8>) {
1253        let req: Value = serde_json::from_slice(body).unwrap_or(Value::Null);
1254        let hash = req.get("hash").and_then(Value::as_str).unwrap_or("");
1255        let because = req.get("because").and_then(Value::as_str).unwrap_or("");
1256        let bound = self.facade.authz();
1257        let actor = if self.credentials.is_some() {
1258            bound.principal().to_string()
1259        } else {
1260            req.get("actor").and_then(Value::as_str).unwrap_or("user:console").to_string()
1261        };
1262        let actor = actor.as_str();
1263        let decision = if req.get("decision").and_then(Value::as_str) == Some("reject") {
1264            Decision::Reject
1265        } else {
1266            Decision::Approve
1267        };
1268        let mut sub = BorrowedSubstrate::new(&self.facade);
1269        let scopes = areev_loop_adapter::scopes_for(&self.facade.authz());
1270        let observer = areev_loop_adapter::observer_for_principal(actor);
1271        match self.engine().review(
1272            &mut sub, hash, decision, actor, observer, &scopes, because, now_ms(),
1273        ) {
1274            Ok(()) => ok_json(json!({"ok": true})),
1275            Err(e) => ok_json(json!({"ok": false, "error": e.to_string(), "code": e.code()})),
1276        }
1277    }
1278
1279    fn loop_apply(&self, body: &[u8]) -> (&'static str, &'static str, Vec<u8>) {
1280        let req: Value = serde_json::from_slice(body).unwrap_or(Value::Null);
1281        let hash = req.get("hash").and_then(Value::as_str).unwrap_or("");
1282        let because = req.get("because").and_then(Value::as_str).unwrap_or("");
1283        let bound = self.facade.authz();
1284        let actor = if self.credentials.is_some() {
1285            bound.principal().to_string()
1286        } else {
1287            req.get("actor").and_then(Value::as_str).unwrap_or("user:console").to_string()
1288        };
1289        let actor = actor.as_str();
1290        let allow_destructive = req.get("allow_destructive").and_then(Value::as_bool).unwrap_or(false);
1291        let mut sub = BorrowedSubstrate::new(&self.facade);
1292        let scopes = areev_loop_adapter::scopes_for(&self.facade.authz());
1293        let observer = areev_loop_adapter::observer_for_principal(actor);
1294        match self.engine().apply(
1295            &mut sub, hash, actor, observer, &scopes, because, allow_destructive, now_ms(),
1296        ) {
1297            Ok(applied) => ok_json(json!({"ok": true, "rollbackable": applied.rollbackable})),
1298            Err(e) => ok_json(json!({"ok": false, "error": e.to_string(), "code": e.code()})),
1299        }
1300    }
1301
1302    fn loop_rollback(&self, body: &[u8]) -> (&'static str, &'static str, Vec<u8>) {
1303        let req: Value = serde_json::from_slice(body).unwrap_or(Value::Null);
1304        let hash = req.get("hash").and_then(Value::as_str).unwrap_or("");
1305        let because = req.get("because").and_then(Value::as_str).unwrap_or("");
1306        let bound = self.facade.authz();
1307        let actor = if self.credentials.is_some() {
1308            bound.principal().to_string()
1309        } else {
1310            req.get("actor").and_then(Value::as_str).unwrap_or("user:console").to_string()
1311        };
1312        let actor = actor.as_str();
1313        let mut sub = BorrowedSubstrate::new(&self.facade);
1314        let scopes = areev_loop_adapter::scopes_for(&self.facade.authz());
1315        let observer = areev_loop_adapter::observer_for_principal(actor);
1316        match self.engine().rollback(
1317            &mut sub, hash, actor, observer, &scopes, because, now_ms(),
1318        ) {
1319            Ok(()) => ok_json(json!({"ok": true})),
1320            Err(e) => ok_json(json!({"ok": false, "error": e.to_string(), "code": e.code()})),
1321        }
1322    }
1323
1324    /// Edit one analyzer's file-config from the Setup view. The body is
1325    /// `{analyzer_id, enabled?, severity_floor?, clear_floor?, params?,
1326    /// namespaces?}`; absent fields are left unchanged. The console holds all
1327    /// scopes (local root of trust), so `Admin` is satisfied; an unknown
1328    /// analyzer or bad param is a structured `ok:false` (not a 500).
1329    fn loop_config(&self, body: &[u8]) -> (&'static str, &'static str, Vec<u8>) {
1330        let id = serde_json::from_slice::<Value>(body)
1331            .ok()
1332            .and_then(|v| v.get("analyzer_id").and_then(Value::as_str).map(str::to_string))
1333            .unwrap_or_default();
1334        // The update reads the same body; the extra `analyzer_id` key is ignored.
1335        let update: areev_loop::AnalyzerConfigUpdate = serde_json::from_slice(body).unwrap_or_default();
1336        let mut sub = BorrowedSubstrate::new(&self.facade);
1337        match self.engine().set_analyzer_config(&mut sub, &id, update, &areev_loop_adapter::scopes_for(&self.facade.authz())) {
1338            Ok(cfg) => ok_json(json!({"ok": true, "config": cfg})),
1339            Err(e) => ok_json(json!({"ok": false, "error": e.to_string(), "code": e.code()})),
1340        }
1341    }
1342}
1343
1344/// True when a `host[:port]` (or `[ipv6]:port`) authority names a loopback
1345/// host. Shared by the Origin drive-by check and the Host-header
1346/// (DNS-rebinding) check.
1347fn host_is_local(host_port: &str) -> bool {
1348    let host = if let Some(h) = host_port.strip_prefix('[') {
1349        h.split(']').next().unwrap_or("")
1350    } else {
1351        host_port.split(':').next().unwrap_or("")
1352    };
1353    matches!(host, "127.0.0.1" | "localhost" | "::1")
1354}
1355
1356/// True when an Origin header names a loopback host (any port, http/https).
1357fn origin_is_local(origin: &str) -> bool {
1358    let rest = origin
1359        .strip_prefix("http://")
1360        .or_else(|| origin.strip_prefix("https://"));
1361    let Some(rest) = rest else { return false };
1362    let host_port = rest.split('/').next().unwrap_or("");
1363    host_is_local(host_port)
1364}
1365
1366fn now_label() -> String {
1367    format!("{}", std::time::SystemTime::now()
1368        .duration_since(std::time::UNIX_EPOCH)
1369        .map(|d| d.as_millis())
1370        .unwrap_or(0))
1371}
1372
1373fn ok_json(v: Value) -> (&'static str, &'static str, Vec<u8>) {
1374    ("200 OK", "application/json", v.to_string().into_bytes())
1375}
1376
1377fn run_api_err(msg: &str) -> (&'static str, &'static str, Vec<u8>) {
1378    (
1379        "400 Bad Request",
1380        "application/json",
1381        serde_json::json!({"ok": false, "error": msg}).to_string().into_bytes(),
1382    )
1383}
1384
1385/// True when a `POST /api/cal` body is a read-only statement. Classification
1386/// is delegated to `areev-cal`'s exhaustive [`areev_cal::classify`] module —
1387/// the single source of truth — so grammar growth cannot desynchronize this
1388/// gate (a keyword sniff lived here before and would have silently
1389/// misclassified any new statement). Fail closed: unparseable text is not
1390/// read-only.
1391fn cal_body_is_read_only(body: &[u8]) -> bool {
1392    let req: Value = serde_json::from_slice(body).unwrap_or(Value::Null);
1393    let q = req.get("query").and_then(Value::as_str).unwrap_or("");
1394    areev_cal::classify::query_is_read_only(q)
1395}
1396
1397fn status_from_str(s: &str) -> Option<RecStatus> {
1398    match s {
1399        "pending" => Some(RecStatus::Pending),
1400        "approved" => Some(RecStatus::Approved),
1401        "rejected" => Some(RecStatus::Rejected),
1402        "applied" => Some(RecStatus::Applied),
1403        "rolled_back" => Some(RecStatus::RolledBack),
1404        "expired" => Some(RecStatus::Expired),
1405        _ => None, // includes "all"
1406    }
1407}
1408
1409fn rec_json(r: &areev_loop::Recommendation) -> Value {
1410    json!({
1411        "hash": r.hash,
1412        "status": r.status.as_str(),
1413        "severity": r.severity.as_str(),
1414        "analyzer": r.analyzer,
1415        "summary": r.summary.render(),
1416        "target_ref": r.target_ref,
1417        "destructive": r.destructive,
1418        "rollbackable": r.rollbackable,
1419        "evidence": r.evidence,
1420    })
1421}
1422
1423fn urldecode(s: &str) -> String {
1424    let mut out = Vec::new();
1425    let b = s.as_bytes();
1426    let mut i = 0;
1427    while i < b.len() {
1428        match b[i] {
1429            b'%' if i + 2 < b.len() + 1 && i + 2 < b.len() + 1 => {
1430                if i + 2 < b.len() + 1 && i + 2 < b.len() {
1431                    let hex = std::str::from_utf8(&b[i + 1..i + 3]).unwrap_or("");
1432                    if let Ok(v) = u8::from_str_radix(hex, 16) {
1433                        out.push(v);
1434                        i += 3;
1435                        continue;
1436                    }
1437                }
1438                out.push(b[i]);
1439                i += 1;
1440            }
1441            b'+' => {
1442                out.push(b' ');
1443                i += 1;
1444            }
1445            c => {
1446                out.push(c);
1447                i += 1;
1448            }
1449        }
1450    }
1451    String::from_utf8_lossy(&out).to_string()
1452}
1453
1454fn html_escape(s: &str) -> String {
1455    s.replace('&', "&amp;").replace('<', "&lt;").replace('>', "&gt;")
1456}
1457
1458/// Constant-time byte comparison — avoids leaking the bearer token through
1459/// response timing. A length mismatch fails fast (token length is not secret).
1460fn ct_eq(a: &[u8], b: &[u8]) -> bool {
1461    if a.len() != b.len() {
1462        return false;
1463    }
1464    let mut diff = 0u8;
1465    for (x, y) in a.iter().zip(b.iter()) {
1466        diff |= x ^ y;
1467    }
1468    diff == 0
1469}
1470
1471/// Decode standard base64 (RFC 4648), enough for HTTP Basic credentials.
1472/// Hand-rolled to keep the server dependency-free. Returns `None` on any
1473/// invalid character or truncated input.
1474fn base64_decode(s: &str) -> Option<Vec<u8>> {
1475    fn val(c: u8) -> Option<u32> {
1476        match c {
1477            b'A'..=b'Z' => Some((c - b'A') as u32),
1478            b'a'..=b'z' => Some((c - b'a' + 26) as u32),
1479            b'0'..=b'9' => Some((c - b'0' + 52) as u32),
1480            b'+' => Some(62),
1481            b'/' => Some(63),
1482            _ => None,
1483        }
1484    }
1485    let s = s.trim().trim_end_matches('=');
1486    let mut out = Vec::with_capacity(s.len() * 3 / 4);
1487    let (mut acc, mut bits) = (0u32, 0u32);
1488    for &c in s.as_bytes() {
1489        acc = (acc << 6) | val(c)?;
1490        bits += 6;
1491        if bits >= 8 {
1492            bits -= 8;
1493            out.push((acc >> bits) as u8);
1494        }
1495    }
1496    Some(out)
1497}
1498
1499/// Extract the password from an HTTP Basic credential (`base64("user:pass")`).
1500/// The console ignores the username and treats the password as the token.
1501/// Returns `None` if the value is not valid base64/UTF-8 or has no `:`.
1502fn basic_auth_password(b64: &str) -> Option<String> {
1503    let text = String::from_utf8(base64_decode(b64)?).ok()?;
1504    text.split_once(':').map(|(_user, pass)| pass.to_string())
1505}
1506
1507/// Sanitize a client-supplied segment name into a single safe filename.
1508/// Strips anything outside `[A-Za-z0-9-._]`, then requires the result to be
1509/// exactly one normal path component — rejecting empty names, `.`/`..`, and
1510/// path separators so a push/pull cannot escape the segment directory.
1511fn safe_segment_name(name: &str) -> Option<String> {
1512    let safe: String = name
1513        .chars()
1514        .filter(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '.' | '_'))
1515        .collect();
1516    if safe.is_empty() || safe.len() > 128 {
1517        return None;
1518    }
1519    let mut comps = std::path::Path::new(&safe).components();
1520    match (comps.next(), comps.next()) {
1521        (Some(std::path::Component::Normal(c)), None) if c.to_str() == Some(safe.as_str()) => {
1522            Some(safe)
1523        }
1524        _ => None,
1525    }
1526}
1527
1528#[cfg(test)]
1529mod loop_route_tests {
1530    use super::UiServer;
1531    use areev_cal::AreevFacade;
1532    use areev_core::types::{Fact, Grain};
1533    use areev_store::Areev;
1534
1535    fn server(auth: Option<&str>) -> UiServer {
1536        let dir = tempfile::tempdir().unwrap();
1537        let path = dir.path().join("t.db");
1538        let mut store = Areev::open(path.to_str().unwrap()).unwrap();
1539        // Two identical facts → a duplicate-consolidation recommendation.
1540        store.add(&Fact::new("acme", "tier", "Enterprise").namespace("caller")).unwrap();
1541        store.add(&Fact::new("acme", "tier", "Enterprise").namespace("caller")).unwrap();
1542        std::mem::forget(dir); // keep the file alive for the server's lifetime
1543        let facade = AreevFacade::with_session(store, Some("caller".into()), None);
1544        let s = UiServer::new(facade, "test".into());
1545        match auth {
1546            Some(t) => s.with_auth(t.to_string()),
1547            None => s,
1548        }
1549    }
1550
1551    fn text(r: &(&str, &str, Vec<u8>)) -> String {
1552        String::from_utf8_lossy(&r.2).to_string()
1553    }
1554
1555    #[test]
1556    fn token_less_console_is_read_only() {
1557        let s = server(None);
1558        // A loop mutation (run) is a write → 401.
1559        assert!(s.route("POST", "/api/loop/run", b"{}", None, None).0.starts_with("401"));
1560        // A write CAL → 401.
1561        let w = s.route("POST", "/api/cal", br#"{"query":"ADD fact SET subject=\"x\""}"#, None, None);
1562        assert!(w.0.starts_with("401"), "write CAL must be 401: {}", text(&w));
1563        // A read CAL → 200.
1564        let r = s.route("POST", "/api/cal", br#"{"query":"RECALL facts WHERE subject = \"acme\""}"#, None, None);
1565        assert!(r.0.starts_with("200"), "read CAL allowed: {}", text(&r));
1566        // Areev Loop reads stay open token-less.
1567        assert!(s.route("GET", "/api/loop/recommendations", b"", None, None).0.starts_with("200"));
1568    }
1569
1570    #[test]
1571    fn telemetry_endpoint_is_an_open_read() {
1572        let s = server(None);
1573        let r = s.route("GET", "/api/loop/telemetry", b"", None, None);
1574        assert!(r.0.starts_with("200"), "telemetry read open: {}", text(&r));
1575        let body = text(&r);
1576        assert!(body.contains("\"ok\":true"), "{body}");
1577        // The test store opens bare (telemetry off) → enabled:false.
1578        assert!(body.contains("\"enabled\":false"), "{body}");
1579    }
1580
1581    #[test]
1582    fn authenticated_run_review_apply_roundtrip() {
1583        let s = server(Some("tok"));
1584        let run = s.route("POST", "/api/loop/run", b"{}", Some("tok"), None);
1585        assert!(run.0.starts_with("200"), "run: {}", text(&run));
1586        assert!(text(&run).contains("\"ran\""));
1587
1588        let list = s.route("GET", "/api/loop/recommendations?status=pending", b"", Some("tok"), None);
1589        let v: serde_json::Value = serde_json::from_slice(&list.2).unwrap();
1590        let recs = v["recommendations"].as_array().unwrap();
1591        assert!(!recs.is_empty(), "at least one recommendation");
1592        let hash = recs[0]["hash"].as_str().unwrap().to_string();
1593
1594        let rev = s.route(
1595            "POST",
1596            "/api/loop/review",
1597            format!(r#"{{"hash":"{hash}","decision":"approve","because":"ok"}}"#).as_bytes(),
1598            Some("tok"),
1599            None,
1600        );
1601        assert!(text(&rev).contains("\"ok\":true"), "review: {}", text(&rev));
1602
1603        let ap = s.route(
1604            "POST",
1605            "/api/loop/apply",
1606            format!(r#"{{"hash":"{hash}","because":"go"}}"#).as_bytes(),
1607            Some("tok"),
1608            None,
1609        );
1610        assert!(text(&ap).contains("\"ok\":true"), "apply: {}", text(&ap));
1611    }
1612
1613    #[test]
1614    fn config_edit_toggles_analyzer_via_console() {
1615        let s = server(Some("tok"));
1616        // Token-less write → 401 (guarded like every POST).
1617        assert!(s.route("POST", "/api/loop/config", b"{}", None, None).0.starts_with("401"));
1618
1619        // Read the analyzers; pick one that is on by default.
1620        let list = s.route("GET", "/api/loop/analyzers", b"", Some("tok"), None);
1621        let v: serde_json::Value = serde_json::from_slice(&list.2).unwrap();
1622        let id = v["analyzers"]
1623            .as_array()
1624            .unwrap()
1625            .iter()
1626            .find(|x| x["enabled"] == true)
1627            .expect("some analyzer is on")["id"]
1628            .as_str()
1629            .unwrap()
1630            .to_string();
1631
1632        // Disable it via the console endpoint.
1633        let post = s.route(
1634            "POST",
1635            "/api/loop/config",
1636            format!(r#"{{"analyzer_id":"{id}","enabled":false}}"#).as_bytes(),
1637            Some("tok"),
1638            None,
1639        );
1640        assert!(text(&post).contains("\"ok\":true"), "config: {}", text(&post));
1641
1642        // It reads back disabled.
1643        let list2 = s.route("GET", "/api/loop/analyzers", b"", Some("tok"), None);
1644        let v2: serde_json::Value = serde_json::from_slice(&list2.2).unwrap();
1645        let now = v2["analyzers"]
1646            .as_array()
1647            .unwrap()
1648            .iter()
1649            .find(|x| x["id"] == id.as_str())
1650            .unwrap();
1651        assert_eq!(now["enabled"], false, "toggled off and persisted");
1652    }
1653
1654    #[test]
1655    fn self_approval_surfaces_lop_code() {
1656        let s = server(Some("tok"));
1657        s.route("POST", "/api/loop/run", b"{}", Some("tok"), None);
1658        let list = s.route("GET", "/api/loop/recommendations?status=pending", b"", Some("tok"), None);
1659        let v: serde_json::Value = serde_json::from_slice(&list.2).unwrap();
1660        let rec = &v["recommendations"].as_array().unwrap()[0];
1661        let hash = rec["hash"].as_str().unwrap();
1662        let analyzer = rec["analyzer"].as_str().unwrap();
1663        let creator = format!("engine:{analyzer}");
1664        // The engine actor approving its own proposal is blocked.
1665        let rev = s.route(
1666            "POST",
1667            "/api/loop/review",
1668            format!(r#"{{"hash":"{hash}","decision":"approve","because":"self","actor":"{creator}"}}"#).as_bytes(),
1669            Some("tok"),
1670            None,
1671        );
1672        assert!(text(&rev).contains("LOP-E021"), "self-approval blocked: {}", text(&rev));
1673    }
1674}
1675
1676#[cfg(test)]
1677mod security_tests {
1678    use super::{base64_decode, basic_auth_password, ct_eq, safe_segment_name};
1679
1680    #[test]
1681    fn ct_eq_matches_only_equal() {
1682        assert!(ct_eq(b"secret-token", b"secret-token"));
1683        assert!(!ct_eq(b"secret-token", b"secret-toker"));
1684        assert!(!ct_eq(b"secret", b"secret-token")); // length mismatch
1685        assert!(ct_eq(b"", b""));
1686    }
1687
1688    #[test]
1689    fn base64_decode_roundtrips_known_vectors() {
1690        // RFC 4648 test vectors (with and without padding).
1691        assert_eq!(base64_decode("").unwrap(), b"");
1692        assert_eq!(base64_decode("Zg==").unwrap(), b"f");
1693        assert_eq!(base64_decode("Zm8=").unwrap(), b"fo");
1694        assert_eq!(base64_decode("Zm9v").unwrap(), b"foo");
1695        assert_eq!(base64_decode("Zm9vYmFy").unwrap(), b"foobar");
1696        // padding is optional for us
1697        assert_eq!(base64_decode("Zg").unwrap(), b"f");
1698        // invalid characters are rejected
1699        assert!(base64_decode("****").is_none());
1700    }
1701
1702    #[test]
1703    fn basic_auth_extracts_password_ignoring_username() {
1704        // base64("areev:s3cret") and base64(":s3cret") both yield the token.
1705        assert_eq!(basic_auth_password("ZGVqYTpzM2NyZXQ=").as_deref(), Some("s3cret"));
1706        assert_eq!(basic_auth_password("OnMzY3JldA==").as_deref(), Some("s3cret"));
1707        // a password may itself contain ':' — only the first ':' splits.
1708        // base64("u:a:b") -> "a:b"
1709        assert_eq!(basic_auth_password("dTphOmI=").as_deref(), Some("a:b"));
1710        // no ':' at all → not a valid Basic credential
1711        assert_eq!(basic_auth_password("bm9jb2xvbg=="), None); // "nocolon"
1712        assert_eq!(basic_auth_password("****"), None); // not base64
1713    }
1714
1715    #[test]
1716    fn safe_segment_name_blocks_traversal() {
1717        assert_eq!(safe_segment_name("push-123.mgb").as_deref(), Some("push-123.mgb"));
1718        assert_eq!(safe_segment_name(".."), None);
1719        assert_eq!(safe_segment_name("."), None);
1720        assert_eq!(safe_segment_name(""), None);
1721        // Separators are stripped, so any accepted name is a single component
1722        // that cannot escape the segment directory.
1723        for probe in ["../../etc/passwd", "/etc/passwd", "a/../b", "foo/bar"] {
1724            if let Some(s) = safe_segment_name(probe) {
1725                assert!(!s.contains('/') && s != ".." && s != ".", "unsafe: {s}");
1726            }
1727        }
1728    }
1729}
1730
1731#[cfg(test)]
1732mod credential_route_tests {
1733    use super::UiServer;
1734    use areev_cal::AreevFacade;
1735    use areev_core::authz::{CredentialMap, AUTHZ_NS, REL_PERMITS};
1736    use areev_core::types::{Fact, Grain};
1737    use areev_store::Areev;
1738
1739    /// A server in credential-map mode: two env-referenced tokens, rights
1740    /// seeded as grant grains in the file itself.
1741    fn server() -> UiServer {
1742        std::env::set_var("AREEV_TEST_READER_TOK", "reader-secret");
1743        std::env::set_var("AREEV_TEST_WRITER_TOK", "writer-secret");
1744        let dir = tempfile::tempdir().unwrap();
1745        let path = dir.path().join("t.db");
1746        let mut store = Areev::open(path.to_str().unwrap()).unwrap();
1747        store
1748            .add(&Fact::new("user:reader", REL_PERMITS, "read ON *").namespace(AUTHZ_NS).created_at(1_000))
1749            .unwrap();
1750        store
1751            .add(
1752                &Fact::new("agent:writer", REL_PERMITS, "read,write ON caller")
1753                    .namespace(AUTHZ_NS)
1754                    .created_at(2_000),
1755            )
1756            .unwrap();
1757        store.add(&Fact::new("acme", "tier", "Enterprise").namespace("caller").created_at(3_000)).unwrap();
1758        std::mem::forget(dir);
1759        let facade = AreevFacade::with_session(store, Some("caller".into()), None);
1760        let map = CredentialMap::from_json(
1761            r#"{"version":1,"tokens":[
1762                {"env":"AREEV_TEST_READER_TOK","principal":"user:reader"},
1763                {"env":"AREEV_TEST_WRITER_TOK","principal":"agent:writer"}
1764            ]}"#,
1765        )
1766        .unwrap();
1767        UiServer::new(facade, "test".into()).with_credentials(map)
1768    }
1769
1770    fn text(r: &(&str, &str, Vec<u8>)) -> String {
1771        String::from_utf8_lossy(&r.2).to_string()
1772    }
1773
1774    const READ: &[u8] = br#"{"query":"RECALL facts WHERE subject = \"acme\""}"#;
1775    const WRITE: &[u8] = br#"{"query":"ADD fact SET subject = \"x\" SET relation = \"r\" SET object = \"o\" SET namespace = \"caller\" REASON \"t\""}"#;
1776
1777    #[test]
1778    fn tokens_bind_principals_and_verbs_decide() {
1779        let s = server();
1780
1781        // The reader reads…
1782        let r = s.route("POST", "/api/cal", READ, Some("reader-secret"), None);
1783        assert!(r.0.starts_with("200"), "{}", text(&r));
1784        assert!(text(&r).contains("grains"));
1785        // …and cannot write.
1786        let w = s.route("POST", "/api/cal", WRITE, Some("reader-secret"), None);
1787        assert!(text(&w).contains("AUT-E001"), "reader write must be refused: {}", text(&w));
1788
1789        // The writer writes.
1790        let w = s.route("POST", "/api/cal", WRITE, Some("writer-secret"), None);
1791        assert!(text(&w).contains("added"), "writer write must land: {}", text(&w));
1792
1793        // Sequential isolation: the very next reader request is still
1794        // read-only — nothing leaked from the writer's binding.
1795        let w = s.route("POST", "/api/cal", WRITE, Some("reader-secret"), None);
1796        assert!(text(&w).contains("AUT-E001"), "{}", text(&w));
1797    }
1798
1799    #[test]
1800    fn anonymous_is_read_only_and_loop_run_is_verb_gated() {
1801        let s = server();
1802        // No token → anonymous: reads work…
1803        let r = s.route("POST", "/api/cal", READ, None, None);
1804        assert!(r.0.starts_with("200"), "{}", text(&r));
1805        assert!(text(&r).contains("grains"));
1806        // …writes are verb-refused…
1807        let w = s.route("POST", "/api/cal", WRITE, None, None);
1808        assert!(text(&w).contains("AUT-E001"), "{}", text(&w));
1809        // …and an unknown token is anonymous too, never an escalation.
1810        let w = s.route("POST", "/api/cal", WRITE, Some("stolen-guess"), None);
1811        assert!(text(&w).contains("AUT-E001"), "{}", text(&w));
1812
1813        // The analysis trigger needs loop.run — anonymous gets 403.
1814        let run = s.route("POST", "/api/loop/run", b"{}", None, None);
1815        assert!(run.0.starts_with("403"), "{}", text(&run));
1816    }
1817
1818    #[test]
1819    fn whoami_reports_the_bound_principal() {
1820        let s = server();
1821        let r = s.route("GET", "/api/whoami", b"", Some("reader-secret"), None);
1822        assert!(text(&r).contains("user:reader"), "{}", text(&r));
1823        assert!(text(&r).contains("credentials"));
1824        let r = s.route("GET", "/api/whoami", b"", None, None);
1825        assert!(text(&r).contains("anonymous"), "{}", text(&r));
1826    }
1827
1828    /// A credential map must not silently disable `--token-env`. Attaching
1829    /// one changes WHO a request is; the operator who also asked for a
1830    /// shared secret still gets authentication on every request.
1831    #[test]
1832    fn a_credential_map_does_not_disable_the_shared_secret() {
1833        let s = server().with_auth("console-token".into());
1834
1835        // No credential at all: refused, not dropped to `anonymous`.
1836        let r = s.route("POST", "/api/cal", READ, None, None);
1837        assert!(r.0.starts_with("401"), "{}", text(&r));
1838        let page = s.route("GET", "/", b"", None, None);
1839        assert!(page.0.starts_with("401"), "the console page is guarded too");
1840        // An unrecognized token is refused rather than downgraded.
1841        let r = s.route("POST", "/api/cal", READ, Some("stolen-guess"), None);
1842        assert!(r.0.starts_with("401"), "{}", text(&r));
1843
1844        // A map token still binds its principal and its verbs still decide.
1845        let r = s.route("POST", "/api/cal", READ, Some("reader-secret"), None);
1846        assert!(r.0.starts_with("200"), "{}", text(&r));
1847        let w = s.route("POST", "/api/cal", WRITE, Some("reader-secret"), None);
1848        assert!(text(&w).contains("AUT-E001"), "{}", text(&w));
1849
1850        // The shared secret is the implied admin (docs/cal-all-you-need
1851        // -proposal.md §5.2b) — it keeps the owner session it always had.
1852        let w = s.route("POST", "/api/cal", WRITE, Some("console-token"), None);
1853        assert!(text(&w).contains("added"), "{}", text(&w));
1854    }
1855
1856    /// `RUN LOOP` through CAL is gated exactly as `POST /api/loop/run` is —
1857    /// otherwise the CAL surface is a way around the HTTP gate.
1858    #[test]
1859    fn run_loop_via_cal_is_verb_gated_like_the_route() {
1860        let s = server();
1861        let run = s.route("POST", "/api/loop/run", b"{}", None, None);
1862        assert!(run.0.starts_with("403"), "{}", text(&run));
1863        let cal = s.route("POST", "/api/cal", br#"{"query":"RUN LOOP"}"#, None, None);
1864        assert!(
1865            text(&cal).contains("AUT-E001"),
1866            "anonymous must not run the loop through CAL either: {}",
1867            text(&cal)
1868        );
1869    }
1870}
1871
1872#[cfg(test)]
1873mod builder_tests {
1874    use super::UiServer;
1875    use areev_cal::AreevFacade;
1876    use areev_store::Areev;
1877
1878    fn server() -> UiServer {
1879        let dir = tempfile::tempdir().unwrap();
1880        let path = dir.path().join("t.db");
1881        let store = Areev::open(path.to_str().unwrap()).unwrap();
1882        std::mem::forget(dir);
1883        UiServer::new(AreevFacade::with_session(store, Some("caller".into()), None), "t".into())
1884    }
1885
1886    /// `areev ui --no-destructive-ops --policy FILE` calls these in this
1887    /// order. A later builder must not re-enable destruction the operator
1888    /// turned off — the console was announced as read-only.
1889    #[test]
1890    fn attaching_a_loop_policy_preserves_the_destructive_cap() {
1891        let s = server()
1892            .allow_destructive_ops(false)
1893            .with_loop_policy(areev_loop::Policy::default());
1894        let r = s.route(
1895            "POST",
1896            "/api/cal",
1897            br#"{"query":"FORGET sha256:684c6c9bda818630a870119d0726e4d242ed537af061658ef6f3acb158a2c67d"}"#,
1898            None,
1899            None,
1900        );
1901        let body = String::from_utf8_lossy(&r.2).to_string();
1902        assert!(
1903            body.contains("--no-destructive-ops") || r.0.starts_with("401"),
1904            "destruction must stay disabled: {body}"
1905        );
1906    }
1907}
1908
1909#[cfg(test)]
1910mod run_api_tests {
1911    use super::UiServer;
1912    use areev_cal::AreevFacade;
1913    use areev_core::authz::{CredentialMap, AUTHZ_NS, REL_PERMITS};
1914    use areev_core::types::{Fact, Grain, Tool, ToolKind, Workflow};
1915    use areev_store::Areev;
1916    use std::sync::Arc;
1917
1918    /// A server whose file holds a PARKED run (Client-gated approve node) —
1919    /// the HITL queue's fixture. Returns (server, ask id).
1920    fn parked_server() -> (UiServer, String) {
1921        std::env::set_var("AREEV_RUN_OFFICER_TOK", "officer-secret");
1922        std::env::set_var("AREEV_RUN_STARTER_TOK", "starter-secret");
1923        let dir = tempfile::tempdir().unwrap();
1924        let path = dir.path().join("t.db");
1925        let mut store = Areev::open(path.to_str().unwrap()).unwrap();
1926        for (who, what) in [
1927            ("user:officer", "read,run.respond ON ops"),
1928            ("user:starter", "read ON ops"),
1929        ] {
1930            store
1931                .add(&Fact::new(who, REL_PERMITS, what).namespace(AUTHZ_NS).created_at(900))
1932                .unwrap();
1933        }
1934        std::mem::forget(dir);
1935        let facade = Arc::new(AreevFacade::with_session(store, Some("ops".into()), None));
1936
1937        // Seed + run the plan to the park with a scripted runner.
1938        let (wf, _g, _a) = facade
1939            .with_store(|m| {
1940                let greet = Tool::new("greet")
1941                    .kind(ToolKind::Definition)
1942                    .tool_description("t")
1943                    .created_at(500)
1944                    .namespace("ops");
1945                let gh = m.add(&greet)?;
1946                let approve = Tool::new("approve")
1947                    .kind(ToolKind::Definition)
1948                    .tool_description("human approves")
1949                    .executor_kind(areev_core::types::ExecutorKind::Client)
1950                    .created_at(501)
1951                    .namespace("ops");
1952                let ah = m.add(&approve)?;
1953                let wf = Workflow::new(vec!["greet".into(), "approve".into()])
1954                    .edge("greet", "approve")
1955                    .bind("greet", &gh.to_hex())
1956                    .bind("approve", &ah.to_hex())
1957                    .created_at(502)
1958                    .namespace("ops");
1959                let wh = m.add(&wf)?;
1960                Ok::<_, areev_core::error::AreevError>((wh, gh, ah))
1961            })
1962            .unwrap();
1963        struct Ok1;
1964        impl areev_run::HostToolExecutor for Ok1 {
1965            fn execute(
1966                &self,
1967                t: &str,
1968                _h: &str,
1969                _i: &serde_json::Value,
1970                _k: &str,
1971            ) -> areev_run::ExecResult {
1972                areev_run::ExecResult::Ok(serde_json::json!({t.to_string(): true}))
1973            }
1974        }
1975        let runner = areev_run::Runner {
1976            facade: Arc::clone(&facade),
1977            clock: Arc::new(areev_run::ScriptedClock::new(
1978                (0..100).map(|i| 1_758_000_000_000 + i * 10).collect(),
1979            )),
1980            executor: Arc::new(Ok1),
1981            llm: None,
1982            observer: None,
1983            ns: "ops".into(),
1984            principal: "user:starter".into(),
1985        };
1986        let session = runner
1987            .start(&wf, "hitl-1", serde_json::json!({}), &areev_run::RunOptions {
1988                workers: 1,
1989                ..Default::default()
1990            })
1991            .unwrap();
1992        let areev_run::RunSession::Parked { envelope, .. } = session else {
1993            panic!("expected park")
1994        };
1995        let ask = envelope["asks"][0]["tool_call_id"].as_str().unwrap().to_string();
1996
1997        // Rebuild a facade-owning server over the SAME file is impossible
1998        // (single writer) — hand the server the same Arc'd facade via the
1999        // Arc-aware constructor path: UiServer::new takes AreevFacade, so
2000        // we build the server FIRST in real deployments; here we reuse the
2001        // internal Arc through a second UiServer field write. Instead:
2002        // drop the runner (facade Arc count drops) and unwrap.
2003        drop(runner);
2004        let facade = Arc::try_unwrap(facade).unwrap_or_else(|_| panic!("facade still shared"));
2005        let map = CredentialMap::from_json(
2006            r#"{"version":1,"tokens":[
2007                {"env":"AREEV_RUN_OFFICER_TOK","principal":"user:officer"},
2008                {"env":"AREEV_RUN_STARTER_TOK","principal":"user:starter"}
2009            ]}"#,
2010        )
2011        .unwrap();
2012        let server = UiServer::new(facade, "test".into())
2013            .with_auth("shared-console-secret".into())
2014            .with_credentials(map);
2015        (server, ask)
2016    }
2017
2018    fn body(run_id: &str, ask: &str) -> Vec<u8> {
2019        serde_json::json!({"run_id": run_id, "tool_call_id": ask, "result": {"approved": true}})
2020            .to_string()
2021            .into_bytes()
2022    }
2023
2024    #[test]
2025    fn shared_token_approvals_are_refused_and_principals_approve() {
2026        let (s, ask) = parked_server();
2027
2028        // The HITL queue is visible.
2029        let r = s.route("GET", "/api/run/list", b"", Some("shared-console-secret"), None);
2030        assert!(r.0.starts_with("200"), "{}", String::from_utf8_lossy(&r.2));
2031        let r = s.route(
2032            "GET",
2033            "/api/run/inspect?run_id=hitl-1",
2034            b"",
2035            Some("shared-console-secret"),
2036            None,
2037        );
2038        assert!(String::from_utf8_lossy(&r.2).contains(&ask), "ask visible in inspect");
2039
2040        // 1. The SHARED console secret cannot approve — identity is the point.
2041        let r = s.route("POST", "/api/run/respond", &body("hitl-1", &ask), Some("shared-console-secret"), None);
2042        assert!(r.0.starts_with("403"), "shared-token approval must refuse: {}", r.0);
2043
2044        // 2. The STARTER's own credential cannot approve their own run
2045        //    (separation of duties, enforced by the runtime).
2046        let r = s.route("POST", "/api/run/respond", &body("hitl-1", &ask), Some("starter-secret"), None);
2047        let text = String::from_utf8_lossy(&r.2).to_string();
2048        assert!(text.contains("responder") || text.contains("RUN-E012"), "{text}");
2049
2050        // 3. The officer's principal-bound credential approves.
2051        let r = s.route("POST", "/api/run/respond", &body("hitl-1", &ask), Some("officer-secret"), None);
2052        assert!(r.0.starts_with("200"), "{}", String::from_utf8_lossy(&r.2));
2053        assert!(String::from_utf8_lossy(&r.2).contains("user:officer"));
2054
2055        // 4. The kill switch keeps its low bar: shared token may cancel.
2056        let r = s.route(
2057            "POST",
2058            "/api/run/cancel",
2059            br#"{"run_id":"hitl-1","because":"test brake"}"#,
2060            Some("shared-console-secret"),
2061            None,
2062        );
2063        assert!(r.0.starts_with("200"), "{}", String::from_utf8_lossy(&r.2));
2064    }
2065}
2066
2067#[cfg(test)]
2068mod memory_scoped_token_tests {
2069    use super::UiServer;
2070    use areev_cal::AreevFacade;
2071    use areev_core::authz::{CredentialMap, AUTHZ_NS, REL_PERMITS};
2072    use areev_core::types::{Fact, Grain};
2073    use areev_store::Areev;
2074
2075    /// The enterprise-plane hub rule: one auth file, per-memory token
2076    /// scopes — a token listed for memory A is an UNKNOWN token on
2077    /// memory B's server.
2078    #[test]
2079    fn memory_scoped_tokens_do_not_cross_memories() {
2080        std::env::set_var("AREEV_SCOPED_TOK", "scoped-secret");
2081        let map_json = r#"{"version":1,"tokens":[
2082            {"env":"AREEV_SCOPED_TOK","principal":"agent:sync","memories":["memory-a"]}
2083        ]}"#;
2084        let server_for = |label: &str| {
2085            let dir = tempfile::tempdir().unwrap();
2086            let mut store = Areev::open(dir.path().join("t.db").to_str().unwrap()).unwrap();
2087            store
2088                .add(
2089                    &Fact::new("agent:sync", REL_PERMITS, "read,write ON caller")
2090                        .namespace(AUTHZ_NS)
2091                        .created_at(1_000),
2092                )
2093                .unwrap();
2094            store
2095                .add(&Fact::new("acme", "tier", "Enterprise").namespace("caller").created_at(2_000))
2096                .unwrap();
2097            std::mem::forget(dir);
2098            let facade = AreevFacade::with_session(store, Some("caller".into()), None);
2099            UiServer::new(facade, label.into())
2100                .with_credentials(CredentialMap::from_json(map_json).unwrap())
2101        };
2102        const WRITE: &[u8] = br#"{"query":"ADD fact SET subject = \"x\" SET relation = \"r\" SET object = \"o\" SET namespace = \"caller\" REASON \"t\""}"#;
2103
2104        // On memory-a the token authenticates and its grants let the write
2105        // through (no authorization refusal in the CAL payload).
2106        let a = server_for("memory-a");
2107        let r = a.route("POST", "/api/cal", WRITE, Some("scoped-secret"), None);
2108        let text = String::from_utf8_lossy(&r.2).to_string();
2109        assert!(r.0.starts_with("200") && !text.contains("AUT-E001"), "{text}");
2110
2111        // On memory-b the SAME token is an unknown credential: the request
2112        // runs as anonymous and the write is refused (AUT-E001 rides the
2113        // CAL payload — the console surfaces statement results in-band).
2114        let b = server_for("memory-b");
2115        let r = b.route("POST", "/api/cal", WRITE, Some("scoped-secret"), None);
2116        let text = String::from_utf8_lossy(&r.2).to_string();
2117        assert!(
2118            text.contains("AUT-E001") && text.contains("anonymous"),
2119            "scoped token must not cross memories: {text}"
2120        );
2121
2122        // An auth file with an EMPTY memories scope refuses to load.
2123        assert!(CredentialMap::from_json(
2124            r#"{"version":1,"tokens":[{"env":"AREEV_SCOPED_TOK","principal":"x","memories":[]}]}"#
2125        )
2126        .is_err());
2127    }
2128}
2129
2130#[cfg(all(test, feature = "tls"))]
2131mod tls_tests {
2132    use super::UiServer;
2133    use areev_cal::AreevFacade;
2134    use areev_store::Areev;
2135    use std::io::{Read, Write};
2136
2137    fn self_signed(dir: &std::path::Path) -> Option<(String, String)> {
2138        let cert = dir.join("cert.pem");
2139        let key = dir.join("key.pem");
2140        let status = std::process::Command::new("openssl")
2141            .args([
2142                "req", "-x509", "-newkey", "rsa:2048", "-nodes",
2143                "-keyout", key.to_str().unwrap(), "-out", cert.to_str().unwrap(),
2144                "-days", "1", "-subj", "/CN=localhost",
2145                "-addext", "subjectAltName=DNS:localhost,IP:127.0.0.1",
2146                "-addext", "basicConstraints=critical,CA:FALSE",
2147            ])
2148            .stdout(std::process::Stdio::null())
2149            .stderr(std::process::Stdio::null())
2150            .status();
2151        match status {
2152            Ok(s) if s.success() => Some((
2153                cert.to_str().unwrap().to_string(),
2154                key.to_str().unwrap().to_string(),
2155            )),
2156            _ => None, // no openssl on this host: the CI leg covers it
2157        }
2158    }
2159
2160    /// The §3.2 gate: a TLS round-trip succeeds, and a PLAINTEXT client on
2161    /// the same listener is refused (no silent downgrade).
2162    #[test]
2163    fn tls_round_trip_and_no_plaintext_downgrade() {
2164        let dir = tempfile::tempdir().unwrap();
2165        let Some((cert, key)) = self_signed(dir.path()) else {
2166            eprintln!("skipping: no openssl binary");
2167            return;
2168        };
2169        let store = Areev::open(dir.path().join("t.db").to_str().unwrap()).unwrap();
2170        let facade = AreevFacade::with_session(store, Some("caller".into()), None);
2171        let server = UiServer::new(facade, "tls-test".into())
2172            .with_tls(&cert, &key)
2173            .unwrap();
2174        let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
2175        let addr = listener.local_addr().unwrap();
2176        let handle = std::thread::spawn(move || {
2177            // Two connections: the TLS client, then the plaintext prober.
2178            for _ in 0..2 {
2179                if let Ok((s, _)) = listener.accept() {
2180                    let _ = server.handle(s);
2181                }
2182            }
2183        });
2184
2185        // 1. TLS round-trip: trust exactly our self-signed cert.
2186        let mut roots = rustls::RootCertStore::empty();
2187        for c in rustls_pemfile::certs(&mut std::io::BufReader::new(
2188            std::fs::File::open(&cert).unwrap(),
2189        )) {
2190            roots.add(c.unwrap()).unwrap();
2191        }
2192        let config = std::sync::Arc::new(
2193            rustls::ClientConfig::builder()
2194                .with_root_certificates(roots)
2195                .with_no_client_auth(),
2196        );
2197        let name = rustls::pki_types::ServerName::try_from("localhost").unwrap();
2198        let session = rustls::ClientConnection::new(config, name).unwrap();
2199        let tcp = std::net::TcpStream::connect(addr).unwrap();
2200        let mut tls = rustls::StreamOwned::new(session, tcp);
2201        tls.write_all(b"GET / HTTP/1.1\r\nHost: 127.0.0.1\r\nConnection: close\r\n\r\n")
2202            .unwrap();
2203        let mut response = String::new();
2204        let _ = tls.read_to_string(&mut response);
2205        assert!(response.starts_with("HTTP/1.1 200"), "{}", &response[..response.len().min(120)]);
2206        assert!(response.contains("Areev"), "console served over TLS");
2207
2208        // 2. No downgrade: plaintext HTTP on the TLS listener gets a
2209        // handshake failure, never a page.
2210        let mut plain = std::net::TcpStream::connect(addr).unwrap();
2211        plain
2212            .write_all(b"GET / HTTP/1.1\r\nHost: 127.0.0.1\r\nConnection: close\r\n\r\n")
2213            .unwrap();
2214        let mut buf = Vec::new();
2215        let _ = plain.read_to_end(&mut buf);
2216        let text = String::from_utf8_lossy(&buf);
2217        assert!(
2218            !text.contains("HTTP/1.1 200"),
2219            "plaintext must not be served on a TLS listener: {text}"
2220        );
2221        handle.join().unwrap();
2222    }
2223}
2224
2225#[cfg(test)]
2226mod sso_tests {
2227    use super::UiServer;
2228    use areev_cal::AreevFacade;
2229    use areev_core::authz::{AUTHZ_NS, REL_PERMITS};
2230    use areev_core::types::{Fact, Grain};
2231    use areev_store::Areev;
2232    use std::io::{Read, Write};
2233
2234    const WRITE: &[u8] = br#"{"query":"ADD fact SET subject = \"x\" SET relation = \"r\" SET object = \"o\" SET namespace = \"caller\" REASON \"t\""}"#;
2235
2236    fn sso_server() -> UiServer {
2237        let dir = tempfile::tempdir().unwrap();
2238        let mut store = Areev::open(dir.path().join("t.db").to_str().unwrap()).unwrap();
2239        store
2240            .add(
2241                &Fact::new("user:pat@example.com", REL_PERMITS, "read,write ON caller")
2242                    .namespace(AUTHZ_NS)
2243                    .created_at(1_000),
2244            )
2245            .unwrap();
2246        std::mem::forget(dir);
2247        let facade = AreevFacade::with_session(store, Some("caller".into()), None);
2248        UiServer::new(facade, "sso-test".into()).with_sso("x-forwarded-user", "proxy-secret")
2249    }
2250
2251    fn post(server: &UiServer, extra_headers: &str) -> String {
2252        let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
2253        let addr = listener.local_addr().unwrap();
2254        std::thread::scope(|scope| {
2255            scope.spawn(|| {
2256                if let Ok((s, _)) = listener.accept() {
2257                    let _ = server.handle(s);
2258                }
2259            });
2260            let mut c = std::net::TcpStream::connect(addr).unwrap();
2261            let req = format!(
2262                "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",
2263                WRITE.len()
2264            );
2265            c.write_all(req.as_bytes()).unwrap();
2266            c.write_all(WRITE).unwrap();
2267            let mut out = String::new();
2268            let _ = c.read_to_string(&mut out);
2269            out
2270        })
2271    }
2272
2273    /// The §3.3 gate: a proxy-proven identity lands as a named actor whose
2274    /// FILE grants decide; a forged identity header without the proxy
2275    /// secret is ignored (the write is refused as anonymous).
2276    #[test]
2277    fn proxied_identity_works_and_forged_headers_are_ignored() {
2278        let server = sso_server();
2279
2280        // 1. Proxy-proven: identity + secret → the granted principal writes.
2281        let ok = post(
2282            &server,
2283            "X-Forwarded-User: user:pat@example.com\r\nX-Areev-Proxy-Secret: proxy-secret\r\n",
2284        );
2285        assert!(ok.contains("HTTP/1.1 200"), "{}", &ok[..ok.len().min(200)]);
2286        assert!(!ok.contains("AUT-E001"), "granted principal must write: {ok}");
2287
2288        // 2. Forged: the identity header WITHOUT the secret is ignored — the
2289        // request is anonymous and the write refuses.
2290        let forged = post(&server, "X-Forwarded-User: user:pat@example.com\r\n");
2291        assert!(
2292            forged.contains("anonymous") || forged.contains("401"),
2293            "forged identity must not authenticate: {forged}"
2294        );
2295
2296        // 3. Wrong secret: same refusal (constant-time compare, same path).
2297        let wrong = post(
2298            &server,
2299            "X-Forwarded-User: user:pat@example.com\r\nX-Areev-Proxy-Secret: guess\r\n",
2300        );
2301        assert!(
2302            wrong.contains("anonymous") || wrong.contains("401"),
2303            "wrong proxy secret must not authenticate: {wrong}"
2304        );
2305    }
2306}