From 45064806741001bf120a2e414adcdb9a5250f3a8 Mon Sep 17 00:00:00 2001 From: Gravirei Date: Tue, 30 Jun 2026 19:52:16 +0600 Subject: [PATCH 01/13] fix(node): gate GET /ipfs/{cid} on reachable allowed-set, not deny-set (#126) The IPFS visibility gate used withheld_blob_oids (a deny-set enumerating only reachable blobs), so a dangling/unreachable blob was absent from the set and served in cleartext to anonymous callers. Flip to an allowed-set (allowed_blob_set_for_caller) that enumerates reachable blobs the caller may read: a dangling blob has no path, is never in the set, and 404s. --- crates/gitlawb-node/src/api/ipfs.rs | 115 ++++++++++-------- .../gitlawb-node/src/git/visibility_pack.rs | 96 ++++++++++++++- crates/gitlawb-node/src/test_support.rs | 112 +++++++++++++++++ 3 files changed, 273 insertions(+), 50 deletions(-) diff --git a/crates/gitlawb-node/src/api/ipfs.rs b/crates/gitlawb-node/src/api/ipfs.rs index 6a43cb5..405eaed 100644 --- a/crates/gitlawb-node/src/api/ipfs.rs +++ b/crates/gitlawb-node/src/api/ipfs.rs @@ -27,7 +27,7 @@ use std::str::FromStr; use crate::auth::AuthenticatedDid; use crate::error::{AppError, Result}; use crate::git::store; -use crate::git::visibility_pack::{has_path_scoped_rule, withheld_blob_oids}; +use crate::git::visibility_pack::{allowed_blob_set_for_caller, has_path_scoped_rule}; use crate::state::AppState; use crate::visibility::{visibility_check, Decision}; @@ -36,16 +36,22 @@ use crate::visibility::{visibility_check, Decision}; /// Search all repos on the node for a git object whose SHA-256 hash matches /// the given CIDv1, returning its raw content if the caller may read it. /// -/// Visibility (#110): the object is served only from a repo row the caller -/// passes. For each iterated row we gate against that row's OWN rules +/// Visibility (#110, #126): the object is served only from a repo row the +/// caller passes. For each iterated row we gate against that row's OWN rules /// (`visibility_check` at `"/"`), never re-resolving via `authorize_repo_read` /// — `get_repo`'s fuzzy match could otherwise authorize a different physical -/// row than the one read (KTD2a). When the row carries path-scoped rules, a -/// blob withheld from the caller (`withheld_blob_oids`) is skipped. Denial and -/// genuine not-found both fall through to an opaque 404. +/// row than the one read (KTD2a). When the row carries path-scoped rules +/// (KTD4) the served object must be either a non-blob (trees/commits are +/// structural; KTD3) OR a blob in the caller's *reachable* allowed-set +/// (`allowed_blob_set_for_caller`). The reachable allowed-set excludes +/// dangling blobs — a blob written via `git hash-object -w` and never +/// committed has no path to gate, so it is fail-closed 404'd under +/// path-scoped rules (#126). Denial and genuine not-found both fall through +/// to an opaque 404. /// -/// Scope: this closes the direct unauthenticated scan. A stale-public mirror -/// row still serves withheld content (tracked separately, #124). +/// Scope: this closes the direct unauthenticated scan, including the dangling +/// case. A stale-public mirror row still serves withheld content (tracked +/// separately, #124). pub async fn get_by_cid( Path(cid_str): Path, State(state): State, @@ -85,11 +91,16 @@ pub async fn get_by_cid( .await .map_err(AppError::Internal)?; - // Request-scoped memo of the per-repo withheld set (KTD1). The caller is - // constant for one request, so `repo.id` alone is a safe, sufficient key — - // never a coarse caller "class", which `visibility_check`'s exact full-DID - // reader match would make unsafe. - let mut withheld_memo: HashMap> = HashMap::new(); + // Request-scoped memo of the per-repo allowed-blob set (KTD1, #126). The + // caller is constant for one request, so `repo.id` alone is a safe, + // sufficient key — never a coarse caller "class", which + // `visibility_check`'s exact full-DID reader match would make unsafe. + // + // We flipped from a deny-set (`withheld_blob_oids`) to an allowed-set + // (`allowed_blob_set_for_caller`) so dangling blobs — never enumerated by + // the reachable walk — fail closed instead of slipping through an empty + // deny entry (#126). + let mut allowed_memo: HashMap> = HashMap::new(); for repo in &repos { // Repo-level read gate against THIS row's own rules (KTD2a). @@ -106,45 +117,51 @@ pub async fn get_by_cid( Err(_) => continue, }; - // Per-blob withholding only applies when a path-scoped rule exists (KTD4). - if has_path_scoped_rule(rules) { - if !withheld_memo.contains_key(&repo.id) { - let rp = repo_path.clone(); - let r = rules.to_vec(); - let is_public = repo.is_public; - let owner = repo.owner_did.clone(); - let caller_for_walk = caller_owned.clone(); - // Full-history walk shells out to git — keep it off the async runtime. - let walk = tokio::task::spawn_blocking(move || { - withheld_blob_oids(&rp, &r, is_public, &owner, caller_for_walk.as_deref()) - }) - .await; - // Fail closed on EITHER a task panic (JoinError) or a walk error: - // we cannot prove the caller may read here, so skip this repo and - // let a public copy (if any) serve. Never serve on an unproven gate. - let set = match walk { - Ok(Ok(set)) => set, - Ok(Err(e)) => { - tracing::warn!(repo = %repo.name, err = %e, "withheld walk failed; skipping repo"); - continue; - } - Err(e) => { - tracing::warn!(repo = %repo.name, err = %e, "withheld walk task panicked; skipping repo"); - continue; - } - }; - withheld_memo.insert(repo.id.clone(), set); - } - if withheld_memo - .get(&repo.id) - .is_some_and(|set| set.contains(&sha256_hex)) - { - continue; - } + // Per-blob gating only applies when a path-scoped rule exists (KTD4). + // Without any path-scoped rule, the "/" gate above is the whole story. + let path_scoped = has_path_scoped_rule(rules); + if path_scoped && !allowed_memo.contains_key(&repo.id) { + let rp = repo_path.clone(); + let r = rules.to_vec(); + let is_public = repo.is_public; + let owner = repo.owner_did.clone(); + let caller_for_walk = caller_owned.clone(); + // Full-history walk shells out to git — keep it off the async runtime. + let walk = tokio::task::spawn_blocking(move || { + allowed_blob_set_for_caller(&rp, &r, is_public, &owner, caller_for_walk.as_deref()) + }) + .await; + // Fail closed on EITHER a task panic (JoinError) or a walk error: + // we cannot prove the caller may read here, so skip this repo and + // let a public copy (if any) serve. Never serve on an unproven gate. + let set = match walk { + Ok(Ok(set)) => set, + Ok(Err(e)) => { + tracing::warn!(repo = %repo.name, err = %e, "allowed-blob walk failed; skipping repo"); + continue; + } + Err(e) => { + tracing::warn!(repo = %repo.name, err = %e, "allowed-blob walk task panicked; skipping repo"); + continue; + } + }; + allowed_memo.insert(repo.id.clone(), set); } match store::read_object(&repo_path, &sha256_hex) { - Ok(Some((_obj_type, content))) => { + Ok(Some((obj_type, content))) => { + // Path-scoped rules: serve trees/commits unconditionally + // (structural; KTD3); a blob must be in the reachable + // allowed-set, which excludes dangling blobs (#126). + if path_scoped && obj_type == "blob" { + let in_allowed = allowed_memo + .get(&repo.id) + .is_some_and(|set| set.contains(&sha256_hex)); + if !in_allowed { + continue; + } + } + // 3. Return the content with IPFS-style headers let mut headers = HeaderMap::new(); headers.insert( diff --git a/crates/gitlawb-node/src/git/visibility_pack.rs b/crates/gitlawb-node/src/git/visibility_pack.rs index 578ee40..cb70e39 100644 --- a/crates/gitlawb-node/src/git/visibility_pack.rs +++ b/crates/gitlawb-node/src/git/visibility_pack.rs @@ -309,11 +309,33 @@ pub fn replicable_blob_set( rules: &[VisibilityRule], is_public: bool, owner_did: &str, +) -> Result> { + allowed_blob_set_for_caller(repo_path, rules, is_public, owner_did, None) +} + +/// Reachable blob OIDs that visibility ALLOWS `caller` at some path. The +/// caller-aware generalization of `replicable_blob_set` (which is the anonymous +/// `caller = None` case). Used by `GET /ipfs/{cid}` to gate fail-closed against +/// dangling/unreachable blobs (#126): a blob written via `git hash-object -w` +/// but unreferenced is absent from the reachable walk, so it is never in this +/// set and the IPFS serve path drops it — even from the owner, who has no path +/// to authorize the blob at. +/// +/// A blob reachable at an allowed path is included even when also denied +/// elsewhere (its content is readable to this caller elsewhere). Trees and +/// commits are NOT included here; the caller decides per object type whether +/// the allow-set applies (it does not for trees/commits — KTD3). +pub fn allowed_blob_set_for_caller( + repo_path: &Path, + rules: &[VisibilityRule], + is_public: bool, + owner_did: &str, + caller: Option<&str>, ) -> Result> { let pairs = blob_paths(repo_path)?; let mut allowed = HashSet::new(); for (oid, path) in &pairs { - if visibility_check(rules, is_public, owner_did, None, path) == Decision::Allow { + if visibility_check(rules, is_public, owner_did, caller, path) == Decision::Allow { allowed.insert(oid.clone()); } } @@ -743,6 +765,78 @@ mod tests { ); } + #[test] + fn allowed_set_excludes_dangling_blob_for_every_caller() { + // #126: a blob written via `git hash-object -w` but never referenced has + // no path to gate on, so it is absent from the reachable allowed-set — + // for anonymous callers, listed readers, AND the owner. The IPFS serve + // path relies on this fail-closed property to drop dangling withheld + // blobs that the deny-set model leaked. + let td = TempDir::new().unwrap(); + let work = td.path().join("work"); + std::fs::create_dir_all(work.join("public")).unwrap(); + std::fs::write(work.join("public/a.txt"), b"public bytes\n").unwrap(); + let run = |args: &[&str]| { + assert!( + Command::new("git") + .args(args) + .current_dir(&work) + .status() + .unwrap() + .success(), + "git {args:?} failed" + ); + }; + run(&["init", "-q"]); + run(&["config", "user.email", "t@t"]); + run(&["config", "user.name", "t"]); + run(&["add", "."]); + run(&["commit", "-qm", "init"]); + let oid_of = |rev: &str| { + let out = Command::new("git") + .args(["rev-parse", rev]) + .current_dir(&work) + .output() + .unwrap(); + String::from_utf8_lossy(&out.stdout).trim().to_string() + }; + let public_oid = oid_of("HEAD:public/a.txt"); + + std::fs::write(work.join("orphan.bin"), b"DANGLING SECRET\n").unwrap(); + let dangling_oid = { + let out = Command::new("git") + .args(["hash-object", "-w", "orphan.bin"]) + .current_dir(&work) + .output() + .unwrap(); + String::from_utf8_lossy(&out.stdout).trim().to_string() + }; + assert!( + matches!(dangling_oid.len(), 40 | 64), + "precondition: hash-object stored the dangling blob" + ); + + // Path-scoped rule: /secret/** denied to anon, allowed to a listed reader. + let reader = "did:key:zReader"; + let rules = [rule("/secret/**", &[reader])]; + + // Every gate-relevant caller: anonymous, listed reader, owner. None of + // them can put the dangling blob in the allowed set — it has no path. + for caller in [None, Some(reader), Some(OWNER)] { + let allowed = allowed_blob_set_for_caller(&work, &rules, true, OWNER, caller).unwrap(); + assert!( + !allowed.contains(&dangling_oid), + "dangling blob must be absent from allowed-set (caller={caller:?})" + ); + // Sanity: the reachable public blob is still in the set for every + // caller (the rule does not deny /public/**). + assert!( + allowed.contains(&public_oid), + "reachable public blob must be in allowed-set (caller={caller:?})" + ); + } + } + #[test] fn recipients_are_owner_plus_allowed_readers_only() { let (_td, repo, secret_oid, public_oid) = fixture(); diff --git a/crates/gitlawb-node/src/test_support.rs b/crates/gitlawb-node/src/test_support.rs index 98fccc5..6de9b0f 100644 --- a/crates/gitlawb-node/src/test_support.rs +++ b/crates/gitlawb-node/src/test_support.rs @@ -1842,4 +1842,116 @@ mod tests { "walk error fails closed: repo skipped, even the public blob is not served" ); } + + /// #126: a dangling blob (written via `git hash-object -w`, never referenced + /// by any commit/tree) must 404 through `GET /ipfs/{cid}` under path-scoped + /// rules — for anon AND the owner. The pre-#126 deny-set was fail-open by + /// construction: dangling oids were absent from the reachable enumeration + /// and thus absent from the deny-set, so the handler served 200. The + /// allowed-set is fail-closed: dangling oids are absent from the reachable + /// allowed-set, so the handler 404s (per team memory: the owner shift to + /// 404 is the accepted fail-closed default — owners can still + /// `git cat-file` directly). + #[sqlx::test] + async fn ipfs_cid_dangling_blob_fails_closed_under_path_rules(pool: PgPool) { + use crate::db::VisibilityMode; + use gitlawb_core::identity::Keypair; + + let owner = Keypair::generate(); + let owner_did = owner.did().to_string(); + let slug = owner_did.replace([':', '/'], "_"); + let short = owner_did.split(':').next_back().unwrap().to_string(); + let state = test_state(pool).await; + + // Seed a normal repo with `secret/b.txt` reachable from HEAD, so the + // path-scoped rule has something to match — without this the rule has + // no anchor and we'd be testing nothing. + let _fx = seed_cid_repos(&slug, &short, &["dangling"]); + let bare = std::path::PathBuf::from("/tmp") + .join(&slug) + .join("dangling.git"); + + // Write a dangling blob: `git hash-object -w --stdin` adds it to the + // object DB but nothing references it, so the reachable walk never + // enumerates it. + let mut cmd = std::process::Command::new("git"); + cmd.args(["hash-object", "-w", "--stdin"]) + .current_dir(&bare) + .stdin(std::process::Stdio::piped()) + .stdout(std::process::Stdio::piped()); + let mut child = cmd.spawn().expect("spawn git hash-object"); + { + use std::io::Write; + let stdin = child.stdin.as_mut().expect("stdin"); + stdin.write_all(b"DANGLING SECRET\n").expect("write stdin"); + } + let out = child.wait_with_output().expect("hash-object output"); + assert!( + out.status.success(), + "git hash-object: {}", + String::from_utf8_lossy(&out.stderr) + ); + let dangling_oid = String::from_utf8_lossy(&out.stdout).trim().to_string(); + // Sanity: must be a 64-hex sha256 oid, since the repo is sha256-format. + assert_eq!( + dangling_oid.len(), + 64, + "expected sha256 oid: {dangling_oid}" + ); + let dangling_cid = cid_for_oid(&dangling_oid); + + state + .db + .create_repo(&seed_repo(&owner_did, "dangling")) + .await + .expect("seed repo"); + let rec = state + .db + .get_repo(&owner_did, "dangling") + .await + .unwrap() + .unwrap(); + // Path-scoped rule triggers the per-blob allowed-set gate (KTD4). + state + .db + .set_visibility_rule(&rec.id, "/secret/**", VisibilityMode::B, &[], &owner_did) + .await + .expect("deny rule"); + + // anon: the dangling blob is absent from the reachable allowed-set → + // 404, no leak. Pre-#126 (deny-set) would serve 200. + let (st, body) = cid_parts( + cid_router(&state) + .oneshot(cid_anon(&dangling_cid)) + .await + .unwrap(), + ) + .await; + assert_eq!( + st, + StatusCode::NOT_FOUND, + "dangling blob must 404 under path-scoped rules" + ); + assert!( + !body.contains("DANGLING SECRET"), + "404 body must not leak the dangling content" + ); + + // owner (signed): same 404. The dangling blob has no path, so it's + // never visibility-checked → never in the allowed set, even for the + // owner. This is the accepted fail-closed shift documented in the PR. + let (st, body) = cid_parts( + cid_router(&state) + .oneshot(cid_signed(&owner, &dangling_cid)) + .await + .unwrap(), + ) + .await; + assert_eq!( + st, + StatusCode::NOT_FOUND, + "owner also 404s on dangling blobs under path-scoped rules (fail-closed default)" + ); + assert!(!body.contains("DANGLING SECRET")); + } } From 3aa7bf06f8cd9acfc26f6b7cea99441760a4716b Mon Sep 17 00:00:00 2001 From: Gravirei Date: Tue, 30 Jun 2026 20:07:58 +0600 Subject: [PATCH 02/13] perf(ipfs): check object existence before allowed-blob walk Move store::read_object before the allowed_blob_set_for_caller spawn_blocking call so random-CID spray against repos with path-scoped rules cannot trigger full-history git walks on repos that don't carry the object. --- crates/gitlawb-node/src/api/ipfs.rs | 141 ++++++++++++++-------------- 1 file changed, 72 insertions(+), 69 deletions(-) diff --git a/crates/gitlawb-node/src/api/ipfs.rs b/crates/gitlawb-node/src/api/ipfs.rs index 405eaed..2177350 100644 --- a/crates/gitlawb-node/src/api/ipfs.rs +++ b/crates/gitlawb-node/src/api/ipfs.rs @@ -40,14 +40,16 @@ use crate::visibility::{visibility_check, Decision}; /// caller passes. For each iterated row we gate against that row's OWN rules /// (`visibility_check` at `"/"`), never re-resolving via `authorize_repo_read` /// — `get_repo`'s fuzzy match could otherwise authorize a different physical -/// row than the one read (KTD2a). When the row carries path-scoped rules -/// (KTD4) the served object must be either a non-blob (trees/commits are -/// structural; KTD3) OR a blob in the caller's *reachable* allowed-set -/// (`allowed_blob_set_for_caller`). The reachable allowed-set excludes -/// dangling blobs — a blob written via `git hash-object -w` and never -/// committed has no path to gate, so it is fail-closed 404'd under -/// path-scoped rules (#126). Denial and genuine not-found both fall through -/// to an opaque 404. +/// row than the one read (KTD2a). We check object existence via +/// `store::read_object` *before* the expensive reachability walk so random-CID +/// spray cannot trigger full-history git walks on repos that don't carry the +/// object. When the row carries path-scoped rules (KTD4) the served object +/// must be either a non-blob (trees/commits are structural; KTD3) OR a blob +/// in the caller's *reachable* allowed-set (`allowed_blob_set_for_caller`). +/// The reachable allowed-set excludes dangling blobs — a blob written via +/// `git hash-object -w` and never committed has no path to gate, so it is +/// fail-closed 404'd under path-scoped rules (#126). Denial and genuine +/// not-found both fall through to an opaque 404. /// /// Scope: this closes the direct unauthenticated scan, including the dangling /// case. A stale-public mirror row still serves withheld content (tracked @@ -117,76 +119,77 @@ pub async fn get_by_cid( Err(_) => continue, }; + // Check whether the object exists in this repo before any expensive + // reachability walk. This prevents random-CID spray from triggering + // full-history git walks on repos that don't carry the object. + let object = store::read_object(&repo_path, &sha256_hex); + let (obj_type, content) = match object { + Ok(Some(t)) => t, + Ok(None) => continue, + Err(e) => { + tracing::warn!(repo = %repo.name, err = %e, "error reading git object"); + continue; + } + }; + // Per-blob gating only applies when a path-scoped rule exists (KTD4). // Without any path-scoped rule, the "/" gate above is the whole story. + // Trees/commits are always served under path-scoped rules (KTD3). let path_scoped = has_path_scoped_rule(rules); - if path_scoped && !allowed_memo.contains_key(&repo.id) { - let rp = repo_path.clone(); - let r = rules.to_vec(); - let is_public = repo.is_public; - let owner = repo.owner_did.clone(); - let caller_for_walk = caller_owned.clone(); - // Full-history walk shells out to git — keep it off the async runtime. - let walk = tokio::task::spawn_blocking(move || { - allowed_blob_set_for_caller(&rp, &r, is_public, &owner, caller_for_walk.as_deref()) - }) - .await; - // Fail closed on EITHER a task panic (JoinError) or a walk error: - // we cannot prove the caller may read here, so skip this repo and - // let a public copy (if any) serve. Never serve on an unproven gate. - let set = match walk { - Ok(Ok(set)) => set, - Ok(Err(e)) => { - tracing::warn!(repo = %repo.name, err = %e, "allowed-blob walk failed; skipping repo"); - continue; - } - Err(e) => { - tracing::warn!(repo = %repo.name, err = %e, "allowed-blob walk task panicked; skipping repo"); - continue; - } - }; - allowed_memo.insert(repo.id.clone(), set); - } - - match store::read_object(&repo_path, &sha256_hex) { - Ok(Some((obj_type, content))) => { - // Path-scoped rules: serve trees/commits unconditionally - // (structural; KTD3); a blob must be in the reachable - // allowed-set, which excludes dangling blobs (#126). - if path_scoped && obj_type == "blob" { - let in_allowed = allowed_memo - .get(&repo.id) - .is_some_and(|set| set.contains(&sha256_hex)); - if !in_allowed { + if path_scoped && obj_type == "blob" { + if !allowed_memo.contains_key(&repo.id) { + let rp = repo_path.clone(); + let r = rules.to_vec(); + let is_public = repo.is_public; + let owner = repo.owner_did.clone(); + let caller_for_walk = caller_owned.clone(); + // Full-history walk shells out to git — keep it off the async runtime. + let walk = tokio::task::spawn_blocking(move || { + allowed_blob_set_for_caller(&rp, &r, is_public, &owner, caller_for_walk.as_deref()) + }) + .await; + // Fail closed on EITHER a task panic (JoinError) or a walk error: + // we cannot prove the caller may read here, so skip this repo and + // let a public copy (if any) serve. Never serve on an unproven gate. + let set = match walk { + Ok(Ok(set)) => set, + Ok(Err(e)) => { + tracing::warn!(repo = %repo.name, err = %e, "allowed-blob walk failed; skipping repo"); + continue; + } + Err(e) => { + tracing::warn!(repo = %repo.name, err = %e, "allowed-blob walk task panicked; skipping repo"); continue; } - } - - // 3. Return the content with IPFS-style headers - let mut headers = HeaderMap::new(); - headers.insert( - HeaderName::from_static("content-type"), - HeaderValue::from_static("application/octet-stream"), - ); - headers.insert( - HeaderName::from_static("x-content-cid"), - HeaderValue::from_str(&cid_str) - .unwrap_or_else(|_| HeaderValue::from_static("invalid")), - ); - headers.insert( - HeaderName::from_static("x-git-hash"), - HeaderValue::from_str(&sha256_hex) - .unwrap_or_else(|_| HeaderValue::from_static("invalid")), - ); - - return Ok((StatusCode::OK, headers, content).into_response()); + }; + allowed_memo.insert(repo.id.clone(), set); } - Ok(None) => continue, - Err(e) => { - tracing::warn!(repo = %repo.name, err = %e, "error reading git object"); + let in_allowed = allowed_memo + .get(&repo.id) + .is_some_and(|set| set.contains(&sha256_hex)); + if !in_allowed { continue; } } + + // 3. Return the content with IPFS-style headers + let mut headers = HeaderMap::new(); + headers.insert( + HeaderName::from_static("content-type"), + HeaderValue::from_static("application/octet-stream"), + ); + headers.insert( + HeaderName::from_static("x-content-cid"), + HeaderValue::from_str(&cid_str) + .unwrap_or_else(|_| HeaderValue::from_static("invalid")), + ); + headers.insert( + HeaderName::from_static("x-git-hash"), + HeaderValue::from_str(&sha256_hex) + .unwrap_or_else(|_| HeaderValue::from_static("invalid")), + ); + + return Ok((StatusCode::OK, headers, content).into_response()); } // Not found in any repo From 63580c730da890851324a45d872ede1c3c7d9a62 Mon Sep 17 00:00:00 2001 From: Gravirei Date: Tue, 30 Jun 2026 23:10:27 +0600 Subject: [PATCH 03/13] refactor(ipfs): improve formatting and readability in get_by_cid function MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ✓ P3 blocker fixed: cargo fmt applied — the format gate will pass. ✓ P3 cleanup resolved: withheld_blob_oids is still used by replication code in repos.rs, so it stays. • P2 follow-up: Tree/commit disclosure tracked in #135 — out of scope here. --- crates/gitlawb-node/src/api/ipfs.rs | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/crates/gitlawb-node/src/api/ipfs.rs b/crates/gitlawb-node/src/api/ipfs.rs index 2177350..d6405fa 100644 --- a/crates/gitlawb-node/src/api/ipfs.rs +++ b/crates/gitlawb-node/src/api/ipfs.rs @@ -145,7 +145,13 @@ pub async fn get_by_cid( let caller_for_walk = caller_owned.clone(); // Full-history walk shells out to git — keep it off the async runtime. let walk = tokio::task::spawn_blocking(move || { - allowed_blob_set_for_caller(&rp, &r, is_public, &owner, caller_for_walk.as_deref()) + allowed_blob_set_for_caller( + &rp, + &r, + is_public, + &owner, + caller_for_walk.as_deref(), + ) }) .await; // Fail closed on EITHER a task panic (JoinError) or a walk error: @@ -180,8 +186,7 @@ pub async fn get_by_cid( ); headers.insert( HeaderName::from_static("x-content-cid"), - HeaderValue::from_str(&cid_str) - .unwrap_or_else(|_| HeaderValue::from_static("invalid")), + HeaderValue::from_str(&cid_str).unwrap_or_else(|_| HeaderValue::from_static("invalid")), ); headers.insert( HeaderName::from_static("x-git-hash"), From 002f35405874898dc538773f0dcda13bafb56f49 Mon Sep 17 00:00:00 2001 From: Gravirei Date: Wed, 1 Jul 2026 02:25:29 +0600 Subject: [PATCH 04/13] refactor(ipfs): streamline object retrieval by separating type and content reading --- crates/gitlawb-node/src/api/ipfs.rs | 14 +++++++++--- crates/gitlawb-node/src/git/store.rs | 34 ++++++++++++++++++++-------- 2 files changed, 36 insertions(+), 12 deletions(-) diff --git a/crates/gitlawb-node/src/api/ipfs.rs b/crates/gitlawb-node/src/api/ipfs.rs index d6405fa..46cbf73 100644 --- a/crates/gitlawb-node/src/api/ipfs.rs +++ b/crates/gitlawb-node/src/api/ipfs.rs @@ -122,12 +122,11 @@ pub async fn get_by_cid( // Check whether the object exists in this repo before any expensive // reachability walk. This prevents random-CID spray from triggering // full-history git walks on repos that don't carry the object. - let object = store::read_object(&repo_path, &sha256_hex); - let (obj_type, content) = match object { + let obj_type = match store::object_type(&repo_path, &sha256_hex) { Ok(Some(t)) => t, Ok(None) => continue, Err(e) => { - tracing::warn!(repo = %repo.name, err = %e, "error reading git object"); + tracing::warn!(repo = %repo.name, err = %e, "error checking git object type"); continue; } }; @@ -178,6 +177,15 @@ pub async fn get_by_cid( } } + // Now that we've passed the gate, read the content. + let content = match store::read_object_content(&repo_path, &sha256_hex, &obj_type) { + Ok(c) => c, + Err(e) => { + tracing::warn!(repo = %repo.name, err = %e, "error reading git object content"); + continue; + } + }; + // 3. Return the content with IPFS-style headers let mut headers = HeaderMap::new(); headers.insert( diff --git a/crates/gitlawb-node/src/git/store.rs b/crates/gitlawb-node/src/git/store.rs index b975914..290da6c 100644 --- a/crates/gitlawb-node/src/git/store.rs +++ b/crates/gitlawb-node/src/git/store.rs @@ -271,9 +271,8 @@ pub struct TreeEntry { /// `/ipfs/` is computed from these same content bytes via /// `gitlawb_core::cid::Cid::from_git_object_bytes`. /// -/// Returns `None` if the object does not exist in this repo. -pub fn read_object(repo_path: &Path, sha256_hex: &str) -> Result)>> { - // First check if the object exists and get its type +/// Get just the object type. Returns `None` if the object doesn't exist. +pub fn object_type(repo_path: &Path, sha256_hex: &str) -> Result> { let type_output = Command::new("git") .args(["cat-file", "-t", sha256_hex]) .current_dir(repo_path) @@ -284,13 +283,13 @@ pub fn read_object(repo_path: &Path, sha256_hex: &str) -> Result Result> { let content_output = Command::new("git") - .args(["cat-file", &obj_type, sha256_hex]) + .args(["cat-file", obj_type, sha256_hex]) .current_dir(repo_path) .output() .context("failed to run git cat-file ")?; @@ -300,7 +299,24 @@ pub fn read_object(repo_path: &Path, sha256_hex: &str) -> Result` is computed from these same content bytes via +/// `gitlawb_core::cid::Cid::from_git_object_bytes`. +/// +/// Returns `None` if the object does not exist in this repo. +pub fn read_object(repo_path: &Path, sha256_hex: &str) -> Result)>> { + let obj_type = match object_type(repo_path, sha256_hex)? { + Some(t) => t, + None => return Ok(None), + }; + let content = read_object_content(repo_path, sha256_hex, &obj_type)?; + Ok(Some((obj_type, content))) } /// Get the diff between two branches: changes on source_branch not in target_branch. From f2c91a868afb57b7e2a2b949536015490492cef7 Mon Sep 17 00:00:00 2001 From: Gravirei Date: Wed, 1 Jul 2026 07:22:24 +0600 Subject: [PATCH 05/13] docs(ipfs): update get_by_cid comment to reflect split object retrieval --- crates/gitlawb-node/src/api/ipfs.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/gitlawb-node/src/api/ipfs.rs b/crates/gitlawb-node/src/api/ipfs.rs index 46cbf73..f3de757 100644 --- a/crates/gitlawb-node/src/api/ipfs.rs +++ b/crates/gitlawb-node/src/api/ipfs.rs @@ -41,7 +41,7 @@ use crate::visibility::{visibility_check, Decision}; /// (`visibility_check` at `"/"`), never re-resolving via `authorize_repo_read` /// — `get_repo`'s fuzzy match could otherwise authorize a different physical /// row than the one read (KTD2a). We check object existence via -/// `store::read_object` *before* the expensive reachability walk so random-CID +/// `store::object_type` *before* the expensive reachability walk so random-CID /// spray cannot trigger full-history git walks on repos that don't carry the /// object. When the row carries path-scoped rules (KTD4) the served object /// must be either a non-blob (trees/commits are structural; KTD3) OR a blob From 03ba7149fc248798df15f9fb26bf897fd2901b4d Mon Sep 17 00:00:00 2001 From: Gravirei Date: Wed, 1 Jul 2026 23:40:07 +0600 Subject: [PATCH 06/13] Run cargo fmt on store.rs --- crates/gitlawb-node/src/git/store.rs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/crates/gitlawb-node/src/git/store.rs b/crates/gitlawb-node/src/git/store.rs index 290da6c..229ee69 100644 --- a/crates/gitlawb-node/src/git/store.rs +++ b/crates/gitlawb-node/src/git/store.rs @@ -283,7 +283,11 @@ pub fn object_type(repo_path: &Path, sha256_hex: &str) -> Result> return Ok(None); } - Ok(Some(String::from_utf8_lossy(&type_output.stdout).trim().to_string())) + Ok(Some( + String::from_utf8_lossy(&type_output.stdout) + .trim() + .to_string(), + )) } /// Read an object's content if its type is already known. From a18997c15558a29aee0da6f5b8e505a5f4a28b1b Mon Sep 17 00:00:00 2001 From: Gravirei Date: Thu, 2 Jul 2026 08:38:40 +0600 Subject: [PATCH 07/13] 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 | 19 +++++++++++++------ crates/gitlawb-node/src/p2p/mod.rs | 6 ++++++ 4 files changed, 25 insertions(+), 6 deletions(-) diff --git a/crates/gitlawb-node/src/api/peers.rs b/crates/gitlawb-node/src/api/peers.rs index 0e71f06..598217d 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 b74f3f6..5b793f2 100644 --- a/crates/gitlawb-node/src/api/repos.rs +++ b/crates/gitlawb-node/src/api/repos.rs @@ -1200,6 +1200,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 31ff72f..f60287e 100644 --- a/crates/gitlawb-node/src/db/mod.rs +++ b/crates/gitlawb-node/src/db/mod.rs @@ -174,6 +174,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)] @@ -536,8 +539,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, @@ -2089,8 +2094,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) @@ -2104,6 +2109,7 @@ impl Db { .bind(&update.cert_id) .bind(&update.received_at) .bind(&update.from_peer) + .bind(&update.owner_did) .execute(&self.pool) .await?; Ok(()) @@ -2112,7 +2118,7 @@ impl Db { pub async fn list_ref_updates(&self, limit: i64) -> Result> { let rows = sqlx::query( "SELECT id, node_did, pusher_did, repo, ref_name, old_sha, new_sha, timestamp, - cert_id, received_at, from_peer + cert_id, received_at, from_peer, owner_did FROM received_ref_updates ORDER BY timestamp DESC LIMIT $1", ) .bind(limit) @@ -2128,7 +2134,7 @@ impl Db { ) -> Result> { let rows = sqlx::query( "SELECT id, node_did, pusher_did, repo, ref_name, old_sha, new_sha, timestamp, - cert_id, received_at, from_peer + cert_id, received_at, from_peer, owner_did FROM received_ref_updates WHERE repo = $1 ORDER BY timestamp DESC LIMIT $2", ) .bind(repo) @@ -2147,7 +2153,7 @@ impl Db { let rows = if let Some(r) = repo { sqlx::query( "SELECT id, node_did, pusher_did, repo, ref_name, old_sha, new_sha, timestamp, - cert_id, received_at, from_peer + cert_id, received_at, from_peer, owner_did FROM received_ref_updates WHERE repo=$1 ORDER BY timestamp DESC LIMIT $2", ) .bind(r) @@ -2157,7 +2163,7 @@ impl Db { } else { sqlx::query( "SELECT id, node_did, pusher_did, repo, ref_name, old_sha, new_sha, timestamp, - cert_id, received_at, from_peer + cert_id, received_at, from_peer, owner_did FROM received_ref_updates ORDER BY timestamp DESC LIMIT $1", ) .bind(limit) @@ -2469,6 +2475,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 5a6992b..1adbb34 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 bf5667c8cb1619daf05822ceb64ab278af5cc0ad Mon Sep 17 00:00:00 2001 From: Gravirei Date: Thu, 2 Jul 2026 09:10:39 +0600 Subject: [PATCH 08/13] test(node): add ref-update owner_did round-trip, DB, and API tests (#144) --- crates/gitlawb-node/src/api/repos.rs | 14 +++ crates/gitlawb-node/src/db/mod.rs | 159 ++++++++++++++++++++++++ crates/gitlawb-node/src/p2p/mod.rs | 64 ++++++++++ crates/gitlawb-node/src/test_support.rs | 87 +++++++++++++ 4 files changed, 324 insertions(+) diff --git a/crates/gitlawb-node/src/api/repos.rs b/crates/gitlawb-node/src/api/repos.rs index 5b793f2..8dfe870 100644 --- a/crates/gitlawb-node/src/api/repos.rs +++ b/crates/gitlawb-node/src/api/repos.rs @@ -711,6 +711,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, @@ -720,6 +721,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, @@ -767,6 +769,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( @@ -780,6 +783,7 @@ async fn notify_peer_of_refs( new_sha, node_did, pusher_did, + owner_did, ) .await; } @@ -1293,6 +1297,7 @@ pub async fn git_receive_pack( &ref_updates_clone, &node_did_str, &pusher_did_clone, + &record.owner_did, ) .await; } @@ -2260,6 +2265,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) @@ -2271,6 +2279,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) @@ -2292,6 +2303,7 @@ mod tests { &ref_updates, "did:key:zNode", "did:key:zPusher", + "did:key:zOwner", ) .await; @@ -2314,6 +2326,7 @@ 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) @@ -2336,6 +2349,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 f60287e..9a43d56 100644 --- a/crates/gitlawb-node/src/db/mod.rs +++ b/crates/gitlawb-node/src/db/mod.rs @@ -3916,3 +3916,162 @@ mod icaptcha_quarantine_tests { assert!(with_stars.iter().all(|(r, _)| r.name != "spam")); } } + +#[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(100).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(100).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_repo_ref_updates("alice/repo1", 100).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_repo_ref_updates("bob/repo2", 100).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_repo_ref_updates("other/repo", 100).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_filtered(Some("ownerA/proj"), 100) + .await + .unwrap(); + assert_eq!(filtered.len(), 1); + assert_eq!(filtered[0].id, "u5"); + + let all = db.list_ref_updates_filtered(None, 100).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(100).await.unwrap(); + assert_eq!(all.len(), 1); + assert_eq!(all[0].new_sha, "gggg"); + } +} diff --git a/crates/gitlawb-node/src/p2p/mod.rs b/crates/gitlawb-node/src/p2p/mod.rs index 1adbb34..ee47301 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 d84f23a..4a54872 100644 --- a/crates/gitlawb-node/src/test_support.rs +++ b/crates/gitlawb-node/src/test_support.rs @@ -1954,4 +1954,91 @@ mod tests { ); assert!(!body.contains("DANGLING SECRET")); } + + // ── 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 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); + } } From b988780efcdae2f8f520eaca0c3d5093a18b9a03 Mon Sep 17 00:00:00 2001 From: Gravirei Date: Thu, 2 Jul 2026 09:14:46 +0600 Subject: [PATCH 09/13] style(node): cargo fmt --- crates/gitlawb-node/src/api/repos.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/crates/gitlawb-node/src/api/repos.rs b/crates/gitlawb-node/src/api/repos.rs index 8dfe870..801e462 100644 --- a/crates/gitlawb-node/src/api/repos.rs +++ b/crates/gitlawb-node/src/api/repos.rs @@ -2326,7 +2326,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()), + mockito::Matcher::PartialJsonString( + r#"{"owner_did":"did:key:zOwner"}"#.to_string(), + ), ])) .with_status(200) .expect(1) From 7c65f51e69cf9d18d65c33fa2511b0029233cd20 Mon Sep 17 00:00:00 2001 From: Gravirei Date: Thu, 2 Jul 2026 09:30:59 +0600 Subject: [PATCH 10/13] fix(node): update gitlawb dependencies to version 0.4.0 and enhance ref-update event handling with owner DID --- Cargo.lock | 10 ++-- crates/gitlawb-node/src/api/events.rs | 1 + crates/gitlawb-node/src/db/mod.rs | 1 + crates/gitlawb-node/src/test_support.rs | 66 ++++++++++++++----------- 4 files changed, 45 insertions(+), 33 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index d42c370..dc6540e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3300,7 +3300,7 @@ dependencies = [ [[package]] name = "git-remote-gitlawb" -version = "0.3.9" +version = "0.4.0" dependencies = [ "anyhow", "gitlawb-core", @@ -3311,7 +3311,7 @@ dependencies = [ [[package]] name = "gitlawb-attest" -version = "0.3.9" +version = "0.4.0" dependencies = [ "base64", "ed25519-dalek", @@ -3328,7 +3328,7 @@ dependencies = [ [[package]] name = "gitlawb-core" -version = "0.3.9" +version = "0.4.0" dependencies = [ "anyhow", "base64", @@ -3355,7 +3355,7 @@ dependencies = [ [[package]] name = "gitlawb-node" -version = "0.3.9" +version = "0.4.0" dependencies = [ "alloy", "anyhow", @@ -3411,7 +3411,7 @@ dependencies = [ [[package]] name = "gl" -version = "0.3.9" +version = "0.4.0" dependencies = [ "alloy", "anyhow", diff --git a/crates/gitlawb-node/src/api/events.rs b/crates/gitlawb-node/src/api/events.rs index 45db8a0..840f75d 100644 --- a/crates/gitlawb-node/src/api/events.rs +++ b/crates/gitlawb-node/src/api/events.rs @@ -35,6 +35,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(); diff --git a/crates/gitlawb-node/src/db/mod.rs b/crates/gitlawb-node/src/db/mod.rs index 9a43d56..4532be3 100644 --- a/crates/gitlawb-node/src/db/mod.rs +++ b/crates/gitlawb-node/src/db/mod.rs @@ -4052,6 +4052,7 @@ mod ref_update_db_tests { .unwrap(); assert_eq!(filtered.len(), 1); assert_eq!(filtered[0].id, "u5"); + assert_eq!(filtered[0].owner_did.as_deref(), Some("did:key:zA")); let all = db.list_ref_updates_filtered(None, 100).await.unwrap(); assert_eq!(all.len(), 2); diff --git a/crates/gitlawb-node/src/test_support.rs b/crates/gitlawb-node/src/test_support.rs index 4a54872..083345e 100644 --- a/crates/gitlawb-node/src/test_support.rs +++ b/crates/gitlawb-node/src/test_support.rs @@ -1966,6 +1966,29 @@ mod tests { .with_state(state) } + fn update( + repo: &str, + owner_did: Option<&str>, + new_sha: &str, + timestamp: &str, + received_at: &str, + ) -> crate::db::ReceivedRefUpdate { + crate::db::ReceivedRefUpdate { + id: uuid::Uuid::new_v4().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: "refs/heads/main".into(), + old_sha: "0000000000000000000000000000000000000000".into(), + new_sha: new_sha.to_string(), + timestamp: timestamp.to_string(), + cert_id: None, + received_at: received_at.to_string(), + from_peer: "12D3KooWTest".into(), + } + } + #[sqlx::test] async fn events_returns_inserted_ref_updates(pool: PgPool) { let state = test_state(pool).await; @@ -1974,20 +1997,13 @@ mod tests { // 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(), - }) + .insert_ref_update(&update( + &format!("{}/myrepo", owner.split(':').next_back().unwrap()), + Some(owner), + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "2026-07-02T12:00:00Z", + "2026-07-02T12:00:01Z", + )) .await .unwrap(); @@ -2004,6 +2020,7 @@ mod tests { events[0]["repo"], format!("{}/myrepo", owner.split(':').next_back().unwrap()) ); + assert_eq!(events[0]["owner_did"], owner); } #[sqlx::test] @@ -2014,20 +2031,13 @@ mod tests { 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(), - }) + .insert_ref_update(&update( + &format!("{}/r{i}", owner.split(':').next_back().unwrap()), + Some(owner), + &format!("{i:040x}"), + &format!("2026-07-02T12:00:{i:02}Z"), + &format!("2026-07-02T12:00:{i:02}Z"), + )) .await .unwrap(); } From 72ccb28dbaa194a3958fe2ea5474f0130e802659 Mon Sep 17 00:00:00 2001 From: Gravirei Date: Thu, 2 Jul 2026 11:21:42 +0600 Subject: [PATCH 11/13] fix(node): move owner_did migration to v10, add upgrade-path test --- crates/gitlawb-node/src/db/mod.rs | 57 +++++++++++++++++++++++++++++-- 1 file changed, 55 insertions(+), 2 deletions(-) diff --git a/crates/gitlawb-node/src/db/mod.rs b/crates/gitlawb-node/src/db/mod.rs index 4532be3..2fb7715 100644 --- a/crates/gitlawb-node/src/db/mod.rs +++ b/crates/gitlawb-node/src/db/mod.rs @@ -539,10 +539,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, @@ -827,6 +825,14 @@ const MIGRATIONS: &[Migration] = &[ "ALTER TABLE repos ADD COLUMN IF NOT EXISTS quarantined BOOLEAN NOT NULL DEFAULT FALSE", ], }, + Migration { + version: 10, + name: "ref_update_owner_did", + stmts: &[ + "ALTER TABLE received_ref_updates ADD COLUMN IF NOT EXISTS owner_did TEXT", + "CREATE INDEX IF NOT EXISTS idx_ref_updates_owner ON received_ref_updates(owner_did)", + ], + }, ]; // ── Repos ───────────────────────────────────────────────────────────────────── @@ -3186,6 +3192,53 @@ 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 v10 creates the owner_did + /// column and index. 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_v10_creates_owner_did_column(pool: sqlx::PgPool) { + let db = super::Db::for_testing(pool); + + // Run the full migration (v1..v10) 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 the index exists. + let idx: (i64,) = sqlx::query_as( + "SELECT COUNT(*) FROM pg_indexes + WHERE tablename = 'received_ref_updates' AND indexname = 'idx_ref_updates_owner'", + ) + .fetch_one(&db.pool) + .await + .unwrap(); + assert_eq!(idx.0, 1, "idx_ref_updates_owner must exist"); + + // Verify version 10 is recorded as applied. + let v10_count: (i64,) = + sqlx::query_as("SELECT COUNT(*) FROM schema_migrations WHERE version = 10") + .fetch_one(&db.pool) + .await + .unwrap(); + assert_eq!( + v10_count.0, 1, + "migration v10 must be recorded in schema_migrations" + ); + + // Re-run: idempotent — ADD COLUMN IF NOT EXISTS must not error. + db.migrate().await.unwrap(); + } } #[cfg(test)] From 59c26a76d7737b497bada26810e3f76d580dfa14 Mon Sep 17 00:00:00 2001 From: Gravirei Date: Thu, 2 Jul 2026 21:59:15 +0600 Subject: [PATCH 12/13] fix(#144): fold in P2 review items - Drop idx_ref_updates_owner from Migration v10; no query reads owner_did yet and CREATE INDEX (non-CONCURRENT) inside a transaction takes a write-blocking lock on populated nodes for zero benefit. Defer the index to #143 alongside the query that uses it. - Expose owner_did on repo-scoped events feed: add the field to the gossip_events json! block in list_repo_events so GET /api/v1/repos/{owner}/{repo}/events is consistent with GET /api/v1/events/ref-updates. - Carry owner_did through GraphQL: add owner_did: Option to RefUpdateBroadcast (state.rs) and RefUpdateType (types.rs), populate it from the DB row in query.rs, from the broadcast in subscription.rs, and from the trusted local record.owner_did in the broadcast send in repos.rs. - Add migration_v10_existing_node_upgrade test: seeds a v1..v9 state, strips the column and version row, re-runs migrate(), and asserts the column and v10 row appear. This is the regression guard: it fails if the DDL is accidentally moved back into v1. Refs #144 --- crates/gitlawb-node/src/api/events.rs | 1 + crates/gitlawb-node/src/api/repos.rs | 1 + crates/gitlawb-node/src/db/mod.rs | 80 +++++++++++++++---- crates/gitlawb-node/src/graphql/query.rs | 1 + .../gitlawb-node/src/graphql/subscription.rs | 1 + crates/gitlawb-node/src/graphql/types.rs | 1 + crates/gitlawb-node/src/state.rs | 1 + 7 files changed, 72 insertions(+), 14 deletions(-) diff --git a/crates/gitlawb-node/src/api/events.rs b/crates/gitlawb-node/src/api/events.rs index 840f75d..7910c33 100644 --- a/crates/gitlawb-node/src/api/events.rs +++ b/crates/gitlawb-node/src/api/events.rs @@ -127,6 +127,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", }) }) diff --git a/crates/gitlawb-node/src/api/repos.rs b/crates/gitlawb-node/src/api/repos.rs index 801e462..804abb4 100644 --- a/crates/gitlawb-node/src/api/repos.rs +++ b/crates/gitlawb-node/src/api/repos.rs @@ -1228,6 +1228,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: Some(owner_did_for_arweave.clone()), }); } diff --git a/crates/gitlawb-node/src/db/mod.rs b/crates/gitlawb-node/src/db/mod.rs index 2fb7715..b6f2836 100644 --- a/crates/gitlawb-node/src/db/mod.rs +++ b/crates/gitlawb-node/src/db/mod.rs @@ -829,8 +829,10 @@ const MIGRATIONS: &[Migration] = &[ version: 10, name: "ref_update_owner_did", stmts: &[ + // Index deferred to #143 — no query reads owner_did yet, and + // CREATE INDEX (non-CONCURRENT) inside a transaction takes a + // write-blocking lock on already-populated nodes for zero benefit. "ALTER TABLE received_ref_updates ADD COLUMN IF NOT EXISTS owner_did TEXT", - "CREATE INDEX IF NOT EXISTS idx_ref_updates_owner ON received_ref_updates(owner_did)", ], }, ]; @@ -3193,9 +3195,8 @@ mod migration_tests { assert_eq!(MIGRATIONS[0].name, MIGRATION_V1_NAME); } - /// Run a full migration from scratch and verify v10 creates the owner_did - /// column and index. Also verifies that an existing node re-running the - /// migration won't error (idempotent ALTER TABLE ADD COLUMN IF NOT EXISTS). + /// Fresh-DB path: verify v10 adds the owner_did column and records the + /// version row. Also exercises idempotency (re-run must not error). #[sqlx::test] async fn migration_v10_creates_owner_did_column(pool: sqlx::PgPool) { let db = super::Db::for_testing(pool); @@ -3215,16 +3216,6 @@ mod migration_tests { assert_eq!(col.0, "owner_did"); assert_eq!(col.1, "text"); - // Verify the index exists. - let idx: (i64,) = sqlx::query_as( - "SELECT COUNT(*) FROM pg_indexes - WHERE tablename = 'received_ref_updates' AND indexname = 'idx_ref_updates_owner'", - ) - .fetch_one(&db.pool) - .await - .unwrap(); - assert_eq!(idx.0, 1, "idx_ref_updates_owner must exist"); - // Verify version 10 is recorded as applied. let v10_count: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM schema_migrations WHERE version = 10") @@ -3239,6 +3230,67 @@ mod migration_tests { // Re-run: idempotent — ADD COLUMN IF NOT EXISTS must not error. db.migrate().await.unwrap(); } + + /// Existing-node upgrade path: simulate a node whose schema_migrations + /// already contains v1..v9 and whose received_ref_updates table lacks + /// owner_did (as it would before this PR). After migrate() the column + /// and v10 schema_migrations row must exist. + /// + /// This is the regression guard the reviewer asked for: if the DDL is + /// accidentally moved back into v1 this test will fail because the ALTER + /// is skipped (v1 is already applied) and the column is never added. + #[sqlx::test] + async fn migration_v10_existing_node_upgrade(pool: sqlx::PgPool) { + let db = super::Db::for_testing(pool); + + // ── Bootstrap: apply only v1..v9 so the DB looks like a pre-v10 node. ── + // We run v1 via migrate() on a fresh pool first, then manually delete + // v10 from schema_migrations and drop the column if it exists. + db.migrate().await.unwrap(); + + // Undo v10 so we simulate the pre-upgrade state. + sqlx::query("DELETE FROM schema_migrations WHERE version = 10") + .execute(&db.pool) + .await + .unwrap(); + sqlx::query("ALTER TABLE received_ref_updates DROP COLUMN IF EXISTS owner_did") + .execute(&db.pool) + .await + .unwrap(); + + // Confirm the column is truly absent before the test proper. + let pre_count: (i64,) = sqlx::query_as( + "SELECT COUNT(*) FROM information_schema.columns + WHERE table_name = 'received_ref_updates' AND column_name = 'owner_did'", + ) + .fetch_one(&db.pool) + .await + .unwrap(); + assert_eq!(pre_count.0, 0, "owner_did must be absent before upgrade"); + + // ── Re-run migrate() — this is the operation that must add the column. ── + db.migrate().await.unwrap(); + + // Column must exist. + 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"); + + // v10 must be recorded. + let v10_count: (i64,) = + sqlx::query_as("SELECT COUNT(*) FROM schema_migrations WHERE version = 10") + .fetch_one(&db.pool) + .await + .unwrap(); + assert_eq!(v10_count.0, 1, "v10 must be recorded after upgrade"); + } } #[cfg(test)] diff --git a/crates/gitlawb-node/src/graphql/query.rs b/crates/gitlawb-node/src/graphql/query.rs index 2caeefb..a9109b2 100644 --- a/crates/gitlawb-node/src/graphql/query.rs +++ b/crates/gitlawb-node/src/graphql/query.rs @@ -66,6 +66,7 @@ impl QueryRoot { pusher_did: u.pusher_did, node_did: u.node_did, timestamp: u.timestamp, + owner_did: u.owner_did, }) .collect()) } diff --git a/crates/gitlawb-node/src/graphql/subscription.rs b/crates/gitlawb-node/src/graphql/subscription.rs index 8fd0b30..0e0135a 100644 --- a/crates/gitlawb-node/src/graphql/subscription.rs +++ b/crates/gitlawb-node/src/graphql/subscription.rs @@ -30,6 +30,7 @@ impl SubscriptionRoot { pusher_did: ev.pusher_did, node_did: ev.node_did, timestamp: ev.timestamp, + owner_did: ev.owner_did, }), _ => None, } diff --git a/crates/gitlawb-node/src/graphql/types.rs b/crates/gitlawb-node/src/graphql/types.rs index 918701f..4264a58 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 85ce0a8..c0c5db1 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: Option, } #[derive(Clone, Debug)] From 63ab6015749040c970604482b5a5638025aa5b3f Mon Sep 17 00:00:00 2001 From: Gravirei Date: Thu, 2 Jul 2026 22:08:58 +0600 Subject: [PATCH 13/13] fix(#144): add owner_did to local cert events in list_repo_events Local cert events in the repo-scoped feed now include owner_did sourced from the trusted local record, matching the field already present on gossipsub events. Keeps the payload consistent for consumers that gate or dedup by full owner DID. Refs #144 --- crates/gitlawb-node/src/api/events.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/crates/gitlawb-node/src/api/events.rs b/crates/gitlawb-node/src/api/events.rs index 7910c33..01d666a 100644 --- a/crates/gitlawb-node/src/api/events.rs +++ b/crates/gitlawb-node/src/api/events.rs @@ -97,6 +97,7 @@ pub async fn list_repo_events( "new_sha": c.new_sha, "pusher_did": c.pusher_did, "node_did": c.node_did, + "owner_did": record.owner_did, "timestamp": c.issued_at, "source": "local", })