Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
04263db
feat(db): add migration v24 for status claims, PR head, and push events
beardthelion Aug 7, 2026
af17831
feat(pulls): track the source branch head on open pull requests
beardthelion Aug 7, 2026
ac31868
feat(api): add the owner-gated commit status write path
beardthelion Aug 7, 2026
6663fed
feat(api): serve the combined commit status behind the visibility gate
beardthelion Aug 7, 2026
7be9f08
feat(api): add the pull request head rollup
beardthelion Aug 7, 2026
db3681e
feat(api): add the catch-up poll surface for missed push webhooks
beardthelion Aug 7, 2026
90b8294
fix(api): resolve the rollup head from push events, not pinned CIDs
beardthelion Aug 7, 2026
ad71f79
fix(status): make a stored claim actually re-verifiable
beardthelion Aug 8, 2026
806f902
fix(status): make the caps hold and the projection mean one identity
beardthelion Aug 8, 2026
612d9d2
fix(api): order the poll cursor on a sequence and batch the push writes
beardthelion Aug 8, 2026
d3074a6
fix(status): make a replayed write idempotent and stop dropping refs
beardthelion Aug 8, 2026
5445dda
fix(db): stop the push cursor skipping a concurrently committed row
beardthelion Aug 8, 2026
61033af
fix(auth): carry the request body only where a route persists it
beardthelion Aug 8, 2026
a4e2b9f
fix(status): bind a stored claim to the body its signature covered
kevincodex1 Aug 31, 2026
8652b86
fix(api): apply a push's side effects only to the refs git accepted
kevincodex1 Aug 31, 2026
6b0763a
fix(api): make the push-event cursor opaque and bound to its repository
kevincodex1 Aug 31, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1,232 changes: 1,232 additions & 0 deletions crates/gitlawb-node/src/api/events.rs

Large diffs are not rendered by default.

