feat(tools): the contract crate — allowlist, header, conformance suite
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.
This commit is contained in:
1 parent
fb05983b05
commit
75585f2dd3
5 files changed
+950
No files matched your search
@@ -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"
|
||||||
@@ -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<F, Fut>(call: F) -> Result<(), String>
|
||||||
|
where
|
||||||
|
F: Fn(CallRequest) -> Fut,
|
||||||
|
Fut: std::future::Future<Output = Result<CallResponse, String>>,
|
||||||
|
{
|
||||||
|
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<i64> = 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::<ApiError>(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<Output = Result<CallResponse, String>>,
|
||||||
|
{
|
||||||
|
call: &'a F,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'a, F, Fut> Ctx<'a, F, Fut>
|
||||||
|
where
|
||||||
|
F: Fn(CallRequest) -> Fut,
|
||||||
|
Fut: std::future::Future<Output = Result<CallResponse, String>>,
|
||||||
|
{
|
||||||
|
/// 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<CallResponse, String> {
|
||||||
|
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<CallResponse, String> {
|
||||||
|
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<CallResponse, String> {
|
||||||
|
(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<u32>,
|
||||||
|
body: Option<serde_json::Value>,
|
||||||
|
) -> CallRequest {
|
||||||
|
CallRequest {
|
||||||
|
method,
|
||||||
|
path: path.to_owned(),
|
||||||
|
auth,
|
||||||
|
contract,
|
||||||
|
body,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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<i64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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<i64>,
|
||||||
|
/// The recurring task's interval, in milliseconds.
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub interval_millis: Option<i64>,
|
||||||
|
/// The session a firing wakes; the task's own `task:<id>` slot when
|
||||||
|
/// `None`.
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub deliver_to: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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<i64>,
|
||||||
|
/// The recurring task's interval, in milliseconds.
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub interval_millis: Option<i64>,
|
||||||
|
/// 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<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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<u32>,
|
||||||
|
/// The JSON body, when the route takes one.
|
||||||
|
pub body: Option<serde_json::Value>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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)
|
||||||
|
}
|
||||||
@@ -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<String>,
|
||||||
|
agent_token: Option<String>,
|
||||||
|
notifications: Mutex<Vec<NotificationOut>>,
|
||||||
|
tasks: Mutex<Vec<TaskOut>>,
|
||||||
|
watches: Mutex<Vec<Value>>,
|
||||||
|
notification_ids: Mutex<i64>,
|
||||||
|
task_ids: Mutex<i64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
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<CallResponse, String> {
|
||||||
|
// 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::<WatchAdd>(
|
||||||
|
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::<NotificationPush>(
|
||||||
|
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::<TaskAdd>(
|
||||||
|
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::<i64>() 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,
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in new issue
Block a user