Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
105 changes: 103 additions & 2 deletions crates/gitlawb-node/src/api/agents.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,10 @@

use axum::extract::{Path, Query, State};
use axum::http::StatusCode;
use axum::Json;
use axum::{Extension, Json};
use serde::{Deserialize, Serialize};

use crate::auth::AuthenticatedDid;
use crate::error::{AppError, Result};
use crate::state::AppState;

Expand All @@ -20,6 +21,14 @@ fn agent_key_segment(did: &str) -> &str {
did.split(':').next_back().unwrap_or(did)
}

/// Whether `caller` (the verified `AuthenticatedDid`) is the same identity as
/// `target` (the DID being acted on), tolerant of full `did:key:...` vs short
/// key-only forms on either side. Used to gate self-deregistration so a DID
/// can only retire itself.
fn caller_matches_did(caller: &str, target: &str) -> bool {
agent_key_segment(caller) == agent_key_segment(target)
}

async fn resolve_agent_did(state: &AppState, did: &str) -> Result<String> {
let normalized_did = normalize_agent_did(did);
if state.db.get_agent(&normalized_did).await?.is_some() {
Expand Down Expand Up @@ -71,6 +80,8 @@ pub struct AgentResponse {
pub capabilities: Vec<String>,
pub registered_at: String,
pub last_seen: Option<String>,
/// Lifecycle status: `active` or `revoked`.
pub status: String,
}

/// GET /api/v1/agents
Expand All @@ -87,6 +98,7 @@ pub async fn list_agents(
capabilities: a.capabilities,
registered_at: a.registered_at,
last_seen: a.last_seen,
status: a.status,
})
.collect();
Ok(Json(serde_json::json!({ "agents": list })))
Expand All @@ -111,6 +123,7 @@ pub async fn show_agent(
capabilities: agent.capabilities,
registered_at: agent.registered_at,
last_seen: agent.last_seen,
status: agent.status,
}),
))
}
Expand All @@ -133,9 +146,97 @@ pub async fn get_trust(
}))
}

/// DELETE /api/v1/agents/{did}
///
/// Self-deregistration: the holder of a DID's key marks their own agent
/// `revoked`, removing it from discovery (issue #29). Authenticated by the
/// rfc9421 signature middleware; a caller may only retire its own DID.
pub async fn deregister_agent(
State(state): State<AppState>,
Extension(auth): Extension<AuthenticatedDid>,
Path(did): Path<String>,
) -> Result<StatusCode> {
// Authorize on the verified identity, and act on it too. The path DID is
// only an intent check (it must name the caller); the row we revoke is the
// authenticated DID itself, never a value derived from the untrusted path.
// Revoking `resolve_agent_did(did)` instead would let the fuzzy prefix
// resolver act on a different identity than the one just authorized.
if !caller_matches_did(&auth.0, &did) {
return Err(AppError::BadRequest(
"an agent can only deregister itself".into(),
));
}

if state.db.revoke_agent(&auth.0).await? {
Ok(StatusCode::NO_CONTENT)
} else {
Err(AppError::NotFound(format!("agent {} not found", auth.0)))
}
}

