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;
31const AUTH_FAILURE_IDLE_SECS: u64 = 15 * 60;
34const MAX_AUTH_FAILURE_IPS: usize = 4096;
38
39
40enum Conn {
47 Plain(TcpStream),
48 #[cfg(feature = "tls")]
49 Tls(Box<rustls::StreamOwned<rustls::ServerConnection, TcpStream>>),
50}
51
52impl Read for Conn {
53 fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
54 match self {
55 Conn::Plain(s) => s.read(buf),
56 #[cfg(feature = "tls")]
57 Conn::Tls(s) => s.read(buf),
58 }
59 }
60}
61
62impl Write for Conn {
63 fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
64 match self {
65 Conn::Plain(s) => s.write(buf),
66 #[cfg(feature = "tls")]
67 Conn::Tls(s) => s.write(buf),
68 }
69 }
70 fn flush(&mut self) -> std::io::Result<()> {
71 match self {
72 Conn::Plain(s) => s.flush(),
73 #[cfg(feature = "tls")]
74 Conn::Tls(s) => s.flush(),
75 }
76 }
77}
78
79pub struct UiServer {
80 facade: std::sync::Arc<AreevFacade>,
81 executor: CalExecutor,
82 db_label: String,
83 db_label_display: String,
91 token: Option<String>,
95 auth_all: bool,
99 allow_remote: bool,
104 allowed_origins: Vec<String>,
114 loop_policy: Option<areev_loop::Policy>,
119 credentials: Option<areev_core::authz::CredentialMap>,
128 allow_destructive_ops: bool,
131 #[cfg(feature = "tls")]
134 tls_config: Option<std::sync::Arc<rustls::ServerConfig>>,
135 sso_header: Option<String>,
142 sso_secrets: Vec<String>,
159 auth_failures: std::sync::Mutex<std::collections::HashMap<std::net::IpAddr, (u32, std::time::Instant)>>,
175}
176
177struct RequestBinding<'a> {
183 facade: &'a AreevFacade,
184}
185
186impl<'a> RequestBinding<'a> {
187 fn bind(
188 facade: &'a AreevFacade,
189 map: &areev_core::authz::CredentialMap,
190 bearer: Option<&str>,
191 memory: &str,
192 ) -> Self {
193 let principal =
196 bearer.and_then(|t| map.resolve_for_memory(t, memory).ok().map(str::to_string));
197 match principal {
198 Some(p) => {
199 if facade.bind_principal(&p).is_err() {
200 Self::bind_anonymous(facade);
203 }
204 }
205 None => Self::bind_anonymous(facade),
206 }
207 RequestBinding { facade }
208 }
209
210 fn bind_identity(facade: &'a AreevFacade, principal: &str) -> Self {
216 if facade.bind_principal(principal).is_err() {
217 Self::bind_anonymous(facade);
218 }
219 RequestBinding { facade }
220 }
221
222 fn bind_anonymous(facade: &AreevFacade) {
223 use areev_core::authz::{AuthzSet, Grant, Verb};
224 let mut grants = facade
225 .with_store(|m| m.authz_grants("anonymous"))
226 .unwrap_or_default();
227 grants.push(Grant { verbs: vec![Verb::Read], namespaces: vec!["*".to_string()] });
228 facade.bind(AuthzSet::restricted("anonymous", grants));
229 }
230}
231
232impl Drop for RequestBinding<'_> {
233 fn drop(&mut self) {
234 self.facade
235 .bind(areev_core::authz::AuthzSet::owner("user:console"));
236 }
237}
238
239impl UiServer {
240 pub fn new(facade: AreevFacade, db_label: String) -> Self {
241 let db_label_display = redact_dsn(&db_label);
242 UiServer {
243 facade: std::sync::Arc::new(facade),
244 executor: CalExecutor::new(CalExecutorConfig::default())
245 .with_governance(std::sync::Arc::new(areev_loop_adapter::LoopGovernance::new())),
246 db_label,
247 db_label_display,
248 token: None,
249 auth_all: false,
250 allow_remote: false,
251 allowed_origins: Vec::new(),
252 loop_policy: None,
253 credentials: None,
254 allow_destructive_ops: CalExecutorConfig::default().allow_destructive_ops,
255 #[cfg(feature = "tls")]
256 tls_config: None,
257 sso_header: None,
258 sso_secrets: Vec::new(),
259 auth_failures: std::sync::Mutex::new(std::collections::HashMap::new()),
260 }
261 }
262
263 pub fn with_sso(self, header: impl Into<String>, secret: impl Into<String>) -> Self {
267 self.with_sso_rotating(header, secret, None::<String>)
268 }
269
270 pub fn with_sso_rotating(
285 mut self,
286 header: impl Into<String>,
287 secret: impl Into<String>,
288 next: Option<impl Into<String>>,
289 ) -> Self {
290 let header = header.into();
291 let secret = secret.into();
292 let next = next.map(Into::into).filter(|s| !s.trim().is_empty());
293 assert!(
297 !header.trim().is_empty() && !secret.trim().is_empty(),
298 "with_sso requires a non-empty header name and proxy secret"
299 );
300 assert!(
304 next.as_deref() != Some(secret.as_str()),
305 "the rotating proxy secret must differ from the current one"
306 );
307 self.sso_header = Some(header.to_ascii_lowercase());
308 self.sso_secrets = match next {
309 Some(n) => vec![secret, n],
310 None => vec![secret],
311 };
312 self
313 }
314
315 fn rebuild_executor(&mut self) {
322 self.executor = CalExecutor::new(CalExecutorConfig {
323 allow_destructive_ops: self.allow_destructive_ops,
324 ..CalExecutorConfig::default()
325 })
326 .with_governance(std::sync::Arc::new(match &self.loop_policy {
327 Some(p) => areev_loop_adapter::LoopGovernance::with_policy(p.clone()),
328 None => areev_loop_adapter::LoopGovernance::new(),
329 }));
330 }
331
332 pub fn with_credentials(mut self, map: areev_core::authz::CredentialMap) -> Self {
337 self.credentials = Some(map);
338 self
339 }
340
341 pub fn with_loop_policy(mut self, policy: areev_loop::Policy) -> Self {
345 self.loop_policy = Some(policy);
346 self.rebuild_executor();
348 self
349 }
350
351 fn engine(&self) -> Engine {
354 match &self.loop_policy {
355 Some(p) => Engine::with_builtins().with_policy(p.clone()),
356 None => Engine::with_builtins(),
357 }
358 }
359
360 pub fn allow_remote(mut self, yes: bool) -> Self {
364 self.allow_remote = yes;
365 self
366 }
367
368 pub fn allow_origin(mut self, origin: impl Into<String>) -> Self {
383 self.allowed_origins.push(normalize_origin(&origin.into()));
384 self
385 }
386
387 fn origin_is_allowed(&self, origin: &str) -> bool {
391 let normalized = normalize_origin(origin);
392 self.allowed_origins.contains(&normalized)
393 }
394
395 pub fn with_auth(mut self, token: String) -> Self {
402 assert!(
406 !token.trim().is_empty(),
407 "with_auth requires a non-empty token"
408 );
409 self.token = Some(token);
410 self.auth_all = true;
411 self
412 }
413
414 pub fn allow_destructive_ops(mut self, allow: bool) -> Self {
419 self.allow_destructive_ops = allow;
420 self.rebuild_executor();
421 self
422 }
423
424 pub fn bind(addr: &str) -> std::io::Result<TcpListener> {
426 TcpListener::bind(addr)
427 }
428
429 pub fn serve(&self, listener: TcpListener) -> std::io::Result<()> {
431 for stream in listener.incoming() {
432 match stream {
433 Ok(s) => {
434 if let Err(e) = self.handle(s) {
435 eprintln!("areev ui: request error: {e}");
436 }
437 }
438 Err(e) => eprintln!("areev ui: accept error: {e}"),
439 }
440 }
441 Ok(())
442 }
443
444 #[cfg(feature = "tls")]
448 pub fn with_tls(mut self, cert_pem: &str, key_pem: &str) -> std::io::Result<Self> {
449 use rustls::pki_types::pem::PemObject;
450 let pem_err =
451 |e: rustls::pki_types::pem::Error| std::io::Error::new(std::io::ErrorKind::InvalidData, e.to_string());
452 let certs: Vec<rustls::pki_types::CertificateDer<'static>> =
453 rustls::pki_types::CertificateDer::pem_file_iter(cert_pem)
454 .map_err(pem_err)?
455 .collect::<Result<_, _>>()
456 .map_err(pem_err)?;
457 let key =
458 rustls::pki_types::PrivateKeyDer::from_pem_file(key_pem).map_err(pem_err)?;
459 let config = rustls::ServerConfig::builder()
460 .with_no_client_auth()
461 .with_single_cert(certs, key)
462 .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e.to_string()))?;
463 self.tls_config = Some(std::sync::Arc::new(config));
464 Ok(self)
465 }
466
467 fn note_auth_failure(&self, ip: std::net::IpAddr) -> u32 {
492 let now = std::time::Instant::now();
493 let idle = Duration::from_secs(AUTH_FAILURE_IDLE_SECS);
494 let mut map = self
495 .auth_failures
496 .lock()
497 .unwrap_or_else(std::sync::PoisonError::into_inner);
498 match map.get_mut(&ip) {
499 Some((n, last)) if now.duration_since(*last) < idle => {
500 *n = n.saturating_add(1);
501 *last = now;
502 *n
503 }
504 Some(entry) => {
505 *entry = (1, now);
506 1
507 }
508 None => {
509 if map.len() >= MAX_AUTH_FAILURE_IPS {
510 if let Some(stalest) =
511 map.iter().min_by_key(|(_, (_, last))| *last).map(|(k, _)| *k)
512 {
513 map.remove(&stalest);
514 }
515 }
516 map.insert(ip, (1, now));
517 1
518 }
519 }
520 }
521
522 fn reset_auth_failures(&self, ip: std::net::IpAddr) {
524 let mut map = self
525 .auth_failures
526 .lock()
527 .unwrap_or_else(std::sync::PoisonError::into_inner);
528 map.remove(&ip);
529 }
530
531 #[cfg(test)]
535 fn auth_failure_count_for_test(&self, ip: std::net::IpAddr) -> u32 {
536 self.auth_failures
537 .lock()
538 .unwrap_or_else(std::sync::PoisonError::into_inner)
539 .get(&ip)
540 .map(|(n, _)| *n)
541 .unwrap_or(0)
542 }
543
544 fn wrap(&self, stream: TcpStream) -> std::io::Result<Conn> {
547 #[cfg(feature = "tls")]
548 if let Some(config) = &self.tls_config {
549 let session = rustls::ServerConnection::new(std::sync::Arc::clone(config))
550 .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e.to_string()))?;
551 return Ok(Conn::Tls(Box::new(rustls::StreamOwned::new(session, stream))));
552 }
553 Ok(Conn::Plain(stream))
554 }
555
556 fn handle(&self, stream: TcpStream) -> std::io::Result<()> {
557 let watchdog_stream = stream.try_clone()?;
563 let done = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
564 let watchdog_done = std::sync::Arc::clone(&done);
565 let watchdog = std::thread::spawn(move || {
566 std::thread::park_timeout(Duration::from_secs(REQUEST_DEADLINE_SECS));
567 if !watchdog_done.load(std::sync::atomic::Ordering::Acquire) {
568 let _ = watchdog_stream.shutdown(std::net::Shutdown::Both);
569 }
570 });
571 let raw = stream.try_clone()?;
572 let conn = self.wrap(stream)?;
573 let result = self.handle_request(&raw, conn);
574 done.store(true, std::sync::atomic::Ordering::Release);
575 watchdog.thread().unpark();
576 let _ = watchdog.join();
577 result
578 }
579
580 fn handle_request(&self, raw: &TcpStream, mut conn: Conn) -> std::io::Result<()> {
581 raw.set_read_timeout(Some(Duration::from_secs(READ_TIMEOUT_SECS)))?;
586 raw.set_write_timeout(Some(Duration::from_secs(READ_TIMEOUT_SECS)))?;
587 let mut reader = BufReader::new((&mut conn).take(MAX_CONN_BYTES));
588 let mut request_line = String::new();
589 reader.read_line(&mut request_line)?;
590 let mut parts = request_line.split_whitespace();
591 let method = parts.next().unwrap_or("").to_string();
592 let path = parts.next().unwrap_or("/").to_string();
593
594 let mut content_length = 0usize;
596 let mut bearer: Option<String> = None;
597 let mut origin: Option<String> = None;
598 let mut host: Option<String> = None;
599 let mut sso_identity_raw: Option<String> = None;
600 let mut proxy_secret: Option<String> = None;
601 let mut header_bytes = 0usize;
602 let mut header_count = 0usize;
603 loop {
604 let mut line = String::new();
605 let n = reader.read_line(&mut line)?;
606 if n == 0 {
607 break; }
609 header_bytes += n;
610 header_count += 1;
611 if header_bytes > MAX_HEADER_BYTES || header_count > MAX_HEADERS {
612 return Err(std::io::Error::new(
613 std::io::ErrorKind::InvalidData,
614 "request headers too large",
615 ));
616 }
617 let l = line.trim();
618 if l.is_empty() {
619 break;
620 }
621 let low = l.to_ascii_lowercase();
622 if let Some(v) = low.strip_prefix("content-length:") {
623 content_length = v.trim().parse().unwrap_or(0);
624 }
625 if let Some(v) = low.strip_prefix("origin:") {
626 origin = Some(v.trim().to_string());
627 }
628 if let Some(v) = low.strip_prefix("host:") {
629 host = Some(v.trim().to_string());
630 }
631 if let Some(v) = low.strip_prefix("x-areev-proxy-secret:") {
632 let _ = v;
635 if let Some((_, orig)) = l.split_once(':') {
636 proxy_secret = Some(orig.trim().to_string());
637 }
638 }
639 if let Some(h) = &self.sso_header {
640 if low.starts_with(&format!("{h}:")) {
641 if let Some((_, orig)) = l.split_once(':') {
642 sso_identity_raw = Some(orig.trim().to_string());
643 }
644 }
645 }
646 if low.starts_with("authorization:") {
647 if let Some((_, v)) = l.split_once(':') {
648 let v = v.trim();
649 bearer = if let Some(t) =
654 v.strip_prefix("Bearer ").or_else(|| v.strip_prefix("bearer "))
655 {
656 Some(t.trim().to_string())
657 } else if let Some(b64) =
658 v.strip_prefix("Basic ").or_else(|| v.strip_prefix("basic "))
659 {
660 basic_auth_password(b64.trim())
661 } else {
662 Some(v.to_string())
663 };
664 }
665 }
666 }
667 if !self.allow_remote && host.as_deref().is_some_and(|h| !host_is_local(h)) {
674 let payload = br#"{"ok":false,"error":"non-loopback Host rejected (use --allow-remote to serve remotely)"}"#;
675 drop(reader);
676 let mut out = conn;
677 write!(
678 out,
679 "HTTP/1.1 403 Forbidden\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
680 payload.len()
681 )?;
682 out.write_all(payload)?;
683 return out.flush();
684 }
685
686 let mut body = vec![0u8; content_length.min(1 << 20)];
687 if content_length > 0 {
688 reader.read_exact(&mut body)?;
689 }
690
691 if method == "POST"
698 && origin
699 .as_deref()
700 .is_some_and(|o| !origin_is_local(o) && !self.origin_is_allowed(o))
701 {
702 let payload = br#"{"ok":false,"error":"cross-origin request rejected"}"#;
703 drop(reader);
704 let mut out = conn;
705 write!(
706 out,
707 "HTTP/1.1 403 Forbidden\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
708 payload.len()
709 )?;
710 out.write_all(payload)?;
711 return out.flush();
712 }
713
714 let proxy_secret_presented = proxy_secret.is_some();
719 let sso_identity: Option<String> = match (proxy_secret, sso_identity_raw) {
724 (Some(presented), Some(id)) if !id.trim().is_empty() => {
725 let proved = self
730 .sso_secrets
731 .iter()
732 .fold(false, |acc, s| ct_eq(s.as_bytes(), presented.as_bytes()) | acc);
733 proved.then_some(id)
734 }
735 _ => None,
736 };
737 let (status, ctype, payload) =
738 self.route(&method, &path, &body, bearer.as_deref(), sso_identity.as_deref());
739
740 let auth_configured =
752 self.token.is_some() || self.credentials.is_some() || !self.sso_secrets.is_empty();
753 let credential_presented = bearer.is_some() || proxy_secret_presented;
754 if auth_configured && credential_presented {
755 if let Ok(peer) = raw.peer_addr() {
756 let ip = peer.ip();
757 if status.starts_with("401") {
758 let consecutive = self.note_auth_failure(ip);
759 eprintln!("areev: console auth FAILED from {ip} ({consecutive} consecutive)");
760 } else {
761 self.reset_auth_failures(ip);
762 }
763 }
764 }
765
766 let auth_challenge = if self.auth_all && status.starts_with("401") {
769 "WWW-Authenticate: Basic realm=\"Areev console\", charset=\"UTF-8\"\r\n"
770 } else {
771 ""
772 };
773 drop(reader);
774 let mut out = conn;
775 write!(
776 out,
777 "HTTP/1.1 {status}\r\nContent-Type: {ctype}\r\n{auth_challenge}Content-Length: {}\r\nConnection: close\r\n\r\n",
778 payload.len()
779 )?;
780 out.write_all(&payload)?;
781 out.flush()
782 }
783
784 fn runner(&self, principal: &str) -> areev_run::Runner {
789 struct NoExec;
790 impl areev_run::HostToolExecutor for NoExec {
791 fn execute(
792 &self,
793 tool_name: &str,
794 _h: &str,
795 _i: &Value,
796 _k: &str,
797 ) -> areev_run::ExecResult {
798 areev_run::ExecResult::Err {
799 cause: areev_run::FailCause::ExecutorError,
800 detail: format!("the console does not execute tools ('{tool_name}')"),
801 }
802 }
803 }
804 areev_run::Runner {
805 facade: std::sync::Arc::clone(&self.facade),
806 clock: std::sync::Arc::new(areev_run::SystemClock),
807 executor: std::sync::Arc::new(NoExec),
808 llm: None,
809 observer: None,
810 ns: self
811 .facade
812 .default_namespace()
813 .unwrap_or("shared")
814 .to_string(),
815 principal: principal.to_string(),
816 }
817 }
818
819 fn route(
820 &self,
821 method: &str,
822 path: &str,
823 body: &[u8],
824 bearer: Option<&str>,
825 sso_identity: Option<&str>,
826 ) -> (&'static str, &'static str, Vec<u8>) {
827 let mut shared_secret_ok = false;
839 if let Some(tok) = &self.token {
840 let guarded = self.auth_all || method == "POST";
841 shared_secret_ok = bearer.is_some_and(|b| ct_eq(b.as_bytes(), tok.as_bytes()));
842 let known = shared_secret_ok
843 || sso_identity.is_some()
844 || match (&self.credentials, bearer) {
845 (Some(map), Some(b)) => map.resolve_for_memory(b, &self.db_label).is_ok(),
846 _ => false,
847 };
848 if guarded && !known {
849 return ("401 Unauthorized", "application/json",
850 br#"{"ok":false,"error":"authentication required"}"#.to_vec());
851 }
852 } else if self.credentials.is_none() && sso_identity.is_none() && method == "POST" {
853 let base = path.split_once('?').map(|(p, _)| p).unwrap_or(path);
859 let allowed = base == "/api/cal" && cal_body_is_read_only(body);
860 if !allowed {
861 return ("401 Unauthorized", "application/json",
862 br#"{"ok":false,"error":"read-only console: restart areev ui with --token-env VAR to enable writes"}"#.to_vec());
863 }
864 }
865
866 let _session = match (&self.credentials, shared_secret_ok) {
873 (Some(map), false) => Some(RequestBinding::bind(&self.facade, map, bearer, &self.db_label)),
874 _ => None,
875 };
876 let mut request_principal: Option<String> = match (&self.credentials, shared_secret_ok, bearer)
881 {
882 (Some(map), false, Some(b)) => map.resolve_for_memory(b, &self.db_label).ok().map(str::to_string),
883 _ => None,
884 };
885 let _sso_session = match (&request_principal, sso_identity, shared_secret_ok) {
889 (None, Some(identity), false) => {
890 request_principal = Some(identity.to_string());
891 Some(RequestBinding::bind_identity(&self.facade, identity))
892 }
893 _ => None,
894 };
895
896 let (path, query) = match path.split_once('?') {
897 Some((p, q)) => (p, q),
898 None => (path, ""),
899 };
900 let q = |key: &str| -> Option<String> {
901 query.split('&').find_map(|kv| {
902 let (k, v) = kv.split_once('=')?;
903 (k == key).then(|| urldecode(v))
904 })
905 };
906 match (method, path) {
907 ("GET", "/") => (
908 "200 OK",
909 "text/html; charset=utf-8",
910 CONSOLE_HTML
911 .replace("{{DB}}", &html_escape(&self.db_label_display))
912 .into_bytes(),
913 ),
914 ("POST", "/api/cal") => {
915 let req: Value = serde_json::from_slice(body).unwrap_or(Value::Null);
916 let queryt = req.get("query").and_then(|v| v.as_str()).unwrap_or("");
917 match self.executor.execute(queryt, &*self.facade) {
918 Ok(res) => ok_json(json!({
919 "ok": true,
920 "result": res.result,
921 "warnings": res.warnings,
922 "statement": res.metadata.statement_type,
923 "elapsed_ms": res.metadata.execution_time_ms,
924 })),
925 Err(e) => {
926 let mut err = json!({
929 "ok": false,
930 "error": e.sanitize_for_client(),
931 "code": e.code(),
932 });
933 if let Some(sp) = e.span() {
934 err["span"] = json!({
935 "start": sp.start, "end": sp.end,
936 "line": sp.line, "col": sp.col,
937 });
938 }
939 if let Some(hint) = e.suggestion() {
940 err["suggestion"] = json!(hint);
941 }
942 ok_json(err)
943 }
944 }
945 }
946 ("GET", "/api/stats") => {
947 let stats = self.facade.with_store(|m| m.stats());
948 match stats {
949 Ok(s) => ok_json(json!({
950 "db": self.db_label_display,
951 "grains": s.grains, "current": s.current,
952 "triples": s.triples, "terms": s.terms,
953 "ops": s.ops, "events_indexed": s.events_indexed,
954 })),
955 Err(e) => ok_json(json!({"ok": false, "error": e.to_string()})),
956 }
957 }
958 ("GET", "/api/log") => {
959 let since: i64 = q("since").and_then(|v| v.parse().ok()).unwrap_or(0);
960 let limit: usize = q("limit").and_then(|v| v.parse().ok()).unwrap_or(100);
961 match self.facade.with_store(|m| m.changes_since(since, limit)) {
962 Ok(ops) => {
963 let rows: Vec<Value> = ops
964 .iter()
965 .map(|o| {
966 json!({
967 "op_seq": o.op_seq, "hlc": o.hlc,
968 "op": match o.op { 1 => "add", 2 => "supersede", 3 => "forget", _ => "?" },
969 "hash": o.hash.to_hex(),
970 })
971 })
972 .collect();
973 ok_json(json!(rows))
974 }
975 Err(e) => ok_json(json!({"ok": false, "error": e.to_string()})),
976 }
977 }
978 ("GET", "/api/whoami") => {
982 let a = self.facade.authz();
983 ok_json(json!({
984 "ok": true,
985 "principal": a.principal(),
986 "owner": a.is_owner(),
987 "mode": if self.credentials.is_some() { "credentials" }
988 else if self.token.is_some() { "token" }
989 else { "open" },
990 }))
991 }
992 ("GET", "/api/config") => {
993 let cfg = self.executor.config();
998 let (index_text, embedder_dim, declared_embed, mut warnings) =
999 self.facade.with_store(|m| {
1000 (
1001 m.index_text_enabled(),
1002 m.embedder_dim(),
1003 m.declared_embedding().map(|(mm, d)| (mm.to_string(), d)),
1004 m.open_warnings().to_vec(),
1005 )
1006 });
1007 if let Some((m, d)) = &declared_embed {
1008 if embedder_dim.is_none() {
1009 warnings.push(format!(
1010 "vector leg dormant: file expects {m}@{d}, no embedding backend installed"
1011 ));
1012 }
1013 }
1014 warnings.extend(self.facade.meta_warnings());
1018 let (anon_declared, anon_floor, anon_audit) = self.facade.with_store(|m| {
1022 (
1023 m.anon_declared(),
1024 m.anonymize_egress_floor(),
1025 m.anon_audit_counts(),
1026 )
1027 });
1028 let anonymization = json!({
1029 "policies": anon_declared
1030 .iter()
1031 .map(|(ns, mode)| json!({"ns": ns, "mode": mode}))
1032 .collect::<Vec<_>>(),
1033 "floor": anon_floor,
1034 "audit_counts": anon_audit
1035 .iter()
1036 .map(|(ns, cat, n)| json!({"ns": ns, "category": cat, "count": n}))
1037 .collect::<Vec<_>>(),
1038 });
1039 ok_json(json!({
1040 "ok": true,
1041 "db": self.db_label_display,
1042 "warnings": warnings,
1043 "anonymization": anonymization,
1044 "file": {
1045 "text_index": index_text,
1046 "embedding": declared_embed.map(|(m, d)| json!({"model": m, "dim": d})),
1047 },
1048 "session": {
1049 "namespace": self.facade.session_namespace(),
1050 "mounts": self.facade.mount_aliases(),
1051 "is_owner": self.facade.authz().is_owner(),
1054 },
1055 "store": {
1056 "index_text": index_text,
1057 "embedder": embedder_dim.map(|d| json!({"dim": d})),
1058 },
1059 "recall": {
1060 "fusion": "rrf",
1061 "rrf_k0": areev_store::RRF_K0,
1062 "overfetch_factor": AreevFacade::RECALL_OVERFETCH,
1063 "legs": {
1064 "structural": true,
1065 "bm25": index_text,
1066 "vector": embedder_dim.is_some(),
1067 },
1068 },
1069 "executor": {
1070 "max_limit": cfg.max_limit,
1071 "default_limit": cfg.default_limit,
1072 "tier1_writes": cfg.tier1_enabled,
1073 "namespace_override": cfg.namespace_override,
1074 "user_id_override": cfg.user_id_override,
1075 },
1076 "server": {
1077 "auth_required": self.token.is_some(),
1078 "auth_all": self.auth_all,
1081 },
1082 "persistence": "per-process (host-supplied at open) — not stored in the .db",
1083 }))
1084 }
1085 ("GET", "/api/browse") => {
1086 let limit: i64 = q("limit")
1092 .and_then(|v| v.parse().ok())
1093 .unwrap_or(500)
1094 .clamp(1, 2000);
1095 let built = self.facade.with_store(|m| -> Result<(i64, Vec<Value>), String> {
1096 let total = m.stats().map_err(|e| e.to_string())?.ops as i64;
1097 let after = (total - limit).max(0);
1098 let ops = m.changes_since(after, limit as usize).map_err(|e| e.to_string())?;
1099 let mut rows: Vec<Value> = Vec::with_capacity(ops.len());
1100 let mut idx: std::collections::HashMap<String, usize> =
1101 std::collections::HashMap::new();
1102 for o in &ops {
1103 let hex = o.hash.to_hex();
1104 let op_name = match o.op { 1 => "add", 2 => "supersede", 3 => "forget", _ => "?" };
1105 if o.op == 3 {
1106 match idx.get(&hex) {
1109 Some(&i) => { rows[i]["forgotten"] = json!(true); }
1110 None => rows.push(json!({
1111 "hash": hex, "op_seq": o.op_seq, "hlc": o.hlc,
1112 "op": op_name, "forgotten": true,
1113 })),
1114 }
1115 continue;
1116 }
1117 if let Some(&i) = idx.get(&hex) {
1121 rows[i]["op"] = json!(op_name);
1122 rows[i]["op_seq"] = json!(o.op_seq);
1123 rows[i]["hlc"] = json!(o.hlc);
1124 continue;
1125 }
1126 let mut row = json!({
1127 "hash": hex, "op_seq": o.op_seq, "hlc": o.hlc, "op": op_name,
1128 });
1129 match m.get(&o.hash) {
1130 Ok(g) => {
1131 row["type"] = json!(format!("{:?}", g.grain_type).to_lowercase());
1132 row["fields"] = serde_json::to_value(&g.fields).unwrap_or(Value::Null);
1133 }
1134 Err(_) => { row["missing"] = json!(true); }
1136 }
1137 idx.insert(hex, rows.len());
1138 rows.push(row);
1139 }
1140 let marks: Vec<(String, String)> = rows.iter()
1143 .filter(|r| r["op"] == "supersede")
1144 .filter_map(|r| {
1145 let old = r["fields"].get("derived_from")?.as_str()?;
1146 Some((old.to_string(), r["hash"].as_str()?.to_string()))
1147 })
1148 .collect();
1149 for (old, newer) in marks {
1150 if let Some(&i) = idx.get(&old) {
1151 rows[i]["superseded_by"] = json!(newer);
1152 }
1153 }
1154 rows.reverse();
1155 Ok((total, rows))
1156 });
1157 let ns_param = q("ns");
1174 let built = built.and_then(|(total, rows)| {
1175 let authz = self.facade.authz();
1176 let filter_ns = match ns_param.as_deref() {
1177 Some("*") => {
1178 if !authz.is_owner() {
1179 return Err("not authorized to browse all namespaces".to_string());
1180 }
1181 None
1182 }
1183 Some(ns) => {
1184 authz
1185 .check(areev_core::authz::Verb::Read, ns)
1186 .map_err(|e| e.to_string())?;
1187 Some(ns.to_string())
1188 }
1189 None => self.facade.session_namespace().map(|s| s.to_string()),
1190 };
1191 let Some(ns) = filter_ns else {
1192 return Ok((total, rows));
1193 };
1194 let kept = rows
1195 .into_iter()
1196 .filter(|r| {
1197 r["fields"]
1198 .get("namespace")
1199 .and_then(Value::as_str)
1200 .is_none_or(|got| got == ns)
1201 })
1202 .collect();
1203 Ok((total, kept))
1204 });
1205 match built {
1206 Ok((total, rows)) => ok_json(json!({"ok": true, "total_ops": total, "grains": rows})),
1207 Err(e) => ok_json(json!({"ok": false, "error": e})),
1208 }
1209 }
1210 ("GET", "/api/namespaces") => {
1211 let result = self.facade.with_store(|m| m.namespaces());
1216 match result {
1217 Ok(list) => {
1218 let authz = self.facade.authz();
1219 let owner = authz.is_owner();
1220 let arr: Vec<Value> = list
1221 .into_iter()
1222 .filter(|(ns, _)| {
1223 owner || authz.check(areev_core::authz::Verb::Read, ns).is_ok()
1224 })
1225 .map(|(ns, n)| json!({"namespace": ns, "count": n}))
1226 .collect();
1227 ok_json(json!({"ok": true, "namespaces": arr}))
1228 }
1229 Err(e) => ok_json(json!({"ok": false, "error": e.to_string()})),
1230 }
1231 }
1232 ("GET", "/api/grain") => {
1233 let hash = q("hash").unwrap_or_default();
1234 match areev_core::error::Hash::from_hex(&hash)
1235 .and_then(|h| self.facade.get(&h))
1236 {
1237 Ok(g) => ok_json(json!({
1238 "hash": g.hash.to_hex(),
1239 "type": format!("{:?}", g.grain_type).to_lowercase(),
1240 "fields": g.fields,
1241 })),
1242 Err(e) => ok_json(json!({"ok": false, "error": e.to_string()})),
1243 }
1244 }
1245 ("GET", "/api/anon/preview") => {
1246 let hash = q("hash").unwrap_or_default();
1249 match areev_core::error::Hash::from_hex(&hash)
1250 .and_then(|h| self.facade.with_store(|m| m.anon_preview(&h)))
1251 {
1252 Ok(g) => ok_json(json!({
1253 "hash": g.hash.to_hex(),
1254 "type": format!("{:?}", g.grain_type).to_lowercase(),
1255 "fields": g.fields,
1256 })),
1257 Err(e) => ok_json(json!({"ok": false, "error": e.to_string()})),
1258 }
1259 }
1260 ("GET", "/api/verify") => match self.facade.with_store(|m| m.verify()) {
1261 Ok(r) => ok_json(json!({
1262 "integrity": r.integrity, "grains": r.grains,
1263 "hash_mismatches": r.hash_mismatches, "undecodable": r.undecodable,
1264 })),
1265 Err(e) => ok_json(json!({"ok": false, "error": e.to_string()})),
1266 },
1267 ("GET", "/api/run/list") => {
1271 let runner = self.runner("console:read");
1272 match runner.recent_runs(50) {
1273 Ok(ids) => {
1274 let rows: Vec<Value> = ids
1275 .iter()
1276 .map(|id| {
1277 let outcome = self.facade.with_store(|m| {
1278 m.recent(
1279 "agent:harness",
1280 Some(areev_core::types::GrainType::Observation),
1281 4096,
1282 )
1283 })
1284 .ok()
1285 .and_then(|rows| {
1286 rows.into_iter().find(|g| {
1287 g.get_str("run_id") == Some(id.as_str())
1288 && g.get_str("observation_kind")
1289 == Some("run_outcome")
1290 })
1291 });
1292 serde_json::json!({
1293 "run_id": id,
1294 "outcome": outcome.as_ref().and_then(|g| g.get_str("object")).unwrap_or("open"),
1295 })
1296 })
1297 .collect();
1298 ok_json(serde_json::json!({"ok": true, "runs": rows}))
1299 }
1300 Err(e) => run_api_err(&e.to_string()),
1301 }
1302 }
1303 ("GET", "/api/run/inspect") => {
1304 let Some(run_id) = q("run_id") else {
1305 return run_api_err("run_id required");
1306 };
1307 match self.runner("console:read").inspect(&run_id) {
1311 Ok(report) => {
1312 let mut v = serde_json::to_value(&report).unwrap_or(Value::Null);
1313 if let Some(o) = v.as_object_mut() {
1314 o.insert("ok".into(), Value::Bool(true));
1315 }
1316 ok_json(v)
1317 }
1318 Err(e) => run_api_err(&e.to_string()),
1319 }
1320 }
1321 ("POST", "/api/run/respond") => {
1322 let Some(responder) = request_principal.clone() else {
1327 return ("403 Forbidden", "application/json",
1328 br#"{"ok":false,"error":"run.respond requires a per-principal credential (areev ui --auth <map>); shared-token or anonymous approvals are refused - the approver's identity IS the audit record"}"#.to_vec());
1329 };
1330 let req: Value = serde_json::from_slice(body).unwrap_or(Value::Null);
1331 let (Some(run_id), Some(ask)) = (
1332 req.get("run_id").and_then(Value::as_str),
1333 req.get("tool_call_id").and_then(Value::as_str),
1334 ) else {
1335 return run_api_err("run_id and tool_call_id required");
1336 };
1337 let result = req.get("result").cloned().unwrap_or(Value::Null);
1338 let is_error = req.get("is_error").and_then(Value::as_bool).unwrap_or(false);
1339 let runner = self.runner(&responder);
1340 match runner.respond(run_id, ask, result, is_error, &responder) {
1341 Ok(()) => ok_json(serde_json::json!({
1342 "ok": true, "responded": ask, "responder": responder,
1343 "note": "resume the run to spend the compute",
1344 })),
1345 Err(e) => run_api_err(&e.to_string()),
1346 }
1347 }
1348 ("POST", "/api/run/cancel") => {
1349 let req: Value = serde_json::from_slice(body).unwrap_or(Value::Null);
1352 let Some(run_id) = req.get("run_id").and_then(Value::as_str) else {
1353 return run_api_err("run_id required");
1354 };
1355 let because = req.get("because").and_then(Value::as_str).unwrap_or("canceled via console");
1356 let who = request_principal.clone().unwrap_or_else(|| "console:shared".into());
1357 let runner = self.runner(&who);
1358 match runner.cancel(run_id, &who, because) {
1359 Ok(()) => ok_json(serde_json::json!({"ok": true, "canceled": run_id})),
1360 Err(e) => run_api_err(&e.to_string()),
1361 }
1362 }
1363 ("GET", "/api/loop/recommendations") => {
1364 let status = q("status").and_then(|s| status_from_str(&s));
1365 let sub = BorrowedSubstrate::new(&self.facade);
1366 match self.engine().recommendations(&sub, status) {
1367 Ok(recs) => ok_json(json!({
1368 "ok": true,
1369 "recommendations": recs.iter().map(rec_json).collect::<Vec<_>>(),
1370 })),
1371 Err(e) => ok_json(json!({"ok": false, "error": e.to_string(), "code": e.code()})),
1372 }
1373 }
1374 ("GET", "/api/loop/health") => {
1375 let sub = BorrowedSubstrate::new(&self.facade);
1376 match self.engine().health(&sub, now_ms()) {
1377 Ok(h) => ok_json(json!({"ok": true, "health": h})),
1378 Err(e) => ok_json(json!({"ok": false, "error": e.to_string(), "code": e.code()})),
1379 }
1380 }
1381 ("GET", "/api/loop/outcomes") => {
1382 let sub = BorrowedSubstrate::new(&self.facade);
1383 match self.engine().outcomes(&sub) {
1384 Ok(o) => ok_json(json!({"ok": true, "outcomes": o})),
1385 Err(e) => ok_json(json!({"ok": false, "error": e.to_string(), "code": e.code()})),
1386 }
1387 }
1388 ("GET", "/api/loop/analyzers") => {
1389 let sub = BorrowedSubstrate::new(&self.facade);
1392 match self.engine().analyzer_settings(&sub) {
1393 Ok(list) => ok_json(json!({"ok": true, "analyzers": list})),
1394 Err(e) => ok_json(json!({"ok": false, "error": e.to_string(), "code": e.code()})),
1395 }
1396 }
1397 ("GET", "/api/loop/telemetry") => {
1398 let mode = self.facade.with_store(|m| m.telemetry_mode());
1401 if mode == areev_store::TelemetryMode::Off {
1402 ok_json(json!({"ok": true, "enabled": false}))
1403 } else {
1404 let access = self.facade.with_store(|m| m.telemetry_access_stats(None));
1405 let queries = self.facade.with_store(|m| m.telemetry_query_stats(None));
1406 let budget = self.facade.with_store(|m| m.telemetry_budget_stats());
1407 match (access, queries, budget) {
1408 (Ok(mut a), Ok(mut q), Ok(b)) => {
1409 a.sort_by_key(|x| std::cmp::Reverse(x.recall_count));
1411 a.truncate(200);
1412 q.sort_by_key(|x| std::cmp::Reverse(x.run_count));
1413 q.truncate(200);
1414 ok_json(json!({
1415 "ok": true,
1416 "enabled": true,
1417 "mode": mode.as_str(),
1418 "access": a.iter().map(|x| json!({
1419 "hash": x.hash, "recall_count": x.recall_count, "last_ms": x.last_ms,
1420 })).collect::<Vec<_>>(),
1421 "queries": q.iter().map(|x| json!({
1422 "sample": x.sample, "run_count": x.run_count,
1423 "empty_count": x.empty_count,
1424 })).collect::<Vec<_>>(),
1425 "budget": {
1426 "sample_count": b.sample_count,
1427 "overflow_count": b.overflow_count,
1428 },
1429 }))
1430 }
1431 _ => ok_json(json!({"ok": false, "error": "telemetry read failed"})),
1432 }
1433 }
1434 }
1435 ("POST", "/api/loop/run") => {
1436 let authz = self.facade.authz();
1437 if !authz.is_owner()
1438 && !authz.allows(areev_core::authz::Verb::LoopRun, areev_loop::LOOP_NS)
1439 {
1440 return ("403 Forbidden", "application/json",
1441 br#"{"ok":false,"error":"AUT-E001: this session lacks loop.run"}"#.to_vec());
1442 }
1443 let mut sub = BorrowedSubstrate::new(&self.facade);
1444 let actor = if self.credentials.is_some() {
1449 authz.principal().to_string()
1450 } else {
1451 serde_json::from_slice::<Value>(body)
1452 .ok()
1453 .and_then(|v| v.get("actor").and_then(Value::as_str).map(str::to_string))
1454 .unwrap_or_else(|| "user:console".to_string())
1455 };
1456 let opts = RunOptions { triggering_actor: Some(actor), ..Default::default() };
1457 match self.engine().run(&mut sub, &opts, now_ms()) {
1458 Ok(res) => ok_json(json!({"ok": true, "run": res})),
1459 Err(e) => ok_json(json!({"ok": false, "error": e.to_string(), "code": e.code()})),
1460 }
1461 }
1462 ("POST", "/api/loop/review") => self.loop_review(body),
1463 ("POST", "/api/loop/apply") => self.loop_apply(body),
1464 ("POST", "/api/loop/rollback") => self.loop_rollback(body),
1465 ("POST", "/api/loop/config") => self.loop_config(body),
1466 ("POST", "/api/anon/config") => self.anon_config(body),
1467 _ => (
1468 "404 Not Found",
1469 "application/json",
1470 br#"{"ok":false,"error":"not found"}"#.to_vec(),
1471 ),
1472 }
1473 }
1474
1475 fn loop_review(&self, body: &[u8]) -> (&'static str, &'static str, Vec<u8>) {
1480 let req: Value = serde_json::from_slice(body).unwrap_or(Value::Null);
1481 let hash = req.get("hash").and_then(Value::as_str).unwrap_or("");
1482 let because = req.get("because").and_then(Value::as_str).unwrap_or("");
1483 let bound = self.facade.authz();
1484 let actor = if self.credentials.is_some() {
1485 bound.principal().to_string()
1486 } else {
1487 req.get("actor").and_then(Value::as_str).unwrap_or("user:console").to_string()
1488 };
1489 let actor = actor.as_str();
1490 let decision = if req.get("decision").and_then(Value::as_str) == Some("reject") {
1491 Decision::Reject
1492 } else {
1493 Decision::Approve
1494 };
1495 let mut sub = BorrowedSubstrate::new(&self.facade);
1496 let scopes = areev_loop_adapter::scopes_for(&self.facade.authz());
1497 let observer = areev_loop_adapter::observer_for_principal(actor);
1498 match self.engine().review(
1499 &mut sub, hash, decision, actor, observer, &scopes, because, now_ms(),
1500 ) {
1501 Ok(()) => ok_json(json!({"ok": true})),
1502 Err(e) => ok_json(json!({"ok": false, "error": e.to_string(), "code": e.code()})),
1503 }
1504 }
1505
1506 fn loop_apply(&self, body: &[u8]) -> (&'static str, &'static str, Vec<u8>) {
1507 let req: Value = serde_json::from_slice(body).unwrap_or(Value::Null);
1508 let hash = req.get("hash").and_then(Value::as_str).unwrap_or("");
1509 let because = req.get("because").and_then(Value::as_str).unwrap_or("");
1510 let bound = self.facade.authz();
1511 let actor = if self.credentials.is_some() {
1512 bound.principal().to_string()
1513 } else {
1514 req.get("actor").and_then(Value::as_str).unwrap_or("user:console").to_string()
1515 };
1516 let actor = actor.as_str();
1517 let allow_destructive = req.get("allow_destructive").and_then(Value::as_bool).unwrap_or(false);
1518 let mut sub = BorrowedSubstrate::new(&self.facade);
1519 let scopes = areev_loop_adapter::scopes_for(&self.facade.authz());
1520 let observer = areev_loop_adapter::observer_for_principal(actor);
1521 let engine = self.engine();
1522 let gating = match req.get("gating_run").and_then(Value::as_str) {
1526 Some(run_id) => match engine.gating_evidence(&sub, hash, run_id) {
1527 Ok(g) => Some(g),
1528 Err(e) => {
1529 return ok_json(json!({"ok": false, "error": e.to_string(), "code": e.code()}))
1530 }
1531 },
1532 None => None,
1533 };
1534 let applied = match &gating {
1535 Some(g) => engine.apply_gated(
1536 &mut sub, hash, actor, observer, &scopes, because, allow_destructive, g, now_ms(),
1537 ),
1538 None => engine.apply(
1539 &mut sub, hash, actor, observer, &scopes, because, allow_destructive, now_ms(),
1540 ),
1541 };
1542 match applied {
1543 Ok(applied) => ok_json(json!({"ok": true, "rollbackable": applied.rollbackable})),
1544 Err(e) => ok_json(json!({"ok": false, "error": e.to_string(), "code": e.code()})),
1545 }
1546 }
1547
1548 fn loop_rollback(&self, body: &[u8]) -> (&'static str, &'static str, Vec<u8>) {
1549 let req: Value = serde_json::from_slice(body).unwrap_or(Value::Null);
1550 let hash = req.get("hash").and_then(Value::as_str).unwrap_or("");
1551 let because = req.get("because").and_then(Value::as_str).unwrap_or("");
1552 let bound = self.facade.authz();
1553 let actor = if self.credentials.is_some() {
1554 bound.principal().to_string()
1555 } else {
1556 req.get("actor").and_then(Value::as_str).unwrap_or("user:console").to_string()
1557 };
1558 let actor = actor.as_str();
1559 let mut sub = BorrowedSubstrate::new(&self.facade);
1560 let scopes = areev_loop_adapter::scopes_for(&self.facade.authz());
1561 let observer = areev_loop_adapter::observer_for_principal(actor);
1562 match self.engine().rollback(
1563 &mut sub, hash, actor, observer, &scopes, because, now_ms(),
1564 ) {
1565 Ok(()) => ok_json(json!({"ok": true})),
1566 Err(e) => ok_json(json!({"ok": false, "error": e.to_string(), "code": e.code()})),
1567 }
1568 }
1569
1570 fn loop_config(&self, body: &[u8]) -> (&'static str, &'static str, Vec<u8>) {
1576 let id = serde_json::from_slice::<Value>(body)
1577 .ok()
1578 .and_then(|v| v.get("analyzer_id").and_then(Value::as_str).map(str::to_string))
1579 .unwrap_or_default();
1580 let update: areev_loop::AnalyzerConfigUpdate = serde_json::from_slice(body).unwrap_or_default();
1582 let mut sub = BorrowedSubstrate::new(&self.facade);
1583 match self.engine().set_analyzer_config(&mut sub, &id, update, &areev_loop_adapter::scopes_for(&self.facade.authz())) {
1584 Ok(cfg) => ok_json(json!({"ok": true, "config": cfg})),
1585 Err(e) => ok_json(json!({"ok": false, "error": e.to_string(), "code": e.code()})),
1586 }
1587 }
1588}
1589
1590impl UiServer {
1591 fn anon_config(&self, body: &[u8]) -> (&'static str, &'static str, Vec<u8>) {
1596 let v: Value = match serde_json::from_slice(body) {
1597 Ok(v) => v,
1598 Err(e) => return ok_json(json!({"ok": false, "error": e.to_string()})),
1599 };
1600 let Some(ns) = v.get("ns").and_then(Value::as_str).filter(|n| !n.trim().is_empty())
1601 else {
1602 return ok_json(json!({"ok": false, "error": "ns is required"}));
1603 };
1604 let result = if v.get("clear").and_then(Value::as_bool) == Some(true) {
1605 self.facade.with_store(|m| m.clear_anon_policy(ns))
1606 } else {
1607 match v.get("policy") {
1608 Some(policy) => {
1609 let policy_json = policy.to_string();
1610 self.facade.with_store(|m| m.set_anon_policy(ns, &policy_json))
1611 }
1612 None => {
1613 return ok_json(json!({"ok": false, "error": "policy (object) or clear: true is required"}));
1614 }
1615 }
1616 };
1617 match result {
1618 Ok(()) => {
1619 let declared = self.facade.with_store(|m| m.anon_declared());
1620 ok_json(json!({"ok": true, "policies": declared
1621 .iter()
1622 .map(|(ns, mode)| json!({"ns": ns, "mode": mode}))
1623 .collect::<Vec<_>>()}))
1624 }
1625 Err(e) => ok_json(json!({"ok": false, "error": e.to_string()})),
1626 }
1627 }
1628}
1629
1630fn host_is_local(host_port: &str) -> bool {
1634 let host = if let Some(h) = host_port.strip_prefix('[') {
1635 h.split(']').next().unwrap_or("")
1636 } else {
1637 host_port.split(':').next().unwrap_or("")
1638 };
1639 matches!(host, "127.0.0.1" | "localhost" | "::1")
1640}
1641
1642fn origin_is_local(origin: &str) -> bool {
1644 let rest = origin
1645 .strip_prefix("http://")
1646 .or_else(|| origin.strip_prefix("https://"));
1647 let Some(rest) = rest else { return false };
1648 let host_port = rest.split('/').next().unwrap_or("");
1649 host_is_local(host_port)
1650}
1651
1652fn normalize_origin(origin: &str) -> String {
1658 let trimmed = origin.trim();
1659 trimmed.strip_suffix('/').unwrap_or(trimmed).to_ascii_lowercase()
1660}
1661
1662pub fn redact_dsn(label: &str) -> String {
1683 let Some(scheme_end) = label.find("://") else {
1684 return label.to_string();
1685 };
1686 let authority_start = scheme_end + 3;
1687 let rest = &label[authority_start..];
1688 let authority_len = rest.find(['/', '?', '#']).unwrap_or(rest.len());
1689 let authority = &rest[..authority_len];
1690 let Some(at) = authority.rfind('@') else {
1691 return label.to_string();
1692 };
1693 let userinfo = &authority[..at];
1694 let Some(colon) = userinfo.find(':') else {
1695 return label.to_string();
1696 };
1697 let user = &userinfo[..colon];
1698 let mut out = String::with_capacity(label.len());
1699 out.push_str(&label[..authority_start]);
1700 out.push_str(user);
1701 out.push_str(":***@");
1702 out.push_str(&authority[at + 1..]);
1703 out.push_str(&rest[authority_len..]);
1704 out
1705}
1706
1707fn ok_json(v: Value) -> (&'static str, &'static str, Vec<u8>) {
1708 ("200 OK", "application/json", v.to_string().into_bytes())
1709}
1710
1711fn run_api_err(msg: &str) -> (&'static str, &'static str, Vec<u8>) {
1712 (
1713 "400 Bad Request",
1714 "application/json",
1715 serde_json::json!({"ok": false, "error": msg}).to_string().into_bytes(),
1716 )
1717}
1718
1719fn cal_body_is_read_only(body: &[u8]) -> bool {
1726 let req: Value = serde_json::from_slice(body).unwrap_or(Value::Null);
1727 let q = req.get("query").and_then(Value::as_str).unwrap_or("");
1728 areev_cal::classify::query_is_read_only(q)
1729}
1730
1731fn status_from_str(s: &str) -> Option<RecStatus> {
1732 match s {
1733 "pending" => Some(RecStatus::Pending),
1734 "approved" => Some(RecStatus::Approved),
1735 "rejected" => Some(RecStatus::Rejected),
1736 "applied" => Some(RecStatus::Applied),
1737 "rolled_back" => Some(RecStatus::RolledBack),
1738 "expired" => Some(RecStatus::Expired),
1739 _ => None, }
1741}
1742
1743fn rec_json(r: &areev_loop::Recommendation) -> Value {
1744 json!({
1745 "hash": r.hash,
1746 "status": r.status.as_str(),
1747 "severity": r.severity.as_str(),
1748 "analyzer": r.analyzer,
1749 "summary": r.summary.render(),
1750 "target_ref": r.target_ref,
1751 "destructive": r.destructive,
1752 "rollbackable": r.rollbackable,
1753 "evidence": r.evidence,
1754 "evalset_hash": r.evalset_hash,
1757 })
1758}
1759
1760fn urldecode(s: &str) -> String {
1761 let mut out = Vec::new();
1762 let b = s.as_bytes();
1763 let mut i = 0;
1764 while i < b.len() {
1765 match b[i] {
1766 b'%' if i + 2 < b.len() + 1 && i + 2 < b.len() + 1 => {
1767 if i + 2 < b.len() + 1 && i + 2 < b.len() {
1768 let hex = std::str::from_utf8(&b[i + 1..i + 3]).unwrap_or("");
1769 if let Ok(v) = u8::from_str_radix(hex, 16) {
1770 out.push(v);
1771 i += 3;
1772 continue;
1773 }
1774 }
1775 out.push(b[i]);
1776 i += 1;
1777 }
1778 b'+' => {
1779 out.push(b' ');
1780 i += 1;
1781 }
1782 c => {
1783 out.push(c);
1784 i += 1;
1785 }
1786 }
1787 }
1788 String::from_utf8_lossy(&out).to_string()
1789}
1790
1791fn html_escape(s: &str) -> String {
1792 s.replace('&', "&").replace('<', "<").replace('>', ">")
1793}
1794
1795fn ct_eq(a: &[u8], b: &[u8]) -> bool {
1798 if a.len() != b.len() {
1799 return false;
1800 }
1801 let mut diff = 0u8;
1802 for (x, y) in a.iter().zip(b.iter()) {
1803 diff |= x ^ y;
1804 }
1805 diff == 0
1806}
1807
1808fn base64_decode(s: &str) -> Option<Vec<u8>> {
1812 areev_core::b64::decode(s)
1813}
1814
1815fn basic_auth_password(b64: &str) -> Option<String> {
1819 let text = String::from_utf8(base64_decode(b64)?).ok()?;
1820 text.split_once(':').map(|(_user, pass)| pass.to_string())
1821}
1822
1823#[cfg(test)]
1824mod loop_route_tests {
1825 use super::UiServer;
1826 use areev_cal::AreevFacade;
1827 use areev_core::types::{Fact, Grain};
1828 use areev_store::Areev;
1829
1830 fn server(auth: Option<&str>) -> UiServer {
1831 let dir = tempfile::tempdir().unwrap();
1832 let path = dir.path().join("t.db");
1833 let mut store = Areev::open(path.to_str().unwrap()).unwrap();
1834 store.add(&Fact::new("acme", "tier", "Enterprise").namespace("caller")).unwrap();
1836 store.add(&Fact::new("acme", "tier", "Enterprise").namespace("caller")).unwrap();
1837 std::mem::forget(dir); let facade = AreevFacade::with_session(store, Some("caller".into()), None);
1839 let s = UiServer::new(facade, "test".into());
1840 match auth {
1841 Some(t) => s.with_auth(t.to_string()),
1842 None => s,
1843 }
1844 }
1845
1846 fn text(r: &(&str, &str, Vec<u8>)) -> String {
1847 String::from_utf8_lossy(&r.2).to_string()
1848 }
1849
1850 #[test]
1851 fn token_less_console_is_read_only() {
1852 let s = server(None);
1853 assert!(s.route("POST", "/api/loop/run", b"{}", None, None).0.starts_with("401"));
1855 let w = s.route("POST", "/api/cal", br#"{"query":"ADD fact SET subject=\"x\""}"#, None, None);
1857 assert!(w.0.starts_with("401"), "write CAL must be 401: {}", text(&w));
1858 let r = s.route("POST", "/api/cal", br#"{"query":"RECALL facts WHERE subject = \"acme\""}"#, None, None);
1860 assert!(r.0.starts_with("200"), "read CAL allowed: {}", text(&r));
1861 assert!(s.route("GET", "/api/loop/recommendations", b"", None, None).0.starts_with("200"));
1863 }
1864
1865 #[test]
1866 fn telemetry_endpoint_is_an_open_read() {
1867 let s = server(None);
1868 let r = s.route("GET", "/api/loop/telemetry", b"", None, None);
1869 assert!(r.0.starts_with("200"), "telemetry read open: {}", text(&r));
1870 let body = text(&r);
1871 assert!(body.contains("\"ok\":true"), "{body}");
1872 assert!(body.contains("\"enabled\":false"), "{body}");
1874 }
1875
1876 #[test]
1877 fn authenticated_run_review_apply_roundtrip() {
1878 let s = server(Some("tok"));
1879 let run = s.route("POST", "/api/loop/run", b"{}", Some("tok"), None);
1880 assert!(run.0.starts_with("200"), "run: {}", text(&run));
1881 assert!(text(&run).contains("\"ran\""));
1882
1883 let list = s.route("GET", "/api/loop/recommendations?status=pending", b"", Some("tok"), None);
1884 let v: serde_json::Value = serde_json::from_slice(&list.2).unwrap();
1885 let recs = v["recommendations"].as_array().unwrap();
1886 assert!(!recs.is_empty(), "at least one recommendation");
1887 let hash = recs[0]["hash"].as_str().unwrap().to_string();
1888
1889 let rev = s.route(
1890 "POST",
1891 "/api/loop/review",
1892 format!(r#"{{"hash":"{hash}","decision":"approve","because":"ok"}}"#).as_bytes(),
1893 Some("tok"),
1894 None,
1895 );
1896 assert!(text(&rev).contains("\"ok\":true"), "review: {}", text(&rev));
1897
1898 let ap = s.route(
1899 "POST",
1900 "/api/loop/apply",
1901 format!(r#"{{"hash":"{hash}","because":"go"}}"#).as_bytes(),
1902 Some("tok"),
1903 None,
1904 );
1905 assert!(text(&ap).contains("\"ok\":true"), "apply: {}", text(&ap));
1906 }
1907
1908 #[test]
1909 fn config_edit_toggles_analyzer_via_console() {
1910 let s = server(Some("tok"));
1911 assert!(s.route("POST", "/api/loop/config", b"{}", None, None).0.starts_with("401"));
1913
1914 let list = s.route("GET", "/api/loop/analyzers", b"", Some("tok"), None);
1916 let v: serde_json::Value = serde_json::from_slice(&list.2).unwrap();
1917 let id = v["analyzers"]
1918 .as_array()
1919 .unwrap()
1920 .iter()
1921 .find(|x| x["enabled"] == true)
1922 .expect("some analyzer is on")["id"]
1923 .as_str()
1924 .unwrap()
1925 .to_string();
1926
1927 let post = s.route(
1929 "POST",
1930 "/api/loop/config",
1931 format!(r#"{{"analyzer_id":"{id}","enabled":false}}"#).as_bytes(),
1932 Some("tok"),
1933 None,
1934 );
1935 assert!(text(&post).contains("\"ok\":true"), "config: {}", text(&post));
1936
1937 let list2 = s.route("GET", "/api/loop/analyzers", b"", Some("tok"), None);
1939 let v2: serde_json::Value = serde_json::from_slice(&list2.2).unwrap();
1940 let now = v2["analyzers"]
1941 .as_array()
1942 .unwrap()
1943 .iter()
1944 .find(|x| x["id"] == id.as_str())
1945 .unwrap();
1946 assert_eq!(now["enabled"], false, "toggled off and persisted");
1947 }
1948
1949 #[test]
1950 fn self_approval_surfaces_lop_code() {
1951 let s = server(Some("tok"));
1952 s.route("POST", "/api/loop/run", b"{}", Some("tok"), None);
1953 let list = s.route("GET", "/api/loop/recommendations?status=pending", b"", Some("tok"), None);
1954 let v: serde_json::Value = serde_json::from_slice(&list.2).unwrap();
1955 let rec = &v["recommendations"].as_array().unwrap()[0];
1956 let hash = rec["hash"].as_str().unwrap();
1957 let analyzer = rec["analyzer"].as_str().unwrap();
1958 let creator = format!("engine:{analyzer}");
1959 let rev = s.route(
1961 "POST",
1962 "/api/loop/review",
1963 format!(r#"{{"hash":"{hash}","decision":"approve","because":"self","actor":"{creator}"}}"#).as_bytes(),
1964 Some("tok"),
1965 None,
1966 );
1967 assert!(text(&rev).contains("LOP-E021"), "self-approval blocked: {}", text(&rev));
1968 }
1969}
1970
1971#[cfg(test)]
1972mod security_tests {
1973 use super::{base64_decode, basic_auth_password, ct_eq, normalize_origin, redact_dsn};
1974
1975 #[test]
1976 fn ct_eq_matches_only_equal() {
1977 assert!(ct_eq(b"secret-token", b"secret-token"));
1978 assert!(!ct_eq(b"secret-token", b"secret-toker"));
1979 assert!(!ct_eq(b"secret", b"secret-token")); assert!(ct_eq(b"", b""));
1981 }
1982
1983 #[test]
1984 fn base64_decode_roundtrips_known_vectors() {
1985 assert_eq!(base64_decode("").unwrap(), b"");
1987 assert_eq!(base64_decode("Zg==").unwrap(), b"f");
1988 assert_eq!(base64_decode("Zm8=").unwrap(), b"fo");
1989 assert_eq!(base64_decode("Zm9v").unwrap(), b"foo");
1990 assert_eq!(base64_decode("Zm9vYmFy").unwrap(), b"foobar");
1991 assert_eq!(base64_decode("Zg").unwrap(), b"f");
1993 assert!(base64_decode("****").is_none());
1995 }
1996
1997 #[test]
1998 fn basic_auth_extracts_password_ignoring_username() {
1999 assert_eq!(basic_auth_password("ZGVqYTpzM2NyZXQ=").as_deref(), Some("s3cret"));
2001 assert_eq!(basic_auth_password("OnMzY3JldA==").as_deref(), Some("s3cret"));
2002 assert_eq!(basic_auth_password("dTphOmI=").as_deref(), Some("a:b"));
2005 assert_eq!(basic_auth_password("bm9jb2xvbg=="), None); assert_eq!(basic_auth_password("****"), None); }
2009
2010 #[test]
2013 fn redact_dsn_hides_a_password() {
2014 assert_eq!(
2015 redact_dsn("postgresql://rounic:SUPERSECRET@pg-x:5432/rounic?sslmode=verify-full&schema=desk_invoice"),
2016 "postgresql://rounic:***@pg-x:5432/rounic?sslmode=verify-full&schema=desk_invoice",
2017 );
2018 }
2019
2020 #[test]
2021 fn redact_dsn_leaves_no_password_alone() {
2022 assert_eq!(redact_dsn("postgres://user@host:5432/db"), "postgres://user@host:5432/db");
2023 }
2024
2025 #[test]
2026 fn redact_dsn_leaves_no_userinfo_alone() {
2027 assert_eq!(redact_dsn("postgres://host:5432/db"), "postgres://host:5432/db");
2028 }
2029
2030 #[test]
2031 fn redact_dsn_leaves_a_plain_file_path_alone() {
2032 assert_eq!(redact_dsn("demo.db"), "demo.db");
2033 assert_eq!(redact_dsn("/var/lib/areev/demo.db"), "/var/lib/areev/demo.db");
2034 }
2035
2036 #[test]
2037 fn redact_dsn_hides_a_percent_encoded_password() {
2038 assert_eq!(
2041 redact_dsn("postgres://user:pa%40ss@host:5432/db"),
2042 "postgres://user:***@host:5432/db",
2043 );
2044 }
2045
2046 #[test]
2047 fn redact_dsn_hides_a_password_with_a_literal_at_sign() {
2048 assert_eq!(
2051 redact_dsn("postgres://user:p@ss@host:5432/db"),
2052 "postgres://user:***@host:5432/db",
2053 );
2054 }
2055
2056 #[test]
2057 fn redact_dsn_ignores_colon_and_at_in_the_query_string() {
2058 assert_eq!(
2059 redact_dsn("postgres://user:pass@host:5432/db?options=-c%20foo:bar@baz"),
2060 "postgres://user:***@host:5432/db?options=-c%20foo:bar@baz",
2061 );
2062 }
2063
2064 #[test]
2065 fn redact_dsn_handles_empty_string() {
2066 assert_eq!(redact_dsn(""), "");
2067 }
2068
2069 #[test]
2072 fn normalize_origin_lowercases_and_drops_one_trailing_slash() {
2073 assert_eq!(normalize_origin("https://Console.Example.com"), "https://console.example.com");
2074 assert_eq!(normalize_origin("https://console.example.com/"), "https://console.example.com");
2075 assert_eq!(
2076 normalize_origin("HTTPS://Console.Example.com/"),
2077 "https://console.example.com",
2078 );
2079 }
2080}
2081
2082#[cfg(test)]
2083mod credential_route_tests {
2084 use super::UiServer;
2085 use areev_cal::AreevFacade;
2086 use areev_core::authz::{CredentialMap, AUTHZ_NS, REL_PERMITS};
2087 use areev_core::types::{Fact, Grain};
2088 use areev_store::Areev;
2089
2090 #[test]
2095 fn loop_apply_honors_a_gating_run() {
2096 let dir = tempfile::tempdir().unwrap();
2097 let path = dir.path().join("g.db");
2098 let mut store = Areev::open(path.to_str().unwrap()).unwrap();
2099 let evalset = store
2101 .add(
2102 &Fact::new("evalset:gate", "mg:evalset", "{\"name\":\"gate\",\"cases\":[]}")
2103 .namespace("agent:harness")
2104 .created_at(1_000),
2105 )
2106 .unwrap()
2107 .to_hex();
2108 store
2109 .add(
2110 &Fact::new(
2111 &format!("evalset:{evalset}"),
2112 "mg:eval_run",
2113 "{\"run_id\":\"eval-1\",\"passed\":3,\"failed\":0}",
2114 )
2115 .namespace("agent:harness")
2116 .created_at(2_000),
2117 )
2118 .unwrap();
2119 store.add(&Fact::new("acme", "tier", "Enterprise").namespace("caller").created_at(3_000)).unwrap();
2120 std::mem::forget(dir);
2121 let facade = AreevFacade::with_session(store, Some("caller".into()), None);
2122 let manifest = facade
2124 .record_corpus_export("RECALL facts", "train.jsonl", None, 4_000, &[], &[])
2125 .unwrap()
2126 .to_hex();
2127 facade
2128 .record_adapter(
2129 r#"{"adapter":{"uri":"file:///tmp/a","sha256":"feed"},"base_model":"qwen3-4b","serves_as":"acme-support"}"#,
2130 &manifest,
2131 &evalset,
2132 5_000,
2133 )
2134 .unwrap();
2135 let s = UiServer::new(facade, "test".into()).with_auth("gate-token".into());
2138 let tok = Some("gate-token");
2139
2140 let run = s.route("POST", "/api/loop/run", b"{}", tok, None);
2142 assert!(run.0.starts_with("200"), "{}", text(&run));
2143 let list = s.route("GET", "/api/loop/recommendations?status=pending", b"", tok, None);
2144 let rows: serde_json::Value = serde_json::from_str(&text(&list)).unwrap();
2145 let rec = rows["recommendations"]
2146 .as_array()
2147 .unwrap()
2148 .iter()
2149 .find(|r| r["analyzer"].as_str().unwrap_or("").contains("adapter_intake"))
2150 .unwrap_or_else(|| panic!("adapter_intake must propose: {rows}"));
2151 assert_eq!(rec["evalset_hash"], evalset.as_str(), "the row must carry the pin");
2152 let hash = rec["hash"].as_str().unwrap().to_string();
2153
2154 let approve = format!(r#"{{"hash":"{hash}","decision":"approve","because":"reviewed"}}"#);
2157 let r = s.route("POST", "/api/loop/review", approve.as_bytes(), tok, None);
2158 assert!(text(&r).contains("\"ok\":true"), "{}", text(&r));
2159 let bare = format!(r#"{{"hash":"{hash}","because":"ship it"}}"#);
2160 let r = s.route("POST", "/api/loop/apply", bare.as_bytes(), tok, None);
2161 assert!(text(&r).contains("gating run"), "ungated must refuse: {}", text(&r));
2162 let bad = format!(r#"{{"hash":"{hash}","because":"ship","gating_run":"eval-nope"}}"#);
2163 let r = s.route("POST", "/api/loop/apply", bad.as_bytes(), tok, None);
2164 assert!(text(&r).contains("no recorded gate run"), "{}", text(&r));
2165 let good = format!(r#"{{"hash":"{hash}","because":"gated and green","gating_run":"eval-1"}}"#);
2166 let r = s.route("POST", "/api/loop/apply", good.as_bytes(), tok, None);
2167 assert!(text(&r).contains("\"ok\":true"), "gated apply failed: {}", text(&r));
2168 }
2169
2170 fn server() -> UiServer {
2173 std::env::set_var("AREEV_TEST_READER_TOK", "reader-secret");
2174 std::env::set_var("AREEV_TEST_WRITER_TOK", "writer-secret");
2175 let dir = tempfile::tempdir().unwrap();
2176 let path = dir.path().join("t.db");
2177 let mut store = Areev::open(path.to_str().unwrap()).unwrap();
2178 store
2179 .add(&Fact::new("user:reader", REL_PERMITS, "read ON *").namespace(AUTHZ_NS).created_at(1_000))
2180 .unwrap();
2181 store
2182 .add(
2183 &Fact::new("agent:writer", REL_PERMITS, "read,write ON caller")
2184 .namespace(AUTHZ_NS)
2185 .created_at(2_000),
2186 )
2187 .unwrap();
2188 store.add(&Fact::new("acme", "tier", "Enterprise").namespace("caller").created_at(3_000)).unwrap();
2189 std::mem::forget(dir);
2190 let facade = AreevFacade::with_session(store, Some("caller".into()), None);
2191 let map = CredentialMap::from_json(
2192 r#"{"version":1,"tokens":[
2193 {"env":"AREEV_TEST_READER_TOK","principal":"user:reader"},
2194 {"env":"AREEV_TEST_WRITER_TOK","principal":"agent:writer"}
2195 ]}"#,
2196 )
2197 .unwrap();
2198 UiServer::new(facade, "test".into()).with_credentials(map)
2199 }
2200
2201 fn text(r: &(&str, &str, Vec<u8>)) -> String {
2202 String::from_utf8_lossy(&r.2).to_string()
2203 }
2204
2205 const READ: &[u8] = br#"{"query":"RECALL facts WHERE subject = \"acme\""}"#;
2206 const WRITE: &[u8] = br#"{"query":"ADD fact SET subject = \"x\" SET relation = \"r\" SET object = \"o\" SET namespace = \"caller\" REASON \"t\""}"#;
2207
2208 #[test]
2209 fn tokens_bind_principals_and_verbs_decide() {
2210 let s = server();
2211
2212 let r = s.route("POST", "/api/cal", READ, Some("reader-secret"), None);
2214 assert!(r.0.starts_with("200"), "{}", text(&r));
2215 assert!(text(&r).contains("grains"));
2216 let w = s.route("POST", "/api/cal", WRITE, Some("reader-secret"), None);
2218 assert!(text(&w).contains("AUT-E001"), "reader write must be refused: {}", text(&w));
2219
2220 let w = s.route("POST", "/api/cal", WRITE, Some("writer-secret"), None);
2222 assert!(text(&w).contains("added"), "writer write must land: {}", text(&w));
2223
2224 let w = s.route("POST", "/api/cal", WRITE, Some("reader-secret"), None);
2227 assert!(text(&w).contains("AUT-E001"), "{}", text(&w));
2228 }
2229
2230 #[test]
2231 fn anonymous_is_read_only_and_loop_run_is_verb_gated() {
2232 let s = server();
2233 let r = s.route("POST", "/api/cal", READ, None, None);
2235 assert!(r.0.starts_with("200"), "{}", text(&r));
2236 assert!(text(&r).contains("grains"));
2237 let w = s.route("POST", "/api/cal", WRITE, None, None);
2239 assert!(text(&w).contains("AUT-E001"), "{}", text(&w));
2240 let w = s.route("POST", "/api/cal", WRITE, Some("stolen-guess"), None);
2242 assert!(text(&w).contains("AUT-E001"), "{}", text(&w));
2243
2244 let run = s.route("POST", "/api/loop/run", b"{}", None, None);
2246 assert!(run.0.starts_with("403"), "{}", text(&run));
2247 }
2248
2249 #[test]
2250 fn whoami_reports_the_bound_principal() {
2251 let s = server();
2252 let r = s.route("GET", "/api/whoami", b"", Some("reader-secret"), None);
2253 assert!(text(&r).contains("user:reader"), "{}", text(&r));
2254 assert!(text(&r).contains("credentials"));
2255 let r = s.route("GET", "/api/whoami", b"", None, None);
2256 assert!(text(&r).contains("anonymous"), "{}", text(&r));
2257 }
2258
2259 #[test]
2263 fn a_credential_map_does_not_disable_the_shared_secret() {
2264 let s = server().with_auth("console-token".into());
2265
2266 let r = s.route("POST", "/api/cal", READ, None, None);
2268 assert!(r.0.starts_with("401"), "{}", text(&r));
2269 let page = s.route("GET", "/", b"", None, None);
2270 assert!(page.0.starts_with("401"), "the console page is guarded too");
2271 let r = s.route("POST", "/api/cal", READ, Some("stolen-guess"), None);
2273 assert!(r.0.starts_with("401"), "{}", text(&r));
2274
2275 let r = s.route("POST", "/api/cal", READ, Some("reader-secret"), None);
2277 assert!(r.0.starts_with("200"), "{}", text(&r));
2278 let w = s.route("POST", "/api/cal", WRITE, Some("reader-secret"), None);
2279 assert!(text(&w).contains("AUT-E001"), "{}", text(&w));
2280
2281 let w = s.route("POST", "/api/cal", WRITE, Some("console-token"), None);
2284 assert!(text(&w).contains("added"), "{}", text(&w));
2285 }
2286
2287 #[test]
2290 fn run_loop_via_cal_is_verb_gated_like_the_route() {
2291 let s = server();
2292 let run = s.route("POST", "/api/loop/run", b"{}", None, None);
2293 assert!(run.0.starts_with("403"), "{}", text(&run));
2294 let cal = s.route("POST", "/api/cal", br#"{"query":"RUN LOOP"}"#, None, None);
2295 assert!(
2296 text(&cal).contains("AUT-E001"),
2297 "anonymous must not run the loop through CAL either: {}",
2298 text(&cal)
2299 );
2300 }
2301}
2302
2303#[cfg(test)]
2304mod namespace_route_tests {
2305 use super::UiServer;
2306 use areev_cal::AreevFacade;
2307 use areev_core::authz::{CredentialMap, AUTHZ_NS, REL_PERMITS};
2308 use areev_core::types::{Fact, Grain};
2309 use areev_store::Areev;
2310
2311 fn text(r: &(&str, &str, Vec<u8>)) -> String {
2312 String::from_utf8_lossy(&r.2).to_string()
2313 }
2314
2315 fn restricted_server() -> UiServer {
2318 std::env::set_var("AREEV_TEST_NS_WRITER_TOK", "writer-secret");
2319 let dir = tempfile::tempdir().unwrap();
2320 let path = dir.path().join("t.db");
2321 let mut store = Areev::open(path.to_str().unwrap()).unwrap();
2322 store
2323 .add(&Fact::new("agent:writer", REL_PERMITS, "read,write ON caller").namespace(AUTHZ_NS).created_at(1_000))
2324 .unwrap();
2325 store.add(&Fact::new("acme", "tier", "Enterprise").namespace("caller").created_at(2_000)).unwrap();
2326 store.add(&Fact::new("bob", "role", "admin").namespace("other").created_at(3_000)).unwrap();
2327 std::mem::forget(dir);
2328 let facade = AreevFacade::with_session(store, Some("caller".into()), None);
2329 let map = CredentialMap::from_json(
2330 r#"{"version":1,"tokens":[{"env":"AREEV_TEST_NS_WRITER_TOK","principal":"agent:writer"}]}"#,
2331 )
2332 .unwrap();
2333 UiServer::new(facade, "test".into()).with_credentials(map)
2334 }
2335
2336 fn owner_server() -> UiServer {
2339 let dir = tempfile::tempdir().unwrap();
2340 let path = dir.path().join("t.db");
2341 let mut store = Areev::open(path.to_str().unwrap()).unwrap();
2342 store.add(&Fact::new("acme", "tier", "Enterprise").namespace("caller").created_at(1_000)).unwrap();
2343 store.add(&Fact::new("bob", "role", "admin").namespace("other").created_at(2_000)).unwrap();
2344 std::mem::forget(dir);
2345 UiServer::new(AreevFacade::with_session(store, Some("caller".into()), None), "t".into())
2346 }
2347
2348 #[test]
2349 fn namespaces_route_hides_what_the_principal_cannot_read() {
2350 let s = restricted_server();
2351 let r = s.route("GET", "/api/namespaces", b"", Some("writer-secret"), None);
2352 let body = text(&r);
2353 assert!(body.contains("\"caller\""), "granted namespace must be listed: {body}");
2354 assert!(!body.contains("\"other\""), "ungranted namespace must not be listed: {body}");
2355 assert!(!body.contains(AUTHZ_NS), "the audit namespace must not leak either: {body}");
2356 }
2357
2358 #[test]
2359 fn namespaces_route_lists_everything_for_the_owner() {
2360 let s = owner_server();
2361 let r = s.route("GET", "/api/namespaces", b"", None, None);
2362 let body = text(&r);
2363 assert!(body.contains("\"caller\"") && body.contains("\"other\""), "{body}");
2364 }
2365
2366 #[test]
2367 fn browse_ns_param_is_read_gated_for_a_restricted_principal() {
2368 let s = restricted_server();
2369 let ok = s.route("GET", "/api/browse?ns=caller", b"", Some("writer-secret"), None);
2370 assert!(text(&ok).contains("\"ok\":true"), "their own granted namespace must work: {}", text(&ok));
2371
2372 let denied = s.route("GET", "/api/browse?ns=other", b"", Some("writer-secret"), None);
2373 let body = text(&denied);
2374 assert!(body.contains("\"ok\":false"), "an ungranted namespace must be refused, not silently emptied: {body}");
2375 }
2376
2377 #[test]
2378 fn browse_ns_star_requires_owner() {
2379 let s = restricted_server();
2380 let r = s.route("GET", "/api/browse?ns=*", b"", Some("writer-secret"), None);
2381 assert!(text(&r).contains("\"ok\":false"), "a restricted principal must not get the unfiltered view: {}", text(&r));
2382
2383 let owner = owner_server();
2384 let r2 = owner.route("GET", "/api/browse?ns=*", b"", None, None);
2385 let body2 = text(&r2);
2386 assert!(body2.contains("\"ok\":true"), "the owner session may lift the filter: {body2}");
2387 assert!(body2.contains("\"caller\"") && body2.contains("\"other\""), "{body2}");
2388 }
2389
2390 #[test]
2391 fn browse_without_ns_param_keeps_original_session_default_behavior() {
2392 let s = owner_server();
2393 let r = s.route("GET", "/api/browse", b"", None, None);
2394 let body = text(&r);
2395 assert!(body.contains("\"caller\""), "{body}");
2396 assert!(!body.contains("\"other\""), "omitting ?ns must behave exactly as before this feature existed: {body}");
2397 }
2398}
2399
2400#[cfg(test)]
2401mod builder_tests {
2402 use super::UiServer;
2403 use areev_cal::AreevFacade;
2404 use areev_store::Areev;
2405
2406 fn server() -> UiServer {
2407 let dir = tempfile::tempdir().unwrap();
2408 let path = dir.path().join("t.db");
2409 let store = Areev::open(path.to_str().unwrap()).unwrap();
2410 std::mem::forget(dir);
2411 UiServer::new(AreevFacade::with_session(store, Some("caller".into()), None), "t".into())
2412 }
2413
2414 #[test]
2418 fn attaching_a_loop_policy_preserves_the_destructive_cap() {
2419 let s = server()
2420 .allow_destructive_ops(false)
2421 .with_loop_policy(areev_loop::Policy::default());
2422 let r = s.route(
2423 "POST",
2424 "/api/cal",
2425 br#"{"query":"FORGET sha256:684c6c9bda818630a870119d0726e4d242ed537af061658ef6f3acb158a2c67d"}"#,
2426 None,
2427 None,
2428 );
2429 let body = String::from_utf8_lossy(&r.2).to_string();
2430 assert!(
2431 body.contains("--no-destructive-ops") || r.0.starts_with("401"),
2432 "destruction must stay disabled: {body}"
2433 );
2434 }
2435}
2436
2437#[cfg(test)]
2438mod run_api_tests {
2439 use super::UiServer;
2440 use areev_cal::AreevFacade;
2441 use areev_core::authz::{CredentialMap, AUTHZ_NS, REL_PERMITS};
2442 use areev_core::types::{Fact, Grain, Tool, ToolKind, Workflow};
2443 use areev_store::Areev;
2444 use std::sync::Arc;
2445
2446 fn parked_server() -> (UiServer, String) {
2449 std::env::set_var("AREEV_RUN_OFFICER_TOK", "officer-secret");
2450 std::env::set_var("AREEV_RUN_STARTER_TOK", "starter-secret");
2451 let dir = tempfile::tempdir().unwrap();
2452 let path = dir.path().join("t.db");
2453 let mut store = Areev::open(path.to_str().unwrap()).unwrap();
2454 for (who, what) in [
2455 ("user:officer", "read,run.respond ON ops"),
2456 ("user:starter", "read ON ops"),
2457 ] {
2458 store
2459 .add(&Fact::new(who, REL_PERMITS, what).namespace(AUTHZ_NS).created_at(900))
2460 .unwrap();
2461 }
2462 std::mem::forget(dir);
2463 let facade = Arc::new(AreevFacade::with_session(store, Some("ops".into()), None));
2464
2465 let (wf, _g, _a) = facade
2467 .with_store(|m| {
2468 let greet = Tool::new("greet")
2469 .kind(ToolKind::Definition)
2470 .tool_description("t")
2471 .created_at(500)
2472 .namespace("ops");
2473 let gh = m.add(&greet)?;
2474 let approve = Tool::new("approve")
2475 .kind(ToolKind::Definition)
2476 .tool_description("human approves")
2477 .executor_kind(areev_core::types::ExecutorKind::Client)
2478 .created_at(501)
2479 .namespace("ops");
2480 let ah = m.add(&approve)?;
2481 let wf = Workflow::new(vec!["greet".into(), "approve".into()])
2482 .edge("greet", "approve")
2483 .bind("greet", &gh.to_hex())
2484 .bind("approve", &ah.to_hex())
2485 .created_at(502)
2486 .namespace("ops");
2487 let wh = m.add(&wf)?;
2488 Ok::<_, areev_core::error::AreevError>((wh, gh, ah))
2489 })
2490 .unwrap();
2491 struct Ok1;
2492 impl areev_run::HostToolExecutor for Ok1 {
2493 fn execute(
2494 &self,
2495 t: &str,
2496 _h: &str,
2497 _i: &serde_json::Value,
2498 _k: &str,
2499 ) -> areev_run::ExecResult {
2500 areev_run::ExecResult::Ok(serde_json::json!({t.to_string(): true}))
2501 }
2502 }
2503 let runner = areev_run::Runner {
2504 facade: Arc::clone(&facade),
2505 clock: Arc::new(areev_run::ScriptedClock::new(
2506 (0..100).map(|i| 1_758_000_000_000 + i * 10).collect(),
2507 )),
2508 executor: Arc::new(Ok1),
2509 llm: None,
2510 observer: None,
2511 ns: "ops".into(),
2512 principal: "user:starter".into(),
2513 };
2514 let session = runner
2515 .start(&wf, "hitl-1", serde_json::json!({}), &areev_run::RunOptions {
2516 workers: 1,
2517 ..Default::default()
2518 })
2519 .unwrap();
2520 let areev_run::RunSession::Parked { envelope, .. } = session else {
2521 panic!("expected park")
2522 };
2523 let ask = envelope["asks"][0]["tool_call_id"].as_str().unwrap().to_string();
2524
2525 drop(runner);
2532 let facade = Arc::try_unwrap(facade).unwrap_or_else(|_| panic!("facade still shared"));
2533 let map = CredentialMap::from_json(
2534 r#"{"version":1,"tokens":[
2535 {"env":"AREEV_RUN_OFFICER_TOK","principal":"user:officer"},
2536 {"env":"AREEV_RUN_STARTER_TOK","principal":"user:starter"}
2537 ]}"#,
2538 )
2539 .unwrap();
2540 let server = UiServer::new(facade, "test".into())
2541 .with_auth("shared-console-secret".into())
2542 .with_credentials(map);
2543 (server, ask)
2544 }
2545
2546 fn body(run_id: &str, ask: &str) -> Vec<u8> {
2547 serde_json::json!({"run_id": run_id, "tool_call_id": ask, "result": {"approved": true}})
2548 .to_string()
2549 .into_bytes()
2550 }
2551
2552 #[test]
2553 fn shared_token_approvals_are_refused_and_principals_approve() {
2554 let (s, ask) = parked_server();
2555
2556 let r = s.route("GET", "/api/run/list", b"", Some("shared-console-secret"), None);
2558 assert!(r.0.starts_with("200"), "{}", String::from_utf8_lossy(&r.2));
2559 let r = s.route(
2560 "GET",
2561 "/api/run/inspect?run_id=hitl-1",
2562 b"",
2563 Some("shared-console-secret"),
2564 None,
2565 );
2566 assert!(String::from_utf8_lossy(&r.2).contains(&ask), "ask visible in inspect");
2567
2568 let r = s.route("POST", "/api/run/respond", &body("hitl-1", &ask), Some("shared-console-secret"), None);
2570 assert!(r.0.starts_with("403"), "shared-token approval must refuse: {}", r.0);
2571
2572 let r = s.route("POST", "/api/run/respond", &body("hitl-1", &ask), Some("starter-secret"), None);
2575 let text = String::from_utf8_lossy(&r.2).to_string();
2576 assert!(text.contains("responder") || text.contains("RUN-E012"), "{text}");
2577
2578 let r = s.route("POST", "/api/run/respond", &body("hitl-1", &ask), Some("officer-secret"), None);
2580 assert!(r.0.starts_with("200"), "{}", String::from_utf8_lossy(&r.2));
2581 assert!(String::from_utf8_lossy(&r.2).contains("user:officer"));
2582
2583 let r = s.route(
2585 "POST",
2586 "/api/run/cancel",
2587 br#"{"run_id":"hitl-1","because":"test brake"}"#,
2588 Some("shared-console-secret"),
2589 None,
2590 );
2591 assert!(r.0.starts_with("200"), "{}", String::from_utf8_lossy(&r.2));
2592 }
2593}
2594
2595#[cfg(test)]
2596mod memory_scoped_token_tests {
2597 use super::UiServer;
2598 use areev_cal::AreevFacade;
2599 use areev_core::authz::{CredentialMap, AUTHZ_NS, REL_PERMITS};
2600 use areev_core::types::{Fact, Grain};
2601 use areev_store::Areev;
2602
2603 #[test]
2607 fn memory_scoped_tokens_do_not_cross_memories() {
2608 std::env::set_var("AREEV_SCOPED_TOK", "scoped-secret");
2609 let map_json = r#"{"version":1,"tokens":[
2610 {"env":"AREEV_SCOPED_TOK","principal":"agent:sync","memories":["memory-a"]}
2611 ]}"#;
2612 let server_for = |label: &str| {
2613 let dir = tempfile::tempdir().unwrap();
2614 let mut store = Areev::open(dir.path().join("t.db").to_str().unwrap()).unwrap();
2615 store
2616 .add(
2617 &Fact::new("agent:sync", REL_PERMITS, "read,write ON caller")
2618 .namespace(AUTHZ_NS)
2619 .created_at(1_000),
2620 )
2621 .unwrap();
2622 store
2623 .add(&Fact::new("acme", "tier", "Enterprise").namespace("caller").created_at(2_000))
2624 .unwrap();
2625 std::mem::forget(dir);
2626 let facade = AreevFacade::with_session(store, Some("caller".into()), None);
2627 UiServer::new(facade, label.into())
2628 .with_credentials(CredentialMap::from_json(map_json).unwrap())
2629 };
2630 const WRITE: &[u8] = br#"{"query":"ADD fact SET subject = \"x\" SET relation = \"r\" SET object = \"o\" SET namespace = \"caller\" REASON \"t\""}"#;
2631
2632 let a = server_for("memory-a");
2635 let r = a.route("POST", "/api/cal", WRITE, Some("scoped-secret"), None);
2636 let text = String::from_utf8_lossy(&r.2).to_string();
2637 assert!(r.0.starts_with("200") && !text.contains("AUT-E001"), "{text}");
2638
2639 let b = server_for("memory-b");
2643 let r = b.route("POST", "/api/cal", WRITE, Some("scoped-secret"), None);
2644 let text = String::from_utf8_lossy(&r.2).to_string();
2645 assert!(
2646 text.contains("AUT-E001") && text.contains("anonymous"),
2647 "scoped token must not cross memories: {text}"
2648 );
2649
2650 assert!(CredentialMap::from_json(
2652 r#"{"version":1,"tokens":[{"env":"AREEV_SCOPED_TOK","principal":"x","memories":[]}]}"#
2653 )
2654 .is_err());
2655 }
2656}
2657
2658#[cfg(all(test, feature = "tls"))]
2659mod tls_tests {
2660 use super::UiServer;
2661 use areev_cal::AreevFacade;
2662 use areev_store::Areev;
2663 use std::io::{Read, Write};
2664
2665 fn self_signed(dir: &std::path::Path) -> Option<(String, String)> {
2666 let cert = dir.join("cert.pem");
2667 let key = dir.join("key.pem");
2668 let status = std::process::Command::new("openssl")
2669 .args([
2670 "req", "-x509", "-newkey", "rsa:2048", "-nodes",
2671 "-keyout", key.to_str().unwrap(), "-out", cert.to_str().unwrap(),
2672 "-days", "1", "-subj", "/CN=localhost",
2673 "-addext", "subjectAltName=DNS:localhost,IP:127.0.0.1",
2674 "-addext", "basicConstraints=critical,CA:FALSE",
2675 ])
2676 .stdout(std::process::Stdio::null())
2677 .stderr(std::process::Stdio::null())
2678 .status();
2679 match status {
2680 Ok(s) if s.success() => Some((
2681 cert.to_str().unwrap().to_string(),
2682 key.to_str().unwrap().to_string(),
2683 )),
2684 _ => None, }
2686 }
2687
2688 #[test]
2691 fn tls_round_trip_and_no_plaintext_downgrade() {
2692 let dir = tempfile::tempdir().unwrap();
2693 let Some((cert, key)) = self_signed(dir.path()) else {
2694 eprintln!("skipping: no openssl binary");
2695 return;
2696 };
2697 let store = Areev::open(dir.path().join("t.db").to_str().unwrap()).unwrap();
2698 let facade = AreevFacade::with_session(store, Some("caller".into()), None);
2699 let server = UiServer::new(facade, "tls-test".into())
2700 .with_tls(&cert, &key)
2701 .unwrap();
2702 let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
2703 let addr = listener.local_addr().unwrap();
2704 let handle = std::thread::spawn(move || {
2705 for _ in 0..2 {
2707 if let Ok((s, _)) = listener.accept() {
2708 let _ = server.handle(s);
2709 }
2710 }
2711 });
2712
2713 let mut roots = rustls::RootCertStore::empty();
2715 {
2716 use rustls::pki_types::pem::PemObject;
2717 for c in rustls::pki_types::CertificateDer::pem_file_iter(&cert).unwrap() {
2718 roots.add(c.unwrap()).unwrap();
2719 }
2720 }
2721 let config = std::sync::Arc::new(
2722 rustls::ClientConfig::builder()
2723 .with_root_certificates(roots)
2724 .with_no_client_auth(),
2725 );
2726 let name = rustls::pki_types::ServerName::try_from("localhost").unwrap();
2727 let session = rustls::ClientConnection::new(config, name).unwrap();
2728 let tcp = std::net::TcpStream::connect(addr).unwrap();
2729 let mut tls = rustls::StreamOwned::new(session, tcp);
2730 tls.write_all(b"GET / HTTP/1.1\r\nHost: 127.0.0.1\r\nConnection: close\r\n\r\n")
2731 .unwrap();
2732 let mut response = String::new();
2733 let _ = tls.read_to_string(&mut response);
2734 assert!(response.starts_with("HTTP/1.1 200"), "{}", &response[..response.len().min(120)]);
2735 assert!(response.contains("Areev"), "console served over TLS");
2736
2737 let mut plain = std::net::TcpStream::connect(addr).unwrap();
2740 plain
2741 .write_all(b"GET / HTTP/1.1\r\nHost: 127.0.0.1\r\nConnection: close\r\n\r\n")
2742 .unwrap();
2743 let mut buf = Vec::new();
2744 let _ = plain.read_to_end(&mut buf);
2745 let text = String::from_utf8_lossy(&buf);
2746 assert!(
2747 !text.contains("HTTP/1.1 200"),
2748 "plaintext must not be served on a TLS listener: {text}"
2749 );
2750 handle.join().unwrap();
2751 }
2752
2753 #[test]
2759 fn an_authenticated_console_terminates_tls_the_same_way() {
2760 let dir = tempfile::tempdir().unwrap();
2761 let Some((cert, key)) = self_signed(dir.path()) else {
2762 eprintln!("skipping: no openssl binary");
2763 return;
2764 };
2765 let store = Areev::open(dir.path().join("t.db").to_str().unwrap()).unwrap();
2766 let facade = AreevFacade::with_session(store, Some("caller".into()), None);
2767 let server = UiServer::new(facade, "auth-tls-test".into())
2768 .with_auth("console-token".into())
2769 .with_tls(&cert, &key)
2770 .unwrap();
2771 let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
2772 let addr = listener.local_addr().unwrap();
2773 let handle = std::thread::spawn(move || {
2774 if let Ok((s, _)) = listener.accept() {
2775 let _ = server.handle(s);
2776 }
2777 });
2778
2779 let mut roots = rustls::RootCertStore::empty();
2780 {
2781 use rustls::pki_types::pem::PemObject;
2782 for c in rustls::pki_types::CertificateDer::pem_file_iter(&cert).unwrap() {
2783 roots.add(c.unwrap()).unwrap();
2784 }
2785 }
2786 let config = std::sync::Arc::new(
2787 rustls::ClientConfig::builder()
2788 .with_root_certificates(roots)
2789 .with_no_client_auth(),
2790 );
2791 let name = rustls::pki_types::ServerName::try_from("localhost").unwrap();
2792 let session = rustls::ClientConnection::new(config, name).unwrap();
2793 let tcp = std::net::TcpStream::connect(addr).unwrap();
2794 let mut tls = rustls::StreamOwned::new(session, tcp);
2795 tls.write_all(
2796 b"GET / HTTP/1.1\r\nHost: 127.0.0.1\r\n\
2797 Authorization: Bearer console-token\r\nConnection: close\r\n\r\n",
2798 )
2799 .unwrap();
2800 let mut response = String::new();
2801 let _ = tls.read_to_string(&mut response);
2802 assert!(
2803 response.starts_with("HTTP/1.1 200"),
2804 "an authenticated console should terminate TLS like an open one: {}",
2805 &response[..response.len().min(120)]
2806 );
2807 handle.join().unwrap();
2808 }
2809}
2810
2811#[cfg(test)]
2812mod sso_tests {
2813 use super::UiServer;
2814 use areev_cal::AreevFacade;
2815 use areev_core::authz::{AUTHZ_NS, REL_PERMITS};
2816 use areev_core::types::{Fact, Grain};
2817 use areev_store::Areev;
2818 use std::io::{Read, Write};
2819
2820 const WRITE: &[u8] = br#"{"query":"ADD fact SET subject = \"x\" SET relation = \"r\" SET object = \"o\" SET namespace = \"caller\" REASON \"t\""}"#;
2821
2822 fn sso_server() -> UiServer {
2823 sso_server_with("proxy-secret", None)
2824 }
2825
2826 fn sso_server_rotating(next: Option<&str>) -> UiServer {
2828 sso_server_with("proxy-secret", next)
2829 }
2830
2831 fn sso_server_with(secret: &str, next: Option<&str>) -> UiServer {
2832 let dir = tempfile::tempdir().unwrap();
2833 let mut store = Areev::open(dir.path().join("t.db").to_str().unwrap()).unwrap();
2834 store
2835 .add(
2836 &Fact::new("user:pat@example.com", REL_PERMITS, "read,write ON caller")
2837 .namespace(AUTHZ_NS)
2838 .created_at(1_000),
2839 )
2840 .unwrap();
2841 std::mem::forget(dir);
2842 let facade = AreevFacade::with_session(store, Some("caller".into()), None);
2843 UiServer::new(facade, "sso-test".into())
2844 .with_sso_rotating("x-forwarded-user", secret, next)
2845 }
2846
2847 fn post(server: &UiServer, extra_headers: &str) -> String {
2848 let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
2849 let addr = listener.local_addr().unwrap();
2850 std::thread::scope(|scope| {
2851 scope.spawn(|| {
2852 if let Ok((s, _)) = listener.accept() {
2853 let _ = server.handle(s);
2854 }
2855 });
2856 let mut c = std::net::TcpStream::connect(addr).unwrap();
2857 let req = format!(
2858 "POST /api/cal HTTP/1.1\r\nHost: 127.0.0.1\r\nContent-Type: application/json\r\n{extra_headers}Content-Length: {}\r\nConnection: close\r\n\r\n",
2859 WRITE.len()
2860 );
2861 c.write_all(req.as_bytes()).unwrap();
2862 c.write_all(WRITE).unwrap();
2863 let mut out = String::new();
2864 let _ = c.read_to_string(&mut out);
2865 out
2866 })
2867 }
2868
2869 #[test]
2873 fn proxied_identity_works_and_forged_headers_are_ignored() {
2874 let server = sso_server();
2875
2876 let ok = post(
2878 &server,
2879 "X-Forwarded-User: user:pat@example.com\r\nX-Areev-Proxy-Secret: proxy-secret\r\n",
2880 );
2881 assert!(ok.contains("HTTP/1.1 200"), "{}", &ok[..ok.len().min(200)]);
2882 assert!(!ok.contains("AUT-E001"), "granted principal must write: {ok}");
2883
2884 let forged = post(&server, "X-Forwarded-User: user:pat@example.com\r\n");
2887 assert!(
2888 forged.contains("anonymous") || forged.contains("401"),
2889 "forged identity must not authenticate: {forged}"
2890 );
2891
2892 let wrong = post(
2894 &server,
2895 "X-Forwarded-User: user:pat@example.com\r\nX-Areev-Proxy-Secret: guess\r\n",
2896 );
2897 assert!(
2898 wrong.contains("anonymous") || wrong.contains("401"),
2899 "wrong proxy secret must not authenticate: {wrong}"
2900 );
2901 }
2902
2903 #[test]
2908 fn both_secrets_authenticate_during_a_rotation_window() {
2909 let server = sso_server_rotating(Some("new-secret"));
2910 let id = "X-Forwarded-User: user:pat@example.com\r\n";
2911
2912 for secret in ["proxy-secret", "new-secret"] {
2913 let out = post(&server, &format!("{id}X-Areev-Proxy-Secret: {secret}\r\n"));
2914 assert!(out.contains("HTTP/1.1 200"), "{secret} rejected: {out}");
2915 assert!(!out.contains("AUT-E001"), "{secret} must authenticate: {out}");
2916 }
2917
2918 let wrong = post(&server, &format!("{id}X-Areev-Proxy-Secret: guess\r\n"));
2921 assert!(
2922 wrong.contains("anonymous") || wrong.contains("401"),
2923 "an unrelated secret must still be refused mid-rotation: {wrong}"
2924 );
2925 }
2926
2927 #[test]
2930 fn the_retired_secret_stops_working_once_the_window_closes() {
2931 let server = sso_server_with("new-secret", None);
2933 let id = "X-Forwarded-User: user:pat@example.com\r\n";
2934
2935 let ok = post(&server, &format!("{id}X-Areev-Proxy-Secret: new-secret\r\n"));
2936 assert!(ok.contains("HTTP/1.1 200"), "{ok}");
2937 assert!(!ok.contains("AUT-E001"), "the promoted secret must authenticate: {ok}");
2938
2939 let old = post(&server, &format!("{id}X-Areev-Proxy-Secret: proxy-secret\r\n"));
2940 assert!(
2941 old.contains("anonymous") || old.contains("401"),
2942 "the retired secret must stop working: {old}"
2943 );
2944 }
2945
2946 #[test]
2947 #[should_panic(expected = "must differ")]
2948 fn rotating_to_the_same_value_is_refused() {
2949 let _ = sso_server_rotating(Some("proxy-secret"));
2952 }
2953
2954 use crate::CONSOLE_HTML;
2955 use std::collections::BTreeSet;
2956
2957 #[test]
2969 fn every_console_page_is_in_the_visibility_list() {
2970 fn all(hay: &str, open: &str) -> BTreeSet<String> {
2971 let mut out = BTreeSet::new();
2972 let mut rest = hay;
2973 while let Some(i) = rest.find(open) {
2974 rest = &rest[i + open.len()..];
2975 let end = rest.find('"').expect("unterminated attribute");
2976 out.insert(rest[..end].to_string());
2977 rest = &rest[end..];
2978 }
2979 out
2980 }
2981
2982 let sections = all(CONSOLE_HTML, "id=\"page-");
2983 let nav = all(CONSOLE_HTML, "data-page=\"");
2984 for want in ["workflows", "runs", "tools"] {
2985 assert!(sections.contains(want), "the {want} section itself went missing");
2986 }
2987 assert!(!sections.contains("triggers"), "triggers belong on the canvas, not on a page");
2991 assert!(
2992 CONSOLE_HTML.contains("if (head === 'triggers')"),
2993 "#triggers must still route somewhere — a removed page is not a dead bookmark"
2994 );
2995
2996 let head = "for (const p of [";
2998 let i = CONSOLE_HTML.find(head).expect("render()'s page-visibility loop");
2999 let tail = &CONSOLE_HTML[i + head.len()..];
3000 let j = tail.find(']').expect("unterminated page array");
3001 let listed: BTreeSet<String> = tail[..j]
3002 .split(',')
3003 .map(|t| t.trim().trim_matches('\'').to_string())
3004 .filter(|t| !t.is_empty())
3005 .collect();
3006
3007 assert_eq!(
3008 sections, listed,
3009 "page sections and render()'s visibility list disagree; a section missing \
3010 from the list renders into a pane that never un-hides"
3011 );
3012 let orphan: Vec<_> = nav.difference(§ions).collect();
3013 assert!(orphan.is_empty(), "nav items with no page section: {orphan:?}");
3014 }
3015}
3016
3017#[cfg(test)]
3021mod dsn_redaction_tests {
3022 use super::UiServer;
3023 use areev_cal::AreevFacade;
3024 use areev_core::authz::{CredentialMap, AUTHZ_NS, REL_PERMITS};
3025 use areev_core::types::{Fact, Grain};
3026 use areev_store::Areev;
3027
3028 const DSN: &str = "postgresql://u:SUPERSECRET@h:5432/d?sslmode=require";
3029
3030 fn server() -> UiServer {
3031 let dir = tempfile::tempdir().unwrap();
3032 let store = Areev::open(dir.path().join("t.db").to_str().unwrap()).unwrap();
3033 std::mem::forget(dir);
3034 let facade = AreevFacade::with_session(store, Some("caller".into()), None);
3035 UiServer::new(facade, DSN.to_string())
3036 }
3037
3038 fn text(r: &(&str, &str, Vec<u8>)) -> String {
3039 String::from_utf8_lossy(&r.2).to_string()
3040 }
3041
3042 #[test]
3043 fn stats_config_and_html_redact_the_dsn_password_but_keep_the_host() {
3044 let s = server();
3045 for (method, path) in [("GET", "/api/stats"), ("GET", "/api/config"), ("GET", "/")] {
3046 let r = s.route(method, path, b"", None, None);
3047 let body = text(&r);
3048 assert!(!body.contains("SUPERSECRET"), "{path} leaked the password: {body}");
3049 assert!(body.contains("h:5432"), "{path} redacted more than the password: {body}");
3050 }
3051 }
3052
3053 #[test]
3058 fn credential_map_memories_entry_still_matches_the_raw_dsn() {
3059 std::env::set_var("AREEV_DSN_TOK", "dsn-scoped-secret");
3060 let map_json = format!(
3061 r#"{{"version":1,"tokens":[{{"env":"AREEV_DSN_TOK","principal":"agent:sync","memories":["{DSN}"]}}]}}"#
3062 );
3063 let dir = tempfile::tempdir().unwrap();
3064 let mut store = Areev::open(dir.path().join("t.db").to_str().unwrap()).unwrap();
3065 store
3066 .add(
3067 &Fact::new("agent:sync", REL_PERMITS, "read,write ON caller")
3068 .namespace(AUTHZ_NS)
3069 .created_at(1_000),
3070 )
3071 .unwrap();
3072 std::mem::forget(dir);
3073 let facade = AreevFacade::with_session(store, Some("caller".into()), None);
3074 let s = UiServer::new(facade, DSN.to_string())
3075 .with_credentials(CredentialMap::from_json(&map_json).unwrap());
3076
3077 const WRITE: &[u8] = br#"{"query":"ADD fact SET subject = \"x\" SET relation = \"r\" SET object = \"o\" SET namespace = \"caller\" REASON \"t\""}"#;
3078 let r = s.route("POST", "/api/cal", WRITE, Some("dsn-scoped-secret"), None);
3079 let body = text(&r);
3080 assert!(r.0.starts_with("200") && !body.contains("AUT-E001"), "{body}");
3081 }
3082}
3083
3084#[cfg(test)]
3089mod origin_tests {
3090 use super::UiServer;
3091 use areev_cal::AreevFacade;
3092 use areev_store::Areev;
3093 use std::io::{Read, Write};
3094
3095 fn server() -> UiServer {
3096 let dir = tempfile::tempdir().unwrap();
3097 let store = Areev::open(dir.path().join("t.db").to_str().unwrap()).unwrap();
3098 std::mem::forget(dir);
3099 let facade = AreevFacade::with_session(store, Some("caller".into()), None);
3100 UiServer::new(facade, "origin-test".into())
3101 }
3102
3103 fn post_with_origin(server: &UiServer, origin: Option<&str>) -> String {
3106 let body: &[u8] = br#"{"query":"RECALL facts WHERE subject = \"acme\""}"#;
3107 let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
3108 let addr = listener.local_addr().unwrap();
3109 std::thread::scope(|scope| {
3110 scope.spawn(|| {
3111 if let Ok((s, _)) = listener.accept() {
3112 let _ = server.handle(s);
3113 }
3114 });
3115 let mut c = std::net::TcpStream::connect(addr).unwrap();
3116 let origin_header = origin.map(|o| format!("Origin: {o}\r\n")).unwrap_or_default();
3117 let req = format!(
3118 "POST /api/cal HTTP/1.1\r\nHost: 127.0.0.1\r\nContent-Type: application/json\r\n{origin_header}Content-Length: {}\r\nConnection: close\r\n\r\n",
3119 body.len()
3120 );
3121 c.write_all(req.as_bytes()).unwrap();
3122 c.write_all(body).unwrap();
3123 let mut out = String::new();
3124 let _ = c.read_to_string(&mut out);
3125 out
3126 })
3127 }
3128
3129 #[test]
3130 fn matching_allowed_origin_is_accepted() {
3131 let s = server().allow_origin("https://console.example.com");
3132 let r = post_with_origin(&s, Some("https://console.example.com"));
3133 assert!(r.contains("HTTP/1.1 200"), "{r}");
3134 }
3135
3136 #[test]
3137 fn non_matching_origin_is_still_rejected() {
3138 let s = server().allow_origin("https://console.example.com");
3139 let r = post_with_origin(&s, Some("https://not-allowed.example.com"));
3140 assert!(r.contains("403"), "{r}");
3141 assert!(r.contains("cross-origin request rejected"), "{r}");
3142 }
3143
3144 #[test]
3145 fn a_subdomain_of_an_allowed_origin_is_rejected_not_matched() {
3146 let s = server().allow_origin("https://console.example.com");
3149 let r = post_with_origin(&s, Some("https://evil-console.example.com"));
3150 assert!(r.contains("403"), "{r}");
3151 }
3152
3153 #[test]
3154 fn loopback_origin_still_works_with_no_allowlist_set() {
3155 let s = server();
3156 let r = post_with_origin(&s, Some("http://127.0.0.1:7437"));
3157 assert!(r.contains("HTTP/1.1 200"), "{r}");
3158 }
3159
3160 #[test]
3161 fn no_origin_header_still_works() {
3162 let s = server();
3163 let r = post_with_origin(&s, None);
3164 assert!(r.contains("HTTP/1.1 200"), "{r}");
3165 }
3166
3167 #[test]
3168 fn matching_origin_differing_only_in_case_and_trailing_slash_is_accepted() {
3169 let s = server().allow_origin("https://console.example.com");
3170 let r = post_with_origin(&s, Some("HTTPS://Console.Example.com/"));
3171 assert!(r.contains("HTTP/1.1 200"), "{r}");
3172 }
3173}
3174
3175#[cfg(test)]
3182mod auth_failure_tracking_tests {
3183 use super::UiServer;
3184 use areev_cal::AreevFacade;
3185 use areev_store::Areev;
3186 use std::io::{Read, Write};
3187 use std::net::IpAddr;
3188
3189 const LOCALHOST: IpAddr = IpAddr::V4(std::net::Ipv4Addr::LOCALHOST);
3190
3191 fn server() -> UiServer {
3192 let dir = tempfile::tempdir().unwrap();
3193 let store = Areev::open(dir.path().join("t.db").to_str().unwrap()).unwrap();
3194 std::mem::forget(dir);
3195 let facade = AreevFacade::with_session(store, Some("caller".into()), None);
3196 UiServer::new(facade, "auth-failure-test".into()).with_auth("right-token".into())
3197 }
3198
3199 fn get_root(server: &UiServer, bearer: Option<&str>) -> String {
3202 let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
3203 let addr = listener.local_addr().unwrap();
3204 std::thread::scope(|scope| {
3205 scope.spawn(|| {
3206 if let Ok((s, _)) = listener.accept() {
3207 let _ = server.handle(s);
3208 }
3209 });
3210 let mut c = std::net::TcpStream::connect(addr).unwrap();
3211 let auth_header = bearer
3212 .map(|t| format!("Authorization: Bearer {t}\r\n"))
3213 .unwrap_or_default();
3214 let req = format!("GET / HTTP/1.1\r\nHost: 127.0.0.1\r\n{auth_header}Connection: close\r\n\r\n");
3215 c.write_all(req.as_bytes()).unwrap();
3216 let mut out = String::new();
3217 let _ = c.read_to_string(&mut out);
3218 out
3219 })
3220 }
3221
3222 #[test]
3223 fn a_bare_request_is_401_but_not_counted() {
3224 let s = server();
3225 let bare = get_root(&s, None);
3229 assert!(bare.contains("HTTP/1.1 401"), "{bare}");
3230 assert_eq!(s.auth_failure_count_for_test(LOCALHOST), 0, "a bare request has nothing to fail");
3231 }
3232
3233 #[test]
3234 fn a_wrong_token_is_401_and_is_counted() {
3235 let s = server();
3236 let wrong = get_root(&s, Some("guess"));
3237 assert!(wrong.contains("HTTP/1.1 401"), "{wrong}");
3238 assert_eq!(s.auth_failure_count_for_test(LOCALHOST), 1);
3239
3240 let wrong_again = get_root(&s, Some("guess"));
3241 assert!(wrong_again.contains("HTTP/1.1 401"), "{wrong_again}");
3242 assert_eq!(s.auth_failure_count_for_test(LOCALHOST), 2, "consecutive failures accumulate");
3243 }
3244
3245 #[test]
3246 fn a_right_token_after_a_streak_succeeds_immediately_and_resets_it() {
3247 let s = server();
3248 for _ in 0..5 {
3249 let r = get_root(&s, Some("guess"));
3250 assert!(r.contains("HTTP/1.1 401"), "{r}");
3251 }
3252 assert_eq!(s.auth_failure_count_for_test(LOCALHOST), 5);
3253
3254 let ok = get_root(&s, Some("right-token"));
3257 assert!(ok.contains("HTTP/1.1 200"), "{ok}");
3258 assert_eq!(s.auth_failure_count_for_test(LOCALHOST), 0, "success resets the streak");
3259
3260 let r = get_root(&s, Some("guess"));
3262 assert!(r.contains("HTTP/1.1 401"), "{r}");
3263 assert_eq!(s.auth_failure_count_for_test(LOCALHOST), 1);
3264 }
3265}