1use std::io::{BufRead, BufReader, Read, Write};
9use std::net::{TcpListener, TcpStream};
10use std::time::Duration;
11
12use areev_cal::{CalExecutor, CalExecutorConfig, CalStoreFacade, AreevFacade};
13use areev_loop_adapter::{now_ms, BorrowedSubstrate};
14use serde_json::{json, Value};
15use areev_loop::{Decision, Engine, RecStatus, RunOptions};
16
17const CONSOLE_HTML: &str = include_str!("console.html");
18
19const READ_TIMEOUT_SECS: u64 = 15;
21const MAX_CONN_BYTES: u64 = (1 << 20) + (128 << 10);
24const MAX_HEADER_BYTES: usize = 64 * 1024;
26const MAX_HEADERS: usize = 100;
27const REQUEST_DEADLINE_SECS: u64 = 30;
31
32
33enum Conn {
40 Plain(TcpStream),
41 #[cfg(feature = "tls")]
42 Tls(Box<rustls::StreamOwned<rustls::ServerConnection, TcpStream>>),
43}
44
45impl Read for Conn {
46 fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
47 match self {
48 Conn::Plain(s) => s.read(buf),
49 #[cfg(feature = "tls")]
50 Conn::Tls(s) => s.read(buf),
51 }
52 }
53}
54
55impl Write for Conn {
56 fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
57 match self {
58 Conn::Plain(s) => s.write(buf),
59 #[cfg(feature = "tls")]
60 Conn::Tls(s) => s.write(buf),
61 }
62 }
63 fn flush(&mut self) -> std::io::Result<()> {
64 match self {
65 Conn::Plain(s) => s.flush(),
66 #[cfg(feature = "tls")]
67 Conn::Tls(s) => s.flush(),
68 }
69 }
70}
71
72pub struct UiServer {
73 facade: std::sync::Arc<AreevFacade>,
74 executor: CalExecutor,
75 db_label: String,
76 token: Option<String>,
80 auth_all: bool,
84 segment_dir: Option<String>,
86 allow_remote: bool,
91 loop_policy: Option<areev_loop::Policy>,
96 credentials: Option<areev_core::authz::CredentialMap>,
105 allow_destructive_ops: bool,
108 #[cfg(feature = "tls")]
111 tls_config: Option<std::sync::Arc<rustls::ServerConfig>>,
112 sso_header: Option<String>,
119 sso_secret: Option<String>,
120}
121
122struct RequestBinding<'a> {
128 facade: &'a AreevFacade,
129}
130
131impl<'a> RequestBinding<'a> {
132 fn bind(
133 facade: &'a AreevFacade,
134 map: &areev_core::authz::CredentialMap,
135 bearer: Option<&str>,
136 memory: &str,
137 ) -> Self {
138 let principal =
141 bearer.and_then(|t| map.resolve_for_memory(t, memory).ok().map(str::to_string));
142 match principal {
143 Some(p) => {
144 if facade.bind_principal(&p).is_err() {
145 Self::bind_anonymous(facade);
148 }
149 }
150 None => Self::bind_anonymous(facade),
151 }
152 RequestBinding { facade }
153 }
154
155 fn bind_identity(facade: &'a AreevFacade, principal: &str) -> Self {
161 if facade.bind_principal(principal).is_err() {
162 Self::bind_anonymous(facade);
163 }
164 RequestBinding { facade }
165 }
166
167 fn bind_anonymous(facade: &AreevFacade) {
168 use areev_core::authz::{AuthzSet, Grant, Verb};
169 let mut grants = facade
170 .with_store(|m| m.authz_grants("anonymous"))
171 .unwrap_or_default();
172 grants.push(Grant { verbs: vec![Verb::Read], namespaces: vec!["*".to_string()] });
173 facade.bind(AuthzSet::restricted("anonymous", grants));
174 }
175}
176
177impl Drop for RequestBinding<'_> {
178 fn drop(&mut self) {
179 self.facade
180 .bind(areev_core::authz::AuthzSet::owner("user:console"));
181 }
182}
183
184impl UiServer {
185 pub fn new(facade: AreevFacade, db_label: String) -> Self {
186 UiServer {
187 facade: std::sync::Arc::new(facade),
188 executor: CalExecutor::new(CalExecutorConfig::default())
189 .with_governance(std::sync::Arc::new(areev_loop_adapter::LoopGovernance::new())),
190 db_label,
191 token: None,
192 auth_all: false,
193 segment_dir: None,
194 allow_remote: false,
195 loop_policy: None,
196 credentials: None,
197 allow_destructive_ops: CalExecutorConfig::default().allow_destructive_ops,
198 #[cfg(feature = "tls")]
199 tls_config: None,
200 sso_header: None,
201 sso_secret: None,
202 }
203 }
204
205 pub fn with_sso(mut self, header: impl Into<String>, secret: impl Into<String>) -> Self {
209 let header = header.into();
210 let secret = secret.into();
211 assert!(
215 !header.trim().is_empty() && !secret.trim().is_empty(),
216 "with_sso requires a non-empty header name and proxy secret"
217 );
218 self.sso_header = Some(header.to_ascii_lowercase());
219 self.sso_secret = Some(secret);
220 self
221 }
222
223 fn rebuild_executor(&mut self) {
230 self.executor = CalExecutor::new(CalExecutorConfig {
231 allow_destructive_ops: self.allow_destructive_ops,
232 ..CalExecutorConfig::default()
233 })
234 .with_governance(std::sync::Arc::new(match &self.loop_policy {
235 Some(p) => areev_loop_adapter::LoopGovernance::with_policy(p.clone()),
236 None => areev_loop_adapter::LoopGovernance::new(),
237 }));
238 }
239
240 pub fn with_credentials(mut self, map: areev_core::authz::CredentialMap) -> Self {
245 self.credentials = Some(map);
246 self
247 }
248
249 pub fn with_loop_policy(mut self, policy: areev_loop::Policy) -> Self {
253 self.loop_policy = Some(policy);
254 self.rebuild_executor();
256 self
257 }
258
259 fn session_may_sync(&self) -> bool {
265 self.facade
266 .authz()
267 .allows(areev_core::authz::Verb::Read, "*")
268 }
269
270 fn engine(&self) -> Engine {
273 match &self.loop_policy {
274 Some(p) => Engine::with_builtins().with_policy(p.clone()),
275 None => Engine::with_builtins(),
276 }
277 }
278
279 pub fn allow_remote(mut self, yes: bool) -> Self {
283 self.allow_remote = yes;
284 self
285 }
286
287 pub fn with_auth(mut self, token: String) -> Self {
294 assert!(
298 !token.trim().is_empty(),
299 "with_auth requires a non-empty token"
300 );
301 self.token = Some(token);
302 self.auth_all = true;
303 self
304 }
305
306 pub fn allow_destructive_ops(mut self, allow: bool) -> Self {
311 self.allow_destructive_ops = allow;
312 self.rebuild_executor();
313 self
314 }
315
316 pub fn into_hub(mut self, token: Option<String>, dir: &str) -> std::io::Result<Self> {
319 std::fs::create_dir_all(dir)?;
320 self.token = token;
321 self.segment_dir = Some(dir.to_string());
322 self.allow_remote = true;
325 Ok(self)
326 }
327
328 pub fn bind(addr: &str) -> std::io::Result<TcpListener> {
330 TcpListener::bind(addr)
331 }
332
333 pub fn serve(&self, listener: TcpListener) -> std::io::Result<()> {
335 for stream in listener.incoming() {
336 match stream {
337 Ok(s) => {
338 if let Err(e) = self.handle(s) {
339 eprintln!("areev ui: request error: {e}");
340 }
341 }
342 Err(e) => eprintln!("areev ui: accept error: {e}"),
343 }
344 }
345 Ok(())
346 }
347
348 #[cfg(feature = "tls")]
352 pub fn with_tls(mut self, cert_pem: &str, key_pem: &str) -> std::io::Result<Self> {
353 let certs: Vec<rustls::pki_types::CertificateDer<'static>> =
354 rustls_pemfile::certs(&mut BufReader::new(std::fs::File::open(cert_pem)?))
355 .collect::<Result<_, _>>()?;
356 let key = rustls_pemfile::private_key(&mut BufReader::new(std::fs::File::open(key_pem)?))?
357 .ok_or_else(|| std::io::Error::new(std::io::ErrorKind::InvalidData, "no private key in PEM"))?;
358 let config = rustls::ServerConfig::builder()
359 .with_no_client_auth()
360 .with_single_cert(certs, key)
361 .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e.to_string()))?;
362 self.tls_config = Some(std::sync::Arc::new(config));
363 Ok(self)
364 }
365
366 fn wrap(&self, stream: TcpStream) -> std::io::Result<Conn> {
369 #[cfg(feature = "tls")]
370 if let Some(config) = &self.tls_config {
371 let session = rustls::ServerConnection::new(std::sync::Arc::clone(config))
372 .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e.to_string()))?;
373 return Ok(Conn::Tls(Box::new(rustls::StreamOwned::new(session, stream))));
374 }
375 Ok(Conn::Plain(stream))
376 }
377
378 fn handle(&self, stream: TcpStream) -> std::io::Result<()> {
379 let watchdog_stream = stream.try_clone()?;
385 let done = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
386 let watchdog_done = std::sync::Arc::clone(&done);
387 let watchdog = std::thread::spawn(move || {
388 std::thread::park_timeout(Duration::from_secs(REQUEST_DEADLINE_SECS));
389 if !watchdog_done.load(std::sync::atomic::Ordering::Acquire) {
390 let _ = watchdog_stream.shutdown(std::net::Shutdown::Both);
391 }
392 });
393 let raw = stream.try_clone()?;
394 let conn = self.wrap(stream)?;
395 let result = self.handle_request(&raw, conn);
396 done.store(true, std::sync::atomic::Ordering::Release);
397 watchdog.thread().unpark();
398 let _ = watchdog.join();
399 result
400 }
401
402 fn handle_request(&self, raw: &TcpStream, mut conn: Conn) -> std::io::Result<()> {
403 raw.set_read_timeout(Some(Duration::from_secs(READ_TIMEOUT_SECS)))?;
408 raw.set_write_timeout(Some(Duration::from_secs(READ_TIMEOUT_SECS)))?;
409 let mut reader = BufReader::new((&mut conn).take(MAX_CONN_BYTES));
410 let mut request_line = String::new();
411 reader.read_line(&mut request_line)?;
412 let mut parts = request_line.split_whitespace();
413 let method = parts.next().unwrap_or("").to_string();
414 let path = parts.next().unwrap_or("/").to_string();
415
416 let mut content_length = 0usize;
418 let mut bearer: Option<String> = None;
419 let mut origin: Option<String> = None;
420 let mut host: Option<String> = None;
421 let mut sso_identity_raw: Option<String> = None;
422 let mut proxy_secret: Option<String> = None;
423 let mut header_bytes = 0usize;
424 let mut header_count = 0usize;
425 loop {
426 let mut line = String::new();
427 let n = reader.read_line(&mut line)?;
428 if n == 0 {
429 break; }
431 header_bytes += n;
432 header_count += 1;
433 if header_bytes > MAX_HEADER_BYTES || header_count > MAX_HEADERS {
434 return Err(std::io::Error::new(
435 std::io::ErrorKind::InvalidData,
436 "request headers too large",
437 ));
438 }
439 let l = line.trim();
440 if l.is_empty() {
441 break;
442 }
443 let low = l.to_ascii_lowercase();
444 if let Some(v) = low.strip_prefix("content-length:") {
445 content_length = v.trim().parse().unwrap_or(0);
446 }
447 if let Some(v) = low.strip_prefix("origin:") {
448 origin = Some(v.trim().to_string());
449 }
450 if let Some(v) = low.strip_prefix("host:") {
451 host = Some(v.trim().to_string());
452 }
453 if let Some(v) = low.strip_prefix("x-areev-proxy-secret:") {
454 let _ = v;
457 if let Some((_, orig)) = l.split_once(':') {
458 proxy_secret = Some(orig.trim().to_string());
459 }
460 }
461 if let Some(h) = &self.sso_header {
462 if low.starts_with(&format!("{h}:")) {
463 if let Some((_, orig)) = l.split_once(':') {
464 sso_identity_raw = Some(orig.trim().to_string());
465 }
466 }
467 }
468 if low.starts_with("authorization:") {
469 if let Some((_, v)) = l.split_once(':') {
470 let v = v.trim();
471 bearer = if let Some(t) =
476 v.strip_prefix("Bearer ").or_else(|| v.strip_prefix("bearer "))
477 {
478 Some(t.trim().to_string())
479 } else if let Some(b64) =
480 v.strip_prefix("Basic ").or_else(|| v.strip_prefix("basic "))
481 {
482 basic_auth_password(b64.trim())
483 } else {
484 Some(v.to_string())
485 };
486 }
487 }
488 }
489 if !self.allow_remote && host.as_deref().is_some_and(|h| !host_is_local(h)) {
496 let payload = br#"{"ok":false,"error":"non-loopback Host rejected (use --allow-remote to serve remotely)"}"#;
497 drop(reader);
498 let mut out = conn;
499 write!(
500 out,
501 "HTTP/1.1 403 Forbidden\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
502 payload.len()
503 )?;
504 out.write_all(payload)?;
505 return out.flush();
506 }
507
508 let mut body = vec![0u8; content_length.min(1 << 20)];
509 if content_length > 0 {
510 reader.read_exact(&mut body)?;
511 }
512
513 if method == "POST" && origin.as_deref().is_some_and(|o| !origin_is_local(o)) {
517 let payload = br#"{"ok":false,"error":"cross-origin request rejected"}"#;
518 drop(reader);
519 let mut out = conn;
520 write!(
521 out,
522 "HTTP/1.1 403 Forbidden\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
523 payload.len()
524 )?;
525 out.write_all(payload)?;
526 return out.flush();
527 }
528
529 let sso_identity: Option<String> = match (&self.sso_secret, proxy_secret, sso_identity_raw) {
534 (Some(expected), Some(presented), Some(id))
535 if ct_eq(expected.as_bytes(), presented.as_bytes())
536 && !id.trim().is_empty() =>
537 {
538 Some(id)
539 }
540 _ => None,
541 };
542 let (status, ctype, payload) =
543 self.route(&method, &path, &body, bearer.as_deref(), sso_identity.as_deref());
544 let auth_challenge = if self.auth_all && status.starts_with("401") {
547 "WWW-Authenticate: Basic realm=\"Areev console\", charset=\"UTF-8\"\r\n"
548 } else {
549 ""
550 };
551 drop(reader);
552 let mut out = conn;
553 write!(
554 out,
555 "HTTP/1.1 {status}\r\nContent-Type: {ctype}\r\n{auth_challenge}Content-Length: {}\r\nConnection: close\r\n\r\n",
556 payload.len()
557 )?;
558 out.write_all(&payload)?;
559 out.flush()
560 }
561
562 fn runner(&self, principal: &str) -> areev_run::Runner {
567 struct NoExec;
568 impl areev_run::HostToolExecutor for NoExec {
569 fn execute(
570 &self,
571 tool_name: &str,
572 _h: &str,
573 _i: &Value,
574 _k: &str,
575 ) -> areev_run::ExecResult {
576 areev_run::ExecResult::Err {
577 cause: areev_run::FailCause::ExecutorError,
578 detail: format!("the console does not execute tools ('{tool_name}')"),
579 }
580 }
581 }
582 areev_run::Runner {
583 facade: std::sync::Arc::clone(&self.facade),
584 clock: std::sync::Arc::new(areev_run::SystemClock),
585 executor: std::sync::Arc::new(NoExec),
586 llm: None,
587 observer: None,
588 ns: self
589 .facade
590 .default_namespace()
591 .unwrap_or("shared")
592 .to_string(),
593 principal: principal.to_string(),
594 }
595 }
596
597 fn route(
598 &self,
599 method: &str,
600 path: &str,
601 body: &[u8],
602 bearer: Option<&str>,
603 sso_identity: Option<&str>,
604 ) -> (&'static str, &'static str, Vec<u8>) {
605 let mut shared_secret_ok = false;
616 if let Some(tok) = &self.token {
617 let guarded = self.auth_all || method == "POST" || path.starts_with("/api/segment");
618 shared_secret_ok = bearer.is_some_and(|b| ct_eq(b.as_bytes(), tok.as_bytes()));
619 let known = shared_secret_ok
620 || sso_identity.is_some()
621 || match (&self.credentials, bearer) {
622 (Some(map), Some(b)) => map.resolve_for_memory(b, &self.db_label).is_ok(),
623 _ => false,
624 };
625 if guarded && !known {
626 return ("401 Unauthorized", "application/json",
627 br#"{"ok":false,"error":"authentication required"}"#.to_vec());
628 }
629 } else if self.credentials.is_none() && sso_identity.is_none() && method == "POST" {
630 let base = path.split_once('?').map(|(p, _)| p).unwrap_or(path);
636 let allowed = base == "/api/cal" && cal_body_is_read_only(body);
637 if !allowed {
638 return ("401 Unauthorized", "application/json",
639 br#"{"ok":false,"error":"read-only console: restart areev ui with --token-env VAR to enable writes"}"#.to_vec());
640 }
641 }
642
643 let _session = match (&self.credentials, shared_secret_ok) {
650 (Some(map), false) => Some(RequestBinding::bind(&self.facade, map, bearer, &self.db_label)),
651 _ => None,
652 };
653 let mut request_principal: Option<String> = match (&self.credentials, shared_secret_ok, bearer)
658 {
659 (Some(map), false, Some(b)) => map.resolve_for_memory(b, &self.db_label).ok().map(str::to_string),
660 _ => None,
661 };
662 let _sso_session = match (&request_principal, sso_identity, shared_secret_ok) {
666 (None, Some(identity), false) => {
667 request_principal = Some(identity.to_string());
668 Some(RequestBinding::bind_identity(&self.facade, identity))
669 }
670 _ => None,
671 };
672
673 let (path, query) = match path.split_once('?') {
674 Some((p, q)) => (p, q),
675 None => (path, ""),
676 };
677 let q = |key: &str| -> Option<String> {
678 query.split('&').find_map(|kv| {
679 let (k, v) = kv.split_once('=')?;
680 (k == key).then(|| urldecode(v))
681 })
682 };
683 match (method, path) {
684 ("GET", "/") => (
685 "200 OK",
686 "text/html; charset=utf-8",
687 CONSOLE_HTML
688 .replace("{{DB}}", &html_escape(&self.db_label))
689 .into_bytes(),
690 ),
691 ("POST", "/api/cal") => {
692 let req: Value = serde_json::from_slice(body).unwrap_or(Value::Null);
693 let queryt = req.get("query").and_then(|v| v.as_str()).unwrap_or("");
694 match self.executor.execute(queryt, &*self.facade) {
695 Ok(res) => ok_json(json!({
696 "ok": true,
697 "result": res.result,
698 "warnings": res.warnings,
699 "statement": res.metadata.statement_type,
700 "elapsed_ms": res.metadata.execution_time_ms,
701 })),
702 Err(e) => {
703 let mut err = json!({
706 "ok": false,
707 "error": e.sanitize_for_client(),
708 "code": e.code(),
709 });
710 if let Some(sp) = e.span() {
711 err["span"] = json!({
712 "start": sp.start, "end": sp.end,
713 "line": sp.line, "col": sp.col,
714 });
715 }
716 if let Some(hint) = e.suggestion() {
717 err["suggestion"] = json!(hint);
718 }
719 ok_json(err)
720 }
721 }
722 }
723 ("GET", "/api/stats") => {
724 let stats = self.facade.with_store(|m| m.stats());
725 match stats {
726 Ok(s) => ok_json(json!({
727 "db": self.db_label,
728 "grains": s.grains, "current": s.current,
729 "triples": s.triples, "terms": s.terms,
730 "ops": s.ops, "events_indexed": s.events_indexed,
731 })),
732 Err(e) => ok_json(json!({"ok": false, "error": e.to_string()})),
733 }
734 }
735 ("GET", "/api/log") => {
736 let since: i64 = q("since").and_then(|v| v.parse().ok()).unwrap_or(0);
737 let limit: usize = q("limit").and_then(|v| v.parse().ok()).unwrap_or(100);
738 match self.facade.with_store(|m| m.changes_since(since, limit)) {
739 Ok(ops) => {
740 let rows: Vec<Value> = ops
741 .iter()
742 .map(|o| {
743 json!({
744 "op_seq": o.op_seq, "hlc": o.hlc,
745 "op": match o.op { 1 => "add", 2 => "supersede", 3 => "forget", _ => "?" },
746 "hash": o.hash.to_hex(),
747 })
748 })
749 .collect();
750 ok_json(json!(rows))
751 }
752 Err(e) => ok_json(json!({"ok": false, "error": e.to_string()})),
753 }
754 }
755 ("GET", "/api/whoami") => {
759 let a = self.facade.authz();
760 ok_json(json!({
761 "ok": true,
762 "principal": a.principal(),
763 "owner": a.is_owner(),
764 "mode": if self.credentials.is_some() { "credentials" }
765 else if self.token.is_some() { "token" }
766 else { "open" },
767 }))
768 }
769 ("GET", "/api/config") => {
770 let cfg = self.executor.config();
775 let (index_text, embedder_dim, declared_embed, mut warnings) =
776 self.facade.with_store(|m| {
777 (
778 m.index_text_enabled(),
779 m.embedder_dim(),
780 m.declared_embedding().map(|(mm, d)| (mm.to_string(), d)),
781 m.open_warnings().to_vec(),
782 )
783 });
784 if let Some((m, d)) = &declared_embed {
785 if embedder_dim.is_none() {
786 warnings.push(format!(
787 "vector leg dormant: file expects {m}@{d}, no embedding backend installed"
788 ));
789 }
790 }
791 warnings.extend(self.facade.meta_warnings());
795 ok_json(json!({
796 "ok": true,
797 "db": self.db_label,
798 "warnings": warnings,
799 "file": {
800 "text_index": index_text,
801 "embedding": declared_embed.map(|(m, d)| json!({"model": m, "dim": d})),
802 },
803 "session": {
804 "namespace": self.facade.session_namespace(),
805 "mounts": self.facade.mount_aliases(),
806 },
807 "store": {
808 "index_text": index_text,
809 "embedder": embedder_dim.map(|d| json!({"dim": d})),
810 },
811 "recall": {
812 "fusion": "rrf",
813 "rrf_k0": areev_store::RRF_K0,
814 "overfetch_factor": AreevFacade::RECALL_OVERFETCH,
815 "legs": {
816 "structural": true,
817 "bm25": index_text,
818 "vector": embedder_dim.is_some(),
819 },
820 },
821 "executor": {
822 "max_limit": cfg.max_limit,
823 "default_limit": cfg.default_limit,
824 "tier1_writes": cfg.tier1_enabled,
825 "namespace_override": cfg.namespace_override,
826 "user_id_override": cfg.user_id_override,
827 },
828 "server": {
829 "hub_mode": self.segment_dir.is_some(),
830 "auth_required": self.token.is_some(),
831 "auth_all": self.auth_all,
834 "segment_dir": self.segment_dir,
835 },
836 "persistence": "per-process (host-supplied at open) — not stored in the .db",
837 }))
838 }
839 ("GET", "/api/browse") => {
840 let limit: i64 = q("limit")
846 .and_then(|v| v.parse().ok())
847 .unwrap_or(500)
848 .clamp(1, 2000);
849 let built = self.facade.with_store(|m| -> Result<(i64, Vec<Value>), String> {
850 let total = m.stats().map_err(|e| e.to_string())?.ops as i64;
851 let after = (total - limit).max(0);
852 let ops = m.changes_since(after, limit as usize).map_err(|e| e.to_string())?;
853 let mut rows: Vec<Value> = Vec::with_capacity(ops.len());
854 let mut idx: std::collections::HashMap<String, usize> =
855 std::collections::HashMap::new();
856 for o in &ops {
857 let hex = o.hash.to_hex();
858 let op_name = match o.op { 1 => "add", 2 => "supersede", 3 => "forget", _ => "?" };
859 if o.op == 3 {
860 match idx.get(&hex) {
863 Some(&i) => { rows[i]["forgotten"] = json!(true); }
864 None => rows.push(json!({
865 "hash": hex, "op_seq": o.op_seq, "hlc": o.hlc,
866 "op": op_name, "forgotten": true,
867 })),
868 }
869 continue;
870 }
871 if let Some(&i) = idx.get(&hex) {
875 rows[i]["op"] = json!(op_name);
876 rows[i]["op_seq"] = json!(o.op_seq);
877 rows[i]["hlc"] = json!(o.hlc);
878 continue;
879 }
880 let mut row = json!({
881 "hash": hex, "op_seq": o.op_seq, "hlc": o.hlc, "op": op_name,
882 });
883 match m.get(&o.hash) {
884 Ok(g) => {
885 row["type"] = json!(format!("{:?}", g.grain_type).to_lowercase());
886 row["fields"] = serde_json::to_value(&g.fields).unwrap_or(Value::Null);
887 }
888 Err(_) => { row["missing"] = json!(true); }
890 }
891 idx.insert(hex, rows.len());
892 rows.push(row);
893 }
894 let marks: Vec<(String, String)> = rows.iter()
897 .filter(|r| r["op"] == "supersede")
898 .filter_map(|r| {
899 let old = r["fields"].get("derived_from")?.as_str()?;
900 Some((old.to_string(), r["hash"].as_str()?.to_string()))
901 })
902 .collect();
903 for (old, newer) in marks {
904 if let Some(&i) = idx.get(&old) {
905 rows[i]["superseded_by"] = json!(newer);
906 }
907 }
908 rows.reverse();
909 Ok((total, rows))
910 });
911 let built = built.map(|(total, rows)| {
918 let Some(ns) = self.facade.session_namespace() else {
919 return (total, rows);
920 };
921 let kept = rows
922 .into_iter()
923 .filter(|r| {
924 r["fields"]
925 .get("namespace")
926 .and_then(Value::as_str)
927 .is_none_or(|got| got == ns)
928 })
929 .collect();
930 (total, kept)
931 });
932 match built {
933 Ok((total, rows)) => ok_json(json!({"ok": true, "total_ops": total, "grains": rows})),
934 Err(e) => ok_json(json!({"ok": false, "error": e})),
935 }
936 }
937 ("GET", "/api/grain") => {
938 let hash = q("hash").unwrap_or_default();
939 match areev_core::error::Hash::from_hex(&hash)
940 .and_then(|h| self.facade.get(&h))
941 {
942 Ok(g) => ok_json(json!({
943 "hash": g.hash.to_hex(),
944 "type": format!("{:?}", g.grain_type).to_lowercase(),
945 "fields": g.fields,
946 })),
947 Err(e) => ok_json(json!({"ok": false, "error": e.to_string()})),
948 }
949 }
950 ("GET", "/api/verify") => match self.facade.with_store(|m| m.verify()) {
951 Ok(r) => ok_json(json!({
952 "integrity": r.integrity, "grains": r.grains,
953 "hash_mismatches": r.hash_mismatches, "undecodable": r.undecodable,
954 })),
955 Err(e) => ok_json(json!({"ok": false, "error": e.to_string()})),
956 },
957 ("POST", "/api/segment") => {
958 if let Err(e) = self
964 .facade
965 .authz()
966 .check(areev_core::authz::Verb::Write, "*")
967 {
968 return ("403 Forbidden", "application/json",
969 json!({"ok": false, "error": e.to_string()}).to_string().into_bytes());
970 }
971 let Some(dir) = &self.segment_dir else {
973 return ("400 Bad Request", "application/json",
974 br#"{"ok":false,"error":"not in hub mode"}"#.to_vec());
975 };
976 let name = q("name").unwrap_or_else(|| format!("push-{}.mgb", now_label()));
977 let Some(safe) = safe_segment_name(&name) else {
978 return ("400 Bad Request", "application/json",
979 br#"{"ok":false,"error":"invalid segment name"}"#.to_vec());
980 };
981 let path = format!("{dir}/{safe}");
982 if std::fs::write(&path, body).is_err() {
983 return ("500 Internal Server Error", "application/json",
984 br#"{"ok":false,"error":"write failed"}"#.to_vec());
985 }
986 match self.facade.with_store(|m| m.import_bundle(&path)) {
987 Ok(st) => ok_json(json!({"ok": true, "applied": st.applied, "skipped": st.skipped, "stored": safe})),
988 Err(e) => ok_json(json!({"ok": false, "error": e.to_string()})),
989 }
990 }
991 ("GET", "/api/segments") | ("GET", "/api/segment") if !self.session_may_sync() => (
992 "403 Forbidden",
993 "application/json",
994 br#"{"ok":false,"error":"AUT-E001: this session lacks read on the whole memory"}"#.to_vec(),
995 ),
996 ("GET", "/api/segments") => {
997 let Some(dir) = &self.segment_dir else {
998 return ok_json(json!([]));
999 };
1000 let mut names: Vec<String> = std::fs::read_dir(dir)
1001 .map(|d| d.filter_map(|e| e.ok().map(|e| e.file_name().to_string_lossy().to_string()))
1002 .filter(|n| n.ends_with(".mgb")).collect())
1003 .unwrap_or_default();
1004 names.sort();
1005 ok_json(json!(names))
1006 }
1007 ("GET", "/api/segment") => {
1008 let Some(dir) = &self.segment_dir else {
1009 return ("400 Bad Request", "application/json", b"{}".to_vec());
1010 };
1011 let name = q("name").unwrap_or_default();
1012 let Some(safe) = safe_segment_name(&name) else {
1013 return ("400 Bad Request", "application/json",
1014 br#"{"ok":false,"error":"invalid segment name"}"#.to_vec());
1015 };
1016 match std::fs::read(format!("{dir}/{safe}")) {
1017 Ok(b) => ("200 OK", "application/octet-stream", b),
1018 Err(_) => ("404 Not Found", "application/json",
1019 br#"{"ok":false,"error":"no such segment"}"#.to_vec()),
1020 }
1021 }
1022 ("GET", "/api/run/list") => {
1026 let runner = self.runner("console:read");
1027 match runner.recent_runs(50) {
1028 Ok(ids) => {
1029 let rows: Vec<Value> = ids
1030 .iter()
1031 .map(|id| {
1032 let outcome = self.facade.with_store(|m| {
1033 m.recent(
1034 "agent:harness",
1035 Some(areev_core::types::GrainType::Observation),
1036 4096,
1037 )
1038 })
1039 .ok()
1040 .and_then(|rows| {
1041 rows.into_iter().find(|g| {
1042 g.get_str("run_id") == Some(id.as_str())
1043 && g.get_str("observation_kind")
1044 == Some("run_outcome")
1045 })
1046 });
1047 serde_json::json!({
1048 "run_id": id,
1049 "outcome": outcome.as_ref().and_then(|g| g.get_str("object")).unwrap_or("open"),
1050 })
1051 })
1052 .collect();
1053 ok_json(serde_json::json!({"ok": true, "runs": rows}))
1054 }
1055 Err(e) => run_api_err(&e.to_string()),
1056 }
1057 }
1058 ("GET", "/api/run/inspect") => {
1059 let Some(run_id) = q("run_id") else {
1060 return run_api_err("run_id required");
1061 };
1062 let manifest = self
1063 .facade
1064 .with_store(|m| areev_run::RunManifest::load(m, &run_id));
1065 let manifest = match manifest {
1066 Ok(m) => m,
1067 Err(e) => return run_api_err(&e.to_string()),
1068 };
1069 let ns = self.facade.default_namespace().unwrap_or("shared").to_string();
1070 let view = self
1071 .facade
1072 .with_store(|m| areev_run::journal::load(m, &ns, &run_id));
1073 let (checkpoints, entries, scheduler) = match view {
1074 Ok(v) => {
1075 let sched = v
1076 .checkpoints
1077 .last()
1078 .map(|c| c.scheduler.clone())
1079 .unwrap_or(Value::Null);
1080 (v.checkpoints.len(), v.entries.len(), sched)
1081 }
1082 Err(_) => (0, 0, Value::Null),
1083 };
1084 ok_json(serde_json::json!({
1085 "ok": true,
1086 "run_id": run_id,
1087 "plan_hash": manifest.plan_hash,
1088 "principal": manifest.principal,
1089 "checkpoints": checkpoints,
1090 "journal_entries": entries,
1091 "spent": scheduler.get("spent"),
1092 "pending_asks": scheduler.get("pending_asks"),
1093 }))
1094 }
1095 ("POST", "/api/run/respond") => {
1096 let Some(responder) = request_principal.clone() else {
1101 return ("403 Forbidden", "application/json",
1102 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());
1103 };
1104 let req: Value = serde_json::from_slice(body).unwrap_or(Value::Null);
1105 let (Some(run_id), Some(ask)) = (
1106 req.get("run_id").and_then(Value::as_str),
1107 req.get("tool_call_id").and_then(Value::as_str),
1108 ) else {
1109 return run_api_err("run_id and tool_call_id required");
1110 };
1111 let result = req.get("result").cloned().unwrap_or(Value::Null);
1112 let is_error = req.get("is_error").and_then(Value::as_bool).unwrap_or(false);
1113 let runner = self.runner(&responder);
1114 match runner.respond(run_id, ask, result, is_error, &responder) {
1115 Ok(()) => ok_json(serde_json::json!({
1116 "ok": true, "responded": ask, "responder": responder,
1117 "note": "resume the run to spend the compute",
1118 })),
1119 Err(e) => run_api_err(&e.to_string()),
1120 }
1121 }
1122 ("POST", "/api/run/cancel") => {
1123 let req: Value = serde_json::from_slice(body).unwrap_or(Value::Null);
1126 let Some(run_id) = req.get("run_id").and_then(Value::as_str) else {
1127 return run_api_err("run_id required");
1128 };
1129 let because = req.get("because").and_then(Value::as_str).unwrap_or("canceled via console");
1130 let who = request_principal.clone().unwrap_or_else(|| "console:shared".into());
1131 let runner = self.runner(&who);
1132 match runner.cancel(run_id, &who, because) {
1133 Ok(()) => ok_json(serde_json::json!({"ok": true, "canceled": run_id})),
1134 Err(e) => run_api_err(&e.to_string()),
1135 }
1136 }
1137 ("GET", "/api/loop/recommendations") => {
1138 let status = q("status").and_then(|s| status_from_str(&s));
1139 let sub = BorrowedSubstrate::new(&self.facade);
1140 match self.engine().recommendations(&sub, status) {
1141 Ok(recs) => ok_json(json!({
1142 "ok": true,
1143 "recommendations": recs.iter().map(rec_json).collect::<Vec<_>>(),
1144 })),
1145 Err(e) => ok_json(json!({"ok": false, "error": e.to_string(), "code": e.code()})),
1146 }
1147 }
1148 ("GET", "/api/loop/health") => {
1149 let sub = BorrowedSubstrate::new(&self.facade);
1150 match self.engine().health(&sub, now_ms()) {
1151 Ok(h) => ok_json(json!({"ok": true, "health": h})),
1152 Err(e) => ok_json(json!({"ok": false, "error": e.to_string(), "code": e.code()})),
1153 }
1154 }
1155 ("GET", "/api/loop/outcomes") => {
1156 let sub = BorrowedSubstrate::new(&self.facade);
1157 match self.engine().outcomes(&sub) {
1158 Ok(o) => ok_json(json!({"ok": true, "outcomes": o})),
1159 Err(e) => ok_json(json!({"ok": false, "error": e.to_string(), "code": e.code()})),
1160 }
1161 }
1162 ("GET", "/api/loop/analyzers") => {
1163 let sub = BorrowedSubstrate::new(&self.facade);
1166 match self.engine().analyzer_settings(&sub) {
1167 Ok(list) => ok_json(json!({"ok": true, "analyzers": list})),
1168 Err(e) => ok_json(json!({"ok": false, "error": e.to_string(), "code": e.code()})),
1169 }
1170 }
1171 ("GET", "/api/loop/telemetry") => {
1172 let mode = self.facade.with_store(|m| m.telemetry_mode());
1175 if mode == areev_store::TelemetryMode::Off {
1176 ok_json(json!({"ok": true, "enabled": false}))
1177 } else {
1178 let access = self.facade.with_store(|m| m.telemetry_access_stats(None));
1179 let queries = self.facade.with_store(|m| m.telemetry_query_stats(None));
1180 let budget = self.facade.with_store(|m| m.telemetry_budget_stats());
1181 match (access, queries, budget) {
1182 (Ok(mut a), Ok(mut q), Ok(b)) => {
1183 a.sort_by_key(|x| std::cmp::Reverse(x.recall_count));
1185 a.truncate(200);
1186 q.sort_by_key(|x| std::cmp::Reverse(x.run_count));
1187 q.truncate(200);
1188 ok_json(json!({
1189 "ok": true,
1190 "enabled": true,
1191 "mode": mode.as_str(),
1192 "access": a.iter().map(|x| json!({
1193 "hash": x.hash, "recall_count": x.recall_count, "last_ms": x.last_ms,
1194 })).collect::<Vec<_>>(),
1195 "queries": q.iter().map(|x| json!({
1196 "sample": x.sample, "run_count": x.run_count,
1197 "empty_count": x.empty_count,
1198 })).collect::<Vec<_>>(),
1199 "budget": {
1200 "sample_count": b.sample_count,
1201 "overflow_count": b.overflow_count,
1202 },
1203 }))
1204 }
1205 _ => ok_json(json!({"ok": false, "error": "telemetry read failed"})),
1206 }
1207 }
1208 }
1209 ("POST", "/api/loop/run") => {
1210 let authz = self.facade.authz();
1211 if !authz.is_owner()
1212 && !authz.allows(areev_core::authz::Verb::LoopRun, areev_loop::LOOP_NS)
1213 {
1214 return ("403 Forbidden", "application/json",
1215 br#"{"ok":false,"error":"AUT-E001: this session lacks loop.run"}"#.to_vec());
1216 }
1217 let mut sub = BorrowedSubstrate::new(&self.facade);
1218 let actor = if self.credentials.is_some() {
1223 authz.principal().to_string()
1224 } else {
1225 serde_json::from_slice::<Value>(body)
1226 .ok()
1227 .and_then(|v| v.get("actor").and_then(Value::as_str).map(str::to_string))
1228 .unwrap_or_else(|| "user:console".to_string())
1229 };
1230 let opts = RunOptions { triggering_actor: Some(actor), ..Default::default() };
1231 match self.engine().run(&mut sub, &opts, now_ms()) {
1232 Ok(res) => ok_json(json!({"ok": true, "run": res})),
1233 Err(e) => ok_json(json!({"ok": false, "error": e.to_string(), "code": e.code()})),
1234 }
1235 }
1236 ("POST", "/api/loop/review") => self.loop_review(body),
1237 ("POST", "/api/loop/apply") => self.loop_apply(body),
1238 ("POST", "/api/loop/rollback") => self.loop_rollback(body),
1239 ("POST", "/api/loop/config") => self.loop_config(body),
1240 _ => (
1241 "404 Not Found",
1242 "application/json",
1243 br#"{"ok":false,"error":"not found"}"#.to_vec(),
1244 ),
1245 }
1246 }
1247
1248 fn loop_review(&self, body: &[u8]) -> (&'static str, &'static str, Vec<u8>) {
1253 let req: Value = serde_json::from_slice(body).unwrap_or(Value::Null);
1254 let hash = req.get("hash").and_then(Value::as_str).unwrap_or("");
1255 let because = req.get("because").and_then(Value::as_str).unwrap_or("");
1256 let bound = self.facade.authz();
1257 let actor = if self.credentials.is_some() {
1258 bound.principal().to_string()
1259 } else {
1260 req.get("actor").and_then(Value::as_str).unwrap_or("user:console").to_string()
1261 };
1262 let actor = actor.as_str();
1263 let decision = if req.get("decision").and_then(Value::as_str) == Some("reject") {
1264 Decision::Reject
1265 } else {
1266 Decision::Approve
1267 };
1268 let mut sub = BorrowedSubstrate::new(&self.facade);
1269 let scopes = areev_loop_adapter::scopes_for(&self.facade.authz());
1270 let observer = areev_loop_adapter::observer_for_principal(actor);
1271 match self.engine().review(
1272 &mut sub, hash, decision, actor, observer, &scopes, because, now_ms(),
1273 ) {
1274 Ok(()) => ok_json(json!({"ok": true})),
1275 Err(e) => ok_json(json!({"ok": false, "error": e.to_string(), "code": e.code()})),
1276 }
1277 }
1278
1279 fn loop_apply(&self, body: &[u8]) -> (&'static str, &'static str, Vec<u8>) {
1280 let req: Value = serde_json::from_slice(body).unwrap_or(Value::Null);
1281 let hash = req.get("hash").and_then(Value::as_str).unwrap_or("");
1282 let because = req.get("because").and_then(Value::as_str).unwrap_or("");
1283 let bound = self.facade.authz();
1284 let actor = if self.credentials.is_some() {
1285 bound.principal().to_string()
1286 } else {
1287 req.get("actor").and_then(Value::as_str).unwrap_or("user:console").to_string()
1288 };
1289 let actor = actor.as_str();
1290 let allow_destructive = req.get("allow_destructive").and_then(Value::as_bool).unwrap_or(false);
1291 let mut sub = BorrowedSubstrate::new(&self.facade);
1292 let scopes = areev_loop_adapter::scopes_for(&self.facade.authz());
1293 let observer = areev_loop_adapter::observer_for_principal(actor);
1294 match self.engine().apply(
1295 &mut sub, hash, actor, observer, &scopes, because, allow_destructive, now_ms(),
1296 ) {
1297 Ok(applied) => ok_json(json!({"ok": true, "rollbackable": applied.rollbackable})),
1298 Err(e) => ok_json(json!({"ok": false, "error": e.to_string(), "code": e.code()})),
1299 }
1300 }
1301
1302 fn loop_rollback(&self, body: &[u8]) -> (&'static str, &'static str, Vec<u8>) {
1303 let req: Value = serde_json::from_slice(body).unwrap_or(Value::Null);
1304 let hash = req.get("hash").and_then(Value::as_str).unwrap_or("");
1305 let because = req.get("because").and_then(Value::as_str).unwrap_or("");
1306 let bound = self.facade.authz();
1307 let actor = if self.credentials.is_some() {
1308 bound.principal().to_string()
1309 } else {
1310 req.get("actor").and_then(Value::as_str).unwrap_or("user:console").to_string()
1311 };
1312 let actor = actor.as_str();
1313 let mut sub = BorrowedSubstrate::new(&self.facade);
1314 let scopes = areev_loop_adapter::scopes_for(&self.facade.authz());
1315 let observer = areev_loop_adapter::observer_for_principal(actor);
1316 match self.engine().rollback(
1317 &mut sub, hash, actor, observer, &scopes, because, now_ms(),
1318 ) {
1319 Ok(()) => ok_json(json!({"ok": true})),
1320 Err(e) => ok_json(json!({"ok": false, "error": e.to_string(), "code": e.code()})),
1321 }
1322 }
1323
1324 fn loop_config(&self, body: &[u8]) -> (&'static str, &'static str, Vec<u8>) {
1330 let id = serde_json::from_slice::<Value>(body)
1331 .ok()
1332 .and_then(|v| v.get("analyzer_id").and_then(Value::as_str).map(str::to_string))
1333 .unwrap_or_default();
1334 let update: areev_loop::AnalyzerConfigUpdate = serde_json::from_slice(body).unwrap_or_default();
1336 let mut sub = BorrowedSubstrate::new(&self.facade);
1337 match self.engine().set_analyzer_config(&mut sub, &id, update, &areev_loop_adapter::scopes_for(&self.facade.authz())) {
1338 Ok(cfg) => ok_json(json!({"ok": true, "config": cfg})),
1339 Err(e) => ok_json(json!({"ok": false, "error": e.to_string(), "code": e.code()})),
1340 }
1341 }
1342}
1343
1344fn host_is_local(host_port: &str) -> bool {
1348 let host = if let Some(h) = host_port.strip_prefix('[') {
1349 h.split(']').next().unwrap_or("")
1350 } else {
1351 host_port.split(':').next().unwrap_or("")
1352 };
1353 matches!(host, "127.0.0.1" | "localhost" | "::1")
1354}
1355
1356fn origin_is_local(origin: &str) -> bool {
1358 let rest = origin
1359 .strip_prefix("http://")
1360 .or_else(|| origin.strip_prefix("https://"));
1361 let Some(rest) = rest else { return false };
1362 let host_port = rest.split('/').next().unwrap_or("");
1363 host_is_local(host_port)
1364}
1365
1366fn now_label() -> String {
1367 format!("{}", std::time::SystemTime::now()
1368 .duration_since(std::time::UNIX_EPOCH)
1369 .map(|d| d.as_millis())
1370 .unwrap_or(0))
1371}
1372
1373fn ok_json(v: Value) -> (&'static str, &'static str, Vec<u8>) {
1374 ("200 OK", "application/json", v.to_string().into_bytes())
1375}
1376
1377fn run_api_err(msg: &str) -> (&'static str, &'static str, Vec<u8>) {
1378 (
1379 "400 Bad Request",
1380 "application/json",
1381 serde_json::json!({"ok": false, "error": msg}).to_string().into_bytes(),
1382 )
1383}
1384
1385fn cal_body_is_read_only(body: &[u8]) -> bool {
1392 let req: Value = serde_json::from_slice(body).unwrap_or(Value::Null);
1393 let q = req.get("query").and_then(Value::as_str).unwrap_or("");
1394 areev_cal::classify::query_is_read_only(q)
1395}
1396
1397fn status_from_str(s: &str) -> Option<RecStatus> {
1398 match s {
1399 "pending" => Some(RecStatus::Pending),
1400 "approved" => Some(RecStatus::Approved),
1401 "rejected" => Some(RecStatus::Rejected),
1402 "applied" => Some(RecStatus::Applied),
1403 "rolled_back" => Some(RecStatus::RolledBack),
1404 "expired" => Some(RecStatus::Expired),
1405 _ => None, }
1407}
1408
1409fn rec_json(r: &areev_loop::Recommendation) -> Value {
1410 json!({
1411 "hash": r.hash,
1412 "status": r.status.as_str(),
1413 "severity": r.severity.as_str(),
1414 "analyzer": r.analyzer,
1415 "summary": r.summary.render(),
1416 "target_ref": r.target_ref,
1417 "destructive": r.destructive,
1418 "rollbackable": r.rollbackable,
1419 "evidence": r.evidence,
1420 })
1421}
1422
1423fn urldecode(s: &str) -> String {
1424 let mut out = Vec::new();
1425 let b = s.as_bytes();
1426 let mut i = 0;
1427 while i < b.len() {
1428 match b[i] {
1429 b'%' if i + 2 < b.len() + 1 && i + 2 < b.len() + 1 => {
1430 if i + 2 < b.len() + 1 && i + 2 < b.len() {
1431 let hex = std::str::from_utf8(&b[i + 1..i + 3]).unwrap_or("");
1432 if let Ok(v) = u8::from_str_radix(hex, 16) {
1433 out.push(v);
1434 i += 3;
1435 continue;
1436 }
1437 }
1438 out.push(b[i]);
1439 i += 1;
1440 }
1441 b'+' => {
1442 out.push(b' ');
1443 i += 1;
1444 }
1445 c => {
1446 out.push(c);
1447 i += 1;
1448 }
1449 }
1450 }
1451 String::from_utf8_lossy(&out).to_string()
1452}
1453
1454fn html_escape(s: &str) -> String {
1455 s.replace('&', "&").replace('<', "<").replace('>', ">")
1456}
1457
1458fn ct_eq(a: &[u8], b: &[u8]) -> bool {
1461 if a.len() != b.len() {
1462 return false;
1463 }
1464 let mut diff = 0u8;
1465 for (x, y) in a.iter().zip(b.iter()) {
1466 diff |= x ^ y;
1467 }
1468 diff == 0
1469}
1470
1471fn base64_decode(s: &str) -> Option<Vec<u8>> {
1475 fn val(c: u8) -> Option<u32> {
1476 match c {
1477 b'A'..=b'Z' => Some((c - b'A') as u32),
1478 b'a'..=b'z' => Some((c - b'a' + 26) as u32),
1479 b'0'..=b'9' => Some((c - b'0' + 52) as u32),
1480 b'+' => Some(62),
1481 b'/' => Some(63),
1482 _ => None,
1483 }
1484 }
1485 let s = s.trim().trim_end_matches('=');
1486 let mut out = Vec::with_capacity(s.len() * 3 / 4);
1487 let (mut acc, mut bits) = (0u32, 0u32);
1488 for &c in s.as_bytes() {
1489 acc = (acc << 6) | val(c)?;
1490 bits += 6;
1491 if bits >= 8 {
1492 bits -= 8;
1493 out.push((acc >> bits) as u8);
1494 }
1495 }
1496 Some(out)
1497}
1498
1499fn basic_auth_password(b64: &str) -> Option<String> {
1503 let text = String::from_utf8(base64_decode(b64)?).ok()?;
1504 text.split_once(':').map(|(_user, pass)| pass.to_string())
1505}
1506
1507fn safe_segment_name(name: &str) -> Option<String> {
1512 let safe: String = name
1513 .chars()
1514 .filter(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '.' | '_'))
1515 .collect();
1516 if safe.is_empty() || safe.len() > 128 {
1517 return None;
1518 }
1519 let mut comps = std::path::Path::new(&safe).components();
1520 match (comps.next(), comps.next()) {
1521 (Some(std::path::Component::Normal(c)), None) if c.to_str() == Some(safe.as_str()) => {
1522 Some(safe)
1523 }
1524 _ => None,
1525 }
1526}
1527
1528#[cfg(test)]
1529mod loop_route_tests {
1530 use super::UiServer;
1531 use areev_cal::AreevFacade;
1532 use areev_core::types::{Fact, Grain};
1533 use areev_store::Areev;
1534
1535 fn server(auth: Option<&str>) -> UiServer {
1536 let dir = tempfile::tempdir().unwrap();
1537 let path = dir.path().join("t.db");
1538 let mut store = Areev::open(path.to_str().unwrap()).unwrap();
1539 store.add(&Fact::new("acme", "tier", "Enterprise").namespace("caller")).unwrap();
1541 store.add(&Fact::new("acme", "tier", "Enterprise").namespace("caller")).unwrap();
1542 std::mem::forget(dir); let facade = AreevFacade::with_session(store, Some("caller".into()), None);
1544 let s = UiServer::new(facade, "test".into());
1545 match auth {
1546 Some(t) => s.with_auth(t.to_string()),
1547 None => s,
1548 }
1549 }
1550
1551 fn text(r: &(&str, &str, Vec<u8>)) -> String {
1552 String::from_utf8_lossy(&r.2).to_string()
1553 }
1554
1555 #[test]
1556 fn token_less_console_is_read_only() {
1557 let s = server(None);
1558 assert!(s.route("POST", "/api/loop/run", b"{}", None, None).0.starts_with("401"));
1560 let w = s.route("POST", "/api/cal", br#"{"query":"ADD fact SET subject=\"x\""}"#, None, None);
1562 assert!(w.0.starts_with("401"), "write CAL must be 401: {}", text(&w));
1563 let r = s.route("POST", "/api/cal", br#"{"query":"RECALL facts WHERE subject = \"acme\""}"#, None, None);
1565 assert!(r.0.starts_with("200"), "read CAL allowed: {}", text(&r));
1566 assert!(s.route("GET", "/api/loop/recommendations", b"", None, None).0.starts_with("200"));
1568 }
1569
1570 #[test]
1571 fn telemetry_endpoint_is_an_open_read() {
1572 let s = server(None);
1573 let r = s.route("GET", "/api/loop/telemetry", b"", None, None);
1574 assert!(r.0.starts_with("200"), "telemetry read open: {}", text(&r));
1575 let body = text(&r);
1576 assert!(body.contains("\"ok\":true"), "{body}");
1577 assert!(body.contains("\"enabled\":false"), "{body}");
1579 }
1580
1581 #[test]
1582 fn authenticated_run_review_apply_roundtrip() {
1583 let s = server(Some("tok"));
1584 let run = s.route("POST", "/api/loop/run", b"{}", Some("tok"), None);
1585 assert!(run.0.starts_with("200"), "run: {}", text(&run));
1586 assert!(text(&run).contains("\"ran\""));
1587
1588 let list = s.route("GET", "/api/loop/recommendations?status=pending", b"", Some("tok"), None);
1589 let v: serde_json::Value = serde_json::from_slice(&list.2).unwrap();
1590 let recs = v["recommendations"].as_array().unwrap();
1591 assert!(!recs.is_empty(), "at least one recommendation");
1592 let hash = recs[0]["hash"].as_str().unwrap().to_string();
1593
1594 let rev = s.route(
1595 "POST",
1596 "/api/loop/review",
1597 format!(r#"{{"hash":"{hash}","decision":"approve","because":"ok"}}"#).as_bytes(),
1598 Some("tok"),
1599 None,
1600 );
1601 assert!(text(&rev).contains("\"ok\":true"), "review: {}", text(&rev));
1602
1603 let ap = s.route(
1604 "POST",
1605 "/api/loop/apply",
1606 format!(r#"{{"hash":"{hash}","because":"go"}}"#).as_bytes(),
1607 Some("tok"),
1608 None,
1609 );
1610 assert!(text(&ap).contains("\"ok\":true"), "apply: {}", text(&ap));
1611 }
1612
1613 #[test]
1614 fn config_edit_toggles_analyzer_via_console() {
1615 let s = server(Some("tok"));
1616 assert!(s.route("POST", "/api/loop/config", b"{}", None, None).0.starts_with("401"));
1618
1619 let list = s.route("GET", "/api/loop/analyzers", b"", Some("tok"), None);
1621 let v: serde_json::Value = serde_json::from_slice(&list.2).unwrap();
1622 let id = v["analyzers"]
1623 .as_array()
1624 .unwrap()
1625 .iter()
1626 .find(|x| x["enabled"] == true)
1627 .expect("some analyzer is on")["id"]
1628 .as_str()
1629 .unwrap()
1630 .to_string();
1631
1632 let post = s.route(
1634 "POST",
1635 "/api/loop/config",
1636 format!(r#"{{"analyzer_id":"{id}","enabled":false}}"#).as_bytes(),
1637 Some("tok"),
1638 None,
1639 );
1640 assert!(text(&post).contains("\"ok\":true"), "config: {}", text(&post));
1641
1642 let list2 = s.route("GET", "/api/loop/analyzers", b"", Some("tok"), None);
1644 let v2: serde_json::Value = serde_json::from_slice(&list2.2).unwrap();
1645 let now = v2["analyzers"]
1646 .as_array()
1647 .unwrap()
1648 .iter()
1649 .find(|x| x["id"] == id.as_str())
1650 .unwrap();
1651 assert_eq!(now["enabled"], false, "toggled off and persisted");
1652 }
1653
1654 #[test]
1655 fn self_approval_surfaces_lop_code() {
1656 let s = server(Some("tok"));
1657 s.route("POST", "/api/loop/run", b"{}", Some("tok"), None);
1658 let list = s.route("GET", "/api/loop/recommendations?status=pending", b"", Some("tok"), None);
1659 let v: serde_json::Value = serde_json::from_slice(&list.2).unwrap();
1660 let rec = &v["recommendations"].as_array().unwrap()[0];
1661 let hash = rec["hash"].as_str().unwrap();
1662 let analyzer = rec["analyzer"].as_str().unwrap();
1663 let creator = format!("engine:{analyzer}");
1664 let rev = s.route(
1666 "POST",
1667 "/api/loop/review",
1668 format!(r#"{{"hash":"{hash}","decision":"approve","because":"self","actor":"{creator}"}}"#).as_bytes(),
1669 Some("tok"),
1670 None,
1671 );
1672 assert!(text(&rev).contains("LOP-E021"), "self-approval blocked: {}", text(&rev));
1673 }
1674}
1675
1676#[cfg(test)]
1677mod security_tests {
1678 use super::{base64_decode, basic_auth_password, ct_eq, safe_segment_name};
1679
1680 #[test]
1681 fn ct_eq_matches_only_equal() {
1682 assert!(ct_eq(b"secret-token", b"secret-token"));
1683 assert!(!ct_eq(b"secret-token", b"secret-toker"));
1684 assert!(!ct_eq(b"secret", b"secret-token")); assert!(ct_eq(b"", b""));
1686 }
1687
1688 #[test]
1689 fn base64_decode_roundtrips_known_vectors() {
1690 assert_eq!(base64_decode("").unwrap(), b"");
1692 assert_eq!(base64_decode("Zg==").unwrap(), b"f");
1693 assert_eq!(base64_decode("Zm8=").unwrap(), b"fo");
1694 assert_eq!(base64_decode("Zm9v").unwrap(), b"foo");
1695 assert_eq!(base64_decode("Zm9vYmFy").unwrap(), b"foobar");
1696 assert_eq!(base64_decode("Zg").unwrap(), b"f");
1698 assert!(base64_decode("****").is_none());
1700 }
1701
1702 #[test]
1703 fn basic_auth_extracts_password_ignoring_username() {
1704 assert_eq!(basic_auth_password("ZGVqYTpzM2NyZXQ=").as_deref(), Some("s3cret"));
1706 assert_eq!(basic_auth_password("OnMzY3JldA==").as_deref(), Some("s3cret"));
1707 assert_eq!(basic_auth_password("dTphOmI=").as_deref(), Some("a:b"));
1710 assert_eq!(basic_auth_password("bm9jb2xvbg=="), None); assert_eq!(basic_auth_password("****"), None); }
1714
1715 #[test]
1716 fn safe_segment_name_blocks_traversal() {
1717 assert_eq!(safe_segment_name("push-123.mgb").as_deref(), Some("push-123.mgb"));
1718 assert_eq!(safe_segment_name(".."), None);
1719 assert_eq!(safe_segment_name("."), None);
1720 assert_eq!(safe_segment_name(""), None);
1721 for probe in ["../../etc/passwd", "/etc/passwd", "a/../b", "foo/bar"] {
1724 if let Some(s) = safe_segment_name(probe) {
1725 assert!(!s.contains('/') && s != ".." && s != ".", "unsafe: {s}");
1726 }
1727 }
1728 }
1729}
1730
1731#[cfg(test)]
1732mod credential_route_tests {
1733 use super::UiServer;
1734 use areev_cal::AreevFacade;
1735 use areev_core::authz::{CredentialMap, AUTHZ_NS, REL_PERMITS};
1736 use areev_core::types::{Fact, Grain};
1737 use areev_store::Areev;
1738
1739 fn server() -> UiServer {
1742 std::env::set_var("AREEV_TEST_READER_TOK", "reader-secret");
1743 std::env::set_var("AREEV_TEST_WRITER_TOK", "writer-secret");
1744 let dir = tempfile::tempdir().unwrap();
1745 let path = dir.path().join("t.db");
1746 let mut store = Areev::open(path.to_str().unwrap()).unwrap();
1747 store
1748 .add(&Fact::new("user:reader", REL_PERMITS, "read ON *").namespace(AUTHZ_NS).created_at(1_000))
1749 .unwrap();
1750 store
1751 .add(
1752 &Fact::new("agent:writer", REL_PERMITS, "read,write ON caller")
1753 .namespace(AUTHZ_NS)
1754 .created_at(2_000),
1755 )
1756 .unwrap();
1757 store.add(&Fact::new("acme", "tier", "Enterprise").namespace("caller").created_at(3_000)).unwrap();
1758 std::mem::forget(dir);
1759 let facade = AreevFacade::with_session(store, Some("caller".into()), None);
1760 let map = CredentialMap::from_json(
1761 r#"{"version":1,"tokens":[
1762 {"env":"AREEV_TEST_READER_TOK","principal":"user:reader"},
1763 {"env":"AREEV_TEST_WRITER_TOK","principal":"agent:writer"}
1764 ]}"#,
1765 )
1766 .unwrap();
1767 UiServer::new(facade, "test".into()).with_credentials(map)
1768 }
1769
1770 fn text(r: &(&str, &str, Vec<u8>)) -> String {
1771 String::from_utf8_lossy(&r.2).to_string()
1772 }
1773
1774 const READ: &[u8] = br#"{"query":"RECALL facts WHERE subject = \"acme\""}"#;
1775 const WRITE: &[u8] = br#"{"query":"ADD fact SET subject = \"x\" SET relation = \"r\" SET object = \"o\" SET namespace = \"caller\" REASON \"t\""}"#;
1776
1777 #[test]
1778 fn tokens_bind_principals_and_verbs_decide() {
1779 let s = server();
1780
1781 let r = s.route("POST", "/api/cal", READ, Some("reader-secret"), None);
1783 assert!(r.0.starts_with("200"), "{}", text(&r));
1784 assert!(text(&r).contains("grains"));
1785 let w = s.route("POST", "/api/cal", WRITE, Some("reader-secret"), None);
1787 assert!(text(&w).contains("AUT-E001"), "reader write must be refused: {}", text(&w));
1788
1789 let w = s.route("POST", "/api/cal", WRITE, Some("writer-secret"), None);
1791 assert!(text(&w).contains("added"), "writer write must land: {}", text(&w));
1792
1793 let w = s.route("POST", "/api/cal", WRITE, Some("reader-secret"), None);
1796 assert!(text(&w).contains("AUT-E001"), "{}", text(&w));
1797 }
1798
1799 #[test]
1800 fn anonymous_is_read_only_and_loop_run_is_verb_gated() {
1801 let s = server();
1802 let r = s.route("POST", "/api/cal", READ, None, None);
1804 assert!(r.0.starts_with("200"), "{}", text(&r));
1805 assert!(text(&r).contains("grains"));
1806 let w = s.route("POST", "/api/cal", WRITE, None, None);
1808 assert!(text(&w).contains("AUT-E001"), "{}", text(&w));
1809 let w = s.route("POST", "/api/cal", WRITE, Some("stolen-guess"), None);
1811 assert!(text(&w).contains("AUT-E001"), "{}", text(&w));
1812
1813 let run = s.route("POST", "/api/loop/run", b"{}", None, None);
1815 assert!(run.0.starts_with("403"), "{}", text(&run));
1816 }
1817
1818 #[test]
1819 fn whoami_reports_the_bound_principal() {
1820 let s = server();
1821 let r = s.route("GET", "/api/whoami", b"", Some("reader-secret"), None);
1822 assert!(text(&r).contains("user:reader"), "{}", text(&r));
1823 assert!(text(&r).contains("credentials"));
1824 let r = s.route("GET", "/api/whoami", b"", None, None);
1825 assert!(text(&r).contains("anonymous"), "{}", text(&r));
1826 }
1827
1828 #[test]
1832 fn a_credential_map_does_not_disable_the_shared_secret() {
1833 let s = server().with_auth("console-token".into());
1834
1835 let r = s.route("POST", "/api/cal", READ, None, None);
1837 assert!(r.0.starts_with("401"), "{}", text(&r));
1838 let page = s.route("GET", "/", b"", None, None);
1839 assert!(page.0.starts_with("401"), "the console page is guarded too");
1840 let r = s.route("POST", "/api/cal", READ, Some("stolen-guess"), None);
1842 assert!(r.0.starts_with("401"), "{}", text(&r));
1843
1844 let r = s.route("POST", "/api/cal", READ, Some("reader-secret"), None);
1846 assert!(r.0.starts_with("200"), "{}", text(&r));
1847 let w = s.route("POST", "/api/cal", WRITE, Some("reader-secret"), None);
1848 assert!(text(&w).contains("AUT-E001"), "{}", text(&w));
1849
1850 let w = s.route("POST", "/api/cal", WRITE, Some("console-token"), None);
1853 assert!(text(&w).contains("added"), "{}", text(&w));
1854 }
1855
1856 #[test]
1859 fn run_loop_via_cal_is_verb_gated_like_the_route() {
1860 let s = server();
1861 let run = s.route("POST", "/api/loop/run", b"{}", None, None);
1862 assert!(run.0.starts_with("403"), "{}", text(&run));
1863 let cal = s.route("POST", "/api/cal", br#"{"query":"RUN LOOP"}"#, None, None);
1864 assert!(
1865 text(&cal).contains("AUT-E001"),
1866 "anonymous must not run the loop through CAL either: {}",
1867 text(&cal)
1868 );
1869 }
1870}
1871
1872#[cfg(test)]
1873mod builder_tests {
1874 use super::UiServer;
1875 use areev_cal::AreevFacade;
1876 use areev_store::Areev;
1877
1878 fn server() -> UiServer {
1879 let dir = tempfile::tempdir().unwrap();
1880 let path = dir.path().join("t.db");
1881 let store = Areev::open(path.to_str().unwrap()).unwrap();
1882 std::mem::forget(dir);
1883 UiServer::new(AreevFacade::with_session(store, Some("caller".into()), None), "t".into())
1884 }
1885
1886 #[test]
1890 fn attaching_a_loop_policy_preserves_the_destructive_cap() {
1891 let s = server()
1892 .allow_destructive_ops(false)
1893 .with_loop_policy(areev_loop::Policy::default());
1894 let r = s.route(
1895 "POST",
1896 "/api/cal",
1897 br#"{"query":"FORGET sha256:684c6c9bda818630a870119d0726e4d242ed537af061658ef6f3acb158a2c67d"}"#,
1898 None,
1899 None,
1900 );
1901 let body = String::from_utf8_lossy(&r.2).to_string();
1902 assert!(
1903 body.contains("--no-destructive-ops") || r.0.starts_with("401"),
1904 "destruction must stay disabled: {body}"
1905 );
1906 }
1907}
1908
1909#[cfg(test)]
1910mod run_api_tests {
1911 use super::UiServer;
1912 use areev_cal::AreevFacade;
1913 use areev_core::authz::{CredentialMap, AUTHZ_NS, REL_PERMITS};
1914 use areev_core::types::{Fact, Grain, Tool, ToolKind, Workflow};
1915 use areev_store::Areev;
1916 use std::sync::Arc;
1917
1918 fn parked_server() -> (UiServer, String) {
1921 std::env::set_var("AREEV_RUN_OFFICER_TOK", "officer-secret");
1922 std::env::set_var("AREEV_RUN_STARTER_TOK", "starter-secret");
1923 let dir = tempfile::tempdir().unwrap();
1924 let path = dir.path().join("t.db");
1925 let mut store = Areev::open(path.to_str().unwrap()).unwrap();
1926 for (who, what) in [
1927 ("user:officer", "read,run.respond ON ops"),
1928 ("user:starter", "read ON ops"),
1929 ] {
1930 store
1931 .add(&Fact::new(who, REL_PERMITS, what).namespace(AUTHZ_NS).created_at(900))
1932 .unwrap();
1933 }
1934 std::mem::forget(dir);
1935 let facade = Arc::new(AreevFacade::with_session(store, Some("ops".into()), None));
1936
1937 let (wf, _g, _a) = facade
1939 .with_store(|m| {
1940 let greet = Tool::new("greet")
1941 .kind(ToolKind::Definition)
1942 .tool_description("t")
1943 .created_at(500)
1944 .namespace("ops");
1945 let gh = m.add(&greet)?;
1946 let approve = Tool::new("approve")
1947 .kind(ToolKind::Definition)
1948 .tool_description("human approves")
1949 .executor_kind(areev_core::types::ExecutorKind::Client)
1950 .created_at(501)
1951 .namespace("ops");
1952 let ah = m.add(&approve)?;
1953 let wf = Workflow::new(vec!["greet".into(), "approve".into()])
1954 .edge("greet", "approve")
1955 .bind("greet", &gh.to_hex())
1956 .bind("approve", &ah.to_hex())
1957 .created_at(502)
1958 .namespace("ops");
1959 let wh = m.add(&wf)?;
1960 Ok::<_, areev_core::error::AreevError>((wh, gh, ah))
1961 })
1962 .unwrap();
1963 struct Ok1;
1964 impl areev_run::HostToolExecutor for Ok1 {
1965 fn execute(
1966 &self,
1967 t: &str,
1968 _h: &str,
1969 _i: &serde_json::Value,
1970 _k: &str,
1971 ) -> areev_run::ExecResult {
1972 areev_run::ExecResult::Ok(serde_json::json!({t.to_string(): true}))
1973 }
1974 }
1975 let runner = areev_run::Runner {
1976 facade: Arc::clone(&facade),
1977 clock: Arc::new(areev_run::ScriptedClock::new(
1978 (0..100).map(|i| 1_758_000_000_000 + i * 10).collect(),
1979 )),
1980 executor: Arc::new(Ok1),
1981 llm: None,
1982 observer: None,
1983 ns: "ops".into(),
1984 principal: "user:starter".into(),
1985 };
1986 let session = runner
1987 .start(&wf, "hitl-1", serde_json::json!({}), &areev_run::RunOptions {
1988 workers: 1,
1989 ..Default::default()
1990 })
1991 .unwrap();
1992 let areev_run::RunSession::Parked { envelope, .. } = session else {
1993 panic!("expected park")
1994 };
1995 let ask = envelope["asks"][0]["tool_call_id"].as_str().unwrap().to_string();
1996
1997 drop(runner);
2004 let facade = Arc::try_unwrap(facade).unwrap_or_else(|_| panic!("facade still shared"));
2005 let map = CredentialMap::from_json(
2006 r#"{"version":1,"tokens":[
2007 {"env":"AREEV_RUN_OFFICER_TOK","principal":"user:officer"},
2008 {"env":"AREEV_RUN_STARTER_TOK","principal":"user:starter"}
2009 ]}"#,
2010 )
2011 .unwrap();
2012 let server = UiServer::new(facade, "test".into())
2013 .with_auth("shared-console-secret".into())
2014 .with_credentials(map);
2015 (server, ask)
2016 }
2017
2018 fn body(run_id: &str, ask: &str) -> Vec<u8> {
2019 serde_json::json!({"run_id": run_id, "tool_call_id": ask, "result": {"approved": true}})
2020 .to_string()
2021 .into_bytes()
2022 }
2023
2024 #[test]
2025 fn shared_token_approvals_are_refused_and_principals_approve() {
2026 let (s, ask) = parked_server();
2027
2028 let r = s.route("GET", "/api/run/list", b"", Some("shared-console-secret"), None);
2030 assert!(r.0.starts_with("200"), "{}", String::from_utf8_lossy(&r.2));
2031 let r = s.route(
2032 "GET",
2033 "/api/run/inspect?run_id=hitl-1",
2034 b"",
2035 Some("shared-console-secret"),
2036 None,
2037 );
2038 assert!(String::from_utf8_lossy(&r.2).contains(&ask), "ask visible in inspect");
2039
2040 let r = s.route("POST", "/api/run/respond", &body("hitl-1", &ask), Some("shared-console-secret"), None);
2042 assert!(r.0.starts_with("403"), "shared-token approval must refuse: {}", r.0);
2043
2044 let r = s.route("POST", "/api/run/respond", &body("hitl-1", &ask), Some("starter-secret"), None);
2047 let text = String::from_utf8_lossy(&r.2).to_string();
2048 assert!(text.contains("responder") || text.contains("RUN-E012"), "{text}");
2049
2050 let r = s.route("POST", "/api/run/respond", &body("hitl-1", &ask), Some("officer-secret"), None);
2052 assert!(r.0.starts_with("200"), "{}", String::from_utf8_lossy(&r.2));
2053 assert!(String::from_utf8_lossy(&r.2).contains("user:officer"));
2054
2055 let r = s.route(
2057 "POST",
2058 "/api/run/cancel",
2059 br#"{"run_id":"hitl-1","because":"test brake"}"#,
2060 Some("shared-console-secret"),
2061 None,
2062 );
2063 assert!(r.0.starts_with("200"), "{}", String::from_utf8_lossy(&r.2));
2064 }
2065}
2066
2067#[cfg(test)]
2068mod memory_scoped_token_tests {
2069 use super::UiServer;
2070 use areev_cal::AreevFacade;
2071 use areev_core::authz::{CredentialMap, AUTHZ_NS, REL_PERMITS};
2072 use areev_core::types::{Fact, Grain};
2073 use areev_store::Areev;
2074
2075 #[test]
2079 fn memory_scoped_tokens_do_not_cross_memories() {
2080 std::env::set_var("AREEV_SCOPED_TOK", "scoped-secret");
2081 let map_json = r#"{"version":1,"tokens":[
2082 {"env":"AREEV_SCOPED_TOK","principal":"agent:sync","memories":["memory-a"]}
2083 ]}"#;
2084 let server_for = |label: &str| {
2085 let dir = tempfile::tempdir().unwrap();
2086 let mut store = Areev::open(dir.path().join("t.db").to_str().unwrap()).unwrap();
2087 store
2088 .add(
2089 &Fact::new("agent:sync", REL_PERMITS, "read,write ON caller")
2090 .namespace(AUTHZ_NS)
2091 .created_at(1_000),
2092 )
2093 .unwrap();
2094 store
2095 .add(&Fact::new("acme", "tier", "Enterprise").namespace("caller").created_at(2_000))
2096 .unwrap();
2097 std::mem::forget(dir);
2098 let facade = AreevFacade::with_session(store, Some("caller".into()), None);
2099 UiServer::new(facade, label.into())
2100 .with_credentials(CredentialMap::from_json(map_json).unwrap())
2101 };
2102 const WRITE: &[u8] = br#"{"query":"ADD fact SET subject = \"x\" SET relation = \"r\" SET object = \"o\" SET namespace = \"caller\" REASON \"t\""}"#;
2103
2104 let a = server_for("memory-a");
2107 let r = a.route("POST", "/api/cal", WRITE, Some("scoped-secret"), None);
2108 let text = String::from_utf8_lossy(&r.2).to_string();
2109 assert!(r.0.starts_with("200") && !text.contains("AUT-E001"), "{text}");
2110
2111 let b = server_for("memory-b");
2115 let r = b.route("POST", "/api/cal", WRITE, Some("scoped-secret"), None);
2116 let text = String::from_utf8_lossy(&r.2).to_string();
2117 assert!(
2118 text.contains("AUT-E001") && text.contains("anonymous"),
2119 "scoped token must not cross memories: {text}"
2120 );
2121
2122 assert!(CredentialMap::from_json(
2124 r#"{"version":1,"tokens":[{"env":"AREEV_SCOPED_TOK","principal":"x","memories":[]}]}"#
2125 )
2126 .is_err());
2127 }
2128}
2129
2130#[cfg(all(test, feature = "tls"))]
2131mod tls_tests {
2132 use super::UiServer;
2133 use areev_cal::AreevFacade;
2134 use areev_store::Areev;
2135 use std::io::{Read, Write};
2136
2137 fn self_signed(dir: &std::path::Path) -> Option<(String, String)> {
2138 let cert = dir.join("cert.pem");
2139 let key = dir.join("key.pem");
2140 let status = std::process::Command::new("openssl")
2141 .args([
2142 "req", "-x509", "-newkey", "rsa:2048", "-nodes",
2143 "-keyout", key.to_str().unwrap(), "-out", cert.to_str().unwrap(),
2144 "-days", "1", "-subj", "/CN=localhost",
2145 "-addext", "subjectAltName=DNS:localhost,IP:127.0.0.1",
2146 "-addext", "basicConstraints=critical,CA:FALSE",
2147 ])
2148 .stdout(std::process::Stdio::null())
2149 .stderr(std::process::Stdio::null())
2150 .status();
2151 match status {
2152 Ok(s) if s.success() => Some((
2153 cert.to_str().unwrap().to_string(),
2154 key.to_str().unwrap().to_string(),
2155 )),
2156 _ => None, }
2158 }
2159
2160 #[test]
2163 fn tls_round_trip_and_no_plaintext_downgrade() {
2164 let dir = tempfile::tempdir().unwrap();
2165 let Some((cert, key)) = self_signed(dir.path()) else {
2166 eprintln!("skipping: no openssl binary");
2167 return;
2168 };
2169 let store = Areev::open(dir.path().join("t.db").to_str().unwrap()).unwrap();
2170 let facade = AreevFacade::with_session(store, Some("caller".into()), None);
2171 let server = UiServer::new(facade, "tls-test".into())
2172 .with_tls(&cert, &key)
2173 .unwrap();
2174 let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
2175 let addr = listener.local_addr().unwrap();
2176 let handle = std::thread::spawn(move || {
2177 for _ in 0..2 {
2179 if let Ok((s, _)) = listener.accept() {
2180 let _ = server.handle(s);
2181 }
2182 }
2183 });
2184
2185 let mut roots = rustls::RootCertStore::empty();
2187 for c in rustls_pemfile::certs(&mut std::io::BufReader::new(
2188 std::fs::File::open(&cert).unwrap(),
2189 )) {
2190 roots.add(c.unwrap()).unwrap();
2191 }
2192 let config = std::sync::Arc::new(
2193 rustls::ClientConfig::builder()
2194 .with_root_certificates(roots)
2195 .with_no_client_auth(),
2196 );
2197 let name = rustls::pki_types::ServerName::try_from("localhost").unwrap();
2198 let session = rustls::ClientConnection::new(config, name).unwrap();
2199 let tcp = std::net::TcpStream::connect(addr).unwrap();
2200 let mut tls = rustls::StreamOwned::new(session, tcp);
2201 tls.write_all(b"GET / HTTP/1.1\r\nHost: 127.0.0.1\r\nConnection: close\r\n\r\n")
2202 .unwrap();
2203 let mut response = String::new();
2204 let _ = tls.read_to_string(&mut response);
2205 assert!(response.starts_with("HTTP/1.1 200"), "{}", &response[..response.len().min(120)]);
2206 assert!(response.contains("Areev"), "console served over TLS");
2207
2208 let mut plain = std::net::TcpStream::connect(addr).unwrap();
2211 plain
2212 .write_all(b"GET / HTTP/1.1\r\nHost: 127.0.0.1\r\nConnection: close\r\n\r\n")
2213 .unwrap();
2214 let mut buf = Vec::new();
2215 let _ = plain.read_to_end(&mut buf);
2216 let text = String::from_utf8_lossy(&buf);
2217 assert!(
2218 !text.contains("HTTP/1.1 200"),
2219 "plaintext must not be served on a TLS listener: {text}"
2220 );
2221 handle.join().unwrap();
2222 }
2223}
2224
2225#[cfg(test)]
2226mod sso_tests {
2227 use super::UiServer;
2228 use areev_cal::AreevFacade;
2229 use areev_core::authz::{AUTHZ_NS, REL_PERMITS};
2230 use areev_core::types::{Fact, Grain};
2231 use areev_store::Areev;
2232 use std::io::{Read, Write};
2233
2234 const WRITE: &[u8] = br#"{"query":"ADD fact SET subject = \"x\" SET relation = \"r\" SET object = \"o\" SET namespace = \"caller\" REASON \"t\""}"#;
2235
2236 fn sso_server() -> UiServer {
2237 let dir = tempfile::tempdir().unwrap();
2238 let mut store = Areev::open(dir.path().join("t.db").to_str().unwrap()).unwrap();
2239 store
2240 .add(
2241 &Fact::new("user:pat@example.com", REL_PERMITS, "read,write ON caller")
2242 .namespace(AUTHZ_NS)
2243 .created_at(1_000),
2244 )
2245 .unwrap();
2246 std::mem::forget(dir);
2247 let facade = AreevFacade::with_session(store, Some("caller".into()), None);
2248 UiServer::new(facade, "sso-test".into()).with_sso("x-forwarded-user", "proxy-secret")
2249 }
2250
2251 fn post(server: &UiServer, extra_headers: &str) -> String {
2252 let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
2253 let addr = listener.local_addr().unwrap();
2254 std::thread::scope(|scope| {
2255 scope.spawn(|| {
2256 if let Ok((s, _)) = listener.accept() {
2257 let _ = server.handle(s);
2258 }
2259 });
2260 let mut c = std::net::TcpStream::connect(addr).unwrap();
2261 let req = format!(
2262 "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",
2263 WRITE.len()
2264 );
2265 c.write_all(req.as_bytes()).unwrap();
2266 c.write_all(WRITE).unwrap();
2267 let mut out = String::new();
2268 let _ = c.read_to_string(&mut out);
2269 out
2270 })
2271 }
2272
2273 #[test]
2277 fn proxied_identity_works_and_forged_headers_are_ignored() {
2278 let server = sso_server();
2279
2280 let ok = post(
2282 &server,
2283 "X-Forwarded-User: user:pat@example.com\r\nX-Areev-Proxy-Secret: proxy-secret\r\n",
2284 );
2285 assert!(ok.contains("HTTP/1.1 200"), "{}", &ok[..ok.len().min(200)]);
2286 assert!(!ok.contains("AUT-E001"), "granted principal must write: {ok}");
2287
2288 let forged = post(&server, "X-Forwarded-User: user:pat@example.com\r\n");
2291 assert!(
2292 forged.contains("anonymous") || forged.contains("401"),
2293 "forged identity must not authenticate: {forged}"
2294 );
2295
2296 let wrong = post(
2298 &server,
2299 "X-Forwarded-User: user:pat@example.com\r\nX-Areev-Proxy-Secret: guess\r\n",
2300 );
2301 assert!(
2302 wrong.contains("anonymous") || wrong.contains("401"),
2303 "wrong proxy secret must not authenticate: {wrong}"
2304 );
2305 }
2306}