Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
4506480
fix(node): gate GET /ipfs/{cid} on reachable allowed-set, not deny-se…
Gravirei Jun 30, 2026
3aa7bf0
perf(ipfs): check object existence before allowed-blob walk
Gravirei Jun 30, 2026
63580c7
refactor(ipfs): improve formatting and readability in get_by_cid func…
Gravirei Jun 30, 2026
002f354
refactor(ipfs): streamline object retrieval by separating type and co…
Gravirei Jun 30, 2026
f2c91a8
docs(ipfs): update get_by_cid comment to reflect split object retrieval
Gravirei Jul 1, 2026
0b7c494
Merge remote-tracking branch 'upstream/main'
Gravirei Jul 1, 2026
03ba714
Run cargo fmt on store.rs
Gravirei Jul 1, 2026
03c90f8
Merge remote-tracking branch 'upstream/main'
Gravirei Jul 2, 2026
94bb216
Merge remote-tracking branch 'upstream/main'
Gravirei Jul 3, 2026
79650df
Merge remote-tracking branch 'upstream/main'
Gravirei Jul 5, 2026
26d8d48
Merge remote-tracking branch 'upstream/main'
Gravirei Jul 6, 2026
3ec0acc
fix(visibility): gate repo-scoped read surfaces on visibility (#120)
Gravirei Jul 6, 2026
f3b08c1
fix(visibility): enhance repo read authorization checks and improve t…
Gravirei Jul 6, 2026
459e21e
Fix review feedback: clamp limit, memoize authz, identical 404 body, …
Gravirei Jul 7, 2026
6fd29c9
cargo update -p crossbeam-epoch: fix RUSTSEC-2026-0204 (invalid point…
Gravirei Jul 7, 2026
119bfb1
Fix review findings: correct authz memoization, page past hidden boun…
Gravirei Jul 7, 2026
0be5e42
Fix findings: sign CLI/MCP reads, keyset pagination, gate claim_bounty
Gravirei Jul 7, 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
14 changes: 7 additions & 7 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

119 changes: 103 additions & 16 deletions crates/gitlawb-node/src/api/bounties.rs
Original file line number Diff line number Diff line change
Expand Up @@ -110,19 +110,28 @@ pub async fn create_bounty(
Ok((StatusCode::CREATED, Json(bounty)))
}

const MAX_BOUNTY_LIMIT: i64 = 200;

/// GET /api/v1/repos/{owner}/{repo}/bounties
pub async fn list_repo_bounties(
State(state): State<AppState>,
Path((owner, repo)): Path<(String, String)>,
Query(q): Query<ListBountiesQuery>,
auth: Option<Extension<AuthenticatedDid>>,
) -> Result<Json<serde_json::Value>> {
let caller = auth.as_ref().map(|e| e.0 .0.as_str());
crate::api::authorize_repo_read(&state, &owner, &repo, caller, "/").await?;

let limit = q.limit.unwrap_or(50).clamp(1, MAX_BOUNTY_LIMIT);
let bounties = state
.db
.list_bounties(
Some(&owner),
Some(&repo),
q.status.as_deref(),
q.limit.unwrap_or(50),
limit,
None,
None,
)
.await?;

Expand All @@ -133,25 +142,92 @@ pub async fn list_repo_bounties(
pub async fn list_all_bounties(
State(state): State<AppState>,
Query(q): Query<ListBountiesQuery>,
auth: Option<Extension<AuthenticatedDid>>,
) -> Result<Json<serde_json::Value>> {
let bounties = state
.db
.list_bounties(None, None, q.status.as_deref(), q.limit.unwrap_or(50))
.await?;
let raw_limit = q.limit.unwrap_or(50);
let limit = raw_limit.clamp(1, MAX_BOUNTY_LIMIT);

let caller = auth.as_ref().map(|e| e.0 .0.as_str());
let mut memo = std::collections::HashMap::new();
let mut allowed: Vec<BountyRecord> = Vec::new();
let page_size = (limit * 5).clamp(1, 200);
let max_scanned = 10_000i64;
let mut total_scanned: i64 = 0;
let mut cursor: Option<(String, String)> = None;

while (allowed.len() as i64) < limit {
let after = cursor.as_ref().map(|(ts, id)| (ts.as_str(), id.as_str()));
let bounties = state
.db
.list_bounties(
None,
None,
q.status.as_deref(),
page_size,
after.map(|(ts, _)| ts),
after.map(|(_, id)| id),
)
.await?;
if bounties.is_empty() {
break;
}

Ok(Json(serde_json::json!({ "bounties": bounties })))
total_scanned += bounties.len() as i64;
if total_scanned > max_scanned {
return Err(AppError::Incomplete(
"too many rows scanned; refine your filters".into(),
));
}

let last = bounties.last().unwrap();
for b in &bounties {
if (allowed.len() as i64) >= limit {
break;
}
let key = format!("{}/{}", b.repo_owner, b.repo_name);
let admitted = if let Some(&result) = memo.get(&key) {
result
} else {
let result = crate::api::authorize_repo_read(
&state,
&b.repo_owner,
&b.repo_name,
caller,
"/",
)
.await
.is_ok();
memo.insert(key, result);
result
};
if admitted {
allowed.push(b.clone());
}
}
cursor = Some((last.created_at.clone(), last.id.clone()));
}

Ok(Json(serde_json::json!({ "bounties": allowed })))
}

/// GET /api/v1/bounties/{id}
pub async fn get_bounty(
State(state): State<AppState>,
Path(id): Path<String>,
auth: Option<Extension<AuthenticatedDid>>,
) -> Result<Json<BountyRecord>> {
let bounty = state
.db
.get_bounty(&id)
.await?
.ok_or_else(|| AppError::NotFound(format!("bounty {id} not found")))?;
let not_found = || AppError::NotFound(format!("bounty {id} not found"));

let bounty = state.db.get_bounty(&id).await?.ok_or_else(not_found)?;

let caller = auth.as_ref().map(|e| e.0 .0.as_str());
if crate::api::authorize_repo_read(&state, &bounty.repo_owner, &bounty.repo_name, caller, "/")
.await
.is_err()
{
return Err(not_found());
}

Ok(Json(bounty))
}

Expand All @@ -162,11 +238,22 @@ pub async fn claim_bounty(
Path(id): Path<String>,
Json(req): Json<ClaimBountyRequest>,
) -> Result<Json<BountyRecord>> {
let bounty = state
.db
.get_bounty(&id)
.await?
.ok_or_else(|| AppError::NotFound(format!("bounty {id} not found")))?;
let not_found = || AppError::NotFound(format!("bounty {id} not found"));

let bounty = state.db.get_bounty(&id).await?.ok_or_else(not_found)?;

if crate::api::authorize_repo_read(
&state,
&bounty.repo_owner,
&bounty.repo_name,
Some(auth.0.as_str()),
"/",
)
.await
.is_err()
{
return Err(not_found());
}

if bounty.status != "open" {
return Err(AppError::BadRequest(format!(
Expand Down
26 changes: 14 additions & 12 deletions crates/gitlawb-node/src/api/certs.rs
Original file line number Diff line number Diff line change
@@ -1,21 +1,21 @@
//! API handlers for ref certificates.

use axum::extract::{Path, State};
use axum::extract::{Extension, Path, State};
use axum::Json;

use crate::auth::AuthenticatedDid;
use crate::error::{AppError, Result};
use crate::state::AppState;

/// GET /api/v1/repos/{owner}/{repo}/certs
pub async fn list_certs(
State(state): State<AppState>,
Path((owner, name)): Path<(String, String)>,
auth: Option<Extension<AuthenticatedDid>>,
) -> Result<Json<serde_json::Value>> {
let record = state
.db
.get_repo(&owner, &name)
.await?
.ok_or_else(|| AppError::RepoNotFound(format!("{owner}/{name}")))?;
let caller = auth.as_ref().map(|e| e.0 .0.as_str());
let (record, _rules) =
crate::api::authorize_repo_read(&state, &owner, &name, caller, "/").await?;

let certs = state.db.list_ref_certificates(&record.id).await?;
let certs_json: Vec<serde_json::Value> = certs
Expand All @@ -42,20 +42,22 @@ pub async fn list_certs(
pub async fn get_cert(
State(state): State<AppState>,
Path((owner, name, id)): Path<(String, String, String)>,
auth: Option<Extension<AuthenticatedDid>>,
) -> Result<Json<serde_json::Value>> {
// Verify the repo exists
let _record = state
.db
.get_repo(&owner, &name)
.await?
.ok_or_else(|| AppError::RepoNotFound(format!("{owner}/{name}")))?;
let caller = auth.as_ref().map(|e| e.0 .0.as_str());
let (record, _rules) =
crate::api::authorize_repo_read(&state, &owner, &name, caller, "/").await?;

let cert = state
.db
.get_ref_certificate(&id)
.await?
.ok_or_else(|| AppError::NotFound(format!("certificate {id}")))?;

if cert.repo_id != record.id {
return Err(AppError::NotFound(format!("certificate {id}")));
}

Ok(Json(serde_json::json!({
"id": cert.id,
"repo_id": cert.repo_id,
Expand Down
47 changes: 31 additions & 16 deletions crates/gitlawb-node/src/api/issues.rs
Original file line number Diff line number Diff line change
Expand Up @@ -90,12 +90,11 @@ pub async fn create_issue(
pub async fn list_issues(
State(state): State<AppState>,
Path((owner, repo)): Path<(String, String)>,
auth: Option<Extension<AuthenticatedDid>>,
) -> Result<Json<serde_json::Value>> {
let record = state
.db
.get_repo(&owner, &repo)
.await?
.ok_or_else(|| AppError::RepoNotFound(format!("{owner}/{repo}")))?;
let caller = auth.as_ref().map(|e| e.0 .0.as_str());
let (record, _rules) =
crate::api::authorize_repo_read(&state, &owner, &repo, caller, "/").await?;

let disk_path = state
.repo_store
Expand All @@ -120,12 +119,11 @@ pub async fn list_issues(
pub async fn get_issue(
State(state): State<AppState>,
Path((owner, repo, issue_id)): Path<(String, String, String)>,
auth: Option<Extension<AuthenticatedDid>>,
) -> Result<Json<serde_json::Value>> {
let record = state
.db
.get_repo(&owner, &repo)
.await?
.ok_or_else(|| AppError::RepoNotFound(format!("{owner}/{repo}")))?;
let caller = auth.as_ref().map(|e| e.0 .0.as_str());
let (record, _rules) =
crate::api::authorize_repo_read(&state, &owner, &repo, caller, "/").await?;

let disk_path = state
.repo_store
Expand Down Expand Up @@ -191,14 +189,31 @@ pub async fn create_issue_comment(
pub async fn list_issue_comments(
State(state): State<AppState>,
Path((owner, repo, issue_id)): Path<(String, String, String)>,
auth: Option<Extension<AuthenticatedDid>>,
) -> Result<Json<serde_json::Value>> {
let _record = state
.db
.get_repo(&owner, &repo)
.await?
.ok_or_else(|| AppError::RepoNotFound(format!("{owner}/{repo}")))?;
let caller = auth.as_ref().map(|e| e.0 .0.as_str());
let (record, _rules) =
crate::api::authorize_repo_read(&state, &owner, &repo, caller, "/").await?;

let disk_path = state
.repo_store
.acquire(&record.owner_did, &record.name)
.await
.map_err(|e| AppError::Git(e.to_string()))?;
// Resolve the full issue ID (accepts 8-char prefix) so the DB fetch
// below uses the same canonical id as the git ref.
let full_id = match git_issues::resolve_issue_id(&disk_path, &issue_id)
.map_err(|e| AppError::Git(e.to_string()))?
{
Some(id) => id,
None => {
return Err(AppError::RepoNotFound(format!(
"issue {issue_id} not found"
)))
}
};

let comments = state.db.list_issue_comments(&issue_id).await?;
let comments = state.db.list_issue_comments(&full_id).await?;
Ok(Json(serde_json::json!({ "comments": comments })))
}

Expand Down
9 changes: 4 additions & 5 deletions crates/gitlawb-node/src/api/labels.rs
Original file line number Diff line number Diff line change
Expand Up @@ -74,12 +74,11 @@ pub async fn remove_label(
pub async fn list_labels(
State(state): State<AppState>,
Path((owner, name)): Path<(String, String)>,
auth: Option<Extension<AuthenticatedDid>>,
) -> Result<Json<serde_json::Value>> {
let record = state
.db
.get_repo(&owner, &name)
.await?
.ok_or_else(|| AppError::RepoNotFound(format!("{owner}/{name}")))?;
let caller = auth.as_ref().map(|e| e.0 .0.as_str());
let (record, _rules) =
crate::api::authorize_repo_read(&state, &owner, &name, caller, "/").await?;

let labels = state.db.list_labels(&record.id).await?;
Ok(Json(serde_json::json!({ "labels": labels })))
Expand Down
8 changes: 0 additions & 8 deletions crates/gitlawb-node/src/api/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -504,14 +504,6 @@ mod authz_guard {
// Repo-scoped reads known to be ungated today, each tracked by an issue.
// Remove an entry the moment its gate lands (the staleness assert enforces it).
let known_ungated: &[(&str, &str)] = &[
("list_certs", "#120"),
("get_cert", "#120"),
("list_issues", "#120"),
("get_issue", "#120"),
("list_issue_comments", "#120"),
("list_labels", "#120"),
("list_repo_bounties", "#120"),
("get_star_status", "#120"),
("list_webhooks", "#94 (PR #113)"),
("list_replicas", "PR #113"),
("list_protected_branches", "PR #113"),
Expand Down
Loading
Loading