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