1use std::io::{BufRead, BufReader, Read, Write};
9use std::net::{TcpListener, TcpStream};
10use std::time::Duration;
11
12use areev_cal::{CalExecutor, CalExecutorConfig, CalStoreFacade, AreevFacade};
13use areev_loop_adapter::{now_ms, BorrowedSubstrate};
14use serde_json::{json, Value};
15use areev_loop::{Decision, Engine, RecStatus, RunOptions};
16
17const CONSOLE_HTML: &str = include_str!("console.html");
18
19const READ_TIMEOUT_SECS: u64 = 15;
21const MAX_CONN_BYTES: u64 = (1 << 20) + (128 << 10);
24const MAX_HEADER_BYTES: usize = 64 * 1024;
26const MAX_HEADERS: usize = 100;
27const REQUEST_DEADLINE_SECS: u64 = 30;
31
32
33enum Conn {
40 Plain(TcpStream),
41 #[cfg(feature = "tls")]
42 Tls(Box<rustls::StreamOwned<rustls::ServerConnection, TcpStream>>),
43}
44
45impl Read for Conn {
46 fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
47 match self {
48 Conn::Plain(s) => s.read(buf),
49 #[cfg(feature = "tls")]
50 Conn::Tls(s) => s.read(buf),
51 }
52 }
53}
54
55impl Write for Conn {
56 fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
57 match self {
58 Conn::Plain(s) => s.write(buf),
59 #[cfg(feature = "tls")]
60 Conn::Tls(s) => s.write(buf),
61 }
62 }
63 fn flush(&mut self) -> std::io::Result<()> {
64 match self {
65 Conn::Plain(s) => s.flush(),
66 #[cfg(feature = "tls")]
67 Conn::Tls(s) => s.flush(),
68 }
69 }
70}
71
72pub struct UiServer {
73 facade: std::sync::Arc<AreevFacade>,
74 executor: CalExecutor,
75 db_label: String,
76 token: Option<String>,
80 auth_all: bool,
84 segment_dir: Option<String>,
86 allow_remote: bool,
91 loop_policy: Option<areev_loop::Policy>,
96 credentials: Option<areev_core::authz::CredentialMap>,
105 allow_destructive_ops: bool,
108 #[cfg(feature = "tls")]
111 tls_config: Option<std::sync::Arc<rustls::ServerConfig>>,
112 sso_header: Option<String>,
119 sso_secret: Option<String>,
120}
121
122struct RequestBinding<'a> {
128 facade: &'a AreevFacade,
129}
130
131impl<'a> RequestBinding<'a> {
132 fn bind(
133 facade: &'a AreevFacade,
134 map: &areev_core::authz::CredentialMap,
135 bearer: Option<&str>,
136 memory: &str,
137 ) -> Self {
138 let principal =
141 bearer.and_then(|t| map.resolve_for_memory(t, memory).ok().map(str::to_string));
142 match principal {
143 Some(p) => {
144 if facade.bind_principal(&p).is_err() {
145 Self::bind_anonymous(facade);
148 }
149 }
150 None => Self::bind_anonymous(facade),
151 }
152 RequestBinding { facade }
153 }
154
155 fn bind_identity(facade: &'a AreevFacade, principal: &str) -> Self {
161 if facade.bind_principal(principal).is_err() {
162 Self::bind_anonymous(facade);
163 }
164 RequestBinding { facade }
165 }
166
167 fn bind_anonymous(facade: &AreevFacade) {
168 use areev_core::authz::{AuthzSet, Grant, Verb};
169 let mut grants = facade
170 .with_store(|m| m.authz_grants("anonymous"))
171 .unwrap_or_default();
172 grants.push(Grant { verbs: vec![Verb::Read], namespaces: vec!["*".to_string()] });
173 facade.bind(AuthzSet::restricted("anonymous", grants));
174 }
175}
176
177impl Drop for RequestBinding<'_> {
178 fn drop(&mut self) {
179 self.facade
180 .bind(areev_core::authz::AuthzSet::owner("user:console"));
181 }
182}
183
184impl UiServer {
185 pub fn new(facade: AreevFacade, db_label: String) -> Self {
186 UiServer {
187 facade: std::sync::Arc::new(facade),
188 executor: CalExecutor::new(CalExecutorConfig::default())
189 .with_governance(std::sync::Arc::new(areev_loop_adapter::LoopGovernance::new())),
190 db_label,
191 token: None,
192 auth_all: false,
193 segment_dir: None,
194 allow_remote: false,
195 loop_policy: None,
196 credentials: None,
197 allow_destructive_ops: CalExecutorConfig::default().allow_destructive_ops,
198 #[cfg(feature = "tls")]
199 tls_config: None,
200 sso_header: None,
201 sso_secret: None,
202 }
203 }
204
205 pub fn with_sso(mut self, header: impl Into<String>, secret: impl Into<String>) -> Self {
209 let header = header.into();
210 let secret = secret.into();
211 assert!(
215 !header.trim().is_empty() && !secret.trim().is_empty(),
216 "with_sso requires a non-empty header name and proxy secret"
217 );
218 self.sso_header = Some(header.to_ascii_lowercase());
219 self.sso_secret = Some(secret);
220 self
221 }
222
223 fn rebuild_executor(&mut self) {
230 self.executor = CalExecutor::new(CalExecutorConfig {
231 allow_destructive_ops: self.allow_destructive_ops,
232 ..CalExecutorConfig::default()
233 })
234 .with_governance(std::sync::Arc::new(match &self.loop_policy {
235 Some(p) => areev_loop_adapter::LoopGovernance::with_policy(p.clone()),
236 None => areev_loop_adapter::LoopGovernance::new(),
237 }));
238 }
239
240 pub fn with_credentials(mut self, map: areev_core::authz::CredentialMap) -> Self {
245 self.credentials = Some(map);
246 self
247 }
248
249 pub fn with_loop_policy(mut self, policy: areev_loop::Policy) -> Self {
253 self.loop_policy = Some(policy);
254 self.rebuild_executor();
256 self
257 }
258
259 fn session_may_sync(&self) -> bool {
265 self.facade
266 .authz()
267 .allows(areev_core::authz::Verb::Read, "*")
268 }
269
270 fn engine(&self) -> Engine {
273 match &self.loop_policy {
274 Some(p) => Engine::with_builtins().with_policy(p.clone()),
275 None => Engine::with_builtins(),
276 }
277 }
278
279 pub fn allow_remote(mut self, yes: bool) -> Self {
283 self.allow_remote = yes;
284 self
285 }
286
287 pub fn with_auth(mut self, token: String) -> Self {
294 assert!(
298 !token.trim().is_empty(),
299 "with_auth requires a non-empty token"
300 );
301 self.token = Some(token);
302 self.auth_all = true;
303 self
304 }
305
306 pub fn allow_destructive_ops(mut self, allow: bool) -> Self {
311 self.allow_destructive_ops = allow;
312 self.rebuild_executor();
313 self
314 }
315
316 pub fn into_hub(mut self, token: Option<String>, dir: &str) -> std::io::Result<Self> {
319 std::fs::create_dir_all(dir)?;
320 self.token = token;
321 self.segment_dir = Some(dir.to_string());
322 self.allow_remote = true;
325 Ok(self)
326 }
327
328 pub fn bind(addr: &str) -> std::io::Result<TcpListener> {
330 TcpListener::bind(addr)
331 }
332
333 pub fn serve(&self, listener: TcpListener) -> std::io::Result<()> {
335 for stream in listener.incoming() {
336 match stream {
337 Ok(s) => {
338 if let Err(e) = self.handle(s) {
339 eprintln!("areev ui: request error: {e}");
340 }
341 }
342 Err(e) => eprintln!("areev ui: accept error: {e}"),
343 }
344 }
345 Ok(())
346 }
347
348 #[cfg(feature = "tls")]
352 pub fn with_tls(mut self, cert_pem: &str, key_pem: &str) -> std::io::Result<Self> {
353 use rustls::pki_types::pem::PemObject;
354 let pem_err =
355 |e: rustls::pki_types::pem::Error| std::io::Error::new(std::io::ErrorKind::InvalidData, e.to_string());
356 let certs: Vec<rustls::pki_types::CertificateDer<'static>> =
357 rustls::pki_types::CertificateDer::pem_file_iter(cert_pem)
358 .map_err(pem_err)?
359 .collect::<Result<_, _>>()
360 .map_err(pem_err)?;
361 let key =
362 rustls::pki_types::PrivateKeyDer::from_pem_file(key_pem).map_err(pem_err)?;
363 let config = rustls::ServerConfig::builder()
364 .with_no_client_auth()
365 .with_single_cert(certs, key)
366 .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e.to_string()))?;
367 self.tls_config = Some(std::sync::Arc::new(config));
368 Ok(self)
369 }
370
371 fn wrap(&self, stream: TcpStream) -> std::io::Result<Conn> {
374 #[cfg(feature = "tls")]
375 if let Some(config) = &self.tls_config {
376 let session = rustls::ServerConnection::new(std::sync::Arc::clone(config))
377 .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e.to_string()))?;
378 return Ok(Conn::Tls(Box::new(rustls::StreamOwned::new(session, stream))));
379 }
380 Ok(Conn::Plain(stream))
381 }
382
383 fn handle(&self, stream: TcpStream) -> std::io::Result<()> {
384 let watchdog_stream = stream.try_clone()?;
390 let done = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
391 let watchdog_done = std::sync::Arc::clone(&done);
392 let watchdog = std::thread::spawn(move || {
393 std::thread::park_timeout(Duration::from_secs(REQUEST_DEADLINE_SECS));
394 if !watchdog_done.load(std::sync::atomic::Ordering::Acquire) {
395 let _ = watchdog_stream.shutdown(std::net::Shutdown::Both);
396 }
397 });
398 let raw = stream.try_clone()?;
399 let conn = self.wrap(stream)?;
400 let result = self.handle_request(&raw, conn);
401 done.store(true, std::sync::atomic::Ordering::Release);
402 watchdog.thread().unpark();
403 let _ = watchdog.join();
404 result
405 }
406
407 fn handle_request(&self, raw: &TcpStream, mut conn: Conn) -> std::io::Result<()> {
408 raw.set_read_timeout(Some(Duration::from_secs(READ_TIMEOUT_SECS)))?;
413 raw.set_write_timeout(Some(Duration::from_secs(READ_TIMEOUT_SECS)))?;
414 let mut reader = BufReader::new((&mut conn).take(MAX_CONN_BYTES));
415 let mut request_line = String::new();
416 reader.read_line(&mut request_line)?;
417 let mut parts = request_line.split_whitespace();
418 let method = parts.next().unwrap_or("").to_string();
419 let path = parts.next().unwrap_or("/").to_string();
420
421 let mut content_length = 0usize;
423 let mut bearer: Option<String> = None;
424 let mut origin: Option<String> = None;
425 let mut host: Option<String> = None;
426 let mut sso_identity_raw: Option<String> = None;
427 let mut proxy_secret: Option<String> = None;
428 let mut header_bytes = 0usize;
429 let mut header_count = 0usize;
430 loop {
431 let mut line = String::new();
432 let n = reader.read_line(&mut line)?;
433 if n == 0 {
434 break; }
436 header_bytes += n;
437 header_count += 1;
438 if header_bytes > MAX_HEADER_BYTES || header_count > MAX_HEADERS {
439 return Err(std::io::Error::new(
440 std::io::ErrorKind::InvalidData,
441 "request headers too large",
442 ));
443 }
444 let l = line.trim();
445 if l.is_empty() {
446 break;
447 }
448 let low = l.to_ascii_lowercase();
449 if let Some(v) = low.strip_prefix("content-length:") {
450 content_length = v.trim().parse().unwrap_or(0);
451 }
452 if let Some(v) = low.strip_prefix("origin:") {
453 origin = Some(v.trim().to_string());
454 }
455 if let Some(v) = low.strip_prefix("host:") {
456 host = Some(v.trim().to_string());
457 }
458 if let Some(v) = low.strip_prefix("x-areev-proxy-secret:") {
459 let _ = v;
462 if let Some((_, orig)) = l.split_once(':') {
463 proxy_secret = Some(orig.trim().to_string());
464 }
465 }
466 if let Some(h) = &self.sso_header {
467 if low.starts_with(&format!("{h}:")) {
468 if let Some((_, orig)) = l.split_once(':') {
469 sso_identity_raw = Some(orig.trim().to_string());
470 }
471 }
472 }
473 if low.starts_with("authorization:") {
474 if let Some((_, v)) = l.split_once(':') {
475 let v = v.trim();
476 bearer = if let Some(t) =
481 v.strip_prefix("Bearer ").or_else(|| v.strip_prefix("bearer "))
482 {
483 Some(t.trim().to_string())
484 } else if let Some(b64) =
485 v.strip_prefix("Basic ").or_else(|| v.strip_prefix("basic "))
486 {
487 basic_auth_password(b64.trim())
488 } else {
489 Some(v.to_string())
490 };
491 }
492 }
493 }
494 if !self.allow_remote && host.as_deref().is_some_and(|h| !host_is_local(h)) {
501 let payload = br#"{"ok":false,"error":"non-loopback Host rejected (use --allow-remote to serve remotely)"}"#;
502 drop(reader);
503 let mut out = conn;
504 write!(
505 out,
506 "HTTP/1.1 403 Forbidden\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
507 payload.len()
508 )?;
509 out.write_all(payload)?;
510 return out.flush();
511 }
512
513 let mut body = vec![0u8; content_length.min(1 << 20)];
514 if content_length > 0 {
515 reader.read_exact(&mut body)?;
516 }
517
518 if method == "POST" && origin.as_deref().is_some_and(|o| !origin_is_local(o)) {
522 let payload = br#"{"ok":false,"error":"cross-origin request rejected"}"#;
523 drop(reader);
524 let mut out = conn;
525 write!(
526 out,
527 "HTTP/1.1 403 Forbidden\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
528 payload.len()
529 )?;
530 out.write_all(payload)?;
531 return out.flush();
532 }
533
534 let sso_identity: Option<String> = match (&self.sso_secret, proxy_secret, sso_identity_raw) {
539 (Some(expected), Some(presented), Some(id))
540 if ct_eq(expected.as_bytes(), presented.as_bytes())
541 && !id.trim().is_empty() =>
542 {
543 Some(id)
544 }
545 _ => None,
546 };
547 let (status, ctype, payload) =
548 self.route(&method, &path, &body, bearer.as_deref(), sso_identity.as_deref());
549 let auth_challenge = if self.auth_all && status.starts_with("401") {
552 "WWW-Authenticate: Basic realm=\"Areev console\", charset=\"UTF-8\"\r\n"
553 } else {
554 ""
555 };
556 drop(reader);
557 let mut out = conn;
558 write!(
559 out,
560 "HTTP/1.1 {status}\r\nContent-Type: {ctype}\r\n{auth_challenge}Content-Length: {}\r\nConnection: close\r\n\r\n",
561 payload.len()
562 )?;
563 out.write_all(&payload)?;
564 out.flush()
565 }
566
567 fn runner(&self, principal: &str) -> areev_run::Runner {
572 struct NoExec;
573 impl areev_run::HostToolExecutor for NoExec {
574 fn execute(
575 &self,
576 tool_name: &str,
577 _h: &str,
578 _i: &Value,
579 _k: &str,
580 ) -> areev_run::ExecResult {
581 areev_run::ExecResult::Err {
582 cause: areev_run::FailCause::ExecutorError,
583 detail: format!("the console does not execute tools ('{tool_name}')"),
584 }
585 }
586 }
587 areev_run::Runner {
588 facade: std::sync::Arc::clone(&self.facade),
589 clock: std::sync::Arc::new(areev_run::SystemClock),
590 executor: std::sync::Arc::new(NoExec),
591 llm: None,
592 observer: None,
593 ns: self
594 .facade
595 .default_namespace()
596 .unwrap_or("shared")
597 .to_string(),
598 principal: principal.to_string(),
599 }
600 }
601
602 fn route(
603 &self,
604 method: &str,
605 path: &str,
606 body: &[u8],
607 bearer: Option<&str>,
608 sso_identity: Option<&str>,
609 ) -> (&'static str, &'static str, Vec<u8>) {
610 let mut shared_secret_ok = false;
621 if let Some(tok) = &self.token {
622 let guarded = self.auth_all || method == "POST" || path.starts_with("/api/segment");
623 shared_secret_ok = bearer.is_some_and(|b| ct_eq(b.as_bytes(), tok.as_bytes()));
624 let known = shared_secret_ok
625 || sso_identity.is_some()
626 || match (&self.credentials, bearer) {
627 (Some(map), Some(b)) => map.resolve_for_memory(b, &self.db_label).is_ok(),
628 _ => false,
629 };
630 if guarded && !known {
631 return ("401 Unauthorized", "application/json",
632 br#"{"ok":false,"error":"authentication required"}"#.to_vec());
633 }
634 } else if self.credentials.is_none() && sso_identity.is_none() && method == "POST" {
635 let base = path.split_once('?').map(|(p, _)| p).unwrap_or(path);
641 let allowed = base == "/api/cal" && cal_body_is_read_only(body);
642 if !allowed {
643 return ("401 Unauthorized", "application/json",
644 br#"{"ok":false,"error":"read-only console: restart areev ui with --token-env VAR to enable writes"}"#.to_vec());
645 }
646 }
647
648 let _session = match (&self.credentials, shared_secret_ok) {
655 (Some(map), false) => Some(RequestBinding::bind(&self.facade, map, bearer, &self.db_label)),
656 _ => None,
657 };
658 let mut request_principal: Option<String> = match (&self.credentials, shared_secret_ok, bearer)
663 {
664 (Some(map), false, Some(b)) => map.resolve_for_memory(b, &self.db_label).ok().map(str::to_string),
665 _ => None,
666 };
667 let _sso_session = match (&request_principal, sso_identity, shared_secret_ok) {
671 (None, Some(identity), false) => {
672 request_principal = Some(identity.to_string());
673 Some(RequestBinding::bind_identity(&self.facade, identity))
674 }
675 _ => None,
676 };
677
678 let (path, query) = match path.split_once('?') {
679 Some((p, q)) => (p, q),
680 None => (path, ""),
681 };
682 let q = |key: &str| -> Option<String> {
683 query.split('&').find_map(|kv| {
684 let (k, v) = kv.split_once('=')?;
685 (k == key).then(|| urldecode(v))
686 })
687 };
688 match (method, path) {
689 ("GET", "/") => (
690 "200 OK",
691 "text/html; charset=utf-8",
692 CONSOLE_HTML
693 .replace("{{DB}}", &html_escape(&self.db_label))
694 .into_bytes(),
695 ),
696 ("POST", "/api/cal") => {
697 let req: Value = serde_json::from_slice(body).unwrap_or(Value::Null);
698 let queryt = req.get("query").and_then(|v| v.as_str()).unwrap_or("");
699 match self.executor.execute(queryt, &*self.facade) {
700 Ok(res) => ok_json(json!({
701 "ok": true,
702 "result": res.result,
703 "warnings": res.warnings,
704 "statement": res.metadata.statement_type,
705 "elapsed_ms": res.metadata.execution_time_ms,
706 })),
707 Err(e) => {
708 let mut err = json!({
711 "ok": false,
712 "error": e.sanitize_for_client(),
713 "code": e.code(),
714 });
715 if let Some(sp) = e.span() {
716 err["span"] = json!({
717 "start": sp.start, "end": sp.end,
718 "line": sp.line, "col": sp.col,
719 });
720 }
721 if let Some(hint) = e.suggestion() {
722 err["suggestion"] = json!(hint);
723 }
724 ok_json(err)
725 }
726 }
727 }
728 ("GET", "/api/stats") => {
729 let stats = self.facade.with_store(|m| m.stats());
730 match stats {
731 Ok(s) => ok_json(json!({
732 "db": self.db_label,
733 "grains": s.grains, "current": s.current,
734 "triples": s.triples, "terms": s.terms,
735 "ops": s.ops, "events_indexed": s.events_indexed,
736 })),
737 Err(e) => ok_json(json!({"ok": false, "error": e.to_string()})),
738 }
739 }
740 ("GET", "/api/log") => {
741 let since: i64 = q("since").and_then(|v| v.parse().ok()).unwrap_or(0);
742 let limit: usize = q("limit").and_then(|v| v.parse().ok()).unwrap_or(100);
743 match self.facade.with_store(|m| m.changes_since(since, limit)) {
744 Ok(ops) => {
745 let rows: Vec<Value> = ops
746 .iter()
747 .map(|o| {
748 json!({
749 "op_seq": o.op_seq, "hlc": o.hlc,
750 "op": match o.op { 1 => "add", 2 => "supersede", 3 => "forget", _ => "?" },
751 "hash": o.hash.to_hex(),
752 })
753 })
754 .collect();
755 ok_json(json!(rows))
756 }
757 Err(e) => ok_json(json!({"ok": false, "error": e.to_string()})),
758 }
759 }
760 ("GET", "/api/whoami") => {
764 let a = self.facade.authz();
765 ok_json(json!({
766 "ok": true,
767 "principal": a.principal(),
768 "owner": a.is_owner(),
769 "mode": if self.credentials.is_some() { "credentials" }
770 else if self.token.is_some() { "token" }
771 else { "open" },
772 }))
773 }
774 ("GET", "/api/config") => {
775 let cfg = self.executor.config();
780 let (index_text, embedder_dim, declared_embed, mut warnings) =
781 self.facade.with_store(|m| {
782 (
783 m.index_text_enabled(),
784 m.embedder_dim(),
785 m.declared_embedding().map(|(mm, d)| (mm.to_string(), d)),
786 m.open_warnings().to_vec(),
787 )
788 });
789 if let Some((m, d)) = &declared_embed {
790 if embedder_dim.is_none() {
791 warnings.push(format!(
792 "vector leg dormant: file expects {m}@{d}, no embedding backend installed"
793 ));
794 }
795 }
796 warnings.extend(self.facade.meta_warnings());
800 let (anon_declared, anon_floor, anon_audit) = self.facade.with_store(|m| {
804 (
805 m.anon_declared(),
806 m.anonymize_egress_floor(),
807 m.anon_audit_counts(),
808 )
809 });
810 let anonymization = json!({
811 "policies": anon_declared
812 .iter()
813 .map(|(ns, mode)| json!({"ns": ns, "mode": mode}))
814 .collect::<Vec<_>>(),
815 "floor": anon_floor,
816 "audit_counts": anon_audit
817 .iter()
818 .map(|(ns, cat, n)| json!({"ns": ns, "category": cat, "count": n}))
819 .collect::<Vec<_>>(),
820 });
821 ok_json(json!({
822 "ok": true,
823 "db": self.db_label,
824 "warnings": warnings,
825 "anonymization": anonymization,
826 "file": {
827 "text_index": index_text,
828 "embedding": declared_embed.map(|(m, d)| json!({"model": m, "dim": d})),
829 },
830 "session": {
831 "namespace": self.facade.session_namespace(),
832 "mounts": self.facade.mount_aliases(),
833 "is_owner": self.facade.authz().is_owner(),
836 },
837 "store": {
838 "index_text": index_text,
839 "embedder": embedder_dim.map(|d| json!({"dim": d})),
840 },
841 "recall": {
842 "fusion": "rrf",
843 "rrf_k0": areev_store::RRF_K0,
844 "overfetch_factor": AreevFacade::RECALL_OVERFETCH,
845 "legs": {
846 "structural": true,
847 "bm25": index_text,
848 "vector": embedder_dim.is_some(),
849 },
850 },
851 "executor": {
852 "max_limit": cfg.max_limit,
853 "default_limit": cfg.default_limit,
854 "tier1_writes": cfg.tier1_enabled,
855 "namespace_override": cfg.namespace_override,
856 "user_id_override": cfg.user_id_override,
857 },
858 "server": {
859 "hub_mode": self.segment_dir.is_some(),
860 "auth_required": self.token.is_some(),
861 "auth_all": self.auth_all,
864 "segment_dir": self.segment_dir,
865 },
866 "persistence": "per-process (host-supplied at open) — not stored in the .db",
867 }))
868 }
869 ("GET", "/api/browse") => {
870 let limit: i64 = q("limit")
876 .and_then(|v| v.parse().ok())
877 .unwrap_or(500)
878 .clamp(1, 2000);
879 let built = self.facade.with_store(|m| -> Result<(i64, Vec<Value>), String> {
880 let total = m.stats().map_err(|e| e.to_string())?.ops as i64;
881 let after = (total - limit).max(0);
882 let ops = m.changes_since(after, limit as usize).map_err(|e| e.to_string())?;
883 let mut rows: Vec<Value> = Vec::with_capacity(ops.len());
884 let mut idx: std::collections::HashMap<String, usize> =
885 std::collections::HashMap::new();
886 for o in &ops {
887 let hex = o.hash.to_hex();
888 let op_name = match o.op { 1 => "add", 2 => "supersede", 3 => "forget", _ => "?" };
889 if o.op == 3 {
890 match idx.get(&hex) {
893 Some(&i) => { rows[i]["forgotten"] = json!(true); }
894 None => rows.push(json!({
895 "hash": hex, "op_seq": o.op_seq, "hlc": o.hlc,
896 "op": op_name, "forgotten": true,
897 })),
898 }
899 continue;
900 }
901 if let Some(&i) = idx.get(&hex) {
905 rows[i]["op"] = json!(op_name);
906 rows[i]["op_seq"] = json!(o.op_seq);
907 rows[i]["hlc"] = json!(o.hlc);
908 continue;
909 }
910 let mut row = json!({
911 "hash": hex, "op_seq": o.op_seq, "hlc": o.hlc, "op": op_name,
912 });
913 match m.get(&o.hash) {
914 Ok(g) => {
915 row["type"] = json!(format!("{:?}", g.grain_type).to_lowercase());
916 row["fields"] = serde_json::to_value(&g.fields).unwrap_or(Value::Null);
917 }
918 Err(_) => { row["missing"] = json!(true); }
920 }
921 idx.insert(hex, rows.len());
922 rows.push(row);
923 }
924 let marks: Vec<(String, String)> = rows.iter()
927 .filter(|r| r["op"] == "supersede")
928 .filter_map(|r| {
929 let old = r["fields"].get("derived_from")?.as_str()?;
930 Some((old.to_string(), r["hash"].as_str()?.to_string()))
931 })
932 .collect();
933 for (old, newer) in marks {
934 if let Some(&i) = idx.get(&old) {
935 rows[i]["superseded_by"] = json!(newer);
936 }
937 }
938 rows.reverse();
939 Ok((total, rows))
940 });
941 let ns_param = q("ns");
958 let built = built.and_then(|(total, rows)| {
959 let authz = self.facade.authz();
960 let filter_ns = match ns_param.as_deref() {
961 Some("*") => {
962 if !authz.is_owner() {
963 return Err("not authorized to browse all namespaces".to_string());
964 }
965 None
966 }
967 Some(ns) => {
968 authz
969 .check(areev_core::authz::Verb::Read, ns)
970 .map_err(|e| e.to_string())?;
971 Some(ns.to_string())
972 }
973 None => self.facade.session_namespace().map(|s| s.to_string()),
974 };
975 let Some(ns) = filter_ns else {
976 return Ok((total, rows));
977 };
978 let kept = rows
979 .into_iter()
980 .filter(|r| {
981 r["fields"]
982 .get("namespace")
983 .and_then(Value::as_str)
984 .is_none_or(|got| got == ns)
985 })
986 .collect();
987 Ok((total, kept))
988 });
989 match built {
990 Ok((total, rows)) => ok_json(json!({"ok": true, "total_ops": total, "grains": rows})),
991 Err(e) => ok_json(json!({"ok": false, "error": e})),
992 }
993 }
994 ("GET", "/api/namespaces") => {
995 let result = self.facade.with_store(|m| m.namespaces());
1000 match result {
1001 Ok(list) => {
1002 let authz = self.facade.authz();
1003 let owner = authz.is_owner();
1004 let arr: Vec<Value> = list
1005 .into_iter()
1006 .filter(|(ns, _)| {
1007 owner || authz.check(areev_core::authz::Verb::Read, ns).is_ok()
1008 })
1009 .map(|(ns, n)| json!({"namespace": ns, "count": n}))
1010 .collect();
1011 ok_json(json!({"ok": true, "namespaces": arr}))
1012 }
1013 Err(e) => ok_json(json!({"ok": false, "error": e.to_string()})),
1014 }
1015 }
1016 ("GET", "/api/grain") => {
1017 let hash = q("hash").unwrap_or_default();
1018 match areev_core::error::Hash::from_hex(&hash)
1019 .and_then(|h| self.facade.get(&h))
1020 {
1021 Ok(g) => ok_json(json!({
1022 "hash": g.hash.to_hex(),
1023 "type": format!("{:?}", g.grain_type).to_lowercase(),
1024 "fields": g.fields,
1025 })),
1026 Err(e) => ok_json(json!({"ok": false, "error": e.to_string()})),
1027 }
1028 }
1029 ("GET", "/api/anon/preview") => {
1030 let hash = q("hash").unwrap_or_default();
1033 match areev_core::error::Hash::from_hex(&hash)
1034 .and_then(|h| self.facade.with_store(|m| m.anon_preview(&h)))
1035 {
1036 Ok(g) => ok_json(json!({
1037 "hash": g.hash.to_hex(),
1038 "type": format!("{:?}", g.grain_type).to_lowercase(),
1039 "fields": g.fields,
1040 })),
1041 Err(e) => ok_json(json!({"ok": false, "error": e.to_string()})),
1042 }
1043 }
1044 ("GET", "/api/verify") => match self.facade.with_store(|m| m.verify()) {
1045 Ok(r) => ok_json(json!({
1046 "integrity": r.integrity, "grains": r.grains,
1047 "hash_mismatches": r.hash_mismatches, "undecodable": r.undecodable,
1048 })),
1049 Err(e) => ok_json(json!({"ok": false, "error": e.to_string()})),
1050 },
1051 ("POST", "/api/segment") => {
1052 if let Err(e) = self
1058 .facade
1059 .authz()
1060 .check(areev_core::authz::Verb::Write, "*")
1061 {
1062 return ("403 Forbidden", "application/json",
1063 json!({"ok": false, "error": e.to_string()}).to_string().into_bytes());
1064 }
1065 let Some(dir) = &self.segment_dir else {
1067 return ("400 Bad Request", "application/json",
1068 br#"{"ok":false,"error":"not in hub mode"}"#.to_vec());
1069 };
1070 let name = q("name").unwrap_or_else(|| format!("push-{}.mgb", now_label()));
1071 let Some(safe) = safe_segment_name(&name) else {
1072 return ("400 Bad Request", "application/json",
1073 br#"{"ok":false,"error":"invalid segment name"}"#.to_vec());
1074 };
1075 let path = format!("{dir}/{safe}");
1076 if std::fs::write(&path, body).is_err() {
1077 return ("500 Internal Server Error", "application/json",
1078 br#"{"ok":false,"error":"write failed"}"#.to_vec());
1079 }
1080 match self.facade.with_store(|m| m.import_bundle(&path)) {
1081 Ok(st) => ok_json(json!({"ok": true, "applied": st.applied, "skipped": st.skipped, "stored": safe})),
1082 Err(e) => ok_json(json!({"ok": false, "error": e.to_string()})),
1083 }
1084 }
1085 ("GET", "/api/segments") | ("GET", "/api/segment") if !self.session_may_sync() => (
1086 "403 Forbidden",
1087 "application/json",
1088 br#"{"ok":false,"error":"AUT-E001: this session lacks read on the whole memory"}"#.to_vec(),
1089 ),
1090 ("GET", "/api/segments") => {
1091 let Some(dir) = &self.segment_dir else {
1092 return ok_json(json!([]));
1093 };
1094 let mut names: Vec<String> = std::fs::read_dir(dir)
1095 .map(|d| d.filter_map(|e| e.ok().map(|e| e.file_name().to_string_lossy().to_string()))
1096 .filter(|n| n.ends_with(".mgb")).collect())
1097 .unwrap_or_default();
1098 names.sort();
1099 ok_json(json!(names))
1100 }
1101 ("GET", "/api/segment") => {
1102 let Some(dir) = &self.segment_dir else {
1103 return ("400 Bad Request", "application/json", b"{}".to_vec());
1104 };
1105 let name = q("name").unwrap_or_default();
1106 let Some(safe) = safe_segment_name(&name) else {
1107 return ("400 Bad Request", "application/json",
1108 br#"{"ok":false,"error":"invalid segment name"}"#.to_vec());
1109 };
1110 match std::fs::read(format!("{dir}/{safe}")) {
1111 Ok(b) => ("200 OK", "application/octet-stream", b),
1112 Err(_) => ("404 Not Found", "application/json",
1113 br#"{"ok":false,"error":"no such segment"}"#.to_vec()),
1114 }
1115 }
1116 ("GET", "/api/run/list") => {
1120 let runner = self.runner("console:read");
1121 match runner.recent_runs(50) {
1122 Ok(ids) => {
1123 let rows: Vec<Value> = ids
1124 .iter()
1125 .map(|id| {
1126 let outcome = self.facade.with_store(|m| {
1127 m.recent(
1128 "agent:harness",
1129 Some(areev_core::types::GrainType::Observation),
1130 4096,
1131 )
1132 })
1133 .ok()
1134 .and_then(|rows| {
1135 rows.into_iter().find(|g| {
1136 g.get_str("run_id") == Some(id.as_str())
1137 && g.get_str("observation_kind")
1138 == Some("run_outcome")
1139 })
1140 });
1141 serde_json::json!({
1142 "run_id": id,
1143 "outcome": outcome.as_ref().and_then(|g| g.get_str("object")).unwrap_or("open"),
1144 })
1145 })
1146 .collect();
1147 ok_json(serde_json::json!({"ok": true, "runs": rows}))
1148 }
1149 Err(e) => run_api_err(&e.to_string()),
1150 }
1151 }
1152 ("GET", "/api/run/inspect") => {
1153 let Some(run_id) = q("run_id") else {
1154 return run_api_err("run_id required");
1155 };
1156 match self.runner("console:read").inspect(&run_id) {
1160 Ok(report) => {
1161 let mut v = serde_json::to_value(&report).unwrap_or(Value::Null);
1162 if let Some(o) = v.as_object_mut() {
1163 o.insert("ok".into(), Value::Bool(true));
1164 }
1165 ok_json(v)
1166 }
1167 Err(e) => run_api_err(&e.to_string()),
1168 }
1169 }
1170 ("POST", "/api/run/respond") => {
1171 let Some(responder) = request_principal.clone() else {
1176 return ("403 Forbidden", "application/json",
1177 br#"{"ok":false,"error":"run.respond requires a per-principal credential (areev ui --auth <map>); shared-token or anonymous approvals are refused - the approver's identity IS the audit record"}"#.to_vec());
1178 };
1179 let req: Value = serde_json::from_slice(body).unwrap_or(Value::Null);
1180 let (Some(run_id), Some(ask)) = (
1181 req.get("run_id").and_then(Value::as_str),
1182 req.get("tool_call_id").and_then(Value::as_str),
1183 ) else {
1184 return run_api_err("run_id and tool_call_id required");
1185 };
1186 let result = req.get("result").cloned().unwrap_or(Value::Null);
1187 let is_error = req.get("is_error").and_then(Value::as_bool).unwrap_or(false);
1188 let runner = self.runner(&responder);
1189 match runner.respond(run_id, ask, result, is_error, &responder) {
1190 Ok(()) => ok_json(serde_json::json!({
1191 "ok": true, "responded": ask, "responder": responder,
1192 "note": "resume the run to spend the compute",
1193 })),
1194 Err(e) => run_api_err(&e.to_string()),
1195 }
1196 }
1197 ("POST", "/api/run/cancel") => {
1198 let req: Value = serde_json::from_slice(body).unwrap_or(Value::Null);
1201 let Some(run_id) = req.get("run_id").and_then(Value::as_str) else {
1202 return run_api_err("run_id required");
1203 };
1204 let because = req.get("because").and_then(Value::as_str).unwrap_or("canceled via console");
1205 let who = request_principal.clone().unwrap_or_else(|| "console:shared".into());
1206 let runner = self.runner(&who);
1207 match runner.cancel(run_id, &who, because) {
1208 Ok(()) => ok_json(serde_json::json!({"ok": true, "canceled": run_id})),
1209 Err(e) => run_api_err(&e.to_string()),
1210 }
1211 }
1212 ("GET", "/api/loop/recommendations") => {
1213 let status = q("status").and_then(|s| status_from_str(&s));
1214 let sub = BorrowedSubstrate::new(&self.facade);
1215 match self.engine().recommendations(&sub, status) {
1216 Ok(recs) => ok_json(json!({
1217 "ok": true,
1218 "recommendations": recs.iter().map(rec_json).collect::<Vec<_>>(),
1219 })),
1220 Err(e) => ok_json(json!({"ok": false, "error": e.to_string(), "code": e.code()})),
1221 }
1222 }
1223 ("GET", "/api/loop/health") => {
1224 let sub = BorrowedSubstrate::new(&self.facade);
1225 match self.engine().health(&sub, now_ms()) {
1226 Ok(h) => ok_json(json!({"ok": true, "health": h})),
1227 Err(e) => ok_json(json!({"ok": false, "error": e.to_string(), "code": e.code()})),
1228 }
1229 }
1230 ("GET", "/api/loop/outcomes") => {
1231 let sub = BorrowedSubstrate::new(&self.facade);
1232 match self.engine().outcomes(&sub) {
1233 Ok(o) => ok_json(json!({"ok": true, "outcomes": o})),
1234 Err(e) => ok_json(json!({"ok": false, "error": e.to_string(), "code": e.code()})),
1235 }
1236 }
1237 ("GET", "/api/loop/analyzers") => {
1238 let sub = BorrowedSubstrate::new(&self.facade);
1241 match self.engine().analyzer_settings(&sub) {
1242 Ok(list) => ok_json(json!({"ok": true, "analyzers": list})),
1243 Err(e) => ok_json(json!({"ok": false, "error": e.to_string(), "code": e.code()})),
1244 }
1245 }
1246 ("GET", "/api/loop/telemetry") => {
1247 let mode = self.facade.with_store(|m| m.telemetry_mode());
1250 if mode == areev_store::TelemetryMode::Off {
1251 ok_json(json!({"ok": true, "enabled": false}))
1252 } else {
1253 let access = self.facade.with_store(|m| m.telemetry_access_stats(None));
1254 let queries = self.facade.with_store(|m| m.telemetry_query_stats(None));
1255 let budget = self.facade.with_store(|m| m.telemetry_budget_stats());
1256 match (access, queries, budget) {
1257 (Ok(mut a), Ok(mut q), Ok(b)) => {
1258 a.sort_by_key(|x| std::cmp::Reverse(x.recall_count));
1260 a.truncate(200);
1261 q.sort_by_key(|x| std::cmp::Reverse(x.run_count));
1262 q.truncate(200);
1263 ok_json(json!({
1264 "ok": true,
1265 "enabled": true,
1266 "mode": mode.as_str(),
1267 "access": a.iter().map(|x| json!({
1268 "hash": x.hash, "recall_count": x.recall_count, "last_ms": x.last_ms,
1269 })).collect::<Vec<_>>(),
1270 "queries": q.iter().map(|x| json!({
1271 "sample": x.sample, "run_count": x.run_count,
1272 "empty_count": x.empty_count,
1273 })).collect::<Vec<_>>(),
1274 "budget": {
1275 "sample_count": b.sample_count,
1276 "overflow_count": b.overflow_count,
1277 },
1278 }))
1279 }
1280 _ => ok_json(json!({"ok": false, "error": "telemetry read failed"})),
1281 }
1282 }
1283 }
1284 ("POST", "/api/loop/run") => {
1285 let authz = self.facade.authz();
1286 if !authz.is_owner()
1287 && !authz.allows(areev_core::authz::Verb::LoopRun, areev_loop::LOOP_NS)
1288 {
1289 return ("403 Forbidden", "application/json",
1290 br#"{"ok":false,"error":"AUT-E001: this session lacks loop.run"}"#.to_vec());
1291 }
1292 let mut sub = BorrowedSubstrate::new(&self.facade);
1293 let actor = if self.credentials.is_some() {
1298 authz.principal().to_string()
1299 } else {
1300 serde_json::from_slice::<Value>(body)
1301 .ok()
1302 .and_then(|v| v.get("actor").and_then(Value::as_str).map(str::to_string))
1303 .unwrap_or_else(|| "user:console".to_string())
1304 };
1305 let opts = RunOptions { triggering_actor: Some(actor), ..Default::default() };
1306 match self.engine().run(&mut sub, &opts, now_ms()) {
1307 Ok(res) => ok_json(json!({"ok": true, "run": res})),
1308 Err(e) => ok_json(json!({"ok": false, "error": e.to_string(), "code": e.code()})),
1309 }
1310 }
1311 ("POST", "/api/loop/review") => self.loop_review(body),
1312 ("POST", "/api/loop/apply") => self.loop_apply(body),
1313 ("POST", "/api/loop/rollback") => self.loop_rollback(body),
1314 ("POST", "/api/loop/config") => self.loop_config(body),
1315 ("POST", "/api/anon/config") => self.anon_config(body),
1316 _ => (
1317 "404 Not Found",
1318 "application/json",
1319 br#"{"ok":false,"error":"not found"}"#.to_vec(),
1320 ),
1321 }
1322 }
1323
1324 fn loop_review(&self, body: &[u8]) -> (&'static str, &'static str, Vec<u8>) {
1329 let req: Value = serde_json::from_slice(body).unwrap_or(Value::Null);
1330 let hash = req.get("hash").and_then(Value::as_str).unwrap_or("");
1331 let because = req.get("because").and_then(Value::as_str).unwrap_or("");
1332 let bound = self.facade.authz();
1333 let actor = if self.credentials.is_some() {
1334 bound.principal().to_string()
1335 } else {
1336 req.get("actor").and_then(Value::as_str).unwrap_or("user:console").to_string()
1337 };
1338 let actor = actor.as_str();
1339 let decision = if req.get("decision").and_then(Value::as_str) == Some("reject") {
1340 Decision::Reject
1341 } else {
1342 Decision::Approve
1343 };
1344 let mut sub = BorrowedSubstrate::new(&self.facade);
1345 let scopes = areev_loop_adapter::scopes_for(&self.facade.authz());
1346 let observer = areev_loop_adapter::observer_for_principal(actor);
1347 match self.engine().review(
1348 &mut sub, hash, decision, actor, observer, &scopes, because, now_ms(),
1349 ) {
1350 Ok(()) => ok_json(json!({"ok": true})),
1351 Err(e) => ok_json(json!({"ok": false, "error": e.to_string(), "code": e.code()})),
1352 }
1353 }
1354
1355 fn loop_apply(&self, body: &[u8]) -> (&'static str, &'static str, Vec<u8>) {
1356 let req: Value = serde_json::from_slice(body).unwrap_or(Value::Null);
1357 let hash = req.get("hash").and_then(Value::as_str).unwrap_or("");
1358 let because = req.get("because").and_then(Value::as_str).unwrap_or("");
1359 let bound = self.facade.authz();
1360 let actor = if self.credentials.is_some() {
1361 bound.principal().to_string()
1362 } else {
1363 req.get("actor").and_then(Value::as_str).unwrap_or("user:console").to_string()
1364 };
1365 let actor = actor.as_str();
1366 let allow_destructive = req.get("allow_destructive").and_then(Value::as_bool).unwrap_or(false);
1367 let mut sub = BorrowedSubstrate::new(&self.facade);
1368 let scopes = areev_loop_adapter::scopes_for(&self.facade.authz());
1369 let observer = areev_loop_adapter::observer_for_principal(actor);
1370 let engine = self.engine();
1371 let gating = match req.get("gating_run").and_then(Value::as_str) {
1375 Some(run_id) => match engine.gating_evidence(&sub, hash, run_id) {
1376 Ok(g) => Some(g),
1377 Err(e) => {
1378 return ok_json(json!({"ok": false, "error": e.to_string(), "code": e.code()}))
1379 }
1380 },
1381 None => None,
1382 };
1383 let applied = match &gating {
1384 Some(g) => engine.apply_gated(
1385 &mut sub, hash, actor, observer, &scopes, because, allow_destructive, g, now_ms(),
1386 ),
1387 None => engine.apply(
1388 &mut sub, hash, actor, observer, &scopes, because, allow_destructive, now_ms(),
1389 ),
1390 };
1391 match applied {
1392 Ok(applied) => ok_json(json!({"ok": true, "rollbackable": applied.rollbackable})),
1393 Err(e) => ok_json(json!({"ok": false, "error": e.to_string(), "code": e.code()})),
1394 }
1395 }
1396
1397 fn loop_rollback(&self, body: &[u8]) -> (&'static str, &'static str, Vec<u8>) {
1398 let req: Value = serde_json::from_slice(body).unwrap_or(Value::Null);
1399 let hash = req.get("hash").and_then(Value::as_str).unwrap_or("");
1400 let because = req.get("because").and_then(Value::as_str).unwrap_or("");
1401 let bound = self.facade.authz();
1402 let actor = if self.credentials.is_some() {
1403 bound.principal().to_string()
1404 } else {
1405 req.get("actor").and_then(Value::as_str).unwrap_or("user:console").to_string()
1406 };
1407 let actor = actor.as_str();
1408 let mut sub = BorrowedSubstrate::new(&self.facade);
1409 let scopes = areev_loop_adapter::scopes_for(&self.facade.authz());
1410 let observer = areev_loop_adapter::observer_for_principal(actor);
1411 match self.engine().rollback(
1412 &mut sub, hash, actor, observer, &scopes, because, now_ms(),
1413 ) {
1414 Ok(()) => ok_json(json!({"ok": true})),
1415 Err(e) => ok_json(json!({"ok": false, "error": e.to_string(), "code": e.code()})),
1416 }
1417 }
1418
1419 fn loop_config(&self, body: &[u8]) -> (&'static str, &'static str, Vec<u8>) {
1425 let id = serde_json::from_slice::<Value>(body)
1426 .ok()
1427 .and_then(|v| v.get("analyzer_id").and_then(Value::as_str).map(str::to_string))
1428 .unwrap_or_default();
1429 let update: areev_loop::AnalyzerConfigUpdate = serde_json::from_slice(body).unwrap_or_default();
1431 let mut sub = BorrowedSubstrate::new(&self.facade);
1432 match self.engine().set_analyzer_config(&mut sub, &id, update, &areev_loop_adapter::scopes_for(&self.facade.authz())) {
1433 Ok(cfg) => ok_json(json!({"ok": true, "config": cfg})),
1434 Err(e) => ok_json(json!({"ok": false, "error": e.to_string(), "code": e.code()})),
1435 }
1436 }
1437}
1438
1439impl UiServer {
1440 fn anon_config(&self, body: &[u8]) -> (&'static str, &'static str, Vec<u8>) {
1445 let v: Value = match serde_json::from_slice(body) {
1446 Ok(v) => v,
1447 Err(e) => return ok_json(json!({"ok": false, "error": e.to_string()})),
1448 };
1449 let Some(ns) = v.get("ns").and_then(Value::as_str).filter(|n| !n.trim().is_empty())
1450 else {
1451 return ok_json(json!({"ok": false, "error": "ns is required"}));
1452 };
1453 let result = if v.get("clear").and_then(Value::as_bool) == Some(true) {
1454 self.facade.with_store(|m| m.clear_anon_policy(ns))
1455 } else {
1456 match v.get("policy") {
1457 Some(policy) => {
1458 let policy_json = policy.to_string();
1459 self.facade.with_store(|m| m.set_anon_policy(ns, &policy_json))
1460 }
1461 None => {
1462 return ok_json(json!({"ok": false, "error": "policy (object) or clear: true is required"}));
1463 }
1464 }
1465 };
1466 match result {
1467 Ok(()) => {
1468 let declared = self.facade.with_store(|m| m.anon_declared());
1469 ok_json(json!({"ok": true, "policies": declared
1470 .iter()
1471 .map(|(ns, mode)| json!({"ns": ns, "mode": mode}))
1472 .collect::<Vec<_>>()}))
1473 }
1474 Err(e) => ok_json(json!({"ok": false, "error": e.to_string()})),
1475 }
1476 }
1477}
1478
1479fn host_is_local(host_port: &str) -> bool {
1483 let host = if let Some(h) = host_port.strip_prefix('[') {
1484 h.split(']').next().unwrap_or("")
1485 } else {
1486 host_port.split(':').next().unwrap_or("")
1487 };
1488 matches!(host, "127.0.0.1" | "localhost" | "::1")
1489}
1490
1491fn origin_is_local(origin: &str) -> bool {
1493 let rest = origin
1494 .strip_prefix("http://")
1495 .or_else(|| origin.strip_prefix("https://"));
1496 let Some(rest) = rest else { return false };
1497 let host_port = rest.split('/').next().unwrap_or("");
1498 host_is_local(host_port)
1499}
1500
1501fn now_label() -> String {
1502 format!("{}", std::time::SystemTime::now()
1503 .duration_since(std::time::UNIX_EPOCH)
1504 .map(|d| d.as_millis())
1505 .unwrap_or(0))
1506}
1507
1508fn ok_json(v: Value) -> (&'static str, &'static str, Vec<u8>) {
1509 ("200 OK", "application/json", v.to_string().into_bytes())
1510}
1511
1512fn run_api_err(msg: &str) -> (&'static str, &'static str, Vec<u8>) {
1513 (
1514 "400 Bad Request",
1515 "application/json",
1516 serde_json::json!({"ok": false, "error": msg}).to_string().into_bytes(),
1517 )
1518}
1519
1520fn cal_body_is_read_only(body: &[u8]) -> bool {
1527 let req: Value = serde_json::from_slice(body).unwrap_or(Value::Null);
1528 let q = req.get("query").and_then(Value::as_str).unwrap_or("");
1529 areev_cal::classify::query_is_read_only(q)
1530}
1531
1532fn status_from_str(s: &str) -> Option<RecStatus> {
1533 match s {
1534 "pending" => Some(RecStatus::Pending),
1535 "approved" => Some(RecStatus::Approved),
1536 "rejected" => Some(RecStatus::Rejected),
1537 "applied" => Some(RecStatus::Applied),
1538 "rolled_back" => Some(RecStatus::RolledBack),
1539 "expired" => Some(RecStatus::Expired),
1540 _ => None, }
1542}
1543
1544fn rec_json(r: &areev_loop::Recommendation) -> Value {
1545 json!({
1546 "hash": r.hash,
1547 "status": r.status.as_str(),
1548 "severity": r.severity.as_str(),
1549 "analyzer": r.analyzer,
1550 "summary": r.summary.render(),
1551 "target_ref": r.target_ref,
1552 "destructive": r.destructive,
1553 "rollbackable": r.rollbackable,
1554 "evidence": r.evidence,
1555 "evalset_hash": r.evalset_hash,
1558 })
1559}
1560
1561fn urldecode(s: &str) -> String {
1562 let mut out = Vec::new();
1563 let b = s.as_bytes();
1564 let mut i = 0;
1565 while i < b.len() {
1566 match b[i] {
1567 b'%' if i + 2 < b.len() + 1 && i + 2 < b.len() + 1 => {
1568 if i + 2 < b.len() + 1 && i + 2 < b.len() {
1569 let hex = std::str::from_utf8(&b[i + 1..i + 3]).unwrap_or("");
1570 if let Ok(v) = u8::from_str_radix(hex, 16) {
1571 out.push(v);
1572 i += 3;
1573 continue;
1574 }
1575 }
1576 out.push(b[i]);
1577 i += 1;
1578 }
1579 b'+' => {
1580 out.push(b' ');
1581 i += 1;
1582 }
1583 c => {
1584 out.push(c);
1585 i += 1;
1586 }
1587 }
1588 }
1589 String::from_utf8_lossy(&out).to_string()
1590}
1591
1592fn html_escape(s: &str) -> String {
1593 s.replace('&', "&").replace('<', "<").replace('>', ">")
1594}
1595
1596fn ct_eq(a: &[u8], b: &[u8]) -> bool {
1599 if a.len() != b.len() {
1600 return false;
1601 }
1602 let mut diff = 0u8;
1603 for (x, y) in a.iter().zip(b.iter()) {
1604 diff |= x ^ y;
1605 }
1606 diff == 0
1607}
1608
1609fn base64_decode(s: &str) -> Option<Vec<u8>> {
1613 fn val(c: u8) -> Option<u32> {
1614 match c {
1615 b'A'..=b'Z' => Some((c - b'A') as u32),
1616 b'a'..=b'z' => Some((c - b'a' + 26) as u32),
1617 b'0'..=b'9' => Some((c - b'0' + 52) as u32),
1618 b'+' => Some(62),
1619 b'/' => Some(63),
1620 _ => None,
1621 }
1622 }
1623 let s = s.trim().trim_end_matches('=');
1624 let mut out = Vec::with_capacity(s.len() * 3 / 4);
1625 let (mut acc, mut bits) = (0u32, 0u32);
1626 for &c in s.as_bytes() {
1627 acc = (acc << 6) | val(c)?;
1628 bits += 6;
1629 if bits >= 8 {
1630 bits -= 8;
1631 out.push((acc >> bits) as u8);
1632 }
1633 }
1634 Some(out)
1635}
1636
1637fn basic_auth_password(b64: &str) -> Option<String> {
1641 let text = String::from_utf8(base64_decode(b64)?).ok()?;
1642 text.split_once(':').map(|(_user, pass)| pass.to_string())
1643}
1644
1645fn safe_segment_name(name: &str) -> Option<String> {
1650 let safe: String = name
1651 .chars()
1652 .filter(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '.' | '_'))
1653 .collect();
1654 if safe.is_empty() || safe.len() > 128 {
1655 return None;
1656 }
1657 let mut comps = std::path::Path::new(&safe).components();
1658 match (comps.next(), comps.next()) {
1659 (Some(std::path::Component::Normal(c)), None) if c.to_str() == Some(safe.as_str()) => {
1660 Some(safe)
1661 }
1662 _ => None,
1663 }
1664}
1665
1666#[cfg(test)]
1667mod loop_route_tests {
1668 use super::UiServer;
1669 use areev_cal::AreevFacade;
1670 use areev_core::types::{Fact, Grain};
1671 use areev_store::Areev;
1672
1673 fn server(auth: Option<&str>) -> UiServer {
1674 let dir = tempfile::tempdir().unwrap();
1675 let path = dir.path().join("t.db");
1676 let mut store = Areev::open(path.to_str().unwrap()).unwrap();
1677 store.add(&Fact::new("acme", "tier", "Enterprise").namespace("caller")).unwrap();
1679 store.add(&Fact::new("acme", "tier", "Enterprise").namespace("caller")).unwrap();
1680 std::mem::forget(dir); let facade = AreevFacade::with_session(store, Some("caller".into()), None);
1682 let s = UiServer::new(facade, "test".into());
1683 match auth {
1684 Some(t) => s.with_auth(t.to_string()),
1685 None => s,
1686 }
1687 }
1688
1689 fn text(r: &(&str, &str, Vec<u8>)) -> String {
1690 String::from_utf8_lossy(&r.2).to_string()
1691 }
1692
1693 #[test]
1694 fn token_less_console_is_read_only() {
1695 let s = server(None);
1696 assert!(s.route("POST", "/api/loop/run", b"{}", None, None).0.starts_with("401"));
1698 let w = s.route("POST", "/api/cal", br#"{"query":"ADD fact SET subject=\"x\""}"#, None, None);
1700 assert!(w.0.starts_with("401"), "write CAL must be 401: {}", text(&w));
1701 let r = s.route("POST", "/api/cal", br#"{"query":"RECALL facts WHERE subject = \"acme\""}"#, None, None);
1703 assert!(r.0.starts_with("200"), "read CAL allowed: {}", text(&r));
1704 assert!(s.route("GET", "/api/loop/recommendations", b"", None, None).0.starts_with("200"));
1706 }
1707
1708 #[test]
1709 fn telemetry_endpoint_is_an_open_read() {
1710 let s = server(None);
1711 let r = s.route("GET", "/api/loop/telemetry", b"", None, None);
1712 assert!(r.0.starts_with("200"), "telemetry read open: {}", text(&r));
1713 let body = text(&r);
1714 assert!(body.contains("\"ok\":true"), "{body}");
1715 assert!(body.contains("\"enabled\":false"), "{body}");
1717 }
1718
1719 #[test]
1720 fn authenticated_run_review_apply_roundtrip() {
1721 let s = server(Some("tok"));
1722 let run = s.route("POST", "/api/loop/run", b"{}", Some("tok"), None);
1723 assert!(run.0.starts_with("200"), "run: {}", text(&run));
1724 assert!(text(&run).contains("\"ran\""));
1725
1726 let list = s.route("GET", "/api/loop/recommendations?status=pending", b"", Some("tok"), None);
1727 let v: serde_json::Value = serde_json::from_slice(&list.2).unwrap();
1728 let recs = v["recommendations"].as_array().unwrap();
1729 assert!(!recs.is_empty(), "at least one recommendation");
1730 let hash = recs[0]["hash"].as_str().unwrap().to_string();
1731
1732 let rev = s.route(
1733 "POST",
1734 "/api/loop/review",
1735 format!(r#"{{"hash":"{hash}","decision":"approve","because":"ok"}}"#).as_bytes(),
1736 Some("tok"),
1737 None,
1738 );
1739 assert!(text(&rev).contains("\"ok\":true"), "review: {}", text(&rev));
1740
1741 let ap = s.route(
1742 "POST",
1743 "/api/loop/apply",
1744 format!(r#"{{"hash":"{hash}","because":"go"}}"#).as_bytes(),
1745 Some("tok"),
1746 None,
1747 );
1748 assert!(text(&ap).contains("\"ok\":true"), "apply: {}", text(&ap));
1749 }
1750
1751 #[test]
1752 fn config_edit_toggles_analyzer_via_console() {
1753 let s = server(Some("tok"));
1754 assert!(s.route("POST", "/api/loop/config", b"{}", None, None).0.starts_with("401"));
1756
1757 let list = s.route("GET", "/api/loop/analyzers", b"", Some("tok"), None);
1759 let v: serde_json::Value = serde_json::from_slice(&list.2).unwrap();
1760 let id = v["analyzers"]
1761 .as_array()
1762 .unwrap()
1763 .iter()
1764 .find(|x| x["enabled"] == true)
1765 .expect("some analyzer is on")["id"]
1766 .as_str()
1767 .unwrap()
1768 .to_string();
1769
1770 let post = s.route(
1772 "POST",
1773 "/api/loop/config",
1774 format!(r#"{{"analyzer_id":"{id}","enabled":false}}"#).as_bytes(),
1775 Some("tok"),
1776 None,
1777 );
1778 assert!(text(&post).contains("\"ok\":true"), "config: {}", text(&post));
1779
1780 let list2 = s.route("GET", "/api/loop/analyzers", b"", Some("tok"), None);
1782 let v2: serde_json::Value = serde_json::from_slice(&list2.2).unwrap();
1783 let now = v2["analyzers"]
1784 .as_array()
1785 .unwrap()
1786 .iter()
1787 .find(|x| x["id"] == id.as_str())
1788 .unwrap();
1789 assert_eq!(now["enabled"], false, "toggled off and persisted");
1790 }
1791
1792 #[test]
1793 fn self_approval_surfaces_lop_code() {
1794 let s = server(Some("tok"));
1795 s.route("POST", "/api/loop/run", b"{}", Some("tok"), None);
1796 let list = s.route("GET", "/api/loop/recommendations?status=pending", b"", Some("tok"), None);
1797 let v: serde_json::Value = serde_json::from_slice(&list.2).unwrap();
1798 let rec = &v["recommendations"].as_array().unwrap()[0];
1799 let hash = rec["hash"].as_str().unwrap();
1800 let analyzer = rec["analyzer"].as_str().unwrap();
1801 let creator = format!("engine:{analyzer}");
1802 let rev = s.route(
1804 "POST",
1805 "/api/loop/review",
1806 format!(r#"{{"hash":"{hash}","decision":"approve","because":"self","actor":"{creator}"}}"#).as_bytes(),
1807 Some("tok"),
1808 None,
1809 );
1810 assert!(text(&rev).contains("LOP-E021"), "self-approval blocked: {}", text(&rev));
1811 }
1812}
1813
1814#[cfg(test)]
1815mod security_tests {
1816 use super::{base64_decode, basic_auth_password, ct_eq, safe_segment_name};
1817
1818 #[test]
1819 fn ct_eq_matches_only_equal() {
1820 assert!(ct_eq(b"secret-token", b"secret-token"));
1821 assert!(!ct_eq(b"secret-token", b"secret-toker"));
1822 assert!(!ct_eq(b"secret", b"secret-token")); assert!(ct_eq(b"", b""));
1824 }
1825
1826 #[test]
1827 fn base64_decode_roundtrips_known_vectors() {
1828 assert_eq!(base64_decode("").unwrap(), b"");
1830 assert_eq!(base64_decode("Zg==").unwrap(), b"f");
1831 assert_eq!(base64_decode("Zm8=").unwrap(), b"fo");
1832 assert_eq!(base64_decode("Zm9v").unwrap(), b"foo");
1833 assert_eq!(base64_decode("Zm9vYmFy").unwrap(), b"foobar");
1834 assert_eq!(base64_decode("Zg").unwrap(), b"f");
1836 assert!(base64_decode("****").is_none());
1838 }
1839
1840 #[test]
1841 fn basic_auth_extracts_password_ignoring_username() {
1842 assert_eq!(basic_auth_password("ZGVqYTpzM2NyZXQ=").as_deref(), Some("s3cret"));
1844 assert_eq!(basic_auth_password("OnMzY3JldA==").as_deref(), Some("s3cret"));
1845 assert_eq!(basic_auth_password("dTphOmI=").as_deref(), Some("a:b"));
1848 assert_eq!(basic_auth_password("bm9jb2xvbg=="), None); assert_eq!(basic_auth_password("****"), None); }
1852
1853 #[test]
1854 fn safe_segment_name_blocks_traversal() {
1855 assert_eq!(safe_segment_name("push-123.mgb").as_deref(), Some("push-123.mgb"));
1856 assert_eq!(safe_segment_name(".."), None);
1857 assert_eq!(safe_segment_name("."), None);
1858 assert_eq!(safe_segment_name(""), None);
1859 for probe in ["../../etc/passwd", "/etc/passwd", "a/../b", "foo/bar"] {
1862 if let Some(s) = safe_segment_name(probe) {
1863 assert!(!s.contains('/') && s != ".." && s != ".", "unsafe: {s}");
1864 }
1865 }
1866 }
1867}
1868
1869#[cfg(test)]
1870mod credential_route_tests {
1871 use super::UiServer;
1872 use areev_cal::AreevFacade;
1873 use areev_core::authz::{CredentialMap, AUTHZ_NS, REL_PERMITS};
1874 use areev_core::types::{Fact, Grain};
1875 use areev_store::Areev;
1876
1877 #[test]
1882 fn loop_apply_honors_a_gating_run() {
1883 let dir = tempfile::tempdir().unwrap();
1884 let path = dir.path().join("g.db");
1885 let mut store = Areev::open(path.to_str().unwrap()).unwrap();
1886 let evalset = store
1888 .add(
1889 &Fact::new("evalset:gate", "mg:evalset", "{\"name\":\"gate\",\"cases\":[]}")
1890 .namespace("agent:harness")
1891 .created_at(1_000),
1892 )
1893 .unwrap()
1894 .to_hex();
1895 store
1896 .add(
1897 &Fact::new(
1898 &format!("evalset:{evalset}"),
1899 "mg:eval_run",
1900 "{\"run_id\":\"eval-1\",\"passed\":3,\"failed\":0}",
1901 )
1902 .namespace("agent:harness")
1903 .created_at(2_000),
1904 )
1905 .unwrap();
1906 store.add(&Fact::new("acme", "tier", "Enterprise").namespace("caller").created_at(3_000)).unwrap();
1907 std::mem::forget(dir);
1908 let facade = AreevFacade::with_session(store, Some("caller".into()), None);
1909 let manifest = facade
1911 .record_corpus_export("RECALL facts", "train.jsonl", None, 4_000, &[], &[])
1912 .unwrap()
1913 .to_hex();
1914 facade
1915 .record_adapter(
1916 r#"{"adapter":{"uri":"file:///tmp/a","sha256":"feed"},"base_model":"qwen3-4b","serves_as":"acme-support"}"#,
1917 &manifest,
1918 &evalset,
1919 5_000,
1920 )
1921 .unwrap();
1922 let s = UiServer::new(facade, "test".into()).with_auth("gate-token".into());
1925 let tok = Some("gate-token");
1926
1927 let run = s.route("POST", "/api/loop/run", b"{}", tok, None);
1929 assert!(run.0.starts_with("200"), "{}", text(&run));
1930 let list = s.route("GET", "/api/loop/recommendations?status=pending", b"", tok, None);
1931 let rows: serde_json::Value = serde_json::from_str(&text(&list)).unwrap();
1932 let rec = rows["recommendations"]
1933 .as_array()
1934 .unwrap()
1935 .iter()
1936 .find(|r| r["analyzer"].as_str().unwrap_or("").contains("adapter_intake"))
1937 .unwrap_or_else(|| panic!("adapter_intake must propose: {rows}"));
1938 assert_eq!(rec["evalset_hash"], evalset.as_str(), "the row must carry the pin");
1939 let hash = rec["hash"].as_str().unwrap().to_string();
1940
1941 let approve = format!(r#"{{"hash":"{hash}","decision":"approve","because":"reviewed"}}"#);
1944 let r = s.route("POST", "/api/loop/review", approve.as_bytes(), tok, None);
1945 assert!(text(&r).contains("\"ok\":true"), "{}", text(&r));
1946 let bare = format!(r#"{{"hash":"{hash}","because":"ship it"}}"#);
1947 let r = s.route("POST", "/api/loop/apply", bare.as_bytes(), tok, None);
1948 assert!(text(&r).contains("gating run"), "ungated must refuse: {}", text(&r));
1949 let bad = format!(r#"{{"hash":"{hash}","because":"ship","gating_run":"eval-nope"}}"#);
1950 let r = s.route("POST", "/api/loop/apply", bad.as_bytes(), tok, None);
1951 assert!(text(&r).contains("no recorded gate run"), "{}", text(&r));
1952 let good = format!(r#"{{"hash":"{hash}","because":"gated and green","gating_run":"eval-1"}}"#);
1953 let r = s.route("POST", "/api/loop/apply", good.as_bytes(), tok, None);
1954 assert!(text(&r).contains("\"ok\":true"), "gated apply failed: {}", text(&r));
1955 }
1956
1957 fn server() -> UiServer {
1960 std::env::set_var("AREEV_TEST_READER_TOK", "reader-secret");
1961 std::env::set_var("AREEV_TEST_WRITER_TOK", "writer-secret");
1962 let dir = tempfile::tempdir().unwrap();
1963 let path = dir.path().join("t.db");
1964 let mut store = Areev::open(path.to_str().unwrap()).unwrap();
1965 store
1966 .add(&Fact::new("user:reader", REL_PERMITS, "read ON *").namespace(AUTHZ_NS).created_at(1_000))
1967 .unwrap();
1968 store
1969 .add(
1970 &Fact::new("agent:writer", REL_PERMITS, "read,write ON caller")
1971 .namespace(AUTHZ_NS)
1972 .created_at(2_000),
1973 )
1974 .unwrap();
1975 store.add(&Fact::new("acme", "tier", "Enterprise").namespace("caller").created_at(3_000)).unwrap();
1976 std::mem::forget(dir);
1977 let facade = AreevFacade::with_session(store, Some("caller".into()), None);
1978 let map = CredentialMap::from_json(
1979 r#"{"version":1,"tokens":[
1980 {"env":"AREEV_TEST_READER_TOK","principal":"user:reader"},
1981 {"env":"AREEV_TEST_WRITER_TOK","principal":"agent:writer"}
1982 ]}"#,
1983 )
1984 .unwrap();
1985 UiServer::new(facade, "test".into()).with_credentials(map)
1986 }
1987
1988 fn text(r: &(&str, &str, Vec<u8>)) -> String {
1989 String::from_utf8_lossy(&r.2).to_string()
1990 }
1991
1992 const READ: &[u8] = br#"{"query":"RECALL facts WHERE subject = \"acme\""}"#;
1993 const WRITE: &[u8] = br#"{"query":"ADD fact SET subject = \"x\" SET relation = \"r\" SET object = \"o\" SET namespace = \"caller\" REASON \"t\""}"#;
1994
1995 #[test]
1996 fn tokens_bind_principals_and_verbs_decide() {
1997 let s = server();
1998
1999 let r = s.route("POST", "/api/cal", READ, Some("reader-secret"), None);
2001 assert!(r.0.starts_with("200"), "{}", text(&r));
2002 assert!(text(&r).contains("grains"));
2003 let w = s.route("POST", "/api/cal", WRITE, Some("reader-secret"), None);
2005 assert!(text(&w).contains("AUT-E001"), "reader write must be refused: {}", text(&w));
2006
2007 let w = s.route("POST", "/api/cal", WRITE, Some("writer-secret"), None);
2009 assert!(text(&w).contains("added"), "writer write must land: {}", text(&w));
2010
2011 let w = s.route("POST", "/api/cal", WRITE, Some("reader-secret"), None);
2014 assert!(text(&w).contains("AUT-E001"), "{}", text(&w));
2015 }
2016
2017 #[test]
2018 fn anonymous_is_read_only_and_loop_run_is_verb_gated() {
2019 let s = server();
2020 let r = s.route("POST", "/api/cal", READ, None, None);
2022 assert!(r.0.starts_with("200"), "{}", text(&r));
2023 assert!(text(&r).contains("grains"));
2024 let w = s.route("POST", "/api/cal", WRITE, None, None);
2026 assert!(text(&w).contains("AUT-E001"), "{}", text(&w));
2027 let w = s.route("POST", "/api/cal", WRITE, Some("stolen-guess"), None);
2029 assert!(text(&w).contains("AUT-E001"), "{}", text(&w));
2030
2031 let run = s.route("POST", "/api/loop/run", b"{}", None, None);
2033 assert!(run.0.starts_with("403"), "{}", text(&run));
2034 }
2035
2036 #[test]
2037 fn whoami_reports_the_bound_principal() {
2038 let s = server();
2039 let r = s.route("GET", "/api/whoami", b"", Some("reader-secret"), None);
2040 assert!(text(&r).contains("user:reader"), "{}", text(&r));
2041 assert!(text(&r).contains("credentials"));
2042 let r = s.route("GET", "/api/whoami", b"", None, None);
2043 assert!(text(&r).contains("anonymous"), "{}", text(&r));
2044 }
2045
2046 #[test]
2050 fn a_credential_map_does_not_disable_the_shared_secret() {
2051 let s = server().with_auth("console-token".into());
2052
2053 let r = s.route("POST", "/api/cal", READ, None, None);
2055 assert!(r.0.starts_with("401"), "{}", text(&r));
2056 let page = s.route("GET", "/", b"", None, None);
2057 assert!(page.0.starts_with("401"), "the console page is guarded too");
2058 let r = s.route("POST", "/api/cal", READ, Some("stolen-guess"), None);
2060 assert!(r.0.starts_with("401"), "{}", text(&r));
2061
2062 let r = s.route("POST", "/api/cal", READ, Some("reader-secret"), None);
2064 assert!(r.0.starts_with("200"), "{}", text(&r));
2065 let w = s.route("POST", "/api/cal", WRITE, Some("reader-secret"), None);
2066 assert!(text(&w).contains("AUT-E001"), "{}", text(&w));
2067
2068 let w = s.route("POST", "/api/cal", WRITE, Some("console-token"), None);
2071 assert!(text(&w).contains("added"), "{}", text(&w));
2072 }
2073
2074 #[test]
2077 fn run_loop_via_cal_is_verb_gated_like_the_route() {
2078 let s = server();
2079 let run = s.route("POST", "/api/loop/run", b"{}", None, None);
2080 assert!(run.0.starts_with("403"), "{}", text(&run));
2081 let cal = s.route("POST", "/api/cal", br#"{"query":"RUN LOOP"}"#, None, None);
2082 assert!(
2083 text(&cal).contains("AUT-E001"),
2084 "anonymous must not run the loop through CAL either: {}",
2085 text(&cal)
2086 );
2087 }
2088}
2089
2090#[cfg(test)]
2091mod namespace_route_tests {
2092 use super::UiServer;
2093 use areev_cal::AreevFacade;
2094 use areev_core::authz::{CredentialMap, AUTHZ_NS, REL_PERMITS};
2095 use areev_core::types::{Fact, Grain};
2096 use areev_store::Areev;
2097
2098 fn text(r: &(&str, &str, Vec<u8>)) -> String {
2099 String::from_utf8_lossy(&r.2).to_string()
2100 }
2101
2102 fn restricted_server() -> UiServer {
2105 std::env::set_var("AREEV_TEST_NS_WRITER_TOK", "writer-secret");
2106 let dir = tempfile::tempdir().unwrap();
2107 let path = dir.path().join("t.db");
2108 let mut store = Areev::open(path.to_str().unwrap()).unwrap();
2109 store
2110 .add(&Fact::new("agent:writer", REL_PERMITS, "read,write ON caller").namespace(AUTHZ_NS).created_at(1_000))
2111 .unwrap();
2112 store.add(&Fact::new("acme", "tier", "Enterprise").namespace("caller").created_at(2_000)).unwrap();
2113 store.add(&Fact::new("bob", "role", "admin").namespace("other").created_at(3_000)).unwrap();
2114 std::mem::forget(dir);
2115 let facade = AreevFacade::with_session(store, Some("caller".into()), None);
2116 let map = CredentialMap::from_json(
2117 r#"{"version":1,"tokens":[{"env":"AREEV_TEST_NS_WRITER_TOK","principal":"agent:writer"}]}"#,
2118 )
2119 .unwrap();
2120 UiServer::new(facade, "test".into()).with_credentials(map)
2121 }
2122
2123 fn owner_server() -> UiServer {
2126 let dir = tempfile::tempdir().unwrap();
2127 let path = dir.path().join("t.db");
2128 let mut store = Areev::open(path.to_str().unwrap()).unwrap();
2129 store.add(&Fact::new("acme", "tier", "Enterprise").namespace("caller").created_at(1_000)).unwrap();
2130 store.add(&Fact::new("bob", "role", "admin").namespace("other").created_at(2_000)).unwrap();
2131 std::mem::forget(dir);
2132 UiServer::new(AreevFacade::with_session(store, Some("caller".into()), None), "t".into())
2133 }
2134
2135 #[test]
2136 fn namespaces_route_hides_what_the_principal_cannot_read() {
2137 let s = restricted_server();
2138 let r = s.route("GET", "/api/namespaces", b"", Some("writer-secret"), None);
2139 let body = text(&r);
2140 assert!(body.contains("\"caller\""), "granted namespace must be listed: {body}");
2141 assert!(!body.contains("\"other\""), "ungranted namespace must not be listed: {body}");
2142 assert!(!body.contains(AUTHZ_NS), "the audit namespace must not leak either: {body}");
2143 }
2144
2145 #[test]
2146 fn namespaces_route_lists_everything_for_the_owner() {
2147 let s = owner_server();
2148 let r = s.route("GET", "/api/namespaces", b"", None, None);
2149 let body = text(&r);
2150 assert!(body.contains("\"caller\"") && body.contains("\"other\""), "{body}");
2151 }
2152
2153 #[test]
2154 fn browse_ns_param_is_read_gated_for_a_restricted_principal() {
2155 let s = restricted_server();
2156 let ok = s.route("GET", "/api/browse?ns=caller", b"", Some("writer-secret"), None);
2157 assert!(text(&ok).contains("\"ok\":true"), "their own granted namespace must work: {}", text(&ok));
2158
2159 let denied = s.route("GET", "/api/browse?ns=other", b"", Some("writer-secret"), None);
2160 let body = text(&denied);
2161 assert!(body.contains("\"ok\":false"), "an ungranted namespace must be refused, not silently emptied: {body}");
2162 }
2163
2164 #[test]
2165 fn browse_ns_star_requires_owner() {
2166 let s = restricted_server();
2167 let r = s.route("GET", "/api/browse?ns=*", b"", Some("writer-secret"), None);
2168 assert!(text(&r).contains("\"ok\":false"), "a restricted principal must not get the unfiltered view: {}", text(&r));
2169
2170 let owner = owner_server();
2171 let r2 = owner.route("GET", "/api/browse?ns=*", b"", None, None);
2172 let body2 = text(&r2);
2173 assert!(body2.contains("\"ok\":true"), "the owner session may lift the filter: {body2}");
2174 assert!(body2.contains("\"caller\"") && body2.contains("\"other\""), "{body2}");
2175 }
2176
2177 #[test]
2178 fn browse_without_ns_param_keeps_original_session_default_behavior() {
2179 let s = owner_server();
2180 let r = s.route("GET", "/api/browse", b"", None, None);
2181 let body = text(&r);
2182 assert!(body.contains("\"caller\""), "{body}");
2183 assert!(!body.contains("\"other\""), "omitting ?ns must behave exactly as before this feature existed: {body}");
2184 }
2185}
2186
2187#[cfg(test)]
2188mod builder_tests {
2189 use super::UiServer;
2190 use areev_cal::AreevFacade;
2191 use areev_store::Areev;
2192
2193 fn server() -> UiServer {
2194 let dir = tempfile::tempdir().unwrap();
2195 let path = dir.path().join("t.db");
2196 let store = Areev::open(path.to_str().unwrap()).unwrap();
2197 std::mem::forget(dir);
2198 UiServer::new(AreevFacade::with_session(store, Some("caller".into()), None), "t".into())
2199 }
2200
2201 #[test]
2205 fn attaching_a_loop_policy_preserves_the_destructive_cap() {
2206 let s = server()
2207 .allow_destructive_ops(false)
2208 .with_loop_policy(areev_loop::Policy::default());
2209 let r = s.route(
2210 "POST",
2211 "/api/cal",
2212 br#"{"query":"FORGET sha256:684c6c9bda818630a870119d0726e4d242ed537af061658ef6f3acb158a2c67d"}"#,
2213 None,
2214 None,
2215 );
2216 let body = String::from_utf8_lossy(&r.2).to_string();
2217 assert!(
2218 body.contains("--no-destructive-ops") || r.0.starts_with("401"),
2219 "destruction must stay disabled: {body}"
2220 );
2221 }
2222}
2223
2224#[cfg(test)]
2225mod run_api_tests {
2226 use super::UiServer;
2227 use areev_cal::AreevFacade;
2228 use areev_core::authz::{CredentialMap, AUTHZ_NS, REL_PERMITS};
2229 use areev_core::types::{Fact, Grain, Tool, ToolKind, Workflow};
2230 use areev_store::Areev;
2231 use std::sync::Arc;
2232
2233 fn parked_server() -> (UiServer, String) {
2236 std::env::set_var("AREEV_RUN_OFFICER_TOK", "officer-secret");
2237 std::env::set_var("AREEV_RUN_STARTER_TOK", "starter-secret");
2238 let dir = tempfile::tempdir().unwrap();
2239 let path = dir.path().join("t.db");
2240 let mut store = Areev::open(path.to_str().unwrap()).unwrap();
2241 for (who, what) in [
2242 ("user:officer", "read,run.respond ON ops"),
2243 ("user:starter", "read ON ops"),
2244 ] {
2245 store
2246 .add(&Fact::new(who, REL_PERMITS, what).namespace(AUTHZ_NS).created_at(900))
2247 .unwrap();
2248 }
2249 std::mem::forget(dir);
2250 let facade = Arc::new(AreevFacade::with_session(store, Some("ops".into()), None));
2251
2252 let (wf, _g, _a) = facade
2254 .with_store(|m| {
2255 let greet = Tool::new("greet")
2256 .kind(ToolKind::Definition)
2257 .tool_description("t")
2258 .created_at(500)
2259 .namespace("ops");
2260 let gh = m.add(&greet)?;
2261 let approve = Tool::new("approve")
2262 .kind(ToolKind::Definition)
2263 .tool_description("human approves")
2264 .executor_kind(areev_core::types::ExecutorKind::Client)
2265 .created_at(501)
2266 .namespace("ops");
2267 let ah = m.add(&approve)?;
2268 let wf = Workflow::new(vec!["greet".into(), "approve".into()])
2269 .edge("greet", "approve")
2270 .bind("greet", &gh.to_hex())
2271 .bind("approve", &ah.to_hex())
2272 .created_at(502)
2273 .namespace("ops");
2274 let wh = m.add(&wf)?;
2275 Ok::<_, areev_core::error::AreevError>((wh, gh, ah))
2276 })
2277 .unwrap();
2278 struct Ok1;
2279 impl areev_run::HostToolExecutor for Ok1 {
2280 fn execute(
2281 &self,
2282 t: &str,
2283 _h: &str,
2284 _i: &serde_json::Value,
2285 _k: &str,
2286 ) -> areev_run::ExecResult {
2287 areev_run::ExecResult::Ok(serde_json::json!({t.to_string(): true}))
2288 }
2289 }
2290 let runner = areev_run::Runner {
2291 facade: Arc::clone(&facade),
2292 clock: Arc::new(areev_run::ScriptedClock::new(
2293 (0..100).map(|i| 1_758_000_000_000 + i * 10).collect(),
2294 )),
2295 executor: Arc::new(Ok1),
2296 llm: None,
2297 observer: None,
2298 ns: "ops".into(),
2299 principal: "user:starter".into(),
2300 };
2301 let session = runner
2302 .start(&wf, "hitl-1", serde_json::json!({}), &areev_run::RunOptions {
2303 workers: 1,
2304 ..Default::default()
2305 })
2306 .unwrap();
2307 let areev_run::RunSession::Parked { envelope, .. } = session else {
2308 panic!("expected park")
2309 };
2310 let ask = envelope["asks"][0]["tool_call_id"].as_str().unwrap().to_string();
2311
2312 drop(runner);
2319 let facade = Arc::try_unwrap(facade).unwrap_or_else(|_| panic!("facade still shared"));
2320 let map = CredentialMap::from_json(
2321 r#"{"version":1,"tokens":[
2322 {"env":"AREEV_RUN_OFFICER_TOK","principal":"user:officer"},
2323 {"env":"AREEV_RUN_STARTER_TOK","principal":"user:starter"}
2324 ]}"#,
2325 )
2326 .unwrap();
2327 let server = UiServer::new(facade, "test".into())
2328 .with_auth("shared-console-secret".into())
2329 .with_credentials(map);
2330 (server, ask)
2331 }
2332
2333 fn body(run_id: &str, ask: &str) -> Vec<u8> {
2334 serde_json::json!({"run_id": run_id, "tool_call_id": ask, "result": {"approved": true}})
2335 .to_string()
2336 .into_bytes()
2337 }
2338
2339 #[test]
2340 fn shared_token_approvals_are_refused_and_principals_approve() {
2341 let (s, ask) = parked_server();
2342
2343 let r = s.route("GET", "/api/run/list", b"", Some("shared-console-secret"), None);
2345 assert!(r.0.starts_with("200"), "{}", String::from_utf8_lossy(&r.2));
2346 let r = s.route(
2347 "GET",
2348 "/api/run/inspect?run_id=hitl-1",
2349 b"",
2350 Some("shared-console-secret"),
2351 None,
2352 );
2353 assert!(String::from_utf8_lossy(&r.2).contains(&ask), "ask visible in inspect");
2354
2355 let r = s.route("POST", "/api/run/respond", &body("hitl-1", &ask), Some("shared-console-secret"), None);
2357 assert!(r.0.starts_with("403"), "shared-token approval must refuse: {}", r.0);
2358
2359 let r = s.route("POST", "/api/run/respond", &body("hitl-1", &ask), Some("starter-secret"), None);
2362 let text = String::from_utf8_lossy(&r.2).to_string();
2363 assert!(text.contains("responder") || text.contains("RUN-E012"), "{text}");
2364
2365 let r = s.route("POST", "/api/run/respond", &body("hitl-1", &ask), Some("officer-secret"), None);
2367 assert!(r.0.starts_with("200"), "{}", String::from_utf8_lossy(&r.2));
2368 assert!(String::from_utf8_lossy(&r.2).contains("user:officer"));
2369
2370 let r = s.route(
2372 "POST",
2373 "/api/run/cancel",
2374 br#"{"run_id":"hitl-1","because":"test brake"}"#,
2375 Some("shared-console-secret"),
2376 None,
2377 );
2378 assert!(r.0.starts_with("200"), "{}", String::from_utf8_lossy(&r.2));
2379 }
2380}
2381
2382#[cfg(test)]
2383mod memory_scoped_token_tests {
2384 use super::UiServer;
2385 use areev_cal::AreevFacade;
2386 use areev_core::authz::{CredentialMap, AUTHZ_NS, REL_PERMITS};
2387 use areev_core::types::{Fact, Grain};
2388 use areev_store::Areev;
2389
2390 #[test]
2394 fn memory_scoped_tokens_do_not_cross_memories() {
2395 std::env::set_var("AREEV_SCOPED_TOK", "scoped-secret");
2396 let map_json = r#"{"version":1,"tokens":[
2397 {"env":"AREEV_SCOPED_TOK","principal":"agent:sync","memories":["memory-a"]}
2398 ]}"#;
2399 let server_for = |label: &str| {
2400 let dir = tempfile::tempdir().unwrap();
2401 let mut store = Areev::open(dir.path().join("t.db").to_str().unwrap()).unwrap();
2402 store
2403 .add(
2404 &Fact::new("agent:sync", REL_PERMITS, "read,write ON caller")
2405 .namespace(AUTHZ_NS)
2406 .created_at(1_000),
2407 )
2408 .unwrap();
2409 store
2410 .add(&Fact::new("acme", "tier", "Enterprise").namespace("caller").created_at(2_000))
2411 .unwrap();
2412 std::mem::forget(dir);
2413 let facade = AreevFacade::with_session(store, Some("caller".into()), None);
2414 UiServer::new(facade, label.into())
2415 .with_credentials(CredentialMap::from_json(map_json).unwrap())
2416 };
2417 const WRITE: &[u8] = br#"{"query":"ADD fact SET subject = \"x\" SET relation = \"r\" SET object = \"o\" SET namespace = \"caller\" REASON \"t\""}"#;
2418
2419 let a = server_for("memory-a");
2422 let r = a.route("POST", "/api/cal", WRITE, Some("scoped-secret"), None);
2423 let text = String::from_utf8_lossy(&r.2).to_string();
2424 assert!(r.0.starts_with("200") && !text.contains("AUT-E001"), "{text}");
2425
2426 let b = server_for("memory-b");
2430 let r = b.route("POST", "/api/cal", WRITE, Some("scoped-secret"), None);
2431 let text = String::from_utf8_lossy(&r.2).to_string();
2432 assert!(
2433 text.contains("AUT-E001") && text.contains("anonymous"),
2434 "scoped token must not cross memories: {text}"
2435 );
2436
2437 assert!(CredentialMap::from_json(
2439 r#"{"version":1,"tokens":[{"env":"AREEV_SCOPED_TOK","principal":"x","memories":[]}]}"#
2440 )
2441 .is_err());
2442 }
2443}
2444
2445#[cfg(all(test, feature = "tls"))]
2446mod tls_tests {
2447 use super::UiServer;
2448 use areev_cal::AreevFacade;
2449 use areev_store::Areev;
2450 use std::io::{Read, Write};
2451
2452 fn self_signed(dir: &std::path::Path) -> Option<(String, String)> {
2453 let cert = dir.join("cert.pem");
2454 let key = dir.join("key.pem");
2455 let status = std::process::Command::new("openssl")
2456 .args([
2457 "req", "-x509", "-newkey", "rsa:2048", "-nodes",
2458 "-keyout", key.to_str().unwrap(), "-out", cert.to_str().unwrap(),
2459 "-days", "1", "-subj", "/CN=localhost",
2460 "-addext", "subjectAltName=DNS:localhost,IP:127.0.0.1",
2461 "-addext", "basicConstraints=critical,CA:FALSE",
2462 ])
2463 .stdout(std::process::Stdio::null())
2464 .stderr(std::process::Stdio::null())
2465 .status();
2466 match status {
2467 Ok(s) if s.success() => Some((
2468 cert.to_str().unwrap().to_string(),
2469 key.to_str().unwrap().to_string(),
2470 )),
2471 _ => None, }
2473 }
2474
2475 #[test]
2478 fn tls_round_trip_and_no_plaintext_downgrade() {
2479 let dir = tempfile::tempdir().unwrap();
2480 let Some((cert, key)) = self_signed(dir.path()) else {
2481 eprintln!("skipping: no openssl binary");
2482 return;
2483 };
2484 let store = Areev::open(dir.path().join("t.db").to_str().unwrap()).unwrap();
2485 let facade = AreevFacade::with_session(store, Some("caller".into()), None);
2486 let server = UiServer::new(facade, "tls-test".into())
2487 .with_tls(&cert, &key)
2488 .unwrap();
2489 let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
2490 let addr = listener.local_addr().unwrap();
2491 let handle = std::thread::spawn(move || {
2492 for _ in 0..2 {
2494 if let Ok((s, _)) = listener.accept() {
2495 let _ = server.handle(s);
2496 }
2497 }
2498 });
2499
2500 let mut roots = rustls::RootCertStore::empty();
2502 {
2503 use rustls::pki_types::pem::PemObject;
2504 for c in rustls::pki_types::CertificateDer::pem_file_iter(&cert).unwrap() {
2505 roots.add(c.unwrap()).unwrap();
2506 }
2507 }
2508 let config = std::sync::Arc::new(
2509 rustls::ClientConfig::builder()
2510 .with_root_certificates(roots)
2511 .with_no_client_auth(),
2512 );
2513 let name = rustls::pki_types::ServerName::try_from("localhost").unwrap();
2514 let session = rustls::ClientConnection::new(config, name).unwrap();
2515 let tcp = std::net::TcpStream::connect(addr).unwrap();
2516 let mut tls = rustls::StreamOwned::new(session, tcp);
2517 tls.write_all(b"GET / HTTP/1.1\r\nHost: 127.0.0.1\r\nConnection: close\r\n\r\n")
2518 .unwrap();
2519 let mut response = String::new();
2520 let _ = tls.read_to_string(&mut response);
2521 assert!(response.starts_with("HTTP/1.1 200"), "{}", &response[..response.len().min(120)]);
2522 assert!(response.contains("Areev"), "console served over TLS");
2523
2524 let mut plain = std::net::TcpStream::connect(addr).unwrap();
2527 plain
2528 .write_all(b"GET / HTTP/1.1\r\nHost: 127.0.0.1\r\nConnection: close\r\n\r\n")
2529 .unwrap();
2530 let mut buf = Vec::new();
2531 let _ = plain.read_to_end(&mut buf);
2532 let text = String::from_utf8_lossy(&buf);
2533 assert!(
2534 !text.contains("HTTP/1.1 200"),
2535 "plaintext must not be served on a TLS listener: {text}"
2536 );
2537 handle.join().unwrap();
2538 }
2539
2540 #[test]
2546 fn hub_mode_terminates_tls_the_same_way_as_ui_mode() {
2547 let dir = tempfile::tempdir().unwrap();
2548 let Some((cert, key)) = self_signed(dir.path()) else {
2549 eprintln!("skipping: no openssl binary");
2550 return;
2551 };
2552 let store = Areev::open(dir.path().join("t.db").to_str().unwrap()).unwrap();
2553 let facade = AreevFacade::with_session(store, Some("caller".into()), None);
2554 let segments = dir.path().join("segments");
2555 let server = UiServer::new(facade, "hub-tls-test".into())
2556 .into_hub(Some("hub-token".into()), segments.to_str().unwrap())
2557 .unwrap()
2558 .with_tls(&cert, &key)
2559 .unwrap();
2560 let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
2561 let addr = listener.local_addr().unwrap();
2562 let handle = std::thread::spawn(move || {
2563 if let Ok((s, _)) = listener.accept() {
2564 let _ = server.handle(s);
2565 }
2566 });
2567
2568 let mut roots = rustls::RootCertStore::empty();
2569 {
2570 use rustls::pki_types::pem::PemObject;
2571 for c in rustls::pki_types::CertificateDer::pem_file_iter(&cert).unwrap() {
2572 roots.add(c.unwrap()).unwrap();
2573 }
2574 }
2575 let config = std::sync::Arc::new(
2576 rustls::ClientConfig::builder()
2577 .with_root_certificates(roots)
2578 .with_no_client_auth(),
2579 );
2580 let name = rustls::pki_types::ServerName::try_from("localhost").unwrap();
2581 let session = rustls::ClientConnection::new(config, name).unwrap();
2582 let tcp = std::net::TcpStream::connect(addr).unwrap();
2583 let mut tls = rustls::StreamOwned::new(session, tcp);
2584 tls.write_all(b"GET / HTTP/1.1\r\nHost: 127.0.0.1\r\nConnection: close\r\n\r\n")
2585 .unwrap();
2586 let mut response = String::new();
2587 let _ = tls.read_to_string(&mut response);
2588 assert!(
2589 response.starts_with("HTTP/1.1 200"),
2590 "hub should terminate TLS the same way ui does: {}",
2591 &response[..response.len().min(120)]
2592 );
2593 handle.join().unwrap();
2594 }
2595}
2596
2597#[cfg(test)]
2598mod sso_tests {
2599 use super::UiServer;
2600 use areev_cal::AreevFacade;
2601 use areev_core::authz::{AUTHZ_NS, REL_PERMITS};
2602 use areev_core::types::{Fact, Grain};
2603 use areev_store::Areev;
2604 use std::io::{Read, Write};
2605
2606 const WRITE: &[u8] = br#"{"query":"ADD fact SET subject = \"x\" SET relation = \"r\" SET object = \"o\" SET namespace = \"caller\" REASON \"t\""}"#;
2607
2608 fn sso_server() -> UiServer {
2609 let dir = tempfile::tempdir().unwrap();
2610 let mut store = Areev::open(dir.path().join("t.db").to_str().unwrap()).unwrap();
2611 store
2612 .add(
2613 &Fact::new("user:pat@example.com", REL_PERMITS, "read,write ON caller")
2614 .namespace(AUTHZ_NS)
2615 .created_at(1_000),
2616 )
2617 .unwrap();
2618 std::mem::forget(dir);
2619 let facade = AreevFacade::with_session(store, Some("caller".into()), None);
2620 UiServer::new(facade, "sso-test".into()).with_sso("x-forwarded-user", "proxy-secret")
2621 }
2622
2623 fn post(server: &UiServer, extra_headers: &str) -> String {
2624 let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
2625 let addr = listener.local_addr().unwrap();
2626 std::thread::scope(|scope| {
2627 scope.spawn(|| {
2628 if let Ok((s, _)) = listener.accept() {
2629 let _ = server.handle(s);
2630 }
2631 });
2632 let mut c = std::net::TcpStream::connect(addr).unwrap();
2633 let req = format!(
2634 "POST /api/cal HTTP/1.1\r\nHost: 127.0.0.1\r\nContent-Type: application/json\r\n{extra_headers}Content-Length: {}\r\nConnection: close\r\n\r\n",
2635 WRITE.len()
2636 );
2637 c.write_all(req.as_bytes()).unwrap();
2638 c.write_all(WRITE).unwrap();
2639 let mut out = String::new();
2640 let _ = c.read_to_string(&mut out);
2641 out
2642 })
2643 }
2644
2645 #[test]
2649 fn proxied_identity_works_and_forged_headers_are_ignored() {
2650 let server = sso_server();
2651
2652 let ok = post(
2654 &server,
2655 "X-Forwarded-User: user:pat@example.com\r\nX-Areev-Proxy-Secret: proxy-secret\r\n",
2656 );
2657 assert!(ok.contains("HTTP/1.1 200"), "{}", &ok[..ok.len().min(200)]);
2658 assert!(!ok.contains("AUT-E001"), "granted principal must write: {ok}");
2659
2660 let forged = post(&server, "X-Forwarded-User: user:pat@example.com\r\n");
2663 assert!(
2664 forged.contains("anonymous") || forged.contains("401"),
2665 "forged identity must not authenticate: {forged}"
2666 );
2667
2668 let wrong = post(
2670 &server,
2671 "X-Forwarded-User: user:pat@example.com\r\nX-Areev-Proxy-Secret: guess\r\n",
2672 );
2673 assert!(
2674 wrong.contains("anonymous") || wrong.contains("401"),
2675 "wrong proxy secret must not authenticate: {wrong}"
2676 );
2677 }
2678
2679 use crate::CONSOLE_HTML;
2680 use std::collections::BTreeSet;
2681
2682 #[test]
2694 fn every_console_page_is_in_the_visibility_list() {
2695 fn all(hay: &str, open: &str) -> BTreeSet<String> {
2696 let mut out = BTreeSet::new();
2697 let mut rest = hay;
2698 while let Some(i) = rest.find(open) {
2699 rest = &rest[i + open.len()..];
2700 let end = rest.find('"').expect("unterminated attribute");
2701 out.insert(rest[..end].to_string());
2702 rest = &rest[end..];
2703 }
2704 out
2705 }
2706
2707 let sections = all(CONSOLE_HTML, "id=\"page-");
2708 let nav = all(CONSOLE_HTML, "data-page=\"");
2709 for want in ["workflows", "runs", "tools"] {
2710 assert!(sections.contains(want), "the {want} section itself went missing");
2711 }
2712 assert!(!sections.contains("triggers"), "triggers belong on the canvas, not on a page");
2716 assert!(
2717 CONSOLE_HTML.contains("if (head === 'triggers')"),
2718 "#triggers must still route somewhere — a removed page is not a dead bookmark"
2719 );
2720
2721 let head = "for (const p of [";
2723 let i = CONSOLE_HTML.find(head).expect("render()'s page-visibility loop");
2724 let tail = &CONSOLE_HTML[i + head.len()..];
2725 let j = tail.find(']').expect("unterminated page array");
2726 let listed: BTreeSet<String> = tail[..j]
2727 .split(',')
2728 .map(|t| t.trim().trim_matches('\'').to_string())
2729 .filter(|t| !t.is_empty())
2730 .collect();
2731
2732 assert_eq!(
2733 sections, listed,
2734 "page sections and render()'s visibility list disagree; a section missing \
2735 from the list renders into a pane that never un-hides"
2736 );
2737 let orphan: Vec<_> = nav.difference(§ions).collect();
2738 assert!(orphan.is_empty(), "nav items with no page section: {orphan:?}");
2739 }
2740}