40 changes: 30 additions & 10 deletions crates/gitlawb-node/src/api/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ pub mod replicas;
pub mod repos;
pub mod resolve;
pub mod stars;
pub mod status;
pub mod tasks;
pub mod visibility;
pub mod webhooks;
Expand Down Expand Up @@ -67,17 +68,14 @@ pub(crate) async fn authorize_repo_read(
/// representation only within `did:key`; never let a bare id match across methods —
/// `did:web` / `did:gitlawb` share the base58 space with `did:key`, so a
/// trailing-segment compare would treat `did:key:X` and `did:gitlawb:X` as equal.
///
/// The collapse itself lives in exactly one place, [`crate::db::normalize_owner_key`],
/// which is also the function the stored `owner_did` / `authorizing_did` columns
/// are normalized through and which `OWNER_KEY_CASE_SQL` mirrors byte for byte.
/// Two identities match when they normalize to the same key; a second copy of the
/// rule here is how the Rust gate and the SQL filters would drift apart.
pub(crate) fn did_matches(a: &str, b: &str) -> bool {
if a == b {
return true;
}
fn key_id(d: &str) -> &str {
d.strip_prefix("did:key:").unwrap_or(d)
}
let (ka, kb) = (key_id(a), key_id(b));
// After stripping `did:key:`, a value still containing ':' is a non-key full
// DID — do not let it match a bare `did:key` id.
!ka.contains(':') && !kb.contains(':') && ka == kb
crate::db::normalize_owner_key(a) == crate::db::normalize_owner_key(b)
}

/// 403 unless `caller` is the repo owner. Uses [`did_matches`] so the owner check
Expand Down Expand Up @@ -175,6 +173,7 @@ mod authz_guard {
let events = include_str!("events.rs");
let tasks = include_str!("tasks.rs");
let stars = include_str!("stars.rs");
let status = include_str!("status/mod.rs");
let protect = include_str!("protect.rs");
let visibility = include_str!("visibility.rs");
let profiles = include_str!("profiles.rs");
Expand All @@ -195,6 +194,13 @@ mod authz_guard {
// (existence hiding) and require_repo_owner guards the owner half.
(webhooks, "list_webhooks", "authorize_repo_read("),
(webhooks, "list_webhooks", "require_repo_owner("),
// Same two-half shape as list_webhooks: authorize_repo_read runs
// first (a quarantined or unreadable repo gets the missing-repo
// not-found, never a 403 that would confirm existence), then
// require_repo_owner 403s a non-owner of a readable repo. Both halves
// are pinned, because dropping either changes what a stranger learns.
(status, "create_status", "authorize_repo_read("),
(status, "create_status", "require_repo_owner("),
(labels, "add_label", "require_repo_owner("),
(labels, "remove_label", "require_repo_owner("),
// Bucket A' — owner OR author (did_matches against the author)
Expand All @@ -217,6 +223,19 @@ mod authz_guard {
(protect, "list_protected_branches", "authorize_repo_read("),
(labels, "list_labels", "authorize_repo_read("),
(events, "list_repo_events", "authorize_repo_read("),
// The catch-up poll surface gates before any push-event row is read,
// so a stranger cannot use it to learn that a private repo exists or
// is being pushed to.
(events, "list_repo_push_events", "authorize_repo_read("),
// The status read gates before any claim data is loaded, so its deny
// is the repo's own not-found rather than an existence oracle over
// which commits were reported on.
(status, "commit_status", "authorize_repo_read("),
// The rollup gates on the same helper before the pull request row is
// loaded, so a caller who cannot read the repo cannot learn which
// pull request numbers exist on it — and the fallback's branch
// resolve and head persist sit behind that same gate.
(status, "pull_request_status", "authorize_repo_read("),
// Bucket C — signer-self: the acting DID is matched/bound to auth.0
(tasks, "create_task", "did_matches("),
(tasks, "claim_task", "did_matches("),
Expand Down Expand Up @@ -490,6 +509,7 @@ mod authz_guard {
(include_str!("replicas.rs"), "replicas.rs"),
(include_str!("repos.rs"), "repos.rs"),
(include_str!("stars.rs"), "stars.rs"),
(include_str!("status/mod.rs"), "status/mod.rs"),
(include_str!("visibility.rs"), "visibility.rs"),
(include_str!("webhooks.rs"), "webhooks.rs"),
];
Expand Down
175 changes: 174 additions & 1 deletion crates/gitlawb-node/src/api/pulls.rs
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ pub async fn create_pr(
status: "open".to_string(),
merged_by_did: None,
merged_at: None,
head_commit: None,
created_at: now.clone(),
updated_at: now,
};
Expand Down Expand Up @@ -216,6 +217,19 @@ pub async fn merge_pr(
.map_err(|e| AppError::Git(e.to_string()))?;
let disk_path = guard.path().to_path_buf();
let merger_did = auth.0;

// The source head this merge is about to consume, read under the write lock.
// The PR row above was loaded before the lock, so a push landing in between
// already moved the branch and the row's own `head_commit` is stale; the
// value frozen on the merged PR has to be what was merged. `None` when the
// ref cannot be resolved, which leaves whatever the push path last stored.
let merged_source_head = store::list_refs(&disk_path).ok().and_then(|refs| {
let want = format!("refs/heads/{}", pr.source_branch);
refs.into_iter()
.find(|(name, _)| *name == want)
.map(|(_, sha)| sha)
});

let merge_result = store::merge_branch(
&disk_path,
&pr.target_branch,
Expand All @@ -229,7 +243,10 @@ pub async fn merge_pr(

let merge_sha = merge_result.map_err(|e| AppError::Git(e.to_string()))?;

state.db.merge_pr(&pr.id, &merger_did).await?;
state
.db
.merge_pr(&pr.id, &merger_did, merged_source_head.as_deref())
.await?;
let _ = state.db.touch_repo(&record.id).await;

webhooks::fire_event(
Expand Down Expand Up @@ -424,3 +441,159 @@ pub async fn list_comments(
let comments = state.db.list_pr_comments(&pr.id).await?;
Ok(Json(serde_json::json!({ "comments": comments })))
}

#[cfg(test)]
mod tests {
use super::*;
use axum::body::Body;
use axum::http::{Method, StatusCode};
use axum::routing::post;
use axum::Router;
use std::process::Command;
use tower::ServiceExt;

const OWNER_DID: &str = "did:key:z6MkpTHR8VNsBxYAAWHut2Geadd9jSwuBV8xRoAnwWsdvktH";
const STALE_SHA: &str = "9999999999999999999999999999999999999999";

fn git(dir: &std::path::Path, args: &[&str]) {
let out = Command::new("git")
.args(args)
.current_dir(dir)
.env("GIT_AUTHOR_NAME", "t")
.env("GIT_AUTHOR_EMAIL", "t@example.com")
.env("GIT_COMMITTER_NAME", "t")
.env("GIT_COMMITTER_EMAIL", "t@example.com")
.output()
.expect("git runs");
assert!(
out.status.success(),
"git {args:?} failed: {}",
String::from_utf8_lossy(&out.stderr)
);
}

/// A bare repo with `main` and a `feature` branch one commit ahead.
/// Returns the bare path and the real `feature` tip.
fn bare_repo_with_feature(
root: &std::path::Path,
owner_slug: &str,
) -> (std::path::PathBuf, String) {
let work = root.join("work");
std::fs::create_dir_all(&work).unwrap();
git(&work, &["init", "-q", "-b", "main", "."]);
std::fs::write(work.join("a.txt"), "one").unwrap();
git(&work, &["add", "."]);
git(&work, &["commit", "-qm", "one"]);
git(&work, &["checkout", "-qb", "feature"]);
std::fs::write(work.join("b.txt"), "two").unwrap();
git(&work, &["add", "."]);
git(&work, &["commit", "-qm", "two"]);
git(&work, &["checkout", "-q", "main"]);

let bare_dir = root.join(owner_slug);
std::fs::create_dir_all(&bare_dir).unwrap();
let bare = bare_dir.join("demo.git");
git(
root,
&[
"clone",
"-q",
"--bare",
work.to_str().unwrap(),
bare.to_str().unwrap(),
],
);

let tip = Command::new("git")
.args(["rev-parse", "refs/heads/feature"])
.current_dir(&bare)
.output()
.unwrap();
let tip = String::from_utf8_lossy(&tip.stdout).trim().to_string();
assert_eq!(tip.len(), 40, "feature tip must resolve");
(bare, tip)
}

/// Merging freezes the stored head at the source commit the merge actually
/// consumed. The PR row is loaded before the write lock is taken, so a push
/// that lands in between leaves the row's own `head_commit` stale; seeding a
/// value that matches nothing on disk stands in for that race, and the
/// merged PR must still come out pointing at the real `feature` tip.
#[sqlx::test]
async fn merge_stamps_the_source_head_it_merged_over_a_racing_value(pool: sqlx::PgPool) {
let tmp = tempfile::tempdir().unwrap();
let owner_slug = OWNER_DID.replace([':', '/'], "_");
let (bare, feature_tip) = bare_repo_with_feature(tmp.path(), &owner_slug);

let mut state = crate::test_support::test_state(pool.clone()).await;
state.repo_store =
crate::git::repo_store::RepoStore::for_testing(tmp.path().to_path_buf(), pool);

let now = Utc::now();
let repo = crate::db::RepoRecord {
id: uuid::Uuid::new_v4().to_string(),
name: "demo".into(),
owner_did: OWNER_DID.into(),
description: None,
is_public: true,
default_branch: "main".into(),
created_at: now,
updated_at: now,
disk_path: bare.to_string_lossy().into_owned(),
forked_from: None,
machine_id: None,
};
state.db.create_repo(&repo).await.unwrap();

let pr = PullRequest {
id: Uuid::new_v4().to_string(),
repo_id: repo.id.clone(),
number: 1,
title: "add b".into(),
body: None,
author_did: OWNER_DID.into(),
source_branch: "feature".into(),
target_branch: "main".into(),
status: "open".into(),
merged_by_did: None,
merged_at: None,
head_commit: None,
created_at: now.to_rfc3339(),
updated_at: now.to_rfc3339(),
};
state.db.create_pr(&pr).await.unwrap();
// The stale value the racing push scenario leaves on the row.
state
.db
.set_open_pr_heads(&repo.id, "feature", STALE_SHA)
.await
.unwrap();

let db = state.db.clone();
let app = Router::new()
.route(
"/api/v1/repos/{owner}/{repo}/pulls/{number}/merge",
post(merge_pr),
)
.with_state(state);

let response = app
.oneshot(crate::test_support::signed_request_as(
OWNER_DID,
Method::POST,
"/api/v1/repos/z6MkpTHR8VNsBxYAAWHut2Geadd9jSwuBV8xRoAnwWsdvktH/demo/pulls/1/merge",
Body::empty(),
))
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);

let merged = db.get_pr(&repo.id, 1).await.unwrap().unwrap();
assert_eq!(merged.status, "merged");
assert_eq!(
merged.head_commit,
Some(feature_tip),
"the merge must stamp the source head it consumed, not the racing value"
);
}
}
Loading
Loading