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