feat(git): add default-branch management to relay and CLI (#7562)

Authored by Brain and opened on behalf of Wes (`wesbillman`).

## Summary

Add `buzz repos default-branch get/set` and the relay operation it
needs. Git push changes branch tips but cannot select the server's
symbolic HEAD. This selects an existing branch without deleting
branches, moving refs, or changing packs.

- Reuse immutable Git manifests and captured-ETag CAS; concurrent
pushes/default changes conflict, including stale no-ops.
- Require request-specific NIP-98, body binding, fail-closed replay
protection, host tenancy, current channel membership and
repository-management authority. Push/project roles alone are
insufficient.
- Send the CLI mutation once, without redirects; ambiguous delivery
retains the observed digest and is non-retryable.
- Document the narrow HTTP exception to Nostr-first, delegation scope
and admission-time ACL revocation. Kind:30618 remains derived; the
manifest CAS is authoritative.
- Register the Postgres/Redis/MinIO route and fresh-clone tests in the
existing Backend Integration lane.

## Review fixes — `7457bda50afa6b52e36315572fa96928b2e21db6`

- Reject signed valueless `payload` tags when verifying a body; preserve
the shared verifier's intentional absent-payload compatibility. The real
settings route now proves 401, unchanged manifest pointer, and unchanged
kind:30618 event IDs for malformed and other invalid credentials.
- Require a nonempty returned branch and `head == refs/heads/{branch}`;
SET additionally requires the returned branch to equal the attempted
branch. Malformed/mismatched mutation responses remain non-retryable
`DeliveryUnknown`, retaining the attempted branch and original digest.
Invalid reads fail before any POST.
- Strengthen the later-push/fresh-clone regression to select
`release/v1` while `main` exists.

## Verification

At `7457bda50afa6b52e36315572fa96928b2e21db6`:
- Full pre-push passed (all 14 Rust test groups, serialized Tauri
tests/clippy, file-size and branch-skew gates; 522 seconds). No hooks
disabled.
- All four isolated Postgres/Redis/MinIO settings tests pass, including
real Smart HTTP `ls-remote --symref` and fresh clone on `release/v1`.
- All 15 focused NIP-98 verifier tests and three default-branch CLI
tests pass; malformed credentials and missing-branch responses were
reproduced failing before the fixes.
- Five targeted mutants are killed: restoring the valueless-payload
bypass, weakening canonical HEAD validation, accepting an empty branch,
dropping requested-branch confirmation, and omitting hydrated HEAD for
the selected non-main branch. Mutations ran in a separate worktree and
target directory; original patch bytes were verified restored.
- `cargo clippy -p buzz-auth -p buzz-cli -p buzz-relay --all-targets --
-D warnings`, formatting, diff and file-size checks pass on the fixed
tree.

Earlier validation at `bb195b140`: seven Git authorization database
regressions and 28 existing transport tests passed (three infrastructure
cases in that module not selected); four mutations killed
management/replay/CAS/redirect regressions. Full pre-push passed with
`RUST_TEST_THREADS=1` (all 14 Rust groups, Tauri clippy and 3,173 tests,
size/branch-skew gates). Serial execution avoids an unrelated
process-global Desktop discovery counter flake; no hooks disabled or
Desktop changes.

Carl and Jude's review findings are addressed in the new head; **fresh
reviewer and exact-head security confirmation remain required**. The
earlier source review is not approval of this revision. No claim of
green new-head CI.

## Rollout

Merge and **deploy the relay support first**, then build/use the updated
CLI and explicitly set/verify the desired default. No schema migration.
This PR does not deploy anything or change a live repository.

Originating conversation (channel
`8e2fda80-9142-4df8-8e14-da288bea6471`):

buzz://message?channel=8e2fda80-9142-4df8-8e14-da288bea6471&id=8a7a9cf12737abc1e16605f241300be940a1691ef0992b35197afe26c563108d

---------

Signed-off-by: Brain <1a02c72794dcd0f07058a353bc3a81f4028b8c77c92c87fce6d5c8b85970a20b@buzz.block.builderlab.xyz>
Co-authored-by: Brain <1a02c72794dcd0f07058a353bc3a81f4028b8c77c92c87fce6d5c8b85970a20b@buzz.block.builderlab.xyz>
This commit is contained in:
Wes
2026-09-11 08:10:43 -07:00
committed by GitHub
parent 813bbd1412
commit 6146c4fd16
14 changed files with 1830 additions and 12 deletions

View File

@@ -484,6 +484,19 @@ jobs:
VALUES ('00000000-0000-4000-8000-00000000c0de', 'localhost:3000')
ON CONFLICT (lower(host)) DO NOTHING
;"
- name: Git default-branch route and clone regressions
env:
# The preceding step already applied and reconciled schema/schema.sql.
BUZZ_TEST_SCHEMA_MODE: desired
BUZZ_TEST_DATABASE_URL: postgres://buzz:${{ env.BUZZ_TEST_POSTGRES_PASSWORD }}@localhost:5432/buzz
BUZZ_TEST_REDIS_URL: redis://localhost:6379
BUZZ_TEST_S3_ENDPOINT: http://localhost:9000
BUZZ_TEST_S3_BUCKET: buzz-media
run: |
cargo nextest run \
--archive-file target/ci/backend-integration-tests.tar.zst \
-E 'package(buzz-relay) and test(/api::git::settings::tests::external_infra::/)' \
--run-ignored ignored-only
- name: Workflow message provenance unit tests
# The relay's workflow_sink suite is not selected by the infra-free
# unit job. Its ignored database cases run in the isolated PostgreSQL

View File

@@ -161,9 +161,11 @@ pub fn verify_nip98_event(
)));
}
}
let payload_tag = event.tags.find(TagKind::Payload).and_then(|t| t.content());
if let (Some(payload_hex), Some(body_bytes)) = (payload_tag, body) {
// Keep a present-but-malformed tag distinct from an absent (optional) tag.
if let (Some(payload_tag), Some(body_bytes)) = (event.tags.find(TagKind::Payload), body) {
let payload_hex = payload_tag.content().ok_or_else(|| {
AuthError::Nip98Invalid("payload tag is missing its SHA-256 hash".to_string())
})?;
let computed: [u8; 32] = Sha256::digest(body_bytes).into();
let computed_hex = hex::encode(computed);
if computed_hex != payload_hex {
@@ -313,6 +315,26 @@ mod tests {
assert!(matches!(result, Err(AuthError::Nip98Invalid(_))));
}
#[test]
fn payload_tag_without_hash_rejected_with_body() {
let keys = Keys::generate();
for payload in [vec!["payload"], vec!["payload", ""]] {
let json = make_nip98_event_raw_tags(
&keys,
vec![
nostr::Tag::parse(["u", TEST_URL]).unwrap(),
nostr::Tag::parse(["method", TEST_METHOD]).unwrap(),
nostr::Tag::parse(payload).unwrap(),
],
);
let result = verify_nip98_event(&json, TEST_URL, TEST_METHOD, Some(b"some body"));
assert!(
matches!(result, Err(AuthError::Nip98Invalid(_))),
"{result:?}"
);
}
}
#[test]
fn payload_tag_absent_with_body_passes() {
// Contract: the shared verifier does NOT require a payload tag even when

View File

@@ -168,6 +168,7 @@ stored rules in `validation_error` so an owner can remove and repair them.
| `repos` | `create` | Announce a git repository (NIP-34) |
| | `get` | Get a repository announcement |
| | `list` | List repository announcements |
| | `default-branch get/set` | Read or select an existing default branch (requires relay support) |
| | `protect list` | List branch and tag protection rules |
| | `protect set` | Create or replace a protection rule |
| | `protect remove` | Remove a protection rule |
@@ -196,3 +197,29 @@ stdout: raw relay JSON
stderr: {"error": "category", "message": "detail"}
exit: 0=ok 1=user 2=network 3=auth 4=other 5=write conflict
```
### Default branch
After deploying relay support, select an existing published branch without
renaming or deleting any branch:
```bash
buzz repos default-branch get --owner <owner-hex> --id my-repo
buzz repos default-branch set --owner <owner-hex> --id my-repo --branch main
# For an explicitly reviewed version, use the manifest digest returned by get:
buzz repos default-branch set --owner <owner-hex> --id my-repo --branch main \
--expected-manifest <manifest-digest>
```
`--owner` defaults to the signing identity, not an agent's attested human owner.
`set` without `--expected-manifest` reads the current version first. Success
returns `branch`, `head`, `manifest` and `changed`; `get` omits `changed`.
A stale version returns conflict (exit 5). Ambiguous write failures return
`delivery_unknown` with `retryable:false` and the original digest: **read before
retrying**, and do not blindly re-run against a newly fetched version.
The signer must be a current channel member and a repository manager, directly
or through an unrestricted, valid NIP-OA owner attestation; permission to push is
not permission to change the default. See the
[protocol and authorization contract](../../docs/git-on-object-storage.md#default-branch-management).

View File

@@ -911,6 +911,56 @@ impl BuzzClient {
.await
}
/// Send a state-changing JSON command exactly once. Ambiguous delivery
/// never invites an automatic re-run with a newly observed version.
pub async fn post_json_once_authed(
&self,
path: &str,
body: &serde_json::Value,
) -> Result<String, CliError> {
let url = format!("{}{path}", self.relay_url);
let body = serde_json::to_vec(body).map_err(|e| CliError::Other(e.to_string()))?;
let auth = sign_nip98(&self.keys, "POST", &url, Some(&body))?;
let unknown = |detail: String| CliError::DeliveryUnknown(detail);
let http = reqwest::Client::builder()
.redirect(reqwest::redirect::Policy::none())
.timeout(env_duration_secs("BUZZ_TIMEOUT_SECS", 30))
.connect_timeout(env_duration_secs("BUZZ_CONNECT_TIMEOUT_SECS", 15))
.build()?;
let response = self
.with_auth_tag(
http.post(&url)
.header("Authorization", auth)
.header("Content-Type", "application/json")
.body(body),
)
.send()
.await
.map_err(|e| {
if e.is_connect() || e.is_builder() {
CliError::Network(e)
} else {
unknown(e.to_string())
}
})?;
let status = response.status();
let body = response.text().await.map_err(|e| unknown(e.to_string()))?;
let message = extract_relay_message_field(&body).unwrap_or_else(|| body.clone());
if status.is_server_error()
|| status.is_redirection()
|| (status.as_u16() == 429 && !message.starts_with("rate-limited:"))
{
return Err(unknown(format!("HTTP {}: {message}", status.as_u16())));
}
if !status.is_success() {
return Err(CliError::Relay {
status: status.as_u16(),
body: message,
});
}
Ok(body)
}
/// Submit a signed Nostr event via POST /events.
///
/// For non-idempotent moderation command kinds (9040–9044), an ambiguous

View File

@@ -16,6 +16,7 @@ pub mod pr;
pub mod project_channel;
pub mod projects;
pub mod reactions;
mod repo_default_branch;
pub mod repos;
pub mod social;
pub mod upload;

View File

@@ -0,0 +1,314 @@
//! Thin client for the relay's CAS-backed default-branch operation.
use serde::Deserialize;
use serde_json::{json, Value};
use crate::{client::BuzzClient, error::CliError, ReposDefaultBranchCmd};
#[derive(Deserialize)]
struct DefaultBranch {
branch: String,
head: String,
manifest: String,
}
fn parse_snapshot(raw: &str) -> Result<Value, CliError> {
let value: Value = serde_json::from_str(raw).map_err(|_| {
CliError::Other("relay did not return default-branch JSON; it may need updating".into())
})?;
let snapshot: DefaultBranch = serde_json::from_value(value.clone()).map_err(|_| {
CliError::Other(
"relay response is missing default-branch state; it may need updating".into(),
)
})?;
crate::validate::validate_hex64(&snapshot.manifest)?;
if snapshot.branch.is_empty() || snapshot.head != format!("refs/heads/{}", snapshot.branch) {
return Err(CliError::Other("relay returned an invalid HEAD".into()));
}
Ok(value)
}
fn classify(error: CliError) -> CliError {
match error {
CliError::Relay { status: 409, body } => CliError::Conflict(body),
CliError::Relay { status: 401 | 403, body } => CliError::Auth(body),
CliError::Relay { status: 400, body } => CliError::Usage(body),
CliError::Relay { status: 404, body } => CliError::NotFound(format!("{body}; check repository access and that this relay supports default-branch management")),
other => other,
}
}
pub(super) async fn dispatch(
command: ReposDefaultBranchCmd,
client: &BuzzClient,
) -> Result<(), CliError> {
let (id, owner, update) = match command {
ReposDefaultBranchCmd::Get { id, owner } => (id, owner, None),
ReposDefaultBranchCmd::Set {
id,
owner,
branch,
expected_manifest,
} => (id, owner, Some((branch, expected_manifest))),
};
crate::validate::validate_repo_id(&id)?;
let owner = owner.unwrap_or_else(|| client.keys().public_key().to_hex());
crate::validate::validate_hex64(&owner)?;
let path = format!("/git/{owner}/{id}/default-branch");
let result = match update {
None => parse_snapshot(&client.get_authed(&path).await.map_err(classify)?)?,
Some((branch, expected)) => {
let expected = match expected {
Some(digest) => {
crate::validate::validate_hex64(&digest)?;
digest
}
None => {
let raw = client.get_authed(&path).await.map_err(classify)?;
let snapshot = parse_snapshot(&raw)?;
snapshot["manifest"]
.as_str()
.ok_or_else(|| CliError::Other("missing manifest".into()))?
.to_string()
}
};
let uncertain = |detail: String| {
CliError::DeliveryUnknown(format!(
"{detail}; attempted branch {branch:?} against manifest {expected}. Read the current default branch before deciding what to do; do not blindly re-run set without --expected-manifest {expected}"
))
};
let raw = client
.post_json_once_authed(
&path,
&json!({"branch": branch, "expected_manifest": expected}),
)
.await
.map_err(|e| match e {
CliError::DeliveryUnknown(detail) => uncertain(detail),
other => classify(other),
})?;
let result = parse_snapshot(&raw).map_err(|e| uncertain(e.to_string()))?;
if !result["changed"].is_boolean() || result["branch"].as_str() != Some(branch.as_str())
{
return Err(uncertain(
"relay did not confirm the default-branch update".into(),
));
}
result
}
};
println!("{result}");
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use axum::{
body::{Body, Bytes},
http::{HeaderMap, Response, StatusCode},
routing::get,
Router,
};
use base64::Engine;
use clap::Parser;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::{Arc, Mutex};
#[test]
fn default_branch_cli_parses_get_and_set() {
for operation in [
vec!["get", "--id", "demo"],
vec![
"set",
"--id",
"demo",
"--branch",
"release/v1",
"--expected-manifest",
&"a".repeat(64),
],
] {
let mut args = vec!["buzz", "repos", "default-branch"];
args.extend(operation);
assert!(crate::Cli::try_parse_from(args).is_ok());
}
assert!(crate::Cli::try_parse_from([
"buzz",
"repos",
"default-branch",
"set",
"--id",
"demo"
])
.is_err());
}
#[tokio::test]
async fn default_branch_reads_validate_state_before_any_mutation() {
for (branch, head, valid) in [
(Some("release/v1"), "refs/heads/release/v1", true),
(None, "refs/heads/main", false),
(Some("main"), "refs/tags/main", false),
(Some("main"), "refs/heads/other", false),
(Some(""), "refs/heads/", false),
] {
let posts = Arc::new(AtomicUsize::new(0));
let post_count = posts.clone();
let keys = nostr::Keys::generate();
let path = format!("/git/{}/demo/default-branch", keys.public_key().to_hex());
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let url = format!("http://{}", listener.local_addr().unwrap());
let route = get(move || async move {
axum::Json(json!({"branch":branch, "head":head, "manifest":"a".repeat(64)}))
})
.post(move || {
post_count.fetch_add(1, Ordering::SeqCst);
async { StatusCode::INTERNAL_SERVER_ERROR }
});
let app = Router::new().route(&path, route);
let server = tokio::spawn(async { axum::serve(listener, app).await.unwrap() });
let client = BuzzClient::new(url, keys, None, None).unwrap();
let result = dispatch(
ReposDefaultBranchCmd::Get {
id: "demo".into(),
owner: None,
},
&client,
)
.await;
assert_eq!(result.is_ok(), valid, "{branch:?} {head}: {result:?}");
if !valid {
let error = dispatch(
ReposDefaultBranchCmd::Set {
id: "demo".into(),
owner: None,
branch: "main".into(),
expected_manifest: None,
},
&client,
)
.await
.unwrap_err();
assert!(
!matches!(error, CliError::DeliveryUnknown(_)),
"no mutation attempted: {error}"
);
}
assert_eq!(posts.load(Ordering::SeqCst), 0);
server.abort();
}
}
#[tokio::test]
async fn default_branch_command_binds_observed_digest_and_does_not_retry_or_follow_redirects() {
let valid = json!({"head":"refs/heads/main", "branch":"main", "manifest":"b".repeat(64), "changed":true});
let mut no_op = valid.clone();
no_op["changed"] = json!(false);
no_op["manifest"] = json!("a".repeat(64));
let mut cases = vec![(200u16, valid.clone(), true), (200, no_op, true)];
for status in [307, 308, 409, 500, 502, 503, 504] {
cases.push((status, json!({"error":"test outcome"}), false));
}
for (field, value) in [
("branch", None),
("branch", Some(json!(""))),
("head", Some(json!("refs/tags/main"))),
("head", Some(json!("refs/heads/other"))),
("manifest", Some(json!("not-a-digest"))),
("changed", None),
] {
let mut invalid = valid.clone();
if let Some(value) = value {
invalid[field] = value;
} else {
invalid.as_object_mut().unwrap().remove(field);
}
cases.push((200, invalid, false));
}
let mut other_branch = valid;
other_branch["branch"] = json!("other");
other_branch["head"] = json!("refs/heads/other");
cases.push((200, other_branch, false));
for (status, reply, success) in cases {
let posts = Arc::new(AtomicUsize::new(0));
let gets = Arc::new(AtomicUsize::new(0));
let captured = Arc::new(Mutex::new(None));
let keys = nostr::Keys::generate();
let owner = keys.public_key().to_hex();
let path = format!("/git/{owner}/demo/default-branch");
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let url = format!("http://{}", listener.local_addr().unwrap());
let expected_url = format!("{url}{path}");
let get_count = gets.clone();
let post_count = posts.clone();
let capture = captured.clone();
let route = get(move || {
get_count.fetch_add(1, Ordering::SeqCst);
async { axum::Json(json!({"head":"refs/heads/legacy", "branch":"legacy", "manifest":"a".repeat(64)})) }
}).post(move |headers: HeaderMap, body: Bytes| {
let post_count = post_count.clone();
let capture = capture.clone();
let expected_url = expected_url.clone();
let reply = reply.clone();
async move {
post_count.fetch_add(1, Ordering::SeqCst);
let auth = headers["authorization"].to_str().unwrap().strip_prefix("Nostr ").unwrap();
let event = String::from_utf8(base64::engine::general_purpose::STANDARD.decode(auth).unwrap()).unwrap();
let event: nostr::Event = serde_json::from_str(&event).unwrap();
event.verify().unwrap();
assert!(event.tags.iter().any(|t| t.as_slice() == ["u", &expected_url]));
assert!(event.tags.iter().any(|t| t.as_slice() == ["method", "POST"]));
use sha2::Digest;
let digest = hex::encode(sha2::Sha256::digest(&body));
assert!(event.tags.iter().any(|t| t.as_slice() == ["payload", &digest]));
*capture.lock().unwrap() = Some(serde_json::from_slice::<Value>(&body).unwrap());
Response::builder().status(status).header("location", "/redirect-target")
.body(Body::from(reply.to_string())).unwrap()
}
});
let redirected = posts.clone();
let app = Router::new().route(&path, route).route(
"/redirect-target",
axum::routing::post(move || {
redirected.fetch_add(1, Ordering::SeqCst);
async { StatusCode::OK }
}),
);
let server = tokio::spawn(async { axum::serve(listener, app).await.unwrap() });
let client = BuzzClient::new(url, keys, None, None).unwrap();
let result = crate::commands::repos::dispatch(
crate::ReposCmd::DefaultBranch(ReposDefaultBranchCmd::Set {
id: "demo".into(),
owner: None,
branch: "main".into(),
expected_manifest: None,
}),
&client,
)
.await;
assert_eq!(gets.load(Ordering::SeqCst), 1);
assert_eq!(
posts.load(Ordering::SeqCst),
1,
"HTTP {status} must not cause another POST"
);
assert_eq!(
*captured.lock().unwrap(),
Some(json!({"branch":"main", "expected_manifest":"a".repeat(64)}))
);
match status {
200 if success => assert!(result.is_ok(), "{result:?}"),
409 => assert!(matches!(result, Err(CliError::Conflict(_)))),
_ => {
let error = result.unwrap_err();
assert!(matches!(error, CliError::DeliveryUnknown(_)), "{error}");
assert!(!crate::error::is_retryable_error(&error));
assert!(error.to_string().contains(&"a".repeat(64)));
assert!(error.to_string().contains("attempted branch \"main\""));
}
}
server.abort();
}
}
}

View File

@@ -442,6 +442,9 @@ pub async fn dispatch(cmd: crate::ReposCmd, client: &BuzzClient) -> Result<(), C
ReposCmd::Get { id, owner } => cmd_get_repo(client, &id, owner.as_deref()).await,
ReposCmd::List { owner, limit } => cmd_list_repos(client, owner.as_deref(), limit).await,
ReposCmd::Bind { id, channel } => cmd_bind_repo(client, &id, &channel).await,
ReposCmd::DefaultBranch(command) => {
super::repo_default_branch::dispatch(command, client).await
}
ReposCmd::Protect(command) => match command {
ReposProtectCmd::List { id } => cmd_protect_list(client, &id).await,
ReposProtectCmd::Set {

View File

@@ -1237,6 +1237,38 @@ pub enum ReposCmd {
/// Manage branch and tag protection rules on one of your repositories.
#[command(subcommand)]
Protect(ReposProtectCmd),
/// Inspect or change the relay-hosted repository's default branch.
#[command(subcommand)]
DefaultBranch(ReposDefaultBranchCmd),
}
/// Commands for the authoritative Git default branch, not announcement metadata.
#[derive(Subcommand)]
pub enum ReposDefaultBranchCmd {
/// Read the default branch and observed manifest version.
Get {
/// Repository identifier.
#[arg(long)]
id: String,
/// Repository owner (64-char hex). Defaults to your signing identity.
#[arg(long)]
owner: Option<String>,
},
/// Select an existing branch without moving or deleting any refs.
Set {
/// Repository identifier.
#[arg(long)]
id: String,
/// Repository owner (64-char hex). Defaults to your signing identity.
#[arg(long)]
owner: Option<String>,
/// Short branch name, e.g. main or release/v1 (not refs/heads/main).
#[arg(long)]
branch: String,
/// Manifest digest returned by get. Omit to read it before updating.
#[arg(long)]
expected_manifest: Option<String>,
},
}
/// Commands for inspecting and changing repository protection rules.
@@ -2399,12 +2431,13 @@ mod tests {
);
assert_eq!(
names(&cmd, "repos"),
vec!["bind", "create", "get", "list", "protect"]
vec!["bind", "create", "default-branch", "get", "list", "protect"]
);
let repos = cmd
.get_subcommands()
.find(|subcommand| subcommand.get_name() == "repos")
.expect("repos command");
assert_eq!(names(repos, "default-branch"), vec!["get", "set"]);
let protect = repos
.get_subcommands()
.find(|subcommand| subcommand.get_name() == "protect")
@@ -2476,7 +2509,7 @@ mod tests {
("pr", 5),
("projects", 8),
("reactions", 3),
("repos", 5),
("repos", 6),
("social", 7),
("upload", 1),
("users", 5),

View File

@@ -243,7 +243,7 @@ pub async fn hydrate_for_write(
///
/// `Ok(None)` if the pointer is absent (caller decides 404 vs first-push
/// per call site). `Err(_)` on any below-pointer failure.
async fn load_pointer(
pub(super) async fn load_pointer(
store: &GitStore,
ctx: &TenantContext,
owner: &str,

View File

@@ -30,6 +30,7 @@ pub mod manifest;
pub mod manifest_event;
pub mod pack_cache;
pub mod policy;
mod settings;
pub mod store;
pub mod transport;

View File

@@ -0,0 +1,418 @@
//! Default-branch management of the authoritative Git manifest.
//!
//! This is a Git control-plane operation, not a replaceable announcement:
//! the pointer CAS is the commit point, shared with receive-pack. A separate
//! strict NIP-98 request prevents reusable Smart HTTP credentials authorizing
//! metadata changes (URL, method, payload and replay are all checked).
use std::sync::Arc;
use axum::{
body::Bytes,
extract::{DefaultBodyLimit, Path, State},
http::{header, HeaderMap, StatusCode},
response::{IntoResponse, Response},
routing::get,
Json, Router,
};
use buzz_core::TenantContext;
use serde::Deserialize;
use serde_json::{json, Value};
use super::{
binding::{resolve_repo_binding, RepoBinding},
hydrate::load_pointer,
manifest::{is_safe_refname, pointer_key, Manifest},
manifest_event::{build_ref_state_event, RefStateInputs},
store::{CasOutcome, ETag, GitStore, Precond},
transport::{authorize_git_read, deny_banned_git_principal, validate_repo_id},
};
use crate::{
api::{api_error, bridge, relay_members},
state::AppState,
};
fn error(status: StatusCode, message: &str) -> Response {
api_error(status, message).into_response()
}
fn backend(error: impl std::fmt::Display) -> Response {
tracing::error!(%error, "git settings backend failure");
self::error(
StatusCode::INTERNAL_SERVER_ERROR,
"git settings backend unavailable; read the default branch before retrying",
)
}
fn conflict() -> Response {
error(
StatusCode::CONFLICT,
"repository changed concurrently; read the latest manifest and retry",
)
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct SetDefaultBranch {
branch: String,
expected_manifest: String,
}
/// A loaded snapshot cannot be rebound to another pointer or refreshed at CAS.
struct DefaultBranchSnapshot {
pointer: String,
etag: ETag,
digest: String,
manifest: Manifest,
}
impl DefaultBranchSnapshot {
async fn load(
store: &GitStore,
tenant: &TenantContext,
owner: &str,
repo: &str,
) -> Result<Self, Response> {
let (etag, digest, manifest) = load_pointer(store, tenant, owner, repo)
.await
.map_err(backend)?
.ok_or_else(|| {
error(
StatusCode::NOT_FOUND,
"repository has no published Git state; push a branch first",
)
})?;
Ok(Self {
pointer: pointer_key(tenant.community(), owner, repo),
etag,
digest,
manifest,
})
}
fn response(&self) -> Value {
json!({"branch": self.manifest.head.strip_prefix("refs/heads/"), "head": self.manifest.head, "manifest": self.digest})
}
async fn set(
mut self,
store: &GitStore,
request: SetDefaultBranch,
) -> Result<(Self, bool), Response> {
if self.digest != request.expected_manifest {
return Err(conflict());
}
let head = format!("refs/heads/{}", request.branch);
if request.branch.is_empty()
|| request.branch.len() > 1024
|| request.branch.starts_with('-')
|| !is_safe_refname(&head)
|| request.branch.ends_with('.')
|| request
.branch
.split('/')
.any(|part| part.starts_with('.') || part.ends_with(".lock"))
{
return Err(error(
StatusCode::BAD_REQUEST,
"invalid branch name; use a short branch name such as main or release/v1",
));
}
if !self.manifest.refs.contains_key(&head) {
return Err(error(
StatusCode::BAD_REQUEST,
"default branch must name an existing published branch",
));
}
let changed = self.manifest.head != head;
if changed {
self.manifest.head = head;
self.manifest.parent = Some(self.digest.clone());
self.manifest.validate().map_err(backend)?;
let bytes = self.manifest.canonical_bytes().map_err(backend)?;
let key = store.put_manifest(&bytes).await.map_err(backend)?;
self.digest = key
.strip_prefix("manifests/")
.ok_or_else(|| backend("invalid manifest key"))?
.to_string();
}
// Even a no-op checks the observed ETag: concurrent deletion/push must
// not be reported as a successful setting of a now-missing branch.
match store
.put_pointer(
&self.pointer,
self.digest.as_bytes(),
Precond::IfMatch(self.etag.clone()),
)
.await
.map_err(backend)?
{
CasOutcome::Won(etag) => self.etag = etag,
CasOutcome::LostRace => return Err(conflict()),
}
Ok((self, changed))
}
}
struct SettingsAuth {
tenant: TenantContext,
caller: nostr::PublicKey,
delegated_owner: Option<nostr::PublicKey>,
}
async fn authenticate(
state: &Arc<AppState>,
headers: &HeaderMap,
path: &str,
body: Option<&[u8]>,
) -> Result<SettingsAuth, Response> {
let host = headers
.get(header::HOST)
.and_then(|h| h.to_str().ok())
.unwrap_or("");
let tenant = crate::tenant::bind_community(&state.db, host)
.await
.map_err(|_| error(StatusCode::NOT_FOUND, "repository not found"))?;
let url = bridge::nip98_expected_url(&state.config.relay_url, &tenant, path);
let auth = bridge::verify_bridge_auth_with_options(
headers,
if body.is_some() { "POST" } else { "GET" },
&url,
body,
true,
body.is_some(),
)
.map_err(IntoResponse::into_response)?;
bridge::enforce_http_admission(state, &tenant, &auth.pubkey)
.await
.map_err(IntoResponse::into_response)?;
bridge::check_nip98_replay(state, &tenant, auth.event_id_bytes)
.await
.map_err(IntoResponse::into_response)?;
let tag = relay_members::extract_auth_tag_header(headers);
relay_members::enforce_relay_membership(
state,
tenant.community(),
auth.pubkey.as_bytes(),
tag,
auth.signed_created_at,
)
.await
.map_err(IntoResponse::into_response)?;
deny_banned_git_principal(
&state.db,
tenant.community(),
&auth.pubkey,
tag,
auth.signed_created_at,
)
.await?;
// Admission ignores kind= restrictions by design (NIP-AA). Repository
// management must not turn a message-only credential into write authority.
// This HTTP operation has no event kind: only kind-unrestricted credentials
// may inherit management authority. Temporal clauses are still enforced.
let delegated_owner = tag
.filter(|tag| {
serde_json::from_str::<Vec<String>>(tag)
.ok()
.and_then(|parts| parts.get(2).cloned())
.is_some_and(|conditions| {
!conditions
.split('&')
.any(|clause| clause.starts_with("kind="))
})
})
.and_then(|tag| {
relay_members::extract_nip_oa_owner(
auth.pubkey.as_bytes(),
Some(tag),
auth.signed_created_at,
)
});
Ok(SettingsAuth {
tenant,
caller: auth.pubkey,
delegated_owner,
})
}
async fn authorize_management(
state: &AppState,
auth: &SettingsAuth,
repo: &nostr::Event,
) -> Result<(), Response> {
let RepoBinding::Bound(channel) = resolve_repo_binding(repo) else {
return Err(error(StatusCode::NOT_FOUND, "repository not found"));
};
let community = auth.tenant.community();
let bound = state
.db
.get_channel(community, channel)
.await
.map_err(backend)?;
if bound.archived_at.is_some() {
return Err(error(
StatusCode::FORBIDDEN,
"channel is archived (read-only)",
));
}
let named_manager = |key: &nostr::PublicKey| {
*key == repo.pubkey
|| repo.tags.iter().any(|tag| {
let values = tag.as_slice();
values.first().is_some_and(|name| name == "maintainers")
&& values
.iter()
.skip(1)
.any(|value| nostr::PublicKey::parse(value).ok().as_ref() == Some(key))
})
};
// Direct authority is independent of an optional owner credential.
if named_manager(&auth.caller)
|| state
.db
.is_agent_owner(community, repo.pubkey.as_bytes(), auth.caller.as_bytes())
.await
.map_err(backend)?
{
return Ok(());
}
if let Some(principal) = &auth.delegated_owner {
let role = state
.db
.get_member_role(community, channel, principal.as_bytes())
.await
.map_err(backend)?;
if role.is_some_and(|r| r.parse::<buzz_core::channel::MemberRole>().is_ok())
&& (named_manager(principal)
|| state
.db
.is_agent_owner(community, repo.pubkey.as_bytes(), principal.as_bytes())
.await
.map_err(backend)?)
{
return Ok(());
}
}
Err(error(
StatusCode::FORBIDDEN,
"only the repository owner or a named maintainer may change its default branch",
))
}
async fn get_default_branch(
State(state): State<Arc<AppState>>,
Path((owner, repo)): Path<(String, String)>,
headers: HeaderMap,
) -> Result<Json<Value>, Response> {
let path = format!("/git/{owner}/{repo}/default-branch");
let repo_name = validate_repo_id(&owner, &repo)?;
let auth = authenticate(&state, &headers, &path, None).await?;
authorize_git_read(
&state.db,
auth.tenant.community(),
&auth.caller,
&owner,
repo_name,
)
.await?;
let snapshot =
DefaultBranchSnapshot::load(&state.git_store, &auth.tenant, &owner, repo_name).await?;
Ok(Json(snapshot.response()))
}
async fn set_default_branch(
State(state): State<Arc<AppState>>,
Path((owner, repo)): Path<(String, String)>,
headers: HeaderMap,
body: Bytes,
) -> Result<Json<Value>, Response> {
let path = format!("/git/{owner}/{repo}/default-branch");
let repo_name = validate_repo_id(&owner, &repo)?;
let auth = authenticate(&state, &headers, &path, Some(&body)).await?;
let announcement = authorize_git_read(
&state.db,
auth.tenant.community(),
&auth.caller,
&owner,
repo_name,
)
.await?;
authorize_management(&state, &auth, &announcement).await?;
let request: SetDefaultBranch = serde_json::from_slice(&body).map_err(|_| {
error(
StatusCode::BAD_REQUEST,
"expected branch and expected_manifest strings",
)
})?;
let snapshot =
DefaultBranchSnapshot::load(&state.git_store, &auth.tenant, &owner, repo_name).await?;
let serving_write = buzz_deletion::acquire_serving_write(
&state.db,
auth.tenant.community(),
"git_default_branch",
)
.await
.map_err(|_| {
error(
StatusCode::SERVICE_UNAVAILABLE,
"community writes are fenced",
)
})?;
serving_write.verify().await.map_err(backend)?;
let (snapshot, changed) = serving_write
.protect(snapshot.set(&state.git_store, request))
.await
.map_err(backend)??;
let publication = async {
if changed {
let actor = auth.caller.to_hex();
let event = build_ref_state_event(
&RefStateInputs {
repo_id: repo_name,
head: &snapshot.manifest.head,
refs: &snapshot.manifest.refs,
actor_pubkey_hex: &actor,
},
&state.relay_keypair,
)
.map_err(backend)?;
let (stored, inserted) = state
.db
.insert_event_with_serving_write_guard(serving_write.lease(), &event, None)
.await
.map_err(backend)?;
if inserted {
crate::handlers::event::fan_out_event_to_local_subscribers(
&state,
auth.tenant.community(),
&stored,
)
.await;
}
}
Ok::<(), Response>(())
}
.await;
serving_write.finish().await.map_err(backend)?;
// Publication failure is not mistaken for a rolled-back manifest.
if publication.is_err() {
return Err(error(StatusCode::INTERNAL_SERVER_ERROR, "default branch committed but notification failed; read the current default branch before retrying"));
}
let mut response = snapshot.response();
response["changed"] = json!(changed);
Ok(Json(response))
}
pub(super) fn router() -> Router<Arc<AppState>> {
Router::new()
.route(
"/git/{owner}/{repo}/default-branch",
get(get_default_branch).post(set_default_branch),
)
.layer(DefaultBodyLimit::max(4096))
}
#[cfg(test)]
#[path = "settings_tests.rs"]
mod tests;

View File

@@ -0,0 +1,880 @@
//! Live route/store/clone regressions. Require explicit isolated service URLs;
//! never fall back to a developer's Desktop database.
mod external_infra {
use super::super::*;
use axum::{
body::{to_bytes, Body},
http::Request,
};
use base64::Engine;
use buzz_core::channel::MemberRole;
use nostr::{EventBuilder, Keys, Kind, Tag};
use sha2::{Digest, Sha256};
use tower::ServiceExt;
struct Fixture {
state: Arc<AppState>,
pool: sqlx::PgPool,
tenant: TenantContext,
owner: Keys,
member: Keys,
maintainer: Keys,
channel: uuid::Uuid,
repo: String,
scratch: tempfile::TempDir,
}
impl Fixture {
async fn new() -> Self {
let database_url = std::env::var("BUZZ_TEST_DATABASE_URL")
.expect("explicit isolated BUZZ_TEST_DATABASE_URL");
let redis_url = std::env::var("BUZZ_TEST_REDIS_URL")
.expect("explicit isolated BUZZ_TEST_REDIS_URL");
let endpoint = std::env::var("BUZZ_TEST_S3_ENDPOINT")
.expect("explicit isolated BUZZ_TEST_S3_ENDPOINT");
let scratch = tempfile::tempdir().unwrap();
let mut config = crate::config::Config::from_env().unwrap();
config.database_url = database_url;
config.redis_url = redis_url;
config.relay_url = "ws://127.0.0.1".into();
config.require_relay_membership = false;
config.git_repo_path = scratch.path().to_path_buf();
config.git_pack_cache_path = scratch.path().join("cache");
config.media.s3_endpoint = endpoint;
config.media.s3_bucket =
std::env::var("BUZZ_TEST_S3_BUCKET").unwrap_or_else(|_| "buzz-git".into());
config.media.s3_access_key = "buzz_dev".into();
config.media.s3_secret_key = "buzz_dev_secret".into();
let pool = sqlx::PgPool::connect(&config.database_url).await.unwrap();
let db = buzz_db::Db::from_pool(pool.clone());
// CI provisions schema/schema.sql with pgschema before this suite.
// Only migration-backed local fixtures own the migration lifecycle.
if std::env::var("BUZZ_TEST_SCHEMA_MODE").as_deref() != Ok("desired") {
db.migrate().await.unwrap();
}
let redis_pool = deadpool_redis::Config::from_url(&config.redis_url)
.create_pool(Some(deadpool_redis::Runtime::Tokio1))
.unwrap();
let pubsub = Arc::new(
buzz_pubsub::PubSubManager::new(&config.redis_url, redis_pool.clone())
.await
.unwrap(),
);
let audit = buzz_audit::AuditService::new(pool.clone());
let auth = buzz_auth::AuthService::new(config.auth.clone());
let search = buzz_search::SearchService::new(pool.clone());
let workflow = Arc::new(buzz_workflow::WorkflowEngine::new(
db.clone(),
buzz_workflow::WorkflowConfig::default(),
));
let media = buzz_media::MediaStorage::new(&config.media).unwrap();
let (state, _) = AppState::new(
config,
db,
redis_pool,
audit,
pubsub,
auth,
search,
workflow,
Keys::generate(),
media,
);
let state = Arc::new(state);
let host = format!("settings-{}.example", uuid::Uuid::new_v4().simple());
let community = state
.db
.ensure_configured_community(&host)
.await
.unwrap()
.id;
let tenant = TenantContext::resolved(community, &host);
let owner = Keys::generate();
let member = Keys::generate();
let maintainer = Keys::generate();
let channel = uuid::Uuid::new_v4();
state
.db
.ensure_user(community, owner.public_key().as_bytes())
.await
.unwrap();
state
.db
.create_channel_with_id(
community,
channel,
&format!("settings-{channel}"),
buzz_db::channel::ChannelType::Stream,
buzz_db::channel::ChannelVisibility::Open,
None,
owner.public_key().as_bytes(),
None,
)
.await
.unwrap();
for (key, role) in [
(&member, MemberRole::Admin),
(&maintainer, MemberRole::Member),
(&owner, MemberRole::Owner),
] {
state
.db
.ensure_user(community, key.public_key().as_bytes())
.await
.unwrap();
state
.db
.add_member(
community,
channel,
key.public_key().as_bytes(),
role,
Some(owner.public_key().as_bytes()),
)
.await
.unwrap();
}
let repo = format!("repo-{}", uuid::Uuid::new_v4().simple());
let announcement = EventBuilder::new(Kind::Custom(30617), "")
.tags([
Tag::parse(["d", &repo]).unwrap(),
Tag::parse(["buzz-channel", &channel.to_string()]).unwrap(),
Tag::parse(["maintainers", &maintainer.public_key().to_hex()]).unwrap(),
])
.sign_with_keys(&owner)
.unwrap();
state
.db
.insert_event(community, &announcement, None)
.await
.unwrap();
let f = Self {
state,
pool,
tenant,
owner,
member,
maintainer,
channel,
repo,
scratch,
};
f.seed_git().await;
f
}
fn path(&self) -> String {
format!(
"/git/{}/{}/default-branch",
self.owner.public_key().to_hex(),
self.repo
)
}
async fn snapshot(&self) -> DefaultBranchSnapshot {
DefaultBranchSnapshot::load(
&self.state.git_store,
&self.tenant,
&self.owner.public_key().to_hex(),
&self.repo,
)
.await
.unwrap()
}
async fn seed_git(&self) {
let source = self.scratch.path().join("source");
std::fs::create_dir(&source).unwrap();
git(&source, &["init", "--initial-branch=legacy"]).await;
git(&source, &["config", "user.name", "Git settings test"]).await;
git(
&source,
&["config", "user.email", "git-settings@example.invalid"],
)
.await;
git(&source, &["commit", "--allow-empty", "-m", "legacy"]).await;
git(&source, &["branch", "main"]).await;
git(&source, &["checkout", "main"]).await;
std::fs::write(source.join("main.txt"), b"selected branch\n").unwrap();
git(&source, &["add", "main.txt"]).await;
git(&source, &["commit", "-m", "main"]).await;
git(&source, &["checkout", "legacy"]).await;
super::super::super::cas_publish::cas_publish(
&self.state.git_store,
&self.tenant,
&source,
&self.owner.public_key().to_hex(),
&self.repo,
&super::super::super::cas_publish::ParentState::fresh(),
limits(0),
)
.await
.unwrap();
}
async fn call(
&self,
key: &Keys,
body: Option<Value>,
tag: Option<&str>,
) -> (StatusCode, Value) {
let body = body.map(|value| value.to_string());
let method = if body.is_some() { "POST" } else { "GET" };
let path = self.path();
let token = token(
key,
method,
&format!("http://{}{path}", self.tenant.host()),
body.as_deref(),
);
let mut request = Request::builder()
.method(method)
.uri(&path)
.header("host", self.tenant.host())
.header("authorization", token);
if let Some(tag) = tag {
request = request.header("x-auth-tag", tag);
}
let request = request.body(Body::from(body.unwrap_or_default())).unwrap();
response(
super::super::super::transport::git_router(self.state.clone())
.oneshot(request)
.await
.unwrap(),
)
.await
}
async fn set(&self, key: &Keys, branch: &str, tag: Option<&str>) -> (StatusCode, Value) {
let digest = self.snapshot().await.digest;
self.call(
key,
Some(json!({"branch": branch, "expected_manifest": digest})),
tag,
)
.await
}
async fn add(&self, key: &Keys) {
self.state
.db
.ensure_user(self.tenant.community(), key.public_key().as_bytes())
.await
.unwrap();
self.state
.db
.add_member(
self.tenant.community(),
self.channel,
key.public_key().as_bytes(),
MemberRole::Bot,
Some(self.owner.public_key().as_bytes()),
)
.await
.unwrap();
}
}
fn limits(parent_hydrated_bytes: u64) -> super::super::super::cas_publish::PublishLimits {
super::super::super::cas_publish::PublishLimits {
parent_hydrated_bytes,
max_pack_bytes: 1024 * 1024,
max_repo_bytes: 2 * 1024 * 1024,
}
}
async fn git(path: &std::path::Path, args: &[&str]) -> String {
let mut command = tokio::process::Command::new("git");
command.current_dir(path).args(args);
super::super::super::transport::harden_git_env(&mut command);
let result = command.output().await.unwrap();
assert!(
result.status.success(),
"git {args:?}: {}",
String::from_utf8_lossy(&result.stderr)
);
String::from_utf8(result.stdout).unwrap()
}
fn token(keys: &Keys, method: &str, url: &str, body: Option<&str>) -> String {
token_with_payload(
keys,
method,
url,
body.map(|body| Tag::parse(["payload", &hex::encode(Sha256::digest(body))]).unwrap()),
)
}
fn token_with_payload(keys: &Keys, method: &str, url: &str, payload: Option<Tag>) -> String {
let mut tags = vec![
Tag::parse(["u", url]).unwrap(),
Tag::parse(["method", method]).unwrap(),
Tag::parse(["nonce", &uuid::Uuid::new_v4().to_string()]).unwrap(),
];
if let Some(payload) = payload {
tags.push(payload);
}
let event = EventBuilder::new(Kind::Custom(27235), "")
.tags(tags)
.sign_with_keys(keys)
.unwrap();
format!(
"Nostr {}",
base64::engine::general_purpose::STANDARD.encode(serde_json::to_vec(&event).unwrap())
)
}
async fn response(response: Response) -> (StatusCode, Value) {
let status = response.status();
let bytes = to_bytes(response.into_body(), 1024 * 1024).await.unwrap();
(
status,
serde_json::from_slice(&bytes)
.unwrap_or_else(|_| json!({"error": String::from_utf8_lossy(&bytes)})),
)
}
#[tokio::test]
#[ignore = "requires isolated Postgres, Redis and MinIO"]
async fn default_branch_route_permissions_and_protocol() {
let f = Fixture::new().await;
let before = f.snapshot().await;
assert_eq!(
f.call(&f.member, None, None).await.1["head"],
"refs/heads/legacy"
);
assert_eq!(
f.set(&f.member, "main", None).await.0,
StatusCode::FORBIDDEN,
"push-capable channel admin is not a repo manager"
);
assert_eq!(
f.set(&Keys::generate(), "main", None).await.0,
StatusCode::NOT_FOUND
);
for branch in [
"",
"absent",
"../main",
"refs/heads/main",
"main.lock",
"bad\nref",
"main/",
"-main",
".main",
] {
assert_eq!(
f.set(&f.owner, branch, None).await.0,
StatusCode::BAD_REQUEST,
"{branch:?}"
);
}
assert_eq!(
f.snapshot().await.digest,
before.digest,
"denials do not write"
);
let result = f.set(&f.maintainer, "main", None).await;
assert_eq!(result.0, StatusCode::OK, "{result:?}");
assert_eq!(result.1["changed"], true);
let after = f.snapshot().await;
assert_eq!(after.manifest.head, "refs/heads/main");
assert_eq!(after.manifest.refs, before.manifest.refs);
assert_eq!(after.manifest.packs, before.manifest.packs);
assert_eq!(after.manifest.parent.as_ref(), Some(&before.digest));
let result = f.set(&f.owner, "main", None).await;
assert_eq!(result.0, StatusCode::OK);
assert_eq!(result.1["changed"], false);
assert_eq!(f.snapshot().await.digest, after.digest);
assert_eq!(
f.call(
&f.owner,
Some(json!({"branch":"legacy", "expected_manifest": before.digest})),
None
)
.await
.0,
StatusCode::CONFLICT
);
let notification_query = buzz_db::EventQuery {
kinds: Some(vec![30618]),
d_tag: Some(f.repo.clone()),
global_only: true,
..buzz_db::EventQuery::for_community(f.tenant.community())
};
let events = f.state.db.query_events(&notification_query).await.unwrap();
let event_ids: Vec<_> = events.iter().map(|e| e.event.id).collect();
assert!(
events.iter().any(|e| e
.event
.tags
.iter()
.any(|t| t.as_slice() == ["HEAD", "ref: refs/heads/main"])),
"committed default notification: {events:?}"
);
// Strict credentials: each mutated property must be rejected at the real route.
let body = json!({"branch":"legacy", "expected_manifest": after.digest}).to_string();
let path = f.path();
let url = format!("http://{}{path}", f.tenant.host());
let requests = [
token_with_payload(
&f.owner,
"POST",
&url,
Some(Tag::parse(["payload"]).unwrap()),
),
token_with_payload(
&f.owner,
"POST",
&url,
Some(Tag::parse(["payload", ""]).unwrap()),
),
token(&f.owner, "GET", &url, Some(&body)),
token(&f.owner, "POST", &url, None),
token(&f.owner, "POST", &url, Some("{}")),
token(
&f.owner,
"POST",
&url.replace(f.tenant.host(), "other.example"),
Some(&body),
),
token(
&f.owner,
"GET",
url.trim_end_matches("/default-branch"),
None,
),
];
for token in requests {
let request = Request::builder()
.method("POST")
.uri(&path)
.header("host", f.tenant.host())
.header("authorization", token)
.body(Body::from(body.clone()))
.unwrap();
let status = super::super::super::transport::git_router(f.state.clone())
.oneshot(request)
.await
.unwrap()
.status();
assert_eq!(status, StatusCode::UNAUTHORIZED);
assert_eq!(
f.snapshot().await.digest,
after.digest,
"auth denial changed pointer"
);
let denied_events = f.state.db.query_events(&notification_query).await.unwrap();
assert_eq!(
denied_events.iter().map(|e| e.event.id).collect::<Vec<_>>(),
event_ids,
"auth denial published kind:30618"
);
}
let reusable = token(&f.owner, "GET", &url, None);
for expected in [StatusCode::OK, StatusCode::UNAUTHORIZED] {
let request = Request::builder()
.uri(&path)
.header("host", f.tenant.host())
.header("authorization", &reusable)
.body(Body::empty())
.unwrap();
assert_eq!(
super::super::super::transport::git_router(f.state.clone())
.oneshot(request)
.await
.unwrap()
.status(),
expected
);
}
let other_host = format!("other-{}.example", uuid::Uuid::new_v4());
f.state
.db
.ensure_configured_community(&other_host)
.await
.unwrap();
let token = token(&f.owner, "GET", &format!("http://{other_host}{path}"), None);
let request = Request::builder()
.uri(&path)
.header("host", &other_host)
.header("authorization", token)
.body(Body::empty())
.unwrap();
assert_eq!(
super::super::super::transport::git_router(f.state.clone())
.oneshot(request)
.await
.unwrap()
.status(),
StatusCode::NOT_FOUND
);
}
#[tokio::test]
#[ignore = "requires isolated Postgres, Redis and MinIO"]
async fn default_branch_delegation_and_revocation() {
let f = Fixture::new().await;
let agent = Keys::generate();
f.add(&agent).await;
let tag = buzz_sdk::nip_oa::compute_auth_tag(&f.owner, &agent.public_key(), "").unwrap();
assert_eq!(f.set(&agent, "main", None).await.0, StatusCode::FORBIDDEN);
let limited =
buzz_sdk::nip_oa::compute_auth_tag(&f.owner, &agent.public_key(), "kind=1").unwrap();
assert_eq!(
f.set(&agent, "main", Some(&limited)).await.0,
StatusCode::FORBIDDEN
);
let expired =
buzz_sdk::nip_oa::compute_auth_tag(&f.owner, &agent.public_key(), "created_at<1")
.unwrap();
assert_eq!(
f.set(&agent, "main", Some(&expired)).await.0,
StatusCode::FORBIDDEN
);
assert_eq!(f.set(&agent, "main", Some(&tag)).await.0, StatusCode::OK);
// Optional credential does not take direct authority away.
let absent_owner = Keys::generate();
let own_tag =
buzz_sdk::nip_oa::compute_auth_tag(&absent_owner, &f.owner.public_key(), "").unwrap();
assert_eq!(
f.set(&f.owner, "legacy", Some(&own_tag)).await.0,
StatusCode::OK
);
// A human can administer a repository announced by their managed agent.
f.state
.db
.set_agent_owner(
f.tenant.community(),
f.owner.public_key().as_bytes(),
f.member.public_key().as_bytes(),
)
.await
.unwrap();
assert_eq!(f.set(&f.member, "main", None).await.0, StatusCode::OK);
f.state
.db
.add_member(
f.tenant.community(),
f.channel,
f.maintainer.public_key().as_bytes(),
MemberRole::Owner,
Some(f.owner.public_key().as_bytes()),
)
.await
.unwrap();
f.state
.db
.remove_member(
f.tenant.community(),
f.channel,
f.owner.public_key().as_bytes(),
f.owner.public_key().as_bytes(),
)
.await
.unwrap();
assert_eq!(
f.set(&agent, "legacy", Some(&tag)).await.0,
StatusCode::FORBIDDEN
);
assert_eq!(
f.set(&f.owner, "legacy", None).await.0,
StatusCode::NOT_FOUND
);
// Durable ban cascades even when the signer has independent maintainer rights.
let ban_tag =
buzz_sdk::nip_oa::compute_auth_tag(&f.member, &f.maintainer.public_key(), "").unwrap();
f.state
.db
.ban_community_member(
f.tenant.community(),
f.member.public_key().as_bytes(),
f.member.public_key().as_bytes(),
Some("test"),
None,
)
.await
.unwrap();
assert_eq!(
f.set(&f.maintainer, "legacy", Some(&ban_tag)).await.0,
StatusCode::FORBIDDEN
);
sqlx::query("UPDATE channels SET archived_at = NOW() WHERE community_id = $1 AND id = $2")
.bind(f.tenant.community().as_uuid())
.bind(f.channel)
.execute(&f.pool)
.await
.unwrap();
assert_eq!(
f.set(&f.maintainer, "legacy", None).await.0,
StatusCode::FORBIDDEN
);
}
#[tokio::test]
#[ignore = "requires isolated Postgres, Redis and MinIO"]
async fn default_branch_push_races_and_fresh_clone() {
let f = Fixture::new().await;
let a = f.snapshot().await;
let b = f.snapshot().await;
let old_digest = a.digest.clone();
let (_, changed) = a
.set(
&f.state.git_store,
SetDefaultBranch {
branch: "main".into(),
expected_manifest: old_digest.clone(),
},
)
.await
.unwrap();
assert!(changed);
let loser = b
.set(
&f.state.git_store,
SetDefaultBranch {
branch: "legacy".into(),
expected_manifest: old_digest,
},
)
.await
.err()
.unwrap();
assert_eq!(
loser.status(),
StatusCode::CONFLICT,
"stale no-op must CAS too"
);
// Snapshot a push before the metadata update; it must not restore stale HEAD.
let options = || super::super::super::hydrate::HydrationOptions {
pack_cache: &f.state.git_pack_cache,
scratch_dir: f.scratch.path(),
max_pack_bytes: 1024 * 1024,
max_repo_bytes: 2 * 1024 * 1024,
};
let (push, parent) = super::super::super::hydrate::hydrate_for_write(
&f.state.git_store,
&f.tenant,
&f.owner.public_key().to_hex(),
&f.repo,
options(),
)
.await
.unwrap();
assert_eq!(f.set(&f.owner, "legacy", None).await.0, StatusCode::OK);
let result = super::super::super::cas_publish::cas_publish(
&f.state.git_store,
&f.tenant,
push.path(),
&f.owner.public_key().to_hex(),
&f.repo,
&parent,
limits(push.hydrated_bytes()),
)
.await;
assert!(matches!(
result,
Err(super::super::super::cas_publish::CasError::Conflict { .. })
));
// Other direction: a push deletes the candidate after settings loaded it.
let stale = f.snapshot().await;
let digest = stale.digest.clone();
let (push, parent) = super::super::super::hydrate::hydrate_for_write(
&f.state.git_store,
&f.tenant,
&f.owner.public_key().to_hex(),
&f.repo,
options(),
)
.await
.unwrap();
git(push.path(), &["update-ref", "-d", "refs/heads/main"]).await;
super::super::super::cas_publish::cas_publish(
&f.state.git_store,
&f.tenant,
push.path(),
&f.owner.public_key().to_hex(),
&f.repo,
&parent,
limits(push.hydrated_bytes()),
)
.await
.unwrap();
assert_eq!(
stale
.set(
&f.state.git_store,
SetDefaultBranch {
branch: "main".into(),
expected_manifest: digest
}
)
.await
.err()
.unwrap()
.status(),
StatusCode::CONFLICT
);
assert!(!f
.snapshot()
.await
.manifest
.refs
.contains_key("refs/heads/main"));
// Restore main and add release/v1, then select the non-main branch so
// Git's initial-branch default cannot mask a lost hydrated HEAD.
let (push, parent) = super::super::super::hydrate::hydrate_for_write(
&f.state.git_store,
&f.tenant,
&f.owner.public_key().to_hex(),
&f.repo,
options(),
)
.await
.unwrap();
let main = git(&f.scratch.path().join("source"), &["rev-parse", "main"]).await;
git(push.path(), &["update-ref", "refs/heads/main", main.trim()]).await;
git(
push.path(),
&["update-ref", "refs/heads/release/v1", main.trim()],
)
.await;
super::super::super::cas_publish::cas_publish(
&f.state.git_store,
&f.tenant,
push.path(),
&f.owner.public_key().to_hex(),
&f.repo,
&parent,
limits(push.hydrated_bytes()),
)
.await
.unwrap();
assert_eq!(f.set(&f.owner, "release/v1", None).await.0, StatusCode::OK);
let (push, parent) = super::super::super::hydrate::hydrate_for_write(
&f.state.git_store,
&f.tenant,
&f.owner.public_key().to_hex(),
&f.repo,
options(),
)
.await
.unwrap();
git(
push.path(),
&["update-ref", "refs/heads/later", main.trim()],
)
.await;
super::super::super::cas_publish::cas_publish(
&f.state.git_store,
&f.tenant,
push.path(),
&f.owner.public_key().to_hex(),
&f.repo,
&parent,
limits(push.hydrated_bytes()),
)
.await
.unwrap();
assert_eq!(f.snapshot().await.manifest.head, "refs/heads/release/v1");
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
// Add a reachable host alias for the same tenant solely in this fixture.
sqlx::query("UPDATE communities SET host = $1 WHERE id = $2")
.bind(addr.to_string())
.bind(f.tenant.community().as_uuid())
.execute(&f.pool)
.await
.unwrap();
let router = super::super::super::transport::git_router(f.state.clone());
let server = tokio::spawn(async move { axum::serve(listener, router).await.unwrap() });
let repo_url = format!(
"http://{addr}/git/{}/{}",
f.owner.public_key().to_hex(),
f.repo
);
let auth = format!(
"http.extraHeader=Authorization: {}",
token(&f.owner, "GET", &repo_url, None)
);
let refs = git(
f.scratch.path(),
&["-c", &auth, "ls-remote", "--symref", &repo_url, "HEAD"],
)
.await;
assert!(refs.contains("ref: refs/heads/release/v1\tHEAD"), "{refs}");
git(
f.scratch.path(),
&["-c", &auth, "clone", &repo_url, "clone"],
)
.await;
assert_eq!(
git(&f.scratch.path().join("clone"), &["symbolic-ref", "HEAD"])
.await
.trim(),
"refs/heads/release/v1"
);
assert_eq!(
std::fs::read(f.scratch.path().join("clone/main.txt")).unwrap(),
b"selected branch\n"
);
server.abort();
}
struct UnavailableReplayGuard;
impl buzz_auth::Nip98ReplayGuard for UnavailableReplayGuard {
fn try_mark_in_scope<'a>(
&'a self,
_scope: &'a str,
_event_id: &'a nostr::EventId,
_ttl_secs: u64,
) -> std::pin::Pin<
Box<dyn std::future::Future<Output = Result<bool, buzz_auth::AuthError>> + Send + 'a>,
> {
Box::pin(async {
Err(buzz_auth::AuthError::Nip98Invalid(
"injected backend failure".into(),
))
})
}
}
#[tokio::test]
#[ignore = "requires isolated Postgres, Redis and MinIO"]
async fn default_branch_replay_outage_and_deletion_fail_closed() {
let mut f = Fixture::new().await;
let before = f.snapshot().await.digest;
let original = f.state.clone();
let mut state = (*original).clone();
state.nip98_replay = Arc::new(UnavailableReplayGuard);
f.state = Arc::new(state);
for body in [
None,
Some(json!({"branch":"main", "expected_manifest":before})),
] {
let (status, body) = f.call(&f.owner, body, None).await;
assert_eq!(status, StatusCode::UNAUTHORIZED, "{body}");
assert!(body.to_string().contains("replay check unavailable"));
}
assert_eq!(f.snapshot().await.digest, before);
f.state = original;
// Enter the deletion executor's transaction scope in this disposable
// fixture; the DB correctly rejects unfenced ad-hoc state changes.
let mut tx = f.pool.begin().await.unwrap();
sqlx::query("SELECT set_config('buzz.deletion_executor_community', $1, true), set_config('buzz.deletion_fence_generation', '0', true)")
.bind(f.tenant.community().to_string())
.execute(&mut *tx).await.unwrap();
sqlx::query("UPDATE communities SET deletion_state = 'quiescing' WHERE id = $1")
.bind(f.tenant.community().as_uuid())
.execute(&mut *tx)
.await
.unwrap();
tx.commit().await.unwrap();
assert_ne!(f.set(&f.owner, "main", None).await.0, StatusCode::OK);
assert_eq!(f.snapshot().await.digest, before);
}
}

View File

@@ -247,7 +247,7 @@ impl axum::extract::FromRequestParts<Arc<AppState>> for GitAuth {
/// Cascades to the proven NIP-OA owner, matching the NIP-42 gate in
/// `handlers::auth`: banning a human must also revoke their agents, or the ban
/// is bypassable by cloning and pushing through an agent key.
async fn deny_banned_git_principal(
pub(super) async fn deny_banned_git_principal(
db: &buzz_db::Db,
community: buzz_core::CommunityId,
pubkey: &nostr::PublicKey,
@@ -362,7 +362,7 @@ fn git_expected_url(
/// repo root — but the *name* validation stays because owner/repo are
/// still used as object-store key components via `manifest::pointer_key`.
#[allow(clippy::result_large_err)] // Response is the natural error type for axum handlers
fn validate_repo_id<'a>(owner: &str, repo: &'a str) -> Result<&'a str, Response> {
pub(super) fn validate_repo_id<'a>(owner: &str, repo: &'a str) -> Result<&'a str, Response> {
// Owner must be exactly 64 lowercase hex chars.
if owner.len() != 64
|| !owner
@@ -483,13 +483,13 @@ fn hydrate_error_to_response(owner: &str, repo: &str, err: HydrateError) -> Resp
/// — so the remediation body leaks nothing, and only the author can rebind
/// (kind:30617 is keyed by `(author, d)`). A *broken* binding stays generic
/// even for the author: ambiguity fails closed.
async fn authorize_git_read(
pub(super) async fn authorize_git_read(
db: &buzz_db::Db,
community: buzz_core::CommunityId,
caller: &nostr::PublicKey,
owner_hex: &str,
repo_name: &str,
) -> Result<(), Response> {
) -> Result<nostr::Event, Response> {
fn denied() -> Response {
(StatusCode::NOT_FOUND, "repository not found").into_response()
}
@@ -552,7 +552,7 @@ async fn authorize_git_read(
.get_member_role(community, channel_id, &caller.to_bytes())
.await
{
Ok(role) if read_role_allows(role.as_deref()) => Ok(()),
Ok(role) if read_role_allows(role.as_deref()) => Ok(repo_event.event),
Ok(_) => Err(denied()),
Err(e) => {
error!(repo = %repo_name, error = %e, "git read gate: role lookup failed (deny)");
@@ -2118,6 +2118,7 @@ pub fn git_router(state: Arc<AppState>) -> Router {
.route("/git/{owner}/{repo}/info/refs", get(info_refs))
.route("/git/{owner}/{repo}/git-upload-pack", post(upload_pack))
.route("/git/{owner}/{repo}/git-receive-pack", post(receive_pack))
.merge(super::settings::router())
.layer(RequestBodyLimitLayer::new(body_limit))
.with_state(state)
}
@@ -3305,7 +3306,7 @@ mod sec005_postgres_tests {
/// can assert on the exact bytes a git client would see. A blind
/// `.is_err()` cannot distinguish the generic 404 from the remediation
/// 404 — and that distinction IS the security property.
async fn denial_parts(result: Result<(), Response>) -> (StatusCode, String) {
async fn denial_parts<T: std::fmt::Debug>(result: Result<T, Response>) -> (StatusCode, String) {
let response = result.expect_err("expected a denial");
let status = response.status();
let bytes = axum::body::to_bytes(response.into_body(), usize::MAX)

View File

@@ -133,6 +133,61 @@ with the relay event published best-effort afterward — so making manifest-CAS
commit, with the event derived from it, is the change, not just a storage swap.
See §Implementation Correspondence.)
### Default-branch management
`buzz repos default-branch get/set` reads or changes the published manifest's
symbolic `head`. It selects an **existing** `refs/heads/<branch>`; it never moves
branch tips, creates/deletes refs, or changes packs. A subsequent push preserves
that HEAD while the branch exists, and fresh clones check it out.
This is a narrow, intentional exception to the Nostr-first API preference:
`GET`/`POST /git/{owner}/{repo}/default-branch` completes against the host-local
Git pointer transaction. A kind:30617 metadata edit is not equivalent: normal
event ingest persists before side effects and duplicate acknowledgements do not
rerun them. A new signed command/result lifecycle could drive the same CAS, but
adds a second completion/retry protocol for this one Git-host operation. Repo
metadata and ACLs remain in Nostr; kind:30618 remains a derived notification,
never authoritative Git state. This exception is not a general repository-settings
HTTP API.
- Both methods require request-specific NIP-98 (exact host-derived URL/method,
timestamp, signature, shared fail-closed replay check). POST also requires a
payload hash. Reusable Smart HTTP credentials are insufficient.
- GET requires current membership of the channel bound by the current 30617
announcement. POST additionally requires a non-archived channel and the repo
author, a named maintainer, or the recorded human owner of an agent-authored
repo. Push permission, channel admin status and project membership alone do
not grant management. Relay admission and signer/attested-owner bans apply.
- An agent may inherit management from a verified NIP-OA owner who is also a
current channel member and manager. Kind-restricted credentials cannot grant
this HTTP authority (there is no event kind); temporal restrictions still
apply. Direct signer authority does not depend on an optional owner's channel
membership. A stored agent-owner relationship is an ownership lookup, not a
live delegation credential.
- Authorization is **admission-time**: later membership/maintainer removal,
rebinding or archival does not cancel an already admitted write. This is
narrower than the vision's instantaneous-revocation aspiration. The shared
serving-write lease separately fences community deletion through the write.
GET returns `{branch, head, manifest}`. POST accepts only
`{branch, expected_manifest}` (maximum 4096 bytes) and returns the same snapshot
plus `changed`. The caller's digest must match the loaded pointer. A changed
manifest records the prior digest as its parent, then commits with that pointer's
observed ETag. Even a no-op checks the ETag. A concurrent push or default change
returns 409 instead of silently reloading and overwriting it.
The pointer CAS is the commit point. Notification failure after commit returns
an explicit error, not a rollback claim. The CLI sends POST once, with redirects
disabled. Ambiguous delivery (including server errors or a lost response) is
non-retryable `delivery_unknown` and retains the original expected digest. Read
current state before deciding on another write; blindly running `set` without
`--expected-manifest` would observe a fresh version and can override someone
else's later decision. A conflict is exit code 5.
Deploy relay support **before** using an updated CLI against that relay. This
requires no schema migration and changes no live repository merely by deploying.
See the [CLI examples](../crates/buzz-cli/README.md#default-branch).
## Axioms
The protocol's safety is proved *relative to* the following properties of the