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