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
17/// Native OIDC (A3) — the second recorded dependency-policy exception, behind
18/// the non-default `oidc` feature. See the module docs for why a proxy
19/// cannot cover the case this exists for.
20#[cfg(feature = "oidc")]
21pub mod oidc;
22
23const CONSOLE_HTML: &str = include_str!("console.html");
24
25/// Per-connection read/write timeout — bounds slow-client (slowloris) attacks.
26const READ_TIMEOUT_SECS: u64 = 15;
27/// Hard ceiling on bytes read from one connection (1 MiB body cap + headroom
28/// for the request line and headers). Bounds memory against oversized requests.
29const MAX_CONN_BYTES: u64 = (1 << 20) + (128 << 10);
30/// Cap on total header bytes and header count per request.
31const MAX_HEADER_BYTES: usize = 64 * 1024;
32const MAX_HEADERS: usize = 100;
33/// Total wall-clock deadline for reading + handling one request. A per-read
34/// timeout cannot bound a slow-drip client (each byte resets it), so a watchdog
35/// shuts the socket down at this deadline.
36const REQUEST_DEADLINE_SECS: u64 = 30;
37/// Issue #126: an auth-failure streak from one source IP idle longer than
38/// this is treated as reset rather than accumulated against forever.
39const AUTH_FAILURE_IDLE_SECS: u64 = 15 * 60;
40/// Cap on distinct source IPs tracked for auth-failure throttling — the key
41/// space is attacker-controlled, so this bounds the memory an attacker can
42/// force the map to hold.
43const MAX_AUTH_FAILURE_IPS: usize = 4096;
44/// Longest accepted proxy-asserted identity. Principals are names, not
45/// documents; anything longer is a malformed or hostile header, and the
46/// string ends up in audit grains that replicate.
47const MAX_SSO_IDENTITY_BYTES: usize = 128;
48/// Principal names an SSO header may never assert.
49///
50/// `anonymous` is the restricted baseline and `user:console` is the owner
51/// default the per-request binding restores on drop — an identity header
52/// naming either would make audit records ambiguous between "a person the
53/// proxy vouched for" and "the console's own unauthenticated floor/ceiling".
54/// Neither is currently an escalation (`bind_principal` always builds a
55/// *restricted* set from the file's grants), but a name that reads as a
56/// system principal in an immutable, replicating audit trail is a defect
57/// whether or not it is exploitable today.
58const RESERVED_PRINCIPALS: [&str; 2] = ["anonymous", "user:console"];
59
60/// Consecutive failed authentications from one source IP after which further
61/// credential-bearing requests are refused with `429` until the streak goes
62/// idle (`AUTH_FAILURE_IDLE_SECS`).
63///
64/// Set high enough that a human fat-fingering a pasted token a few times is
65/// never locked out, and low enough that an online guessing attack gets a few
66/// attempts per quarter-hour instead of thousands per second. It is a brake
67/// on rate, not a replacement for token entropy — which is why `areev auth
68/// mint` (256-bit tokens) is the real control and this is the backstop.
69const MAX_CONSECUTIVE_AUTH_FAILURES: u32 = 10;
70
71
72/// One accepted connection: plaintext, or TLS when the server was built
73/// with the `tls` feature and configured with a certificate. The raw
74/// socket handle stays available for timeouts and the watchdog shutdown;
75/// all request/response bytes flow through this wrapper. A plaintext
76/// client hitting a TLS listener fails the handshake — there is no
77/// downgrade path by construction.
78enum Conn {
79    Plain(TcpStream),
80    #[cfg(feature = "tls")]
81    Tls(Box<rustls::StreamOwned<rustls::ServerConnection, TcpStream>>),
82}
83
84impl Read for Conn {
85    fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
86        match self {
87            Conn::Plain(s) => s.read(buf),
88            #[cfg(feature = "tls")]
89            Conn::Tls(s) => s.read(buf),
90        }
91    }
92}
93
94impl Write for Conn {
95    fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
96        match self {
97            Conn::Plain(s) => s.write(buf),
98            #[cfg(feature = "tls")]
99            Conn::Tls(s) => s.write(buf),
100        }
101    }
102    fn flush(&mut self) -> std::io::Result<()> {
103        match self {
104            Conn::Plain(s) => s.flush(),
105            #[cfg(feature = "tls")]
106            Conn::Tls(s) => s.flush(),
107        }
108    }
109}
110
111pub struct UiServer {
112    facade: std::sync::Arc<AreevFacade>,
113    executor: CalExecutor,
114    db_label: String,
115    /// `db_label`, redacted for DISPLAY (issue #124): every response body,
116    /// the served HTML, and any startup log line must use this, never the
117    /// raw label — on the Postgres backend `db_label` is a DSN with an
118    /// inline password. Computed once in `UiServer::new` via `redact_dsn`.
119    /// The raw `db_label` is kept only for the `resolve_for_memory` call
120    /// sites below, which must keep comparing against the exact `--db`
121    /// value a credential map's `memories` entry is written as.
122    db_label_display: String,
123    /// Shared secret required for access when set. It guards mutating
124    /// endpoints (`Bearer`); in console-auth mode (`auth_all`) it guards
125    /// every request via `Bearer` **or** HTTP `Basic` (password = token).
126    token: Option<String>,
127    /// When true, *every* request requires the token — the console page, all
128    /// reads, and all writes — and a `401` carries `WWW-Authenticate: Basic` so
129    /// browsers prompt. Set by [`UiServer::with_auth`].
130    auth_all: bool,
131    /// When false (the default), reject any request whose `Host` header is not
132    /// loopback — the standard DNS-rebinding defense for a localhost service.
133    /// Set true only when the operator intentionally serves to other hosts
134    /// (CLI `--allow-remote`), where a non-loopback `Host` is expected.
135    allow_remote: bool,
136    /// Origins (issue #125), normalized, whose cross-origin **POST**s are
137    /// accepted even though they are not loopback. The Origin drive-by
138    /// check is NOT lifted by `allow_remote` — HTTP Basic is browser-cached
139    /// and re-attached cross-site, so Origin is the only thing telling the
140    /// console's own page apart from an attacker's page riding a viewer's
141    /// cached credential. This is the exact-match allowlist an operator
142    /// populates instead (CLI `--allow-origin`), one entry per
143    /// [`allow_origin`](Self::allow_origin) call. No wildcards, no
144    /// suffix/subdomain matching.
145    allowed_origins: Vec<String>,
146    /// Host loop policy (§6.2) applied to the `/api/loop/*` engine — the
147    /// same `loop-policy.json` the CLI takes (`areev ui --policy`). Absent →
148    /// the closed default: nothing auto-applies, nothing is denied. Host
149    /// config, never persisted in the memory file.
150    loop_policy: Option<areev_loop::Policy>,
151    /// Multi-principal mode (`areev ui --auth areev-auth.json`): a credential
152    /// map resolving bearer/Basic tokens to principal names. Rights come
153    /// from the FILE's own grant grains — the map holds no policy and no
154    /// raw secrets. Unauthenticated requests run as `anonymous`
155    /// (read-only unless the file grants more). It changes *who* a request
156    /// is, never *whether* the shared `--token-env` secret is required: when
157    /// both are configured the secret keeps its contract (implied admin, and
158    /// still demanded on the requests it guards).
159    credentials: Option<areev_core::authz::CredentialMap>,
160    /// Whether destructive CAL is permitted, kept alongside the executor so
161    /// every builder can rebuild it without clobbering the others' settings.
162    allow_destructive_ops: bool,
163    /// Native TLS (the recorded dependency exception; `tls` cargo feature).
164    /// None = plaintext — the documented default is a TLS-terminating proxy.
165    #[cfg(feature = "tls")]
166    tls_config: Option<std::sync::Arc<rustls::ServerConfig>>,
167    /// SSO v0 — trusted-header auth (`areev ui --sso-header X-Forwarded-User
168    /// --sso-secret-env VAR`): an authenticating proxy does OIDC/SAML and
169    /// forwards the identity in this header. Honored ONLY when the request
170    /// also carries the proxy shared secret in `x-areev-proxy-secret` —
171    /// otherwise identity headers are attacker-controlled input and are
172    /// ignored entirely. Rights still come from the FILE's grant grains.
173    sso_header: Option<String>,
174    /// The proxy shared secrets this instance accepts, newest first.
175    ///
176    /// A list rather than a value so the secret can be **rotated without a
177    /// zero-overlap cutover** (#79). It is an impersonation-grade credential:
178    /// whoever holds it can present any identity header, approval-capable
179    /// principals included. Rotating one of those atomically across a proxy
180    /// fleet and a console is not achievable in practice, so the honest
181    /// choices were a brief outage or a window where the wrong secret is live
182    /// — and an operator facing either under suspected-compromise pressure
183    /// will defer the rotation, which is the outcome that actually costs.
184    ///
185    /// Two at a time, deliberately: enough for old-and-new, not enough to
186    /// accumulate a drawer of forgotten live credentials. Both are checked in
187    /// constant time and the comparison never short-circuits on the first
188    /// mismatch, so timing cannot reveal which one matched or how many are
189    /// configured.
190    sso_secrets: Vec<String>,
191    /// Optional groups header (A2, `--sso-groups-header X-Forwarded-Groups`):
192    /// a comma-separated list of IdP groups, honored under the same proxy
193    /// secret as `sso_header`. Resolved through the credential map's `groups`
194    /// table, and only when the asserted identity has no grants of its own —
195    /// see `resolve_sso_principal` for the precedence and why.
196    sso_groups_header: Option<String>,
197    /// Prefix stamped onto every proxy-asserted principal
198    /// (`--sso-principal-prefix`). Lets an operator keep IdP-sourced
199    /// identities visibly distinct from credential-map ones in grant grains
200    /// and audit records, so `GRANT` can target one population without
201    /// depending on IdP naming conventions never colliding with local ones.
202    sso_principal_prefix: Option<String>,
203    /// Native OIDC (A3, `oidc` feature). When configured, the console serves
204    /// its own login: `/auth/login` → the IdP → `/auth/callback` → an
205    /// `HttpOnly` session cookie. Unlike the trusted-header path, the
206    /// identity is proven by a signature over an issuer-published key set,
207    /// which is why an OIDC principal MAY approve by default — that
208    /// difference is the entire reason the feature exists.
209    #[cfg(feature = "oidc")]
210    oidc: Option<std::sync::Arc<oidc::OidcProvider>>,
211    /// Whether a proxy-asserted SSO identity may answer a HITL approval
212    /// (`POST /api/run/respond`). **Default false — deny.**
213    ///
214    /// `run.respond` already refuses shared-token and anonymous callers
215    /// because the approver's identity IS the audit record. An SSO identity
216    /// arrives in a header, trusted because the request also carried
217    /// `sso_secrets` — a static, shared, impersonation-grade value. Whoever
218    /// holds it can assert *any* identity, approval-capable principals
219    /// included, and the resulting audit grain is indistinguishable from a
220    /// genuine approval: a well-formed answer by a granted principal. The
221    /// blast radius of that one secret is the integrity of the whole HITL
222    /// trail — which is the control the governance story rests on.
223    ///
224    /// A credential-map principal is materially stronger (it holds a
225    /// per-principal secret, not a fleet-wide one) and is unaffected by this.
226    /// So the default fails closed and an operator who accepts the trade-off
227    /// opts in explicitly with `areev ui --sso-approvals allow`.
228    sso_approvals: bool,
229    /// Auth-failure counter (issue #126), keyed by source IP — the port is
230    /// deliberately excluded, since a NAT'd or proxied attacker's source
231    /// port varies per connection while the IP does not. Each entry is
232    /// `(consecutive failures, last-failure time)`; see
233    /// `note_auth_failure`/`reset_auth_failures`. The map is
234    /// attacker-controlled key space (one entry per source IP that ever
235    /// fails once) and is bounded at `MAX_AUTH_FAILURE_IPS` for exactly that
236    /// reason. The lock is held only for the map bookkeeping itself, never
237    /// across a response write.
238    ///
239    /// This counts and logs — it does **not** delay or lock out. See
240    /// `note_auth_failure` for why: `serve` is a strictly serial accept
241    /// loop, so any per-request sleep here would be a lever an
242    /// unauthenticated caller pulls to stall the console for everyone, not
243    /// just themselves.
244    auth_failures: std::sync::Mutex<std::collections::HashMap<std::net::IpAddr, (u32, std::time::Instant)>>,
245    /// The host's decision-backend chain, as `(describe(), calibrated())`,
246    /// reported by `GET /api/config` (decision-model proposal §2 rule 4:
247    /// every judgment that shaped output is attributable). A label, not the
248    /// backend: the console never calls it, and holding the trait object
249    /// would pull areev-llm into this crate for nothing.
250    decider: Option<(String, bool)>,
251}
252
253/// A login-flow error, rendered as text rather than JSON: the audience is a
254/// browser mid-redirect, not a script. The message is ours, never the IdP's
255/// raw response body.
256#[cfg(feature = "oidc")]
257fn auth_error(msg: &str) -> (String, String, String) {
258    (
259        "400 Bad Request".into(),
260        "Content-Type: text/plain; charset=utf-8\r\nCache-Control: no-store\r\n".into(),
261        format!("login failed: {msg}\n\nStart again at /auth/login\n"),
262    )
263}
264
265#[cfg(feature = "oidc")]
266fn not_found_auth() -> (String, String, String) {
267    (
268        "404 Not Found".into(),
269        "Content-Type: text/plain; charset=utf-8\r\n".into(),
270        "no such auth endpoint\n".into(),
271    )
272}
273
274/// One cookie's value out of a `Cookie:` header, or `None`.
275///
276/// Deliberately exact on the name: a `Cookie` header is attacker-influenced
277/// (any script on any same-site origin can set cookies), so a prefix match
278/// would let `areev_session_decoy=...` shadow the real one.
279#[cfg(feature = "oidc")]
280fn cookie_value(header: &str, name: &str) -> Option<String> {
281    header.split(';').find_map(|pair| {
282        let (k, v) = pair.split_once('=')?;
283        (k.trim() == name).then(|| v.trim().to_string())
284    })
285}
286
287/// Validate a proxy-asserted identity before it becomes a principal.
288///
289/// The proxy secret decides *who may assert*; it says nothing about whether
290/// what was asserted is well-formed. An identity flows into audit grains that
291/// are immutable and replicate, so a control character here is a log-injection
292/// that outlives the request — and a name colliding with a system principal is
293/// an ambiguity no later reader can resolve.
294///
295/// Returns `None` for anything rejected, which the caller treats exactly like
296/// an absent header: the request proceeds as whatever its other credentials
297/// make it. Refusing the *request* would let a misconfigured proxy take the
298/// console down; refusing the *identity* fails closed on rights instead.
299fn sanitize_sso_identity(raw: &str) -> Option<String> {
300    let id = raw.trim();
301    if id.is_empty() || id.len() > MAX_SSO_IDENTITY_BYTES {
302        return None;
303    }
304    // No control characters (CR/LF above all — header smuggling into logs),
305    // and no internal whitespace: a principal is one token, and " user:a"
306    // vs "user:a" must not be two audit identities for one person.
307    if id.chars().any(|c| c.is_control() || c.is_whitespace()) {
308        return None;
309    }
310    if RESERVED_PRINCIPALS.contains(&id) {
311        return None;
312    }
313    Some(id.to_string())
314}
315
316/// Per-request principal binding. The server handles one request at a
317/// time, so rebinding the shared facade for the request's duration is
318/// race-free; the guard restores the owner default on drop — including on
319/// panic — so a failed handler can never leak a stray principal into the
320/// next request.
321struct RequestBinding<'a> {
322    facade: &'a AreevFacade,
323}
324
325impl<'a> RequestBinding<'a> {
326    fn bind(
327        facade: &'a AreevFacade,
328        map: &areev_core::authz::CredentialMap,
329        bearer: Option<&str>,
330        memory: &str,
331    ) -> Self {
332        // Memory-scoped resolution: a credential whose `memories` list
333        // excludes THIS memory is indistinguishable from an unknown token.
334        let principal =
335            bearer.and_then(|t| map.resolve_for_memory(t, memory).ok().map(str::to_string));
336        match principal {
337            Some(p) => {
338                if facade.bind_principal(&p).is_err() {
339                    // A store failure reading grants fails toward LESS
340                    // privilege, never more.
341                    Self::bind_anonymous(facade);
342                }
343            }
344            None => Self::bind_anonymous(facade),
345        }
346        RequestBinding { facade }
347    }
348
349    /// `anonymous` = a read-only baseline plus whatever the file explicitly
350    /// grants the `anonymous` principal — the formalized version of the old
351    /// token-less read-only console.
352    /// Bind a proxy-proven SSO identity directly (no credential map row
353    /// needed — the IdP said who this is; the FILE says what they may do).
354    fn bind_identity(facade: &'a AreevFacade, principal: &str) -> Self {
355        if facade.bind_principal(principal).is_err() {
356            Self::bind_anonymous(facade);
357        }
358        RequestBinding { facade }
359    }
360
361    fn bind_anonymous(facade: &AreevFacade) {
362        use areev_core::authz::{AuthzSet, Grant, Verb};
363        let mut grants = facade
364            .with_store(|m| m.authz_grants("anonymous"))
365            .unwrap_or_default();
366        grants.push(Grant { verbs: vec![Verb::Read], namespaces: vec!["*".to_string()] });
367        facade.bind(AuthzSet::restricted("anonymous", grants));
368    }
369}
370
371impl Drop for RequestBinding<'_> {
372    fn drop(&mut self) {
373        self.facade
374            .bind(areev_core::authz::AuthzSet::owner("user:console"));
375    }
376}
377
378impl UiServer {
379    pub fn new(facade: AreevFacade, db_label: String) -> Self {
380        let db_label_display = redact_dsn(&db_label);
381        UiServer {
382            facade: std::sync::Arc::new(facade),
383            executor: CalExecutor::new(CalExecutorConfig::default())
384                .with_governance(std::sync::Arc::new(areev_loop_adapter::LoopGovernance::new())),
385            db_label,
386            db_label_display,
387            token: None,
388            auth_all: false,
389            allow_remote: false,
390            allowed_origins: Vec::new(),
391            loop_policy: None,
392            credentials: None,
393            allow_destructive_ops: CalExecutorConfig::default().allow_destructive_ops,
394            #[cfg(feature = "tls")]
395            tls_config: None,
396            sso_header: None,
397            sso_secrets: Vec::new(),
398            sso_groups_header: None,
399            sso_principal_prefix: None,
400            #[cfg(feature = "oidc")]
401            oidc: None,
402            sso_approvals: false,
403            auth_failures: std::sync::Mutex::new(std::collections::HashMap::new()),
404            decider: None,
405        }
406    }
407
408    /// Report the host's decision-backend chain in `GET /api/config` as
409    /// `decide: {chain, calibrated}` (`null` when none is configured).
410    /// `chain` is the backend's `describe()` and `calibrated` its
411    /// `calibrated()`; both are host config, never read from the file.
412    pub fn with_decider(mut self, chain: impl Into<String>, calibrated: bool) -> Self {
413        self.decider = Some((chain.into(), calibrated));
414        self
415    }
416
417    /// Enable trusted-header SSO (v0). `header` names the identity header
418    /// the authenticating proxy forwards; `secret` is the proxy's shared
419    /// secret, demanded in `x-areev-proxy-secret` on every SSO request.
420    pub fn with_sso(self, header: impl Into<String>, secret: impl Into<String>) -> Self {
421        self.with_sso_rotating(header, secret, None::<String>)
422    }
423
424    /// [`with_sso`](Self::with_sso) with a second secret accepted during a
425    /// rotation window (#79).
426    ///
427    /// `next` is the incoming secret; `secret` stays the one already deployed.
428    /// While both are configured either proves the proxy, so the fleet can be
429    /// moved over one node at a time and the old secret retired afterwards —
430    /// the way a TLS key is rotated. Order carries no privilege: they are
431    /// equally valid until one is removed, which is what makes the window
432    /// safe to run in both directions (roll forward, or roll back).
433    ///
434    /// The window is meant to be short. Nothing here enforces that — a
435    /// deadline the server could enforce would have to live in the file or a
436    /// clock it does not own — so retiring the old secret is the operator's
437    /// step, and `docs/runbooks/sso-secret-rotation.md` is the procedure.
438    pub fn with_sso_rotating(
439        mut self,
440        header: impl Into<String>,
441        secret: impl Into<String>,
442        next: Option<impl Into<String>>,
443    ) -> Self {
444        let header = header.into();
445        let secret = secret.into();
446        let next = next.map(Into::into).filter(|s| !s.trim().is_empty());
447        // An empty proxy secret would let `x-areev-proxy-secret:` (empty)
448        // prove any identity — refuse loudly at the library boundary too,
449        // not just in the CLI.
450        assert!(
451            !header.trim().is_empty() && !secret.trim().is_empty(),
452            "with_sso requires a non-empty header name and proxy secret"
453        );
454        // A rotation that deploys the same value twice is a rotation that did
455        // not happen, and it would read as "both live" in every log and
456        // status line. Refuse rather than silently accept the no-op.
457        assert!(
458            next.as_deref() != Some(secret.as_str()),
459            "the rotating proxy secret must differ from the current one"
460        );
461        self.sso_header = Some(header.to_ascii_lowercase());
462        self.sso_secrets = match next {
463            Some(n) => vec![secret, n],
464            None => vec![secret],
465        };
466        self
467    }
468
469    /// Serve native OIDC login for the console (A3).
470    #[cfg(feature = "oidc")]
471    pub fn with_oidc(mut self, provider: oidc::OidcProvider) -> Self {
472        self.oidc = Some(std::sync::Arc::new(provider));
473        self
474    }
475
476    /// The principal for this request's session cookie, if any.
477    #[cfg(feature = "oidc")]
478    fn oidc_principal(&self, cookie_header: Option<&str>) -> Option<String> {
479        let provider = self.oidc.as_ref()?;
480        let sid = cookie_value(cookie_header?, oidc::SESSION_COOKIE)?;
481        provider.principal_for_session(&sid)
482    }
483
484    /// The `/auth/*` endpoints (A3). Returns `(status, extra headers, body)`.
485    ///
486    /// These are the only routes that set headers of their own, which is why
487    /// they bypass `route_full`'s (status, content-type, body) contract.
488    #[cfg(feature = "oidc")]
489    fn oidc_route(
490        &self,
491        method: &str,
492        path: &str,
493        cookie_header: Option<&str>,
494    ) -> (String, String, String) {
495        let provider = match &self.oidc {
496            Some(p) => p,
497            None => return not_found_auth(),
498        };
499        let (base, query) = match path.split_once('?') {
500            Some((p, q)) => (p, q),
501            None => (path, ""),
502        };
503        let q = |key: &str| -> Option<String> {
504            query.split('&').find_map(|kv| {
505                let (k, v) = kv.split_once('=')?;
506                (k == key).then(|| urldecode(v))
507            })
508        };
509        // `Secure` only when the redirect URI is https: a loopback console
510        // over plain http must still be able to log in, and a `Secure` cookie
511        // the browser refuses to send would look like a broken login rather
512        // than a policy.
513        let secure = if provider.is_secure() { "; Secure" } else { "" };
514
515        match (method, base) {
516            ("GET", "/auth/login") => match provider.authorize_url() {
517                Ok(url) => (
518                    "302 Found".into(),
519                    format!("Location: {url}\r\nCache-Control: no-store\r\n"),
520                    String::new(),
521                ),
522                Err(e) => auth_error(&e.to_string()),
523            },
524            ("GET", "/auth/callback") => {
525                let (Some(code), Some(state)) = (q("code"), q("state")) else {
526                    // An IdP-side failure comes back as ?error=...; surface
527                    // it rather than a bare "missing code".
528                    return auth_error(&match q("error") {
529                        Some(e) => format!("the identity provider refused the login: {e}"),
530                        None => "callback missing `code` or `state`".to_string(),
531                    });
532                };
533                match provider.complete_login(&code, &state) {
534                    Ok(sid) => (
535                        "302 Found".into(),
536                        format!(
537                            "Location: /\r\nCache-Control: no-store\r\n\
538                             Set-Cookie: {}={sid}; HttpOnly; SameSite=Strict; Path=/{secure}\r\n",
539                            oidc::SESSION_COOKIE
540                        ),
541                        String::new(),
542                    ),
543                    Err(e) => auth_error(&e.to_string()),
544                }
545            }
546            // Logout accepts POST (the console's button, Origin-checked like
547            // every other mutation) and GET (a bookmarkable escape hatch —
548            // and a GET logout is not a CSRF worth defending: forcing someone
549            // to log out grants an attacker nothing).
550            ("POST", "/auth/logout") | ("GET", "/auth/logout") => {
551                if let Some(sid) = cookie_header.and_then(|c| cookie_value(c, oidc::SESSION_COOKIE))
552                {
553                    // Invalidate SERVER-side too. Clearing only the browser's
554                    // copy would leave a cookie captured beforehand alive.
555                    provider.logout(&sid);
556                }
557                (
558                    "302 Found".into(),
559                    format!(
560                        "Location: /\r\nCache-Control: no-store\r\n\
561                         Set-Cookie: {}=; HttpOnly; SameSite=Strict; Path=/{secure}; Max-Age=0\r\n",
562                        oidc::SESSION_COOKIE
563                    ),
564                    String::new(),
565                )
566            }
567            _ => not_found_auth(),
568        }
569    }
570
571    /// Honor an IdP groups header (A2) under the same proxy secret as the
572    /// identity header. Groups resolve through the credential map's `groups`
573    /// table; without a map, this does nothing.
574    pub fn with_sso_groups_header(mut self, header: impl Into<String>) -> Self {
575        let header = header.into();
576        assert!(
577            !header.trim().is_empty(),
578            "with_sso_groups_header requires a non-empty header name"
579        );
580        self.sso_groups_header = Some(header.to_ascii_lowercase());
581        self
582    }
583
584    /// Stamp `prefix` onto every proxy-asserted principal, so IdP-sourced
585    /// identities are visibly distinct from credential-map ones everywhere
586    /// they land — grants, logs, audit grains.
587    pub fn with_sso_principal_prefix(mut self, prefix: impl Into<String>) -> Self {
588        let prefix = prefix.into();
589        assert!(
590            !prefix.trim().is_empty(),
591            "with_sso_principal_prefix requires a non-empty prefix"
592        );
593        self.sso_principal_prefix = Some(prefix);
594        self
595    }
596
597    /// Resolve a proxy-proven request to its principal (A2).
598    ///
599    /// Precedence, most specific first:
600    /// 1. the asserted **identity**, when the file grants it anything;
601    /// 2. the first **group** (in header order) the credential map maps to a
602    ///    principal;
603    /// 3. nothing — the request falls through to `anonymous`.
604    ///
605    /// Identity-before-group is what makes an individual grant able to
606    /// override a role, which is the direction an operator expects (and the
607    /// direction that lets someone be *removed* from an exception without
608    /// editing the group). Groups are consulted only when the identity is
609    /// ungranted, so adding a groups header can never *narrow* a person who
610    /// already had rights.
611    ///
612    /// Returns `(principal, from_group)`. `from_group` matters downstream: a
613    /// role is not a person, so a group-derived principal can never answer an
614    /// approval — "someone in engineering approved this" is not an audit
615    /// record, whatever `--sso-approvals` says.
616    fn resolve_sso_principal(
617        &self,
618        identity: &str,
619        groups_raw: Option<&str>,
620    ) -> Option<(String, bool)> {
621        let stamp = |p: &str| match &self.sso_principal_prefix {
622            Some(prefix) if !p.starts_with(prefix.as_str()) => format!("{prefix}{p}"),
623            _ => p.to_string(),
624        };
625        let identity = stamp(identity);
626
627        // "Has grants of its own" is asked of the FILE, which is the only
628        // authority on rights. A store error answers "no" — falling toward
629        // the group (and ultimately anonymous) rather than assuming rights
630        // we could not read.
631        let identity_granted = self
632            .facade
633            .with_store(|m| m.authz_grants(&identity))
634            .map(|g| !g.is_empty())
635            .unwrap_or(false);
636        if identity_granted {
637            return Some((identity, false));
638        }
639
640        if let (Some(map), Some(raw)) = (&self.credentials, groups_raw) {
641            for group in raw.split(',') {
642                if let Some(principal) = map.principal_for_group(group) {
643                    return Some((principal.to_string(), true));
644                }
645            }
646        }
647
648        // No grants either way: still bind the identity. It is who the proxy
649        // says this is, `bind_principal` resolves it to the same empty grant
650        // set anonymous would get, and an audit line naming the person beats
651        // one naming "anonymous".
652        Some((identity, false))
653    }
654
655    /// Permit proxy-asserted SSO identities to answer HITL approvals
656    /// (`POST /api/run/respond`). **Off by default**: the identity header's
657    /// only proof is the shared, fleet-wide proxy secret, so whoever holds it
658    /// could approve as anyone — including the approver the audit record then
659    /// names.
660    ///
661    /// Turning this on is a deliberate statement that the proxy shared secret
662    /// is held to the same standard as an approver's own credential. That is
663    /// achievable (a proxy on a Unix socket, or mTLS between proxy and
664    /// console, with the secret never leaving the host) — it is just not
665    /// something the console can verify, so it cannot be the default.
666    pub fn with_sso_approvals(mut self, allow: bool) -> Self {
667        self.sso_approvals = allow;
668        self
669    }
670
671    /// Rebuild the executor from the current gate settings. Every builder
672    /// that touches the CAL surface goes through here so `--no-destructive-ops`
673    /// and `--policy` compose instead of clobbering each other's config —
674    /// and the governance host survives every rebuild. (Same shape as
675    /// `areev-mcp`'s; building `CalExecutorConfig::default()` in one builder
676    /// silently re-enabled destruction configured off by another.)
677    fn rebuild_executor(&mut self) {
678        self.executor = CalExecutor::new(CalExecutorConfig {
679            allow_destructive_ops: self.allow_destructive_ops,
680            ..CalExecutorConfig::default()
681        })
682        .with_governance(std::sync::Arc::new(match &self.loop_policy {
683            Some(p) => areev_loop_adapter::LoopGovernance::with_policy(p.clone()),
684            None => areev_loop_adapter::LoopGovernance::new(),
685        }));
686    }
687
688    /// Multi-principal mode: attach the credential map (`areev-auth.json`).
689    /// Tokens resolve to principal names; rights come from the memory
690    /// file's own grant grains; unauthenticated requests run as
691    /// `anonymous`. See `docs/cal-reference.md` §9.
692    pub fn with_credentials(mut self, map: areev_core::authz::CredentialMap) -> Self {
693        self.credentials = Some(map);
694        self
695    }
696
697    /// Attach a host loop policy to the console's loop routes, so a
698    /// console-triggered run honors the same auto-apply grants, denies, and
699    /// severity floors as `areev loop run --policy`.
700    pub fn with_loop_policy(mut self, policy: areev_loop::Policy) -> Self {
701        self.loop_policy = Some(policy);
702        // The CAL surface's governance host honors the same policy.
703        self.rebuild_executor();
704        self
705    }
706
707    /// The loop engine for a request: builtins + the host policy when one
708    /// was attached.
709    fn engine(&self) -> Engine {
710        match &self.loop_policy {
711            Some(p) => Engine::with_builtins().with_policy(p.clone()),
712            None => Engine::with_builtins(),
713        }
714    }
715
716    /// Accept requests whose `Host` header is non-loopback. Off by default so
717    /// the loopback console is protected against DNS-rebinding reads even when
718    /// unauthenticated; the CLI enables it under `--allow-remote`.
719    pub fn allow_remote(mut self, yes: bool) -> Self {
720        self.allow_remote = yes;
721        self
722    }
723
724    /// Accept a cross-origin **POST** whose `Origin` header exactly matches
725    /// this origin (scheme + `host[:port]`, compared case-insensitively with a
726    /// single trailing `/` ignored). Call once per origin to allow — there
727    /// is no wildcard or subdomain form, by design: naming
728    /// `https://console.example.com` must never thereby accept
729    /// `https://evil-console.example.com`.
730    ///
731    /// `--allow-remote` does **not** imply this. The Origin check exists
732    /// because the console authenticates browsers with HTTP Basic, which the
733    /// browser caches and re-attaches to cross-site requests — Origin is the
734    /// only thing telling the console's own page apart from an attacker's
735    /// page riding a viewer's cached credential, so it is CSRF protection,
736    /// not defence in depth, and it stays enforced on every non-loopback
737    /// origin except the ones named here.
738    pub fn allow_origin(mut self, origin: impl Into<String>) -> Self {
739        self.allowed_origins.push(normalize_origin(&origin.into()));
740        self
741    }
742
743    /// Whether `origin` (a request's raw `Origin` header value) matches one
744    /// of the operator-configured [`allow_origin`](Self::allow_origin)
745    /// entries, after normalizing both sides identically. Exact match only.
746    fn origin_is_allowed(&self, origin: &str) -> bool {
747        let normalized = normalize_origin(origin);
748        self.allowed_origins.contains(&normalized)
749    }
750
751    /// Require a shared secret on **every** request (the console page, reads,
752    /// and writes). Browsers authenticate through the native `Basic` prompt
753    /// (any username; password = `token`); scripts may send `Authorization:
754    /// Bearer <token>`. Use this to serve the console to more than a single
755    /// trusted local operator. Pair with a TLS-terminating proxy for non-local
756    /// exposure — the token crosses the wire in the clear otherwise.
757    pub fn with_auth(mut self, token: String) -> Self {
758        // An empty token would authenticate `Authorization: Bearer ` (the
759        // empty credential) as the OWNER — refuse at the library boundary,
760        // not just in the CLI's --token-env validation.
761        assert!(
762            !token.trim().is_empty(),
763            "with_auth requires a non-empty token"
764        );
765        self.token = Some(token);
766        self.auth_all = true;
767        self
768    }
769
770    /// Permit or forbid destructive CAL (`FORGET <hash>`) from the query
771    /// console. Enabled by default; pass `false` to serve a read-only console.
772    /// Since the plain console is unauthenticated, disabling this is the safe
773    /// choice when the console is exposed beyond a trusted local operator.
774    pub fn allow_destructive_ops(mut self, allow: bool) -> Self {
775        self.allow_destructive_ops = allow;
776        self.rebuild_executor();
777        self
778    }
779
780    /// Bind and return the listener (lets callers learn the ephemeral port).
781    pub fn bind(addr: &str) -> std::io::Result<TcpListener> {
782        TcpListener::bind(addr)
783    }
784
785    /// Serve forever on an already-bound listener.
786    pub fn serve(&self, listener: TcpListener) -> std::io::Result<()> {
787        for stream in listener.incoming() {
788            match stream {
789                Ok(s) => {
790                    if let Err(e) = self.handle(s) {
791                        eprintln!("areev ui: request error: {e}");
792                    }
793                }
794                Err(e) => eprintln!("areev ui: accept error: {e}"),
795            }
796        }
797        Ok(())
798    }
799
800    /// Configure native TLS from PEM files (`tls` cargo feature). The
801    /// documented default remains a TLS-terminating proxy; this is for
802    /// deployments with nowhere to put one (edge boxes, appliances).
803    #[cfg(feature = "tls")]
804    pub fn with_tls(mut self, cert_pem: &str, key_pem: &str) -> std::io::Result<Self> {
805        use rustls::pki_types::pem::PemObject;
806        let pem_err =
807            |e: rustls::pki_types::pem::Error| std::io::Error::new(std::io::ErrorKind::InvalidData, e.to_string());
808        let certs: Vec<rustls::pki_types::CertificateDer<'static>> =
809            rustls::pki_types::CertificateDer::pem_file_iter(cert_pem)
810                .map_err(pem_err)?
811                .collect::<Result<_, _>>()
812                .map_err(pem_err)?;
813        let key =
814            rustls::pki_types::PrivateKeyDer::from_pem_file(key_pem).map_err(pem_err)?;
815        let config = rustls::ServerConfig::builder()
816            .with_no_client_auth()
817            .with_single_cert(certs, key)
818            .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e.to_string()))?;
819        self.tls_config = Some(std::sync::Arc::new(config));
820        Ok(self)
821    }
822
823    /// Record one failed authentication attempt from `ip` (issue #126) and
824    /// return the new consecutive-failure count — for the caller to LOG,
825    /// never to sleep on. An entry idle for more than
826    /// `AUTH_FAILURE_IDLE_SECS` is treated as reset rather than accumulating
827    /// forever. The map is bounded at `MAX_AUTH_FAILURE_IPS` — the key is
828    /// one attacker-controlled source IP per entry, so an unbounded map here
829    /// would itself be a memory-exhaustion vector; past the cap, the
830    /// stalest entry is evicted before the new one is inserted. The lock is
831    /// held only for this bookkeeping — never across the response write.
832    ///
833    /// **Deliberately no artificial delay or lockout on this count.**
834    /// `UiServer::serve` is a strictly SERIAL accept loop — one connection
835    /// handled at a time, per its own doc comment — so a `std::thread::sleep`
836    /// here, before a response is written, would not slow down one
837    /// attacker: it would stall the entire console for every caller,
838    /// including the operator, behind whatever backoff an unauthenticated
839    /// caller chooses to trigger by sending bad credentials. That trade is
840    /// worse than the problem this counter exists to fix (a 401 that reads
841    /// as ordinary traffic in the log). Rate limiting belongs in front of
842    /// this server — the TLS-terminating proxy the deployment profile
843    /// already calls for — where it can also see the caller's real address;
844    /// this count and the log line built from it are what such a rule is
845    /// written against. Do not reintroduce a sleep/lockout here without
846    /// first making the accept loop concurrent.
847    fn note_auth_failure(&self, ip: std::net::IpAddr) -> u32 {
848        let now = std::time::Instant::now();
849        let idle = Duration::from_secs(AUTH_FAILURE_IDLE_SECS);
850        let mut map = self
851            .auth_failures
852            .lock()
853            .unwrap_or_else(std::sync::PoisonError::into_inner);
854        match map.get_mut(&ip) {
855            Some((n, last)) if now.duration_since(*last) < idle => {
856                *n = n.saturating_add(1);
857                *last = now;
858                *n
859            }
860            Some(entry) => {
861                *entry = (1, now);
862                1
863            }
864            None => {
865                if map.len() >= MAX_AUTH_FAILURE_IPS {
866                    if let Some(stalest) =
867                        map.iter().min_by_key(|(_, (_, last))| *last).map(|(k, _)| *k)
868                    {
869                        map.remove(&stalest);
870                    }
871                }
872                map.insert(ip, (1, now));
873                1
874            }
875        }
876    }
877
878    /// Clear a source IP's failure streak after it authenticates successfully.
879    fn reset_auth_failures(&self, ip: std::net::IpAddr) {
880        let mut map = self
881            .auth_failures
882            .lock()
883            .unwrap_or_else(std::sync::PoisonError::into_inner);
884        map.remove(&ip);
885    }
886
887    /// A source IP's current consecutive-failure count (0 if it has none, or
888    /// if its last failure is older than `AUTH_FAILURE_IDLE_SECS`).
889    ///
890    /// The idle check is applied on READ as well as on write so a lockout
891    /// expires on its own: a stale entry that `note_auth_failure` has not
892    /// been called on again must not keep refusing forever, which is what
893    /// turns a brute-force brake into a self-inflicted outage.
894    fn auth_failure_count(&self, ip: std::net::IpAddr) -> u32 {
895        let idle = Duration::from_secs(AUTH_FAILURE_IDLE_SECS);
896        self.auth_failures
897            .lock()
898            .unwrap_or_else(std::sync::PoisonError::into_inner)
899            .get(&ip)
900            .filter(|(_, last)| last.elapsed() < idle)
901            .map(|(n, _)| *n)
902            .unwrap_or(0)
903    }
904
905    /// Test-only alias — tests read the counter directly because there is no
906    /// sleep to observe via timing (see `note_auth_failure`).
907    #[cfg(test)]
908    fn auth_failure_count_for_test(&self, ip: std::net::IpAddr) -> u32 {
909        self.auth_failure_count(ip)
910    }
911
912    /// Wrap an accepted socket per the TLS posture. The handshake happens
913    /// lazily on first read/write; a plaintext client fails it loudly.
914    fn wrap(&self, stream: TcpStream) -> std::io::Result<Conn> {
915        #[cfg(feature = "tls")]
916        if let Some(config) = &self.tls_config {
917            let session = rustls::ServerConnection::new(std::sync::Arc::clone(config))
918                .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e.to_string()))?;
919            return Ok(Conn::Tls(Box::new(rustls::StreamOwned::new(session, stream))));
920        }
921        Ok(Conn::Plain(stream))
922    }
923
924    fn handle(&self, stream: TcpStream) -> std::io::Result<()> {
925        // The server handles one connection at a time, so a single slow-drip
926        // client could otherwise hold it open indefinitely (a per-read timeout
927        // resets on every byte). A watchdog thread enforces a hard wall-clock
928        // deadline by shutting the socket down; it is unparked and joined the
929        // instant the request completes, so it adds no latency in the fast path.
930        let watchdog_stream = stream.try_clone()?;
931        let done = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
932        let watchdog_done = std::sync::Arc::clone(&done);
933        let watchdog = std::thread::spawn(move || {
934            std::thread::park_timeout(Duration::from_secs(REQUEST_DEADLINE_SECS));
935            if !watchdog_done.load(std::sync::atomic::Ordering::Acquire) {
936                let _ = watchdog_stream.shutdown(std::net::Shutdown::Both);
937            }
938        });
939        let raw = stream.try_clone()?;
940        let conn = self.wrap(stream)?;
941        let result = self.handle_request(&raw, conn);
942        done.store(true, std::sync::atomic::Ordering::Release);
943        watchdog.thread().unpark();
944        let _ = watchdog.join();
945        result
946    }
947
948    fn handle_request(&self, raw: &TcpStream, mut conn: Conn) -> std::io::Result<()> {
949        // Bound slow clients (slowloris) and total bytes read from the
950        // connection so a malicious client cannot stall the server or exhaust
951        // memory with an oversized request line / headers / body. The
952        // timeouts sit on the RAW socket, so they bound TLS handshakes too.
953        raw.set_read_timeout(Some(Duration::from_secs(READ_TIMEOUT_SECS)))?;
954        raw.set_write_timeout(Some(Duration::from_secs(READ_TIMEOUT_SECS)))?;
955        let mut reader = BufReader::new((&mut conn).take(MAX_CONN_BYTES));
956        let mut request_line = String::new();
957        reader.read_line(&mut request_line)?;
958        let mut parts = request_line.split_whitespace();
959        let method = parts.next().unwrap_or("").to_string();
960        let path = parts.next().unwrap_or("/").to_string();
961
962        // headers → content-length + authorization + origin + host
963        let mut content_length = 0usize;
964        let mut bearer: Option<String> = None;
965        let mut origin: Option<String> = None;
966        let mut host: Option<String> = None;
967        let mut sso_identity_raw: Option<String> = None;
968        let mut sso_groups_raw: Option<String> = None;
969        #[cfg(feature = "oidc")]
970        let mut cookie_header: Option<String> = None;
971        let mut proxy_secret: Option<String> = None;
972        let mut header_bytes = 0usize;
973        let mut header_count = 0usize;
974        loop {
975            let mut line = String::new();
976            let n = reader.read_line(&mut line)?;
977            if n == 0 {
978                break; // EOF / connection closed by peer
979            }
980            header_bytes += n;
981            header_count += 1;
982            if header_bytes > MAX_HEADER_BYTES || header_count > MAX_HEADERS {
983                return Err(std::io::Error::new(
984                    std::io::ErrorKind::InvalidData,
985                    "request headers too large",
986                ));
987            }
988            let l = line.trim();
989            if l.is_empty() {
990                break;
991            }
992            let low = l.to_ascii_lowercase();
993            if let Some(v) = low.strip_prefix("content-length:") {
994                content_length = v.trim().parse().unwrap_or(0);
995            }
996            if let Some(v) = low.strip_prefix("origin:") {
997                origin = Some(v.trim().to_string());
998            }
999            if let Some(v) = low.strip_prefix("host:") {
1000                host = Some(v.trim().to_string());
1001            }
1002            #[cfg(feature = "oidc")]
1003            if low.starts_with("cookie:") {
1004                // Re-slice the ORIGINAL line: the lowercased copy would
1005                // mangle a base64url session id.
1006                if let Some((_, orig)) = l.split_once(':') {
1007                    cookie_header = Some(orig.trim().to_string());
1008                }
1009            }
1010            if let Some(v) = low.strip_prefix("x-areev-proxy-secret:") {
1011                // Secrets are compared, never logged; the lowercased copy is
1012                // wrong for comparison, so re-slice the original line.
1013                let _ = v;
1014                if let Some((_, orig)) = l.split_once(':') {
1015                    proxy_secret = Some(orig.trim().to_string());
1016                }
1017            }
1018            if let Some(h) = &self.sso_header {
1019                if low.starts_with(&format!("{h}:")) {
1020                    if let Some((_, orig)) = l.split_once(':') {
1021                        sso_identity_raw = Some(orig.trim().to_string());
1022                    }
1023                }
1024            }
1025            if let Some(h) = &self.sso_groups_header {
1026                if low.starts_with(&format!("{h}:")) {
1027                    if let Some((_, orig)) = l.split_once(':') {
1028                        sso_groups_raw = Some(orig.trim().to_string());
1029                    }
1030                }
1031            }
1032            if low.starts_with("authorization:") {
1033                if let Some((_, v)) = l.split_once(':') {
1034                    let v = v.trim();
1035                    // Accept `Bearer <token>` (scripts/CLI) or HTTP `Basic`
1036                    // (browsers, via the native login prompt). For Basic the
1037                    // credential is base64(user:pass) and the token is the
1038                    // password; the username is ignored.
1039                    bearer = if let Some(t) =
1040                        v.strip_prefix("Bearer ").or_else(|| v.strip_prefix("bearer "))
1041                    {
1042                        Some(t.trim().to_string())
1043                    } else if let Some(b64) =
1044                        v.strip_prefix("Basic ").or_else(|| v.strip_prefix("basic "))
1045                    {
1046                        basic_auth_password(b64.trim())
1047                    } else {
1048                        Some(v.to_string())
1049                    };
1050                }
1051            }
1052        }
1053        // DNS-rebinding defense: a browser tricked into pointing an attacker
1054        // domain at 127.0.0.1 sends that domain in the Host header. Unless the
1055        // operator opted into remote serving, reject any non-loopback Host on
1056        // *every* method (the Origin check below only covers POST, so this is
1057        // what stops a drive-by page from reading memory over GET). A missing
1058        // Host (bare HTTP/1.0, some CLI clients) passes, mirroring Origin.
1059        if !self.allow_remote && host.as_deref().is_some_and(|h| !host_is_local(h)) {
1060            let payload = br#"{"ok":false,"error":"non-loopback Host rejected (use --allow-remote to serve remotely)"}"#;
1061            drop(reader);
1062            let mut out = conn;
1063            write!(
1064                out,
1065                "HTTP/1.1 403 Forbidden\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
1066                payload.len()
1067            )?;
1068            out.write_all(payload)?;
1069            return out.flush();
1070        }
1071
1072        let mut body = vec![0u8; content_length.min(1 << 20)];
1073        if content_length > 0 {
1074            reader.read_exact(&mut body)?;
1075        }
1076
1077        // Cross-origin drive-by protection: browsers attach an Origin header
1078        // to cross-site requests. Only loopback pages (the console itself),
1079        // or an origin the operator explicitly allowlisted with
1080        // --allow-origin (issue #125), may mutate; curl/CLI clients send no
1081        // Origin and pass through. `--allow-remote` does NOT lift this on
1082        // its own — see `allow_origin`'s doc comment for why.
1083        if method == "POST"
1084            && origin
1085                .as_deref()
1086                .is_some_and(|o| !origin_is_local(o) && !self.origin_is_allowed(o))
1087        {
1088            let payload = br#"{"ok":false,"error":"cross-origin request rejected"}"#;
1089            drop(reader);
1090            let mut out = conn;
1091            write!(
1092                out,
1093                "HTTP/1.1 403 Forbidden\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
1094                payload.len()
1095            )?;
1096            out.write_all(payload)?;
1097            return out.flush();
1098        }
1099
1100        // Captured before the move below: issue #126's failure counter needs
1101        // to know whether a proxy secret was PRESENTED at all (a
1102        // wrong-guess attempt to count), independent of `sso_identity`,
1103        // which by then only ever reflects a *proven* identity.
1104        let proxy_secret_presented = proxy_secret.is_some();
1105        // The identity header is TRUSTED only when the proxy proved itself
1106        // (constant-time secret check). A forged header without the secret
1107        // is silently ignored — the request proceeds as whatever its other
1108        // credentials make it.
1109        //
1110        // [A2] The secret proves the PROXY; it says nothing about whether
1111        // what the proxy forwarded is well-formed. `sanitize_sso_identity`
1112        // is that second question, and a rejected identity is treated
1113        // exactly like an absent one.
1114        let proxy_proved = match &proxy_secret {
1115            Some(presented) => {
1116                // `fold`, not `any`: `any` short-circuits on the first match,
1117                // so during a rotation window the response time would say
1118                // WHICH secret was presented. Every configured secret is
1119                // compared, every time.
1120                self.sso_secrets
1121                    .iter()
1122                    .fold(false, |acc, s| ct_eq(s.as_bytes(), presented.as_bytes()) | acc)
1123            }
1124            None => false,
1125        };
1126        let sso_identity: Option<String> = sso_identity_raw
1127            .filter(|_| proxy_proved)
1128            .as_deref()
1129            .and_then(sanitize_sso_identity);
1130        let sso_groups: Option<String> = sso_groups_raw.filter(|_| proxy_proved);
1131        // [A3] The OIDC login endpoints own their own responses: they answer
1132        // with redirects and `Set-Cookie`, which the JSON `route` contract
1133        // (status, content-type, body) has no slot for. Handled here, where
1134        // the socket and its headers are already in hand.
1135        #[cfg(feature = "oidc")]
1136        if self.oidc.is_some() && path.starts_with("/auth/") {
1137            let (status, extra, payload) =
1138                self.oidc_route(&method, &path, cookie_header.as_deref());
1139            drop(reader);
1140            let mut out = conn;
1141            write!(
1142                out,
1143                "HTTP/1.1 {status}\r\n{extra}Content-Length: {}\r\nConnection: close\r\n\r\n",
1144                payload.len()
1145            )?;
1146            out.write_all(payload.as_bytes())?;
1147            return out.flush();
1148        }
1149        #[cfg(feature = "oidc")]
1150        let oidc_principal = self.oidc_principal(cookie_header.as_deref());
1151        #[cfg(not(feature = "oidc"))]
1152        let oidc_principal: Option<String> = None;
1153
1154        // [A1] Fail closed after repeated failures from one source.
1155        //
1156        // This REJECTS; it never delays. `serve` is a strictly serial accept
1157        // loop, so a per-request sleep would be a lever an unauthenticated
1158        // caller pulls to stall the console for everyone (the reasoning
1159        // recorded on `note_auth_failure`, which is why that counter only
1160        // ever counted). Rejection has the opposite shape: it is cheaper than
1161        // serving, costs the attacker their pipeline, and — placed HERE,
1162        // before `route` — touches no store, runs no constant-time scan, and
1163        // dispatches nothing.
1164        //
1165        // Only when auth is configured AND a credential was presented, the
1166        // same two conditions the counter itself uses: a console with no auth
1167        // has nothing to brute-force, and a browser's first credential-less
1168        // probe is not an attempt.
1169        //
1170        // AND NOT through a trusted proxy. Behind the documented deployment
1171        // (a TLS-terminating, authenticating proxy) every request shares the
1172        // proxy's source address, so a per-IP lockout would let one attacker's
1173        // ten bad guesses refuse *every* user behind it — a self-inflicted
1174        // outage in exactly the configuration we recommend. A request carrying
1175        // a verified proxy secret is proven to have come through that proxy,
1176        // and per-source throttling there is the proxy's job (the same
1177        // division of labour as TLS and the IdP handshake). Direct connections
1178        // — which is every connection on a console with no proxy — are
1179        // unaffected and still brake.
1180        let auth_configured =
1181            self.token.is_some() || self.credentials.is_some() || !self.sso_secrets.is_empty();
1182        let credential_presented = bearer.is_some() || proxy_secret_presented;
1183        if auth_configured && credential_presented && !proxy_proved {
1184            if let Ok(peer) = raw.peer_addr() {
1185                if self.auth_failure_count(peer.ip()) >= MAX_CONSECUTIVE_AUTH_FAILURES {
1186                    let payload = format!(
1187                        r#"{{"ok":false,"error":"too many failed authentications from this address - wait {} minutes, or restart the console to clear the counter"}}"#,
1188                        AUTH_FAILURE_IDLE_SECS / 60
1189                    );
1190                    drop(reader);
1191                    let mut out = conn;
1192                    write!(
1193                        out,
1194                        "HTTP/1.1 429 Too Many Requests\r\nContent-Type: application/json\r\nRetry-After: {}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
1195                        AUTH_FAILURE_IDLE_SECS,
1196                        payload.len()
1197                    )?;
1198                    out.write_all(payload.as_bytes())?;
1199                    return out.flush();
1200                }
1201            }
1202        }
1203
1204        let (status, ctype, payload) = self.route_full(
1205            &method,
1206            &path,
1207            &body,
1208            bearer.as_deref(),
1209            sso_identity.as_deref(),
1210            sso_groups.as_deref(),
1211            oidc_principal.as_deref(),
1212        );
1213
1214        // Auth-failure count + log (issue #126). Only when this server has
1215        // SOME auth mechanism configured, AND a credential was actually
1216        // PRESENTED (a Bearer/Basic value, or a proxy secret) — a plain
1217        // unauthenticated request (a token-less console's read-only-write
1218        // refusal, a browser's very first Basic-auth probe with no
1219        // Authorization header at all) attempted nothing and has no failure
1220        // to count. The token itself is never logged, not even a prefix.
1221        //
1222        // Deliberately no in-process DELAY here — see the comment on
1223        // `note_auth_failure` for why an artificial delay would itself be the
1224        // more dangerous bug on this server. The cheap rejection above is the
1225        // lockout; this is the counter that feeds it.
1226        if auth_configured && credential_presented {
1227            if let Ok(peer) = raw.peer_addr() {
1228                let ip = peer.ip();
1229                if status.starts_with("401") {
1230                    let consecutive = self.note_auth_failure(ip);
1231                    eprintln!("areev: console auth FAILED from {ip} ({consecutive} consecutive)");
1232                } else {
1233                    self.reset_auth_failures(ip);
1234                }
1235            }
1236        }
1237
1238        // On a console-auth 401, challenge with Basic so browsers show the
1239        // native login prompt (any username; password = token).
1240        let auth_challenge = if self.auth_all && status.starts_with("401") {
1241            "WWW-Authenticate: Basic realm=\"Areev console\", charset=\"UTF-8\"\r\n"
1242        } else {
1243            ""
1244        };
1245        drop(reader);
1246        let mut out = conn;
1247        write!(
1248            out,
1249            "HTTP/1.1 {status}\r\nContent-Type: {ctype}\r\n{auth_challenge}Content-Length: {}\r\nConnection: close\r\n\r\n",
1250            payload.len()
1251        )?;
1252        out.write_all(&payload)?;
1253        out.flush()
1254    }
1255
1256    /// The runtime driver for the /api/run/* surface. The console never
1257    /// EXECUTES tools (list/inspect read; respond/cancel journal) — the
1258    /// executor is a refusing stub, present only to satisfy the driver
1259    /// shape.
1260    fn runner(&self, principal: &str) -> areev_run::Runner {
1261        struct NoExec;
1262        impl areev_run::HostToolExecutor for NoExec {
1263            fn execute(
1264                &self,
1265                tool_name: &str,
1266                _h: &str,
1267                _i: &Value,
1268                _k: &str,
1269            ) -> areev_run::ExecResult {
1270                areev_run::ExecResult::Err {
1271                    cause: areev_run::FailCause::ExecutorError,
1272                    detail: format!("the console does not execute tools ('{tool_name}')"),
1273                }
1274            }
1275        }
1276        areev_run::Runner {
1277            facade: std::sync::Arc::clone(&self.facade),
1278            clock: std::sync::Arc::new(areev_run::SystemClock),
1279            executor: std::sync::Arc::new(NoExec),
1280            llm: None,
1281            observer: None,
1282            ns: self
1283                .facade
1284                .default_namespace()
1285                .unwrap_or("shared")
1286                .to_string(),
1287            principal: principal.to_string(),
1288        }
1289    }
1290
1291    /// The no-groups spelling, for the tests written before A2 added the
1292    /// groups header. Production always goes through
1293    /// [`route_with_groups`](Self::route_with_groups); this exists only so
1294    /// two dozen assertions that never had a groups header do not each grow
1295    /// a trailing `None`.
1296    #[cfg(test)]
1297    fn route(
1298        &self,
1299        method: &str,
1300        path: &str,
1301        body: &[u8],
1302        bearer: Option<&str>,
1303        sso_identity: Option<&str>,
1304    ) -> (&'static str, &'static str, Vec<u8>) {
1305        self.route_full(method, path, body, bearer, sso_identity, None, None)
1306    }
1307
1308    /// [`route`](Self::route) with the A2 groups header but no OIDC session —
1309    /// the spelling the A2 tests use.
1310    #[cfg(test)]
1311    fn route_with_groups(
1312        &self,
1313        method: &str,
1314        path: &str,
1315        body: &[u8],
1316        bearer: Option<&str>,
1317        sso_identity: Option<&str>,
1318        sso_groups: Option<&str>,
1319    ) -> (&'static str, &'static str, Vec<u8>) {
1320        self.route_full(method, path, body, bearer, sso_identity, sso_groups, None)
1321    }
1322
1323    /// Route one request.
1324    ///
1325    /// `sso_groups` is the raw, proxy-proven groups header (A2) and
1326    /// `oidc_principal` the identity behind a valid session cookie (A3) —
1327    /// both already authenticated by the caller, which is why neither
1328    /// carries its proof this far down.
1329    #[allow(clippy::too_many_arguments)]
1330    fn route_full(
1331        &self,
1332        method: &str,
1333        path: &str,
1334        body: &[u8],
1335        bearer: Option<&str>,
1336        sso_identity: Option<&str>,
1337        sso_groups: Option<&str>,
1338        oidc_principal: Option<&str>,
1339    ) -> (&'static str, &'static str, Vec<u8>) {
1340        // Auth: console-auth mode (`auth_all`) guards every request;
1341        // otherwise the token guards only mutating endpoints. The credential
1342        // arrives as a Bearer token or an HTTP Basic password (see
1343        // `handle_request`).
1344        //
1345        // A credential map changes WHO a request is, not WHETHER the shared
1346        // secret is required. When both are configured, a map token
1347        // authenticates as its principal, the shared secret authenticates as
1348        // the implied admin (`docs/cal-all-you-need-proposal.md` §5.2b), and
1349        // anything else is refused — an attached map must never silently
1350        // downgrade a `--token-env` console to an open `anonymous` one.
1351        let mut shared_secret_ok = false;
1352        if let Some(tok) = &self.token {
1353            let guarded = self.auth_all || method == "POST";
1354            shared_secret_ok = bearer.is_some_and(|b| ct_eq(b.as_bytes(), tok.as_bytes()));
1355            let known = shared_secret_ok
1356                || sso_identity.is_some()
1357                || oidc_principal.is_some()
1358                || match (&self.credentials, bearer) {
1359                    (Some(map), Some(b)) => map.resolve_for_memory(b, &self.db_label).is_ok(),
1360                    _ => false,
1361                };
1362            if guarded && !known {
1363                return ("401 Unauthorized", "application/json",
1364                        br#"{"ok":false,"error":"authentication required"}"#.to_vec());
1365            }
1366        } else if self.credentials.is_none()
1367            && sso_identity.is_none()
1368            && oidc_principal.is_none()
1369            && method == "POST"
1370        {
1371            // §5.7: token-less `areev ui` is read-only. The ONLY POST allowed is
1372            // a read-only CAL statement; every write (any loop mutation, an
1373            // ADD/SUPERSEDE/FORGET CAL batch, etc.) requires --token-env. This
1374            // closes the bypass where a local process could execute a
1375            // proposal's CAL directly and skip the review queue.
1376            let base = path.split_once('?').map(|(p, _)| p).unwrap_or(path);
1377            let allowed = base == "/api/cal" && cal_body_is_read_only(body);
1378            if !allowed {
1379                return ("401 Unauthorized", "application/json",
1380                        br#"{"ok":false,"error":"read-only console: restart areev ui with --auth <map> (per-principal credentials, attributable writes and approvals) or --token-env VAR (one shared secret, writes only) to enable writes"}"#.to_vec());
1381            }
1382        }
1383
1384        // Credential-map mode: resolve this request's principal and bind
1385        // the facade for the request's duration (the guard restores the
1386        // owner default on drop). Every verb check downstream — CAL,
1387        // loop scopes, the run gate below — now answers for THIS caller.
1388        // A request authenticated by the shared secret keeps the owner
1389        // session that secret has always carried.
1390        let _session = match (&self.credentials, shared_secret_ok) {
1391            (Some(map), false) => Some(RequestBinding::bind(&self.facade, map, bearer, &self.db_label)),
1392            _ => None,
1393        };
1394        // The request's resolved PRINCIPAL identity — Some only when a
1395        // per-principal credential authenticated it. The shared secret and
1396        // anonymous access deliberately resolve to None: approvals need an
1397        // identity, and "whoever holds the console token" is not one.
1398        let mut request_principal: Option<String> = match (&self.credentials, shared_secret_ok, bearer)
1399        {
1400            (Some(map), false, Some(b)) => map.resolve_for_memory(b, &self.db_label).ok().map(str::to_string),
1401            _ => None,
1402        };
1403        // SSO v0: a proxy-proven identity becomes the request principal when
1404        // no bearer credential resolved one (machines keep tokens; SSO is
1405        // for humans behind the proxy). Rights come from the file's grants.
1406        //
1407        // `principal_from_sso` records the PROVENANCE of the identity, not
1408        // just its value: everything downstream treats a principal as a
1409        // principal, but one control (HITL approval) must distinguish an
1410        // identity proven by a per-principal credential from one asserted by
1411        // a proxy holding a shared secret. See `sso_approvals`.
1412        let mut principal_from_sso = false;
1413        // [A2] `principal_from_group` is a strictly stronger refusal than
1414        // `principal_from_sso`: a role name cannot be an approver's identity
1415        // under ANY setting, because there is no person behind it.
1416        let mut principal_from_group = false;
1417        // [A3] An OIDC session outranks a proxy-asserted header: its identity
1418        // was proven by a signature this process verified against the
1419        // issuer's key set, not by a shared secret whose holder can assert
1420        // anyone. That is the whole reason native OIDC exists here, so it is
1421        // resolved FIRST and the header path never overwrites it.
1422        let mut principal_from_oidc = false;
1423        let _oidc_session = match (&request_principal, oidc_principal, shared_secret_ok) {
1424            (None, Some(principal), false) => {
1425                principal_from_oidc = true;
1426                let binding = RequestBinding::bind_identity(&self.facade, principal);
1427                request_principal = Some(principal.to_string());
1428                Some(binding)
1429            }
1430            _ => None,
1431        };
1432        let _sso_session = match (&request_principal, sso_identity, shared_secret_ok) {
1433            (None, Some(identity), false) => {
1434                match self.resolve_sso_principal(identity, sso_groups) {
1435                    Some((principal, from_group)) => {
1436                        principal_from_sso = true;
1437                        principal_from_group = from_group;
1438                        let binding = RequestBinding::bind_identity(&self.facade, &principal);
1439                        request_principal = Some(principal);
1440                        Some(binding)
1441                    }
1442                    None => None,
1443                }
1444            }
1445            _ => None,
1446        };
1447
1448        let (path, query) = match path.split_once('?') {
1449            Some((p, q)) => (p, q),
1450            None => (path, ""),
1451        };
1452        let q = |key: &str| -> Option<String> {
1453            query.split('&').find_map(|kv| {
1454                let (k, v) = kv.split_once('=')?;
1455                (k == key).then(|| urldecode(v))
1456            })
1457        };
1458        match (method, path) {
1459            ("GET", "/") => (
1460                "200 OK",
1461                "text/html; charset=utf-8",
1462                CONSOLE_HTML
1463                    .replace("{{DB}}", &html_escape(&self.db_label_display))
1464                    .into_bytes(),
1465            ),
1466            ("POST", "/api/cal") => {
1467                let req: Value = serde_json::from_slice(body).unwrap_or(Value::Null);
1468                let queryt = req.get("query").and_then(|v| v.as_str()).unwrap_or("");
1469                match self.executor.execute(queryt, &*self.facade) {
1470                    Ok(res) => ok_json(json!({
1471                        "ok": true,
1472                        "result": res.result,
1473                        "warnings": res.warnings,
1474                        "statement": res.metadata.statement_type,
1475                        "elapsed_ms": res.metadata.execution_time_ms,
1476                    })),
1477                    Err(e) => {
1478                        // Structured error: code + span + hint let the console
1479                        // point at the offending token instead of just quoting.
1480                        let mut err = json!({
1481                            "ok": false,
1482                            "error": e.sanitize_for_client(),
1483                            "code": e.code(),
1484                        });
1485                        if let Some(sp) = e.span() {
1486                            err["span"] = json!({
1487                                "start": sp.start, "end": sp.end,
1488                                "line": sp.line, "col": sp.col,
1489                            });
1490                        }
1491                        if let Some(hint) = e.suggestion() {
1492                            err["suggestion"] = json!(hint);
1493                        }
1494                        // The store's code survives sanitizing: it is a
1495                        // bounded constant, and the one a host routes on.
1496                        if let Some(sc) = e.store_code() {
1497                            err["store_code"] = json!(sc);
1498                        }
1499                        ok_json(err)
1500                    }
1501                }
1502            }
1503            ("GET", "/api/stats") => {
1504                let stats = self.facade.with_store(|m| m.stats());
1505                match stats {
1506                    Ok(s) => ok_json(json!({
1507                        "db": self.db_label_display,
1508                        "grains": s.grains, "current": s.current,
1509                        "triples": s.triples, "terms": s.terms,
1510                        "ops": s.ops, "events_indexed": s.events_indexed,
1511                    })),
1512                    Err(e) => ok_json(json!({"ok": false, "error": e.to_string()})),
1513                }
1514            }
1515            ("GET", "/api/log") => {
1516                let since: i64 = q("since").and_then(|v| v.parse().ok()).unwrap_or(0);
1517                let limit: usize = q("limit").and_then(|v| v.parse().ok()).unwrap_or(100);
1518                match self.facade.with_store(|m| m.changes_since(since, limit)) {
1519                    Ok(ops) => {
1520                        let rows: Vec<Value> = ops
1521                            .iter()
1522                            .map(|o| {
1523                                json!({
1524                                    "op_seq": o.op_seq, "hlc": o.hlc,
1525                                    "op": match o.op { 1 => "add", 2 => "supersede", 3 => "forget", _ => "?" },
1526                                    "hash": o.hash.to_hex(),
1527                                })
1528                            })
1529                            .collect();
1530                        ok_json(json!(rows))
1531                    }
1532                    Err(e) => ok_json(json!({"ok": false, "error": e.to_string()})),
1533                }
1534            }
1535            // Who am I: the principal this request is bound to. In
1536            // credential-map mode this reflects the per-request binding;
1537            // read-only and mode-agnostic.
1538            ("GET", "/api/whoami") => {
1539                let a = self.facade.authz();
1540                ok_json(json!({
1541                    "ok": true,
1542                    "principal": a.principal(),
1543                    "owner": a.is_owner(),
1544                    "mode": if self.credentials.is_some() { "credentials" }
1545                            else if self.token.is_some() { "token" }
1546                            else { "open" },
1547                    // Provenance, not just value: the console shows an
1548                    // approver whether their identity is one this instance
1549                    // will accept on `run.respond` BEFORE they try it.
1550                    "identity_source": if principal_from_oidc { "oidc" }
1551                                       else if principal_from_group { "sso-group" }
1552                                       else if principal_from_sso { "sso" }
1553                                       else if request_principal.is_some() { "credential" }
1554                                       else { "none" },
1555                    "may_approve": request_principal.is_some()
1556                        && !principal_from_group
1557                        && (!principal_from_sso || self.sso_approvals),
1558                }))
1559            }
1560            ("GET", "/api/config") => {
1561                // Read-only observability: the *effective* per-process
1562                // configuration. Nothing here is persisted in the .db —
1563                // the file holds data only; these values are supplied by
1564                // the host at open time.
1565                let cfg = self.executor.config();
1566                let (index_text, embedder_dim, declared_embed, mut warnings) =
1567                    self.facade.with_store(|m| {
1568                        (
1569                            m.index_text_enabled(),
1570                            m.embedder_dim(),
1571                            m.declared_embedding().map(|(mm, d)| (mm.to_string(), d)),
1572                            m.open_warnings().to_vec(),
1573                        )
1574                    });
1575                if let Some((m, d)) = &declared_embed {
1576                    if embedder_dim.is_none() {
1577                        warnings.push(format!(
1578                            "vector leg dormant: file expects {m}@{d}, no embedding backend installed"
1579                        ));
1580                    }
1581                }
1582                // Saved queries and templates the file carries that this build
1583                // cannot load. Same class of thing as an open warning — the
1584                // file and the host disagree — so it belongs in the same list.
1585                warnings.extend(self.facade.meta_warnings());
1586                // Anonymization observability (REQ-ANON-5: no policy is
1587                // loud): declared per-ns modes, the host floor, and the
1588                // audit counters. Modes only — never mappings or values.
1589                let (anon_declared, anon_floor, anon_audit) = self.facade.with_store(|m| {
1590                    (
1591                        m.anon_declared(),
1592                        m.anonymize_egress_floor(),
1593                        m.anon_audit_counts(),
1594                    )
1595                });
1596                let anonymization = json!({
1597                    "policies": anon_declared
1598                        .iter()
1599                        .map(|(ns, mode)| json!({"ns": ns, "mode": mode}))
1600                        .collect::<Vec<_>>(),
1601                    "floor": anon_floor,
1602                    "audit_counts": anon_audit
1603                        .iter()
1604                        .map(|(ns, cat, n)| json!({"ns": ns, "category": cat, "count": n}))
1605                        .collect::<Vec<_>>(),
1606                });
1607                ok_json(json!({
1608                    "ok": true,
1609                    "db": self.db_label_display,
1610                    "warnings": warnings,
1611                    "anonymization": anonymization,
1612                    "file": {
1613                        "text_index": index_text,
1614                        "embedding": declared_embed.map(|(m, d)| json!({"model": m, "dim": d})),
1615                    },
1616                    "session": {
1617                        "namespace": self.facade.session_namespace(),
1618                        "mounts": self.facade.mount_aliases(),
1619                        // Gates the console's "All namespaces" picker option —
1620                        // same rule /api/browse?ns=* and /api/namespaces enforce.
1621                        "is_owner": self.facade.authz().is_owner(),
1622                    },
1623                    "store": {
1624                        "index_text": index_text,
1625                        "embedder": embedder_dim.map(|d| json!({"dim": d})),
1626                    },
1627                    "recall": {
1628                        "fusion": "rrf",
1629                        "rrf_k0": areev_store::RRF_K0,
1630                        "overfetch_factor": AreevFacade::RECALL_OVERFETCH,
1631                        "legs": {
1632                            "structural": true,
1633                            "bm25": index_text,
1634                            "vector": embedder_dim.is_some(),
1635                        },
1636                    },
1637                    "executor": {
1638                        "max_limit": cfg.max_limit,
1639                        "default_limit": cfg.default_limit,
1640                        "tier1_writes": cfg.tier1_enabled,
1641                        "namespace_override": cfg.namespace_override,
1642                        "user_id_override": cfg.user_id_override,
1643                    },
1644                    "server": {
1645                        "auth_required": self.token.is_some(),
1646                        // true = every request is authenticated (console-auth);
1647                        // false with auth_required = writes only.
1648                        "auth_all": self.auth_all,
1649                    },
1650                    // Decision backend (host config, per-process): which
1651                    // chain may score and order, and whether its answers are
1652                    // calibrated (an uncalibrated chain may reorder, never
1653                    // omit). The label only — never a key or a URL's secret.
1654                    "decide": self.decider.as_ref().map(|(chain, calibrated)| {
1655                        json!({"chain": chain, "calibrated": calibrated})
1656                    }),
1657                    "persistence": "per-process (host-supplied at open) — not stored in the .db",
1658                }))
1659            }
1660            ("GET", "/api/browse") => {
1661                // Browse-without-queries: the tail of the op-log joined with
1662                // grain summaries, newest first. Supersession and tombstone
1663                // status are resolved within the returned window so the
1664                // console can dim/strike them (grains are never mutated —
1665                // this reads the index + immutable blobs only).
1666                let limit: i64 = q("limit")
1667                    .and_then(|v| v.parse().ok())
1668                    .unwrap_or(500)
1669                    .clamp(1, 2000);
1670                let built = self.facade.with_store(|m| -> Result<(i64, Vec<Value>), String> {
1671                    let total = m.stats().map_err(|e| e.to_string())?.ops as i64;
1672                    let after = (total - limit).max(0);
1673                    let ops = m.changes_since(after, limit as usize).map_err(|e| e.to_string())?;
1674                    let mut rows: Vec<Value> = Vec::with_capacity(ops.len());
1675                    let mut idx: std::collections::HashMap<String, usize> =
1676                        std::collections::HashMap::new();
1677                    for o in &ops {
1678                        let hex = o.hash.to_hex();
1679                        let op_name = match o.op { 1 => "add", 2 => "supersede", 3 => "forget", _ => "?" };
1680                        if o.op == 3 {
1681                            // tombstone: flag the earlier row if it is in the
1682                            // window, else emit a stub (blob is already gone).
1683                            match idx.get(&hex) {
1684                                Some(&i) => { rows[i]["forgotten"] = json!(true); }
1685                                None => rows.push(json!({
1686                                    "hash": hex, "op_seq": o.op_seq, "hlc": o.hlc,
1687                                    "op": op_name, "forgotten": true,
1688                                })),
1689                            }
1690                            continue;
1691                        }
1692                        // A SUPERSEDE logs two ops for the new grain (add +
1693                        // supersede) — merge them into one row, keeping the
1694                        // later op label.
1695                        if let Some(&i) = idx.get(&hex) {
1696                            rows[i]["op"] = json!(op_name);
1697                            rows[i]["op_seq"] = json!(o.op_seq);
1698                            rows[i]["hlc"] = json!(o.hlc);
1699                            continue;
1700                        }
1701                        let mut row = json!({
1702                            "hash": hex, "op_seq": o.op_seq, "hlc": o.hlc, "op": op_name,
1703                        });
1704                        match m.get(&o.hash) {
1705                            Ok(g) => {
1706                                row["type"] = json!(format!("{:?}", g.grain_type).to_lowercase());
1707                                row["fields"] = serde_json::to_value(&g.fields).unwrap_or(Value::Null);
1708                            }
1709                            // erased since (forget op outside the window)
1710                            Err(_) => { row["missing"] = json!(true); }
1711                        }
1712                        idx.insert(hex, rows.len());
1713                        rows.push(row);
1714                    }
1715                    // A supersede op's grain points at its predecessor via
1716                    // derived_from; mark the predecessor if it is in-window.
1717                    let marks: Vec<(String, String)> = rows.iter()
1718                        .filter(|r| r["op"] == "supersede")
1719                        .filter_map(|r| {
1720                            let old = r["fields"].get("derived_from")?.as_str()?;
1721                            Some((old.to_string(), r["hash"].as_str()?.to_string()))
1722                        })
1723                        .collect();
1724                    for (old, newer) in marks {
1725                        if let Some(&i) = idx.get(&old) {
1726                            rows[i]["superseded_by"] = json!(newer);
1727                        }
1728                    }
1729                    rows.reverse();
1730                    Ok((total, rows))
1731                });
1732                // Scope the browse to a namespace, the way CAL already scopes
1733                // recall. Without this the rail lists entities (`john`, `bob`)
1734                // that every query on this console then reports as missing,
1735                // because the two disagree about what is in view.
1736                //
1737                // `?ns=<name>` lets the console browse a namespace other than
1738                // the one it was launched with, and `?ns=*` lifts the filter
1739                // entirely — both read-gated against the session's own
1740                // `AuthzSet` exactly as a `RECALL ... WHERE namespace = ...`
1741                // would be (`changes_since` itself reads the whole op-log
1742                // unscoped; this filter is the only enforcement point, so it
1743                // has to check what `AreevFacade::recall` checks, not less).
1744                // Omitting `?ns` keeps the original session-default behavior
1745                // byte-for-byte. Rows we cannot attribute — tombstone stubs,
1746                // grains erased since — are kept regardless: dropping them
1747                // would hide an erasure.
1748                let ns_param = q("ns");
1749                let built = built.and_then(|(total, rows)| {
1750                    let authz = self.facade.authz();
1751                    let filter_ns = match ns_param.as_deref() {
1752                        Some("*") => {
1753                            if !authz.is_owner() {
1754                                return Err("not authorized to browse all namespaces".to_string());
1755                            }
1756                            None
1757                        }
1758                        Some(ns) => {
1759                            authz
1760                                .check(areev_core::authz::Verb::Read, ns)
1761                                .map_err(|e| e.to_string())?;
1762                            Some(ns.to_string())
1763                        }
1764                        None => self.facade.session_namespace().map(|s| s.to_string()),
1765                    };
1766                    let Some(ns) = filter_ns else {
1767                        return Ok((total, rows));
1768                    };
1769                    let kept = rows
1770                        .into_iter()
1771                        .filter(|r| {
1772                            r["fields"]
1773                                .get("namespace")
1774                                .and_then(Value::as_str)
1775                                .is_none_or(|got| got == ns)
1776                        })
1777                        .collect();
1778                    Ok((total, kept))
1779                });
1780                match built {
1781                    Ok((total, rows)) => ok_json(json!({"ok": true, "total_ops": total, "grains": rows})),
1782                    Err(e) => ok_json(json!({"ok": false, "error": e})),
1783                }
1784            }
1785            ("GET", "/api/namespaces") => {
1786                // Every namespace present in the file, for the console's
1787                // namespace picker. A restricted principal only ever sees the
1788                // ones its own grants cover — read access to the *names* of
1789                // namespaces it cannot read from would itself be a disclosure.
1790                let result = self.facade.with_store(|m| m.namespaces());
1791                match result {
1792                    Ok(list) => {
1793                        let authz = self.facade.authz();
1794                        let owner = authz.is_owner();
1795                        let arr: Vec<Value> = list
1796                            .into_iter()
1797                            .filter(|(ns, _)| {
1798                                owner || authz.check(areev_core::authz::Verb::Read, ns).is_ok()
1799                            })
1800                            .map(|(ns, n)| json!({"namespace": ns, "count": n}))
1801                            .collect();
1802                        ok_json(json!({"ok": true, "namespaces": arr}))
1803                    }
1804                    Err(e) => ok_json(json!({"ok": false, "error": e.to_string()})),
1805                }
1806            }
1807            ("GET", "/api/grain") => {
1808                let hash = q("hash").unwrap_or_default();
1809                match areev_core::error::Hash::from_hex(&hash)
1810                    .and_then(|h| self.facade.get(&h))
1811                {
1812                    Ok(g) => ok_json(json!({
1813                        "hash": g.hash.to_hex(),
1814                        "type": format!("{:?}", g.grain_type).to_lowercase(),
1815                        "fields": g.fields,
1816                    })),
1817                    Err(e) => ok_json(json!({"ok": false, "error": e.to_string()})),
1818                }
1819            }
1820            ("GET", "/api/anon/preview") => {
1821                // "As the model sees it" (proposal §8.3): the grain rendered
1822                // the way an egress boundary would show it, policy or not.
1823                let hash = q("hash").unwrap_or_default();
1824                match areev_core::error::Hash::from_hex(&hash)
1825                    .and_then(|h| self.facade.with_store(|m| m.anon_preview(&h)))
1826                {
1827                    Ok(g) => ok_json(json!({
1828                        "hash": g.hash.to_hex(),
1829                        "type": format!("{:?}", g.grain_type).to_lowercase(),
1830                        "fields": g.fields,
1831                    })),
1832                    Err(e) => ok_json(json!({"ok": false, "error": e.to_string()})),
1833                }
1834            }
1835            ("GET", "/api/verify") => match self.facade.with_store(|m| m.verify()) {
1836                Ok(r) => ok_json(json!({
1837                    "integrity": r.integrity, "grains": r.grains,
1838                    "hash_mismatches": r.hash_mismatches, "undecodable": r.undecodable,
1839                })),
1840                Err(e) => ok_json(json!({"ok": false, "error": e.to_string()})),
1841            },
1842            // ── Areev Loop API (§5.4) — GETs are reads (token-less OK); the POST
1843            //    mutations are guarded above (token-less → 401). ────────────
1844            // ── /api/run/*: the governed runtime's console surface ──
1845            ("GET", "/api/run/list") => {
1846                // Paged and namespace-scoped on the server (#165): the
1847                // console used to filter a fixed newest-50 page client-side,
1848                // so a quiet tenant's runs — open approvals included — were
1849                // simply absent behind a busier tenant's. `ns` takes an exact
1850                // name or an `org.*` prefix; `limit` is clamped so one
1851                // request cannot ask for the whole history; the response
1852                // says the total and whether it truncated.
1853                let runner = self.runner("console:read");
1854                let ns = q("ns").filter(|s| !s.is_empty() && s.as_str() != "*");
1855                let limit = q("limit")
1856                    .and_then(|v| v.parse::<usize>().ok())
1857                    .unwrap_or(50)
1858                    .clamp(1, 500);
1859                let offset = q("offset").and_then(|v| v.parse::<usize>().ok()).unwrap_or(0);
1860                match runner.list_runs(ns.as_deref(), offset, limit) {
1861                    Ok(page) => {
1862                        let rows: Vec<Value> = page
1863                            .runs
1864                            .iter()
1865                            .map(|r| {
1866                                serde_json::json!({
1867                                    "run_id": r.run_id,
1868                                    "ns": r.ns,
1869                                    "outcome": r.outcome,
1870                                })
1871                            })
1872                            .collect();
1873                        ok_json(serde_json::json!({
1874                            "ok": true,
1875                            "runs": rows,
1876                            "total": page.total,
1877                            "truncated": page.truncated,
1878                            "offset": page.offset,
1879                            "limit": page.limit,
1880                            "unattributed": page.unattributed,
1881                        }))
1882                    }
1883                    Err(e) => run_api_err(&e.to_string()),
1884                }
1885            }
1886            ("GET", "/api/run/inspect") => {
1887                let Some(run_id) = q("run_id") else {
1888                    return run_api_err("run_id required");
1889                };
1890                // Same Runner::inspect the CLI and bindings call (issue
1891                // #34) — this used to hand-roll a divergent, smaller subset
1892                // (no pinned/budgets/fork_of/superstep/phase).
1893                match self.runner("console:read").inspect(&run_id) {
1894                    Ok(report) => {
1895                        let mut v = serde_json::to_value(&report).unwrap_or(Value::Null);
1896                        if let Some(o) = v.as_object_mut() {
1897                            o.insert("ok".into(), Value::Bool(true));
1898                        }
1899                        ok_json(v)
1900                    }
1901                    Err(e) => run_api_err(&e.to_string()),
1902                }
1903            }
1904            // The plan-change rehearsal (`areev run shadow --plan`): a read,
1905            // guarded like the other run reads. GET names a stored plan;
1906            // POST additionally takes an unstored draft body — what the
1907            // Workflows canvas rehearses before Save.
1908            ("GET", "/api/run/shadow") => {
1909                let Some(runs) = q("runs") else { return run_api_err("runs required (comma-separated)") };
1910                let Some(plan) = q("plan") else { return run_api_err("plan required (a Workflow hash)") };
1911                let ids: Vec<String> = runs.split(',').map(|s| s.trim().to_string()).filter(|s| !s.is_empty()).collect();
1912                let h = match areev_core::error::Hash::from_hex(&plan) {
1913                    Ok(h) => h,
1914                    Err(e) => return run_api_err(&e.to_string()),
1915                };
1916                match self.runner("console:read").shadow_plan(&ids, &areev_run::PlanCandidate::Hash(h)) {
1917                    Ok(report) => {
1918                        let mut v = serde_json::to_value(&report).unwrap_or(Value::Null);
1919                        if let Some(o) = v.as_object_mut() {
1920                            o.insert("ok".into(), Value::Bool(true));
1921                        }
1922                        ok_json(v)
1923                    }
1924                    Err(e) => run_api_err(&e.to_string()),
1925                }
1926            }
1927            ("POST", "/api/run/shadow") => {
1928                let v: Value = match serde_json::from_slice(body) {
1929                    Ok(v) => v,
1930                    Err(e) => return run_api_err(&e.to_string()),
1931                };
1932                let ids: Vec<String> = match v.get("runs") {
1933                    Some(Value::Array(a)) => a.iter().filter_map(|x| x.as_str().map(str::to_string)).collect(),
1934                    Some(Value::String(csv)) => csv.split(',').map(|s| s.trim().to_string()).filter(|s| !s.is_empty()).collect(),
1935                    _ => return run_api_err("runs required (array or comma-separated)"),
1936                };
1937                let candidate = match (v.get("plan").and_then(Value::as_str), v.get("plan_body").and_then(Value::as_object)) {
1938                    (Some(_), Some(_)) => return run_api_err("give plan or plan_body, not both"),
1939                    (Some(h), None) => match areev_core::error::Hash::from_hex(h) {
1940                        Ok(h) => areev_run::PlanCandidate::Hash(h),
1941                        Err(e) => return run_api_err(&e.to_string()),
1942                    },
1943                    (None, Some(body)) => areev_run::PlanCandidate::Body(body.clone()),
1944                    (None, None) => return run_api_err("plan (hash) or plan_body (object) required"),
1945                };
1946                match self.runner("console:read").shadow_plan(&ids, &candidate) {
1947                    Ok(report) => {
1948                        let mut v = serde_json::to_value(&report).unwrap_or(Value::Null);
1949                        if let Some(o) = v.as_object_mut() {
1950                            o.insert("ok".into(), Value::Bool(true));
1951                        }
1952                        ok_json(v)
1953                    }
1954                    Err(e) => run_api_err(&e.to_string()),
1955                }
1956            }
1957            ("POST", "/api/run/respond") => {
1958                // [R3] SHARED-TOKEN APPROVALS ARE REFUSED: an approval whose
1959                // "identity" is a console-wide secret voids the
1960                // approver-identity edge of the provenance chain. Only a
1961                // per-principal credential (areev ui --auth) may respond.
1962                let Some(responder) = request_principal.clone() else {
1963                    return ("403 Forbidden", "application/json",
1964                        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());
1965                };
1966                // [A0] PROXY-ASSERTED APPROVALS ARE REFUSED BY DEFAULT: an
1967                // SSO identity's only proof is a shared, static, fleet-wide
1968                // proxy secret — whoever holds it can assert any identity,
1969                // including this approver's. That is a weaker claim than the
1970                // per-principal credential [R3] demands, and the resulting
1971                // audit grain would be indistinguishable from a real
1972                // approval. Opt in with `areev ui --sso-approvals allow`.
1973                if principal_from_sso && !self.sso_approvals {
1974                    return ("403 Forbidden", "application/json",
1975                        br#"{"ok":false,"error":"run.respond refuses proxy-asserted SSO identities: the identity header is trusted only via a shared proxy secret, which whoever holds it can use to assert any approver. Approve with a per-principal credential (areev ui --auth <map>), or accept the trade-off explicitly with --sso-approvals allow"}"#.to_vec());
1976                }
1977                // [A2] A group-derived principal is a ROLE. It is refused
1978                // even under `--sso-approvals allow`, and deliberately has no
1979                // flag of its own: the audit record would read "role:eng
1980                // approved", which names no one who can be asked why.
1981                if principal_from_group {
1982                    return ("403 Forbidden", "application/json",
1983                        br#"{"ok":false,"error":"run.respond refuses group-derived principals: a role is not an approver, and an audit record naming one identifies nobody. Grant this person a principal of their own, or give them a per-principal credential"}"#.to_vec());
1984                }
1985                let req: Value = serde_json::from_slice(body).unwrap_or(Value::Null);
1986                let (Some(run_id), Some(ask)) = (
1987                    req.get("run_id").and_then(Value::as_str),
1988                    req.get("tool_call_id").and_then(Value::as_str),
1989                ) else {
1990                    return run_api_err("run_id and tool_call_id required");
1991                };
1992                let result = req.get("result").cloned().unwrap_or(Value::Null);
1993                let is_error = req.get("is_error").and_then(Value::as_bool).unwrap_or(false);
1994                let runner = self.runner(&responder);
1995                match runner.respond(run_id, ask, result, is_error, &responder) {
1996                    Ok(()) => ok_json(serde_json::json!({
1997                        "ok": true, "responded": ask, "responder": responder,
1998                        "note": "resume the run to spend the compute",
1999                    })),
2000                    Err(e) => run_api_err(&e.to_string()),
2001                }
2002            }
2003            ("POST", "/api/run/cancel") => {
2004                // The kill switch keeps its LOW bar: any authenticated POST
2005                // may brake a run (the identity label still records who).
2006                let req: Value = serde_json::from_slice(body).unwrap_or(Value::Null);
2007                let Some(run_id) = req.get("run_id").and_then(Value::as_str) else {
2008                    return run_api_err("run_id required");
2009                };
2010                let because = req.get("because").and_then(Value::as_str).unwrap_or("canceled via console");
2011                let who = request_principal.clone().unwrap_or_else(|| "console:shared".into());
2012                let runner = self.runner(&who);
2013                match runner.cancel(run_id, &who, because) {
2014                    Ok(()) => ok_json(serde_json::json!({"ok": true, "canceled": run_id})),
2015                    Err(e) => run_api_err(&e.to_string()),
2016                }
2017            }
2018            ("GET", "/api/loop/recommendations") => {
2019                let status = q("status").and_then(|s| status_from_str(&s));
2020                let sub = BorrowedSubstrate::new(&self.facade);
2021                // Coverage-filtered (#312).
2022                match areev_loop_adapter::visible_recommendations(
2023                    &self.engine(),
2024                    &sub,
2025                    &self.facade.authz(),
2026                    status,
2027                ) {
2028                    Ok(recs) => ok_json(json!({
2029                        "ok": true,
2030                        "recommendations": recs.iter().map(rec_json).collect::<Vec<_>>(),
2031                    })),
2032                    Err(e) => ok_json(json!({"ok": false, "error": e.to_string(), "code": e.code()})),
2033                }
2034            }
2035            ("GET", "/api/loop/health") => {
2036                let sub = BorrowedSubstrate::new(&self.facade);
2037                match self.engine().health(&sub, now_ms()) {
2038                    Ok(h) => ok_json(json!({"ok": true, "health": h})),
2039                    Err(e) => ok_json(json!({"ok": false, "error": e.to_string(), "code": e.code()})),
2040                }
2041            }
2042            ("GET", "/api/loop/outcomes") => {
2043                let sub = BorrowedSubstrate::new(&self.facade);
2044                match self.engine().outcomes(&sub) {
2045                    Ok(o) => ok_json(json!({"ok": true, "outcomes": o})),
2046                    Err(e) => ok_json(json!({"ok": false, "error": e.to_string(), "code": e.code()})),
2047                }
2048            }
2049            ("GET", "/api/loop/analyzers") => {
2050                // Effective settings (manifest merged with the file-config), so
2051                // the Setup view renders accurate on/off state and floors.
2052                let sub = BorrowedSubstrate::new(&self.facade);
2053                match self.engine().analyzer_settings(&sub) {
2054                    Ok(list) => ok_json(json!({"ok": true, "analyzers": list})),
2055                    Err(e) => ok_json(json!({"ok": false, "error": e.to_string(), "code": e.code()})),
2056                }
2057            }
2058            ("GET", "/api/loop/telemetry") => {
2059                // Recall-telemetry rollups for the Sessions view. A read — open
2060                // in read-only mode like the other loop GETs.
2061                let mode = self.facade.with_store(|m| m.telemetry_mode());
2062                if mode == areev_store::TelemetryMode::Off {
2063                    ok_json(json!({"ok": true, "enabled": false}))
2064                } else {
2065                    let access = self.facade.with_store(|m| m.telemetry_access_stats(None));
2066                    let queries = self.facade.with_store(|m| m.telemetry_query_stats(None));
2067                    let budget = self.facade.with_store(|m| m.telemetry_budget_stats());
2068                    match (access, queries, budget) {
2069                        (Ok(mut a), Ok(mut q), Ok(b)) => {
2070                            // Most-recalled first; recurring-gap questions first.
2071                            a.sort_by_key(|x| std::cmp::Reverse(x.recall_count));
2072                            a.truncate(200);
2073                            q.sort_by_key(|x| std::cmp::Reverse(x.run_count));
2074                            q.truncate(200);
2075                            ok_json(json!({
2076                                "ok": true,
2077                                "enabled": true,
2078                                "mode": mode.as_str(),
2079                                "access": a.iter().map(|x| json!({
2080                                    "hash": x.hash, "recall_count": x.recall_count, "last_ms": x.last_ms,
2081                                })).collect::<Vec<_>>(),
2082                                "queries": q.iter().map(|x| json!({
2083                                    "sample": x.sample, "run_count": x.run_count,
2084                                    "empty_count": x.empty_count,
2085                                })).collect::<Vec<_>>(),
2086                                "budget": {
2087                                    "sample_count": b.sample_count,
2088                                    "overflow_count": b.overflow_count,
2089                                },
2090                            }))
2091                        }
2092                        _ => ok_json(json!({"ok": false, "error": "telemetry read failed"})),
2093                    }
2094                }
2095            }
2096            ("POST", "/api/loop/run") => {
2097                let authz = self.facade.authz();
2098                if !authz.is_owner()
2099                    && !authz.allows(areev_core::authz::Verb::LoopRun, areev_loop::LOOP_NS)
2100                {
2101                    return ("403 Forbidden", "application/json",
2102                            br#"{"ok":false,"error":"AUT-E001: this session lacks loop.run"}"#.to_vec());
2103                }
2104                let mut sub = BorrowedSubstrate::new(&self.facade);
2105                // The trigger is recorded as co-creator on LLM/external
2106                // findings, so the same principal cannot later approve them.
2107                // In credential-map mode the actor IS the bound principal —
2108                // a request cannot claim an identity.
2109                let actor = if self.credentials.is_some() {
2110                    authz.principal().to_string()
2111                } else {
2112                    serde_json::from_slice::<Value>(body)
2113                        .ok()
2114                        .and_then(|v| v.get("actor").and_then(Value::as_str).map(str::to_string))
2115                        .unwrap_or_else(|| "user:console".to_string())
2116                };
2117                let opts = RunOptions { triggering_actor: Some(actor), ..Default::default() };
2118                match self.engine().run(&mut sub, &opts, now_ms()) {
2119                    Ok(res) => ok_json(json!({"ok": true, "run": res})),
2120                    Err(e) => ok_json(json!({"ok": false, "error": e.to_string(), "code": e.code()})),
2121                }
2122            }
2123            ("POST", "/api/loop/review") => self.loop_review(body),
2124            ("POST", "/api/loop/apply") => self.loop_apply(body),
2125            ("POST", "/api/loop/rollback") => self.loop_rollback(body),
2126            ("POST", "/api/loop/config") => self.loop_config(body),
2127            ("POST", "/api/loop/replay") => self.loop_replay(body),
2128            ("POST", "/api/anon/config") => self.anon_config(body),
2129            _ => (
2130                "404 Not Found",
2131                "application/json",
2132                br#"{"ok":false,"error":"not found"}"#.to_vec(),
2133            ),
2134        }
2135    }
2136
2137    /// The console session's scopes derive from its grants (an owner
2138    /// session — today's only mode — holds all of them); actor
2139    /// `user:console` unless overridden, observer derived from the actor
2140    /// label, never from request text.
2141    fn loop_review(&self, body: &[u8]) -> (&'static str, &'static str, Vec<u8>) {
2142        let req: Value = serde_json::from_slice(body).unwrap_or(Value::Null);
2143        let hash = req.get("hash").and_then(Value::as_str).unwrap_or("");
2144        let because = req.get("because").and_then(Value::as_str).unwrap_or("");
2145        let bound = self.facade.authz();
2146        let actor = if self.credentials.is_some() {
2147            bound.principal().to_string()
2148        } else {
2149            req.get("actor").and_then(Value::as_str).unwrap_or("user:console").to_string()
2150        };
2151        let actor = actor.as_str();
2152        let decision = if req.get("decision").and_then(Value::as_str) == Some("reject") {
2153            Decision::Reject
2154        } else {
2155            Decision::Approve
2156        };
2157        let mut sub = BorrowedSubstrate::new(&self.facade);
2158        let scopes = areev_loop_adapter::scopes_for(&self.facade.authz());
2159        let observer = areev_loop_adapter::observer_for_principal(actor);
2160        match self.engine().review(
2161            &mut sub, hash, decision, actor, observer, &scopes, because, now_ms(),
2162        ) {
2163            Ok(()) => ok_json(json!({"ok": true})),
2164            Err(e) => ok_json(json!({"ok": false, "error": e.to_string(), "code": e.code()})),
2165        }
2166    }
2167
2168    fn loop_apply(&self, body: &[u8]) -> (&'static str, &'static str, Vec<u8>) {
2169        let req: Value = serde_json::from_slice(body).unwrap_or(Value::Null);
2170        let hash = req.get("hash").and_then(Value::as_str).unwrap_or("");
2171        let because = req.get("because").and_then(Value::as_str).unwrap_or("");
2172        let bound = self.facade.authz();
2173        let actor = if self.credentials.is_some() {
2174            bound.principal().to_string()
2175        } else {
2176            req.get("actor").and_then(Value::as_str).unwrap_or("user:console").to_string()
2177        };
2178        let actor = actor.as_str();
2179        let allow_destructive = req.get("allow_destructive").and_then(Value::as_bool).unwrap_or(false);
2180        let mut sub = BorrowedSubstrate::new(&self.facade);
2181        let scopes = areev_loop_adapter::scopes_for(&self.facade.authz());
2182        let observer = areev_loop_adapter::observer_for_principal(actor);
2183        let engine = self.engine();
2184        // A code or adapter revision applies only through its recorded gating
2185        // edge: `gating_run` names an eval run whose journaled `mg:eval_run`
2186        // summary becomes the evidence — the stats never come from the client.
2187        let gating = match req.get("gating_run").and_then(Value::as_str) {
2188            Some(run_id) => match engine.gating_evidence(&sub, hash, run_id) {
2189                Ok(g) => Some(g),
2190                Err(e) => {
2191                    return ok_json(json!({"ok": false, "error": e.to_string(), "code": e.code()}))
2192                }
2193            },
2194            None => None,
2195        };
2196        let applied = match &gating {
2197            Some(g) => engine.apply_gated(
2198                &mut sub, hash, actor, observer, &scopes, because, allow_destructive, g, now_ms(),
2199            ),
2200            None => engine.apply(
2201                &mut sub, hash, actor, observer, &scopes, because, allow_destructive, now_ms(),
2202            ),
2203        };
2204        match applied {
2205            Ok(applied) => ok_json(json!({"ok": true, "rollbackable": applied.rollbackable})),
2206            Err(e) => ok_json(json!({"ok": false, "error": e.to_string(), "code": e.code()})),
2207        }
2208    }
2209
2210    fn loop_rollback(&self, body: &[u8]) -> (&'static str, &'static str, Vec<u8>) {
2211        let req: Value = serde_json::from_slice(body).unwrap_or(Value::Null);
2212        let hash = req.get("hash").and_then(Value::as_str).unwrap_or("");
2213        let because = req.get("because").and_then(Value::as_str).unwrap_or("");
2214        let bound = self.facade.authz();
2215        let actor = if self.credentials.is_some() {
2216            bound.principal().to_string()
2217        } else {
2218            req.get("actor").and_then(Value::as_str).unwrap_or("user:console").to_string()
2219        };
2220        let actor = actor.as_str();
2221        let mut sub = BorrowedSubstrate::new(&self.facade);
2222        let scopes = areev_loop_adapter::scopes_for(&self.facade.authz());
2223        let observer = areev_loop_adapter::observer_for_principal(actor);
2224        match self.engine().rollback(
2225            &mut sub, hash, actor, observer, &scopes, because, now_ms(),
2226        ) {
2227            Ok(()) => ok_json(json!({"ok": true})),
2228            Err(e) => ok_json(json!({"ok": false, "error": e.to_string(), "code": e.code()})),
2229        }
2230    }
2231
2232    /// Edit one analyzer's file-config from the Setup view. The body is
2233    /// `{analyzer_id, enabled?, severity_floor?, clear_floor?, params?,
2234    /// namespaces?}`; absent fields are left unchanged. The console holds all
2235    /// scopes (local root of trust), so `Admin` is satisfied; an unknown
2236    /// analyzer or bad param is a structured `ok:false` (not a 500).
2237    fn loop_config(&self, body: &[u8]) -> (&'static str, &'static str, Vec<u8>) {
2238        let id = serde_json::from_slice::<Value>(body)
2239            .ok()
2240            .and_then(|v| v.get("analyzer_id").and_then(Value::as_str).map(str::to_string))
2241            .unwrap_or_default();
2242        // The update reads the same body; the extra `analyzer_id` key is ignored.
2243        let update: areev_loop::AnalyzerConfigUpdate = serde_json::from_slice(body).unwrap_or_default();
2244        let mut sub = BorrowedSubstrate::new(&self.facade);
2245        match self.engine().set_analyzer_config(&mut sub, &id, update, &areev_loop_adapter::scopes_for(&self.facade.authz())) {
2246            Ok(cfg) => ok_json(json!({"ok": true, "config": cfg})),
2247            Err(e) => ok_json(json!({"ok": false, "error": e.to_string(), "code": e.code()})),
2248        }
2249    }
2250}
2251
2252impl UiServer {
2253    /// Score a candidate loop configuration against the recorded past
2254    /// (`areev loop replay`): a computation, not a write — but token-guarded
2255    /// like `/api/loop/config`, because it reads the whole review history
2256    /// and runs every analyzer over every step. Body = the candidate plus
2257    /// the window (`areev_loop::replay::ReplayRequest`).
2258    fn loop_replay(&self, body: &[u8]) -> (&'static str, &'static str, Vec<u8>) {
2259        let req = match serde_json::from_slice::<areev_loop::replay::ReplayRequest>(body) {
2260            Ok(r) => r,
2261            Err(e) => return ok_json(json!({"ok": false, "error": format!("replay request: {e}")})),
2262        };
2263        let (candidate, opts) = match req.resolve(now_ms()) {
2264            Ok(v) => v,
2265            Err(e) => return ok_json(json!({"ok": false, "error": e.to_string(), "code": e.code()})),
2266        };
2267        let sub = BorrowedSubstrate::new(&self.facade);
2268        match self.engine().replay(&sub, &candidate, &opts) {
2269            Ok(report) => ok_json(json!({"ok": true, "replay": report})),
2270            Err(e) => ok_json(json!({"ok": false, "error": e.to_string(), "code": e.code()})),
2271        }
2272    }
2273
2274    /// Declare or clear a per-namespace anonymization policy from the
2275    /// console (Connect → Settings). Rides the same write gate as every
2276    /// console POST (auth + Origin + body cap upstream); the policy itself
2277    /// validates fail-closed in the store.
2278    fn anon_config(&self, body: &[u8]) -> (&'static str, &'static str, Vec<u8>) {
2279        let v: Value = match serde_json::from_slice(body) {
2280            Ok(v) => v,
2281            Err(e) => return ok_json(json!({"ok": false, "error": e.to_string()})),
2282        };
2283        let Some(ns) = v.get("ns").and_then(Value::as_str).filter(|n| !n.trim().is_empty())
2284        else {
2285            return ok_json(json!({"ok": false, "error": "ns is required"}));
2286        };
2287        let result = if v.get("clear").and_then(Value::as_bool) == Some(true) {
2288            self.facade.with_store(|m| m.clear_anon_policy(ns))
2289        } else {
2290            match v.get("policy") {
2291                Some(policy) => {
2292                    let policy_json = policy.to_string();
2293                    self.facade.with_store(|m| m.set_anon_policy(ns, &policy_json))
2294                }
2295                None => {
2296                    return ok_json(json!({"ok": false, "error": "policy (object) or clear: true is required"}));
2297                }
2298            }
2299        };
2300        match result {
2301            Ok(()) => {
2302                let declared = self.facade.with_store(|m| m.anon_declared());
2303                ok_json(json!({"ok": true, "policies": declared
2304                    .iter()
2305                    .map(|(ns, mode)| json!({"ns": ns, "mode": mode}))
2306                    .collect::<Vec<_>>()}))
2307            }
2308            Err(e) => ok_json(json!({"ok": false, "error": e.to_string()})),
2309        }
2310    }
2311}
2312
2313/// True when a `host[:port]` (or `[ipv6]:port`) authority names a loopback
2314/// host. Shared by the Origin drive-by check and the Host-header
2315/// (DNS-rebinding) check.
2316fn host_is_local(host_port: &str) -> bool {
2317    let host = if let Some(h) = host_port.strip_prefix('[') {
2318        h.split(']').next().unwrap_or("")
2319    } else {
2320        host_port.split(':').next().unwrap_or("")
2321    };
2322    matches!(host, "127.0.0.1" | "localhost" | "::1")
2323}
2324
2325/// True when an Origin header names a loopback host (any port, http/https).
2326fn origin_is_local(origin: &str) -> bool {
2327    let rest = origin
2328        .strip_prefix("http://")
2329        .or_else(|| origin.strip_prefix("https://"));
2330    let Some(rest) = rest else { return false };
2331    let host_port = rest.split('/').next().unwrap_or("");
2332    host_is_local(host_port)
2333}
2334
2335/// Normalize an origin (an `Origin` header value, or an operator-configured
2336/// `--allow-origin` entry) for EXACT comparison: lowercase, with a single
2337/// trailing `/` dropped. Nothing else — this only makes two spellings of the
2338/// same origin compare equal; it does not validate shape (the CLI's
2339/// `--allow-origin` parser does that at startup).
2340fn normalize_origin(origin: &str) -> String {
2341    let trimmed = origin.trim();
2342    trimmed.strip_suffix('/').unwrap_or(trimmed).to_ascii_lowercase()
2343}
2344
2345/// Redact a DISPLAY copy of a connection-string / file-path DB label
2346/// (issue #124): if `label` has a `scheme://[user[:pass]@]host...` shape and
2347/// the userinfo carries a password, replace the password with `***`.
2348/// Anything else — a plain file path (no `://`), a DSN with no userinfo, or
2349/// userinfo with no password — is returned byte-for-byte unchanged.
2350///
2351/// **DISPLAY ONLY.** The raw label is still what
2352/// `CredentialMap::resolve_for_memory` (`areev-core/src/authz.rs`) matches a
2353/// token's `memories` scope against via an exact string compare — never swap
2354/// in this redacted form at those call sites, or every `--auth FILE`
2355/// deployment that scopes a token to a Postgres memory silently breaks.
2356///
2357/// Hand-rolled (no URL crate, per the workspace's dependency-light policy):
2358/// only the AUTHORITY segment — between `://` and the first `/`, `?`, or `#`
2359/// that follows — is considered, and within it the LAST `@` is taken as the
2360/// userinfo delimiter. RFC 3986 allows a percent-encoded `@` in a password
2361/// and real passwords contain literal `@` too, so an earlier `@` is presumed
2362/// to be *inside* the password rather than a second delimiter; and a `:` or
2363/// `@` appearing later — in a query string, say — is outside the authority
2364/// entirely and is never mistaken for userinfo.
2365pub fn redact_dsn(label: &str) -> String {
2366    let Some(scheme_end) = label.find("://") else {
2367        return label.to_string();
2368    };
2369    let authority_start = scheme_end + 3;
2370    let rest = &label[authority_start..];
2371    let authority_len = rest.find(['/', '?', '#']).unwrap_or(rest.len());
2372    let authority = &rest[..authority_len];
2373    let Some(at) = authority.rfind('@') else {
2374        return label.to_string();
2375    };
2376    let userinfo = &authority[..at];
2377    let Some(colon) = userinfo.find(':') else {
2378        return label.to_string();
2379    };
2380    let user = &userinfo[..colon];
2381    let mut out = String::with_capacity(label.len());
2382    out.push_str(&label[..authority_start]);
2383    out.push_str(user);
2384    out.push_str(":***@");
2385    out.push_str(&authority[at + 1..]);
2386    out.push_str(&rest[authority_len..]);
2387    out
2388}
2389
2390fn ok_json(v: Value) -> (&'static str, &'static str, Vec<u8>) {
2391    ("200 OK", "application/json", v.to_string().into_bytes())
2392}
2393
2394fn run_api_err(msg: &str) -> (&'static str, &'static str, Vec<u8>) {
2395    (
2396        "400 Bad Request",
2397        "application/json",
2398        serde_json::json!({"ok": false, "error": msg}).to_string().into_bytes(),
2399    )
2400}
2401
2402/// True when a `POST /api/cal` body is a read-only statement. Classification
2403/// is delegated to `areev-cal`'s exhaustive [`areev_cal::classify`] module —
2404/// the single source of truth — so grammar growth cannot desynchronize this
2405/// gate (a keyword sniff lived here before and would have silently
2406/// misclassified any new statement). Fail closed: unparseable text is not
2407/// read-only.
2408fn cal_body_is_read_only(body: &[u8]) -> bool {
2409    let req: Value = serde_json::from_slice(body).unwrap_or(Value::Null);
2410    let q = req.get("query").and_then(Value::as_str).unwrap_or("");
2411    areev_cal::classify::query_is_read_only(q)
2412}
2413
2414fn status_from_str(s: &str) -> Option<RecStatus> {
2415    match s {
2416        "pending" => Some(RecStatus::Pending),
2417        "approved" => Some(RecStatus::Approved),
2418        "rejected" => Some(RecStatus::Rejected),
2419        "applied" => Some(RecStatus::Applied),
2420        "rolled_back" => Some(RecStatus::RolledBack),
2421        "expired" => Some(RecStatus::Expired),
2422        "withdrawn" => Some(RecStatus::Withdrawn),
2423        _ => None, // includes "all"
2424    }
2425}
2426
2427fn rec_json(r: &areev_loop::Recommendation) -> Value {
2428    let mut v = json!({
2429        "hash": r.hash,
2430        "status": r.status.as_str(),
2431        "severity": r.severity.as_str(),
2432        "analyzer": r.analyzer,
2433        "summary": r.summary.render(),
2434        "target_ref": r.target_ref,
2435        "destructive": r.destructive,
2436        "rollbackable": r.rollbackable,
2437        "evidence": r.evidence,
2438        // The Rule E1 pin, when present — the console reads it to know this
2439        // apply needs a recorded gating run (and which evalset to run).
2440        "evalset_hash": r.evalset_hash,
2441    });
2442    // Live lessons this authored lesson restates — the console shows the
2443    // existing rule beside the proposal. Present only when there are any,
2444    // so every other row reads exactly as before.
2445    if !r.near_duplicate_of.is_empty() {
2446        v["near_duplicate_of"] = json!(r.near_duplicate_of);
2447    }
2448    // A plan revision's rehearsal report, when the runtime could run one.
2449    if let Some(replay) = &r.replay {
2450        v["replay"] = replay.clone();
2451    }
2452    v
2453}
2454
2455fn urldecode(s: &str) -> String {
2456    let mut out = Vec::new();
2457    let b = s.as_bytes();
2458    let mut i = 0;
2459    while i < b.len() {
2460        match b[i] {
2461            b'%' if i + 2 < b.len() + 1 && i + 2 < b.len() + 1 => {
2462                if i + 2 < b.len() + 1 && i + 2 < b.len() {
2463                    let hex = std::str::from_utf8(&b[i + 1..i + 3]).unwrap_or("");
2464                    if let Ok(v) = u8::from_str_radix(hex, 16) {
2465                        out.push(v);
2466                        i += 3;
2467                        continue;
2468                    }
2469                }
2470                out.push(b[i]);
2471                i += 1;
2472            }
2473            b'+' => {
2474                out.push(b' ');
2475                i += 1;
2476            }
2477            c => {
2478                out.push(c);
2479                i += 1;
2480            }
2481        }
2482    }
2483    String::from_utf8_lossy(&out).to_string()
2484}
2485
2486fn html_escape(s: &str) -> String {
2487    s.replace('&', "&amp;").replace('<', "&lt;").replace('>', "&gt;")
2488}
2489
2490/// Constant-time byte comparison — avoids leaking the bearer token through
2491/// response timing. A length mismatch fails fast (token length is not secret).
2492fn ct_eq(a: &[u8], b: &[u8]) -> bool {
2493    if a.len() != b.len() {
2494        return false;
2495    }
2496    let mut diff = 0u8;
2497    for (x, y) in a.iter().zip(b.iter()) {
2498        diff |= x ^ y;
2499    }
2500    diff == 0
2501}
2502
2503/// Decode standard base64 (RFC 4648), enough for HTTP Basic credentials.
2504/// The one hand-rolled decoder lives in `areev_core::b64` (the trigger
2505/// evaluator shares it for connector blobs, #93).
2506fn base64_decode(s: &str) -> Option<Vec<u8>> {
2507    areev_core::b64::decode(s)
2508}
2509
2510/// Extract the password from an HTTP Basic credential (`base64("user:pass")`).
2511/// The console ignores the username and treats the password as the token.
2512/// Returns `None` if the value is not valid base64/UTF-8 or has no `:`.
2513fn basic_auth_password(b64: &str) -> Option<String> {
2514    let text = String::from_utf8(base64_decode(b64)?).ok()?;
2515    text.split_once(':').map(|(_user, pass)| pass.to_string())
2516}
2517
2518#[cfg(test)]
2519mod loop_route_tests {
2520    use super::UiServer;
2521    use areev_cal::AreevFacade;
2522    use areev_core::types::{Fact, Grain};
2523    use areev_store::Areev;
2524
2525    fn server(auth: Option<&str>) -> UiServer {
2526        let dir = tempfile::tempdir().unwrap();
2527        let path = dir.path().join("t.db");
2528        let mut store = Areev::open(path.to_str().unwrap()).unwrap();
2529        // Two identical facts → a duplicate-consolidation recommendation.
2530        store.add(&Fact::new("acme", "tier", "Enterprise").namespace("caller")).unwrap();
2531        store.add(&Fact::new("acme", "tier", "Enterprise").namespace("caller")).unwrap();
2532        std::mem::forget(dir); // keep the file alive for the server's lifetime
2533        let facade = AreevFacade::with_session(store, Some("caller".into()), None);
2534        let s = UiServer::new(facade, "test".into());
2535        match auth {
2536            Some(t) => s.with_auth(t.to_string()),
2537            None => s,
2538        }
2539    }
2540
2541    fn text(r: &(&str, &str, Vec<u8>)) -> String {
2542        String::from_utf8_lossy(&r.2).to_string()
2543    }
2544
2545    #[test]
2546    fn token_less_console_is_read_only() {
2547        let s = server(None);
2548        // A loop mutation (run) is a write → 401.
2549        assert!(s.route("POST", "/api/loop/run", b"{}", None, None).0.starts_with("401"));
2550        // A write CAL → 401.
2551        let w = s.route("POST", "/api/cal", br#"{"query":"ADD fact SET subject=\"x\""}"#, None, None);
2552        assert!(w.0.starts_with("401"), "write CAL must be 401: {}", text(&w));
2553        // A read CAL → 200.
2554        let r = s.route("POST", "/api/cal", br#"{"query":"RECALL facts WHERE subject = \"acme\""}"#, None, None);
2555        assert!(r.0.starts_with("200"), "read CAL allowed: {}", text(&r));
2556        // Areev Loop reads stay open token-less.
2557        assert!(s.route("GET", "/api/loop/recommendations", b"", None, None).0.starts_with("200"));
2558    }
2559
2560    #[test]
2561    fn telemetry_endpoint_is_an_open_read() {
2562        let s = server(None);
2563        let r = s.route("GET", "/api/loop/telemetry", b"", None, None);
2564        assert!(r.0.starts_with("200"), "telemetry read open: {}", text(&r));
2565        let body = text(&r);
2566        assert!(body.contains("\"ok\":true"), "{body}");
2567        // The test store opens bare (telemetry off) → enabled:false.
2568        assert!(body.contains("\"enabled\":false"), "{body}");
2569    }
2570
2571    #[test]
2572    fn authenticated_run_review_apply_roundtrip() {
2573        let s = server(Some("tok"));
2574        let run = s.route("POST", "/api/loop/run", b"{}", Some("tok"), None);
2575        assert!(run.0.starts_with("200"), "run: {}", text(&run));
2576        assert!(text(&run).contains("\"ran\""));
2577
2578        let list = s.route("GET", "/api/loop/recommendations?status=pending", b"", Some("tok"), None);
2579        let v: serde_json::Value = serde_json::from_slice(&list.2).unwrap();
2580        let recs = v["recommendations"].as_array().unwrap();
2581        assert!(!recs.is_empty(), "at least one recommendation");
2582        let hash = recs[0]["hash"].as_str().unwrap().to_string();
2583
2584        let rev = s.route(
2585            "POST",
2586            "/api/loop/review",
2587            format!(r#"{{"hash":"{hash}","decision":"approve","because":"ok"}}"#).as_bytes(),
2588            Some("tok"),
2589            None,
2590        );
2591        assert!(text(&rev).contains("\"ok\":true"), "review: {}", text(&rev));
2592
2593        let ap = s.route(
2594            "POST",
2595            "/api/loop/apply",
2596            format!(r#"{{"hash":"{hash}","because":"go"}}"#).as_bytes(),
2597            Some("tok"),
2598            None,
2599        );
2600        assert!(text(&ap).contains("\"ok\":true"), "apply: {}", text(&ap));
2601    }
2602
2603    #[test]
2604    fn config_edit_toggles_analyzer_via_console() {
2605        let s = server(Some("tok"));
2606        // Token-less write → 401 (guarded like every POST).
2607        assert!(s.route("POST", "/api/loop/config", b"{}", None, None).0.starts_with("401"));
2608        // Replay is a read, but it is guarded like config: it runs every
2609        // analyzer over the whole history on request.
2610        assert!(s.route("POST", "/api/loop/replay", b"{}", None, None).0.starts_with("401"));
2611        let (status, _ct, body) = s.route("POST", "/api/loop/replay", br#"{"window":"7d","step":"1d"}"#, Some("tok"), None);
2612        assert!(status.starts_with("200"), "{status}");
2613        let v: serde_json::Value = serde_json::from_slice(&body).unwrap();
2614        assert_eq!(v["ok"], true, "{v}");
2615        for key in ["incumbent", "candidate", "steps", "not_replayed", "matching"] {
2616            assert!(v["replay"].get(key).is_some(), "replay report lacks {key}: {v}");
2617        }
2618        assert!(v["replay"]["incumbent"]["total"]["findings"].is_u64(), "{v}");
2619        let (_status, _ct, body) = s.route("POST", "/api/loop/replay", br#"{"analyzers": {}}"#, Some("tok"), None);
2620        let v: serde_json::Value = serde_json::from_slice(&body).unwrap();
2621        assert_eq!(v["ok"], false, "unknown keys are refused: {v}");
2622
2623        // Read the analyzers; pick one that is on by default.
2624        let list = s.route("GET", "/api/loop/analyzers", b"", Some("tok"), None);
2625        let v: serde_json::Value = serde_json::from_slice(&list.2).unwrap();
2626        let id = v["analyzers"]
2627            .as_array()
2628            .unwrap()
2629            .iter()
2630            .find(|x| x["enabled"] == true)
2631            .expect("some analyzer is on")["id"]
2632            .as_str()
2633            .unwrap()
2634            .to_string();
2635
2636        // Disable it via the console endpoint.
2637        let post = s.route(
2638            "POST",
2639            "/api/loop/config",
2640            format!(r#"{{"analyzer_id":"{id}","enabled":false}}"#).as_bytes(),
2641            Some("tok"),
2642            None,
2643        );
2644        assert!(text(&post).contains("\"ok\":true"), "config: {}", text(&post));
2645
2646        // It reads back disabled.
2647        let list2 = s.route("GET", "/api/loop/analyzers", b"", Some("tok"), None);
2648        let v2: serde_json::Value = serde_json::from_slice(&list2.2).unwrap();
2649        let now = v2["analyzers"]
2650            .as_array()
2651            .unwrap()
2652            .iter()
2653            .find(|x| x["id"] == id.as_str())
2654            .unwrap();
2655        assert_eq!(now["enabled"], false, "toggled off and persisted");
2656    }
2657
2658    #[test]
2659    fn self_approval_surfaces_lop_code() {
2660        let s = server(Some("tok"));
2661        s.route("POST", "/api/loop/run", b"{}", Some("tok"), None);
2662        let list = s.route("GET", "/api/loop/recommendations?status=pending", b"", Some("tok"), None);
2663        let v: serde_json::Value = serde_json::from_slice(&list.2).unwrap();
2664        let rec = &v["recommendations"].as_array().unwrap()[0];
2665        let hash = rec["hash"].as_str().unwrap();
2666        let analyzer = rec["analyzer"].as_str().unwrap();
2667        let creator = format!("engine:{analyzer}");
2668        // The engine actor approving its own proposal is blocked.
2669        let rev = s.route(
2670            "POST",
2671            "/api/loop/review",
2672            format!(r#"{{"hash":"{hash}","decision":"approve","because":"self","actor":"{creator}"}}"#).as_bytes(),
2673            Some("tok"),
2674            None,
2675        );
2676        assert!(text(&rev).contains("LOP-E021"), "self-approval blocked: {}", text(&rev));
2677    }
2678}
2679
2680#[cfg(test)]
2681mod security_tests {
2682    use super::{base64_decode, basic_auth_password, ct_eq, normalize_origin, redact_dsn};
2683
2684    #[test]
2685    fn ct_eq_matches_only_equal() {
2686        assert!(ct_eq(b"secret-token", b"secret-token"));
2687        assert!(!ct_eq(b"secret-token", b"secret-toker"));
2688        assert!(!ct_eq(b"secret", b"secret-token")); // length mismatch
2689        assert!(ct_eq(b"", b""));
2690    }
2691
2692    #[test]
2693    fn base64_decode_roundtrips_known_vectors() {
2694        // RFC 4648 test vectors (with and without padding).
2695        assert_eq!(base64_decode("").unwrap(), b"");
2696        assert_eq!(base64_decode("Zg==").unwrap(), b"f");
2697        assert_eq!(base64_decode("Zm8=").unwrap(), b"fo");
2698        assert_eq!(base64_decode("Zm9v").unwrap(), b"foo");
2699        assert_eq!(base64_decode("Zm9vYmFy").unwrap(), b"foobar");
2700        // padding is optional for us
2701        assert_eq!(base64_decode("Zg").unwrap(), b"f");
2702        // invalid characters are rejected
2703        assert!(base64_decode("****").is_none());
2704    }
2705
2706    #[test]
2707    fn basic_auth_extracts_password_ignoring_username() {
2708        // base64("areev:s3cret") and base64(":s3cret") both yield the token.
2709        assert_eq!(basic_auth_password("ZGVqYTpzM2NyZXQ=").as_deref(), Some("s3cret"));
2710        assert_eq!(basic_auth_password("OnMzY3JldA==").as_deref(), Some("s3cret"));
2711        // a password may itself contain ':' — only the first ':' splits.
2712        // base64("u:a:b") -> "a:b"
2713        assert_eq!(basic_auth_password("dTphOmI=").as_deref(), Some("a:b"));
2714        // no ':' at all → not a valid Basic credential
2715        assert_eq!(basic_auth_password("bm9jb2xvbg=="), None); // "nocolon"
2716        assert_eq!(basic_auth_password("****"), None); // not base64
2717    }
2718
2719    // ── redact_dsn (issue #124) ─────────────────────────────────────────
2720
2721    #[test]
2722    fn redact_dsn_hides_a_password() {
2723        assert_eq!(
2724            redact_dsn("postgresql://rounic:SUPERSECRET@pg-x:5432/rounic?sslmode=verify-full&schema=desk_invoice"),
2725            "postgresql://rounic:***@pg-x:5432/rounic?sslmode=verify-full&schema=desk_invoice",
2726        );
2727    }
2728
2729    #[test]
2730    fn redact_dsn_leaves_no_password_alone() {
2731        assert_eq!(redact_dsn("postgres://user@host:5432/db"), "postgres://user@host:5432/db");
2732    }
2733
2734    #[test]
2735    fn redact_dsn_leaves_no_userinfo_alone() {
2736        assert_eq!(redact_dsn("postgres://host:5432/db"), "postgres://host:5432/db");
2737    }
2738
2739    #[test]
2740    fn redact_dsn_leaves_a_plain_file_path_alone() {
2741        assert_eq!(redact_dsn("demo.db"), "demo.db");
2742        assert_eq!(redact_dsn("/var/lib/areev/demo.db"), "/var/lib/areev/demo.db");
2743    }
2744
2745    #[test]
2746    fn redact_dsn_hides_a_percent_encoded_password() {
2747        // %40 is an encoded '@' inside the password — the LITERAL '@' that
2748        // ends the authority is the one after it.
2749        assert_eq!(
2750            redact_dsn("postgres://user:pa%40ss@host:5432/db"),
2751            "postgres://user:***@host:5432/db",
2752        );
2753    }
2754
2755    #[test]
2756    fn redact_dsn_hides_a_password_with_a_literal_at_sign() {
2757        // The LAST '@' in the authority is the userinfo/host boundary, so a
2758        // literal '@' inside the password is presumed to be part of it.
2759        assert_eq!(
2760            redact_dsn("postgres://user:p@ss@host:5432/db"),
2761            "postgres://user:***@host:5432/db",
2762        );
2763    }
2764
2765    #[test]
2766    fn redact_dsn_ignores_colon_and_at_in_the_query_string() {
2767        assert_eq!(
2768            redact_dsn("postgres://user:pass@host:5432/db?options=-c%20foo:bar@baz"),
2769            "postgres://user:***@host:5432/db?options=-c%20foo:bar@baz",
2770        );
2771    }
2772
2773    #[test]
2774    fn redact_dsn_handles_empty_string() {
2775        assert_eq!(redact_dsn(""), "");
2776    }
2777
2778    // ── normalize_origin (issue #125) ───────────────────────────────────
2779
2780    #[test]
2781    fn normalize_origin_lowercases_and_drops_one_trailing_slash() {
2782        assert_eq!(normalize_origin("https://Console.Example.com"), "https://console.example.com");
2783        assert_eq!(normalize_origin("https://console.example.com/"), "https://console.example.com");
2784        assert_eq!(
2785            normalize_origin("HTTPS://Console.Example.com/"),
2786            "https://console.example.com",
2787        );
2788    }
2789}
2790
2791#[cfg(test)]
2792mod credential_route_tests {
2793    use super::UiServer;
2794    use areev_cal::AreevFacade;
2795    use areev_core::authz::{CredentialMap, AUTHZ_NS, REL_PERMITS};
2796    use areev_core::types::{Fact, Grain};
2797    use areev_store::Areev;
2798
2799    /// The tuning seam over HTTP: an adapter recommendation applies only
2800    /// with `gating_run` in the POST body — evidence loaded from the
2801    /// journaled eval summary, never from the client — and the rec row
2802    /// carries `evalset_hash` so the console knows the apply is gated.
2803    #[test]
2804    fn loop_apply_honors_a_gating_run() {
2805        let dir = tempfile::tempdir().unwrap();
2806        let path = dir.path().join("g.db");
2807        let mut store = Areev::open(path.to_str().unwrap()).unwrap();
2808        // The pin and a clean recorded gate run, as `areev eval` writes them.
2809        let evalset = store
2810            .add(
2811                &Fact::new("evalset:gate", "mg:evalset", "{\"name\":\"gate\",\"cases\":[]}")
2812                    .namespace("agent:harness")
2813                    .created_at(1_000),
2814            )
2815            .unwrap()
2816            .to_hex();
2817        store
2818            .add(
2819                &Fact::new(
2820                    &format!("evalset:{evalset}"),
2821                    "mg:eval_run",
2822                    "{\"run_id\":\"eval-1\",\"passed\":3,\"failed\":0}",
2823                )
2824                .namespace("agent:harness")
2825                .created_at(2_000),
2826            )
2827            .unwrap();
2828        store.add(&Fact::new("acme", "tier", "Enterprise").namespace("caller").created_at(3_000)).unwrap();
2829        std::mem::forget(dir);
2830        let facade = AreevFacade::with_session(store, Some("caller".into()), None);
2831        // Register an adapter with real lineage (a recorded corpus export).
2832        let manifest = facade
2833            .record_corpus_export("RECALL facts", "train.jsonl", None, 4_000, &[], &[])
2834            .unwrap()
2835            .to_hex();
2836        facade
2837            .record_adapter(
2838                r#"{"adapter":{"uri":"file:///tmp/a","sha256":"feed"},"base_model":"qwen3-4b","serves_as":"acme-support"}"#,
2839                &manifest,
2840                &evalset,
2841                5_000,
2842            )
2843            .unwrap();
2844        // Token mode: the shared secret is the implied local owner — the
2845        // token-less console is read-only by design.
2846        let s = UiServer::new(facade, "test".into()).with_auth("gate-token".into());
2847        let tok = Some("gate-token");
2848
2849        // Propose, then find the adapter rec — its row must carry the pin.
2850        let run = s.route("POST", "/api/loop/run", b"{}", tok, None);
2851        assert!(run.0.starts_with("200"), "{}", text(&run));
2852        let list = s.route("GET", "/api/loop/recommendations?status=pending", b"", tok, None);
2853        let rows: serde_json::Value = serde_json::from_str(&text(&list)).unwrap();
2854        let rec = rows["recommendations"]
2855            .as_array()
2856            .unwrap()
2857            .iter()
2858            .find(|r| r["analyzer"].as_str().unwrap_or("").contains("adapter_intake"))
2859            .unwrap_or_else(|| panic!("adapter_intake must propose: {rows}"));
2860        assert_eq!(rec["evalset_hash"], evalset.as_str(), "the row must carry the pin");
2861        let hash = rec["hash"].as_str().unwrap().to_string();
2862
2863        // Approve (the console's first POST), then: ungated apply refused,
2864        // a bad run id refused, the recorded run admits.
2865        let approve = format!(r#"{{"hash":"{hash}","decision":"approve","because":"reviewed"}}"#);
2866        let r = s.route("POST", "/api/loop/review", approve.as_bytes(), tok, None);
2867        assert!(text(&r).contains("\"ok\":true"), "{}", text(&r));
2868        let bare = format!(r#"{{"hash":"{hash}","because":"ship it"}}"#);
2869        let r = s.route("POST", "/api/loop/apply", bare.as_bytes(), tok, None);
2870        assert!(text(&r).contains("gating run"), "ungated must refuse: {}", text(&r));
2871        let bad = format!(r#"{{"hash":"{hash}","because":"ship","gating_run":"eval-nope"}}"#);
2872        let r = s.route("POST", "/api/loop/apply", bad.as_bytes(), tok, None);
2873        assert!(text(&r).contains("no recorded gate run"), "{}", text(&r));
2874        let good = format!(r#"{{"hash":"{hash}","because":"gated and green","gating_run":"eval-1"}}"#);
2875        let r = s.route("POST", "/api/loop/apply", good.as_bytes(), tok, None);
2876        assert!(text(&r).contains("\"ok\":true"), "gated apply failed: {}", text(&r));
2877    }
2878
2879    /// A server in credential-map mode: two env-referenced tokens, rights
2880    /// seeded as grant grains in the file itself.
2881    fn server() -> UiServer {
2882        std::env::set_var("AREEV_TEST_READER_TOK", "reader-secret");
2883        std::env::set_var("AREEV_TEST_WRITER_TOK", "writer-secret");
2884        let dir = tempfile::tempdir().unwrap();
2885        let path = dir.path().join("t.db");
2886        let mut store = Areev::open(path.to_str().unwrap()).unwrap();
2887        store
2888            .add(&Fact::new("user:reader", REL_PERMITS, "read ON *").namespace(AUTHZ_NS).created_at(1_000))
2889            .unwrap();
2890        store
2891            .add(
2892                &Fact::new("agent:writer", REL_PERMITS, "read,write ON caller")
2893                    .namespace(AUTHZ_NS)
2894                    .created_at(2_000),
2895            )
2896            .unwrap();
2897        store.add(&Fact::new("acme", "tier", "Enterprise").namespace("caller").created_at(3_000)).unwrap();
2898        std::mem::forget(dir);
2899        let facade = AreevFacade::with_session(store, Some("caller".into()), None);
2900        let map = CredentialMap::from_json(
2901            r#"{"version":1,"tokens":[
2902                {"env":"AREEV_TEST_READER_TOK","principal":"user:reader"},
2903                {"env":"AREEV_TEST_WRITER_TOK","principal":"agent:writer"}
2904            ]}"#,
2905        )
2906        .unwrap();
2907        UiServer::new(facade, "test".into()).with_credentials(map)
2908    }
2909
2910    fn text(r: &(&str, &str, Vec<u8>)) -> String {
2911        String::from_utf8_lossy(&r.2).to_string()
2912    }
2913
2914    const READ: &[u8] = br#"{"query":"RECALL facts WHERE subject = \"acme\""}"#;
2915    const WRITE: &[u8] = br#"{"query":"ADD fact SET subject = \"x\" SET relation = \"r\" SET object = \"o\" SET namespace = \"caller\" REASON \"t\""}"#;
2916
2917    #[test]
2918    fn tokens_bind_principals_and_verbs_decide() {
2919        let s = server();
2920
2921        // The reader reads…
2922        let r = s.route("POST", "/api/cal", READ, Some("reader-secret"), None);
2923        assert!(r.0.starts_with("200"), "{}", text(&r));
2924        assert!(text(&r).contains("grains"));
2925        // …and cannot write.
2926        let w = s.route("POST", "/api/cal", WRITE, Some("reader-secret"), None);
2927        assert!(text(&w).contains("AUT-E001"), "reader write must be refused: {}", text(&w));
2928
2929        // The writer writes.
2930        let w = s.route("POST", "/api/cal", WRITE, Some("writer-secret"), None);
2931        assert!(text(&w).contains("added"), "writer write must land: {}", text(&w));
2932
2933        // Sequential isolation: the very next reader request is still
2934        // read-only — nothing leaked from the writer's binding.
2935        let w = s.route("POST", "/api/cal", WRITE, Some("reader-secret"), None);
2936        assert!(text(&w).contains("AUT-E001"), "{}", text(&w));
2937    }
2938
2939    #[test]
2940    fn anonymous_is_read_only_and_loop_run_is_verb_gated() {
2941        let s = server();
2942        // No token → anonymous: reads work…
2943        let r = s.route("POST", "/api/cal", READ, None, None);
2944        assert!(r.0.starts_with("200"), "{}", text(&r));
2945        assert!(text(&r).contains("grains"));
2946        // …writes are verb-refused…
2947        let w = s.route("POST", "/api/cal", WRITE, None, None);
2948        assert!(text(&w).contains("AUT-E001"), "{}", text(&w));
2949        // …and an unknown token is anonymous too, never an escalation.
2950        let w = s.route("POST", "/api/cal", WRITE, Some("stolen-guess"), None);
2951        assert!(text(&w).contains("AUT-E001"), "{}", text(&w));
2952
2953        // The analysis trigger needs loop.run — anonymous gets 403.
2954        let run = s.route("POST", "/api/loop/run", b"{}", None, None);
2955        assert!(run.0.starts_with("403"), "{}", text(&run));
2956    }
2957
2958    #[test]
2959    fn whoami_reports_the_bound_principal() {
2960        let s = server();
2961        let r = s.route("GET", "/api/whoami", b"", Some("reader-secret"), None);
2962        assert!(text(&r).contains("user:reader"), "{}", text(&r));
2963        assert!(text(&r).contains("credentials"));
2964        let r = s.route("GET", "/api/whoami", b"", None, None);
2965        assert!(text(&r).contains("anonymous"), "{}", text(&r));
2966    }
2967
2968    /// A credential map must not silently disable `--token-env`. Attaching
2969    /// one changes WHO a request is; the operator who also asked for a
2970    /// shared secret still gets authentication on every request.
2971    #[test]
2972    fn a_credential_map_does_not_disable_the_shared_secret() {
2973        let s = server().with_auth("console-token".into());
2974
2975        // No credential at all: refused, not dropped to `anonymous`.
2976        let r = s.route("POST", "/api/cal", READ, None, None);
2977        assert!(r.0.starts_with("401"), "{}", text(&r));
2978        let page = s.route("GET", "/", b"", None, None);
2979        assert!(page.0.starts_with("401"), "the console page is guarded too");
2980        // An unrecognized token is refused rather than downgraded.
2981        let r = s.route("POST", "/api/cal", READ, Some("stolen-guess"), None);
2982        assert!(r.0.starts_with("401"), "{}", text(&r));
2983
2984        // A map token still binds its principal and its verbs still decide.
2985        let r = s.route("POST", "/api/cal", READ, Some("reader-secret"), None);
2986        assert!(r.0.starts_with("200"), "{}", text(&r));
2987        let w = s.route("POST", "/api/cal", WRITE, Some("reader-secret"), None);
2988        assert!(text(&w).contains("AUT-E001"), "{}", text(&w));
2989
2990        // The shared secret is the implied admin (docs/cal-all-you-need
2991        // -proposal.md §5.2b) — it keeps the owner session it always had.
2992        let w = s.route("POST", "/api/cal", WRITE, Some("console-token"), None);
2993        assert!(text(&w).contains("added"), "{}", text(&w));
2994    }
2995
2996    /// `RUN LOOP` through CAL is gated exactly as `POST /api/loop/run` is —
2997    /// otherwise the CAL surface is a way around the HTTP gate.
2998    #[test]
2999    fn run_loop_via_cal_is_verb_gated_like_the_route() {
3000        let s = server();
3001        let run = s.route("POST", "/api/loop/run", b"{}", None, None);
3002        assert!(run.0.starts_with("403"), "{}", text(&run));
3003        let cal = s.route("POST", "/api/cal", br#"{"query":"RUN LOOP"}"#, None, None);
3004        assert!(
3005            text(&cal).contains("AUT-E001"),
3006            "anonymous must not run the loop through CAL either: {}",
3007            text(&cal)
3008        );
3009    }
3010}
3011
3012#[cfg(test)]
3013mod namespace_route_tests {
3014    use super::UiServer;
3015    use areev_cal::AreevFacade;
3016    use areev_core::authz::{CredentialMap, AUTHZ_NS, REL_PERMITS};
3017    use areev_core::types::{Fact, Grain};
3018    use areev_store::Areev;
3019
3020    fn text(r: &(&str, &str, Vec<u8>)) -> String {
3021        String::from_utf8_lossy(&r.2).to_string()
3022    }
3023
3024    /// `agent:writer` is granted read+write on `caller` only — nothing on
3025    /// `other`, nothing on the audit namespace.
3026    fn restricted_server() -> UiServer {
3027        std::env::set_var("AREEV_TEST_NS_WRITER_TOK", "writer-secret");
3028        let dir = tempfile::tempdir().unwrap();
3029        let path = dir.path().join("t.db");
3030        let mut store = Areev::open(path.to_str().unwrap()).unwrap();
3031        store
3032            .add(&Fact::new("agent:writer", REL_PERMITS, "read,write ON caller").namespace(AUTHZ_NS).created_at(1_000))
3033            .unwrap();
3034        store.add(&Fact::new("acme", "tier", "Enterprise").namespace("caller").created_at(2_000)).unwrap();
3035        store.add(&Fact::new("bob", "role", "admin").namespace("other").created_at(3_000)).unwrap();
3036        std::mem::forget(dir);
3037        let facade = AreevFacade::with_session(store, Some("caller".into()), None);
3038        let map = CredentialMap::from_json(
3039            r#"{"version":1,"tokens":[{"env":"AREEV_TEST_NS_WRITER_TOK","principal":"agent:writer"}]}"#,
3040        )
3041        .unwrap();
3042        UiServer::new(facade, "test".into()).with_credentials(map)
3043    }
3044
3045    /// Unbound local open — the implicit owner, unrestricted on every
3046    /// namespace, same as a plain `areev ui` with no `--auth`.
3047    fn owner_server() -> UiServer {
3048        let dir = tempfile::tempdir().unwrap();
3049        let path = dir.path().join("t.db");
3050        let mut store = Areev::open(path.to_str().unwrap()).unwrap();
3051        store.add(&Fact::new("acme", "tier", "Enterprise").namespace("caller").created_at(1_000)).unwrap();
3052        store.add(&Fact::new("bob", "role", "admin").namespace("other").created_at(2_000)).unwrap();
3053        std::mem::forget(dir);
3054        UiServer::new(AreevFacade::with_session(store, Some("caller".into()), None), "t".into())
3055    }
3056
3057    #[test]
3058    fn namespaces_route_hides_what_the_principal_cannot_read() {
3059        let s = restricted_server();
3060        let r = s.route("GET", "/api/namespaces", b"", Some("writer-secret"), None);
3061        let body = text(&r);
3062        assert!(body.contains("\"caller\""), "granted namespace must be listed: {body}");
3063        assert!(!body.contains("\"other\""), "ungranted namespace must not be listed: {body}");
3064        assert!(!body.contains(AUTHZ_NS), "the audit namespace must not leak either: {body}");
3065    }
3066
3067    #[test]
3068    fn namespaces_route_lists_everything_for_the_owner() {
3069        let s = owner_server();
3070        let r = s.route("GET", "/api/namespaces", b"", None, None);
3071        let body = text(&r);
3072        assert!(body.contains("\"caller\"") && body.contains("\"other\""), "{body}");
3073    }
3074
3075    #[test]
3076    fn browse_ns_param_is_read_gated_for_a_restricted_principal() {
3077        let s = restricted_server();
3078        let ok = s.route("GET", "/api/browse?ns=caller", b"", Some("writer-secret"), None);
3079        assert!(text(&ok).contains("\"ok\":true"), "their own granted namespace must work: {}", text(&ok));
3080
3081        let denied = s.route("GET", "/api/browse?ns=other", b"", Some("writer-secret"), None);
3082        let body = text(&denied);
3083        assert!(body.contains("\"ok\":false"), "an ungranted namespace must be refused, not silently emptied: {body}");
3084    }
3085
3086    #[test]
3087    fn browse_ns_star_requires_owner() {
3088        let s = restricted_server();
3089        let r = s.route("GET", "/api/browse?ns=*", b"", Some("writer-secret"), None);
3090        assert!(text(&r).contains("\"ok\":false"), "a restricted principal must not get the unfiltered view: {}", text(&r));
3091
3092        let owner = owner_server();
3093        let r2 = owner.route("GET", "/api/browse?ns=*", b"", None, None);
3094        let body2 = text(&r2);
3095        assert!(body2.contains("\"ok\":true"), "the owner session may lift the filter: {body2}");
3096        assert!(body2.contains("\"caller\"") && body2.contains("\"other\""), "{body2}");
3097    }
3098
3099    #[test]
3100    fn browse_without_ns_param_keeps_original_session_default_behavior() {
3101        let s = owner_server();
3102        let r = s.route("GET", "/api/browse", b"", None, None);
3103        let body = text(&r);
3104        assert!(body.contains("\"caller\""), "{body}");
3105        assert!(!body.contains("\"other\""), "omitting ?ns must behave exactly as before this feature existed: {body}");
3106    }
3107}
3108
3109#[cfg(test)]
3110mod builder_tests {
3111    use super::UiServer;
3112    use areev_cal::AreevFacade;
3113    use areev_store::Areev;
3114
3115    fn server() -> UiServer {
3116        let dir = tempfile::tempdir().unwrap();
3117        let path = dir.path().join("t.db");
3118        let store = Areev::open(path.to_str().unwrap()).unwrap();
3119        std::mem::forget(dir);
3120        UiServer::new(AreevFacade::with_session(store, Some("caller".into()), None), "t".into())
3121    }
3122
3123    /// `areev ui --no-destructive-ops --policy FILE` calls these in this
3124    /// order. A later builder must not re-enable destruction the operator
3125    /// turned off — the console was announced as read-only.
3126    #[test]
3127    fn attaching_a_loop_policy_preserves_the_destructive_cap() {
3128        let s = server()
3129            .allow_destructive_ops(false)
3130            .with_loop_policy(areev_loop::Policy::default());
3131        let r = s.route(
3132            "POST",
3133            "/api/cal",
3134            br#"{"query":"FORGET sha256:684c6c9bda818630a870119d0726e4d242ed537af061658ef6f3acb158a2c67d"}"#,
3135            None,
3136            None,
3137        );
3138        let body = String::from_utf8_lossy(&r.2).to_string();
3139        assert!(
3140            body.contains("--no-destructive-ops") || r.0.starts_with("401"),
3141            "destruction must stay disabled: {body}"
3142        );
3143    }
3144}
3145
3146#[cfg(test)]
3147mod run_api_tests {
3148    use super::UiServer;
3149    use areev_cal::AreevFacade;
3150    use areev_core::authz::{CredentialMap, AUTHZ_NS, REL_PERMITS};
3151    use areev_core::types::{Fact, Grain, Tool, ToolKind, Workflow};
3152    use areev_store::Areev;
3153    use std::sync::Arc;
3154
3155    /// A server whose file holds a PARKED run (Client-gated approve node) —
3156    /// the HITL queue's fixture. Returns (server, ask id).
3157    fn parked_server() -> (UiServer, String) {
3158        std::env::set_var("AREEV_RUN_OFFICER_TOK", "officer-secret");
3159        std::env::set_var("AREEV_RUN_STARTER_TOK", "starter-secret");
3160        let dir = tempfile::tempdir().unwrap();
3161        let path = dir.path().join("t.db");
3162        let mut store = Areev::open(path.to_str().unwrap()).unwrap();
3163        for (who, what) in [
3164            ("user:officer", "read,run.respond ON ops"),
3165            ("user:starter", "read ON ops"),
3166        ] {
3167            store
3168                .add(&Fact::new(who, REL_PERMITS, what).namespace(AUTHZ_NS).created_at(900))
3169                .unwrap();
3170        }
3171        std::mem::forget(dir);
3172        let facade = Arc::new(AreevFacade::with_session(store, Some("ops".into()), None));
3173
3174        // Seed + run the plan to the park with a scripted runner.
3175        let (wf, _g, _a) = facade
3176            .with_store(|m| {
3177                let greet = Tool::new("greet")
3178                    .kind(ToolKind::Definition)
3179                    .tool_description("t")
3180                    .created_at(500)
3181                    .namespace("ops");
3182                let gh = m.add(&greet)?;
3183                let approve = Tool::new("approve")
3184                    .kind(ToolKind::Definition)
3185                    .tool_description("human approves")
3186                    .executor_kind(areev_core::types::ExecutorKind::Client)
3187                    .created_at(501)
3188                    .namespace("ops");
3189                let ah = m.add(&approve)?;
3190                let wf = Workflow::new(vec!["greet".into(), "approve".into()])
3191                    .edge("greet", "approve")
3192                    .bind("greet", &gh.to_hex())
3193                    .bind("approve", &ah.to_hex())
3194                    .created_at(502)
3195                    .namespace("ops");
3196                let wh = m.add(&wf)?;
3197                Ok::<_, areev_core::error::AreevError>((wh, gh, ah))
3198            })
3199            .unwrap();
3200        struct Ok1;
3201        impl areev_run::HostToolExecutor for Ok1 {
3202            fn execute(
3203                &self,
3204                t: &str,
3205                _h: &str,
3206                _i: &serde_json::Value,
3207                _k: &str,
3208            ) -> areev_run::ExecResult {
3209                areev_run::ExecResult::Ok(serde_json::json!({t.to_string(): true}))
3210            }
3211        }
3212        let runner = areev_run::Runner {
3213            facade: Arc::clone(&facade),
3214            clock: Arc::new(areev_run::ScriptedClock::new(
3215                (0..100).map(|i| 1_758_000_000_000 + i * 10).collect(),
3216            )),
3217            executor: Arc::new(Ok1),
3218            llm: None,
3219            observer: None,
3220            ns: "ops".into(),
3221            principal: "user:starter".into(),
3222        };
3223        let session = runner
3224            .start(&wf, "hitl-1", serde_json::json!({}), &areev_run::RunOptions {
3225                workers: 1,
3226                ..Default::default()
3227            })
3228            .unwrap();
3229        let areev_run::RunSession::Parked { envelope, .. } = session else {
3230            panic!("expected park")
3231        };
3232        let ask = envelope["asks"][0]["tool_call_id"].as_str().unwrap().to_string();
3233
3234        // Rebuild a facade-owning server over the SAME file is impossible
3235        // (single writer) — hand the server the same Arc'd facade via the
3236        // Arc-aware constructor path: UiServer::new takes AreevFacade, so
3237        // we build the server FIRST in real deployments; here we reuse the
3238        // internal Arc through a second UiServer field write. Instead:
3239        // drop the runner (facade Arc count drops) and unwrap.
3240        drop(runner);
3241        let facade = Arc::try_unwrap(facade).unwrap_or_else(|_| panic!("facade still shared"));
3242        let map = CredentialMap::from_json(
3243            r#"{"version":1,"tokens":[
3244                {"env":"AREEV_RUN_OFFICER_TOK","principal":"user:officer"},
3245                {"env":"AREEV_RUN_STARTER_TOK","principal":"user:starter"}
3246            ]}"#,
3247        )
3248        .unwrap();
3249        let server = UiServer::new(facade, "test".into())
3250            .with_auth("shared-console-secret".into())
3251            .with_credentials(map);
3252        (server, ask)
3253    }
3254
3255    fn body(run_id: &str, ask: &str) -> Vec<u8> {
3256        serde_json::json!({"run_id": run_id, "tool_call_id": ask, "result": {"approved": true}})
3257            .to_string()
3258            .into_bytes()
3259    }
3260
3261    /// #165: `/api/run/list` is scoped and paged on the server, and says
3262    /// its total and whether it truncated. Twenty older runs in a quiet
3263    /// namespace behind forty newer ones in a busy one is exactly the
3264    /// shape under which the old fixed newest-50 page, filtered
3265    /// client-side, showed the quiet tenant as having no runs.
3266    #[test]
3267    fn run_list_is_scoped_and_paged_server_side() {
3268        let dir = tempfile::tempdir().unwrap();
3269        let path = dir.path().join("t.db");
3270        let mut store = Areev::open(path.to_str().unwrap()).unwrap();
3271        for i in 0..60u32 {
3272            let (id, ns) = if i < 20 {
3273                (format!("quiet-{i}"), "tenant.quiet")
3274            } else {
3275                (format!("busy-{i}"), "tenant.busy")
3276            };
3277            let mut f = Fact::new(&format!("run:{id}"), "mg:harness", "00")
3278                .namespace(areev_core::authz::HARNESS_NS)
3279                .created_at(1_000 + i64::from(i));
3280            f.common.extra_fields.insert("run_id".into(), serde_json::json!(id));
3281            f.common.extra_fields.insert("run_ns".into(), serde_json::json!(ns));
3282            store.add(&f).unwrap();
3283        }
3284        std::mem::forget(dir);
3285        let s = UiServer::new(AreevFacade::with_session(store, Some("ops".into()), None), "test".into());
3286        let page = |query: &str| -> serde_json::Value {
3287            let r = s.route("GET", &format!("/api/run/list{query}"), b"", None, None);
3288            assert!(r.0.starts_with("200"), "{} {}", r.0, String::from_utf8_lossy(&r.2));
3289            serde_json::from_slice(&r.2).unwrap()
3290        };
3291        let rows = |v: &serde_json::Value| v["runs"].as_array().unwrap().clone();
3292
3293        // The default page: newest fifty of sixty, and it SAYS so.
3294        let all = page("");
3295        assert_eq!(rows(&all).len(), 50);
3296        assert_eq!(all["total"], 60);
3297        assert_eq!(all["truncated"], true);
3298        assert_eq!(rows(&all).iter().filter(|r| r["ns"] == "tenant.quiet").count(), 10);
3299
3300        // Scoped: the whole quiet tenant, no truncation, nothing else.
3301        let quiet = page("?ns=tenant.quiet");
3302        assert_eq!(quiet["total"], 20);
3303        assert_eq!(quiet["truncated"], false);
3304        assert_eq!(rows(&quiet).len(), 20);
3305        assert!(rows(&quiet).iter().all(|r| r["ns"] == "tenant.quiet"), "{quiet}");
3306
3307        // A prefix scope, paged to the end.
3308        let tail = page("?ns=tenant.*&limit=10&offset=55");
3309        assert_eq!(tail["total"], 60);
3310        assert_eq!(rows(&tail).len(), 5);
3311        assert_eq!(tail["truncated"], false);
3312        assert_eq!((tail["offset"].as_u64(), tail["limit"].as_u64()), (Some(55), Some(10)));
3313
3314        // `*` is unscoped; an absurd limit is clamped rather than honored.
3315        assert_eq!(page("?ns=*")["total"], 60);
3316        assert_eq!(page("?limit=100000")["limit"], 500);
3317
3318        // A row from before the namespace stamp is not known to be in any
3319        // scope: excluded from a scoped read and COUNTED, present unscoped.
3320        s.facade
3321            .with_store(|m| {
3322                let mut f = Fact::new("run:old", "mg:harness", "00")
3323                    .namespace(areev_core::authz::HARNESS_NS)
3324                    .created_at(1);
3325                f.common.extra_fields.insert("run_id".into(), serde_json::json!("old"));
3326                m.add(&f)
3327            })
3328            .unwrap();
3329        let quiet = page("?ns=tenant.quiet");
3330        assert_eq!(quiet["total"], 20, "scoping stays exact: {quiet}");
3331        assert_eq!(quiet["unattributed"], 1, "and says what it left out: {quiet}");
3332        let unscoped = page("");
3333        assert_eq!(unscoped["total"], 61);
3334        assert_eq!(unscoped["unattributed"], 0);
3335    }
3336
3337    /// `/api/run/shadow` is the plan-change rehearsal as a read: GET names a
3338    /// stored plan, POST may carry an unstored draft. Pinned on the parked
3339    /// fixture under its own plan — the identity row — and on the refusals.
3340    #[test]
3341    fn run_shadow_route_rehearses_a_run_under_a_candidate_plan() {
3342        let (s, _ask) = parked_server();
3343        let (_st, _ct, body) = s.route("GET", "/api/run/inspect?run_id=hitl-1", b"", Some("officer-secret"), None);
3344        let inspect: serde_json::Value = serde_json::from_slice(&body).unwrap();
3345        let plan = inspect["plan_hash"].as_str().expect("the run's plan").to_string();
3346
3347        let (status, _ct, body) = s.route("GET", &format!("/api/run/shadow?runs=hitl-1&plan={plan}"), b"", Some("officer-secret"), None);
3348        assert!(status.starts_with("200"), "{status}");
3349        let v: serde_json::Value = serde_json::from_slice(&body).unwrap();
3350        assert_eq!(v["ok"], true, "{v}");
3351        assert_eq!(v["candidate_plan"], plan.as_str());
3352        assert_eq!((v["effect_dispatches"].as_u64(), v["writes"].as_u64()), (Some(0), Some(0)));
3353        let row = &v["runs"][0];
3354        assert_eq!(row["run_id"], "hitl-1");
3355        assert!(row["identity"].is_object(), "the incumbent as candidate is a verify: {row}");
3356
3357        // A draft body over the same run; and the two refusals.
3358        let draft = serde_json::json!({"runs": ["hitl-1"], "plan_body": {"nodes": ["greet"], "edges": []}});
3359        let (status, _ct, body) = s.route("POST", "/api/run/shadow", draft.to_string().as_bytes(), Some("officer-secret"), None);
3360        assert!(status.starts_with("200"), "{status}");
3361        let v: serde_json::Value = serde_json::from_slice(&body).unwrap();
3362        assert_eq!(v["ok"], true, "{v}");
3363        assert_eq!(v["candidate_is_draft"], true);
3364        let (_st, _ct, body) = s.route("POST", "/api/run/shadow", br#"{"runs": ["hitl-1"]}"#, Some("officer-secret"), None);
3365        let v: serde_json::Value = serde_json::from_slice(&body).unwrap();
3366        assert_eq!(v["ok"], false, "{v}");
3367        let (_st, _ct, body) = s.route("GET", "/api/run/shadow?runs=hitl-1", b"", Some("officer-secret"), None);
3368        let v: serde_json::Value = serde_json::from_slice(&body).unwrap();
3369        assert_eq!(v["ok"], false, "plan required: {v}");
3370    }
3371
3372    #[test]
3373    fn shared_token_approvals_are_refused_and_principals_approve() {
3374        let (s, ask) = parked_server();
3375
3376        // The HITL queue is visible.
3377        let r = s.route("GET", "/api/run/list", b"", Some("shared-console-secret"), None);
3378        assert!(r.0.starts_with("200"), "{}", String::from_utf8_lossy(&r.2));
3379        let r = s.route(
3380            "GET",
3381            "/api/run/inspect?run_id=hitl-1",
3382            b"",
3383            Some("shared-console-secret"),
3384            None,
3385        );
3386        assert!(String::from_utf8_lossy(&r.2).contains(&ask), "ask visible in inspect");
3387
3388        // 1. The SHARED console secret cannot approve — identity is the point.
3389        let r = s.route("POST", "/api/run/respond", &body("hitl-1", &ask), Some("shared-console-secret"), None);
3390        assert!(r.0.starts_with("403"), "shared-token approval must refuse: {}", r.0);
3391
3392        // 2. The STARTER's own credential cannot approve their own run
3393        //    (separation of duties, enforced by the runtime).
3394        let r = s.route("POST", "/api/run/respond", &body("hitl-1", &ask), Some("starter-secret"), None);
3395        let text = String::from_utf8_lossy(&r.2).to_string();
3396        assert!(text.contains("responder") || text.contains("RUN-E012"), "{text}");
3397
3398        // 3. The officer's principal-bound credential approves.
3399        let r = s.route("POST", "/api/run/respond", &body("hitl-1", &ask), Some("officer-secret"), None);
3400        assert!(r.0.starts_with("200"), "{}", String::from_utf8_lossy(&r.2));
3401        assert!(String::from_utf8_lossy(&r.2).contains("user:officer"));
3402
3403        // 4. The kill switch keeps its low bar: shared token may cancel.
3404        let r = s.route(
3405            "POST",
3406            "/api/run/cancel",
3407            br#"{"run_id":"hitl-1","because":"test brake"}"#,
3408            Some("shared-console-secret"),
3409            None,
3410        );
3411        assert!(r.0.starts_with("200"), "{}", String::from_utf8_lossy(&r.2));
3412    }
3413
3414    /// [A0] The approval trust floor. `user:officer` holds `run.respond ON
3415    /// ops` in the FILE, so the grants are identical whichever way the
3416    /// identity arrives — which is exactly the point. What differs is the
3417    /// PROOF: a credential-map token is a per-principal secret; an SSO header
3418    /// is trusted via one shared, fleet-wide proxy secret that lets its
3419    /// holder assert any approver. The default refuses the weaker proof.
3420    #[test]
3421    fn proxy_asserted_identities_cannot_approve_by_default() {
3422        let (s, ask) = parked_server();
3423        let s = s.with_sso_rotating("x-forwarded-user", "proxy-secret", None::<String>);
3424
3425        // The SSO identity is a GRANTED principal and still cannot approve.
3426        let r = s.route("POST", "/api/run/respond", &body("hitl-1", &ask), None, Some("user:officer"));
3427        assert!(
3428            r.0.starts_with("403"),
3429            "proxy-asserted approval must refuse by default: {} {}",
3430            r.0,
3431            String::from_utf8_lossy(&r.2)
3432        );
3433        let text = String::from_utf8_lossy(&r.2).to_string();
3434        assert!(
3435            text.contains("--sso-approvals"),
3436            "the refusal must name the flag that lifts it: {text}"
3437        );
3438
3439        // Reviewing is unaffected — SSO identities keep every read.
3440        let r = s.route("GET", "/api/run/list", b"", None, Some("user:officer"));
3441        assert!(r.0.starts_with("200"), "SSO identities may still review: {}", r.0);
3442
3443        // And the SAME principal, proven by its own credential, approves.
3444        let r = s.route("POST", "/api/run/respond", &body("hitl-1", &ask), Some("officer-secret"), None);
3445        assert!(r.0.starts_with("200"), "{}", String::from_utf8_lossy(&r.2));
3446    }
3447
3448    /// [A0] `--sso-approvals allow` is the operator accepting that trade-off
3449    /// explicitly. Nothing else changes: the same identity, the same file
3450    /// grants, the same runtime separation-of-duties check behind it.
3451    #[test]
3452    fn sso_approvals_allow_opts_in_to_proxy_asserted_approvals() {
3453        let (s, ask) = parked_server();
3454        let s = s
3455            .with_sso_rotating("x-forwarded-user", "proxy-secret", None::<String>)
3456            .with_sso_approvals(true);
3457
3458        let r = s.route("POST", "/api/run/respond", &body("hitl-1", &ask), None, Some("user:officer"));
3459        assert!(r.0.starts_with("200"), "{}", String::from_utf8_lossy(&r.2));
3460        assert!(String::from_utf8_lossy(&r.2).contains("user:officer"));
3461    }
3462
3463    /// [A2] Groups → principals, so SSO scales without a grant grain per
3464    /// person — and the precedence that makes it safe: an identity with its
3465    /// own grants outranks its group, so adding a groups header can never
3466    /// narrow someone who already had rights.
3467    #[test]
3468    fn groups_resolve_to_principals_but_never_outrank_an_identity() {
3469        let (s, _ask) = parked_server();
3470        // `user:officer` is granted in the file; `role:reviewer` is not a
3471        // credential, only a group target.
3472        let map = CredentialMap::from_json(
3473            r#"{"version":1,
3474                "tokens":[{"env":"AREEV_RUN_OFFICER_TOK","principal":"user:officer","id":"off"}],
3475                "groups":{"Engineering":"user:officer"}}"#,
3476        )
3477        .unwrap();
3478        let s = s
3479            .with_credentials(map)
3480            .with_sso_rotating("x-forwarded-user", "proxy-secret", None::<String>)
3481            .with_sso_groups_header("x-forwarded-groups");
3482
3483        // An UNGRANTED identity in a mapped group inherits the group's
3484        // principal — the whole point of the feature.
3485        let r = s.route_with_groups(
3486            "GET",
3487            "/api/whoami",
3488            b"",
3489            None,
3490            Some("user:newhire@example.com"),
3491            Some("platform,Engineering"),
3492        );
3493        let text = String::from_utf8_lossy(&r.2).to_string();
3494        assert!(text.contains("user:officer"), "group should resolve: {text}");
3495        assert!(text.contains(r#""identity_source":"sso-group""#), "{text}");
3496
3497        // Case-insensitive: directories are inconsistent, and a mapping that
3498        // misses on case would fail OPEN into the identity's own (empty)
3499        // grants.
3500        let r = s.route_with_groups(
3501            "GET", "/api/whoami", b"", None, Some("user:newhire@example.com"), Some("ENGINEERING"),
3502        );
3503        assert!(String::from_utf8_lossy(&r.2).contains("user:officer"));
3504
3505        // An identity that IS granted keeps its own principal, group or not.
3506        let r = s.route_with_groups(
3507            "GET", "/api/whoami", b"", None, Some("user:officer"), Some("Engineering"),
3508        );
3509        let text = String::from_utf8_lossy(&r.2).to_string();
3510        assert!(text.contains(r#""identity_source":"sso""#), "identity outranks group: {text}");
3511
3512        // An unmapped group resolves to nothing and the identity stands.
3513        let r = s.route_with_groups(
3514            "GET", "/api/whoami", b"", None, Some("user:newhire@example.com"), Some("sales"),
3515        );
3516        assert!(String::from_utf8_lossy(&r.2).contains("user:newhire@example.com"));
3517    }
3518
3519    /// [A2] A role is not an approver. This refusal has no flag of its own —
3520    /// `--sso-approvals allow` does not lift it — because an audit record
3521    /// reading "role:eng approved" names nobody who can be asked why.
3522    #[test]
3523    fn group_derived_principals_can_never_approve() {
3524        let (s, ask) = parked_server();
3525        let map = CredentialMap::from_json(
3526            r#"{"version":1,
3527                "tokens":[{"env":"AREEV_RUN_OFFICER_TOK","principal":"user:officer","id":"off"}],
3528                "groups":{"approvers":"user:officer"}}"#,
3529        )
3530        .unwrap();
3531        let s = s
3532            .with_credentials(map)
3533            .with_sso_rotating("x-forwarded-user", "proxy-secret", None::<String>)
3534            .with_sso_groups_header("x-forwarded-groups")
3535            // Even with the A0 opt-in explicitly granted.
3536            .with_sso_approvals(true);
3537
3538        let r = s.route_with_groups(
3539            "POST",
3540            "/api/run/respond",
3541            &body("hitl-1", &ask),
3542            None,
3543            Some("user:newhire@example.com"),
3544            Some("approvers"),
3545        );
3546        assert!(r.0.starts_with("403"), "{} {}", r.0, String::from_utf8_lossy(&r.2));
3547        let text = String::from_utf8_lossy(&r.2).to_string();
3548        assert!(text.contains("role"), "the refusal must say why: {text}");
3549    }
3550
3551    /// [A2] The prefix keeps IdP-sourced principals visibly distinct from
3552    /// local ones everywhere they land.
3553    #[test]
3554    fn the_principal_prefix_is_stamped_on_proxy_asserted_identities() {
3555        let (s, _ask) = parked_server();
3556        let s = s
3557            .with_sso_rotating("x-forwarded-user", "proxy-secret", None::<String>)
3558            .with_sso_principal_prefix("sso:");
3559
3560        let r = s.route("GET", "/api/whoami", b"", None, Some("pat@example.com"));
3561        assert!(String::from_utf8_lossy(&r.2).contains("sso:pat@example.com"), "{}", String::from_utf8_lossy(&r.2));
3562
3563        // Idempotent: an identity the proxy already prefixed is not
3564        // double-stamped into a different principal than the grant names.
3565        let r = s.route("GET", "/api/whoami", b"", None, Some("sso:pat@example.com"));
3566        let text = String::from_utf8_lossy(&r.2).to_string();
3567        assert!(text.contains("sso:pat@example.com"), "{text}");
3568        assert!(!text.contains("sso:sso:"), "must not double-stamp: {text}");
3569    }
3570
3571    /// [A0] `whoami` tells an approver where they stand BEFORE they try to
3572    /// approve — a console that only discovers this at the 403 is a console
3573    /// that discovers it mid-incident.
3574    #[test]
3575    fn whoami_reports_identity_provenance_and_approval_capability() {
3576        let (s, _ask) = parked_server();
3577        let s = s.with_sso_rotating("x-forwarded-user", "proxy-secret", None::<String>);
3578
3579        let r = s.route("GET", "/api/whoami", b"", None, Some("user:officer"));
3580        let text = String::from_utf8_lossy(&r.2).to_string();
3581        assert!(text.contains(r#""identity_source":"sso""#), "{text}");
3582        assert!(text.contains(r#""may_approve":false"#), "{text}");
3583
3584        let r = s.route("GET", "/api/whoami", b"", Some("officer-secret"), None);
3585        let text = String::from_utf8_lossy(&r.2).to_string();
3586        assert!(text.contains(r#""identity_source":"credential""#), "{text}");
3587        assert!(text.contains(r#""may_approve":true"#), "{text}");
3588    }
3589}
3590
3591#[cfg(test)]
3592mod memory_scoped_token_tests {
3593    use super::UiServer;
3594    use areev_cal::AreevFacade;
3595    use areev_core::authz::{CredentialMap, AUTHZ_NS, REL_PERMITS};
3596    use areev_core::types::{Fact, Grain};
3597    use areev_store::Areev;
3598
3599    /// The enterprise-plane rule: one auth file, per-memory token
3600    /// scopes — a token listed for memory A is an UNKNOWN token on
3601    /// memory B's server.
3602    #[test]
3603    fn memory_scoped_tokens_do_not_cross_memories() {
3604        std::env::set_var("AREEV_SCOPED_TOK", "scoped-secret");
3605        let map_json = r#"{"version":1,"tokens":[
3606            {"env":"AREEV_SCOPED_TOK","principal":"agent:sync","memories":["memory-a"]}
3607        ]}"#;
3608        let server_for = |label: &str| {
3609            let dir = tempfile::tempdir().unwrap();
3610            let mut store = Areev::open(dir.path().join("t.db").to_str().unwrap()).unwrap();
3611            store
3612                .add(
3613                    &Fact::new("agent:sync", REL_PERMITS, "read,write ON caller")
3614                        .namespace(AUTHZ_NS)
3615                        .created_at(1_000),
3616                )
3617                .unwrap();
3618            store
3619                .add(&Fact::new("acme", "tier", "Enterprise").namespace("caller").created_at(2_000))
3620                .unwrap();
3621            std::mem::forget(dir);
3622            let facade = AreevFacade::with_session(store, Some("caller".into()), None);
3623            UiServer::new(facade, label.into())
3624                .with_credentials(CredentialMap::from_json(map_json).unwrap())
3625        };
3626        const WRITE: &[u8] = br#"{"query":"ADD fact SET subject = \"x\" SET relation = \"r\" SET object = \"o\" SET namespace = \"caller\" REASON \"t\""}"#;
3627
3628        // On memory-a the token authenticates and its grants let the write
3629        // through (no authorization refusal in the CAL payload).
3630        let a = server_for("memory-a");
3631        let r = a.route("POST", "/api/cal", WRITE, Some("scoped-secret"), None);
3632        let text = String::from_utf8_lossy(&r.2).to_string();
3633        assert!(r.0.starts_with("200") && !text.contains("AUT-E001"), "{text}");
3634
3635        // On memory-b the SAME token is an unknown credential: the request
3636        // runs as anonymous and the write is refused (AUT-E001 rides the
3637        // CAL payload — the console surfaces statement results in-band).
3638        let b = server_for("memory-b");
3639        let r = b.route("POST", "/api/cal", WRITE, Some("scoped-secret"), None);
3640        let text = String::from_utf8_lossy(&r.2).to_string();
3641        assert!(
3642            text.contains("AUT-E001") && text.contains("anonymous"),
3643            "scoped token must not cross memories: {text}"
3644        );
3645
3646        // An auth file with an EMPTY memories scope refuses to load.
3647        assert!(CredentialMap::from_json(
3648            r#"{"version":1,"tokens":[{"env":"AREEV_SCOPED_TOK","principal":"x","memories":[]}]}"#
3649        )
3650        .is_err());
3651    }
3652}
3653
3654#[cfg(all(test, feature = "tls"))]
3655mod tls_tests {
3656    use super::UiServer;
3657    use areev_cal::AreevFacade;
3658    use areev_store::Areev;
3659    use std::io::{Read, Write};
3660
3661    fn self_signed(dir: &std::path::Path) -> Option<(String, String)> {
3662        let cert = dir.join("cert.pem");
3663        let key = dir.join("key.pem");
3664        let status = std::process::Command::new("openssl")
3665            .args([
3666                "req", "-x509", "-newkey", "rsa:2048", "-nodes",
3667                "-keyout", key.to_str().unwrap(), "-out", cert.to_str().unwrap(),
3668                "-days", "1", "-subj", "/CN=localhost",
3669                "-addext", "subjectAltName=DNS:localhost,IP:127.0.0.1",
3670                "-addext", "basicConstraints=critical,CA:FALSE",
3671            ])
3672            .stdout(std::process::Stdio::null())
3673            .stderr(std::process::Stdio::null())
3674            .status();
3675        match status {
3676            Ok(s) if s.success() => Some((
3677                cert.to_str().unwrap().to_string(),
3678                key.to_str().unwrap().to_string(),
3679            )),
3680            _ => None, // no openssl on this host: the CI leg covers it
3681        }
3682    }
3683
3684    /// The §3.2 gate: a TLS round-trip succeeds, and a PLAINTEXT client on
3685    /// the same listener is refused (no silent downgrade).
3686    #[test]
3687    fn tls_round_trip_and_no_plaintext_downgrade() {
3688        let dir = tempfile::tempdir().unwrap();
3689        let Some((cert, key)) = self_signed(dir.path()) else {
3690            eprintln!("skipping: no openssl binary");
3691            return;
3692        };
3693        let store = Areev::open(dir.path().join("t.db").to_str().unwrap()).unwrap();
3694        let facade = AreevFacade::with_session(store, Some("caller".into()), None);
3695        let server = UiServer::new(facade, "tls-test".into())
3696            .with_tls(&cert, &key)
3697            .unwrap();
3698        let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
3699        let addr = listener.local_addr().unwrap();
3700        let handle = std::thread::spawn(move || {
3701            // Two connections: the TLS client, then the plaintext prober.
3702            for _ in 0..2 {
3703                if let Ok((s, _)) = listener.accept() {
3704                    let _ = server.handle(s);
3705                }
3706            }
3707        });
3708
3709        // 1. TLS round-trip: trust exactly our self-signed cert.
3710        let mut roots = rustls::RootCertStore::empty();
3711        {
3712            use rustls::pki_types::pem::PemObject;
3713            for c in rustls::pki_types::CertificateDer::pem_file_iter(&cert).unwrap() {
3714                roots.add(c.unwrap()).unwrap();
3715            }
3716        }
3717        let config = std::sync::Arc::new(
3718            rustls::ClientConfig::builder()
3719                .with_root_certificates(roots)
3720                .with_no_client_auth(),
3721        );
3722        let name = rustls::pki_types::ServerName::try_from("localhost").unwrap();
3723        let session = rustls::ClientConnection::new(config, name).unwrap();
3724        let tcp = std::net::TcpStream::connect(addr).unwrap();
3725        let mut tls = rustls::StreamOwned::new(session, tcp);
3726        tls.write_all(b"GET / HTTP/1.1\r\nHost: 127.0.0.1\r\nConnection: close\r\n\r\n")
3727            .unwrap();
3728        let mut response = String::new();
3729        let _ = tls.read_to_string(&mut response);
3730        assert!(response.starts_with("HTTP/1.1 200"), "{}", &response[..response.len().min(120)]);
3731        assert!(response.contains("Areev"), "console served over TLS");
3732
3733        // 2. No downgrade: plaintext HTTP on the TLS listener gets a
3734        // handshake failure, never a page.
3735        let mut plain = std::net::TcpStream::connect(addr).unwrap();
3736        plain
3737            .write_all(b"GET / HTTP/1.1\r\nHost: 127.0.0.1\r\nConnection: close\r\n\r\n")
3738            .unwrap();
3739        let mut buf = Vec::new();
3740        let _ = plain.read_to_end(&mut buf);
3741        let text = String::from_utf8_lossy(&buf);
3742        assert!(
3743            !text.contains("HTTP/1.1 200"),
3744            "plaintext must not be served on a TLS listener: {text}"
3745        );
3746        handle.join().unwrap();
3747    }
3748
3749    /// `with_tls` composes with `with_auth` — `with_auth` only sets the
3750    /// token/auth_all fields, not the connection layer, so an authenticated
3751    /// console terminates TLS identically to an open one. Pins the CLI
3752    /// wiring that lets `areev ui --token-env VAR --tls-cert ... --tls-key
3753    /// ...` work, not just `areev ui --tls-cert ...`.
3754    #[test]
3755    fn an_authenticated_console_terminates_tls_the_same_way() {
3756        let dir = tempfile::tempdir().unwrap();
3757        let Some((cert, key)) = self_signed(dir.path()) else {
3758            eprintln!("skipping: no openssl binary");
3759            return;
3760        };
3761        let store = Areev::open(dir.path().join("t.db").to_str().unwrap()).unwrap();
3762        let facade = AreevFacade::with_session(store, Some("caller".into()), None);
3763        let server = UiServer::new(facade, "auth-tls-test".into())
3764            .with_auth("console-token".into())
3765            .with_tls(&cert, &key)
3766            .unwrap();
3767        let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
3768        let addr = listener.local_addr().unwrap();
3769        let handle = std::thread::spawn(move || {
3770            if let Ok((s, _)) = listener.accept() {
3771                let _ = server.handle(s);
3772            }
3773        });
3774
3775        let mut roots = rustls::RootCertStore::empty();
3776        {
3777            use rustls::pki_types::pem::PemObject;
3778            for c in rustls::pki_types::CertificateDer::pem_file_iter(&cert).unwrap() {
3779                roots.add(c.unwrap()).unwrap();
3780            }
3781        }
3782        let config = std::sync::Arc::new(
3783            rustls::ClientConfig::builder()
3784                .with_root_certificates(roots)
3785                .with_no_client_auth(),
3786        );
3787        let name = rustls::pki_types::ServerName::try_from("localhost").unwrap();
3788        let session = rustls::ClientConnection::new(config, name).unwrap();
3789        let tcp = std::net::TcpStream::connect(addr).unwrap();
3790        let mut tls = rustls::StreamOwned::new(session, tcp);
3791        tls.write_all(
3792            b"GET / HTTP/1.1\r\nHost: 127.0.0.1\r\n\
3793              Authorization: Bearer console-token\r\nConnection: close\r\n\r\n",
3794        )
3795        .unwrap();
3796        let mut response = String::new();
3797        let _ = tls.read_to_string(&mut response);
3798        assert!(
3799            response.starts_with("HTTP/1.1 200"),
3800            "an authenticated console should terminate TLS like an open one: {}",
3801            &response[..response.len().min(120)]
3802        );
3803        handle.join().unwrap();
3804    }
3805}
3806
3807#[cfg(test)]
3808mod sso_tests {
3809    use super::UiServer;
3810    use areev_cal::AreevFacade;
3811    use areev_core::authz::{AUTHZ_NS, REL_PERMITS};
3812    use areev_core::types::{Fact, Grain};
3813    use areev_store::Areev;
3814    use std::io::{Read, Write};
3815
3816    const WRITE: &[u8] = br#"{"query":"ADD fact SET subject = \"x\" SET relation = \"r\" SET object = \"o\" SET namespace = \"caller\" REASON \"t\""}"#;
3817
3818    fn sso_server() -> UiServer {
3819        sso_server_with("proxy-secret", None)
3820    }
3821
3822    /// The same server mid-rotation: `proxy-secret` deployed, `next` incoming.
3823    fn sso_server_rotating(next: Option<&str>) -> UiServer {
3824        sso_server_with("proxy-secret", next)
3825    }
3826
3827    fn sso_server_with(secret: &str, next: Option<&str>) -> UiServer {
3828        let dir = tempfile::tempdir().unwrap();
3829        let mut store = Areev::open(dir.path().join("t.db").to_str().unwrap()).unwrap();
3830        store
3831            .add(
3832                &Fact::new("user:pat@example.com", REL_PERMITS, "read,write ON caller")
3833                    .namespace(AUTHZ_NS)
3834                    .created_at(1_000),
3835            )
3836            .unwrap();
3837        std::mem::forget(dir);
3838        let facade = AreevFacade::with_session(store, Some("caller".into()), None);
3839        UiServer::new(facade, "sso-test".into())
3840            .with_sso_rotating("x-forwarded-user", secret, next)
3841    }
3842
3843    fn post(server: &UiServer, extra_headers: &str) -> String {
3844        let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
3845        let addr = listener.local_addr().unwrap();
3846        std::thread::scope(|scope| {
3847            scope.spawn(|| {
3848                if let Ok((s, _)) = listener.accept() {
3849                    let _ = server.handle(s);
3850                }
3851            });
3852            let mut c = std::net::TcpStream::connect(addr).unwrap();
3853            let req = format!(
3854                "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",
3855                WRITE.len()
3856            );
3857            c.write_all(req.as_bytes()).unwrap();
3858            c.write_all(WRITE).unwrap();
3859            let mut out = String::new();
3860            let _ = c.read_to_string(&mut out);
3861            out
3862        })
3863    }
3864
3865    /// The §3.3 gate: a proxy-proven identity lands as a named actor whose
3866    /// FILE grants decide; a forged identity header without the proxy
3867    /// secret is ignored (the write is refused as anonymous).
3868    #[test]
3869    fn proxied_identity_works_and_forged_headers_are_ignored() {
3870        let server = sso_server();
3871
3872        // 1. Proxy-proven: identity + secret → the granted principal writes.
3873        let ok = post(
3874            &server,
3875            "X-Forwarded-User: user:pat@example.com\r\nX-Areev-Proxy-Secret: proxy-secret\r\n",
3876        );
3877        assert!(ok.contains("HTTP/1.1 200"), "{}", &ok[..ok.len().min(200)]);
3878        assert!(!ok.contains("AUT-E001"), "granted principal must write: {ok}");
3879
3880        // 2. Forged: the identity header WITHOUT the secret is ignored — the
3881        // request is anonymous and the write refuses.
3882        let forged = post(&server, "X-Forwarded-User: user:pat@example.com\r\n");
3883        assert!(
3884            forged.contains("anonymous") || forged.contains("401"),
3885            "forged identity must not authenticate: {forged}"
3886        );
3887
3888        // 3. Wrong secret: same refusal (constant-time compare, same path).
3889        let wrong = post(
3890            &server,
3891            "X-Forwarded-User: user:pat@example.com\r\nX-Areev-Proxy-Secret: guess\r\n",
3892        );
3893        assert!(
3894            wrong.contains("anonymous") || wrong.contains("401"),
3895            "wrong proxy secret must not authenticate: {wrong}"
3896        );
3897    }
3898
3899    /// [A2] The proxy secret proves the PROXY. It does not make whatever the
3900    /// proxy forwarded well-formed — and an identity lands in immutable,
3901    /// replicating audit grains, so a malformed one outlives its request.
3902    #[test]
3903    fn malformed_identities_are_rejected_even_with_a_valid_proxy_secret() {
3904        assert_eq!(
3905            super::sanitize_sso_identity("  user:pat@example.com  ").as_deref(),
3906            Some("user:pat@example.com"),
3907            "surrounding whitespace is trimmed, not rejected"
3908        );
3909
3910        // CR/LF would smuggle a second line into every log and audit record.
3911        assert!(super::sanitize_sso_identity("user:a\r\nX-Admin: true").is_none());
3912        assert!(super::sanitize_sso_identity("user:a\tb").is_none());
3913        // Internal whitespace would make "user: a" and "user:a" two audit
3914        // identities for one person.
3915        assert!(super::sanitize_sso_identity("user a").is_none());
3916        assert!(super::sanitize_sso_identity("").is_none());
3917        assert!(super::sanitize_sso_identity(&"x".repeat(129)).is_none());
3918        // Reserved: an audit line must never be ambiguous between a vouched
3919        // person and the console's own floor/ceiling principals.
3920        assert!(super::sanitize_sso_identity("anonymous").is_none());
3921        assert!(super::sanitize_sso_identity("user:console").is_none());
3922    }
3923
3924    /// [A2] A rejected identity is treated as an ABSENT one — the request
3925    /// degrades to anonymous rather than the console refusing to serve, so a
3926    /// misconfigured proxy cannot take the console down.
3927    #[test]
3928    fn a_rejected_identity_degrades_to_anonymous_rather_than_erroring() {
3929        let server = sso_server();
3930        let out = post(
3931            &server,
3932            "X-Forwarded-User: anonymous\r\nX-Areev-Proxy-Secret: proxy-secret\r\n",
3933        );
3934        assert!(
3935            out.contains("anonymous") || out.contains("401"),
3936            "a reserved name must not authenticate: {out}"
3937        );
3938        assert!(!out.contains("HTTP/1.1 500"), "and must not error: {out}");
3939    }
3940
3941    /// The rotation window (#79): while two secrets are configured, either
3942    /// proves the proxy — so the fleet moves over one node at a time instead
3943    /// of atomically, which is the only way an impersonation-grade credential
3944    /// gets rotated without an outage or a gap.
3945    #[test]
3946    fn both_secrets_authenticate_during_a_rotation_window() {
3947        let server = sso_server_rotating(Some("new-secret"));
3948        let id = "X-Forwarded-User: user:pat@example.com\r\n";
3949
3950        for secret in ["proxy-secret", "new-secret"] {
3951            let out = post(&server, &format!("{id}X-Areev-Proxy-Secret: {secret}\r\n"));
3952            assert!(out.contains("HTTP/1.1 200"), "{secret} rejected: {out}");
3953            assert!(!out.contains("AUT-E001"), "{secret} must authenticate: {out}");
3954        }
3955
3956        // The window widens what proves the proxy, never what a proved
3957        // identity may do, and never to a third value.
3958        let wrong = post(&server, &format!("{id}X-Areev-Proxy-Secret: guess\r\n"));
3959        assert!(
3960            wrong.contains("anonymous") || wrong.contains("401"),
3961            "an unrelated secret must still be refused mid-rotation: {wrong}"
3962        );
3963    }
3964
3965    /// Closing the window is what actually retires the old credential — the
3966    /// step the runbook exists to make sure nobody skips.
3967    #[test]
3968    fn the_retired_secret_stops_working_once_the_window_closes() {
3969        // Rotation complete: the new value is now the only one deployed.
3970        let server = sso_server_with("new-secret", None);
3971        let id = "X-Forwarded-User: user:pat@example.com\r\n";
3972
3973        let ok = post(&server, &format!("{id}X-Areev-Proxy-Secret: new-secret\r\n"));
3974        assert!(ok.contains("HTTP/1.1 200"), "{ok}");
3975        assert!(!ok.contains("AUT-E001"), "the promoted secret must authenticate: {ok}");
3976
3977        let old = post(&server, &format!("{id}X-Areev-Proxy-Secret: proxy-secret\r\n"));
3978        assert!(
3979            old.contains("anonymous") || old.contains("401"),
3980            "the retired secret must stop working: {old}"
3981        );
3982    }
3983
3984    #[test]
3985    #[should_panic(expected = "must differ")]
3986    fn rotating_to_the_same_value_is_refused() {
3987        // A rotation that deploys the same value twice did not happen, and it
3988        // would read as "both live" in every log and status line.
3989        let _ = sso_server_rotating(Some("proxy-secret"));
3990    }
3991
3992    use crate::CONSOLE_HTML;
3993    use std::collections::BTreeSet;
3994
3995    /// Every `<section class="page" id="page-X">` must be named in the one
3996    /// array in `render()` that clears `hidden`, and every sidebar
3997    /// `data-page="X"` must have such a section.
3998    ///
3999    /// The console's page sections ship `hidden` in the markup, so a page is
4000    /// visible only if `render()` un-hides it. The Triggers tab shipped
4001    /// missing from that array: `renderTriggers()` filled `#trgBody` on every
4002    /// render, the nav item highlighted, the hash routed — and the section
4003    /// stayed `hidden` while every other page was hidden too, so the tab
4004    /// showed a blank pane. Nothing else in the file could catch it: the
4005    /// omission is a *missing* string, not a wrong one.
4006    #[test]
4007    fn every_console_page_is_in_the_visibility_list() {
4008        fn all(hay: &str, open: &str) -> BTreeSet<String> {
4009            let mut out = BTreeSet::new();
4010            let mut rest = hay;
4011            while let Some(i) = rest.find(open) {
4012                rest = &rest[i + open.len()..];
4013                let end = rest.find('"').expect("unterminated attribute");
4014                out.insert(rest[..end].to_string());
4015                rest = &rest[end..];
4016            }
4017            out
4018        }
4019
4020        let sections = all(CONSOLE_HTML, "id=\"page-");
4021        let nav = all(CONSOLE_HTML, "data-page=\"");
4022        for want in ["workflows", "runs", "tools"] {
4023            assert!(sections.contains(want), "the {want} section itself went missing");
4024        }
4025        // Triggers deliberately have no page: a trigger points AT a plan and is
4026        // only legible next to it, so they render in the workflow canvas's own
4027        // lane. The old deep link must still land somewhere real.
4028        assert!(!sections.contains("triggers"), "triggers belong on the canvas, not on a page");
4029        assert!(
4030            CONSOLE_HTML.contains("if (head === 'triggers')"),
4031            "#triggers must still route somewhere — a removed page is not a dead bookmark"
4032        );
4033
4034        // The single array in render() that drives `.hidden`.
4035        let head = "for (const p of [";
4036        let i = CONSOLE_HTML.find(head).expect("render()'s page-visibility loop");
4037        let tail = &CONSOLE_HTML[i + head.len()..];
4038        let j = tail.find(']').expect("unterminated page array");
4039        let listed: BTreeSet<String> = tail[..j]
4040            .split(',')
4041            .map(|t| t.trim().trim_matches('\'').to_string())
4042            .filter(|t| !t.is_empty())
4043            .collect();
4044
4045        assert_eq!(
4046            sections, listed,
4047            "page sections and render()'s visibility list disagree; a section missing \
4048             from the list renders into a pane that never un-hides"
4049        );
4050        let orphan: Vec<_> = nav.difference(&sections).collect();
4051        assert!(orphan.is_empty(), "nav items with no page section: {orphan:?}");
4052    }
4053}
4054
4055/// Issue #124: the console must never disclose the password half of a
4056/// Postgres DSN, at any of its three display surfaces, while the RAW label
4057/// keeps working as the credential map's `memories` comparison key.
4058#[cfg(test)]
4059mod dsn_redaction_tests {
4060    use super::UiServer;
4061    use areev_cal::AreevFacade;
4062    use areev_core::authz::{CredentialMap, AUTHZ_NS, REL_PERMITS};
4063    use areev_core::types::{Fact, Grain};
4064    use areev_store::Areev;
4065
4066    const DSN: &str = "postgresql://u:SUPERSECRET@h:5432/d?sslmode=require";
4067
4068    fn server() -> UiServer {
4069        let dir = tempfile::tempdir().unwrap();
4070        let store = Areev::open(dir.path().join("t.db").to_str().unwrap()).unwrap();
4071        std::mem::forget(dir);
4072        let facade = AreevFacade::with_session(store, Some("caller".into()), None);
4073        UiServer::new(facade, DSN.to_string())
4074    }
4075
4076    fn text(r: &(&str, &str, Vec<u8>)) -> String {
4077        String::from_utf8_lossy(&r.2).to_string()
4078    }
4079
4080    /// Decision-model proposal §5: `GET /api/config` reports the decision
4081    /// chain as `decide: {chain, calibrated}`, and `null` when there is none.
4082    #[test]
4083    fn config_reports_the_decision_chain_or_null() {
4084        let cfg = |s: &UiServer| -> serde_json::Value {
4085            serde_json::from_slice(&s.route("GET", "/api/config", b"", None, None).2).unwrap()
4086        };
4087        let none = cfg(&server());
4088        assert!(none.get("decide").is_some_and(serde_json::Value::is_null), "{none}");
4089
4090        let with = cfg(&server().with_decider("systemone:jev-latest,llm:ollama:qwen3.5:4b", false));
4091        assert_eq!(
4092            with["decide"],
4093            serde_json::json!({"chain": "systemone:jev-latest,llm:ollama:qwen3.5:4b", "calibrated": false}),
4094            "{with}"
4095        );
4096        let calibrated = cfg(&server().with_decider("typesafe:jev-latest", true));
4097        assert_eq!(calibrated["decide"]["calibrated"], serde_json::json!(true), "{calibrated}");
4098    }
4099
4100    #[test]
4101    fn stats_config_and_html_redact_the_dsn_password_but_keep_the_host() {
4102        let s = server();
4103        for (method, path) in [("GET", "/api/stats"), ("GET", "/api/config"), ("GET", "/")] {
4104            let r = s.route(method, path, b"", None, None);
4105            let body = text(&r);
4106            assert!(!body.contains("SUPERSECRET"), "{path} leaked the password: {body}");
4107            assert!(body.contains("h:5432"), "{path} redacted more than the password: {body}");
4108        }
4109    }
4110
4111    /// Redaction is DISPLAY ONLY: a credential map's `memories` entry is
4112    /// documented as the raw `--db` value, and it must still resolve —
4113    /// proving the redacted form never leaked into the auth-comparison
4114    /// path (`resolve_for_memory`'s exact string compare).
4115    #[test]
4116    fn credential_map_memories_entry_still_matches_the_raw_dsn() {
4117        std::env::set_var("AREEV_DSN_TOK", "dsn-scoped-secret");
4118        let map_json = format!(
4119            r#"{{"version":1,"tokens":[{{"env":"AREEV_DSN_TOK","principal":"agent:sync","memories":["{DSN}"]}}]}}"#
4120        );
4121        let dir = tempfile::tempdir().unwrap();
4122        let mut store = Areev::open(dir.path().join("t.db").to_str().unwrap()).unwrap();
4123        store
4124            .add(
4125                &Fact::new("agent:sync", REL_PERMITS, "read,write ON caller")
4126                    .namespace(AUTHZ_NS)
4127                    .created_at(1_000),
4128            )
4129            .unwrap();
4130        std::mem::forget(dir);
4131        let facade = AreevFacade::with_session(store, Some("caller".into()), None);
4132        let s = UiServer::new(facade, DSN.to_string())
4133            .with_credentials(CredentialMap::from_json(&map_json).unwrap());
4134
4135        const WRITE: &[u8] = br#"{"query":"ADD fact SET subject = \"x\" SET relation = \"r\" SET object = \"o\" SET namespace = \"caller\" REASON \"t\""}"#;
4136        let r = s.route("POST", "/api/cal", WRITE, Some("dsn-scoped-secret"), None);
4137        let body = text(&r);
4138        assert!(r.0.starts_with("200") && !body.contains("AUT-E001"), "{body}");
4139    }
4140}
4141
4142/// Issue #125: `--allow-origin` lets an exact origin's cross-origin POSTs
4143/// through, without weakening the check for anyone else — no wildcards, no
4144/// suffix/subdomain matching. The Origin header is parsed in
4145/// `handle_request` (not `route`), so these go over a real socket.
4146#[cfg(test)]
4147mod origin_tests {
4148    use super::UiServer;
4149    use areev_cal::AreevFacade;
4150    use areev_store::Areev;
4151    use std::io::{Read, Write};
4152
4153    fn server() -> UiServer {
4154        let dir = tempfile::tempdir().unwrap();
4155        let store = Areev::open(dir.path().join("t.db").to_str().unwrap()).unwrap();
4156        std::mem::forget(dir);
4157        let facade = AreevFacade::with_session(store, Some("caller".into()), None);
4158        UiServer::new(facade, "origin-test".into())
4159    }
4160
4161    /// POST a read-only CAL query (works token-less on an open console) with
4162    /// an optional `Origin` header, returning the raw HTTP response text.
4163    fn post_with_origin(server: &UiServer, origin: Option<&str>) -> String {
4164        let body: &[u8] = br#"{"query":"RECALL facts WHERE subject = \"acme\""}"#;
4165        let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
4166        let addr = listener.local_addr().unwrap();
4167        std::thread::scope(|scope| {
4168            scope.spawn(|| {
4169                if let Ok((s, _)) = listener.accept() {
4170                    let _ = server.handle(s);
4171                }
4172            });
4173            let mut c = std::net::TcpStream::connect(addr).unwrap();
4174            let origin_header = origin.map(|o| format!("Origin: {o}\r\n")).unwrap_or_default();
4175            let req = format!(
4176                "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",
4177                body.len()
4178            );
4179            c.write_all(req.as_bytes()).unwrap();
4180            c.write_all(body).unwrap();
4181            let mut out = String::new();
4182            let _ = c.read_to_string(&mut out);
4183            out
4184        })
4185    }
4186
4187    #[test]
4188    fn matching_allowed_origin_is_accepted() {
4189        let s = server().allow_origin("https://console.example.com");
4190        let r = post_with_origin(&s, Some("https://console.example.com"));
4191        assert!(r.contains("HTTP/1.1 200"), "{r}");
4192    }
4193
4194    #[test]
4195    fn non_matching_origin_is_still_rejected() {
4196        let s = server().allow_origin("https://console.example.com");
4197        let r = post_with_origin(&s, Some("https://not-allowed.example.com"));
4198        assert!(r.contains("403"), "{r}");
4199        assert!(r.contains("cross-origin request rejected"), "{r}");
4200    }
4201
4202    #[test]
4203    fn a_subdomain_of_an_allowed_origin_is_rejected_not_matched() {
4204        // The allowlist entry is the console's real origin; a lookalike
4205        // subdomain must not be accepted by suffix matching.
4206        let s = server().allow_origin("https://console.example.com");
4207        let r = post_with_origin(&s, Some("https://evil-console.example.com"));
4208        assert!(r.contains("403"), "{r}");
4209    }
4210
4211    #[test]
4212    fn loopback_origin_still_works_with_no_allowlist_set() {
4213        let s = server();
4214        let r = post_with_origin(&s, Some("http://127.0.0.1:7437"));
4215        assert!(r.contains("HTTP/1.1 200"), "{r}");
4216    }
4217
4218    #[test]
4219    fn no_origin_header_still_works() {
4220        let s = server();
4221        let r = post_with_origin(&s, None);
4222        assert!(r.contains("HTTP/1.1 200"), "{r}");
4223    }
4224
4225    #[test]
4226    fn matching_origin_differing_only_in_case_and_trailing_slash_is_accepted() {
4227        let s = server().allow_origin("https://console.example.com");
4228        let r = post_with_origin(&s, Some("HTTPS://Console.Example.com/"));
4229        assert!(r.contains("HTTP/1.1 200"), "{r}");
4230    }
4231}
4232
4233/// Issue #126: repeated bad credentials from one source IP are COUNTED and
4234/// LOGGED in a stable, greppable shape — but never delayed or locked out
4235/// in-process (`serve` is a strictly serial accept loop; see
4236/// `note_auth_failure`'s doc comment for why a sleep here would itself be a
4237/// denial-of-service lever). A request that never presented a credential at
4238/// all has nothing to fail and is never counted.
4239/// [A3] Native OIDC at the request level: cookies, the `/auth/*` endpoints,
4240/// and the approval rules an IdP-proven identity is subject to.
4241#[cfg(all(test, feature = "oidc"))]
4242mod oidc_route_tests {
4243    use super::{cookie_value, oidc, UiServer};
4244    use areev_cal::AreevFacade;
4245    use areev_core::authz::{AUTHZ_NS, REL_PERMITS};
4246    use areev_core::types::{Fact, Grain};
4247    use areev_store::Areev;
4248
4249    fn cfg() -> oidc::OidcConfig {
4250        oidc::OidcConfig {
4251            issuer: "https://idp.example.com".into(),
4252            client_id: "console".into(),
4253            client_secret: "s3cr3t".into(),
4254            redirect_uri: "https://console.example.com/auth/callback".into(),
4255            scopes: "openid email".into(),
4256            principal_claim: "email".into(),
4257            principal_prefix: None,
4258        }
4259    }
4260
4261    /// A cookie header is attacker-influenced: any script on any same-site
4262    /// origin can set cookies. A prefix or substring match would let a decoy
4263    /// shadow the real session.
4264    #[test]
4265    fn cookie_lookup_is_exact_on_the_name() {
4266        let h = "theme=dark; areev_session=real; other=x";
4267        assert_eq!(cookie_value(h, "areev_session").as_deref(), Some("real"));
4268        // A decoy whose name merely CONTAINS the real one must not match.
4269        let decoy = "areev_session_decoy=evil; areev_session=real";
4270        assert_eq!(cookie_value(decoy, "areev_session").as_deref(), Some("real"));
4271        let only_decoy = "xareev_session=evil; areev_sessionx=evil2";
4272        assert!(cookie_value(only_decoy, "areev_session").is_none());
4273        assert!(cookie_value("", "areev_session").is_none());
4274    }
4275
4276    /// `/auth/login` redirects to the IdP and stores nothing in the browser;
4277    /// `/auth/logout` clears the cookie AND drops the server-side session.
4278    #[test]
4279    fn the_auth_endpoints_redirect_and_manage_the_cookie() {
4280        let dir = tempfile::tempdir().unwrap();
4281        let store = Areev::open(dir.path().join("t.db").to_str().unwrap()).unwrap();
4282        std::mem::forget(dir);
4283        let facade = AreevFacade::with_session(store, Some("ops".into()), None);
4284        let provider = oidc::OidcProvider::with_test_session(cfg(), "sid-1", "user:pat");
4285        let s = UiServer::new(facade, "oidc-test".into()).with_oidc(provider);
4286
4287        // login → 302 to the IdP, and no Set-Cookie (nothing to remember yet).
4288        let (status, extra, _) = s.oidc_route("GET", "/auth/login", None);
4289        assert!(status.starts_with("302"), "{status}");
4290        assert!(extra.contains("Location: https://idp.example.com/authorize"), "{extra}");
4291        assert!(!extra.contains("Set-Cookie"), "{extra}");
4292        assert!(extra.contains("Cache-Control: no-store"), "a redirect carrying state must not cache: {extra}");
4293
4294        // The session works before logout...
4295        assert!(s.oidc_principal(Some("areev_session=sid-1")).is_some());
4296
4297        // ...and logout clears the browser copy AND invalidates server-side.
4298        let (status, extra, _) =
4299            s.oidc_route("POST", "/auth/logout", Some("areev_session=sid-1"));
4300        assert!(status.starts_with("302"), "{status}");
4301        assert!(extra.contains("Max-Age=0"), "{extra}");
4302        assert!(extra.contains("HttpOnly"), "{extra}");
4303        assert!(extra.contains("SameSite=Strict"), "{extra}");
4304        assert!(
4305            s.oidc_principal(Some("areev_session=sid-1")).is_none(),
4306            "logout must invalidate the session, not just the browser's copy"
4307        );
4308
4309        // A callback with no code surfaces the IdP's own error.
4310        let (status, _, body) =
4311            s.oidc_route("GET", "/auth/callback?error=access_denied", None);
4312        assert!(status.starts_with("400"), "{status}");
4313        assert!(body.contains("access_denied"), "{body}");
4314    }
4315
4316    /// The cookie is `HttpOnly` + `SameSite=Strict`, and `Secure` exactly
4317    /// when the redirect URI is https — a loopback console over plain http
4318    /// must still be able to log in.
4319    #[test]
4320    fn the_session_cookie_is_httponly_strict_and_secure_only_over_https() {
4321        let mk = |redirect: &str| {
4322            let dir = tempfile::tempdir().unwrap();
4323            let store = Areev::open(dir.path().join("t.db").to_str().unwrap()).unwrap();
4324            std::mem::forget(dir);
4325            let facade = AreevFacade::with_session(store, Some("ops".into()), None);
4326            let mut c = cfg();
4327            c.redirect_uri = redirect.into();
4328            let p = oidc::OidcProvider::with_test_session(c, "sid", "user:pat");
4329            UiServer::new(facade, "t".into()).with_oidc(p)
4330        };
4331
4332        let https = mk("https://console.example.com/auth/callback");
4333        let (_, extra, _) = https.oidc_route("GET", "/auth/logout", None);
4334        assert!(extra.contains("; Secure"), "https deployment must set Secure: {extra}");
4335
4336        let http = mk("http://127.0.0.1:7437/auth/callback");
4337        let (_, extra, _) = http.oidc_route("GET", "/auth/logout", None);
4338        assert!(!extra.contains("; Secure"), "loopback http must not set Secure: {extra}");
4339        // The other two are unconditional.
4340        assert!(extra.contains("HttpOnly") && extra.contains("SameSite=Strict"), "{extra}");
4341    }
4342
4343    /// The point of the whole feature: an OIDC-proven identity MAY approve,
4344    /// and `--sso-approvals` (which governs the header path) does not touch
4345    /// it. A signature verified against the issuer's key set is a stronger
4346    /// claim than a shared proxy secret, so it gets the stronger right.
4347    #[test]
4348    fn an_oidc_principal_may_approve_regardless_of_sso_approvals() {
4349        let dir = tempfile::tempdir().unwrap();
4350        let path = dir.path().join("t.db");
4351        let mut store = Areev::open(path.to_str().unwrap()).unwrap();
4352        store
4353            .add(
4354                &Fact::new("user:pat", REL_PERMITS, "read,run.respond ON ops")
4355                    .namespace(AUTHZ_NS)
4356                    .created_at(900),
4357            )
4358            .unwrap();
4359        std::mem::forget(dir);
4360        let facade = AreevFacade::with_session(store, Some("ops".into()), None);
4361        let provider = oidc::OidcProvider::with_test_session(cfg(), "sid-1", "user:pat");
4362        let s = UiServer::new(facade, "oidc-test".into())
4363            .with_oidc(provider)
4364            // The A0 default, explicitly: SSO headers may NOT approve.
4365            .with_sso_approvals(false);
4366
4367        let r = s.route_full("GET", "/api/whoami", b"", None, None, None, Some("user:pat"));
4368        let text = String::from_utf8_lossy(&r.2).to_string();
4369        assert!(text.contains(r#""identity_source":"oidc""#), "{text}");
4370        assert!(
4371            text.contains(r#""may_approve":true"#),
4372            "an IdP-proven identity is exactly what --sso-approvals exists to \
4373             substitute for; it must not be caught by that refusal: {text}"
4374        );
4375        assert!(text.contains("user:pat"), "{text}");
4376    }
4377}
4378
4379#[cfg(test)]
4380mod auth_failure_tracking_tests {
4381    use super::UiServer;
4382    use areev_cal::AreevFacade;
4383    use areev_store::Areev;
4384    use std::io::{Read, Write};
4385    use std::net::IpAddr;
4386
4387    const LOCALHOST: IpAddr = IpAddr::V4(std::net::Ipv4Addr::LOCALHOST);
4388
4389    fn server() -> UiServer {
4390        let dir = tempfile::tempdir().unwrap();
4391        let store = Areev::open(dir.path().join("t.db").to_str().unwrap()).unwrap();
4392        std::mem::forget(dir);
4393        let facade = AreevFacade::with_session(store, Some("caller".into()), None);
4394        UiServer::new(facade, "auth-failure-test".into()).with_auth("right-token".into())
4395    }
4396
4397    /// GET `/` with an optional `Authorization: Bearer` header, over a real
4398    /// socket (the counter hooks `handle_request`, not `route`).
4399    fn get_root(server: &UiServer, bearer: Option<&str>) -> String {
4400        let auth_header = bearer
4401            .map(|t| format!("Authorization: Bearer {t}\r\n"))
4402            .unwrap_or_default();
4403        get_root_with(server, &auth_header)
4404    }
4405
4406    /// `GET /` with arbitrary extra headers, over a real socket (the counter
4407    /// hooks `handle_request`, not `route`).
4408    fn get_root_with(server: &UiServer, extra_headers: &str) -> String {
4409        let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
4410        let addr = listener.local_addr().unwrap();
4411        std::thread::scope(|scope| {
4412            scope.spawn(|| {
4413                if let Ok((s, _)) = listener.accept() {
4414                    let _ = server.handle(s);
4415                }
4416            });
4417            let mut c = std::net::TcpStream::connect(addr).unwrap();
4418            let req = format!("GET / HTTP/1.1\r\nHost: 127.0.0.1\r\n{extra_headers}Connection: close\r\n\r\n");
4419            c.write_all(req.as_bytes()).unwrap();
4420            let mut out = String::new();
4421            let _ = c.read_to_string(&mut out);
4422            out
4423        })
4424    }
4425
4426    #[test]
4427    fn a_bare_request_is_401_but_not_counted() {
4428        let s = server();
4429        // No credential presented at all: 401 (auth_all guards every
4430        // request), but nothing was attempted, so it must not be counted —
4431        // a token-less probe is not an attack attempt.
4432        let bare = get_root(&s, None);
4433        assert!(bare.contains("HTTP/1.1 401"), "{bare}");
4434        assert_eq!(s.auth_failure_count_for_test(LOCALHOST), 0, "a bare request has nothing to fail");
4435    }
4436
4437    #[test]
4438    fn a_wrong_token_is_401_and_is_counted() {
4439        let s = server();
4440        let wrong = get_root(&s, Some("guess"));
4441        assert!(wrong.contains("HTTP/1.1 401"), "{wrong}");
4442        assert_eq!(s.auth_failure_count_for_test(LOCALHOST), 1);
4443
4444        let wrong_again = get_root(&s, Some("guess"));
4445        assert!(wrong_again.contains("HTTP/1.1 401"), "{wrong_again}");
4446        assert_eq!(s.auth_failure_count_for_test(LOCALHOST), 2, "consecutive failures accumulate");
4447    }
4448
4449    #[test]
4450    fn a_right_token_after_a_streak_succeeds_immediately_and_resets_it() {
4451        let s = server();
4452        for _ in 0..5 {
4453            let r = get_root(&s, Some("guess"));
4454            assert!(r.contains("HTTP/1.1 401"), "{r}");
4455        }
4456        assert_eq!(s.auth_failure_count_for_test(LOCALHOST), 5);
4457
4458        // The right token succeeds on the very next request — there is no
4459        // in-process DELAY at any streak length, and below
4460        // MAX_CONSECUTIVE_AUTH_FAILURES no rejection either, so an operator
4461        // who fat-fingers a pasted token is never locked out.
4462        let ok = get_root(&s, Some("right-token"));
4463        assert!(ok.contains("HTTP/1.1 200"), "{ok}");
4464        assert_eq!(s.auth_failure_count_for_test(LOCALHOST), 0, "success resets the streak");
4465
4466        // The next wrong guess starts a fresh streak at 1, not 6.
4467        let r = get_root(&s, Some("guess"));
4468        assert!(r.contains("HTTP/1.1 401"), "{r}");
4469        assert_eq!(s.auth_failure_count_for_test(LOCALHOST), 1);
4470    }
4471
4472    /// [A1] Behind a trusted proxy every request shares ONE source address,
4473    /// so a per-IP lockout would let one attacker's bad guesses refuse every
4474    /// user behind it — a self-inflicted outage in the deployment we
4475    /// recommend. A proxy-proven request is exempt; throttling there belongs
4476    /// to the proxy, like TLS and the IdP handshake.
4477    #[test]
4478    fn a_proxy_proven_request_is_never_locked_out() {
4479        let dir = tempfile::tempdir().unwrap();
4480        let store = Areev::open(dir.path().join("t.db").to_str().unwrap()).unwrap();
4481        std::mem::forget(dir);
4482        let facade = AreevFacade::with_session(store, Some("caller".into()), None);
4483        let s = UiServer::new(facade, "proxy-test".into())
4484            .with_auth("right-token".into())
4485            .with_sso_rotating("x-forwarded-user", "proxy-secret", None::<String>);
4486
4487        // Burn well past the threshold with wrong bearer tokens, but all
4488        // arriving through the proxy (valid proxy secret on every request).
4489        for _ in 0..(super::MAX_CONSECUTIVE_AUTH_FAILURES + 5) {
4490            let _ = get_root_with(
4491                &s,
4492                "Authorization: Bearer guess\r\nX-Areev-Proxy-Secret: proxy-secret\r\n",
4493            );
4494        }
4495        // A legitimate user behind the same proxy is still served.
4496        let ok = get_root_with(
4497            &s,
4498            "Authorization: Bearer right-token\r\nX-Areev-Proxy-Secret: proxy-secret\r\n",
4499        );
4500        assert!(ok.contains("HTTP/1.1 200"), "proxy-borne traffic must not be locked out: {ok}");
4501    }
4502
4503    /// [A1] Past the threshold the console stops answering credential-bearing
4504    /// requests from that source — refusing, never sleeping, because a serial
4505    /// accept loop makes a sleep a denial-of-service lever for everyone else.
4506    #[test]
4507    fn a_long_streak_is_refused_with_429_until_it_goes_idle() {
4508        let s = server();
4509        for i in 0..super::MAX_CONSECUTIVE_AUTH_FAILURES {
4510            let r = get_root(&s, Some("guess"));
4511            assert!(r.contains("HTTP/1.1 401"), "attempt {i} should still be a 401: {r}");
4512        }
4513
4514        // The next credential-bearing request is refused before routing.
4515        let blocked = get_root(&s, Some("guess"));
4516        assert!(blocked.contains("HTTP/1.1 429"), "{blocked}");
4517        assert!(blocked.contains("Retry-After:"), "a 429 must say when: {blocked}");
4518
4519        // The brake is on the SOURCE, not on the credential: even the right
4520        // token is refused while the streak stands. That is the intended
4521        // trade — an attacker who has burned an IP cannot then race a guess
4522        // through — and it is why the streak expires on its own.
4523        let right = get_root(&s, Some("right-token"));
4524        assert!(right.contains("HTTP/1.1 429"), "{right}");
4525
4526        // A credential-LESS request is untouched: it was never counted, so it
4527        // must not be blocked either. The console page still serves its 401
4528        // challenge, which is what lets a browser prompt for a fresh login.
4529        let bare = get_root(&s, None);
4530        assert!(bare.contains("HTTP/1.1 401"), "{bare}");
4531    }
4532}