From 75585f2dd34a821a96b13c1555e4dd099ffe1aee Mon Sep 17 00:00:00 2001 From: Valentin Haudiquet Date: Fri, 23 Oct 2026 21:47:14 +0800 Subject: [PATCH] =?UTF-8?q?feat(tools):=20the=20contract=20crate=20?= =?UTF-8?q?=E2=80=94=20allowlist,=20header,=20conformance=20suite?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The client tools need one shared definition of what a tool may do, and the daemon needs to enforce exactly that definition, not a hand-copied copy of it. This crate is that definition, pure: no HTTP client, no process — the allowlist as data, the version header as constants, the request/response and error types, and the conformance suite that drives any transport through the checks the contract promises (the bearer gate, the version handshake, the allowlist's denials, round-trips through every allowed route, one error shape). `agent_allowed` is the whole security surface in one function: method and path segments with `*` and `**`, exact otherwise — a daemon's middleware asks it and nothing else, so the claw's scoping and the tools' expectations cannot drift apart. The suite runs against a mock router in this crate's tests and against the daemon's real axum router in the claw's; same results is the definition of implementing the contract. --- tools/contract/Cargo.toml | 20 ++ tools/contract/src/conformance.rs | 427 ++++++++++++++++++++++++++++ tools/contract/src/lib.rs | 236 +++++++++++++++ tools/contract/src/mock.rs | 213 ++++++++++++++ tools/contract/tests/conformance.rs | 54 ++++ 5 files changed, 950 insertions(+) create mode 100644 tools/contract/Cargo.toml create mode 100644 tools/contract/src/conformance.rs create mode 100644 tools/contract/src/lib.rs create mode 100644 tools/contract/src/mock.rs create mode 100644 tools/contract/tests/conformance.rs diff --git a/tools/contract/Cargo.toml b/tools/contract/Cargo.toml new file mode 100644 index 0000000..9c59b43 --- /dev/null +++ b/tools/contract/Cargo.toml @@ -0,0 +1,20 @@ +[package] +name = "rhema-tools-contract" +description = "The wire contract between the rhema claw daemon and its thin client tools: routes, shapes, the agent-token allowlist, and the conformance suite" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +repository.workspace = true +authors.workspace = true + +[dependencies] +serde.workspace = true +serde_json.workspace = true + +[dev-dependencies] +tokio.workspace = true + +[lints.rust] +unsafe_code = "forbid" +missing_docs = "deny" diff --git a/tools/contract/src/conformance.rs b/tools/contract/src/conformance.rs new file mode 100644 index 0000000..b3466be --- /dev/null +++ b/tools/contract/src/conformance.rs @@ -0,0 +1,427 @@ +//! The conformance suite: every guarantee the contract names, phrased +//! as requests against an opaque transport. The daemon's real router +//! and the mock router both answer it; same results is the definition +//! of the daemon implementing the contract. + +use crate::{ApiError, Auth, CallRequest, CallResponse, now_millis}; + +/// Runs every check against `call`, which performs one HTTP request. +/// The first failure is returned, named by the check that failed; `Ok` +/// means the transport speaks the contract. +/// +/// The suite assumes a daemon configured with both tokens — a human +/// one and an agent one — because the agent-token class is the +/// contract's load-bearing guarantee. A daemon running an open surface +/// (no tokens) cannot pass it, by design: the credential section of +/// the spec (§5) makes the scoped agent token a precondition of the +/// client tools. +pub async fn conformance(call: F) -> Result<(), String> +where + F: Fn(CallRequest) -> Fut, + Fut: std::future::Future>, +{ + let ctx = Ctx { call: &call }; + + // — The bearer gate — + ctx.expect( + "a missing bearer is rejected", + req("GET", "/v1/watches", Auth::None, Some(1), None), + 401, + ) + .await?; + ctx.expect( + "a wrong bearer is rejected", + req("GET", "/v1/watches", Auth::Invalid, Some(1), None), + 401, + ) + .await?; + + // — The contract version handshake — + ctx.expect( + "an agent call without the contract header conflicts", + req("GET", "/v1/watches", Auth::Agent, None, None), + 409, + ) + .await?; + ctx.expect( + "an agent call at a foreign contract version conflicts", + req("GET", "/v1/watches", Auth::Agent, Some(999), None), + 409, + ) + .await?; + + // — Watches: add, see it listed, remove — + ctx.expect( + "the agent lists watches", + req("GET", "/v1/watches", Auth::Agent, Some(1), None), + 200, + ) + .await?; + let added = ctx + .expect_success( + "the agent adds a watch", + req( + "POST", + "/v1/watches", + Auth::Agent, + Some(1), + Some(serde_json::json!({ + "forge": "github", "repo": "acme/widgets" + })), + ), + ) + .await?; + if added.body.to_string().contains("error") { + return Err("the agent adds a watch: the answer reads as an error".into()); + } + let listed = ctx + .expect( + "the agent lists watches again", + req("GET", "/v1/watches", Auth::Agent, Some(1), None), + 200, + ) + .await?; + if !listed.body.to_string().contains("acme/widgets") { + return Err("the watch the agent added is not in the list".into()); + } + ctx.expect_success( + "the agent removes the watch", + req( + "DELETE", + "/v1/watches/github/acme/widgets", + Auth::Agent, + Some(1), + None, + ), + ) + .await?; + + // — Notifications: push with and without a device — + let pushed = ctx + .expect_success( + "the agent pushes a notification", + req( + "POST", + "/v1/notifications", + Auth::Agent, + Some(1), + Some(serde_json::json!({ + "title": "check", "body": "the build", "device": "phone" + })), + ), + ) + .await?; + if pushed + .body + .get("id") + .and_then(serde_json::Value::as_i64) + .is_none() + { + return Err("a pushed notification answers with its row id".into()); + } + if pushed + .body + .get("device") + .and_then(serde_json::Value::as_str) + != Some("phone") + { + return Err("a pushed notification echoes its device".into()); + } + let defaulted = ctx + .expect_success( + "a device-less push lands on the default device", + req( + "POST", + "/v1/notifications", + Auth::Agent, + Some(1), + Some(serde_json::json!({ + "title": "check", "body": "the build" + })), + ), + ) + .await?; + if defaulted + .body + .get("device") + .and_then(serde_json::Value::as_str) + .unwrap_or("") + .is_empty() + { + return Err("a device-less push answers a non-empty device".into()); + } + + // — Tasks: schedule both kinds, list, remove, remove again — + let at = ctx + .expect_success( + "the agent schedules a one-shot task", + req( + "POST", + "/v1/tasks", + Auth::Agent, + Some(1), + Some(serde_json::json!({ + "kind": "at", "prompt": "check CI", "at_millis": now_millis() + 60_000 + })), + ), + ) + .await?; + let at_id = at + .body + .get("id") + .and_then(serde_json::Value::as_i64) + .ok_or("a scheduled task answers with its row id")?; + ctx.expect_success( + "the agent schedules a recurring task", + req( + "POST", + "/v1/tasks", + Auth::Agent, + Some(1), + Some(serde_json::json!({ + "kind": "every", "prompt": "water the plants", "interval_millis": 3_600_000 + })), + ), + ) + .await?; + let listed = ctx + .expect( + "the agent lists tasks", + req("GET", "/v1/tasks", Auth::Agent, Some(1), None), + 200, + ) + .await?; + let ids: Vec = listed + .body + .get("tasks") + .and_then(serde_json::Value::as_array) + .map(|tasks| { + tasks + .iter() + .filter_map(|t| t.get("id").and_then(serde_json::Value::as_i64)) + .collect() + }) + .unwrap_or_default(); + if !ids.contains(&at_id) { + return Err("the task the agent scheduled is not in the list".into()); + } + let removed = ctx + .expect_success( + "the agent cancels the task", + req( + "DELETE", + &format!("/v1/tasks/{at_id}"), + Auth::Agent, + Some(1), + None, + ), + ) + .await?; + if removed + .body + .get("removed") + .and_then(serde_json::Value::as_bool) + != Some(true) + { + return Err("cancelling an existing task answers removed: true".into()); + } + let again = ctx + .expect_success( + "cancelling a cancelled task still answers ok", + req( + "DELETE", + &format!("/v1/tasks/{at_id}"), + Auth::Agent, + Some(1), + None, + ), + ) + .await?; + if again + .body + .get("removed") + .and_then(serde_json::Value::as_bool) + != Some(false) + { + return Err("cancelling a gone task answers removed: false".into()); + } + + // — The class a malformed body meets: a client error. The exact + // code is the server's framework's business (400 or 422); the + // contract only fixes the class. + let malformed = ctx + .send( + "a malformed task body", + req( + "POST", + "/v1/tasks", + Auth::Agent, + Some(1), + Some(serde_json::json!({})), + ), + ) + .await?; + if !(400..500).contains(&malformed.status) { + return Err(format!( + "a malformed task body is a client error, got {} ({})", + malformed.status, malformed.body + )); + } + + // — The allowlist's denials: the routes a tool must never reach — + ctx.expect( + "the agent cannot answer a consent", + req( + "POST", + "/v1/consents/c-1", + Auth::Agent, + Some(1), + Some(serde_json::json!({"grant": true})), + ), + 403, + ) + .await?; + ctx.expect( + "the agent cannot ingest an event", + req( + "POST", + "/v1/ingest", + Auth::Agent, + Some(1), + Some(serde_json::json!({ + "source": "x", "key": "y", "payload": {} + })), + ), + 403, + ) + .await?; + ctx.expect( + "the agent cannot create a session", + req( + "POST", + "/v1/sessions", + Auth::Agent, + Some(1), + Some(serde_json::json!({})), + ), + 403, + ) + .await?; + ctx.expect( + "the agent cannot forget a phone", + req("DELETE", "/v1/phones/p1", Auth::Agent, Some(1), None), + 403, + ) + .await?; + + // — The human token is untouched: the full surface — + ctx.expect( + "the human lists consents", + req("GET", "/v1/consents", Auth::Human, None, None), + 200, + ) + .await?; + ctx.expect_success( + "the human ingests an event", + req( + "POST", + "/v1/ingest", + Auth::Human, + None, + Some(serde_json::json!({ + "source": "ciwatch", "key": "ci:build/1", "payload": {"text": "ping"} + })), + ), + ) + .await?; + + // — The one error shape — + let missing = ctx + .expect( + "an unknown route answers the error shape", + req("GET", "/v1/no-such-route", Auth::Human, None, None), + 404, + ) + .await?; + if serde_json::from_value::(missing.body.clone()).is_err() { + return Err("every error answer is {\"error\": \"...\"}".into()); + } + + Ok(()) +} + +/// The suite's transport: the closure, plus named checks over it. +struct Ctx<'a, F, Fut> +where + F: Fn(CallRequest) -> Fut, + Fut: std::future::Future>, +{ + call: &'a F, +} + +impl<'a, F, Fut> Ctx<'a, F, Fut> +where + F: Fn(CallRequest) -> Fut, + Fut: std::future::Future>, +{ + /// Performs one request and requires its status; anything else is + /// the check's failure, spelled with the check's name. + async fn expect( + &self, + name: &str, + request: CallRequest, + status: u16, + ) -> Result { + let got = self.send(name, request).await?; + if got.status != status { + return Err(format!( + "{name}: expected {status}, got {} ({})", + got.status, got.body + )); + } + Ok(got) + } + + /// Performs one request and requires success — any 2xx. Creations + /// may answer 200 or 201; the contract does not pick for the + /// server. Denials stay exact in [`expect`]. + async fn expect_success( + &self, + name: &str, + request: CallRequest, + ) -> Result { + let got = self.send(name, request).await?; + if !(200..300).contains(&got.status) { + return Err(format!( + "{name}: expected a success, got {} ({})", + got.status, got.body + )); + } + Ok(got) + } + + /// Performs one request, answering whatever came back; the caller + /// judges. A transport failure is the check's failure. + async fn send(&self, name: &str, request: CallRequest) -> Result { + (self.call)(request) + .await + .map_err(|err| format!("{name}: the transport failed: {err}")) + } +} + +/// Builds one request with the shared defaults. +fn req( + method: &'static str, + path: &str, + auth: Auth, + contract: Option, + body: Option, +) -> CallRequest { + CallRequest { + method, + path: path.to_owned(), + auth, + contract, + body, + } +} diff --git a/tools/contract/src/lib.rs b/tools/contract/src/lib.rs new file mode 100644 index 0000000..68564e5 --- /dev/null +++ b/tools/contract/src/lib.rs @@ -0,0 +1,236 @@ +//! The wire contract between the rhema claw daemon and its thin client +//! tools — the entire knowledge the tools project may have of the claw. +//! +//! The claw's model-facing data tools are executables the agent runs in +//! its shell: parse arguments, make one HTTP call, print JSON, exit. +//! The daemon keeps the state; the clients hold nothing. What binds the +//! two sides without binding either to the other's crate is this +//! contract: the routes, the JSON shapes, the agent token's allowlist, +//! and the conformance suite both sides answer to. The dependency +//! arrows run claw → contract ← tools and never cross. +//! +//! One decision shapes everything else: the surface reuses the claw's +//! existing `/v1` HTTP API — the same routes the web UI and the phone +//! client speak — so promoting a tool to a route serves the other +//! clients too. The agent token (a second, scoped bearer) is what makes +//! handing the model a credential safe: it answers only the data routes +//! in [`agent_allowed`], never consents, sessions, or ingest, so a tool +//! cannot answer its own `ask_secret`. + +use serde::{Deserialize, Serialize}; + +/// The request header carrying the contract version a client speaks. +pub const CONTRACT_HEADER: &str = "x-rhema-contract"; + +/// The contract version this crate speaks. A daemon answering at a +/// different version rejects the request with `409` and the standard +/// error shape, so a stale installed binary fails loudly on first use. +pub const CONTRACT_VERSION: u32 = 1; + +/// The notifications collection: `GET` lists the queue, `POST` pushes +/// one row a device delivers at its next poll-and-ack. +pub const NOTIFICATIONS: &str = "/v1/notifications"; + +/// The scheduled tasks: `GET` lists, `POST` schedules, and +/// `DELETE /v1/tasks/{id}` cancels. +pub const TASKS: &str = "/v1/tasks"; + +/// The forge watch table: `GET` lists, `POST` adds, and +/// `DELETE /v1/watches/{forge}/{repo}` removes. +pub const WATCHES: &str = "/v1/watches"; + +/// The route allowlist the agent token answers for. The claw's auth +/// middleware and the mock router both run this — one list, so the +/// daemon's enforcement and the contract's conformance suite cannot +/// drift. `path` is the daemon-side path under which the routes nest. +/// +/// Reads and writes over the data domains the clients serve: watches, +/// notifications, tasks. Everything else — consent answers, session +/// creation, ingest, phone or account removal — is human-token +/// territory: an agent that could answer consents could answer its own +/// `ask_secret`. +pub fn agent_allowed(method: &str, path: &str) -> bool { + /// One allowlist row: the method and the path segments, `*` matching + /// one segment and `**` matching the rest. + const ROUTES: &[(&str, &[&str])] = &[ + ("GET", &["v1", "watches"]), + ("POST", &["v1", "watches"]), + ("DELETE", &["v1", "watches", "*", "**"]), + ("GET", &["v1", "notifications"]), + ("POST", &["v1", "notifications"]), + ("GET", &["v1", "tasks"]), + ("POST", &["v1", "tasks"]), + ("DELETE", &["v1", "tasks", "*"]), + ]; + // The routes are daemon-side (`/v1/...`), but a daemon whose auth + // middleware runs inside its `/v1` nest sees the stripped path — + // accept both spellings, one allowlist. + let prefixed = format!("/v1{path}"); + let path = if path.starts_with("/v1") { + path + } else { + prefixed.as_str() + }; + let segments: Vec<&str> = path.trim_matches('/').split('/').collect(); + ROUTES + .iter() + .any(|(route_method, pattern)| *route_method == method && match_pattern(pattern, &segments)) +} + +/// Matches one allowlist pattern against a request's path segments. +fn match_pattern(pattern: &[&str], segments: &[&str]) -> bool { + match (pattern.first(), segments.first()) { + (None, None) => true, + (Some(&"**"), _) => true, + (Some(p), Some(s)) if *p == "*" || p == s => match_pattern(&pattern[1..], &segments[1..]), + _ => false, + } +} + +/// The body of a `POST /v1/notifications` push. The device picks which +/// registered device polls the row; `None` means the default device. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct NotificationPush { + /// The notification's title line. + pub title: String, + /// The notification's body text. + pub body: String, + /// The device to deliver to; the daemon's default device when + /// `None`. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub device: Option, +} + +/// One notification row as the daemon returns it. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct NotificationOut { + /// The row id. + pub id: i64, + /// The device the row waits for. + pub device: String, + /// The title line. + pub title: String, + /// The body text. + pub body: String, + /// Creation time, milliseconds since the Unix epoch. + pub created_at: i64, + /// Delivery time, milliseconds since the Unix epoch, once the + /// device acked; `None` while outstanding. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub delivered_at: Option, +} + +/// The body of a `POST /v1/tasks` schedule. `kind` picks the shape: +/// `at` requires `at_millis`, `every` requires `interval_millis`. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct TaskAdd { + /// `at` for a one-shot, `every` for a recurring task. + pub kind: String, + /// The prompt the firing turn runs. + pub prompt: String, + /// The one-shot's firing time, milliseconds since the Unix epoch. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub at_millis: Option, + /// The recurring task's interval, in milliseconds. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub interval_millis: Option, + /// The session a firing wakes; the task's own `task:` slot when + /// `None`. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub deliver_to: Option, +} + +/// One task row as the daemon returns it. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct TaskOut { + /// The row id; `DELETE /v1/tasks/{id}` addresses it. + pub id: i64, + /// The prompt the scheduled turn runs. + pub prompt: String, + /// `at` or `every`. + pub kind: String, + /// The one-shot's firing time, milliseconds since the Unix epoch. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub at_millis: Option, + /// The recurring task's interval, in milliseconds. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub interval_millis: Option, + /// When the task next fires, milliseconds since the Unix epoch. + pub next_due_millis: i64, + /// Creation time, milliseconds since the Unix epoch. + pub created_at: i64, + /// The session a firing wakes; `None` meaning the task's own slot. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub deliver_to: Option, +} + +/// The removal answer both watches and tasks speak: `{"removed": true}` +/// even when the row was already gone — a replay after a crash +/// converges instead of 404ing. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub struct Removed { + /// Whether a row was removed by this call. + pub removed: bool, +} + +/// The one error shape every failure speaks: the last line of stdout is +/// `{"error": "..."}` and the exit code says the class. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ApiError { + /// What went wrong, in one line a model can react to. + pub error: String, +} + +/// Which bearer a conformance call presents. The transport resolves a +/// class to the actual token it holds; the suite never spells tokens +/// into request bodies. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Auth { + /// No `Authorization` header at all. + None, + /// The human token: the full surface. + Human, + /// The agent token: the [`agent_allowed`] routes only. + Agent, + /// A bearer that matches neither configured token. + Invalid, +} + +/// One conformance request. +#[derive(Debug, Clone)] +pub struct CallRequest { + /// The HTTP method. + pub method: &'static str, + /// The daemon-side path, e.g. `/v1/tasks`. + pub path: String, + /// Which bearer to present. + pub auth: Auth, + /// The `x-rhema-contract` value; `None` sends no header. + pub contract: Option, + /// The JSON body, when the route takes one. + pub body: Option, +} + +/// One conformance response. +#[derive(Debug, Clone)] +pub struct CallResponse { + /// The HTTP status. + pub status: u16, + /// The JSON body; `{}` when the route answers empty. + pub body: serde_json::Value, +} + +mod conformance; +pub use conformance::conformance; + +mod mock; +pub use mock::MockClaw; + +/// Milliseconds since the Unix epoch — the timestamp vocabulary every +/// contract shape speaks. +pub fn now_millis() -> i64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_millis() as i64) + .unwrap_or(0) +} diff --git a/tools/contract/src/mock.rs b/tools/contract/src/mock.rs new file mode 100644 index 0000000..0313215 --- /dev/null +++ b/tools/contract/src/mock.rs @@ -0,0 +1,213 @@ +//! The mock router: the contract's semantics, in memory, no HTTP. The +//! conformance suite passes against it first — a daemon that fails the +//! suite diverges from this file, and that is the diagnosis. + +use std::sync::Mutex; + +use serde_json::{Value, json}; + +use std::sync::PoisonError; + +use crate::{ + Auth, CONTRACT_VERSION, CallRequest, CallResponse, NotificationOut, NotificationPush, TaskAdd, + TaskOut, agent_allowed, now_millis, +}; + +/// An in-memory daemon speaking the contract. Configure it with the +/// two tokens a real daemon boots with; the conformance suite assumes +/// both are set. +pub struct MockClaw { + human_token: Option, + agent_token: Option, + notifications: Mutex>, + tasks: Mutex>, + watches: Mutex>, + notification_ids: Mutex, + task_ids: Mutex, +} + +impl MockClaw { + /// A mock with the daemon's two tokens; `None` means that class is + /// unconfigured and its bearers are strangers. + pub fn new(human_token: Option<&str>, agent_token: Option<&str>) -> Self { + Self { + human_token: human_token.map(str::to_owned), + agent_token: agent_token.map(str::to_owned), + notifications: Mutex::new(Vec::new()), + tasks: Mutex::new(Vec::new()), + watches: Mutex::new(Vec::new()), + notification_ids: Mutex::new(0), + task_ids: Mutex::new(0), + } + } + + /// Answers one request, exactly the daemon's auth middleware then + /// its route handlers would. + pub async fn call(&self, request: CallRequest) -> Result { + // The bearer gate. With no tokens configured the surface is + // open; with either, a stranger is a 401. + let open = self.human_token.is_none() && self.agent_token.is_none(); + if !open && matches!(request.auth, Auth::None | Auth::Invalid) { + return Ok(self.error(401, "unauthorized")); + } + // The agent class: scoped routes, contract version required. + if request.auth == Auth::Agent { + if request.contract != Some(CONTRACT_VERSION) { + return Ok(self.error(409, "contract version mismatch")); + } + if !agent_allowed(request.method, &request.path) { + return Ok(self.error(403, "this route is not the agent token's")); + } + } + + let segments: Vec<&str> = request.path.trim_matches('/').split('/').collect(); + match (request.method, segments.as_slice()) { + ("GET", ["v1", "watches"]) => Ok(self.answer(200, json!({ + "watches": self.watches.lock().unwrap_or_else(PoisonError::into_inner).clone(), + "account_watches": [], + }))), + ("POST", ["v1", "watches"]) => { + let Ok(body) = serde_json::from_value::( + request.body.clone().unwrap_or(Value::Null), + ) else { + return Ok(self.error(400, "malformed watch body")); + }; + if !matches!(body.forge.as_str(), "github" | "gitea" | "gitlab") + || body.repo.trim().is_empty() + { + return Ok(self.error(400, "forge must be github, gitea, or gitlab")); + } + let mut watches = self.watches.lock().unwrap_or_else(PoisonError::into_inner); + watches.retain(|w| w["repo"] != body.repo); + watches.push(json!({"forge": body.forge, "repo": body.repo})); + Ok(self.answer(200, json!({"forge": body.forge, "repo": body.repo}))) + } + ("DELETE", ["v1", "watches", forge, rest @ ..]) => { + let repo = rest.join("/"); + let mut watches = self.watches.lock().unwrap_or_else(PoisonError::into_inner); + let before = watches.len(); + watches.retain(|w| w["forge"] != *forge || w["repo"] != repo); + Ok(self.answer(200, json!({"removed": watches.len() != before}))) + } + + ("GET", ["v1", "notifications"]) => Ok(self.answer(200, json!({ + "notifications": self.notifications.lock().unwrap_or_else(PoisonError::into_inner).clone(), + }))), + ("POST", ["v1", "notifications"]) => { + let Ok(push) = serde_json::from_value::( + request.body.clone().unwrap_or(Value::Null), + ) else { + return Ok(self.error(400, "malformed notification body")); + }; + if push.title.trim().is_empty() || push.body.trim().is_empty() { + return Ok(self.error(400, "title and body must not be empty")); + } + let mut ids = self.notification_ids.lock().unwrap_or_else(PoisonError::into_inner); + *ids += 1; + let row = NotificationOut { + id: *ids, + device: push.device.unwrap_or_else(|| "phone".to_owned()), + title: push.title, + body: push.body, + created_at: now_millis(), + delivered_at: None, + }; + self.notifications + .lock() + .unwrap_or_else(PoisonError::into_inner) + .push(row.clone()); + Ok(self.answer(200, serde_json::to_value(row).unwrap_or(Value::Null))) + } + + ("GET", ["v1", "tasks"]) => Ok(self.answer(200, json!({ + "tasks": self.tasks.lock().unwrap_or_else(PoisonError::into_inner).clone(), + }))), + ("POST", ["v1", "tasks"]) => { + let Ok(add) = serde_json::from_value::( + request.body.clone().unwrap_or(Value::Null), + ) else { + return Ok(self.error(400, "malformed task body")); + }; + let now = now_millis(); + let (at_millis, interval_millis, next_due) = match add.kind.as_str() { + "at" => { + let Some(at) = add.at_millis else { + return Ok(self.error(400, "a one-shot task needs at_millis")); + }; + (Some(at), None, at) + } + "every" => { + let Some(interval) = add.interval_millis else { + return Ok(self.error(400, "a recurring task needs interval_millis")); + }; + (None, Some(interval), now + interval) + } + _ => return Ok(self.error(400, "kind must be at or every")), + }; + let mut ids = self.task_ids.lock().unwrap_or_else(PoisonError::into_inner); + *ids += 1; + let row = TaskOut { + id: *ids, + prompt: add.prompt, + kind: add.kind, + at_millis, + interval_millis, + next_due_millis: next_due, + created_at: now, + deliver_to: add.deliver_to, + }; + self.tasks.lock().unwrap_or_else(PoisonError::into_inner).push(row.clone()); + Ok(self.answer(200, serde_json::to_value(row).unwrap_or(Value::Null))) + } + ("DELETE", ["v1", "tasks", id]) => { + let Ok(id) = id.parse::() else { + return Ok(self.error(400, "the task id is a number")); + }; + let mut tasks = self.tasks.lock().unwrap_or_else(PoisonError::into_inner); + let before = tasks.len(); + tasks.retain(|t| t.id != id); + Ok(self.answer(200, json!({"removed": tasks.len() != before}))) + } + + // Human-surface stand-ins: just enough for the suite's + // full-surface checks to have something to hit. + ("GET", ["v1", "consents"]) => Ok(self.answer(200, json!({"consents": []}))), + ("POST", ["v1", "ingest"]) => { + let body = request.body.unwrap_or(Value::Null); + let source_ok = body.get("source").and_then(Value::as_str).is_some_and(|s| !s.is_empty()); + let key_ok = body.get("key").and_then(Value::as_str).is_some_and(|s| !s.is_empty()); + let payload_ok = body.get("payload").is_some_and(Value::is_object); + if source_ok && key_ok && payload_ok { + Ok(self.answer(200, json!({"accepted": true}))) + } else { + Ok(self.error(400, "ingest needs source, key, and a payload object")) + } + } + ("POST", ["v1", "sessions"]) => Ok(self.answer(200, json!({}))), + + _ => Ok(self.error(404, "no such route")), + } + } + + /// A JSON answer. + fn answer(&self, status: u16, body: Value) -> CallResponse { + CallResponse { status, body } + } + + /// The one error shape. + fn error(&self, status: u16, message: &str) -> CallResponse { + CallResponse { + status, + body: json!({"error": message}), + } + } +} + +/// The body `POST /v1/watches` takes. +#[derive(serde::Deserialize)] +struct WatchAdd { + /// The forge: `github`, `gitea`, or `gitlab`. + forge: String, + /// The `owner/name` repository. + repo: String, +} diff --git a/tools/contract/tests/conformance.rs b/tools/contract/tests/conformance.rs new file mode 100644 index 0000000..ca98c99 --- /dev/null +++ b/tools/contract/tests/conformance.rs @@ -0,0 +1,54 @@ +//! The contract's own gate: the suite passes against the mock router. +//! The daemon's real router runs the same suite in its test tree — +//! same results is the definition of implementing the contract. + +use rhema_tools_contract::{CallRequest, MockClaw, conformance}; + +#[tokio::test] +async fn the_suite_passes_against_the_mock_router() { + let mock = MockClaw::new(Some("human-token"), Some("agent-token")); + let result = + conformance(|request: CallRequest| async { Ok(mock.call(request).await.unwrap()) }).await; + assert_eq!(result, Ok(()), "the mock router speaks the contract"); +} + +#[tokio::test] +async fn an_open_mock_fails_the_suite() { + // No tokens: the agent-token guarantees the suite exists to check + // are gone. The suite must say so, not sail through. + let mock = MockClaw::new(None, None); + let result = + conformance(|request: CallRequest| async { Ok(mock.call(request).await.unwrap()) }).await; + assert!(result.is_err(), "an open surface cannot pass the suite"); +} + +#[tokio::test] +async fn the_agent_allowlist_reads_the_documented_routes() { + assert!(rhema_tools_contract::agent_allowed("GET", "/v1/watches")); + assert!(rhema_tools_contract::agent_allowed( + "DELETE", + "/v1/watches/github/acme/widgets" + )); + assert!(rhema_tools_contract::agent_allowed( + "POST", + "/v1/notifications" + )); + assert!(rhema_tools_contract::agent_allowed( + "DELETE", + "/v1/tasks/12" + )); + assert!(!rhema_tools_contract::agent_allowed("POST", "/v1/ingest")); + assert!(!rhema_tools_contract::agent_allowed( + "POST", + "/v1/consents/c-1" + )); + assert!(!rhema_tools_contract::agent_allowed("GET", "/v1/consents")); + assert!(!rhema_tools_contract::agent_allowed( + "DELETE", + "/v1/phones/p1" + )); + assert!( + !rhema_tools_contract::agent_allowed("GET", "/v1/watches/github/acme/widgets"), + "only DELETE reaches subpaths of /v1/watches" + ); +}