#[cfg(test)]
mod tests {
use super::{agent_key_segment, normalize_agent_did};
use super::{agent_key_segment, caller_matches_did, normalize_agent_did, AgentResponse};

#[test]
fn caller_matches_own_did_full_form() {
assert!(caller_matches_did(
"did:key:z6MkExample",
"did:key:z6MkExample"
));
}

#[test]
fn caller_matches_own_did_short_form() {
// Authenticated full DID vs short path form, and vice versa.
assert!(caller_matches_did("did:key:z6MkExample", "z6MkExample"));
assert!(caller_matches_did("z6MkExample", "did:key:z6MkExample"));
}

#[test]
fn caller_cannot_revoke_a_different_did() {
// The core authorization property for issue #29.
assert!(!caller_matches_did(
"did:key:z6MkAttacker",
"did:key:z6MkVictim"
));
assert!(!caller_matches_did("did:key:z6MkAttacker", "z6MkVictim"));
}

#[test]
fn agent_response_surfaces_status() {
// A revoked DID's response must carry its status so callers can see it
// is retired (issue #29).
let resp = AgentResponse {
did: "did:key:orphan".to_string(),
trust_score: 0.1,
capabilities: vec!["reputation:score".to_string()],
registered_at: "2026-06-19T00:00:00Z".to_string(),
last_seen: None,
status: "revoked".to_string(),
};

let json = serde_json::to_value(&resp).unwrap();

assert_eq!(json["status"], "revoked");
assert!(json.get("replaced_by").is_none());
}

#[test]
fn agent_response_active_status() {
let resp = AgentResponse {
did: "did:key:active".to_string(),
trust_score: 0.5,
capabilities: vec![],
registered_at: "2026-06-19T00:00:00Z".to_string(),
last_seen: None,
status: "active".to_string(),
};

let json = serde_json::to_value(&resp).unwrap();

assert_eq!(json["status"], "active");
}

#[test]
fn normalize_agent_did_preserves_full_did() {
Expand Down
183 changes: 157 additions & 26 deletions crates/gitlawb-node/src/db/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -221,6 +221,8 @@ pub struct AgentRow {
pub capabilities: Vec<String>,
pub registered_at: String,
pub last_seen: Option<String>,
/// Lifecycle status: `active` (default) or `revoked` (self-deregistered).
pub status: String,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
Expand Down Expand Up @@ -735,6 +737,16 @@ const MIGRATIONS: &[Migration] = &[
"CREATE INDEX IF NOT EXISTS idx_encrypted_blobs_repo ON encrypted_blobs(repo_id)",
],
},
Migration {
version: 5,
name: "agent_retirement",
stmts: &[
// Agent lifecycle status for issue #29. `active` is the default;
// the key holder can self-deregister to `revoked` (terminal).
"ALTER TABLE agents ADD COLUMN IF NOT EXISTS status TEXT NOT NULL DEFAULT 'active'",
"ALTER TABLE agents ADD COLUMN IF NOT EXISTS deactivated_at TEXT",
],
},
];

// ── Repos ─────────────────────────────────────────────────────────────────────
Expand Down Expand Up @@ -941,10 +953,41 @@ impl Db {

// ── Agents / Trust ────────────────────────────────────────────────────────────

/// Map an `agents` row (selected with the status columns) into an `AgentRow`.
fn row_to_agent(r: &sqlx::postgres::PgRow) -> AgentRow {
AgentRow {
did: r.get("did"),
trust_score: r.get("trust_score"),
capabilities: serde_json::from_str(r.get::<&str, _>("capabilities")).unwrap_or_default(),
registered_at: r.get("registered_at"),
last_seen: r.get("last_seen"),
status: r.get("status"),
}
}

/// Reduce a trust-ranked agent list to what discovery should surface: only
/// `active` agents, optionally narrowed to those advertising `capability`.
/// Revoked agents are dropped so an orphaned DID can never win capability
/// routing. Input order is preserved, so an already trust-sorted list stays
/// active-first.
fn filter_discoverable(agents: Vec<AgentRow>, capability: Option<&str>) -> Vec<AgentRow> {
agents
.into_iter()
.filter(|a| a.status == "active")
.filter(|a| match capability {
Some(cap) => a.capabilities.iter().any(|c| c == cap),
None => true,
})
.collect()
}

impl Db {
pub async fn register_agent(&self, did: &str, capabilities: &[String]) -> Result<()> {
let caps = serde_json::to_string(capabilities)?;
let now = Utc::now().to_rfc3339();
// The ON CONFLICT clause deliberately updates only `last_seen` and
// never touches `status`. That makes revocation terminal: re-registering
// a `revoked` DID does not bring it back to `active` (issue #29).
sqlx::query(
"INSERT INTO agents (did, trust_score, capabilities, registered_at)
VALUES ($1, 0.0, $2, $3)
Expand Down Expand Up @@ -1021,46 +1064,46 @@ impl Db {

pub async fn list_agents(&self, capability: Option<&str>) -> Result<Vec<AgentRow>> {
let rows = sqlx::query(
"SELECT did, trust_score, capabilities, registered_at, last_seen FROM agents ORDER BY trust_score DESC",
"SELECT did, trust_score, capabilities, registered_at, last_seen, status \
FROM agents ORDER BY trust_score DESC",
)
.fetch_all(&self.pool)
.await?;

let mut agents: Vec<AgentRow> = rows
.iter()
.map(|r| AgentRow {
did: r.get("did"),
trust_score: r.get("trust_score"),
capabilities: serde_json::from_str(r.get::<&str, _>("capabilities"))
.unwrap_or_default(),
registered_at: r.get("registered_at"),
last_seen: r.get("last_seen"),
})
.collect();

if let Some(cap) = capability {
agents.retain(|a| a.capabilities.iter().any(|c| c == cap));
}
let agents: Vec<AgentRow> = rows.iter().map(row_to_agent).collect();

Ok(agents)
Ok(filter_discoverable(agents, capability))
}

pub async fn get_agent(&self, did: &str) -> Result<Option<AgentRow>> {
let row = sqlx::query(
"SELECT did, trust_score, capabilities, registered_at, last_seen FROM agents WHERE did = $1",
"SELECT did, trust_score, capabilities, registered_at, last_seen, status \
FROM agents WHERE did = $1",
)
.bind(did)
.fetch_optional(&self.pool)
.await?;

Ok(row.map(|r| AgentRow {
did: r.get("did"),
trust_score: r.get("trust_score"),
capabilities: serde_json::from_str(r.get::<&str, _>("capabilities"))
.unwrap_or_default(),
registered_at: r.get("registered_at"),
last_seen: r.get("last_seen"),
}))
// Unfiltered by design: a revoked DID must still resolve so callers
// can read its `status` and see it is retired.
Ok(row.as_ref().map(row_to_agent))
}

/// Mark an agent `revoked` (terminal self-deregistration, issue #29).
/// Returns `false` when no such agent exists so the caller can surface a
/// 404. Revoking an already-revoked agent is idempotent, and a retry keeps
/// the original `deactivated_at` (COALESCE) rather than overwriting it.
pub async fn revoke_agent(&self, did: &str) -> Result<bool> {
let now = Utc::now().to_rfc3339();
let result = sqlx::query(
"UPDATE agents SET status = 'revoked', \
deactivated_at = COALESCE(deactivated_at, $2) WHERE did = $1",
)
.bind(did)
.bind(&now)
.execute(&self.pool)
.await?;
Ok(result.rows_affected() > 0)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

pub async fn count_pushes(&self) -> Result<i64> {
Expand Down Expand Up @@ -2876,3 +2919,91 @@ mod migration_tests {
assert_eq!(MIGRATIONS[0].name, MIGRATION_V1_NAME);
}
}

#[cfg(test)]
mod agent_discovery_tests {
use super::{filter_discoverable, AgentRow};

fn agent(did: &str, trust: f64, status: &str, caps: &[&str]) -> AgentRow {
AgentRow {
did: did.to_string(),
trust_score: trust,
capabilities: caps.iter().map(|c| c.to_string()).collect(),
registered_at: "2026-06-19T00:00:00Z".to_string(),
last_seen: None,
status: status.to_string(),
}
}

fn dids(rows: &[AgentRow]) -> Vec<&str> {
rows.iter().map(|a| a.did.as_str()).collect()
}

#[test]
fn only_active_agents_are_returned() {
let rows = vec![
agent("did:key:active1", 0.5, "active", &["reputation:score"]),
agent("did:key:revoked1", 0.4, "revoked", &["reputation:score"]),
agent("did:key:revoked2", 0.3, "revoked", &["reputation:score"]),
];

let out = filter_discoverable(rows, None);

assert_eq!(dids(&out), vec!["did:key:active1"]);
}

#[test]
fn revoked_orphan_never_wins_capability_routing() {
// Reproduces issue #29: a self-deregistered orphan sharing the
// canonical agent's capability and equal trust must be excluded so the
// active replacement is the only capability match.
let rows = vec![
agent("did:key:orphan", 0.1, "revoked", &["reputation:score"]),
agent("did:key:canonical", 0.1, "active", &["reputation:score"]),
];

let out = filter_discoverable(rows, Some("reputation:score"));

assert_eq!(dids(&out), vec!["did:key:canonical"]);
}

#[test]
fn capability_and_status_filters_compose() {
let rows = vec![
// matches capability but retired -> excluded
agent("did:key:revoked", 0.9, "revoked", &["attestation:verify"]),
// active but wrong capability -> excluded
agent("did:key:other", 0.8, "active", &["oracle:agent-trust"]),
// active and matches -> kept
agent("did:key:match", 0.7, "active", &["attestation:verify"]),
];

let out = filter_discoverable(rows, Some("attestation:verify"));

assert_eq!(dids(&out), vec!["did:key:match"]);
}

#[test]
fn input_order_is_preserved_so_active_stays_trust_ranked() {
// Input arrives pre-sorted by trust desc; filtering must not reorder.
let rows = vec![
agent("did:key:high", 0.9, "active", &[]),
agent("did:key:retired", 0.8, "revoked", &[]),
agent("did:key:mid", 0.5, "active", &[]),
agent("did:key:low", 0.2, "active", &[]),
];

let out = filter_discoverable(rows, None);

assert_eq!(
dids(&out),
vec!["did:key:high", "did:key:mid", "did:key:low"]
);
}

#[test]
fn empty_input_returns_empty() {
assert!(filter_discoverable(vec![], None).is_empty());
assert!(filter_discoverable(vec![], Some("reputation:score")).is_empty());
}
}
4 changes: 4 additions & 0 deletions crates/gitlawb-node/src/server.rs
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,10 @@ pub fn build_router(state: AppState) -> Router {
axum::routing::put(visibility::set_visibility)
.delete(visibility::remove_visibility)
.get(visibility::list_visibility),
)
.route(
"/api/v1/agents/{did}",
axum::routing::delete(agents::deregister_agent),
Comment thread
coderabbitai[bot] marked this conversation as resolved.
),
state.clone(),
);
Expand Down
Loading