Skip to content

Commit 5ff30ff

Browse files
MysticMystic
authored andcommitted
fix(#477): scope bounty aggregates to anonymously-listable repos
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 This avoids the N+1 per-row authorize_repo_read problem (PR #483) while keeping aggregation in SQL for O(1) per status query after the initial repo resolution. Fail-closed: DB errors collapse the visible set to empty, so all counts return 0 — an under-count never leaks existence. Fixes #477
1 parent bfc44f9 commit 5ff30ff

3 files changed

Lines changed: 376 additions & 7 deletions

File tree

‎crates/gitlawb-node/src/api/bounties.rs‎

Lines changed: 59 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -456,21 +456,67 @@ pub async fn dispute_bounty(
456456
Ok(Json(updated))
457457
}
458458

459+
/// Resolve the set of `(owner_did, name)` pairs for repos an anonymous caller
460+
/// can list. Mirrors the `stats()` pattern in `server.rs` (#104): batch-load
461+
/// all deduped repos, batch-load their visibility rules, keep only those that
462+
/// pass `listable_at_root`. Pure I/O after the two DB round-trips — no
463+
/// per-repo authorization queries.
464+
///
465+
/// Both bounty stats handlers mount behind `optional_signature` (see
466+
/// `bounty_read_routes` in server.rs), so the caller is always `None` for
467+
/// anonymous access. The auth extractor is accepted but currently unused;
468+
/// when authenticated-caller support is added the `caller` arg threads through.
469+
async fn visible_repo_pairs(state: &AppState) -> Vec<(String, String)> {
470+
let result: std::result::Result<Vec<(String, String)>, anyhow::Error> = async {
471+
let rows = state.db.list_all_repos_deduped().await?;
472+
let ids: Vec<String> = rows.iter().map(|r| r.id.clone()).collect();
473+
let rules_by_repo = state.db.list_visibility_rules_for_repos(&ids).await?;
474+
let pairs = rows
475+
.iter()
476+
.filter(|r| {
477+
let rules = rules_by_repo.get(&r.id).map(Vec::as_slice).unwrap_or(&[]);
478+
crate::visibility::listable_at_root(rules, r.is_public, &r.owner_did, None)
479+
})
480+
.map(|r| (r.owner_did.clone(), r.name.clone()))
481+
.collect();
482+
Ok(pairs)
483+
}
484+
.await;
485+
// Fail closed: DB error → empty set → all counts collapse to 0, never
486+
// leaking existence of private repos.
487+
result.unwrap_or_default()
488+
}
489+
459490
/// GET /api/v1/bounties/stats
491+
///
492+
/// Aggregates are restricted to anonymously-listable repos so private-repo
493+
/// bounty activity is not exposed (#477). The visible-repo set is resolved
494+
/// once per request via `visible_repo_pairs` (two SQL round-trips), then
495+
/// passed into the filtered aggregate queries.
460496
pub async fn bounty_stats(State(state): State<AppState>) -> Result<Json<BountyStatsResponse>> {
461-
let open = state.db.count_bounties_by_status("open").await.unwrap_or(0);
497+
let visible = visible_repo_pairs(&state).await;
498+
499+
let open = state
500+
.db
501+
.count_bounties_by_status_visible("open", &visible)
502+
.await
503+
.unwrap_or(0);
462504
let claimed = state
463505
.db
464-
.count_bounties_by_status("claimed")
506+
.count_bounties_by_status_visible("claimed", &visible)
465507
.await
466508
.unwrap_or(0);
467509
let completed = state
468510
.db
469-
.count_bounties_by_status("completed")
511+
.count_bounties_by_status_visible("completed", &visible)
470512
.await
471513
.unwrap_or(0);
472514

473-
let leaders = state.db.bounty_leaderboard(10).await.unwrap_or_default();
515+
let leaders = state
516+
.db
517+
.bounty_leaderboard_visible(10, &visible)
518+
.await
519+
.unwrap_or_default();
474520
let leaderboard = leaders
475521
.into_iter()
476522
.map(|(did, cnt, total)| AgentBountyEntry {
@@ -489,14 +535,22 @@ pub async fn bounty_stats(State(state): State<AppState>) -> Result<Json<BountySt
489535
}
490536

491537
/// GET /api/v1/agents/{did}/bounties
538+
///
539+
/// Per-agent earnings restricted to anonymously-listable repos (#477).
492540
pub async fn agent_bounty_stats(
493541
State(state): State<AppState>,
494542
Path(did): Path<String>,
495543
) -> Result<Json<serde_json::Value>> {
496-
let (count, total) = state.db.agent_bounty_stats(&did).await.unwrap_or((0, 0));
544+
let visible = visible_repo_pairs(&state).await;
545+
let (count, total) = state
546+
.db
547+
.agent_bounty_stats_visible(&did, &visible)
548+
.await
549+
.unwrap_or((0, 0));
497550
Ok(Json(serde_json::json!({
498551
"did": did,
499552
"completed_bounties": count,
500553
"total_earned": total,
501554
})))
502555
}
556+

‎crates/gitlawb-node/src/db/mod.rs‎

Lines changed: 100 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4527,6 +4527,7 @@ impl Db {
45274527
Ok(())
45284528
}
45294529

4530+
/// Count total bounties matching a given status across all repositories.
45304531
pub async fn count_bounties_by_status(&self, status: &str) -> Result<i64> {
45314532
let row = sqlx::query("SELECT COUNT(*) as c FROM bounties WHERE status = $1")
45324533
.bind(status)
@@ -4535,19 +4536,21 @@ impl Db {
45354536
Ok(row.get::<i64, _>("c"))
45364537
}
45374538

4539+
/// Calculate total completed bounties count and aggregate earnings for a specific agent.
45384540
pub async fn agent_bounty_stats(&self, agent_did: &str) -> Result<(i64, i64)> {
45394541
let row = sqlx::query(
4540-
"SELECT COUNT(*) as cnt, COALESCE(SUM(amount),0) as total FROM bounties WHERE claimant_did = $1 AND status = 'completed'",
4542+
"SELECT COUNT(*) as cnt, COALESCE(SUM(amount), 0)::BIGINT as total FROM bounties WHERE claimant_did = $1 AND status = 'completed'",
45414543
)
45424544
.bind(agent_did)
45434545
.fetch_one(&self.pool)
45444546
.await?;
45454547
Ok((row.get::<i64, _>("cnt"), row.get::<i64, _>("total")))
45464548
}
45474549

4550+
/// Retrieve the top bounty earners across the node ordered by total earnings descending.
45484551
pub async fn bounty_leaderboard(&self, limit: i64) -> Result<Vec<(String, i64, i64)>> {
45494552
let rows = sqlx::query(
4550-
"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",
4553+
"SELECT claimant_did, COUNT(*) as cnt, COALESCE(SUM(amount), 0)::BIGINT as total FROM bounties WHERE status='completed' AND claimant_did IS NOT NULL GROUP BY claimant_did ORDER BY total DESC LIMIT $1",
45514554
)
45524555
.bind(limit)
45534556
.fetch_all(&self.pool)
@@ -4564,6 +4567,101 @@ impl Db {
45644567
.collect())
45654568
}
45664569

4570+
// ── Visibility-filtered bounty aggregates (#477) ─────────────────────
4571+
4572+
/// Count bounties by status, restricted to repos whose `(owner_did, name)`
4573+
/// pairs are in `visible`. An empty set returns 0.
4574+
pub async fn count_bounties_by_status_visible(
4575+
&self,
4576+
status: &str,
4577+
visible: &[(String, String)],
4578+
) -> Result<i64> {
4579+
if visible.is_empty() {
4580+
return Ok(0);
4581+
}
4582+
let owners: Vec<String> = visible.iter().map(|(o, _)| o.clone()).collect();
4583+
let names: Vec<String> = visible.iter().map(|(_, n)| n.clone()).collect();
4584+
let row = sqlx::query(
4585+
"SELECT COUNT(*) as c FROM bounties b \
4586+
WHERE b.status = $1 \
4587+
AND EXISTS ( \
4588+
SELECT 1 FROM unnest($2::text[], $3::text[]) AS v(o, n) \
4589+
WHERE b.repo_owner = v.o AND b.repo_name = v.n \
4590+
)",
4591+
)
4592+
.bind(status)
4593+
.bind(&owners)
4594+
.bind(&names)
4595+
.fetch_one(&self.pool)
4596+
.await?;
4597+
Ok(row.get::<i64, _>("c"))
4598+
}
4599+
4600+
/// Per-agent bounty stats restricted to visible repos.
4601+
pub async fn agent_bounty_stats_visible(
4602+
&self,
4603+
agent_did: &str,
4604+
visible: &[(String, String)],
4605+
) -> Result<(i64, i64)> {
4606+
if visible.is_empty() {
4607+
return Ok((0, 0));
4608+
}
4609+
let owners: Vec<String> = visible.iter().map(|(o, _)| o.clone()).collect();
4610+
let names: Vec<String> = visible.iter().map(|(_, n)| n.clone()).collect();
4611+
let row = sqlx::query(
4612+
"SELECT COUNT(*) as cnt, COALESCE(SUM(amount), 0)::BIGINT as total FROM bounties b \
4613+
WHERE b.claimant_did = $1 AND b.status = 'completed' \
4614+
AND EXISTS ( \
4615+
SELECT 1 FROM unnest($2::text[], $3::text[]) AS v(o, n) \
4616+
WHERE b.repo_owner = v.o AND b.repo_name = v.n \
4617+
)",
4618+
)
4619+
.bind(agent_did)
4620+
.bind(&owners)
4621+
.bind(&names)
4622+
.fetch_one(&self.pool)
4623+
.await?;
4624+
Ok((row.get::<i64, _>("cnt"), row.get::<i64, _>("total")))
4625+
}
4626+
4627+
/// Leaderboard restricted to visible repos.
4628+
pub async fn bounty_leaderboard_visible(
4629+
&self,
4630+
limit: i64,
4631+
visible: &[(String, String)],
4632+
) -> Result<Vec<(String, i64, i64)>> {
4633+
if visible.is_empty() {
4634+
return Ok(Vec::new());
4635+
}
4636+
let owners: Vec<String> = visible.iter().map(|(o, _)| o.clone()).collect();
4637+
let names: Vec<String> = visible.iter().map(|(_, n)| n.clone()).collect();
4638+
let rows = sqlx::query(
4639+
"SELECT claimant_did, COUNT(*) as cnt, COALESCE(SUM(amount), 0)::BIGINT as total \
4640+
FROM bounties b \
4641+
WHERE b.status = 'completed' AND b.claimant_did IS NOT NULL \
4642+
AND EXISTS ( \
4643+
SELECT 1 FROM unnest($1::text[], $2::text[]) AS v(o, n) \
4644+
WHERE b.repo_owner = v.o AND b.repo_name = v.n \
4645+
) \
4646+
GROUP BY claimant_did ORDER BY total DESC LIMIT $3",
4647+
)
4648+
.bind(&owners)
4649+
.bind(&names)
4650+
.bind(limit)
4651+
.fetch_all(&self.pool)
4652+
.await?;
4653+
Ok(rows
4654+
.iter()
4655+
.map(|r| {
4656+
(
4657+
r.get::<String, _>("claimant_did"),
4658+
r.get::<i64, _>("cnt"),
4659+
r.get::<i64, _>("total"),
4660+
)
4661+
})
4662+
.collect())
4663+
}
4664+
45674665
fn bounty_from_row(&self, r: &sqlx::postgres::PgRow) -> BountyRecord {
45684666
BountyRecord {
45694667
id: r.get("id"),

0 commit comments

Comments
 (0)