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