diff --git a/Cargo.lock b/Cargo.lock index d12daa43..24beb9ea 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3300,7 +3300,7 @@ dependencies = [ [[package]] name = "git-remote-gitlawb" -version = "0.5.0" +version = "0.5.1" dependencies = [ "anyhow", "gitlawb-core", @@ -3312,7 +3312,7 @@ dependencies = [ [[package]] name = "gitlawb-attest" -version = "0.5.0" +version = "0.5.1" dependencies = [ "base64", "ed25519-dalek", @@ -3329,7 +3329,7 @@ dependencies = [ [[package]] name = "gitlawb-core" -version = "0.5.0" +version = "0.5.1" dependencies = [ "anyhow", "base64", @@ -3356,7 +3356,7 @@ dependencies = [ [[package]] name = "gitlawb-node" -version = "0.5.0" +version = "0.5.1" dependencies = [ "alloy", "anyhow", @@ -3412,7 +3412,7 @@ dependencies = [ [[package]] name = "gl" -version = "0.5.0" +version = "0.5.1" dependencies = [ "alloy", "anyhow", @@ -3824,6 +3824,7 @@ dependencies = [ "reqwest", "serde", "serde_json", + "sha2", "thiserror 2.0.18", "tracing", ] diff --git a/crates/gitlawb-node/src/api/events.rs b/crates/gitlawb-node/src/api/events.rs index 99cc2eeb..875e6261 100644 --- a/crates/gitlawb-node/src/api/events.rs +++ b/crates/gitlawb-node/src/api/events.rs @@ -138,9 +138,33 @@ pub async fn list_ref_updates( let caller = auth.as_ref().map(|e| e.0 .0.as_str()); let updates = collect_visible_ref_updates(&state.db, None, limit, caller).await?; + // Resolve the trusted display owner_did per row. The stored wire value is + // untrusted (arrives over gossip / unsigned peer notify), so it is echoed + // only when it matches the canonical owner of the local repo the slug + // names (#P1); legacy None rows are attributed via an exact unique local + // match (#P3). Both surfaces share this resolver so they cannot drift. + // The batch resolver issues at most one query per distinct local repo + // rather than one per event row (#P2). + let raw_dids: Vec> = state + .db + .resolve_ref_update_owner_dids( + &updates + .iter() + .map(|u| (u.repo.as_str(), u.owner_did.as_deref())) + .collect::>(), + ) + .await?; + + let owner_dids: Vec = raw_dids + .into_iter() + .map(|o| o.map_or(serde_json::Value::Null, |s| serde_json::json!(s))) + .collect(); + let events: Vec = updates .iter() - .map(|u| { + .enumerate() + .map(|(i, u)| { + let owner_did = owner_dids[i].clone(); serde_json::json!({ "id": u.id, "node_did": u.node_did, @@ -153,6 +177,7 @@ pub async fn list_ref_updates( "cert_id": u.cert_id, "received_at": u.received_at, "from_peer": u.from_peer, + "owner_did": owner_did, }) }) .collect(); @@ -225,6 +250,7 @@ pub async fn list_repo_events( "pusher_did": c.pusher_did, "node_did": c.node_did, "timestamp": c.issued_at, + "owner_did": record.owner_did, "source": "local", }) }) @@ -240,28 +266,46 @@ pub async fn list_repo_events( // collect_visible_ref_updates drops any row whose slug matches a local repo the // caller cannot read (fail-closed), and propagates DB errors instead of swallowing // them. - let gossip_events: Vec = - collect_visible_ref_updates(&state.db, Some(&repo_id_str), limit, caller) - .await? - .iter() - .map(|u| { - serde_json::json!({ - "type": "gossipsub", - "id": u.id, - "repo": u.repo, - "ref_name": u.ref_name, - "old_sha": u.old_sha, - "new_sha": u.new_sha, - "pusher_did": u.pusher_did, - "node_did": u.node_did, - "timestamp": u.timestamp, - "cert_id": u.cert_id, - "received_at": u.received_at, - "from_peer": u.from_peer, - "source": "gossipsub", - }) + let gossip_updates = + collect_visible_ref_updates(&state.db, Some(&repo_id_str), limit, caller).await?; + let gossip_raw: Vec> = state + .db + .resolve_ref_update_owner_dids( + &gossip_updates + .iter() + .map(|u| (u.repo.as_str(), u.owner_did.as_deref())) + .collect::>(), + ) + .await?; + + let gossip_owner_dids: Vec = gossip_raw + .into_iter() + .map(|o| o.map_or(serde_json::Value::Null, |s| serde_json::json!(s))) + .collect(); + + let gossip_events: Vec = gossip_updates + .iter() + .enumerate() + .map(|(i, u)| { + let owner_did = gossip_owner_dids[i].clone(); + serde_json::json!({ + "type": "gossipsub", + "id": u.id, + "repo": u.repo, + "ref_name": u.ref_name, + "old_sha": u.old_sha, + "new_sha": u.new_sha, + "pusher_did": u.pusher_did, + "node_did": u.node_did, + "timestamp": u.timestamp, + "cert_id": u.cert_id, + "received_at": u.received_at, + "from_peer": u.from_peer, + "owner_did": owner_did, + "source": "gossipsub", }) - .collect(); + }) + .collect(); // Merge both lists let mut all_events: Vec = cert_events; @@ -326,6 +370,7 @@ mod ref_updates_feed_tests { cert_id: None, received_at: Utc::now().to_rfc3339(), from_peer: "peer1".into(), + owner_did: None, } } @@ -1105,7 +1150,14 @@ mod ref_updates_feed_tests { .await .unwrap(); assert_eq!(resp.status(), StatusCode::OK); - assert_eq!(count(&body_json(resp).await), 2); + let body = body_json(resp).await; + assert_eq!(count(&body), 2); + for event in body["events"].as_array().unwrap() { + assert_eq!( + event["owner_did"], OWNER, + "each event must carry the local owner_did" + ); + } } // Anon reads a quarantined mirror → 404 (withheld without disclosing existence diff --git a/crates/gitlawb-node/src/api/peers.rs b/crates/gitlawb-node/src/api/peers.rs index a125c313..5c535f0c 100644 --- a/crates/gitlawb-node/src/api/peers.rs +++ b/crates/gitlawb-node/src/api/peers.rs @@ -347,6 +347,10 @@ pub struct NotifyRequest { pub timestamp: Option, #[serde(default)] pub cert_id: Option, + /// Full owner DID — added in #144 for DID-aware feed gating. + /// Optional for backward compat with older senders. + #[serde(default)] + pub owner_did: Option, } pub async fn notify_sync( @@ -391,6 +395,7 @@ pub async fn notify_sync( node_did: req.node_did.clone(), pusher_did: req.pusher_did.clone().unwrap_or_default(), repo: req.repo.clone(), + owner_did: req.owner_did.clone(), ref_name: req.ref_name.clone(), old_sha: req.old_sha.clone().unwrap_or_default(), new_sha: req.new_sha.clone(), diff --git a/crates/gitlawb-node/src/api/repos.rs b/crates/gitlawb-node/src/api/repos.rs index b9cbc352..b38b177b 100644 --- a/crates/gitlawb-node/src/api/repos.rs +++ b/crates/gitlawb-node/src/api/repos.rs @@ -772,6 +772,7 @@ async fn notify_peer_of_ref( new_sha: &str, node_did: &str, pusher_did: &str, + owner_did: &str, ) { let body = serde_json::json!({ "repo": repo_slug, @@ -781,6 +782,7 @@ async fn notify_peer_of_ref( "pusher_did": pusher_did, "old_sha": old_sha, "timestamp": chrono::Utc::now().to_rfc3339(), + "owner_did": owner_did, }); let body_bytes = match serde_json::to_vec(&body) { Ok(bytes) => bytes, @@ -828,6 +830,7 @@ async fn notify_peer_of_refs( ref_updates: &[(String, String, String)], node_did: &str, pusher_did: &str, + owner_did: &str, ) { for (ref_name, old_sha, new_sha) in ref_updates { notify_peer_of_ref( @@ -841,6 +844,7 @@ async fn notify_peer_of_refs( new_sha, node_did, pusher_did, + owner_did, ) .await; } @@ -1261,6 +1265,7 @@ pub async fn git_receive_pack( node_did: node_did_str.clone(), pusher_did: pusher_did_clone.clone(), repo: repo_slug.clone(), + owner_did: Some(record.owner_did.clone()), ref_name: ref_name.clone(), old_sha: old_sha.clone(), new_sha: new_sha.clone(), @@ -1292,6 +1297,7 @@ pub async fn git_receive_pack( pusher_did: pusher_did_clone.clone(), node_did: node_did_str.clone(), timestamp: now_ts.clone(), + owner_did: record.owner_did.clone(), }); } } @@ -1362,6 +1368,7 @@ pub async fn git_receive_pack( &ref_updates_clone, &node_did_str, &pusher_did_clone, + &record.owner_did, ) .await; } @@ -2402,6 +2409,9 @@ mod tests { mockito::Matcher::PartialJsonString(format!(r#"{{"ref_name":"{ref_a}"}}"#)), mockito::Matcher::PartialJsonString(format!(r#"{{"old_sha":"{old_a}"}}"#)), mockito::Matcher::PartialJsonString(format!(r#"{{"new_sha":"{new_a}"}}"#)), + mockito::Matcher::PartialJsonString( + r#"{"owner_did":"did:key:zOwner"}"#.to_string(), + ), ])) .with_status(200) .expect(1) @@ -2413,6 +2423,9 @@ mod tests { mockito::Matcher::PartialJsonString(format!(r#"{{"ref_name":"{ref_b}"}}"#)), mockito::Matcher::PartialJsonString(format!(r#"{{"old_sha":"{old_b}"}}"#)), mockito::Matcher::PartialJsonString(format!(r#"{{"new_sha":"{new_b}"}}"#)), + mockito::Matcher::PartialJsonString( + r#"{"owner_did":"did:key:zOwner"}"#.to_string(), + ), ])) .with_status(200) .expect(1) @@ -2434,6 +2447,7 @@ mod tests { &ref_updates, "did:key:zNode", "did:key:zPusher", + "did:key:zOwner", ) .await; @@ -2456,6 +2470,9 @@ mod tests { .match_body(mockito::Matcher::AllOf(vec![ mockito::Matcher::PartialJsonString(format!(r#"{{"old_sha":"{zero}"}}"#)), mockito::Matcher::PartialJsonString(format!(r#"{{"new_sha":"{new_sha}"}}"#)), + mockito::Matcher::PartialJsonString( + r#"{"owner_did":"did:key:zOwner"}"#.to_string(), + ), ])) .with_status(200) .expect(1) @@ -2478,6 +2495,7 @@ mod tests { &ref_updates, "did:key:zNode", "did:key:zPusher", + "did:key:zOwner", ) .await; diff --git a/crates/gitlawb-node/src/db/mod.rs b/crates/gitlawb-node/src/db/mod.rs index 5324a4b9..33f5bd67 100644 --- a/crates/gitlawb-node/src/db/mod.rs +++ b/crates/gitlawb-node/src/db/mod.rs @@ -175,6 +175,9 @@ pub struct ReceivedRefUpdate { pub cert_id: Option, pub received_at: String, pub from_peer: String, + /// Full owner DID — populated by new peers; None for events from older + /// peers that predate the wire-format change (#144). + pub owner_did: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -872,6 +875,14 @@ const MIGRATIONS: &[Migration] = &[ "CREATE UNIQUE INDEX IF NOT EXISTS idx_ref_certs_repo_ref ON ref_certificates(repo_id, ref_name)", ], }, + Migration { + version: 11, + name: "ref_update_owner_did", + stmts: &[ + // Index deferred — the feed gate (#144) does not read owner_did yet. + "ALTER TABLE received_ref_updates ADD COLUMN IF NOT EXISTS owner_did TEXT", + ], + }, ]; // ── Repos ───────────────────────────────────────────────────────────────────── @@ -2304,8 +2315,8 @@ impl Db { sqlx::query( "INSERT INTO received_ref_updates (id, node_did, pusher_did, repo, ref_name, old_sha, new_sha, timestamp, - cert_id, received_at, from_peer) - VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11) + cert_id, received_at, from_peer, owner_did) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12) ON CONFLICT(id) DO NOTHING", ) .bind(&update.id) @@ -2319,11 +2330,138 @@ impl Db { .bind(&update.cert_id) .bind(&update.received_at) .bind(&update.from_peer) + .bind(&update.owner_did) .execute(&self.pool) .await?; Ok(()) } + /// Resolve the trusted display `owner_did` for every ref-update row in a page, + /// issuing at most one query per *unique* local repo so the cost scales with + /// the number of distinct repos in the page rather than the page size. + /// + /// The stored `owner_did` (and the `repo` slug) arrive over gossipsub or the + /// unsigned peer-notify HTTP path, so neither is trusted. This method binds + /// the wire `owner_did` to the local repo the slug names before it is ever + /// surfaced: + /// + /// * **P1 (untrusted wire value):** a peer-supplied `owner_did` is only + /// echoed when it normalizes equal to the canonical owner of the *actual + /// local repo* at that slug. A caller asserting `did:key:zVictim` on a + /// `zAlice/widget` row is dropped, because `zVictim` does not own the + /// local `zAlice/widget` repo. The canonical DID is returned (not the raw + /// wire bytes), so the projection is always the local source of truth. + /// * **P3 (legacy fallback):** a row stored with `owner_did = None` is + /// attributed only via an *exact, normalized, unique* local match — + /// `get_repo` matches the slug's owner key and name exactly (`LIMIT 1`, + /// preferring canonical rows). The loose prefix-tolerant drop gate + /// (`ref_update_row_names_repo`) is never used for attribution, so a + /// cross-method slug collision cannot synthesize the wrong owner. When no + /// unique local repo proves the owner, `None` is returned. + /// + /// # P2 mirror-only fallback + /// + /// Mirror-only repos store their owner as the bare normalized key (no DID + /// method prefix). When a validated wire DID carries a full prefix (e.g. + /// `did:key:z…`) and the matching repo is a mirror, the full wire value is + /// returned so the API contract preserves the DID method for these rows. + /// + /// The slug must be `"{owner}/{name}"`; a slug without a `/` cannot be + /// attributed and yields `None`. + pub async fn resolve_ref_update_owner_dids( + &self, + rows: &[(&str, Option<&str>)], + ) -> Result>> { + if rows.is_empty() { + return Ok(Vec::new()); + } + + // ── 1. Collect unique lookup keys ──────────────────────────────── + let mut slug_parts: Vec> = Vec::with_capacity(rows.len()); + // Keys are stored as `format!("{normalized_key}\0{name}")` for cheap + // HashMap lookup. + let mut unique_keys: Vec = Vec::new(); + + for (slug, _wire) in rows { + if let Some((owner_part, name)) = slug.rsplit_once('/') { + let normalized = normalize_owner_key(owner_part); + let key = format!("{normalized}\0{name}"); + if !unique_keys.contains(&key) { + unique_keys.push(key.clone()); + } + slug_parts.push(Some(key)); + } else { + slug_parts.push(None); + } + } + + // ── 2. Fetch all matching repos in one set-based query ────────── + // Build a single SQL with one OR condition per unique key so every + // distinct slug is resolved in one round-trip regardless of how many + // unique repos the page names. + let mut repo_map: std::collections::HashMap = + std::collections::HashMap::new(); + + if !unique_keys.is_empty() { + let mut sql = String::from( + "SELECT id, name, owner_did, description, is_public, default_branch, + created_at, updated_at, disk_path, forked_from, machine_id + FROM repos WHERE (", + ); + let mut conds: Vec = Vec::with_capacity(unique_keys.len()); + for i in 0..unique_keys.len() { + let p = (2 * i + 1) as i64; + let q = (2 * i + 2) as i64; + conds.push(format!("({}) = ${p} AND name = ${q}", OWNER_KEY_CASE_SQL)); + } + sql.push_str(&conds.join(" OR ")); + sql.push_str( + ") ORDER BY CASE WHEN position('/' in id) > 0 THEN 1 ELSE 0 END, \ + created_at ASC, id ASC", + ); + + let mut q = sqlx::query(&sql); + for key in &unique_keys { + if let Some((owner_part, name)) = key.split_once('\0') { + q = q.bind(owner_part).bind(name); + } + } + + for row in q.fetch_all(&self.pool).await? { + let repo = row_to_repo(row); + let key = format!("{}\0{}", normalize_owner_key(&repo.owner_did), repo.name); + repo_map.entry(key).or_insert(repo); + } + } + + // ── 3. Resolve every input row ────────────────────────────────── + let mut results = Vec::with_capacity(rows.len()); + for (i, (_slug, wire_owner_did)) in rows.iter().enumerate() { + let Some(repo) = slug_parts[i].as_ref().and_then(|k| repo_map.get(k)) else { + results.push(None); + continue; + }; + + match (wire_owner_did, repo) { + (Some(wire), repo) + if normalize_owner_key(wire) == normalize_owner_key(&repo.owner_did) => + { + if repo.id.contains('/') && *wire != repo.owner_did { + results.push(Some((*wire).to_string())); + } else { + results.push(Some(repo.owner_did.clone())); + } + } + (None, _) => { + results.push(Some(repo.owner_did.clone())); + } + _ => results.push(None), + } + } + + Ok(results) + } + /// One page of ref updates (newest first), optionally scoped to one repo. /// The `(timestamp DESC, id DESC)` order gives a stable tiebreak so offset /// paging does not skip or duplicate rows when timestamps collide. Used by @@ -2348,7 +2486,7 @@ impl Db { after: Option<(&str, &str)>, ) -> Result> { const COLS: &str = "id, node_did, pusher_did, repo, ref_name, old_sha, new_sha, \ - timestamp, cert_id, received_at, from_peer"; + timestamp, cert_id, received_at, from_peer, owner_did"; // Positional params in bind order: repo?, after_ts?, after_id?, limit. let mut conds: Vec = Vec::new(); @@ -2686,6 +2824,7 @@ fn row_to_ref_update(r: sqlx::postgres::PgRow) -> ReceivedRefUpdate { cert_id: r.get("cert_id"), received_at: r.get("received_at"), from_peer: r.get("from_peer"), + owner_did: r.get("owner_did"), } } @@ -3406,6 +3545,115 @@ mod migration_tests { // it, you must also update the backfill. assert_eq!(MIGRATIONS[0].name, MIGRATION_V1_NAME); } + + /// Simulate an existing node at v9 with populated received_ref_updates, + /// then apply the v11 migration and verify (a) owner_did IS NULL on + /// existing rows, (b) the column exists and is nullable TEXT, and + /// (c) idempotent re-run does not error. + #[sqlx::test] + async fn migration_v11_creates_owner_did_column(pool: sqlx::PgPool) { + let db = super::Db::for_testing(pool); + + // Create all tables by running the full migration chain from scratch, + // then drop the owner_did column to simulate a pre-v10 schema. + db.migrate().await.unwrap(); + sqlx::query("ALTER TABLE received_ref_updates DROP COLUMN owner_did") + .execute(&db.pool) + .await + .unwrap(); + + // Truncate schema_migrations and re-seed at v9 — simulate an existing + // node that has run v1..v9 but not yet v10. + sqlx::query("DELETE FROM schema_migrations") + .execute(&db.pool) + .await + .unwrap(); + for m in MIGRATIONS.iter().take_while(|m| m.version < 10) { + sqlx::query( + "INSERT INTO schema_migrations (version, name, applied_at) + VALUES ($1, $2, $3)", + ) + .bind(m.version) + .bind(m.name) + .bind("2026-07-01T00:00:00Z") + .execute(&db.pool) + .await + .unwrap(); + } + + // ── Simulate an existing node with rows recorded before v11 ──────── + // The owner_did column does not exist yet, so we INSERT without it. + let row_id = uuid::Uuid::new_v4().to_string(); + sqlx::query( + "INSERT INTO received_ref_updates + (id, node_did, pusher_did, repo, ref_name, old_sha, new_sha, + timestamp, cert_id, received_at, from_peer) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11)", + ) + .bind(&row_id) + .bind("did:key:zNode") + .bind("did:key:zPusher") + .bind("z6MkOwner/myrepo") + .bind("refs/heads/main") + .bind("0000000000000000000000000000000000000000") + .bind("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa") + .bind("2026-07-01T12:00:00Z") + .bind::>(None) + .bind("2026-07-01T12:00:01Z") + .bind("12D3KooWPeer") + .execute(&db.pool) + .await + .unwrap(); + assert_eq!( + sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM received_ref_updates") + .fetch_one(&db.pool) + .await + .unwrap(), + 1, + "pre-migration row must exist" + ); + + // ── Apply pending migrations (v10 ref_cert_unique_per_ref, v11 owner_did) ── + db.migrate().await.unwrap(); + + // ── Assertions ──────────────────────────────────────────────────── + + // (a) Existing row has owner_did IS NULL (not overwritten). + let owner: Option = + sqlx::query_scalar("SELECT owner_did FROM received_ref_updates WHERE id = $1") + .bind(&row_id) + .fetch_one(&db.pool) + .await + .unwrap(); + assert_eq!(owner, None, "existing row's owner_did must be NULL"); + + // (b) Column exists and is nullable TEXT. + let col: (String, String, String) = sqlx::query_as( + "SELECT column_name, data_type, is_nullable + FROM information_schema.columns + WHERE table_name = 'received_ref_updates' AND column_name = 'owner_did'", + ) + .fetch_one(&db.pool) + .await + .unwrap(); + assert_eq!(col.0, "owner_did"); + assert_eq!(col.1, "text"); + assert_eq!(col.2, "YES", "owner_did must be nullable"); + + // (c) Version 11 is recorded as applied. + let v11_count: (i64,) = + sqlx::query_as("SELECT COUNT(*) FROM schema_migrations WHERE version = 11") + .fetch_one(&db.pool) + .await + .unwrap(); + assert_eq!( + v11_count.0, 1, + "migration v11 must be recorded in schema_migrations" + ); + + // (d) Re-run: idempotent — ADD COLUMN IF NOT EXISTS must not error. + db.migrate().await.unwrap(); + } } #[cfg(test)] @@ -4454,6 +4702,7 @@ mod ref_update_keyset_paging_tests { cert_id: None, received_at: ts.into(), from_peer: "peer".into(), + owner_did: None, } } @@ -4562,6 +4811,7 @@ mod ref_update_keyset_repo_filtered_tests { cert_id: None, received_at: ts.into(), from_peer: "peer".into(), + owner_did: None, } } @@ -4668,6 +4918,7 @@ mod ref_update_keyset_same_timestamp_tests { cert_id: None, received_at: ts.into(), from_peer: "peer".into(), + owner_did: None, } } @@ -5263,3 +5514,170 @@ mod ref_certificate_tests { ); } } +#[cfg(test)] +mod ref_update_db_tests { + use super::{Db, ReceivedRefUpdate}; + use sqlx::PgPool; + + async fn db(pool: PgPool) -> Db { + let db = Db::for_testing(pool); + db.run_migrations().await.unwrap(); + db + } + + fn update( + id: &str, + repo: &str, + owner_did: Option<&str>, + ref_name: &str, + sha: &str, + ) -> ReceivedRefUpdate { + ReceivedRefUpdate { + id: id.to_string(), + node_did: "did:key:zNode".into(), + pusher_did: "did:key:zPusher".into(), + repo: repo.to_string(), + owner_did: owner_did.map(|s| s.to_string()), + ref_name: ref_name.to_string(), + old_sha: "0000000000000000000000000000000000000000".into(), + new_sha: sha.to_string(), + timestamp: "2026-07-02T12:00:00Z".into(), + cert_id: None, + received_at: "2026-07-02T12:00:01Z".into(), + from_peer: "12D3KooWTest".into(), + } + } + + #[sqlx::test] + async fn insert_and_list_with_owner_did(pool: PgPool) { + let db = db(pool).await; + db.insert_ref_update(&update( + "u1", + "zOwner/myrepo", + Some("did:key:zOwner"), + "refs/heads/main", + "aaaa", + )) + .await + .unwrap(); + + let all = db.list_ref_updates_keyset(None, 100, None).await.unwrap(); + assert_eq!(all.len(), 1); + assert_eq!(all[0].owner_did.as_deref(), Some("did:key:zOwner")); + assert_eq!(all[0].repo, "zOwner/myrepo"); + } + + #[sqlx::test] + async fn insert_and_list_without_owner_did(pool: PgPool) { + let db = db(pool).await; + db.insert_ref_update(&update( + "u2", + "zOwner/myrepo", + None, + "refs/heads/main", + "bbbb", + )) + .await + .unwrap(); + + let all = db.list_ref_updates_keyset(None, 100, None).await.unwrap(); + assert_eq!(all.len(), 1); + assert_eq!(all[0].owner_did, None); + } + + #[sqlx::test] + async fn list_repo_ref_updates_filters_by_repo(pool: PgPool) { + let db = db(pool).await; + db.insert_ref_update(&update( + "u3", + "alice/repo1", + Some("did:key:zAlice"), + "refs/heads/main", + "cccc", + )) + .await + .unwrap(); + db.insert_ref_update(&update( + "u4", + "bob/repo2", + Some("did:key:zBob"), + "refs/heads/feat", + "dddd", + )) + .await + .unwrap(); + + let alice_events = db + .list_ref_updates_keyset(Some("alice/repo1"), 100, None) + .await + .unwrap(); + assert_eq!(alice_events.len(), 1); + assert_eq!(alice_events[0].id, "u3"); + assert_eq!(alice_events[0].owner_did.as_deref(), Some("did:key:zAlice")); + + let bob_events = db + .list_ref_updates_keyset(Some("bob/repo2"), 100, None) + .await + .unwrap(); + assert_eq!(bob_events.len(), 1); + assert_eq!(bob_events[0].id, "u4"); + assert_eq!(bob_events[0].owner_did.as_deref(), Some("did:key:zBob")); + + let empty = db + .list_ref_updates_keyset(Some("other/repo"), 100, None) + .await + .unwrap(); + assert!(empty.is_empty()); + } + + #[sqlx::test] + async fn list_ref_updates_filtered_by_repo(pool: PgPool) { + let db = db(pool).await; + db.insert_ref_update(&update( + "u5", + "ownerA/proj", + Some("did:key:zA"), + "refs/heads/main", + "eeee", + )) + .await + .unwrap(); + db.insert_ref_update(&update( + "u6", + "ownerB/proj", + Some("did:web:host:zB"), + "refs/heads/main", + "ffff", + )) + .await + .unwrap(); + + let filtered = db + .list_ref_updates_keyset(Some("ownerA/proj"), 100, None) + .await + .unwrap(); + assert_eq!(filtered.len(), 1); + assert_eq!(filtered[0].id, "u5"); + + let all = db.list_ref_updates_keyset(None, 100, None).await.unwrap(); + assert_eq!(all.len(), 2); + } + + #[sqlx::test] + async fn insert_update_idempotent_on_conflict(pool: PgPool) { + let db = db(pool).await; + let u = update( + "u7", + "repo/x", + Some("did:key:zX"), + "refs/heads/main", + "gggg", + ); + db.insert_ref_update(&u).await.unwrap(); + db.insert_ref_update(&u).await.unwrap(); + + let all = db.list_ref_updates_keyset(None, 100, None).await.unwrap(); + assert_eq!(all.len(), 1); + assert_eq!(all[0].new_sha, "gggg"); + } +} diff --git a/crates/gitlawb-node/src/graphql/query.rs b/crates/gitlawb-node/src/graphql/query.rs index 55148e02..1666d33a 100644 --- a/crates/gitlawb-node/src/graphql/query.rs +++ b/crates/gitlawb-node/src/graphql/query.rs @@ -73,9 +73,25 @@ impl QueryRoot { .await .map_err(|e| async_graphql::Error::new(e.to_string()))?; - Ok(updates + // Resolve the trusted display owner_did per row, identical to the REST + // feed: the stored wire value is untrusted, so it is echoed only when it + // matches the canonical owner of the local repo the slug names (#P1); + // legacy None rows are attributed via an exact unique local match (#P3). + // The batch resolver issues at most one query per distinct local repo + // rather than one per event row (#P2). + let pairs: Vec<(&str, Option<&str>)> = updates + .iter() + .map(|u| (u.repo.as_str(), u.owner_did.as_deref())) + .collect(); + let owner_dids = db + .resolve_ref_update_owner_dids(&pairs) + .await + .map_err(|e| async_graphql::Error::new(e.to_string()))?; + + let resolved: Vec = updates .into_iter() - .map(|u| RefUpdateType { + .zip(owner_dids) + .map(|(u, owner_did)| RefUpdateType { repo: u.repo, ref_name: u.ref_name, old_sha: u.old_sha, @@ -83,8 +99,10 @@ impl QueryRoot { pusher_did: u.pusher_did, node_did: u.node_did, timestamp: u.timestamp, + owner_did, }) - .collect()) + .collect(); + Ok(resolved) } async fn tasks( @@ -162,6 +180,7 @@ mod tests { cert_id: None, received_at: Utc::now().to_rfc3339(), from_peer: "peer1".into(), + owner_did: None, } } @@ -245,10 +264,10 @@ mod tests { .await .unwrap(); let schema = schema(db); - let q = r#"{ refUpdates { repo refName } }"#; + let q = r#"{ refUpdates { repo refName ownerDid } }"#; let resp = anon(&schema, q).await; assert_eq!(count(&resp), 1); - // The one row returned must be the public repo's. + // The one row returned must be the public repo's with owner_did echoed. let async_graphql::Value::Object(obj) = &resp.data else { unreachable!() }; @@ -262,6 +281,11 @@ mod tests { row.get("repo").unwrap(), &async_graphql::Value::from("z6MkOwner/openrepo") ); + assert_eq!( + row.get("ownerDid").unwrap(), + &async_graphql::Value::from(OWNER), + "ownerDid must fall back to the local record's owner for legacy rows" + ); } // Scenario 4 — alias fail-closed: private repo's row stored full-DID form. diff --git a/crates/gitlawb-node/src/graphql/subscription.rs b/crates/gitlawb-node/src/graphql/subscription.rs index 6c639bc6..7248cbf4 100644 --- a/crates/gitlawb-node/src/graphql/subscription.rs +++ b/crates/gitlawb-node/src/graphql/subscription.rs @@ -39,6 +39,7 @@ impl SubscriptionRoot { pusher_did: ev.pusher_did, node_did: ev.node_did, timestamp: ev.timestamp, + owner_did: Some(ev.owner_did), }), _ => None, } diff --git a/crates/gitlawb-node/src/graphql/types.rs b/crates/gitlawb-node/src/graphql/types.rs index 918701fd..4264a581 100644 --- a/crates/gitlawb-node/src/graphql/types.rs +++ b/crates/gitlawb-node/src/graphql/types.rs @@ -57,6 +57,7 @@ pub struct RefUpdateType { pub pusher_did: String, pub node_did: String, pub timestamp: String, + pub owner_did: Option, } #[derive(SimpleObject, Clone)] diff --git a/crates/gitlawb-node/src/p2p/mod.rs b/crates/gitlawb-node/src/p2p/mod.rs index 5a6992b1..80e28a4a 100644 --- a/crates/gitlawb-node/src/p2p/mod.rs +++ b/crates/gitlawb-node/src/p2p/mod.rs @@ -39,6 +39,11 @@ pub struct RefUpdateEvent { pub pusher_did: String, /// Repository identifier (owner/name) pub repo: String, + /// Full owner DID — added in #144 for display and storage; not yet + /// wired into the feed gate matcher. Optional for backward compat with + /// older peers that don't include it. + #[serde(default)] + pub owner_did: Option, /// Git ref that changed (e.g., "refs/heads/main") pub ref_name: String, /// SHA before the push (all-zeros for new ref) @@ -307,6 +312,7 @@ pub async fn start( node_did: event.node_did.clone(), pusher_did: event.pusher_did.clone(), repo: event.repo.clone(), + owner_did: event.owner_did.clone(), ref_name: event.ref_name.clone(), old_sha: event.old_sha.clone(), new_sha: event.new_sha.clone(), @@ -432,3 +438,67 @@ pub async fn start( Ok(handle) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn ref_update_event_round_trip_with_owner_did() { + let event = RefUpdateEvent { + node_did: "did:key:zNode".into(), + pusher_did: "did:key:zPusher".into(), + repo: "zOwner/myrepo".into(), + owner_did: Some("did:key:zOwner".into()), + ref_name: "refs/heads/main".into(), + old_sha: "0000000000000000000000000000000000000000".into(), + new_sha: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa".into(), + timestamp: "2026-07-02T12:00:00Z".into(), + cert_id: None, + cid: None, + }; + let json = serde_json::to_value(&event).unwrap(); + // owner_did must be present in the serialized output + assert_eq!(json["owner_did"], "did:key:zOwner"); + assert_eq!(json["repo"], "zOwner/myrepo"); + + let deserialized: RefUpdateEvent = serde_json::from_value(json).unwrap(); + assert_eq!(deserialized.owner_did, Some("did:key:zOwner".into())); + } + + #[test] + fn ref_update_event_backward_compat_no_owner_did() { + let old_json = serde_json::json!({ + "node_did": "did:key:zNode", + "pusher_did": "did:key:zPusher", + "repo": "zOwner/myrepo", + "ref_name": "refs/heads/main", + "old_sha": "0000000000000000000000000000000000000000", + "new_sha": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "timestamp": "2026-07-02T12:00:00Z", + "cert_id": null, + "cid": null + }); + let deserialized: RefUpdateEvent = serde_json::from_value(old_json).unwrap(); + assert_eq!(deserialized.owner_did, None); + assert_eq!(deserialized.repo, "zOwner/myrepo"); + } + + #[test] + fn ref_update_event_backward_compat_null_owner_did() { + let with_null = serde_json::json!({ + "node_did": "did:key:zNode", + "pusher_did": "did:key:zPusher", + "repo": "zOwner/myrepo", + "owner_did": null, + "ref_name": "refs/heads/main", + "old_sha": "0000000000000000000000000000000000000000", + "new_sha": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "timestamp": "2026-07-02T12:00:00Z", + "cert_id": null, + "cid": null + }); + let deserialized: RefUpdateEvent = serde_json::from_value(with_null).unwrap(); + assert_eq!(deserialized.owner_did, None); + } +} diff --git a/crates/gitlawb-node/src/state.rs b/crates/gitlawb-node/src/state.rs index 6a84b3c2..d3e53f3a 100644 --- a/crates/gitlawb-node/src/state.rs +++ b/crates/gitlawb-node/src/state.rs @@ -17,6 +17,7 @@ pub struct RefUpdateBroadcast { pub pusher_did: String, pub node_did: String, pub timestamp: String, + pub owner_did: String, } #[derive(Clone, Debug)] diff --git a/crates/gitlawb-node/src/test_support.rs b/crates/gitlawb-node/src/test_support.rs index 4fe52f64..dd2777fe 100644 --- a/crates/gitlawb-node/src/test_support.rs +++ b/crates/gitlawb-node/src/test_support.rs @@ -3379,6 +3379,172 @@ mod tests { ); } + // ── Ref-update events (issue #144: owner_did wire format) ───────────────── + + fn events_router(state: AppState) -> Router { + Router::new() + .route( + "/api/v1/events/ref-updates", + axum::routing::get(crate::api::events::list_ref_updates), + ) + .with_state(state) + } + + #[sqlx::test] + async fn events_returns_inserted_ref_updates(pool: PgPool) { + let state = test_state(pool).await; + let owner = "did:key:zEVENTSOWNERAAAAAAAAAAAAAAAAAAAAAAAAA"; + + // Seed a local repo the wire owner_did is bound to. The stored wire + // owner_did is untrusted; it is only surfaced when it matches the + // canonical owner of the local repo the slug names. + state + .db + .create_repo(&seed_repo(owner, "myrepo")) + .await + .unwrap(); + + // Insert a gossip event with owner_did set + state + .db + .insert_ref_update(&crate::db::ReceivedRefUpdate { + id: uuid::Uuid::new_v4().to_string(), + node_did: "did:key:zNode".into(), + pusher_did: "did:key:zPusher".into(), + repo: format!("{}/myrepo", owner.split(':').next_back().unwrap()), + owner_did: Some(owner.into()), + ref_name: "refs/heads/main".into(), + old_sha: "0000000000000000000000000000000000000000".into(), + new_sha: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa".into(), + timestamp: "2026-07-02T12:00:00Z".into(), + cert_id: None, + received_at: "2026-07-02T12:00:01Z".into(), + from_peer: "12D3KooWTest".into(), + }) + .await + .unwrap(); + + let resp = events_router(state) + .oneshot(anon_get("/api/v1/events/ref-updates")) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::OK); + + let body = json_body(resp).await; + let events = body["events"].as_array().unwrap(); + assert_eq!(events.len(), 1); + assert_eq!( + events[0]["repo"], + format!("{}/myrepo", owner.split(':').next_back().unwrap()) + ); + assert_eq!(events[0]["owner_did"], owner); + } + + // P1: a peer-supplied owner_did that does NOT match the canonical owner of + // the local repo the slug names must NOT be surfaced. Here zVictim asserts + // ownership of alice's widget repo; the projection must drop it to null + // rather than poisoning persisted event ownership. + #[sqlx::test] + async fn events_drop_forged_peer_owner_did(pool: PgPool) { + let state = test_state(pool).await; + let alice = "did:key:zALICEOWNERAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"; + let victim = "did:key:zVICTIMOWNERAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"; + + state + .db + .create_repo(&seed_repo(alice, "widget")) + .await + .unwrap(); + + state + .db + .insert_ref_update(&crate::db::ReceivedRefUpdate { + id: uuid::Uuid::new_v4().to_string(), + node_did: "did:key:zNode".into(), + pusher_did: "did:key:zPusher".into(), + repo: format!("{}/widget", alice.split(':').next_back().unwrap()), + owner_did: Some(victim.into()), + ref_name: "refs/heads/main".into(), + old_sha: "0000000000000000000000000000000000000000".into(), + new_sha: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa".into(), + timestamp: "2026-07-02T12:00:00Z".into(), + cert_id: None, + received_at: "2026-07-02T12:00:01Z".into(), + from_peer: "12D3KooWTest".into(), + }) + .await + .unwrap(); + + let resp = events_router(state) + .oneshot(anon_get("/api/v1/events/ref-updates")) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::OK); + let body = json_body(resp).await; + let events = body["events"].as_array().unwrap(); + assert_eq!(events.len(), 1); + // Forged value dropped: owner_did is null, NOT zVictim. + assert_eq!(events[0]["owner_did"], serde_json::Value::Null); + } + + // P3: a legacy row stored with owner_did = None must be attributed only via + // an exact, unique local match — never a loose prefix-tolerant collision. + // alice/widget is owned by alice; a stray None row on a different repo whose + // owner key shares a segment must not inherit alice's full DID. + #[sqlx::test] + async fn events_legacy_none_owner_uses_exact_local_match(pool: PgPool) { + let state = test_state(pool).await; + let alice = "did:key:zALICEOWNER2AAAAAAAAAAAAAAAAAAAAAAAAAAAAA"; + let bob = "did:key:zBOBOWNER2AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"; + + // Alice owns "widget". + state + .db + .create_repo(&seed_repo(alice, "widget")) + .await + .unwrap(); + // Bob owns a distinct "gadget" repo. + state + .db + .create_repo(&seed_repo(bob, "gadget")) + .await + .unwrap(); + + // Legacy None row claiming slug "bob/gadget" (matches bob exactly). + state + .db + .insert_ref_update(&crate::db::ReceivedRefUpdate { + id: uuid::Uuid::new_v4().to_string(), + node_did: "did:key:zNode".into(), + pusher_did: "did:key:zPusher".into(), + repo: format!("{}/gadget", bob.split(':').next_back().unwrap()), + owner_did: None, + ref_name: "refs/heads/main".into(), + old_sha: "0000000000000000000000000000000000000000".into(), + new_sha: "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb".into(), + timestamp: "2026-07-02T12:00:00Z".into(), + cert_id: None, + received_at: "2026-07-02T12:00:01Z".into(), + from_peer: "12D3KooWTest".into(), + }) + .await + .unwrap(); + + let resp = events_router(state) + .oneshot(anon_get("/api/v1/events/ref-updates")) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::OK); + let body = json_body(resp).await; + let events = body["events"].as_array().unwrap(); + assert_eq!(events.len(), 1); + // Attributed to the exact local owner (bob), not alice via collision. + assert_eq!( + events[0]["owner_did"], + serde_json::Value::String(bob.to_string()) + ); + } + #[sqlx::test] async fn list_all_bounties_past_private_window_finds_public(pool: PgPool) { let state = test_state(pool).await; @@ -3549,6 +3715,42 @@ mod tests { assert!(resp.status().is_success()); } + #[sqlx::test] + async fn events_limit_respects_limit_param(pool: PgPool) { + let state = test_state(pool).await; + let owner = "did:key:zEVENTLIMITAAAAAAAAAAAAAAAAAAAAAAAA"; + + for i in 0..5 { + state + .db + .insert_ref_update(&crate::db::ReceivedRefUpdate { + id: uuid::Uuid::new_v4().to_string(), + node_did: "did:key:zNode".into(), + pusher_did: "did:key:zPusher".into(), + repo: format!("{}/r{i}", owner.split(':').next_back().unwrap()), + owner_did: Some(owner.into()), + ref_name: "refs/heads/main".into(), + old_sha: "0000000000000000000000000000000000000000".into(), + new_sha: format!("{i:040x}"), + timestamp: format!("2026-07-02T12:00:{i:02}Z"), + cert_id: None, + received_at: format!("2026-07-02T12:00:{i:02}Z"), + from_peer: "12D3KooWTest".into(), + }) + .await + .unwrap(); + } + + let resp = events_router(state) + .oneshot(anon_get("/api/v1/events/ref-updates?limit=2")) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::OK); + let body = json_body(resp).await; + assert_eq!(body["count"].as_i64(), Some(2)); + assert_eq!(body["events"].as_array().unwrap().len(), 2); + } + #[sqlx::test] async fn claim_bounty_gate_denies_non_reader_on_private(pool: PgPool) { let state = test_state(pool).await;