From 7afd30bcfd117596804c904883971f016b049d59 Mon Sep 17 00:00:00 2001 From: Mystic Date: Mon, 28 Sep 2026 09:24:14 +0530 Subject: [PATCH] fix(#477): scope bounty aggregates to anonymously-listable repos MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit bounty_stats and agent_bounty_stats ran unfiltered aggregates over all bounties, leaking private-repo bounty activity to anonymous callers. Mirror the stats() pattern from server.rs (#104): 1. Batch-load all deduped repos + visibility rules (2 SQL round-trips) 2. Filter to listable_at_root(rules, is_public, owner_did, None) 3. Pass visible (owner, name) pairs into SQL aggregates via EXISTS/unnest with owner key normalization on both sides (BOUNTY_OWNER_CASE_SQL). 4. Normalize claimant DID in agent_bounty_stats_visible and group by normalized claimant in bounty_leaderboard_visible (BOUNTY_CLAIMANT_CASE_SQL). 5. Remove orphaned unfiltered aggregate methods to pass dead_code lints. 6. In tests, seed completed bounty claimant state via the real claim/approve path, and verify root-rule-decided visibility overrides. Aggregates are intentionally anonymous-scoped for all callers (matching the #104 stats pattern). Fail-closed: DB errors collapse the visible set to empty, so all counts return 0 — an under-count never leaks existence. Fixes #477 --- crates/gitlawb-node/src/api/bounties.rs | 65 +++- crates/gitlawb-node/src/db/mod.rs | 114 +++++- crates/gitlawb-node/src/test_support.rs | 445 ++++++++++++++++++++++++ 3 files changed, 603 insertions(+), 21 deletions(-) diff --git a/crates/gitlawb-node/src/api/bounties.rs b/crates/gitlawb-node/src/api/bounties.rs index 68b98178..776347eb 100644 --- a/crates/gitlawb-node/src/api/bounties.rs +++ b/crates/gitlawb-node/src/api/bounties.rs @@ -456,21 +456,68 @@ pub async fn dispute_bounty( Ok(Json(updated)) } +/// Resolve the set of `(owner_did, name)` pairs for repos an anonymous caller +/// can list. Mirrors the `stats()` pattern in `server.rs` (#104): batch-load +/// all deduped repos, batch-load their visibility rules, keep only those that +/// pass `listable_at_root`. Pure I/O after the two DB round-trips — no +/// per-repo authorization queries. +/// +/// Both bounty stats handlers mount behind `optional_signature` (see +/// `bounty_read_routes` in server.rs), so the caller is always `None` for +/// anonymous access. Every caller, signed or not, currently receives the +/// anonymous-scoped aggregates (matching the #104 pattern); per-caller +/// visibility can be threaded through here when that work lands. +async fn visible_repo_pairs(state: &AppState) -> Vec<(String, String)> { + let result: std::result::Result, anyhow::Error> = async { + let rows = state.db.list_all_repos_deduped().await?; + let ids: Vec = rows.iter().map(|r| r.id.clone()).collect(); + let rules_by_repo = state.db.list_visibility_rules_for_repos(&ids).await?; + let pairs = rows + .iter() + .filter(|r| { + let rules = rules_by_repo.get(&r.id).map(Vec::as_slice).unwrap_or(&[]); + crate::visibility::listable_at_root(rules, r.is_public, &r.owner_did, None) + }) + .map(|r| (crate::db::normalize_owner_key(&r.owner_did).to_string(), r.name.clone())) + .collect(); + Ok(pairs) + } + .await; + // Fail closed: DB error → empty set → all counts collapse to 0, never + // leaking existence of private repos. + result.unwrap_or_default() +} + /// GET /api/v1/bounties/stats +/// +/// Aggregates are restricted to anonymously-listable repos so private-repo +/// bounty activity is not exposed (#477). The visible-repo set is resolved +/// once per request via `visible_repo_pairs` (two SQL round-trips), then +/// passed into the filtered aggregate queries. pub async fn bounty_stats(State(state): State) -> Result> { - let open = state.db.count_bounties_by_status("open").await.unwrap_or(0); + let visible = visible_repo_pairs(&state).await; + + let open = state + .db + .count_bounties_by_status_visible("open", &visible) + .await + .unwrap_or(0); let claimed = state .db - .count_bounties_by_status("claimed") + .count_bounties_by_status_visible("claimed", &visible) .await .unwrap_or(0); let completed = state .db - .count_bounties_by_status("completed") + .count_bounties_by_status_visible("completed", &visible) .await .unwrap_or(0); - let leaders = state.db.bounty_leaderboard(10).await.unwrap_or_default(); + let leaders = state + .db + .bounty_leaderboard_visible(10, &visible) + .await + .unwrap_or_default(); let leaderboard = leaders .into_iter() .map(|(did, cnt, total)| AgentBountyEntry { @@ -489,14 +536,22 @@ pub async fn bounty_stats(State(state): State) -> Result, Path(did): Path, ) -> Result> { - let (count, total) = state.db.agent_bounty_stats(&did).await.unwrap_or((0, 0)); + let visible = visible_repo_pairs(&state).await; + let (count, total) = state + .db + .agent_bounty_stats_visible(&did, &visible) + .await + .unwrap_or((0, 0)); Ok(Json(serde_json::json!({ "did": did, "completed_bounties": count, "total_earned": total, }))) } + diff --git a/crates/gitlawb-node/src/db/mod.rs b/crates/gitlawb-node/src/db/mod.rs index cc2cf0bd..106f58d3 100644 --- a/crates/gitlawb-node/src/db/mod.rs +++ b/crates/gitlawb-node/src/db/mod.rs @@ -1149,6 +1149,16 @@ const OWNER_KEY_CASE_SQL: &str = "CASE WHEN owner_did LIKE 'did:key:%' AND posit /// named `did` (like in agent_profiles) instead of `owner_did`. const PROFILE_DID_CASE_SQL: &str = "CASE WHEN did LIKE 'did:key:%' AND position(':' in substr(did, 9)) = 0 THEN substr(did, 9) ELSE did END"; +/// SQL CASE expression byte-identical to `normalize_owner_key`, but for columns +/// named `repo_owner` (bounties table). The bounties `repo_owner` is stored +/// verbatim from the URL segment, so it may be either the full `did:key:` form +/// or the bare key. +const BOUNTY_OWNER_CASE_SQL: &str = "CASE WHEN repo_owner LIKE 'did:key:%' AND position(':' in substr(repo_owner, 9)) = 0 THEN substr(repo_owner, 9) ELSE repo_owner END"; + +/// SQL CASE expression byte-identical to `normalize_owner_key`, but for columns +/// named `claimant_did` (bounties table). +const BOUNTY_CLAIMANT_CASE_SQL: &str = "CASE WHEN claimant_did LIKE 'did:key:%' AND position(':' in substr(claimant_did, 9)) = 0 THEN substr(claimant_did, 9) ELSE claimant_did END"; + #[cfg(test)] mod normalize_owner_key_tests { use super::normalize_owner_key; @@ -4527,31 +4537,103 @@ impl Db { Ok(()) } - pub async fn count_bounties_by_status(&self, status: &str) -> Result { - let row = sqlx::query("SELECT COUNT(*) as c FROM bounties WHERE status = $1") + // ── Visibility-filtered bounty aggregates (#477) ───────────────────── + + /// Count bounties by status, restricted to repos whose `(owner_did, name)` + /// pairs are in `visible`. Owner keys are normalized on both sides so + /// `did:key:` and bare-key forms match. An empty set returns 0. + pub async fn count_bounties_by_status_visible( + &self, + status: &str, + visible: &[(String, String)], + ) -> Result { + if visible.is_empty() { + return Ok(0); + } + let owners: Vec = visible.iter().map(|(o, _)| o.clone()).collect(); + let names: Vec = visible.iter().map(|(_, n)| n.clone()).collect(); + let sql = format!( + "SELECT COUNT(*) as c FROM bounties b \ + WHERE b.status = $1 \ + AND EXISTS ( \ + SELECT 1 FROM unnest($2::text[], $3::text[]) AS v(o, n) \ + WHERE ({bounty_owner}) = v.o AND b.repo_name = v.n \ + )", + bounty_owner = BOUNTY_OWNER_CASE_SQL, + ); + let row = sqlx::query(&sql) .bind(status) + .bind(&owners) + .bind(&names) .fetch_one(&self.pool) .await?; Ok(row.get::("c")) } - pub async fn agent_bounty_stats(&self, agent_did: &str) -> Result<(i64, i64)> { - let row = sqlx::query( - "SELECT COUNT(*) as cnt, COALESCE(SUM(amount),0) as total FROM bounties WHERE claimant_did = $1 AND status = 'completed'", - ) - .bind(agent_did) - .fetch_one(&self.pool) - .await?; + /// Per-agent bounty stats restricted to visible repos. Both + /// `b.repo_owner` and `b.claimant_did` are normalized so `did:key:` and + /// bare-key forms match. + pub async fn agent_bounty_stats_visible( + &self, + agent_did: &str, + visible: &[(String, String)], + ) -> Result<(i64, i64)> { + if visible.is_empty() { + return Ok((0, 0)); + } + let normalized_agent = normalize_owner_key(agent_did); + let owners: Vec = visible.iter().map(|(o, _)| o.clone()).collect(); + let names: Vec = visible.iter().map(|(_, n)| n.clone()).collect(); + let sql = format!( + "SELECT COUNT(*) as cnt, COALESCE(SUM(amount), 0)::BIGINT as total FROM bounties b \ + WHERE ({claimant_case}) = $1 \ + AND b.status = 'completed' \ + AND EXISTS ( \ + SELECT 1 FROM unnest($2::text[], $3::text[]) AS v(o, n) \ + WHERE ({bounty_owner}) = v.o AND b.repo_name = v.n \ + )", + claimant_case = BOUNTY_CLAIMANT_CASE_SQL, + bounty_owner = BOUNTY_OWNER_CASE_SQL, + ); + let row = sqlx::query(&sql) + .bind(normalized_agent) + .bind(&owners) + .bind(&names) + .fetch_one(&self.pool) + .await?; Ok((row.get::("cnt"), row.get::("total"))) } - pub async fn bounty_leaderboard(&self, limit: i64) -> Result> { - let rows = sqlx::query( - "SELECT claimant_did, COUNT(*) as cnt, COALESCE(SUM(amount),0) as total FROM bounties WHERE status='completed' AND claimant_did IS NOT NULL GROUP BY claimant_did ORDER BY total DESC LIMIT $1", - ) - .bind(limit) - .fetch_all(&self.pool) - .await?; + /// Leaderboard restricted to visible repos. Owner and claimant keys are + /// normalized so `did:key:` and bare-key forms match and group together. + pub async fn bounty_leaderboard_visible( + &self, + limit: i64, + visible: &[(String, String)], + ) -> Result> { + if visible.is_empty() { + return Ok(Vec::new()); + } + let owners: Vec = visible.iter().map(|(o, _)| o.clone()).collect(); + let names: Vec = visible.iter().map(|(_, n)| n.clone()).collect(); + let sql = format!( + "SELECT MIN(claimant_did) as claimant_did, COUNT(*) as cnt, COALESCE(SUM(amount), 0)::BIGINT as total \ + FROM bounties b \ + WHERE b.status = 'completed' AND b.claimant_did IS NOT NULL \ + AND EXISTS ( \ + SELECT 1 FROM unnest($1::text[], $2::text[]) AS v(o, n) \ + WHERE ({bounty_owner}) = v.o AND b.repo_name = v.n \ + ) \ + GROUP BY ({claimant_case}) ORDER BY total DESC LIMIT $3", + claimant_case = BOUNTY_CLAIMANT_CASE_SQL, + bounty_owner = BOUNTY_OWNER_CASE_SQL, + ); + let rows = sqlx::query(&sql) + .bind(&owners) + .bind(&names) + .bind(limit) + .fetch_all(&self.pool) + .await?; Ok(rows .iter() .map(|r| { diff --git a/crates/gitlawb-node/src/test_support.rs b/crates/gitlawb-node/src/test_support.rs index 430c0600..e954c587 100644 --- a/crates/gitlawb-node/src/test_support.rs +++ b/crates/gitlawb-node/src/test_support.rs @@ -14853,6 +14853,451 @@ mod tests { ); } + // ── #477: bounty stats aggregates ignore repo visibility ────────────────── + + #[sqlx::test] + async fn bounty_stats_filters_private_repos_for_anon(pool: PgPool) { + let state = test_state(pool).await; + let owner = "did:key:zSTATSBOUNTYOWNERAAAAAAAAAAAAAAAAAAAA"; + let agent_private = "did:key:zSTATSPRIVATECLAIMANT00000000000"; + let agent_public = "did:key:zSTATSPUBLICCLAIMANT000000000000"; + + // Private repo with 1 open and 1 completed bounty + state + .db + .create_repo(&seed_private_repo(owner, "private-repo")) + .await + .unwrap(); + state + .db + .create_bounty(&crate::db::BountyRecord { + id: "bounty-priv-open".into(), + repo_owner: owner.into(), + repo_name: "private-repo".into(), + issue_id: None, + title: "Private Open".into(), + amount: 100, + creator_did: owner.into(), + claimant_did: None, + claimant_wallet: None, + pr_id: None, + status: "open".into(), + created_at: "2026-01-01T00:00:00Z".into(), + claimed_at: None, + submitted_at: None, + completed_at: None, + deadline_secs: 86400, + tx_hash: None, + }) + .await + .unwrap(); + // Drive the private completed bounty through the real claim/approve path + // so `claimant_did` and `completed_at` are actually persisted (#477 P1). + state + .db + .create_bounty(&crate::db::BountyRecord { + id: "bounty-priv-comp".into(), + repo_owner: owner.into(), + repo_name: "private-repo".into(), + issue_id: None, + title: "Private Completed".into(), + amount: 500, + creator_did: owner.into(), + claimant_did: None, + claimant_wallet: None, + pr_id: None, + status: "open".into(), + created_at: "2026-01-02T00:00:00Z".into(), + claimed_at: None, + submitted_at: None, + completed_at: None, + deadline_secs: 86400, + tx_hash: None, + }) + .await + .unwrap(); + state + .db + .claim_bounty("bounty-priv-comp", agent_private, None, "2026-01-02T01:00:00Z") + .await + .unwrap(); + state + .db + .submit_bounty("bounty-priv-comp", "pr-priv-1", "2026-01-02T02:00:00Z") + .await + .unwrap(); + state + .db + .approve_bounty("bounty-priv-comp", "2026-01-03T00:00:00Z", None) + .await + .unwrap(); + + // Public repo with 1 open and 1 completed bounty + let mut public_repo = seed_private_repo(owner, "public-repo"); + public_repo.is_public = true; + state.db.create_repo(&public_repo).await.unwrap(); + state + .db + .create_bounty(&crate::db::BountyRecord { + id: "bounty-pub-open".into(), + repo_owner: owner.into(), + repo_name: "public-repo".into(), + issue_id: None, + title: "Public Open".into(), + amount: 200, + creator_did: owner.into(), + claimant_did: None, + claimant_wallet: None, + pr_id: None, + status: "open".into(), + created_at: "2026-01-04T00:00:00Z".into(), + claimed_at: None, + submitted_at: None, + completed_at: None, + deadline_secs: 86400, + tx_hash: None, + }) + .await + .unwrap(); + // Drive the public completed bounty through claim→submit→approve. + state + .db + .create_bounty(&crate::db::BountyRecord { + id: "bounty-pub-comp".into(), + repo_owner: owner.into(), + repo_name: "public-repo".into(), + issue_id: None, + title: "Public Completed".into(), + amount: 300, + creator_did: owner.into(), + claimant_did: None, + claimant_wallet: None, + pr_id: None, + status: "open".into(), + created_at: "2026-01-05T00:00:00Z".into(), + claimed_at: None, + submitted_at: None, + completed_at: None, + deadline_secs: 86400, + tx_hash: None, + }) + .await + .unwrap(); + state + .db + .claim_bounty("bounty-pub-comp", agent_public, None, "2026-01-05T01:00:00Z") + .await + .unwrap(); + state + .db + .submit_bounty("bounty-pub-comp", "pr-pub-1", "2026-01-05T02:00:00Z") + .await + .unwrap(); + state + .db + .approve_bounty("bounty-pub-comp", "2026-01-06T00:00:00Z", None) + .await + .unwrap(); + + let router = crate::server::build_router(state); + let resp = router + .oneshot(anon_get("/api/v1/bounties/stats")) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::OK); + let body = json_body(resp).await; + + // Anonymous observer must only see counts from the public repo (#477) + assert_eq!(body["open"], 1, "only 1 public open bounty should be counted"); + assert_eq!( + body["completed"], 1, + "only 1 public completed bounty should be counted" + ); + let leaders = body["leaderboard"].as_array().unwrap(); + assert_eq!(leaders.len(), 1, "leaderboard must exclude private earnings"); + assert_eq!(leaders[0]["did"], agent_public); + assert_eq!(leaders[0]["completed"], 1); + assert_eq!(leaders[0]["total_earned"], 300); + } + + #[sqlx::test] + async fn agent_bounty_stats_filters_private_repos_for_anon(pool: PgPool) { + let state = test_state(pool).await; + let owner = "did:key:zAGNTSTATSBOWNERAAAAAAAAAAAAAAAAAAAA"; + let agent = "did:key:zAGNTSTATSAAGENT000000000000000000"; + + // Private repo where agent earned a bounty + state + .db + .create_repo(&seed_private_repo(owner, "agent-secret-repo")) + .await + .unwrap(); + // Drive through the real claim path so claimant_did persists. + state + .db + .create_bounty(&crate::db::BountyRecord { + id: "bounty-secret-agent".into(), + repo_owner: owner.into(), + repo_name: "agent-secret-repo".into(), + issue_id: None, + title: "Secret Task".into(), + amount: 750, + creator_did: owner.into(), + claimant_did: None, + claimant_wallet: None, + pr_id: None, + status: "open".into(), + created_at: "2026-01-01T00:00:00Z".into(), + claimed_at: None, + submitted_at: None, + completed_at: None, + deadline_secs: 86400, + tx_hash: None, + }) + .await + .unwrap(); + state + .db + .claim_bounty("bounty-secret-agent", agent, None, "2026-01-01T01:00:00Z") + .await + .unwrap(); + state + .db + .submit_bounty("bounty-secret-agent", "pr-secret-1", "2026-01-01T02:00:00Z") + .await + .unwrap(); + state + .db + .approve_bounty("bounty-secret-agent", "2026-01-02T00:00:00Z", None) + .await + .unwrap(); + + // Public repo where the SAME agent also earned a bounty: proves the + // filter admits public earnings rather than always returning zero. + let mut public_repo = seed_private_repo(owner, "agent-public-repo"); + public_repo.is_public = true; + state.db.create_repo(&public_repo).await.unwrap(); + state + .db + .create_bounty(&crate::db::BountyRecord { + id: "bounty-public-agent".into(), + repo_owner: owner.into(), + repo_name: "agent-public-repo".into(), + issue_id: None, + title: "Public Task".into(), + amount: 250, + creator_did: owner.into(), + claimant_did: None, + claimant_wallet: None, + pr_id: None, + status: "open".into(), + created_at: "2026-01-03T00:00:00Z".into(), + claimed_at: None, + submitted_at: None, + completed_at: None, + deadline_secs: 86400, + tx_hash: None, + }) + .await + .unwrap(); + state + .db + .claim_bounty("bounty-public-agent", agent, None, "2026-01-03T01:00:00Z") + .await + .unwrap(); + state + .db + .submit_bounty("bounty-public-agent", "pr-pub-1", "2026-01-03T02:00:00Z") + .await + .unwrap(); + state + .db + .approve_bounty("bounty-public-agent", "2026-01-04T00:00:00Z", None) + .await + .unwrap(); + + let router = crate::server::build_router(state); + let uri = format!("/api/v1/agents/{agent}/bounties"); + let resp = router.oneshot(anon_get(&uri)).await.unwrap(); + assert_eq!(resp.status(), StatusCode::OK); + let body = json_body(resp).await; + + // Anonymous query must not leak private earnings, but must still admit + // the agent's public earnings (not an always-zero over-denial). + assert_eq!( + body["completed_bounties"], 1, + "only the public completed bounty must be counted" + ); + assert_eq!( + body["total_earned"], 250, + "only the public bounty's earnings must be counted, not the private 750" + ); + } + + /// Exercises the root-rule branch of `listable_at_root`: a repo with + /// `is_public = true` plus a root visibility rule (`path_glob = "/"`) + /// whose `reader_dids` does NOT include the anonymous caller. The rule + /// takes precedence over `is_public`, so the repo's bounties must be + /// excluded from anonymous aggregates. + #[sqlx::test] + async fn bounty_stats_root_rule_overrides_is_public(pool: PgPool) { + let state = test_state(pool).await; + let owner = "did:key:zROOTRULEOWNERAAAAAAAAAAAAAAAAAAAAA"; + let insider = "did:key:zROOTRULEINSIDERAAAAAAAAAAAAAAAAA"; + let agent = "did:key:zROOTRULEAGENT0AAAAAAAAAAAAAAAAAAA"; + + // Public repo with a root rule that restricts to `insider` only. + // `is_public = true`, but the root rule makes it anonymous-invisible. + let mut repo = seed_private_repo(owner, "restricted-public"); + repo.is_public = true; + state.db.create_repo(&repo).await.unwrap(); + state + .db + .set_visibility_rule( + &repo.id, + "/", + crate::db::VisibilityMode::A, + &[insider.to_string()], + owner, + ) + .await + .unwrap(); + + // Open bounty on the root-rule-restricted repo. + state + .db + .create_bounty(&crate::db::BountyRecord { + id: "bounty-rootrule-open".into(), + repo_owner: owner.into(), + repo_name: "restricted-public".into(), + issue_id: None, + title: "Root Rule Open".into(), + amount: 400, + creator_did: owner.into(), + claimant_did: None, + claimant_wallet: None, + pr_id: None, + status: "open".into(), + created_at: "2026-02-01T00:00:00Z".into(), + claimed_at: None, + submitted_at: None, + completed_at: None, + deadline_secs: 86400, + tx_hash: None, + }) + .await + .unwrap(); + + // Completed bounty, driven through claim→submit→approve. + state + .db + .create_bounty(&crate::db::BountyRecord { + id: "bounty-rootrule-comp".into(), + repo_owner: owner.into(), + repo_name: "restricted-public".into(), + issue_id: None, + title: "Root Rule Completed".into(), + amount: 600, + creator_did: owner.into(), + claimant_did: None, + claimant_wallet: None, + pr_id: None, + status: "open".into(), + created_at: "2026-02-02T00:00:00Z".into(), + claimed_at: None, + submitted_at: None, + completed_at: None, + deadline_secs: 86400, + tx_hash: None, + }) + .await + .unwrap(); + state + .db + .claim_bounty("bounty-rootrule-comp", agent, None, "2026-02-02T01:00:00Z") + .await + .unwrap(); + state + .db + .submit_bounty("bounty-rootrule-comp", "pr-rootrule-1", "2026-02-02T02:00:00Z") + .await + .unwrap(); + state + .db + .approve_bounty("bounty-rootrule-comp", "2026-02-03T00:00:00Z", None) + .await + .unwrap(); + + // Truly public repo (no root rule) as a control. + let mut control = seed_private_repo(owner, "truly-public"); + control.is_public = true; + state.db.create_repo(&control).await.unwrap(); + state + .db + .create_bounty(&crate::db::BountyRecord { + id: "bounty-control-open".into(), + repo_owner: owner.into(), + repo_name: "truly-public".into(), + issue_id: None, + title: "Control Open".into(), + amount: 100, + creator_did: owner.into(), + claimant_did: None, + claimant_wallet: None, + pr_id: None, + status: "open".into(), + created_at: "2026-02-04T00:00:00Z".into(), + claimed_at: None, + submitted_at: None, + completed_at: None, + deadline_secs: 86400, + tx_hash: None, + }) + .await + .unwrap(); + + let router = crate::server::build_router(state.clone()); + let resp = router + .oneshot(anon_get("/api/v1/bounties/stats")) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::OK); + let body = json_body(resp).await; + + // Anonymous caller must see only the control repo's bounty, not the + // root-rule-restricted repo's bounties — even though is_public = true. + assert_eq!( + body["open"], 1, + "root-rule-restricted repo's open bounty must be excluded" + ); + assert_eq!( + body["completed"], 0, + "root-rule-restricted repo's completed bounty must be excluded" + ); + let leaders = body["leaderboard"].as_array().unwrap(); + assert!( + leaders.is_empty(), + "leaderboard must not show earnings from root-rule-restricted repo" + ); + + // Also verify agent bounty stats for the root-rule-restricted repo's claimant + let router = crate::server::build_router(state); + let resp_agent = router + .oneshot(anon_get(&format!("/api/v1/agents/{agent}/bounties"))) + .await + .unwrap(); + assert_eq!(resp_agent.status(), StatusCode::OK); + let body_agent = json_body(resp_agent).await; + assert_eq!( + body_agent["completed_bounties"], 0, + "root-rule-restricted repo's completed bounty must not count for agent" + ); + assert_eq!( + body_agent["total_earned"], 0, + "root-rule-restricted repo's earnings must not count for agent" + ); + } + // ── Ref-update events (issue #144: owner_did wire format) ───────────────── fn events_router(state: AppState) -> Router {