use std::io::{BufRead, BufReader, Read, Write};
use std::net::{TcpListener, TcpStream};
use std::time::Duration;
use areev_cal::{CalExecutor, CalExecutorConfig, CalStoreFacade, AreevFacade};
use areev_loop_adapter::{now_ms, BorrowedSubstrate};
use serde_json::{json, Value};
use areev_loop::{Decision, Engine, RecStatus, RunOptions};
const CONSOLE_HTML: &str = include_str!("console.html");
const READ_TIMEOUT_SECS: u64 = 15;
const MAX_CONN_BYTES: u64 = (1 << 20) + (128 << 10);
const MAX_HEADER_BYTES: usize = 64 * 1024;
const MAX_HEADERS: usize = 100;
const REQUEST_DEADLINE_SECS: u64 = 30;
enum Conn {
Plain(TcpStream),
#[cfg(feature = "tls")]
Tls(Box<rustls::StreamOwned<rustls::ServerConnection, TcpStream>>),
}
impl Read for Conn {
fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
match self {
Conn::Plain(s) => s.read(buf),
#[cfg(feature = "tls")]
Conn::Tls(s) => s.read(buf),
}
}
}
impl Write for Conn {
fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
match self {
Conn::Plain(s) => s.write(buf),
#[cfg(feature = "tls")]
Conn::Tls(s) => s.write(buf),
}
}
fn flush(&mut self) -> std::io::Result<()> {
match self {
Conn::Plain(s) => s.flush(),
#[cfg(feature = "tls")]
Conn::Tls(s) => s.flush(),
}
}
}
pub struct UiServer {
facade: std::sync::Arc<AreevFacade>,
executor: CalExecutor,
db_label: String,
token: Option<String>,
auth_all: bool,
segment_dir: Option<String>,
allow_remote: bool,
loop_policy: Option<areev_loop::Policy>,
credentials: Option<areev_core::authz::CredentialMap>,
allow_destructive_ops: bool,
#[cfg(feature = "tls")]
tls_config: Option<std::sync::Arc<rustls::ServerConfig>>,
sso_header: Option<String>,
sso_secrets: Vec<String>,
}
struct RequestBinding<'a> {
facade: &'a AreevFacade,
}
impl<'a> RequestBinding<'a> {
fn bind(
facade: &'a AreevFacade,
map: &areev_core::authz::CredentialMap,
bearer: Option<&str>,
memory: &str,
) -> Self {
let principal =
bearer.and_then(|t| map.resolve_for_memory(t, memory).ok().map(str::to_string));
match principal {
Some(p) => {
if facade.bind_principal(&p).is_err() {
Self::bind_anonymous(facade);
}
}
None => Self::bind_anonymous(facade),
}
RequestBinding { facade }
}
fn bind_identity(facade: &'a AreevFacade, principal: &str) -> Self {
if facade.bind_principal(principal).is_err() {
Self::bind_anonymous(facade);
}
RequestBinding { facade }
}
fn bind_anonymous(facade: &AreevFacade) {
use areev_core::authz::{AuthzSet, Grant, Verb};
let mut grants = facade
.with_store(|m| m.authz_grants("anonymous"))
.unwrap_or_default();
grants.push(Grant { verbs: vec![Verb::Read], namespaces: vec!["*".to_string()] });
facade.bind(AuthzSet::restricted("anonymous", grants));
}
}
impl Drop for RequestBinding<'_> {
fn drop(&mut self) {
self.facade
.bind(areev_core::authz::AuthzSet::owner("user:console"));
}
}
impl UiServer {
pub fn new(facade: AreevFacade, db_label: String) -> Self {
UiServer {
facade: std::sync::Arc::new(facade),
executor: CalExecutor::new(CalExecutorConfig::default())
.with_governance(std::sync::Arc::new(areev_loop_adapter::LoopGovernance::new())),
db_label,
token: None,
auth_all: false,
segment_dir: None,
allow_remote: false,
loop_policy: None,
credentials: None,
allow_destructive_ops: CalExecutorConfig::default().allow_destructive_ops,
#[cfg(feature = "tls")]
tls_config: None,
sso_header: None,
sso_secrets: Vec::new(),
}
}
pub fn with_sso(self, header: impl Into<String>, secret: impl Into<String>) -> Self {
self.with_sso_rotating(header, secret, None::<String>)
}
pub fn with_sso_rotating(
mut self,
header: impl Into<String>,
secret: impl Into<String>,
next: Option<impl Into<String>>,
) -> Self {
let header = header.into();
let secret = secret.into();
let next = next.map(Into::into).filter(|s| !s.trim().is_empty());
assert!(
!header.trim().is_empty() && !secret.trim().is_empty(),
"with_sso requires a non-empty header name and proxy secret"
);
assert!(
next.as_deref() != Some(secret.as_str()),
"the rotating proxy secret must differ from the current one"
);
self.sso_header = Some(header.to_ascii_lowercase());
self.sso_secrets = match next {
Some(n) => vec![secret, n],
None => vec![secret],
};
self
}
fn rebuild_executor(&mut self) {
self.executor = CalExecutor::new(CalExecutorConfig {
allow_destructive_ops: self.allow_destructive_ops,
..CalExecutorConfig::default()
})
.with_governance(std::sync::Arc::new(match &self.loop_policy {
Some(p) => areev_loop_adapter::LoopGovernance::with_policy(p.clone()),
None => areev_loop_adapter::LoopGovernance::new(),
}));
}
pub fn with_credentials(mut self, map: areev_core::authz::CredentialMap) -> Self {
self.credentials = Some(map);
self
}
pub fn with_loop_policy(mut self, policy: areev_loop::Policy) -> Self {
self.loop_policy = Some(policy);
self.rebuild_executor();
self
}
fn session_may_sync(&self) -> bool {
self.facade
.authz()
.allows(areev_core::authz::Verb::Read, "*")
}
fn engine(&self) -> Engine {
match &self.loop_policy {
Some(p) => Engine::with_builtins().with_policy(p.clone()),
None => Engine::with_builtins(),
}
}
pub fn allow_remote(mut self, yes: bool) -> Self {
self.allow_remote = yes;
self
}
pub fn with_auth(mut self, token: String) -> Self {
assert!(
!token.trim().is_empty(),
"with_auth requires a non-empty token"
);
self.token = Some(token);
self.auth_all = true;
self
}
pub fn allow_destructive_ops(mut self, allow: bool) -> Self {
self.allow_destructive_ops = allow;
self.rebuild_executor();
self
}
pub fn into_hub(mut self, token: Option<String>, dir: &str) -> std::io::Result<Self> {
std::fs::create_dir_all(dir)?;
self.token = token;
self.segment_dir = Some(dir.to_string());
self.allow_remote = true;
Ok(self)
}
pub fn bind(addr: &str) -> std::io::Result<TcpListener> {
TcpListener::bind(addr)
}
pub fn serve(&self, listener: TcpListener) -> std::io::Result<()> {
for stream in listener.incoming() {
match stream {
Ok(s) => {
if let Err(e) = self.handle(s) {
eprintln!("areev ui: request error: {e}");
}
}
Err(e) => eprintln!("areev ui: accept error: {e}"),
}
}
Ok(())
}
#[cfg(feature = "tls")]
pub fn with_tls(mut self, cert_pem: &str, key_pem: &str) -> std::io::Result<Self> {
use rustls::pki_types::pem::PemObject;
let pem_err =
|e: rustls::pki_types::pem::Error| std::io::Error::new(std::io::ErrorKind::InvalidData, e.to_string());
let certs: Vec<rustls::pki_types::CertificateDer<'static>> =
rustls::pki_types::CertificateDer::pem_file_iter(cert_pem)
.map_err(pem_err)?
.collect::<Result<_, _>>()
.map_err(pem_err)?;
let key =
rustls::pki_types::PrivateKeyDer::from_pem_file(key_pem).map_err(pem_err)?;
let config = rustls::ServerConfig::builder()
.with_no_client_auth()
.with_single_cert(certs, key)
.map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e.to_string()))?;
self.tls_config = Some(std::sync::Arc::new(config));
Ok(self)
}
fn wrap(&self, stream: TcpStream) -> std::io::Result<Conn> {
#[cfg(feature = "tls")]
if let Some(config) = &self.tls_config {
let session = rustls::ServerConnection::new(std::sync::Arc::clone(config))
.map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e.to_string()))?;
return Ok(Conn::Tls(Box::new(rustls::StreamOwned::new(session, stream))));
}
Ok(Conn::Plain(stream))
}
fn handle(&self, stream: TcpStream) -> std::io::Result<()> {
let watchdog_stream = stream.try_clone()?;
let done = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
let watchdog_done = std::sync::Arc::clone(&done);
let watchdog = std::thread::spawn(move || {
std::thread::park_timeout(Duration::from_secs(REQUEST_DEADLINE_SECS));
if !watchdog_done.load(std::sync::atomic::Ordering::Acquire) {
let _ = watchdog_stream.shutdown(std::net::Shutdown::Both);
}
});
let raw = stream.try_clone()?;
let conn = self.wrap(stream)?;
let result = self.handle_request(&raw, conn);
done.store(true, std::sync::atomic::Ordering::Release);
watchdog.thread().unpark();
let _ = watchdog.join();
result
}
fn handle_request(&self, raw: &TcpStream, mut conn: Conn) -> std::io::Result<()> {
raw.set_read_timeout(Some(Duration::from_secs(READ_TIMEOUT_SECS)))?;
raw.set_write_timeout(Some(Duration::from_secs(READ_TIMEOUT_SECS)))?;
let mut reader = BufReader::new((&mut conn).take(MAX_CONN_BYTES));
let mut request_line = String::new();
reader.read_line(&mut request_line)?;
let mut parts = request_line.split_whitespace();
let method = parts.next().unwrap_or("").to_string();
let path = parts.next().unwrap_or("/").to_string();
let mut content_length = 0usize;
let mut bearer: Option<String> = None;
let mut origin: Option<String> = None;
let mut host: Option<String> = None;
let mut sso_identity_raw: Option<String> = None;
let mut proxy_secret: Option<String> = None;
let mut header_bytes = 0usize;
let mut header_count = 0usize;
loop {
let mut line = String::new();
let n = reader.read_line(&mut line)?;
if n == 0 {
break; }
header_bytes += n;
header_count += 1;
if header_bytes > MAX_HEADER_BYTES || header_count > MAX_HEADERS {
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidData,
"request headers too large",
));
}
let l = line.trim();
if l.is_empty() {
break;
}
let low = l.to_ascii_lowercase();
if let Some(v) = low.strip_prefix("content-length:") {
content_length = v.trim().parse().unwrap_or(0);
}
if let Some(v) = low.strip_prefix("origin:") {
origin = Some(v.trim().to_string());
}
if let Some(v) = low.strip_prefix("host:") {
host = Some(v.trim().to_string());
}
if let Some(v) = low.strip_prefix("x-areev-proxy-secret:") {
let _ = v;
if let Some((_, orig)) = l.split_once(':') {
proxy_secret = Some(orig.trim().to_string());
}
}
if let Some(h) = &self.sso_header {
if low.starts_with(&format!("{h}:")) {
if let Some((_, orig)) = l.split_once(':') {
sso_identity_raw = Some(orig.trim().to_string());
}
}
}
if low.starts_with("authorization:") {
if let Some((_, v)) = l.split_once(':') {
let v = v.trim();
bearer = if let Some(t) =
v.strip_prefix("Bearer ").or_else(|| v.strip_prefix("bearer "))
{
Some(t.trim().to_string())
} else if let Some(b64) =
v.strip_prefix("Basic ").or_else(|| v.strip_prefix("basic "))
{
basic_auth_password(b64.trim())
} else {
Some(v.to_string())
};
}
}
}
if !self.allow_remote && host.as_deref().is_some_and(|h| !host_is_local(h)) {
let payload = br#"{"ok":false,"error":"non-loopback Host rejected (use --allow-remote to serve remotely)"}"#;
drop(reader);
let mut out = conn;
write!(
out,
"HTTP/1.1 403 Forbidden\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
payload.len()
)?;
out.write_all(payload)?;
return out.flush();
}
let mut body = vec![0u8; content_length.min(1 << 20)];
if content_length > 0 {
reader.read_exact(&mut body)?;
}
if method == "POST" && origin.as_deref().is_some_and(|o| !origin_is_local(o)) {
let payload = br#"{"ok":false,"error":"cross-origin request rejected"}"#;
drop(reader);
let mut out = conn;
write!(
out,
"HTTP/1.1 403 Forbidden\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
payload.len()
)?;
out.write_all(payload)?;
return out.flush();
}
let sso_identity: Option<String> = match (proxy_secret, sso_identity_raw) {
(Some(presented), Some(id)) if !id.trim().is_empty() => {
let proved = self
.sso_secrets
.iter()
.fold(false, |acc, s| ct_eq(s.as_bytes(), presented.as_bytes()) | acc);
proved.then_some(id)
}
_ => None,
};
let (status, ctype, payload) =
self.route(&method, &path, &body, bearer.as_deref(), sso_identity.as_deref());
let auth_challenge = if self.auth_all && status.starts_with("401") {
"WWW-Authenticate: Basic realm=\"Areev console\", charset=\"UTF-8\"\r\n"
} else {
""
};
drop(reader);
let mut out = conn;
write!(
out,
"HTTP/1.1 {status}\r\nContent-Type: {ctype}\r\n{auth_challenge}Content-Length: {}\r\nConnection: close\r\n\r\n",
payload.len()
)?;
out.write_all(&payload)?;
out.flush()
}
fn runner(&self, principal: &str) -> areev_run::Runner {
struct NoExec;
impl areev_run::HostToolExecutor for NoExec {
fn execute(
&self,
tool_name: &str,
_h: &str,
_i: &Value,
_k: &str,
) -> areev_run::ExecResult {
areev_run::ExecResult::Err {
cause: areev_run::FailCause::ExecutorError,
detail: format!("the console does not execute tools ('{tool_name}')"),
}
}
}
areev_run::Runner {
facade: std::sync::Arc::clone(&self.facade),
clock: std::sync::Arc::new(areev_run::SystemClock),
executor: std::sync::Arc::new(NoExec),
llm: None,
observer: None,
ns: self
.facade
.default_namespace()
.unwrap_or("shared")
.to_string(),
principal: principal.to_string(),
}
}
fn route(
&self,
method: &str,
path: &str,
body: &[u8],
bearer: Option<&str>,
sso_identity: Option<&str>,
) -> (&'static str, &'static str, Vec<u8>) {
let mut shared_secret_ok = false;
if let Some(tok) = &self.token {
let guarded = self.auth_all || method == "POST" || path.starts_with("/api/segment");
shared_secret_ok = bearer.is_some_and(|b| ct_eq(b.as_bytes(), tok.as_bytes()));
let known = shared_secret_ok
|| sso_identity.is_some()
|| match (&self.credentials, bearer) {
(Some(map), Some(b)) => map.resolve_for_memory(b, &self.db_label).is_ok(),
_ => false,
};
if guarded && !known {
return ("401 Unauthorized", "application/json",
br#"{"ok":false,"error":"authentication required"}"#.to_vec());
}
} else if self.credentials.is_none() && sso_identity.is_none() && method == "POST" {
let base = path.split_once('?').map(|(p, _)| p).unwrap_or(path);
let allowed = base == "/api/cal" && cal_body_is_read_only(body);
if !allowed {
return ("401 Unauthorized", "application/json",
br#"{"ok":false,"error":"read-only console: restart areev ui with --token-env VAR to enable writes"}"#.to_vec());
}
}
let _session = match (&self.credentials, shared_secret_ok) {
(Some(map), false) => Some(RequestBinding::bind(&self.facade, map, bearer, &self.db_label)),
_ => None,
};
let mut request_principal: Option<String> = match (&self.credentials, shared_secret_ok, bearer)
{
(Some(map), false, Some(b)) => map.resolve_for_memory(b, &self.db_label).ok().map(str::to_string),
_ => None,
};
let _sso_session = match (&request_principal, sso_identity, shared_secret_ok) {
(None, Some(identity), false) => {
request_principal = Some(identity.to_string());
Some(RequestBinding::bind_identity(&self.facade, identity))
}
_ => None,
};
let (path, query) = match path.split_once('?') {
Some((p, q)) => (p, q),
None => (path, ""),
};
let q = |key: &str| -> Option<String> {
query.split('&').find_map(|kv| {
let (k, v) = kv.split_once('=')?;
(k == key).then(|| urldecode(v))
})
};
match (method, path) {
("GET", "/") => (
"200 OK",
"text/html; charset=utf-8",
CONSOLE_HTML
.replace("{{DB}}", &html_escape(&self.db_label))
.into_bytes(),
),
("POST", "/api/cal") => {
let req: Value = serde_json::from_slice(body).unwrap_or(Value::Null);
let queryt = req.get("query").and_then(|v| v.as_str()).unwrap_or("");
match self.executor.execute(queryt, &*self.facade) {
Ok(res) => ok_json(json!({
"ok": true,
"result": res.result,
"warnings": res.warnings,
"statement": res.metadata.statement_type,
"elapsed_ms": res.metadata.execution_time_ms,
})),
Err(e) => {
let mut err = json!({
"ok": false,
"error": e.sanitize_for_client(),
"code": e.code(),
});
if let Some(sp) = e.span() {
err["span"] = json!({
"start": sp.start, "end": sp.end,
"line": sp.line, "col": sp.col,
});
}
if let Some(hint) = e.suggestion() {
err["suggestion"] = json!(hint);
}
ok_json(err)
}
}
}
("GET", "/api/stats") => {
let stats = self.facade.with_store(|m| m.stats());
match stats {
Ok(s) => ok_json(json!({
"db": self.db_label,
"grains": s.grains, "current": s.current,
"triples": s.triples, "terms": s.terms,
"ops": s.ops, "events_indexed": s.events_indexed,
})),
Err(e) => ok_json(json!({"ok": false, "error": e.to_string()})),
}
}
("GET", "/api/log") => {
let since: i64 = q("since").and_then(|v| v.parse().ok()).unwrap_or(0);
let limit: usize = q("limit").and_then(|v| v.parse().ok()).unwrap_or(100);
match self.facade.with_store(|m| m.changes_since(since, limit)) {
Ok(ops) => {
let rows: Vec<Value> = ops
.iter()
.map(|o| {
json!({
"op_seq": o.op_seq, "hlc": o.hlc,
"op": match o.op { 1 => "add", 2 => "supersede", 3 => "forget", _ => "?" },
"hash": o.hash.to_hex(),
})
})
.collect();
ok_json(json!(rows))
}
Err(e) => ok_json(json!({"ok": false, "error": e.to_string()})),
}
}
("GET", "/api/whoami") => {
let a = self.facade.authz();
ok_json(json!({
"ok": true,
"principal": a.principal(),
"owner": a.is_owner(),
"mode": if self.credentials.is_some() { "credentials" }
else if self.token.is_some() { "token" }
else { "open" },
}))
}
("GET", "/api/config") => {
let cfg = self.executor.config();
let (index_text, embedder_dim, declared_embed, mut warnings) =
self.facade.with_store(|m| {
(
m.index_text_enabled(),
m.embedder_dim(),
m.declared_embedding().map(|(mm, d)| (mm.to_string(), d)),
m.open_warnings().to_vec(),
)
});
if let Some((m, d)) = &declared_embed {
if embedder_dim.is_none() {
warnings.push(format!(
"vector leg dormant: file expects {m}@{d}, no embedding backend installed"
));
}
}
warnings.extend(self.facade.meta_warnings());
let (anon_declared, anon_floor, anon_audit) = self.facade.with_store(|m| {
(
m.anon_declared(),
m.anonymize_egress_floor(),
m.anon_audit_counts(),
)
});
let anonymization = json!({
"policies": anon_declared
.iter()
.map(|(ns, mode)| json!({"ns": ns, "mode": mode}))
.collect::<Vec<_>>(),
"floor": anon_floor,
"audit_counts": anon_audit
.iter()
.map(|(ns, cat, n)| json!({"ns": ns, "category": cat, "count": n}))
.collect::<Vec<_>>(),
});
ok_json(json!({
"ok": true,
"db": self.db_label,
"warnings": warnings,
"anonymization": anonymization,
"file": {
"text_index": index_text,
"embedding": declared_embed.map(|(m, d)| json!({"model": m, "dim": d})),
},
"session": {
"namespace": self.facade.session_namespace(),
"mounts": self.facade.mount_aliases(),
"is_owner": self.facade.authz().is_owner(),
},
"store": {
"index_text": index_text,
"embedder": embedder_dim.map(|d| json!({"dim": d})),
},
"recall": {
"fusion": "rrf",
"rrf_k0": areev_store::RRF_K0,
"overfetch_factor": AreevFacade::RECALL_OVERFETCH,
"legs": {
"structural": true,
"bm25": index_text,
"vector": embedder_dim.is_some(),
},
},
"executor": {
"max_limit": cfg.max_limit,
"default_limit": cfg.default_limit,
"tier1_writes": cfg.tier1_enabled,
"namespace_override": cfg.namespace_override,
"user_id_override": cfg.user_id_override,
},
"server": {
"hub_mode": self.segment_dir.is_some(),
"auth_required": self.token.is_some(),
"auth_all": self.auth_all,
"segment_dir": self.segment_dir,
},
"persistence": "per-process (host-supplied at open) — not stored in the .db",
}))
}
("GET", "/api/browse") => {
let limit: i64 = q("limit")
.and_then(|v| v.parse().ok())
.unwrap_or(500)
.clamp(1, 2000);
let built = self.facade.with_store(|m| -> Result<(i64, Vec<Value>), String> {
let total = m.stats().map_err(|e| e.to_string())?.ops as i64;
let after = (total - limit).max(0);
let ops = m.changes_since(after, limit as usize).map_err(|e| e.to_string())?;
let mut rows: Vec<Value> = Vec::with_capacity(ops.len());
let mut idx: std::collections::HashMap<String, usize> =
std::collections::HashMap::new();
for o in &ops {
let hex = o.hash.to_hex();
let op_name = match o.op { 1 => "add", 2 => "supersede", 3 => "forget", _ => "?" };
if o.op == 3 {
match idx.get(&hex) {
Some(&i) => { rows[i]["forgotten"] = json!(true); }
None => rows.push(json!({
"hash": hex, "op_seq": o.op_seq, "hlc": o.hlc,
"op": op_name, "forgotten": true,
})),
}
continue;
}
if let Some(&i) = idx.get(&hex) {
rows[i]["op"] = json!(op_name);
rows[i]["op_seq"] = json!(o.op_seq);
rows[i]["hlc"] = json!(o.hlc);
continue;
}
let mut row = json!({
"hash": hex, "op_seq": o.op_seq, "hlc": o.hlc, "op": op_name,
});
match m.get(&o.hash) {
Ok(g) => {
row["type"] = json!(format!("{:?}", g.grain_type).to_lowercase());
row["fields"] = serde_json::to_value(&g.fields).unwrap_or(Value::Null);
}
Err(_) => { row["missing"] = json!(true); }
}
idx.insert(hex, rows.len());
rows.push(row);
}
let marks: Vec<(String, String)> = rows.iter()
.filter(|r| r["op"] == "supersede")
.filter_map(|r| {
let old = r["fields"].get("derived_from")?.as_str()?;
Some((old.to_string(), r["hash"].as_str()?.to_string()))
})
.collect();
for (old, newer) in marks {
if let Some(&i) = idx.get(&old) {
rows[i]["superseded_by"] = json!(newer);
}
}
rows.reverse();
Ok((total, rows))
});
let ns_param = q("ns");
let built = built.and_then(|(total, rows)| {
let authz = self.facade.authz();
let filter_ns = match ns_param.as_deref() {
Some("*") => {
if !authz.is_owner() {
return Err("not authorized to browse all namespaces".to_string());
}
None
}
Some(ns) => {
authz
.check(areev_core::authz::Verb::Read, ns)
.map_err(|e| e.to_string())?;
Some(ns.to_string())
}
None => self.facade.session_namespace().map(|s| s.to_string()),
};
let Some(ns) = filter_ns else {
return Ok((total, rows));
};
let kept = rows
.into_iter()
.filter(|r| {
r["fields"]
.get("namespace")
.and_then(Value::as_str)
.is_none_or(|got| got == ns)
})
.collect();
Ok((total, kept))
});
match built {
Ok((total, rows)) => ok_json(json!({"ok": true, "total_ops": total, "grains": rows})),
Err(e) => ok_json(json!({"ok": false, "error": e})),
}
}
("GET", "/api/namespaces") => {
let result = self.facade.with_store(|m| m.namespaces());
match result {
Ok(list) => {
let authz = self.facade.authz();
let owner = authz.is_owner();
let arr: Vec<Value> = list
.into_iter()
.filter(|(ns, _)| {
owner || authz.check(areev_core::authz::Verb::Read, ns).is_ok()
})
.map(|(ns, n)| json!({"namespace": ns, "count": n}))
.collect();
ok_json(json!({"ok": true, "namespaces": arr}))
}
Err(e) => ok_json(json!({"ok": false, "error": e.to_string()})),
}
}
("GET", "/api/grain") => {
let hash = q("hash").unwrap_or_default();
match areev_core::error::Hash::from_hex(&hash)
.and_then(|h| self.facade.get(&h))
{
Ok(g) => ok_json(json!({
"hash": g.hash.to_hex(),
"type": format!("{:?}", g.grain_type).to_lowercase(),
"fields": g.fields,
})),
Err(e) => ok_json(json!({"ok": false, "error": e.to_string()})),
}
}
("GET", "/api/anon/preview") => {
let hash = q("hash").unwrap_or_default();
match areev_core::error::Hash::from_hex(&hash)
.and_then(|h| self.facade.with_store(|m| m.anon_preview(&h)))
{
Ok(g) => ok_json(json!({
"hash": g.hash.to_hex(),
"type": format!("{:?}", g.grain_type).to_lowercase(),
"fields": g.fields,
})),
Err(e) => ok_json(json!({"ok": false, "error": e.to_string()})),
}
}
("GET", "/api/verify") => match self.facade.with_store(|m| m.verify()) {
Ok(r) => ok_json(json!({
"integrity": r.integrity, "grains": r.grains,
"hash_mismatches": r.hash_mismatches, "undecodable": r.undecodable,
})),
Err(e) => ok_json(json!({"ok": false, "error": e.to_string()})),
},
("POST", "/api/segment") => {
if let Err(e) = self
.facade
.authz()
.check(areev_core::authz::Verb::Write, "*")
{
return ("403 Forbidden", "application/json",
json!({"ok": false, "error": e.to_string()}).to_string().into_bytes());
}
let Some(dir) = &self.segment_dir else {
return ("400 Bad Request", "application/json",
br#"{"ok":false,"error":"not in hub mode"}"#.to_vec());
};
let name = q("name").unwrap_or_else(|| format!("push-{}.mgb", now_label()));
let Some(safe) = safe_segment_name(&name) else {
return ("400 Bad Request", "application/json",
br#"{"ok":false,"error":"invalid segment name"}"#.to_vec());
};
let path = format!("{dir}/{safe}");
if std::fs::write(&path, body).is_err() {
return ("500 Internal Server Error", "application/json",
br#"{"ok":false,"error":"write failed"}"#.to_vec());
}
match self.facade.with_store(|m| m.import_bundle(&path)) {
Ok(st) => ok_json(json!({"ok": true, "applied": st.applied, "skipped": st.skipped, "stored": safe})),
Err(e) => ok_json(json!({"ok": false, "error": e.to_string()})),
}
}
("GET", "/api/segments") | ("GET", "/api/segment") if !self.session_may_sync() => (
"403 Forbidden",
"application/json",
br#"{"ok":false,"error":"AUT-E001: this session lacks read on the whole memory"}"#.to_vec(),
),
("GET", "/api/segments") => {
let Some(dir) = &self.segment_dir else {
return ok_json(json!([]));
};
let mut names: Vec<String> = std::fs::read_dir(dir)
.map(|d| d.filter_map(|e| e.ok().map(|e| e.file_name().to_string_lossy().to_string()))
.filter(|n| n.ends_with(".mgb")).collect())
.unwrap_or_default();
names.sort();
ok_json(json!(names))
}
("GET", "/api/segment") => {
let Some(dir) = &self.segment_dir else {
return ("400 Bad Request", "application/json", b"{}".to_vec());
};
let name = q("name").unwrap_or_default();
let Some(safe) = safe_segment_name(&name) else {
return ("400 Bad Request", "application/json",
br#"{"ok":false,"error":"invalid segment name"}"#.to_vec());
};
match std::fs::read(format!("{dir}/{safe}")) {
Ok(b) => ("200 OK", "application/octet-stream", b),
Err(_) => ("404 Not Found", "application/json",
br#"{"ok":false,"error":"no such segment"}"#.to_vec()),
}
}
("GET", "/api/run/list") => {
let runner = self.runner("console:read");
match runner.recent_runs(50) {
Ok(ids) => {
let rows: Vec<Value> = ids
.iter()
.map(|id| {
let outcome = self.facade.with_store(|m| {
m.recent(
"agent:harness",
Some(areev_core::types::GrainType::Observation),
4096,
)
})
.ok()
.and_then(|rows| {
rows.into_iter().find(|g| {
g.get_str("run_id") == Some(id.as_str())
&& g.get_str("observation_kind")
== Some("run_outcome")
})
});
serde_json::json!({
"run_id": id,
"outcome": outcome.as_ref().and_then(|g| g.get_str("object")).unwrap_or("open"),
})
})
.collect();
ok_json(serde_json::json!({"ok": true, "runs": rows}))
}
Err(e) => run_api_err(&e.to_string()),
}
}
("GET", "/api/run/inspect") => {
let Some(run_id) = q("run_id") else {
return run_api_err("run_id required");
};
match self.runner("console:read").inspect(&run_id) {
Ok(report) => {
let mut v = serde_json::to_value(&report).unwrap_or(Value::Null);
if let Some(o) = v.as_object_mut() {
o.insert("ok".into(), Value::Bool(true));
}
ok_json(v)
}
Err(e) => run_api_err(&e.to_string()),
}
}
("POST", "/api/run/respond") => {
let Some(responder) = request_principal.clone() else {
return ("403 Forbidden", "application/json",
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());
};
let req: Value = serde_json::from_slice(body).unwrap_or(Value::Null);
let (Some(run_id), Some(ask)) = (
req.get("run_id").and_then(Value::as_str),
req.get("tool_call_id").and_then(Value::as_str),
) else {
return run_api_err("run_id and tool_call_id required");
};
let result = req.get("result").cloned().unwrap_or(Value::Null);
let is_error = req.get("is_error").and_then(Value::as_bool).unwrap_or(false);
let runner = self.runner(&responder);
match runner.respond(run_id, ask, result, is_error, &responder) {
Ok(()) => ok_json(serde_json::json!({
"ok": true, "responded": ask, "responder": responder,
"note": "resume the run to spend the compute",
})),
Err(e) => run_api_err(&e.to_string()),
}
}
("POST", "/api/run/cancel") => {
let req: Value = serde_json::from_slice(body).unwrap_or(Value::Null);
let Some(run_id) = req.get("run_id").and_then(Value::as_str) else {
return run_api_err("run_id required");
};
let because = req.get("because").and_then(Value::as_str).unwrap_or("canceled via console");
let who = request_principal.clone().unwrap_or_else(|| "console:shared".into());
let runner = self.runner(&who);
match runner.cancel(run_id, &who, because) {
Ok(()) => ok_json(serde_json::json!({"ok": true, "canceled": run_id})),
Err(e) => run_api_err(&e.to_string()),
}
}
("GET", "/api/loop/recommendations") => {
let status = q("status").and_then(|s| status_from_str(&s));
let sub = BorrowedSubstrate::new(&self.facade);
match self.engine().recommendations(&sub, status) {
Ok(recs) => ok_json(json!({
"ok": true,
"recommendations": recs.iter().map(rec_json).collect::<Vec<_>>(),
})),
Err(e) => ok_json(json!({"ok": false, "error": e.to_string(), "code": e.code()})),
}
}
("GET", "/api/loop/health") => {
let sub = BorrowedSubstrate::new(&self.facade);
match self.engine().health(&sub, now_ms()) {
Ok(h) => ok_json(json!({"ok": true, "health": h})),
Err(e) => ok_json(json!({"ok": false, "error": e.to_string(), "code": e.code()})),
}
}
("GET", "/api/loop/outcomes") => {
let sub = BorrowedSubstrate::new(&self.facade);
match self.engine().outcomes(&sub) {
Ok(o) => ok_json(json!({"ok": true, "outcomes": o})),
Err(e) => ok_json(json!({"ok": false, "error": e.to_string(), "code": e.code()})),
}
}
("GET", "/api/loop/analyzers") => {
let sub = BorrowedSubstrate::new(&self.facade);
match self.engine().analyzer_settings(&sub) {
Ok(list) => ok_json(json!({"ok": true, "analyzers": list})),
Err(e) => ok_json(json!({"ok": false, "error": e.to_string(), "code": e.code()})),
}
}
("GET", "/api/loop/telemetry") => {
let mode = self.facade.with_store(|m| m.telemetry_mode());
if mode == areev_store::TelemetryMode::Off {
ok_json(json!({"ok": true, "enabled": false}))
} else {
let access = self.facade.with_store(|m| m.telemetry_access_stats(None));
let queries = self.facade.with_store(|m| m.telemetry_query_stats(None));
let budget = self.facade.with_store(|m| m.telemetry_budget_stats());
match (access, queries, budget) {
(Ok(mut a), Ok(mut q), Ok(b)) => {
a.sort_by_key(|x| std::cmp::Reverse(x.recall_count));
a.truncate(200);
q.sort_by_key(|x| std::cmp::Reverse(x.run_count));
q.truncate(200);
ok_json(json!({
"ok": true,
"enabled": true,
"mode": mode.as_str(),
"access": a.iter().map(|x| json!({
"hash": x.hash, "recall_count": x.recall_count, "last_ms": x.last_ms,
})).collect::<Vec<_>>(),
"queries": q.iter().map(|x| json!({
"sample": x.sample, "run_count": x.run_count,
"empty_count": x.empty_count,
})).collect::<Vec<_>>(),
"budget": {
"sample_count": b.sample_count,
"overflow_count": b.overflow_count,
},
}))
}
_ => ok_json(json!({"ok": false, "error": "telemetry read failed"})),
}
}
}
("POST", "/api/loop/run") => {
let authz = self.facade.authz();
if !authz.is_owner()
&& !authz.allows(areev_core::authz::Verb::LoopRun, areev_loop::LOOP_NS)
{
return ("403 Forbidden", "application/json",
br#"{"ok":false,"error":"AUT-E001: this session lacks loop.run"}"#.to_vec());
}
let mut sub = BorrowedSubstrate::new(&self.facade);
let actor = if self.credentials.is_some() {
authz.principal().to_string()
} else {
serde_json::from_slice::<Value>(body)
.ok()
.and_then(|v| v.get("actor").and_then(Value::as_str).map(str::to_string))
.unwrap_or_else(|| "user:console".to_string())
};
let opts = RunOptions { triggering_actor: Some(actor), ..Default::default() };
match self.engine().run(&mut sub, &opts, now_ms()) {
Ok(res) => ok_json(json!({"ok": true, "run": res})),
Err(e) => ok_json(json!({"ok": false, "error": e.to_string(), "code": e.code()})),
}
}
("POST", "/api/loop/review") => self.loop_review(body),
("POST", "/api/loop/apply") => self.loop_apply(body),
("POST", "/api/loop/rollback") => self.loop_rollback(body),
("POST", "/api/loop/config") => self.loop_config(body),
("POST", "/api/anon/config") => self.anon_config(body),
_ => (
"404 Not Found",
"application/json",
br#"{"ok":false,"error":"not found"}"#.to_vec(),
),
}
}
fn loop_review(&self, body: &[u8]) -> (&'static str, &'static str, Vec<u8>) {
let req: Value = serde_json::from_slice(body).unwrap_or(Value::Null);
let hash = req.get("hash").and_then(Value::as_str).unwrap_or("");
let because = req.get("because").and_then(Value::as_str).unwrap_or("");
let bound = self.facade.authz();
let actor = if self.credentials.is_some() {
bound.principal().to_string()
} else {
req.get("actor").and_then(Value::as_str).unwrap_or("user:console").to_string()
};
let actor = actor.as_str();
let decision = if req.get("decision").and_then(Value::as_str) == Some("reject") {
Decision::Reject
} else {
Decision::Approve
};
let mut sub = BorrowedSubstrate::new(&self.facade);
let scopes = areev_loop_adapter::scopes_for(&self.facade.authz());
let observer = areev_loop_adapter::observer_for_principal(actor);
match self.engine().review(
&mut sub, hash, decision, actor, observer, &scopes, because, now_ms(),
) {
Ok(()) => ok_json(json!({"ok": true})),
Err(e) => ok_json(json!({"ok": false, "error": e.to_string(), "code": e.code()})),
}
}
fn loop_apply(&self, body: &[u8]) -> (&'static str, &'static str, Vec<u8>) {
let req: Value = serde_json::from_slice(body).unwrap_or(Value::Null);
let hash = req.get("hash").and_then(Value::as_str).unwrap_or("");
let because = req.get("because").and_then(Value::as_str).unwrap_or("");
let bound = self.facade.authz();
let actor = if self.credentials.is_some() {
bound.principal().to_string()
} else {
req.get("actor").and_then(Value::as_str).unwrap_or("user:console").to_string()
};
let actor = actor.as_str();
let allow_destructive = req.get("allow_destructive").and_then(Value::as_bool).unwrap_or(false);
let mut sub = BorrowedSubstrate::new(&self.facade);
let scopes = areev_loop_adapter::scopes_for(&self.facade.authz());
let observer = areev_loop_adapter::observer_for_principal(actor);
let engine = self.engine();
let gating = match req.get("gating_run").and_then(Value::as_str) {
Some(run_id) => match engine.gating_evidence(&sub, hash, run_id) {
Ok(g) => Some(g),
Err(e) => {
return ok_json(json!({"ok": false, "error": e.to_string(), "code": e.code()}))
}
},
None => None,
};
let applied = match &gating {
Some(g) => engine.apply_gated(
&mut sub, hash, actor, observer, &scopes, because, allow_destructive, g, now_ms(),
),
None => engine.apply(
&mut sub, hash, actor, observer, &scopes, because, allow_destructive, now_ms(),
),
};
match applied {
Ok(applied) => ok_json(json!({"ok": true, "rollbackable": applied.rollbackable})),
Err(e) => ok_json(json!({"ok": false, "error": e.to_string(), "code": e.code()})),
}
}
fn loop_rollback(&self, body: &[u8]) -> (&'static str, &'static str, Vec<u8>) {
let req: Value = serde_json::from_slice(body).unwrap_or(Value::Null);
let hash = req.get("hash").and_then(Value::as_str).unwrap_or("");
let because = req.get("because").and_then(Value::as_str).unwrap_or("");
let bound = self.facade.authz();
let actor = if self.credentials.is_some() {
bound.principal().to_string()
} else {
req.get("actor").and_then(Value::as_str).unwrap_or("user:console").to_string()
};
let actor = actor.as_str();
let mut sub = BorrowedSubstrate::new(&self.facade);
let scopes = areev_loop_adapter::scopes_for(&self.facade.authz());
let observer = areev_loop_adapter::observer_for_principal(actor);
match self.engine().rollback(
&mut sub, hash, actor, observer, &scopes, because, now_ms(),
) {
Ok(()) => ok_json(json!({"ok": true})),
Err(e) => ok_json(json!({"ok": false, "error": e.to_string(), "code": e.code()})),
}
}
fn loop_config(&self, body: &[u8]) -> (&'static str, &'static str, Vec<u8>) {
let id = serde_json::from_slice::<Value>(body)
.ok()
.and_then(|v| v.get("analyzer_id").and_then(Value::as_str).map(str::to_string))
.unwrap_or_default();
let update: areev_loop::AnalyzerConfigUpdate = serde_json::from_slice(body).unwrap_or_default();
let mut sub = BorrowedSubstrate::new(&self.facade);
match self.engine().set_analyzer_config(&mut sub, &id, update, &areev_loop_adapter::scopes_for(&self.facade.authz())) {
Ok(cfg) => ok_json(json!({"ok": true, "config": cfg})),
Err(e) => ok_json(json!({"ok": false, "error": e.to_string(), "code": e.code()})),
}
}
}
impl UiServer {
fn anon_config(&self, body: &[u8]) -> (&'static str, &'static str, Vec<u8>) {
let v: Value = match serde_json::from_slice(body) {
Ok(v) => v,
Err(e) => return ok_json(json!({"ok": false, "error": e.to_string()})),
};
let Some(ns) = v.get("ns").and_then(Value::as_str).filter(|n| !n.trim().is_empty())
else {
return ok_json(json!({"ok": false, "error": "ns is required"}));
};
let result = if v.get("clear").and_then(Value::as_bool) == Some(true) {
self.facade.with_store(|m| m.clear_anon_policy(ns))
} else {
match v.get("policy") {
Some(policy) => {
let policy_json = policy.to_string();
self.facade.with_store(|m| m.set_anon_policy(ns, &policy_json))
}
None => {
return ok_json(json!({"ok": false, "error": "policy (object) or clear: true is required"}));
}
}
};
match result {
Ok(()) => {
let declared = self.facade.with_store(|m| m.anon_declared());
ok_json(json!({"ok": true, "policies": declared
.iter()
.map(|(ns, mode)| json!({"ns": ns, "mode": mode}))
.collect::<Vec<_>>()}))
}
Err(e) => ok_json(json!({"ok": false, "error": e.to_string()})),
}
}
}
fn host_is_local(host_port: &str) -> bool {
let host = if let Some(h) = host_port.strip_prefix('[') {
h.split(']').next().unwrap_or("")
} else {
host_port.split(':').next().unwrap_or("")
};
matches!(host, "127.0.0.1" | "localhost" | "::1")
}
fn origin_is_local(origin: &str) -> bool {
let rest = origin
.strip_prefix("http://")
.or_else(|| origin.strip_prefix("https://"));
let Some(rest) = rest else { return false };
let host_port = rest.split('/').next().unwrap_or("");
host_is_local(host_port)
}
fn now_label() -> String {
format!("{}", std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_millis())
.unwrap_or(0))
}
fn ok_json(v: Value) -> (&'static str, &'static str, Vec<u8>) {
("200 OK", "application/json", v.to_string().into_bytes())
}
fn run_api_err(msg: &str) -> (&'static str, &'static str, Vec<u8>) {
(
"400 Bad Request",
"application/json",
serde_json::json!({"ok": false, "error": msg}).to_string().into_bytes(),
)
}
fn cal_body_is_read_only(body: &[u8]) -> bool {
let req: Value = serde_json::from_slice(body).unwrap_or(Value::Null);
let q = req.get("query").and_then(Value::as_str).unwrap_or("");
areev_cal::classify::query_is_read_only(q)
}
fn status_from_str(s: &str) -> Option<RecStatus> {
match s {
"pending" => Some(RecStatus::Pending),
"approved" => Some(RecStatus::Approved),
"rejected" => Some(RecStatus::Rejected),
"applied" => Some(RecStatus::Applied),
"rolled_back" => Some(RecStatus::RolledBack),
"expired" => Some(RecStatus::Expired),
_ => None, }
}
fn rec_json(r: &areev_loop::Recommendation) -> Value {
json!({
"hash": r.hash,
"status": r.status.as_str(),
"severity": r.severity.as_str(),
"analyzer": r.analyzer,
"summary": r.summary.render(),
"target_ref": r.target_ref,
"destructive": r.destructive,
"rollbackable": r.rollbackable,
"evidence": r.evidence,
"evalset_hash": r.evalset_hash,
})
}
fn urldecode(s: &str) -> String {
let mut out = Vec::new();
let b = s.as_bytes();
let mut i = 0;
while i < b.len() {
match b[i] {
b'%' if i + 2 < b.len() + 1 && i + 2 < b.len() + 1 => {
if i + 2 < b.len() + 1 && i + 2 < b.len() {
let hex = std::str::from_utf8(&b[i + 1..i + 3]).unwrap_or("");
if let Ok(v) = u8::from_str_radix(hex, 16) {
out.push(v);
i += 3;
continue;
}
}
out.push(b[i]);
i += 1;
}
b'+' => {
out.push(b' ');
i += 1;
}
c => {
out.push(c);
i += 1;
}
}
}
String::from_utf8_lossy(&out).to_string()
}
fn html_escape(s: &str) -> String {
s.replace('&', "&").replace('<', "<").replace('>', ">")
}
fn ct_eq(a: &[u8], b: &[u8]) -> bool {
if a.len() != b.len() {
return false;
}
let mut diff = 0u8;
for (x, y) in a.iter().zip(b.iter()) {
diff |= x ^ y;
}
diff == 0
}
fn base64_decode(s: &str) -> Option<Vec<u8>> {
areev_core::b64::decode(s)
}
fn basic_auth_password(b64: &str) -> Option<String> {
let text = String::from_utf8(base64_decode(b64)?).ok()?;
text.split_once(':').map(|(_user, pass)| pass.to_string())
}
fn safe_segment_name(name: &str) -> Option<String> {
let safe: String = name
.chars()
.filter(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '.' | '_'))
.collect();
if safe.is_empty() || safe.len() > 128 {
return None;
}
let mut comps = std::path::Path::new(&safe).components();
match (comps.next(), comps.next()) {
(Some(std::path::Component::Normal(c)), None) if c.to_str() == Some(safe.as_str()) => {
Some(safe)
}
_ => None,
}
}
#[cfg(test)]
mod loop_route_tests {
use super::UiServer;
use areev_cal::AreevFacade;
use areev_core::types::{Fact, Grain};
use areev_store::Areev;
fn server(auth: Option<&str>) -> UiServer {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("t.db");
let mut store = Areev::open(path.to_str().unwrap()).unwrap();
store.add(&Fact::new("acme", "tier", "Enterprise").namespace("caller")).unwrap();
store.add(&Fact::new("acme", "tier", "Enterprise").namespace("caller")).unwrap();
std::mem::forget(dir); let facade = AreevFacade::with_session(store, Some("caller".into()), None);
let s = UiServer::new(facade, "test".into());
match auth {
Some(t) => s.with_auth(t.to_string()),
None => s,
}
}
fn text(r: &(&str, &str, Vec<u8>)) -> String {
String::from_utf8_lossy(&r.2).to_string()
}
#[test]
fn token_less_console_is_read_only() {
let s = server(None);
assert!(s.route("POST", "/api/loop/run", b"{}", None, None).0.starts_with("401"));
let w = s.route("POST", "/api/cal", br#"{"query":"ADD fact SET subject=\"x\""}"#, None, None);
assert!(w.0.starts_with("401"), "write CAL must be 401: {}", text(&w));
let r = s.route("POST", "/api/cal", br#"{"query":"RECALL facts WHERE subject = \"acme\""}"#, None, None);
assert!(r.0.starts_with("200"), "read CAL allowed: {}", text(&r));
assert!(s.route("GET", "/api/loop/recommendations", b"", None, None).0.starts_with("200"));
}
#[test]
fn telemetry_endpoint_is_an_open_read() {
let s = server(None);
let r = s.route("GET", "/api/loop/telemetry", b"", None, None);
assert!(r.0.starts_with("200"), "telemetry read open: {}", text(&r));
let body = text(&r);
assert!(body.contains("\"ok\":true"), "{body}");
assert!(body.contains("\"enabled\":false"), "{body}");
}
#[test]
fn authenticated_run_review_apply_roundtrip() {
let s = server(Some("tok"));
let run = s.route("POST", "/api/loop/run", b"{}", Some("tok"), None);
assert!(run.0.starts_with("200"), "run: {}", text(&run));
assert!(text(&run).contains("\"ran\""));
let list = s.route("GET", "/api/loop/recommendations?status=pending", b"", Some("tok"), None);
let v: serde_json::Value = serde_json::from_slice(&list.2).unwrap();
let recs = v["recommendations"].as_array().unwrap();
assert!(!recs.is_empty(), "at least one recommendation");
let hash = recs[0]["hash"].as_str().unwrap().to_string();
let rev = s.route(
"POST",
"/api/loop/review",
format!(r#"{{"hash":"{hash}","decision":"approve","because":"ok"}}"#).as_bytes(),
Some("tok"),
None,
);
assert!(text(&rev).contains("\"ok\":true"), "review: {}", text(&rev));
let ap = s.route(
"POST",
"/api/loop/apply",
format!(r#"{{"hash":"{hash}","because":"go"}}"#).as_bytes(),
Some("tok"),
None,
);
assert!(text(&ap).contains("\"ok\":true"), "apply: {}", text(&ap));
}
#[test]
fn config_edit_toggles_analyzer_via_console() {
let s = server(Some("tok"));
assert!(s.route("POST", "/api/loop/config", b"{}", None, None).0.starts_with("401"));
let list = s.route("GET", "/api/loop/analyzers", b"", Some("tok"), None);
let v: serde_json::Value = serde_json::from_slice(&list.2).unwrap();
let id = v["analyzers"]
.as_array()
.unwrap()
.iter()
.find(|x| x["enabled"] == true)
.expect("some analyzer is on")["id"]
.as_str()
.unwrap()
.to_string();
let post = s.route(
"POST",
"/api/loop/config",
format!(r#"{{"analyzer_id":"{id}","enabled":false}}"#).as_bytes(),
Some("tok"),
None,
);
assert!(text(&post).contains("\"ok\":true"), "config: {}", text(&post));
let list2 = s.route("GET", "/api/loop/analyzers", b"", Some("tok"), None);
let v2: serde_json::Value = serde_json::from_slice(&list2.2).unwrap();
let now = v2["analyzers"]
.as_array()
.unwrap()
.iter()
.find(|x| x["id"] == id.as_str())
.unwrap();
assert_eq!(now["enabled"], false, "toggled off and persisted");
}
#[test]
fn self_approval_surfaces_lop_code() {
let s = server(Some("tok"));
s.route("POST", "/api/loop/run", b"{}", Some("tok"), None);
let list = s.route("GET", "/api/loop/recommendations?status=pending", b"", Some("tok"), None);
let v: serde_json::Value = serde_json::from_slice(&list.2).unwrap();
let rec = &v["recommendations"].as_array().unwrap()[0];
let hash = rec["hash"].as_str().unwrap();
let analyzer = rec["analyzer"].as_str().unwrap();
let creator = format!("engine:{analyzer}");
let rev = s.route(
"POST",
"/api/loop/review",
format!(r#"{{"hash":"{hash}","decision":"approve","because":"self","actor":"{creator}"}}"#).as_bytes(),
Some("tok"),
None,
);
assert!(text(&rev).contains("LOP-E021"), "self-approval blocked: {}", text(&rev));
}
}
#[cfg(test)]
mod security_tests {
use super::{base64_decode, basic_auth_password, ct_eq, safe_segment_name};
#[test]
fn ct_eq_matches_only_equal() {
assert!(ct_eq(b"secret-token", b"secret-token"));
assert!(!ct_eq(b"secret-token", b"secret-toker"));
assert!(!ct_eq(b"secret", b"secret-token")); assert!(ct_eq(b"", b""));
}
#[test]
fn base64_decode_roundtrips_known_vectors() {
assert_eq!(base64_decode("").unwrap(), b"");
assert_eq!(base64_decode("Zg==").unwrap(), b"f");
assert_eq!(base64_decode("Zm8=").unwrap(), b"fo");
assert_eq!(base64_decode("Zm9v").unwrap(), b"foo");
assert_eq!(base64_decode("Zm9vYmFy").unwrap(), b"foobar");
assert_eq!(base64_decode("Zg").unwrap(), b"f");
assert!(base64_decode("****").is_none());
}
#[test]
fn basic_auth_extracts_password_ignoring_username() {
assert_eq!(basic_auth_password("ZGVqYTpzM2NyZXQ=").as_deref(), Some("s3cret"));
assert_eq!(basic_auth_password("OnMzY3JldA==").as_deref(), Some("s3cret"));
assert_eq!(basic_auth_password("dTphOmI=").as_deref(), Some("a:b"));
assert_eq!(basic_auth_password("bm9jb2xvbg=="), None); assert_eq!(basic_auth_password("****"), None); }
#[test]
fn safe_segment_name_blocks_traversal() {
assert_eq!(safe_segment_name("push-123.mgb").as_deref(), Some("push-123.mgb"));
assert_eq!(safe_segment_name(".."), None);
assert_eq!(safe_segment_name("."), None);
assert_eq!(safe_segment_name(""), None);
for probe in ["../../etc/passwd", "/etc/passwd", "a/../b", "foo/bar"] {
if let Some(s) = safe_segment_name(probe) {
assert!(!s.contains('/') && s != ".." && s != ".", "unsafe: {s}");
}
}
}
}
#[cfg(test)]
mod credential_route_tests {
use super::UiServer;
use areev_cal::AreevFacade;
use areev_core::authz::{CredentialMap, AUTHZ_NS, REL_PERMITS};
use areev_core::types::{Fact, Grain};
use areev_store::Areev;
#[test]
fn loop_apply_honors_a_gating_run() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("g.db");
let mut store = Areev::open(path.to_str().unwrap()).unwrap();
let evalset = store
.add(
&Fact::new("evalset:gate", "mg:evalset", "{\"name\":\"gate\",\"cases\":[]}")
.namespace("agent:harness")
.created_at(1_000),
)
.unwrap()
.to_hex();
store
.add(
&Fact::new(
&format!("evalset:{evalset}"),
"mg:eval_run",
"{\"run_id\":\"eval-1\",\"passed\":3,\"failed\":0}",
)
.namespace("agent:harness")
.created_at(2_000),
)
.unwrap();
store.add(&Fact::new("acme", "tier", "Enterprise").namespace("caller").created_at(3_000)).unwrap();
std::mem::forget(dir);
let facade = AreevFacade::with_session(store, Some("caller".into()), None);
let manifest = facade
.record_corpus_export("RECALL facts", "train.jsonl", None, 4_000, &[], &[])
.unwrap()
.to_hex();
facade
.record_adapter(
r#"{"adapter":{"uri":"file:///tmp/a","sha256":"feed"},"base_model":"qwen3-4b","serves_as":"acme-support"}"#,
&manifest,
&evalset,
5_000,
)
.unwrap();
let s = UiServer::new(facade, "test".into()).with_auth("gate-token".into());
let tok = Some("gate-token");
let run = s.route("POST", "/api/loop/run", b"{}", tok, None);
assert!(run.0.starts_with("200"), "{}", text(&run));
let list = s.route("GET", "/api/loop/recommendations?status=pending", b"", tok, None);
let rows: serde_json::Value = serde_json::from_str(&text(&list)).unwrap();
let rec = rows["recommendations"]
.as_array()
.unwrap()
.iter()
.find(|r| r["analyzer"].as_str().unwrap_or("").contains("adapter_intake"))
.unwrap_or_else(|| panic!("adapter_intake must propose: {rows}"));
assert_eq!(rec["evalset_hash"], evalset.as_str(), "the row must carry the pin");
let hash = rec["hash"].as_str().unwrap().to_string();
let approve = format!(r#"{{"hash":"{hash}","decision":"approve","because":"reviewed"}}"#);
let r = s.route("POST", "/api/loop/review", approve.as_bytes(), tok, None);
assert!(text(&r).contains("\"ok\":true"), "{}", text(&r));
let bare = format!(r#"{{"hash":"{hash}","because":"ship it"}}"#);
let r = s.route("POST", "/api/loop/apply", bare.as_bytes(), tok, None);
assert!(text(&r).contains("gating run"), "ungated must refuse: {}", text(&r));
let bad = format!(r#"{{"hash":"{hash}","because":"ship","gating_run":"eval-nope"}}"#);
let r = s.route("POST", "/api/loop/apply", bad.as_bytes(), tok, None);
assert!(text(&r).contains("no recorded gate run"), "{}", text(&r));
let good = format!(r#"{{"hash":"{hash}","because":"gated and green","gating_run":"eval-1"}}"#);
let r = s.route("POST", "/api/loop/apply", good.as_bytes(), tok, None);
assert!(text(&r).contains("\"ok\":true"), "gated apply failed: {}", text(&r));
}
fn server() -> UiServer {
std::env::set_var("AREEV_TEST_READER_TOK", "reader-secret");
std::env::set_var("AREEV_TEST_WRITER_TOK", "writer-secret");
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("t.db");
let mut store = Areev::open(path.to_str().unwrap()).unwrap();
store
.add(&Fact::new("user:reader", REL_PERMITS, "read ON *").namespace(AUTHZ_NS).created_at(1_000))
.unwrap();
store
.add(
&Fact::new("agent:writer", REL_PERMITS, "read,write ON caller")
.namespace(AUTHZ_NS)
.created_at(2_000),
)
.unwrap();
store.add(&Fact::new("acme", "tier", "Enterprise").namespace("caller").created_at(3_000)).unwrap();
std::mem::forget(dir);
let facade = AreevFacade::with_session(store, Some("caller".into()), None);
let map = CredentialMap::from_json(
r#"{"version":1,"tokens":[
{"env":"AREEV_TEST_READER_TOK","principal":"user:reader"},
{"env":"AREEV_TEST_WRITER_TOK","principal":"agent:writer"}
]}"#,
)
.unwrap();
UiServer::new(facade, "test".into()).with_credentials(map)
}
fn text(r: &(&str, &str, Vec<u8>)) -> String {
String::from_utf8_lossy(&r.2).to_string()
}
const READ: &[u8] = br#"{"query":"RECALL facts WHERE subject = \"acme\""}"#;
const WRITE: &[u8] = br#"{"query":"ADD fact SET subject = \"x\" SET relation = \"r\" SET object = \"o\" SET namespace = \"caller\" REASON \"t\""}"#;
#[test]
fn tokens_bind_principals_and_verbs_decide() {
let s = server();
let r = s.route("POST", "/api/cal", READ, Some("reader-secret"), None);
assert!(r.0.starts_with("200"), "{}", text(&r));
assert!(text(&r).contains("grains"));
let w = s.route("POST", "/api/cal", WRITE, Some("reader-secret"), None);
assert!(text(&w).contains("AUT-E001"), "reader write must be refused: {}", text(&w));
let w = s.route("POST", "/api/cal", WRITE, Some("writer-secret"), None);
assert!(text(&w).contains("added"), "writer write must land: {}", text(&w));
let w = s.route("POST", "/api/cal", WRITE, Some("reader-secret"), None);
assert!(text(&w).contains("AUT-E001"), "{}", text(&w));
}
#[test]
fn anonymous_is_read_only_and_loop_run_is_verb_gated() {
let s = server();
let r = s.route("POST", "/api/cal", READ, None, None);
assert!(r.0.starts_with("200"), "{}", text(&r));
assert!(text(&r).contains("grains"));
let w = s.route("POST", "/api/cal", WRITE, None, None);
assert!(text(&w).contains("AUT-E001"), "{}", text(&w));
let w = s.route("POST", "/api/cal", WRITE, Some("stolen-guess"), None);
assert!(text(&w).contains("AUT-E001"), "{}", text(&w));
let run = s.route("POST", "/api/loop/run", b"{}", None, None);
assert!(run.0.starts_with("403"), "{}", text(&run));
}
#[test]
fn whoami_reports_the_bound_principal() {
let s = server();
let r = s.route("GET", "/api/whoami", b"", Some("reader-secret"), None);
assert!(text(&r).contains("user:reader"), "{}", text(&r));
assert!(text(&r).contains("credentials"));
let r = s.route("GET", "/api/whoami", b"", None, None);
assert!(text(&r).contains("anonymous"), "{}", text(&r));
}
#[test]
fn a_credential_map_does_not_disable_the_shared_secret() {
let s = server().with_auth("console-token".into());
let r = s.route("POST", "/api/cal", READ, None, None);
assert!(r.0.starts_with("401"), "{}", text(&r));
let page = s.route("GET", "/", b"", None, None);
assert!(page.0.starts_with("401"), "the console page is guarded too");
let r = s.route("POST", "/api/cal", READ, Some("stolen-guess"), None);
assert!(r.0.starts_with("401"), "{}", text(&r));
let r = s.route("POST", "/api/cal", READ, Some("reader-secret"), None);
assert!(r.0.starts_with("200"), "{}", text(&r));
let w = s.route("POST", "/api/cal", WRITE, Some("reader-secret"), None);
assert!(text(&w).contains("AUT-E001"), "{}", text(&w));
let w = s.route("POST", "/api/cal", WRITE, Some("console-token"), None);
assert!(text(&w).contains("added"), "{}", text(&w));
}
#[test]
fn run_loop_via_cal_is_verb_gated_like_the_route() {
let s = server();
let run = s.route("POST", "/api/loop/run", b"{}", None, None);
assert!(run.0.starts_with("403"), "{}", text(&run));
let cal = s.route("POST", "/api/cal", br#"{"query":"RUN LOOP"}"#, None, None);
assert!(
text(&cal).contains("AUT-E001"),
"anonymous must not run the loop through CAL either: {}",
text(&cal)
);
}
}
#[cfg(test)]
mod namespace_route_tests {
use super::UiServer;
use areev_cal::AreevFacade;
use areev_core::authz::{CredentialMap, AUTHZ_NS, REL_PERMITS};
use areev_core::types::{Fact, Grain};
use areev_store::Areev;
fn text(r: &(&str, &str, Vec<u8>)) -> String {
String::from_utf8_lossy(&r.2).to_string()
}
fn restricted_server() -> UiServer {
std::env::set_var("AREEV_TEST_NS_WRITER_TOK", "writer-secret");
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("t.db");
let mut store = Areev::open(path.to_str().unwrap()).unwrap();
store
.add(&Fact::new("agent:writer", REL_PERMITS, "read,write ON caller").namespace(AUTHZ_NS).created_at(1_000))
.unwrap();
store.add(&Fact::new("acme", "tier", "Enterprise").namespace("caller").created_at(2_000)).unwrap();
store.add(&Fact::new("bob", "role", "admin").namespace("other").created_at(3_000)).unwrap();
std::mem::forget(dir);
let facade = AreevFacade::with_session(store, Some("caller".into()), None);
let map = CredentialMap::from_json(
r#"{"version":1,"tokens":[{"env":"AREEV_TEST_NS_WRITER_TOK","principal":"agent:writer"}]}"#,
)
.unwrap();
UiServer::new(facade, "test".into()).with_credentials(map)
}
fn owner_server() -> UiServer {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("t.db");
let mut store = Areev::open(path.to_str().unwrap()).unwrap();
store.add(&Fact::new("acme", "tier", "Enterprise").namespace("caller").created_at(1_000)).unwrap();
store.add(&Fact::new("bob", "role", "admin").namespace("other").created_at(2_000)).unwrap();
std::mem::forget(dir);
UiServer::new(AreevFacade::with_session(store, Some("caller".into()), None), "t".into())
}
#[test]
fn namespaces_route_hides_what_the_principal_cannot_read() {
let s = restricted_server();
let r = s.route("GET", "/api/namespaces", b"", Some("writer-secret"), None);
let body = text(&r);
assert!(body.contains("\"caller\""), "granted namespace must be listed: {body}");
assert!(!body.contains("\"other\""), "ungranted namespace must not be listed: {body}");
assert!(!body.contains(AUTHZ_NS), "the audit namespace must not leak either: {body}");
}
#[test]
fn namespaces_route_lists_everything_for_the_owner() {
let s = owner_server();
let r = s.route("GET", "/api/namespaces", b"", None, None);
let body = text(&r);
assert!(body.contains("\"caller\"") && body.contains("\"other\""), "{body}");
}
#[test]
fn browse_ns_param_is_read_gated_for_a_restricted_principal() {
let s = restricted_server();
let ok = s.route("GET", "/api/browse?ns=caller", b"", Some("writer-secret"), None);
assert!(text(&ok).contains("\"ok\":true"), "their own granted namespace must work: {}", text(&ok));
let denied = s.route("GET", "/api/browse?ns=other", b"", Some("writer-secret"), None);
let body = text(&denied);
assert!(body.contains("\"ok\":false"), "an ungranted namespace must be refused, not silently emptied: {body}");
}
#[test]
fn browse_ns_star_requires_owner() {
let s = restricted_server();
let r = s.route("GET", "/api/browse?ns=*", b"", Some("writer-secret"), None);
assert!(text(&r).contains("\"ok\":false"), "a restricted principal must not get the unfiltered view: {}", text(&r));
let owner = owner_server();
let r2 = owner.route("GET", "/api/browse?ns=*", b"", None, None);
let body2 = text(&r2);
assert!(body2.contains("\"ok\":true"), "the owner session may lift the filter: {body2}");
assert!(body2.contains("\"caller\"") && body2.contains("\"other\""), "{body2}");
}
#[test]
fn browse_without_ns_param_keeps_original_session_default_behavior() {
let s = owner_server();
let r = s.route("GET", "/api/browse", b"", None, None);
let body = text(&r);
assert!(body.contains("\"caller\""), "{body}");
assert!(!body.contains("\"other\""), "omitting ?ns must behave exactly as before this feature existed: {body}");
}
}
#[cfg(test)]
mod builder_tests {
use super::UiServer;
use areev_cal::AreevFacade;
use areev_store::Areev;
fn server() -> UiServer {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("t.db");
let store = Areev::open(path.to_str().unwrap()).unwrap();
std::mem::forget(dir);
UiServer::new(AreevFacade::with_session(store, Some("caller".into()), None), "t".into())
}
#[test]
fn attaching_a_loop_policy_preserves_the_destructive_cap() {
let s = server()
.allow_destructive_ops(false)
.with_loop_policy(areev_loop::Policy::default());
let r = s.route(
"POST",
"/api/cal",
br#"{"query":"FORGET sha256:684c6c9bda818630a870119d0726e4d242ed537af061658ef6f3acb158a2c67d"}"#,
None,
None,
);
let body = String::from_utf8_lossy(&r.2).to_string();
assert!(
body.contains("--no-destructive-ops") || r.0.starts_with("401"),
"destruction must stay disabled: {body}"
);
}
}
#[cfg(test)]
mod run_api_tests {
use super::UiServer;
use areev_cal::AreevFacade;
use areev_core::authz::{CredentialMap, AUTHZ_NS, REL_PERMITS};
use areev_core::types::{Fact, Grain, Tool, ToolKind, Workflow};
use areev_store::Areev;
use std::sync::Arc;
fn parked_server() -> (UiServer, String) {
std::env::set_var("AREEV_RUN_OFFICER_TOK", "officer-secret");
std::env::set_var("AREEV_RUN_STARTER_TOK", "starter-secret");
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("t.db");
let mut store = Areev::open(path.to_str().unwrap()).unwrap();
for (who, what) in [
("user:officer", "read,run.respond ON ops"),
("user:starter", "read ON ops"),
] {
store
.add(&Fact::new(who, REL_PERMITS, what).namespace(AUTHZ_NS).created_at(900))
.unwrap();
}
std::mem::forget(dir);
let facade = Arc::new(AreevFacade::with_session(store, Some("ops".into()), None));
let (wf, _g, _a) = facade
.with_store(|m| {
let greet = Tool::new("greet")
.kind(ToolKind::Definition)
.tool_description("t")
.created_at(500)
.namespace("ops");
let gh = m.add(&greet)?;
let approve = Tool::new("approve")
.kind(ToolKind::Definition)
.tool_description("human approves")
.executor_kind(areev_core::types::ExecutorKind::Client)
.created_at(501)
.namespace("ops");
let ah = m.add(&approve)?;
let wf = Workflow::new(vec!["greet".into(), "approve".into()])
.edge("greet", "approve")
.bind("greet", &gh.to_hex())
.bind("approve", &ah.to_hex())
.created_at(502)
.namespace("ops");
let wh = m.add(&wf)?;
Ok::<_, areev_core::error::AreevError>((wh, gh, ah))
})
.unwrap();
struct Ok1;
impl areev_run::HostToolExecutor for Ok1 {
fn execute(
&self,
t: &str,
_h: &str,
_i: &serde_json::Value,
_k: &str,
) -> areev_run::ExecResult {
areev_run::ExecResult::Ok(serde_json::json!({t.to_string(): true}))
}
}
let runner = areev_run::Runner {
facade: Arc::clone(&facade),
clock: Arc::new(areev_run::ScriptedClock::new(
(0..100).map(|i| 1_758_000_000_000 + i * 10).collect(),
)),
executor: Arc::new(Ok1),
llm: None,
observer: None,
ns: "ops".into(),
principal: "user:starter".into(),
};
let session = runner
.start(&wf, "hitl-1", serde_json::json!({}), &areev_run::RunOptions {
workers: 1,
..Default::default()
})
.unwrap();
let areev_run::RunSession::Parked { envelope, .. } = session else {
panic!("expected park")
};
let ask = envelope["asks"][0]["tool_call_id"].as_str().unwrap().to_string();
drop(runner);
let facade = Arc::try_unwrap(facade).unwrap_or_else(|_| panic!("facade still shared"));
let map = CredentialMap::from_json(
r#"{"version":1,"tokens":[
{"env":"AREEV_RUN_OFFICER_TOK","principal":"user:officer"},
{"env":"AREEV_RUN_STARTER_TOK","principal":"user:starter"}
]}"#,
)
.unwrap();
let server = UiServer::new(facade, "test".into())
.with_auth("shared-console-secret".into())
.with_credentials(map);
(server, ask)
}
fn body(run_id: &str, ask: &str) -> Vec<u8> {
serde_json::json!({"run_id": run_id, "tool_call_id": ask, "result": {"approved": true}})
.to_string()
.into_bytes()
}
#[test]
fn shared_token_approvals_are_refused_and_principals_approve() {
let (s, ask) = parked_server();
let r = s.route("GET", "/api/run/list", b"", Some("shared-console-secret"), None);
assert!(r.0.starts_with("200"), "{}", String::from_utf8_lossy(&r.2));
let r = s.route(
"GET",
"/api/run/inspect?run_id=hitl-1",
b"",
Some("shared-console-secret"),
None,
);
assert!(String::from_utf8_lossy(&r.2).contains(&ask), "ask visible in inspect");
let r = s.route("POST", "/api/run/respond", &body("hitl-1", &ask), Some("shared-console-secret"), None);
assert!(r.0.starts_with("403"), "shared-token approval must refuse: {}", r.0);
let r = s.route("POST", "/api/run/respond", &body("hitl-1", &ask), Some("starter-secret"), None);
let text = String::from_utf8_lossy(&r.2).to_string();
assert!(text.contains("responder") || text.contains("RUN-E012"), "{text}");
let r = s.route("POST", "/api/run/respond", &body("hitl-1", &ask), Some("officer-secret"), None);
assert!(r.0.starts_with("200"), "{}", String::from_utf8_lossy(&r.2));
assert!(String::from_utf8_lossy(&r.2).contains("user:officer"));
let r = s.route(
"POST",
"/api/run/cancel",
br#"{"run_id":"hitl-1","because":"test brake"}"#,
Some("shared-console-secret"),
None,
);
assert!(r.0.starts_with("200"), "{}", String::from_utf8_lossy(&r.2));
}
}
#[cfg(test)]
mod memory_scoped_token_tests {
use super::UiServer;
use areev_cal::AreevFacade;
use areev_core::authz::{CredentialMap, AUTHZ_NS, REL_PERMITS};
use areev_core::types::{Fact, Grain};
use areev_store::Areev;
#[test]
fn memory_scoped_tokens_do_not_cross_memories() {
std::env::set_var("AREEV_SCOPED_TOK", "scoped-secret");
let map_json = r#"{"version":1,"tokens":[
{"env":"AREEV_SCOPED_TOK","principal":"agent:sync","memories":["memory-a"]}
]}"#;
let server_for = |label: &str| {
let dir = tempfile::tempdir().unwrap();
let mut store = Areev::open(dir.path().join("t.db").to_str().unwrap()).unwrap();
store
.add(
&Fact::new("agent:sync", REL_PERMITS, "read,write ON caller")
.namespace(AUTHZ_NS)
.created_at(1_000),
)
.unwrap();
store
.add(&Fact::new("acme", "tier", "Enterprise").namespace("caller").created_at(2_000))
.unwrap();
std::mem::forget(dir);
let facade = AreevFacade::with_session(store, Some("caller".into()), None);
UiServer::new(facade, label.into())
.with_credentials(CredentialMap::from_json(map_json).unwrap())
};
const WRITE: &[u8] = br#"{"query":"ADD fact SET subject = \"x\" SET relation = \"r\" SET object = \"o\" SET namespace = \"caller\" REASON \"t\""}"#;
let a = server_for("memory-a");
let r = a.route("POST", "/api/cal", WRITE, Some("scoped-secret"), None);
let text = String::from_utf8_lossy(&r.2).to_string();
assert!(r.0.starts_with("200") && !text.contains("AUT-E001"), "{text}");
let b = server_for("memory-b");
let r = b.route("POST", "/api/cal", WRITE, Some("scoped-secret"), None);
let text = String::from_utf8_lossy(&r.2).to_string();
assert!(
text.contains("AUT-E001") && text.contains("anonymous"),
"scoped token must not cross memories: {text}"
);
assert!(CredentialMap::from_json(
r#"{"version":1,"tokens":[{"env":"AREEV_SCOPED_TOK","principal":"x","memories":[]}]}"#
)
.is_err());
}
}
#[cfg(all(test, feature = "tls"))]
mod tls_tests {
use super::UiServer;
use areev_cal::AreevFacade;
use areev_store::Areev;
use std::io::{Read, Write};
fn self_signed(dir: &std::path::Path) -> Option<(String, String)> {
let cert = dir.join("cert.pem");
let key = dir.join("key.pem");
let status = std::process::Command::new("openssl")
.args([
"req", "-x509", "-newkey", "rsa:2048", "-nodes",
"-keyout", key.to_str().unwrap(), "-out", cert.to_str().unwrap(),
"-days", "1", "-subj", "/CN=localhost",
"-addext", "subjectAltName=DNS:localhost,IP:127.0.0.1",
"-addext", "basicConstraints=critical,CA:FALSE",
])
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status();
match status {
Ok(s) if s.success() => Some((
cert.to_str().unwrap().to_string(),
key.to_str().unwrap().to_string(),
)),
_ => None, }
}
#[test]
fn tls_round_trip_and_no_plaintext_downgrade() {
let dir = tempfile::tempdir().unwrap();
let Some((cert, key)) = self_signed(dir.path()) else {
eprintln!("skipping: no openssl binary");
return;
};
let store = Areev::open(dir.path().join("t.db").to_str().unwrap()).unwrap();
let facade = AreevFacade::with_session(store, Some("caller".into()), None);
let server = UiServer::new(facade, "tls-test".into())
.with_tls(&cert, &key)
.unwrap();
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
let addr = listener.local_addr().unwrap();
let handle = std::thread::spawn(move || {
for _ in 0..2 {
if let Ok((s, _)) = listener.accept() {
let _ = server.handle(s);
}
}
});
let mut roots = rustls::RootCertStore::empty();
{
use rustls::pki_types::pem::PemObject;
for c in rustls::pki_types::CertificateDer::pem_file_iter(&cert).unwrap() {
roots.add(c.unwrap()).unwrap();
}
}
let config = std::sync::Arc::new(
rustls::ClientConfig::builder()
.with_root_certificates(roots)
.with_no_client_auth(),
);
let name = rustls::pki_types::ServerName::try_from("localhost").unwrap();
let session = rustls::ClientConnection::new(config, name).unwrap();
let tcp = std::net::TcpStream::connect(addr).unwrap();
let mut tls = rustls::StreamOwned::new(session, tcp);
tls.write_all(b"GET / HTTP/1.1\r\nHost: 127.0.0.1\r\nConnection: close\r\n\r\n")
.unwrap();
let mut response = String::new();
let _ = tls.read_to_string(&mut response);
assert!(response.starts_with("HTTP/1.1 200"), "{}", &response[..response.len().min(120)]);
assert!(response.contains("Areev"), "console served over TLS");
let mut plain = std::net::TcpStream::connect(addr).unwrap();
plain
.write_all(b"GET / HTTP/1.1\r\nHost: 127.0.0.1\r\nConnection: close\r\n\r\n")
.unwrap();
let mut buf = Vec::new();
let _ = plain.read_to_end(&mut buf);
let text = String::from_utf8_lossy(&buf);
assert!(
!text.contains("HTTP/1.1 200"),
"plaintext must not be served on a TLS listener: {text}"
);
handle.join().unwrap();
}
#[test]
fn hub_mode_terminates_tls_the_same_way_as_ui_mode() {
let dir = tempfile::tempdir().unwrap();
let Some((cert, key)) = self_signed(dir.path()) else {
eprintln!("skipping: no openssl binary");
return;
};
let store = Areev::open(dir.path().join("t.db").to_str().unwrap()).unwrap();
let facade = AreevFacade::with_session(store, Some("caller".into()), None);
let segments = dir.path().join("segments");
let server = UiServer::new(facade, "hub-tls-test".into())
.into_hub(Some("hub-token".into()), segments.to_str().unwrap())
.unwrap()
.with_tls(&cert, &key)
.unwrap();
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
let addr = listener.local_addr().unwrap();
let handle = std::thread::spawn(move || {
if let Ok((s, _)) = listener.accept() {
let _ = server.handle(s);
}
});
let mut roots = rustls::RootCertStore::empty();
{
use rustls::pki_types::pem::PemObject;
for c in rustls::pki_types::CertificateDer::pem_file_iter(&cert).unwrap() {
roots.add(c.unwrap()).unwrap();
}
}
let config = std::sync::Arc::new(
rustls::ClientConfig::builder()
.with_root_certificates(roots)
.with_no_client_auth(),
);
let name = rustls::pki_types::ServerName::try_from("localhost").unwrap();
let session = rustls::ClientConnection::new(config, name).unwrap();
let tcp = std::net::TcpStream::connect(addr).unwrap();
let mut tls = rustls::StreamOwned::new(session, tcp);
tls.write_all(b"GET / HTTP/1.1\r\nHost: 127.0.0.1\r\nConnection: close\r\n\r\n")
.unwrap();
let mut response = String::new();
let _ = tls.read_to_string(&mut response);
assert!(
response.starts_with("HTTP/1.1 200"),
"hub should terminate TLS the same way ui does: {}",
&response[..response.len().min(120)]
);
handle.join().unwrap();
}
}
#[cfg(test)]
mod sso_tests {
use super::UiServer;
use areev_cal::AreevFacade;
use areev_core::authz::{AUTHZ_NS, REL_PERMITS};
use areev_core::types::{Fact, Grain};
use areev_store::Areev;
use std::io::{Read, Write};
const WRITE: &[u8] = br#"{"query":"ADD fact SET subject = \"x\" SET relation = \"r\" SET object = \"o\" SET namespace = \"caller\" REASON \"t\""}"#;
fn sso_server() -> UiServer {
sso_server_with("proxy-secret", None)
}
fn sso_server_rotating(next: Option<&str>) -> UiServer {
sso_server_with("proxy-secret", next)
}
fn sso_server_with(secret: &str, next: Option<&str>) -> UiServer {
let dir = tempfile::tempdir().unwrap();
let mut store = Areev::open(dir.path().join("t.db").to_str().unwrap()).unwrap();
store
.add(
&Fact::new("user:pat@example.com", REL_PERMITS, "read,write ON caller")
.namespace(AUTHZ_NS)
.created_at(1_000),
)
.unwrap();
std::mem::forget(dir);
let facade = AreevFacade::with_session(store, Some("caller".into()), None);
UiServer::new(facade, "sso-test".into())
.with_sso_rotating("x-forwarded-user", secret, next)
}
fn post(server: &UiServer, extra_headers: &str) -> String {
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
let addr = listener.local_addr().unwrap();
std::thread::scope(|scope| {
scope.spawn(|| {
if let Ok((s, _)) = listener.accept() {
let _ = server.handle(s);
}
});
let mut c = std::net::TcpStream::connect(addr).unwrap();
let req = format!(
"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",
WRITE.len()
);
c.write_all(req.as_bytes()).unwrap();
c.write_all(WRITE).unwrap();
let mut out = String::new();
let _ = c.read_to_string(&mut out);
out
})
}
#[test]
fn proxied_identity_works_and_forged_headers_are_ignored() {
let server = sso_server();
let ok = post(
&server,
"X-Forwarded-User: user:pat@example.com\r\nX-Areev-Proxy-Secret: proxy-secret\r\n",
);
assert!(ok.contains("HTTP/1.1 200"), "{}", &ok[..ok.len().min(200)]);
assert!(!ok.contains("AUT-E001"), "granted principal must write: {ok}");
let forged = post(&server, "X-Forwarded-User: user:pat@example.com\r\n");
assert!(
forged.contains("anonymous") || forged.contains("401"),
"forged identity must not authenticate: {forged}"
);
let wrong = post(
&server,
"X-Forwarded-User: user:pat@example.com\r\nX-Areev-Proxy-Secret: guess\r\n",
);
assert!(
wrong.contains("anonymous") || wrong.contains("401"),
"wrong proxy secret must not authenticate: {wrong}"
);
}
#[test]
fn both_secrets_authenticate_during_a_rotation_window() {
let server = sso_server_rotating(Some("new-secret"));
let id = "X-Forwarded-User: user:pat@example.com\r\n";
for secret in ["proxy-secret", "new-secret"] {
let out = post(&server, &format!("{id}X-Areev-Proxy-Secret: {secret}\r\n"));
assert!(out.contains("HTTP/1.1 200"), "{secret} rejected: {out}");
assert!(!out.contains("AUT-E001"), "{secret} must authenticate: {out}");
}
let wrong = post(&server, &format!("{id}X-Areev-Proxy-Secret: guess\r\n"));
assert!(
wrong.contains("anonymous") || wrong.contains("401"),
"an unrelated secret must still be refused mid-rotation: {wrong}"
);
}
#[test]
fn the_retired_secret_stops_working_once_the_window_closes() {
let server = sso_server_with("new-secret", None);
let id = "X-Forwarded-User: user:pat@example.com\r\n";
let ok = post(&server, &format!("{id}X-Areev-Proxy-Secret: new-secret\r\n"));
assert!(ok.contains("HTTP/1.1 200"), "{ok}");
assert!(!ok.contains("AUT-E001"), "the promoted secret must authenticate: {ok}");
let old = post(&server, &format!("{id}X-Areev-Proxy-Secret: proxy-secret\r\n"));
assert!(
old.contains("anonymous") || old.contains("401"),
"the retired secret must stop working: {old}"
);
}
#[test]
#[should_panic(expected = "must differ")]
fn rotating_to_the_same_value_is_refused() {
let _ = sso_server_rotating(Some("proxy-secret"));
}
use crate::CONSOLE_HTML;
use std::collections::BTreeSet;
#[test]
fn every_console_page_is_in_the_visibility_list() {
fn all(hay: &str, open: &str) -> BTreeSet<String> {
let mut out = BTreeSet::new();
let mut rest = hay;
while let Some(i) = rest.find(open) {
rest = &rest[i + open.len()..];
let end = rest.find('"').expect("unterminated attribute");
out.insert(rest[..end].to_string());
rest = &rest[end..];
}
out
}
let sections = all(CONSOLE_HTML, "id=\"page-");
let nav = all(CONSOLE_HTML, "data-page=\"");
for want in ["workflows", "runs", "tools"] {
assert!(sections.contains(want), "the {want} section itself went missing");
}
assert!(!sections.contains("triggers"), "triggers belong on the canvas, not on a page");
assert!(
CONSOLE_HTML.contains("if (head === 'triggers')"),
"#triggers must still route somewhere — a removed page is not a dead bookmark"
);
let head = "for (const p of [";
let i = CONSOLE_HTML.find(head).expect("render()'s page-visibility loop");
let tail = &CONSOLE_HTML[i + head.len()..];
let j = tail.find(']').expect("unterminated page array");
let listed: BTreeSet<String> = tail[..j]
.split(',')
.map(|t| t.trim().trim_matches('\'').to_string())
.filter(|t| !t.is_empty())
.collect();
assert_eq!(
sections, listed,
"page sections and render()'s visibility list disagree; a section missing \
from the list renders into a pane that never un-hides"
);
let orphan: Vec<_> = nav.difference(§ions).collect();
assert!(orphan.is_empty(), "nav items with no page section: {orphan:?}");
}
}