From e9311384ab12ea6f404914c7248bc4e97be02f81 Mon Sep 17 00:00:00 2001 From: Gravirei Date: Thu, 2 Jul 2026 08:38:40 +0600 Subject: [PATCH 01/14] fix(node): carry full owner DID on ref-update wire event (#144) --- crates/gitlawb-node/src/api/peers.rs | 5 +++++ crates/gitlawb-node/src/api/repos.rs | 1 + crates/gitlawb-node/src/db/mod.rs | 13 ++++++++++--- crates/gitlawb-node/src/p2p/mod.rs | 6 ++++++ 4 files changed, 22 insertions(+), 3 deletions(-) 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..9b3e7c52 100644 --- a/crates/gitlawb-node/src/api/repos.rs +++ b/crates/gitlawb-node/src/api/repos.rs @@ -1261,6 +1261,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(), diff --git a/crates/gitlawb-node/src/db/mod.rs b/crates/gitlawb-node/src/db/mod.rs index 5324a4b9..7e0f61bf 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)] @@ -566,8 +569,10 @@ const MIGRATIONS: &[Migration] = &[ received_at TEXT NOT NULL, from_peer TEXT NOT NULL )"#, + "ALTER TABLE received_ref_updates ADD COLUMN IF NOT EXISTS owner_did TEXT", "CREATE INDEX IF NOT EXISTS idx_ref_updates_repo ON received_ref_updates(repo)", "CREATE INDEX IF NOT EXISTS idx_ref_updates_ts ON received_ref_updates(timestamp DESC)", + "CREATE INDEX IF NOT EXISTS idx_ref_updates_owner ON received_ref_updates(owner_did)", r#"CREATE TABLE IF NOT EXISTS pull_requests ( id TEXT NOT NULL PRIMARY KEY, repo_id TEXT NOT NULL, @@ -2304,8 +2309,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,6 +2324,7 @@ impl Db { .bind(&update.cert_id) .bind(&update.received_at) .bind(&update.from_peer) + .bind(&update.owner_did) .execute(&self.pool) .await?; Ok(()) @@ -2348,7 +2354,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 +2692,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"), } } diff --git a/crates/gitlawb-node/src/p2p/mod.rs b/crates/gitlawb-node/src/p2p/mod.rs index 5a6992b1..1adbb34a 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 so the feed gate can distinguish + /// different DID methods that share the same trailing segment. + /// 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(), From ec74d4d19d2334e433522360dc59e2c6ed819ad4 Mon Sep 17 00:00:00 2001 From: Gravirei Date: Thu, 2 Jul 2026 09:10:39 +0600 Subject: [PATCH 02/14] test(node): add ref-update owner_did round-trip, DB, and API tests (#144) --- Cargo.lock | 10 +- crates/gitlawb-node/src/api/events.rs | 1 + crates/gitlawb-node/src/api/repos.rs | 16 +++ crates/gitlawb-node/src/db/mod.rs | 170 +++++++++++++++++++++++ crates/gitlawb-node/src/graphql/query.rs | 1 + crates/gitlawb-node/src/p2p/mod.rs | 64 +++++++++ crates/gitlawb-node/src/test_support.rs | 87 ++++++++++++ 7 files changed, 344 insertions(+), 5 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index d12daa43..e6f04d76 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", diff --git a/crates/gitlawb-node/src/api/events.rs b/crates/gitlawb-node/src/api/events.rs index 99cc2eeb..39aea7bc 100644 --- a/crates/gitlawb-node/src/api/events.rs +++ b/crates/gitlawb-node/src/api/events.rs @@ -326,6 +326,7 @@ mod ref_updates_feed_tests { cert_id: None, received_at: Utc::now().to_rfc3339(), from_peer: "peer1".into(), + owner_did: None, } } diff --git a/crates/gitlawb-node/src/api/repos.rs b/crates/gitlawb-node/src/api/repos.rs index 9b3e7c52..0625d7c2 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; } @@ -1363,6 +1367,7 @@ pub async fn git_receive_pack( &ref_updates_clone, &node_did_str, &pusher_did_clone, + &record.owner_did, ) .await; } @@ -2403,6 +2408,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) @@ -2414,6 +2422,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) @@ -2435,6 +2446,7 @@ mod tests { &ref_updates, "did:key:zNode", "did:key:zPusher", + "did:key:zOwner", ) .await; @@ -2457,6 +2469,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) @@ -2479,6 +2494,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 7e0f61bf..fc46ae69 100644 --- a/crates/gitlawb-node/src/db/mod.rs +++ b/crates/gitlawb-node/src/db/mod.rs @@ -4461,6 +4461,7 @@ mod ref_update_keyset_paging_tests { cert_id: None, received_at: ts.into(), from_peer: "peer".into(), + owner_did: None, } } @@ -4569,6 +4570,7 @@ mod ref_update_keyset_repo_filtered_tests { cert_id: None, received_at: ts.into(), from_peer: "peer".into(), + owner_did: None, } } @@ -4675,6 +4677,7 @@ mod ref_update_keyset_same_timestamp_tests { cert_id: None, received_at: ts.into(), from_peer: "peer".into(), + owner_did: None, } } @@ -5270,3 +5273,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..4aaac261 100644 --- a/crates/gitlawb-node/src/graphql/query.rs +++ b/crates/gitlawb-node/src/graphql/query.rs @@ -162,6 +162,7 @@ mod tests { cert_id: None, received_at: Utc::now().to_rfc3339(), from_peer: "peer1".into(), + owner_did: None, } } diff --git a/crates/gitlawb-node/src/p2p/mod.rs b/crates/gitlawb-node/src/p2p/mod.rs index 1adbb34a..ee473011 100644 --- a/crates/gitlawb-node/src/p2p/mod.rs +++ b/crates/gitlawb-node/src/p2p/mod.rs @@ -438,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/test_support.rs b/crates/gitlawb-node/src/test_support.rs index 4fe52f64..7c12df92 100644 --- a/crates/gitlawb-node/src/test_support.rs +++ b/crates/gitlawb-node/src/test_support.rs @@ -3379,6 +3379,57 @@ 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"; + + // 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()) + ); + } + #[sqlx::test] async fn list_all_bounties_past_private_window_finds_public(pool: PgPool) { let state = test_state(pool).await; @@ -3549,6 +3600,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; From 11210f3ed32c93d104dfc1917869bfad3f038123 Mon Sep 17 00:00:00 2001 From: Gravirei Date: Thu, 2 Jul 2026 11:21:42 +0600 Subject: [PATCH 03/14] fix(node): move owner_did migration to v10, add upgrade-path test --- crates/gitlawb-node/src/db/mod.rs | 47 +++++++++++++++++++++++++++++-- 1 file changed, 45 insertions(+), 2 deletions(-) diff --git a/crates/gitlawb-node/src/db/mod.rs b/crates/gitlawb-node/src/db/mod.rs index fc46ae69..591d9c79 100644 --- a/crates/gitlawb-node/src/db/mod.rs +++ b/crates/gitlawb-node/src/db/mod.rs @@ -569,10 +569,8 @@ const MIGRATIONS: &[Migration] = &[ received_at TEXT NOT NULL, from_peer TEXT NOT NULL )"#, - "ALTER TABLE received_ref_updates ADD COLUMN IF NOT EXISTS owner_did TEXT", "CREATE INDEX IF NOT EXISTS idx_ref_updates_repo ON received_ref_updates(repo)", "CREATE INDEX IF NOT EXISTS idx_ref_updates_ts ON received_ref_updates(timestamp DESC)", - "CREATE INDEX IF NOT EXISTS idx_ref_updates_owner ON received_ref_updates(owner_did)", r#"CREATE TABLE IF NOT EXISTS pull_requests ( id TEXT NOT NULL PRIMARY KEY, repo_id TEXT NOT NULL, @@ -877,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 ───────────────────────────────────────────────────────────────────── @@ -3413,6 +3419,43 @@ mod migration_tests { // it, you must also update the backfill. assert_eq!(MIGRATIONS[0].name, MIGRATION_V1_NAME); } + + /// Run a full migration from scratch and verify v11 creates the owner_did + /// column. Also verifies that an existing node re-running the migration + /// won't error (idempotent ALTER TABLE ADD COLUMN IF NOT EXISTS). + #[sqlx::test] + async fn migration_v11_creates_owner_did_column(pool: sqlx::PgPool) { + let db = super::Db::for_testing(pool); + + // Run the full migration (v1..v11) on a fresh database. + db.migrate().await.unwrap(); + + // Verify the owner_did 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"); + + // Verify 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" + ); + + // Re-run: idempotent — ADD COLUMN IF NOT EXISTS must not error. + db.migrate().await.unwrap(); + } } #[cfg(test)] From 7be4e46094cf89e7787219afc1e5f7246033aea7 Mon Sep 17 00:00:00 2001 From: Gravirei Date: Sun, 5 Jul 2026 22:40:12 +0600 Subject: [PATCH 04/14] fix(node): add owner_did to GraphQL, broadcast, and REST feed surfaces (#144) --- crates/gitlawb-node/src/api/events.rs | 4 ++++ crates/gitlawb-node/src/api/repos.rs | 1 + crates/gitlawb-node/src/graphql/query.rs | 2 ++ crates/gitlawb-node/src/graphql/subscription.rs | 1 + crates/gitlawb-node/src/graphql/types.rs | 1 + crates/gitlawb-node/src/state.rs | 1 + 6 files changed, 10 insertions(+) diff --git a/crates/gitlawb-node/src/api/events.rs b/crates/gitlawb-node/src/api/events.rs index 39aea7bc..1b2b5756 100644 --- a/crates/gitlawb-node/src/api/events.rs +++ b/crates/gitlawb-node/src/api/events.rs @@ -153,6 +153,7 @@ pub async fn list_ref_updates( "cert_id": u.cert_id, "received_at": u.received_at, "from_peer": u.from_peer, + "owner_did": u.owner_did, }) }) .collect(); @@ -225,6 +226,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", }) }) @@ -258,6 +260,7 @@ pub async fn list_repo_events( "cert_id": u.cert_id, "received_at": u.received_at, "from_peer": u.from_peer, + "owner_did": u.owner_did, "source": "gossipsub", }) }) @@ -324,6 +327,7 @@ mod ref_updates_feed_tests { new_sha: "a".repeat(40), timestamp: Utc::now().to_rfc3339(), cert_id: None, + owner_did: None, received_at: Utc::now().to_rfc3339(), from_peer: "peer1".into(), owner_did: None, diff --git a/crates/gitlawb-node/src/api/repos.rs b/crates/gitlawb-node/src/api/repos.rs index 0625d7c2..b38b177b 100644 --- a/crates/gitlawb-node/src/api/repos.rs +++ b/crates/gitlawb-node/src/api/repos.rs @@ -1297,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(), }); } } diff --git a/crates/gitlawb-node/src/graphql/query.rs b/crates/gitlawb-node/src/graphql/query.rs index 4aaac261..f513a5ea 100644 --- a/crates/gitlawb-node/src/graphql/query.rs +++ b/crates/gitlawb-node/src/graphql/query.rs @@ -83,6 +83,7 @@ impl QueryRoot { pusher_did: u.pusher_did, node_did: u.node_did, timestamp: u.timestamp, + owner_did: u.owner_did, }) .collect()) } @@ -160,6 +161,7 @@ mod tests { new_sha: "a".repeat(40), timestamp: Utc::now().to_rfc3339(), cert_id: None, + owner_did: None, received_at: Utc::now().to_rfc3339(), from_peer: "peer1".into(), owner_did: None, 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/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)] From e65160033b2d3cb8b28d1c554876d17ad16f058f Mon Sep 17 00:00:00 2001 From: Gravirei Date: Mon, 6 Jul 2026 10:09:25 +0600 Subject: [PATCH 05/14] =?UTF-8?q?fix(node):=20address=20review=20feedback?= =?UTF-8?q?=20=E2=80=94=20drop=20ipfs=20regression,=20fix=20doc,=20improve?= =?UTF-8?q?=20migration=20test,=20use=20local=20owner=5Fdid?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- crates/gitlawb-node/src/api/events.rs | 3 +- crates/gitlawb-node/src/db/mod.rs | 80 +++++++++++++++++++++--- crates/gitlawb-node/src/graphql/query.rs | 1 - crates/gitlawb-node/src/p2p/mod.rs | 6 +- 4 files changed, 77 insertions(+), 13 deletions(-) diff --git a/crates/gitlawb-node/src/api/events.rs b/crates/gitlawb-node/src/api/events.rs index 1b2b5756..62b9a493 100644 --- a/crates/gitlawb-node/src/api/events.rs +++ b/crates/gitlawb-node/src/api/events.rs @@ -260,7 +260,7 @@ pub async fn list_repo_events( "cert_id": u.cert_id, "received_at": u.received_at, "from_peer": u.from_peer, - "owner_did": u.owner_did, + "owner_did": serde_json::json!(record.owner_did), "source": "gossipsub", }) }) @@ -327,7 +327,6 @@ mod ref_updates_feed_tests { new_sha: "a".repeat(40), timestamp: Utc::now().to_rfc3339(), cert_id: None, - owner_did: None, received_at: Utc::now().to_rfc3339(), from_peer: "peer1".into(), owner_did: None, diff --git a/crates/gitlawb-node/src/db/mod.rs b/crates/gitlawb-node/src/db/mod.rs index 591d9c79..27371c32 100644 --- a/crates/gitlawb-node/src/db/mod.rs +++ b/crates/gitlawb-node/src/db/mod.rs @@ -3420,17 +3420,83 @@ mod migration_tests { assert_eq!(MIGRATIONS[0].name, MIGRATION_V1_NAME); } - /// Run a full migration from scratch and verify v11 creates the owner_did - /// column. Also verifies that an existing node re-running the migration - /// won't error (idempotent ALTER TABLE ADD COLUMN IF NOT EXISTS). + /// 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); - // Run the full migration (v1..v11) on a fresh database. + // Create all tables by running the full migration chain from scratch. db.migrate().await.unwrap(); - // Verify the owner_did column exists and is nullable TEXT. + // 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 @@ -3442,7 +3508,7 @@ mod migration_tests { assert_eq!(col.0, "owner_did"); assert_eq!(col.1, "text"); - // Verify version 11 is recorded as applied. + // (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) @@ -3453,7 +3519,7 @@ mod migration_tests { "migration v11 must be recorded in schema_migrations" ); - // Re-run: idempotent — ADD COLUMN IF NOT EXISTS must not error. + // (d) Re-run: idempotent — ADD COLUMN IF NOT EXISTS must not error. db.migrate().await.unwrap(); } } diff --git a/crates/gitlawb-node/src/graphql/query.rs b/crates/gitlawb-node/src/graphql/query.rs index f513a5ea..b13d130f 100644 --- a/crates/gitlawb-node/src/graphql/query.rs +++ b/crates/gitlawb-node/src/graphql/query.rs @@ -161,7 +161,6 @@ mod tests { new_sha: "a".repeat(40), timestamp: Utc::now().to_rfc3339(), cert_id: None, - owner_did: None, received_at: Utc::now().to_rfc3339(), from_peer: "peer1".into(), owner_did: None, diff --git a/crates/gitlawb-node/src/p2p/mod.rs b/crates/gitlawb-node/src/p2p/mod.rs index ee473011..80e28a4a 100644 --- a/crates/gitlawb-node/src/p2p/mod.rs +++ b/crates/gitlawb-node/src/p2p/mod.rs @@ -39,9 +39,9 @@ pub struct RefUpdateEvent { pub pusher_did: String, /// Repository identifier (owner/name) pub repo: String, - /// Full owner DID — added in #144 so the feed gate can distinguish - /// different DID methods that share the same trailing segment. - /// Optional for backward compat with older peers that don't include it. + /// 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") From 42ea041bd533542ca8c49ab71391495820f61449 Mon Sep 17 00:00:00 2001 From: Gravirei Date: Mon, 6 Jul 2026 11:29:19 +0600 Subject: [PATCH 06/14] fix(node): fix migration v10 test to actually start from v9 schema; complete owner_did API assertion --- crates/gitlawb-node/src/db/mod.rs | 7 ++++++- crates/gitlawb-node/src/test_support.rs | 1 + 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/crates/gitlawb-node/src/db/mod.rs b/crates/gitlawb-node/src/db/mod.rs index 27371c32..5066c040 100644 --- a/crates/gitlawb-node/src/db/mod.rs +++ b/crates/gitlawb-node/src/db/mod.rs @@ -3428,8 +3428,13 @@ mod migration_tests { 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. + // 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. diff --git a/crates/gitlawb-node/src/test_support.rs b/crates/gitlawb-node/src/test_support.rs index 7c12df92..175879c1 100644 --- a/crates/gitlawb-node/src/test_support.rs +++ b/crates/gitlawb-node/src/test_support.rs @@ -3428,6 +3428,7 @@ mod tests { events[0]["repo"], format!("{}/myrepo", owner.split(':').next_back().unwrap()) ); + assert_eq!(events[0]["owner_did"], owner); } #[sqlx::test] From 6b45dc37648bf6aa363a63275f0a4bec004d9187 Mon Sep 17 00:00:00 2001 From: Gravirei Date: Tue, 7 Jul 2026 11:14:03 +0600 Subject: [PATCH 07/14] fix(node): show local owner on global feed, assert nullable in migration test, add owner_did echo assertions --- crates/gitlawb-node/src/api/events.rs | 27 ++++++++++++++++++++++-- crates/gitlawb-node/src/db/mod.rs | 1 + crates/gitlawb-node/src/graphql/query.rs | 8 +++++-- 3 files changed, 32 insertions(+), 4 deletions(-) diff --git a/crates/gitlawb-node/src/api/events.rs b/crates/gitlawb-node/src/api/events.rs index 62b9a493..e7e4896e 100644 --- a/crates/gitlawb-node/src/api/events.rs +++ b/crates/gitlawb-node/src/api/events.rs @@ -138,9 +138,25 @@ 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 local owner_did for each row so locally-hosted repos + // display their canonical owner rather than a peer-supplied wire value. + let deduped = state.db.list_all_repos_deduped().await?; + let resolve_local = |slug: &str| -> Option<&str> { + for record in &deduped { + if crate::visibility::ref_update_row_names_repo(record, slug) { + return Some(&record.owner_did); + } + } + None + }; + let events: Vec = updates .iter() .map(|u| { + let owner_did = match resolve_local(&u.repo) { + Some(local) => serde_json::json!(local), + None => serde_json::json!(u.owner_did), + }; serde_json::json!({ "id": u.id, "node_did": u.node_did, @@ -153,7 +169,7 @@ pub async fn list_ref_updates( "cert_id": u.cert_id, "received_at": u.received_at, "from_peer": u.from_peer, - "owner_did": u.owner_did, + "owner_did": owner_did, }) }) .collect(); @@ -1109,7 +1125,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/db/mod.rs b/crates/gitlawb-node/src/db/mod.rs index 5066c040..aa9bcf0d 100644 --- a/crates/gitlawb-node/src/db/mod.rs +++ b/crates/gitlawb-node/src/db/mod.rs @@ -3512,6 +3512,7 @@ mod migration_tests { .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,) = diff --git a/crates/gitlawb-node/src/graphql/query.rs b/crates/gitlawb-node/src/graphql/query.rs index b13d130f..803f9de9 100644 --- a/crates/gitlawb-node/src/graphql/query.rs +++ b/crates/gitlawb-node/src/graphql/query.rs @@ -247,10 +247,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!() }; @@ -264,6 +264,10 @@ mod tests { row.get("repo").unwrap(), &async_graphql::Value::from("z6MkOwner/openrepo") ); + assert!( + row.contains_key("ownerDid"), + "ownerDid must be present in the refUpdates response" + ); } // Scenario 4 — alias fail-closed: private repo's row stored full-DID form. From db87e673c76f638fd3d4860a67e14e855e08fe4b Mon Sep 17 00:00:00 2001 From: Gravirei Date: Tue, 7 Jul 2026 20:40:44 +0600 Subject: [PATCH 08/14] fix(node): prefer stored wire owner_did over lossy slug-local resolution; mirror projection in GraphQL --- crates/gitlawb-node/src/api/events.rs | 15 +++++--- crates/gitlawb-node/src/graphql/query.rs | 44 +++++++++++++++++------- 2 files changed, 42 insertions(+), 17 deletions(-) diff --git a/crates/gitlawb-node/src/api/events.rs b/crates/gitlawb-node/src/api/events.rs index e7e4896e..e58ca280 100644 --- a/crates/gitlawb-node/src/api/events.rs +++ b/crates/gitlawb-node/src/api/events.rs @@ -138,8 +138,10 @@ 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 local owner_did for each row so locally-hosted repos - // display their canonical owner rather than a peer-supplied wire value. + // Prefer the stored wire owner_did (full DID, may differ from local + // record's trailing-segment match). Fall back to the local record's + // owner_did only for backward-compat rows (stored as None) that name a + // repo this node hosts, so legacy rows still display an owner. let deduped = state.db.list_all_repos_deduped().await?; let resolve_local = |slug: &str| -> Option<&str> { for record in &deduped { @@ -153,9 +155,12 @@ pub async fn list_ref_updates( let events: Vec = updates .iter() .map(|u| { - let owner_did = match resolve_local(&u.repo) { - Some(local) => serde_json::json!(local), - None => serde_json::json!(u.owner_did), + let owner_did = match &u.owner_did { + Some(wire) => serde_json::json!(wire), + None => match resolve_local(&u.repo) { + Some(local) => serde_json::json!(local), + None => serde_json::Value::Null, + }, }; serde_json::json!({ "id": u.id, diff --git a/crates/gitlawb-node/src/graphql/query.rs b/crates/gitlawb-node/src/graphql/query.rs index 803f9de9..3fbc3139 100644 --- a/crates/gitlawb-node/src/graphql/query.rs +++ b/crates/gitlawb-node/src/graphql/query.rs @@ -73,17 +73,36 @@ impl QueryRoot { .await .map_err(|e| async_graphql::Error::new(e.to_string()))?; + // Match the REST feed's owner_did projection: prefer the stored wire + // value (full DID), fall back to the local record's owner for + // backward-compat rows (stored as None) that name a local repo. + let deduped = db + .list_all_repos_deduped() + .await + .map_err(|e| async_graphql::Error::new(e.to_string()))?; + let resolve_local = |slug: &str| -> Option { + for record in &deduped { + if crate::visibility::ref_update_row_names_repo(record, slug) { + return Some(record.owner_did.clone()); + } + } + None + }; + Ok(updates .into_iter() - .map(|u| RefUpdateType { - 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, - owner_did: u.owner_did, + .map(|u| { + let owner_did = u.owner_did.or_else(|| resolve_local(&u.repo)); + RefUpdateType { + 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, + owner_did, + } }) .collect()) } @@ -264,9 +283,10 @@ mod tests { row.get("repo").unwrap(), &async_graphql::Value::from("z6MkOwner/openrepo") ); - assert!( - row.contains_key("ownerDid"), - "ownerDid must be present in the refUpdates response" + assert_eq!( + row.get("ownerDid").unwrap(), + &async_graphql::Value::from(OWNER), + "ownerDid must fall back to the local record's owner for legacy rows" ); } From 830dc47af9c4109340d7b40e84c4e1e0df69dcfe Mon Sep 17 00:00:00 2001 From: Gravirei Date: Mon, 13 Jul 2026 09:38:37 +0600 Subject: [PATCH 09/14] =?UTF-8?q?fix:=20address=20review=20findings=20?= =?UTF-8?q?=E2=80=94=20restore=20iCaptcha=20PoW/origin=20matching,=20fix?= =?UTF-8?q?=20feed=20owner=5Fdid=20projections?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Cargo.lock | 1 + crates/gitlawb-node/src/api/events.rs | 11 +++++++++-- crates/gitlawb-node/src/graphql/query.rs | 14 ++++++++++---- crates/icaptcha-client/Cargo.toml | 1 + 4 files changed, 21 insertions(+), 6 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index e6f04d76..24beb9ea 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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 e58ca280..af790fc4 100644 --- a/crates/gitlawb-node/src/api/events.rs +++ b/crates/gitlawb-node/src/api/events.rs @@ -142,7 +142,14 @@ pub async fn list_ref_updates( // record's trailing-segment match). Fall back to the local record's // owner_did only for backward-compat rows (stored as None) that name a // repo this node hosts, so legacy rows still display an owner. - let deduped = state.db.list_all_repos_deduped().await?; + // The deduped set is loaded only when a legacy None row is actually + // present, avoiding the scan on every global-feed read. + let needs_fallback = updates.iter().any(|u| u.owner_did.is_none()); + let deduped: Vec = if needs_fallback { + state.db.list_all_repos_deduped().await? + } else { + Vec::new() + }; let resolve_local = |slug: &str| -> Option<&str> { for record in &deduped { if crate::visibility::ref_update_row_names_repo(record, slug) { @@ -281,7 +288,7 @@ pub async fn list_repo_events( "cert_id": u.cert_id, "received_at": u.received_at, "from_peer": u.from_peer, - "owner_did": serde_json::json!(record.owner_did), + "owner_did": u.owner_did.as_ref().map(|s| serde_json::json!(s)).unwrap_or(serde_json::json!(record.owner_did)), "source": "gossipsub", }) }) diff --git a/crates/gitlawb-node/src/graphql/query.rs b/crates/gitlawb-node/src/graphql/query.rs index 3fbc3139..cf779e83 100644 --- a/crates/gitlawb-node/src/graphql/query.rs +++ b/crates/gitlawb-node/src/graphql/query.rs @@ -76,10 +76,16 @@ impl QueryRoot { // Match the REST feed's owner_did projection: prefer the stored wire // value (full DID), fall back to the local record's owner for // backward-compat rows (stored as None) that name a local repo. - let deduped = db - .list_all_repos_deduped() - .await - .map_err(|e| async_graphql::Error::new(e.to_string()))?; + // The deduped set is loaded only when a legacy None row actually + // needs fallback, avoiding the scan on every query. + let needs_fallback = updates.iter().any(|u| u.owner_did.is_none()); + let deduped: Vec = if needs_fallback { + db.list_all_repos_deduped() + .await + .map_err(|e| async_graphql::Error::new(e.to_string()))? + } else { + Vec::new() + }; let resolve_local = |slug: &str| -> Option { for record in &deduped { if crate::visibility::ref_update_row_names_repo(record, slug) { diff --git a/crates/icaptcha-client/Cargo.toml b/crates/icaptcha-client/Cargo.toml index 0a3a9215..44728f41 100644 --- a/crates/icaptcha-client/Cargo.toml +++ b/crates/icaptcha-client/Cargo.toml @@ -13,6 +13,7 @@ reqwest = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } anyhow = { workspace = true } +sha2 = { workspace = true } thiserror = { workspace = true } tracing = { workspace = true } sha2 = { workspace = true } From 1aefc8b20cae79d490dd286c101f8ff0273040c6 Mon Sep 17 00:00:00 2001 From: Gravirei Date: Mon, 13 Jul 2026 09:46:45 +0600 Subject: [PATCH 10/14] fix: match icaptcha-client Cargo.toml ordering with upstream/main sha2 was at line 16 in our branch but at line 18 in upstream/main. The 3-way merge in CI's pull/145/merge checkout kept both lines, causing a 'duplicate key' error. Moving sha2 after tracing matches upstream/main exactly, so the merge commit will have a single entry. --- crates/icaptcha-client/Cargo.toml | 1 - 1 file changed, 1 deletion(-) diff --git a/crates/icaptcha-client/Cargo.toml b/crates/icaptcha-client/Cargo.toml index 44728f41..0a3a9215 100644 --- a/crates/icaptcha-client/Cargo.toml +++ b/crates/icaptcha-client/Cargo.toml @@ -13,7 +13,6 @@ reqwest = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } anyhow = { workspace = true } -sha2 = { workspace = true } thiserror = { workspace = true } tracing = { workspace = true } sha2 = { workspace = true } From c64c0822a3418e095e457dbe4b2b68e46571e873 Mon Sep 17 00:00:00 2001 From: Gravirei Date: Mon, 13 Jul 2026 21:14:22 +0600 Subject: [PATCH 11/14] fix(node): bind wire owner_did to local repo before surfacing as trusted The peer-supplied owner_did (HTTP notify + gossipsub receive) and the repo slug arrive over untrusted paths, yet REST and GraphQL projected the wire value as trusted feed ownership. - P1: add Db::resolve_ref_update_owner_did so a wire owner_did is echoed only when it matches the canonical owner of the actual local repo at that slug; otherwise it is dropped (returns null). The canonical DID is returned, never the raw wire bytes. - P3: legacy owner_did = None rows are now attributed via an exact, normalized, unique local match (get_repo), not the prefix-tolerant drop gate, so a cross-method slug collision cannot synthesize the wrong owner. - Unify REST (global + per-repo gossip) and GraphQL on the shared resolver so the two surfaces cannot drift. Add tests for forged-wire drop and exact-legacy attribution. --- crates/gitlawb-node/src/api/events.rs | 105 +++++++++++---------- crates/gitlawb-node/src/db/mod.rs | 50 ++++++++++ crates/gitlawb-node/src/graphql/query.rs | 58 +++++------- crates/gitlawb-node/src/test_support.rs | 114 +++++++++++++++++++++++ 4 files changed, 241 insertions(+), 86 deletions(-) diff --git a/crates/gitlawb-node/src/api/events.rs b/crates/gitlawb-node/src/api/events.rs index af790fc4..baae35c0 100644 --- a/crates/gitlawb-node/src/api/events.rs +++ b/crates/gitlawb-node/src/api/events.rs @@ -138,37 +138,29 @@ 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?; - // Prefer the stored wire owner_did (full DID, may differ from local - // record's trailing-segment match). Fall back to the local record's - // owner_did only for backward-compat rows (stored as None) that name a - // repo this node hosts, so legacy rows still display an owner. - // The deduped set is loaded only when a legacy None row is actually - // present, avoiding the scan on every global-feed read. - let needs_fallback = updates.iter().any(|u| u.owner_did.is_none()); - let deduped: Vec = if needs_fallback { - state.db.list_all_repos_deduped().await? - } else { - Vec::new() - }; - let resolve_local = |slug: &str| -> Option<&str> { - for record in &deduped { - if crate::visibility::ref_update_row_names_repo(record, slug) { - return Some(&record.owner_did); - } - } - None - }; + // 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. + let owner_dids: Vec = futures::future::join_all( + updates + .iter() + .map(|u| state.db.resolve_ref_update_owner_did(&u.repo, u.owner_did.as_deref())), + ) + .await + .into_iter() + .map(|r| { + r.map(|o| o.map_or(serde_json::Value::Null, |s| serde_json::json!(s))) + .unwrap_or(serde_json::Value::Null) + }) + .collect(); let events: Vec = updates .iter() - .map(|u| { - let owner_did = match &u.owner_did { - Some(wire) => serde_json::json!(wire), - None => match resolve_local(&u.repo) { - Some(local) => serde_json::json!(local), - None => serde_json::Value::Null, - }, - }; + .enumerate() + .map(|(i, u)| { + let owner_did = owner_dids[i].clone(); serde_json::json!({ "id": u.id, "node_did": u.node_did, @@ -270,29 +262,44 @@ 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? + let gossip_updates = + collect_visible_ref_updates(&state.db, Some(&repo_id_str), limit, caller).await?; + let gossip_owner_dids: Vec = futures::future::join_all( + gossip_updates .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, - "owner_did": u.owner_did.as_ref().map(|s| serde_json::json!(s)).unwrap_or(serde_json::json!(record.owner_did)), - "source": "gossipsub", - }) + .map(|u| state.db.resolve_ref_update_owner_did(&u.repo, u.owner_did.as_deref())), + ) + .await + .into_iter() + .map(|r| { + r.map(|o| o.map_or(serde_json::Value::Null, |s| serde_json::json!(s))) + .unwrap_or(serde_json::Value::Null) + }) + .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; diff --git a/crates/gitlawb-node/src/db/mod.rs b/crates/gitlawb-node/src/db/mod.rs index aa9bcf0d..a6385788 100644 --- a/crates/gitlawb-node/src/db/mod.rs +++ b/crates/gitlawb-node/src/db/mod.rs @@ -2336,6 +2336,56 @@ impl Db { Ok(()) } + /// Resolve the trusted display `owner_did` for a ref-update row, guarding + /// against a forged peer-supplied wire value. + /// + /// 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. + /// + /// The slug must be `"{owner}/{name}"`; a slug without a `/` cannot be + /// attributed and yields `None`. + pub async fn resolve_ref_update_owner_did( + &self, + slug: &str, + wire_owner_did: Option<&str>, + ) -> Result> { + let Some((owner_part, name)) = slug.rsplit_once('/') else { + return Ok(None); + }; + + let repo = self.get_repo(owner_part, name).await?; + + match (wire_owner_did, repo) { + // P1: trusted only when the wire DID matches the canonical owner of + // the local repo the slug names. + (Some(wire), Some(repo)) + if normalize_owner_key(wire) == normalize_owner_key(&repo.owner_did) => + { + Ok(Some(repo.owner_did)) + } + // P3: legacy None row, attributed via an exact unique local match. + (None, Some(repo)) => Ok(Some(repo.owner_did)), + // Anything else (mismatch, or no proven local owner) is untrusted. + _ => Ok(None), + } + } + /// 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 diff --git a/crates/gitlawb-node/src/graphql/query.rs b/crates/gitlawb-node/src/graphql/query.rs index cf779e83..90aa8424 100644 --- a/crates/gitlawb-node/src/graphql/query.rs +++ b/crates/gitlawb-node/src/graphql/query.rs @@ -73,44 +73,28 @@ impl QueryRoot { .await .map_err(|e| async_graphql::Error::new(e.to_string()))?; - // Match the REST feed's owner_did projection: prefer the stored wire - // value (full DID), fall back to the local record's owner for - // backward-compat rows (stored as None) that name a local repo. - // The deduped set is loaded only when a legacy None row actually - // needs fallback, avoiding the scan on every query. - let needs_fallback = updates.iter().any(|u| u.owner_did.is_none()); - let deduped: Vec = if needs_fallback { - db.list_all_repos_deduped() + // 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). + let mut resolved = Vec::with_capacity(updates.len()); + for u in updates { + let owner_did = db + .resolve_ref_update_owner_did(&u.repo, u.owner_did.as_deref()) .await - .map_err(|e| async_graphql::Error::new(e.to_string()))? - } else { - Vec::new() - }; - let resolve_local = |slug: &str| -> Option { - for record in &deduped { - if crate::visibility::ref_update_row_names_repo(record, slug) { - return Some(record.owner_did.clone()); - } - } - None - }; - - Ok(updates - .into_iter() - .map(|u| { - let owner_did = u.owner_did.or_else(|| resolve_local(&u.repo)); - RefUpdateType { - 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, - owner_did, - } - }) - .collect()) + .map_err(|e| async_graphql::Error::new(e.to_string()))?; + resolved.push(RefUpdateType { + 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, + owner_did, + }); + } + Ok(resolved) } async fn tasks( diff --git a/crates/gitlawb-node/src/test_support.rs b/crates/gitlawb-node/src/test_support.rs index 175879c1..dd2777fe 100644 --- a/crates/gitlawb-node/src/test_support.rs +++ b/crates/gitlawb-node/src/test_support.rs @@ -3395,6 +3395,15 @@ mod tests { 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 @@ -3431,6 +3440,111 @@ mod tests { 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; From e4fcd64725b6e42a1f1e19a946ca573a259ee953 Mon Sep 17 00:00:00 2001 From: Gravirei Date: Mon, 13 Jul 2026 21:21:29 +0600 Subject: [PATCH 12/14] style(node): apply cargo fmt to owner_did resolver --- crates/gitlawb-node/src/api/events.rs | 35 ++++++++++++++------------- 1 file changed, 18 insertions(+), 17 deletions(-) diff --git a/crates/gitlawb-node/src/api/events.rs b/crates/gitlawb-node/src/api/events.rs index baae35c0..ca6019f9 100644 --- a/crates/gitlawb-node/src/api/events.rs +++ b/crates/gitlawb-node/src/api/events.rs @@ -143,11 +143,11 @@ pub async fn list_ref_updates( // 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. - let owner_dids: Vec = futures::future::join_all( - updates - .iter() - .map(|u| state.db.resolve_ref_update_owner_did(&u.repo, u.owner_did.as_deref())), - ) + let owner_dids: Vec = futures::future::join_all(updates.iter().map(|u| { + state + .db + .resolve_ref_update_owner_did(&u.repo, u.owner_did.as_deref()) + })) .await .into_iter() .map(|r| { @@ -264,18 +264,19 @@ pub async fn list_repo_events( // them. let gossip_updates = collect_visible_ref_updates(&state.db, Some(&repo_id_str), limit, caller).await?; - let gossip_owner_dids: Vec = futures::future::join_all( - gossip_updates - .iter() - .map(|u| state.db.resolve_ref_update_owner_did(&u.repo, u.owner_did.as_deref())), - ) - .await - .into_iter() - .map(|r| { - r.map(|o| o.map_or(serde_json::Value::Null, |s| serde_json::json!(s))) - .unwrap_or(serde_json::Value::Null) - }) - .collect(); + let gossip_owner_dids: Vec = + futures::future::join_all(gossip_updates.iter().map(|u| { + state + .db + .resolve_ref_update_owner_did(&u.repo, u.owner_did.as_deref()) + })) + .await + .into_iter() + .map(|r| { + r.map(|o| o.map_or(serde_json::Value::Null, |s| serde_json::json!(s))) + .unwrap_or(serde_json::Value::Null) + }) + .collect(); let gossip_events: Vec = gossip_updates .iter() From 97e0e2aaf48228d650b1d98e778b12bc8dade62f Mon Sep 17 00:00:00 2001 From: Gravirei Date: Tue, 14 Jul 2026 20:01:06 +0600 Subject: [PATCH 13/14] fix: batch owner_did resolution, propagate errors, preserve full DID for mirrors P2 batch: replace per-row join_all with resolve_ref_update_owner_dids that issues at most one get_repo query per distinct local repo in the page rather than one per event row. Applies to both REST and GraphQL surfaces. P2 error: REST handler now propagates DB errors (rather than silently coercing them to null) by calling the batch method with ?. P2 mirror: when the validated wire DID carries a full method prefix (e.g. did:key:z...) and the matching repo is a mirror storing only the bare key, return the full wire DID instead of the bare key to preserve the DID-method contract. P1/P3 already addressed in fb553e6. --- crates/gitlawb-node/src/api/events.rs | 51 +++++++------ crates/gitlawb-node/src/db/mod.rs | 93 ++++++++++++++++++------ crates/gitlawb-node/src/graphql/query.rs | 26 ++++--- 3 files changed, 117 insertions(+), 53 deletions(-) diff --git a/crates/gitlawb-node/src/api/events.rs b/crates/gitlawb-node/src/api/events.rs index ca6019f9..875e6261 100644 --- a/crates/gitlawb-node/src/api/events.rs +++ b/crates/gitlawb-node/src/api/events.rs @@ -143,18 +143,22 @@ pub async fn list_ref_updates( // 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. - let owner_dids: Vec = futures::future::join_all(updates.iter().map(|u| { - state - .db - .resolve_ref_update_owner_did(&u.repo, u.owner_did.as_deref()) - })) - .await - .into_iter() - .map(|r| { - r.map(|o| o.map_or(serde_json::Value::Null, |s| serde_json::json!(s))) - .unwrap_or(serde_json::Value::Null) - }) - .collect(); + // 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() @@ -264,18 +268,19 @@ pub async fn list_repo_events( // them. let gossip_updates = collect_visible_ref_updates(&state.db, Some(&repo_id_str), limit, caller).await?; - let gossip_owner_dids: Vec = - futures::future::join_all(gossip_updates.iter().map(|u| { - state - .db - .resolve_ref_update_owner_did(&u.repo, u.owner_did.as_deref()) - })) - .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(|r| { - r.map(|o| o.map_or(serde_json::Value::Null, |s| serde_json::json!(s))) - .unwrap_or(serde_json::Value::Null) - }) + .map(|o| o.map_or(serde_json::Value::Null, |s| serde_json::json!(s))) .collect(); let gossip_events: Vec = gossip_updates diff --git a/crates/gitlawb-node/src/db/mod.rs b/crates/gitlawb-node/src/db/mod.rs index a6385788..7eb37593 100644 --- a/crates/gitlawb-node/src/db/mod.rs +++ b/crates/gitlawb-node/src/db/mod.rs @@ -2336,8 +2336,9 @@ impl Db { Ok(()) } - /// Resolve the trusted display `owner_did` for a ref-update row, guarding - /// against a forged peer-supplied wire value. + /// 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 @@ -2358,32 +2359,82 @@ impl Db { /// 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_did( + pub async fn resolve_ref_update_owner_dids( &self, - slug: &str, - wire_owner_did: Option<&str>, - ) -> Result> { - let Some((owner_part, name)) = slug.rsplit_once('/') else { - return Ok(None); - }; + rows: &[(&str, Option<&str>)], + ) -> Result>> { + if rows.is_empty() { + return Ok(Vec::new()); + } - let repo = self.get_repo(owner_part, name).await?; + // ── 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); + } + } - match (wire_owner_did, repo) { - // P1: trusted only when the wire DID matches the canonical owner of - // the local repo the slug names. - (Some(wire), Some(repo)) - if normalize_owner_key(wire) == normalize_owner_key(&repo.owner_did) => - { - Ok(Some(repo.owner_did)) + // ── 2. Fetch all matching repos (one query per unique key) ────── + // The keys are typically few (1–20 for a 200-row page), so per-key + // queries are far cheaper than one per event row. + let mut repo_map: std::collections::HashMap = + std::collections::HashMap::new(); + for key in &unique_keys { + let Some((owner_part, name)) = key.split_once('\0') else { + continue; + }; + if let Some(repo) = self.get_repo(owner_part, name).await? { + repo_map.insert(key.clone(), repo); } - // P3: legacy None row, attributed via an exact unique local match. - (None, Some(repo)) => Ok(Some(repo.owner_did)), - // Anything else (mismatch, or no proven local owner) is untrusted. - _ => Ok(None), } + + // ── 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. diff --git a/crates/gitlawb-node/src/graphql/query.rs b/crates/gitlawb-node/src/graphql/query.rs index 90aa8424..1666d33a 100644 --- a/crates/gitlawb-node/src/graphql/query.rs +++ b/crates/gitlawb-node/src/graphql/query.rs @@ -77,13 +77,21 @@ impl QueryRoot { // 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). - let mut resolved = Vec::with_capacity(updates.len()); - for u in updates { - let owner_did = db - .resolve_ref_update_owner_did(&u.repo, u.owner_did.as_deref()) - .await - .map_err(|e| async_graphql::Error::new(e.to_string()))?; - resolved.push(RefUpdateType { + // 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() + .zip(owner_dids) + .map(|(u, owner_did)| RefUpdateType { repo: u.repo, ref_name: u.ref_name, old_sha: u.old_sha, @@ -92,8 +100,8 @@ impl QueryRoot { node_did: u.node_did, timestamp: u.timestamp, owner_did, - }); - } + }) + .collect(); Ok(resolved) } From 083540b54a5da804a0c8a3f9c97c7f1edd21ce27 Mon Sep 17 00:00:00 2001 From: Gravirei Date: Wed, 15 Jul 2026 18:46:52 +0600 Subject: [PATCH 14/14] fix: rebase onto current main and use single batch SQL for owner DID resolution Rebased from stale 3ec8cbf base onto current upstream/main (ad7c2b2), dropping the already-merged iCaptcha work from the diff. The PR diff is now only the 11 owner-DID files. The batch owner-DID resolver now issues a single set-based SQL query with one OR condition per distinct slug, replacing the loop of per-key get_repo calls. A 200-row page of 200 distinct remote slugs now costs 1 SQL round-trip instead of 200. P1/P3 already addressed in c64c082. P2 mirror, error propagation, and the earlier batch refactor preserved in the rebased commits. --- crates/gitlawb-node/src/db/mod.rs | 43 ++++++++++++++++++++++++------- 1 file changed, 34 insertions(+), 9 deletions(-) diff --git a/crates/gitlawb-node/src/db/mod.rs b/crates/gitlawb-node/src/db/mod.rs index 7eb37593..33f5bd67 100644 --- a/crates/gitlawb-node/src/db/mod.rs +++ b/crates/gitlawb-node/src/db/mod.rs @@ -2395,17 +2395,42 @@ impl Db { } } - // ── 2. Fetch all matching repos (one query per unique key) ────── - // The keys are typically few (1–20 for a 200-row page), so per-key - // queries are far cheaper than one per event row. + // ── 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(); - for key in &unique_keys { - let Some((owner_part, name)) = key.split_once('\0') else { - continue; - }; - if let Some(repo) = self.get_repo(owner_part, name).await? { - repo_map.insert(key.clone(), repo); + + 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); } }