Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
13 changes: 13 additions & 0 deletions .github/workflows/_ci-relay.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
28 changes: 25 additions & 3 deletions crates/buzz-auth/src/nip98.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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
Expand Down
27 changes: 27 additions & 0 deletions crates/buzz-cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down Expand Up @@ -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).
50 changes: 50 additions & 0 deletions crates/buzz-cli/src/client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions crates/buzz-cli/src/commands/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Loading
Loading