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