diff --git a/crates/buzz-cli/src/commands/repos.rs b/crates/buzz-cli/src/commands/repos.rs index 34d50d177b..608d495055 100644 --- a/crates/buzz-cli/src/commands/repos.rs +++ b/crates/buzz-cli/src/commands/repos.rs @@ -214,21 +214,30 @@ async fn submit_repo_update(client: &BuzzClient, builder: EventBuilder) -> Resul Ok(()) } -pub async fn cmd_create_repo( - client: &BuzzClient, +/// Build the kind:30617 announcement for `repos create`, including the +/// `buzz-channel` binding when requested. +/// +/// Pure (no I/O) so the emitted tags are unit-testable. Exactly one +/// validated `buzz-channel` tag is appended — the tag is the git ACL +/// (issue #3527: without it the relay 404s every clone/fetch/push), so the +/// UUID is shape-validated here and its existence/membership is the relay's +/// authority at git-access time, same posture as `repos bind`. +#[allow(clippy::too_many_arguments)] +fn build_create_announcement( repo_id: &str, name: Option<&str>, description: Option<&str>, clone_urls: &[String], web_url: Option<&str>, relays: &[String], -) -> Result<(), CliError> { + channel: Option<&str>, +) -> Result { validate_repo_id(repo_id)?; let clone_refs: Vec<&str> = clone_urls.iter().map(|s| s.as_str()).collect(); let relay_refs: Vec<&str> = relays.iter().map(|s| s.as_str()).collect(); - let builder = buzz_sdk::build_repo_announcement( + let mut builder = buzz_sdk::build_repo_announcement( repo_id, name, description, @@ -238,6 +247,33 @@ pub async fn cmd_create_repo( ) .map_err(|e| CliError::Other(format!("build_repo_announcement failed: {e}")))?; + if let Some(channel) = channel { + crate::validate::validate_uuid(channel)?; + builder = builder.tag(Tag::parse(["buzz-channel", channel]).map_err(tag_error)?); + } + Ok(builder) +} + +#[allow(clippy::too_many_arguments)] +pub async fn cmd_create_repo( + client: &BuzzClient, + repo_id: &str, + name: Option<&str>, + description: Option<&str>, + clone_urls: &[String], + web_url: Option<&str>, + relays: &[String], + channel: Option<&str>, +) -> Result<(), CliError> { + let builder = build_create_announcement( + repo_id, + name, + description, + clone_urls, + web_url, + relays, + channel, + )?; let event = client.sign_event(builder)?; let resp = client.submit_event(event).await?; println!("{resp}"); @@ -391,6 +427,7 @@ pub async fn dispatch(cmd: crate::ReposCmd, client: &BuzzClient) -> Result<(), C clone_urls, web, relays, + channel, } => { cmd_create_repo( client, @@ -400,6 +437,7 @@ pub async fn dispatch(cmd: crate::ReposCmd, client: &BuzzClient) -> Result<(), C &clone_urls, web.as_deref(), &relays, + channel.as_deref(), ) .await } @@ -439,8 +477,8 @@ mod tests { use nostr::{EventBuilder, Keys, Kind, Tag, Timestamp}; use super::{ - build_protection_tag, build_updated_repo_announcement, protection_rules_json, - validate_write_response, RepoChange, + build_create_announcement, build_protection_tag, build_updated_repo_announcement, + protection_rules_json, validate_write_response, RepoChange, }; fn signed_repo(tags: Vec, content: &str, created_at: u64) -> nostr::Event { @@ -732,6 +770,64 @@ mod tests { assert!(matches!(error, crate::error::CliError::Usage(_))); } + /// Issue #3527: `repos create --channel` must emit exactly one + /// `buzz-channel` tag so the primary create command stops producing + /// repos the relay 404s forever. + #[test] + fn create_with_channel_emits_exactly_one_binding_tag() { + let channel = uuid::Uuid::new_v4().to_string(); + let event = build_create_announcement( + "demo", + Some("Demo"), + None, + &["https://relay.example/git/owner/demo".to_string()], + None, + &[], + Some(&channel), + ) + .expect("build create announcement") + .sign_with_keys(&Keys::generate()) + .expect("sign create announcement"); + + assert_eq!(event.kind, Kind::Custom(30617)); + let bindings: Vec<_> = event + .tags + .iter() + .filter(|tag| tag.as_slice().first().map(String::as_str) == Some("buzz-channel")) + .collect(); + assert_eq!(bindings.len(), 1, "exactly one buzz-channel tag"); + assert_eq!(bindings[0].as_slice(), ["buzz-channel", channel.as_str()]); + // The standard metadata still rides along. + assert!(event.tags.iter().any(|tag| tag.as_slice() == ["d", "demo"])); + assert!(event + .tags + .iter() + .any(|tag| tag.as_slice() == ["name", "Demo"])); + } + + #[test] + fn create_without_channel_emits_no_binding_tag() { + let event = build_create_announcement("demo", None, None, &[], None, &[], None) + .expect("build create announcement") + .sign_with_keys(&Keys::generate()) + .expect("sign create announcement"); + + assert!( + !event + .tags + .iter() + .any(|tag| tag.as_slice().first().map(String::as_str) == Some("buzz-channel")), + "no --channel means no binding tag (vanilla NIP-34 stays possible)" + ); + } + + #[test] + fn create_rejects_malformed_channel_uuid() { + let error = build_create_announcement("demo", None, None, &[], None, &[], Some("nope")) + .expect_err("malformed channel id must not build an announcement"); + assert!(matches!(error, crate::error::CliError::Usage(_))); + } + #[test] fn duplicate_write_response_is_a_conflict() { let error = validate_write_response( diff --git a/crates/buzz-cli/src/lib.rs b/crates/buzz-cli/src/lib.rs index dd364e0434..02a58a618e 100644 --- a/crates/buzz-cli/src/lib.rs +++ b/crates/buzz-cli/src/lib.rs @@ -1129,6 +1129,11 @@ pub enum ReposCmd { /// Preferred Nostr relay(s) for repo discovery — can be specified multiple times #[arg(long = "nostr-relay")] relays: Vec, + /// Channel UUID to bind the repo to. The `buzz-channel` tag is the + /// git ACL: without it the relay 404s every clone/fetch/push until + /// the author runs `buzz repos bind` (issue #3527). + #[arg(long)] + channel: Option, }, /// Get a repository announcement Get { diff --git a/crates/buzz-relay/src/api/git/policy.rs b/crates/buzz-relay/src/api/git/policy.rs index bf381af4b0..32d63f4600 100644 --- a/crates/buzz-relay/src/api/git/policy.rs +++ b/crates/buzz-relay/src/api/git/policy.rs @@ -302,15 +302,26 @@ pub async fn hook_policy_check( // 6. Resolve channel binding via the shared resolver (same first-tag, // fail-closed semantics as the read gate) and check archived state - // (applies to ALL pushers including owner). On the push side, NotBound - // and Broken collapse into the same bucket — both historically produced - // the "no channel binding" denial for non-owners, and a pusher is past - // NIP-98 auth so the body leaks nothing new. Only the read gate - // distinguishes them (author remediation is NotBound-only). + // (applies to ALL pushers including owner). + // + // `Broken` denies HERE, before owner resolution: a malformed or + // ambiguous first binding fails closed for *everyone*, exactly like the + // read gate. Letting it fall through as "unbound" would hand the owner + // short-circuit below a push path through a binding the read gate + // refuses to honor — the tri-state exists precisely so Broken and + // NotBound cannot collapse. Only genuinely-NotBound repos proceed, and + // only they may earn the remediation-token denial. let channel_id = match crate::api::git::binding::resolve_repo_binding(&repo_event.event) { crate::api::git::binding::RepoBinding::Bound(id) => Some(id), - crate::api::git::binding::RepoBinding::NotBound - | crate::api::git::binding::RepoBinding::Broken => None, + crate::api::git::binding::RepoBinding::NotBound => None, + crate::api::git::binding::RepoBinding::Broken => { + warn!(repo = %req.repo_id, "hook callback: broken buzz-channel binding"); + // Deliberately NOT the no_channel_binding token body: the + // remediation contract is NotBound-only. A broken binding is + // ambiguity, and ambiguity gets a generic denial (matching the + // read gate's posture for the same announcement). + return (StatusCode::FORBIDDEN, "invalid channel binding").into_response(); + } }; if let Some(ch_id) = channel_id { @@ -808,4 +819,169 @@ printf '%s' "$HMAC_INPUT" | openssl dgst -sha256 -hmac "{secret}" -hex 2>/dev/nu "Single-ref HMAC mismatch!\n Rust: {rust_sig}\n Bash: {bash_sig}" ); } + + // ── hook_policy_check binding gate (requires Postgres) ────────────── + + const TEST_DB_URL: &str = "postgres://buzz:buzz_dev@localhost:5432/buzz"; // sadscan:disable np.postgres.1 + + async fn policy_test_state() -> Arc { + let mut config = crate::config::Config::from_env().expect("default config loads"); + config.require_relay_membership = false; + config.redis_url = "redis://127.0.0.1:1".to_string(); + config.database_url = std::env::var("BUZZ_TEST_DATABASE_URL") + .or_else(|_| std::env::var("DATABASE_URL")) + .unwrap_or_else(|_| TEST_DB_URL.to_string()); + let pool = sqlx::PgPool::connect(&config.database_url) + .await + .expect("connect test DB"); + let db = buzz_db::Db::from_pool(pool.clone()); + let redis_pool = deadpool_redis::Config::from_url(&config.redis_url) + .create_pool(Some(deadpool_redis::Runtime::Tokio1)) + .expect("redis pool"); + let pubsub = Arc::new( + buzz_pubsub::PubSubManager::new(&config.redis_url, redis_pool.clone()) + .await + .expect("pubsub manager"), + ); + 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_engine = Arc::new(buzz_workflow::WorkflowEngine::new( + db.clone(), + buzz_workflow::WorkflowConfig::default(), + )); + let media_storage = buzz_media::MediaStorage::new(&config.media).expect("media storage"); + let (state, _audit_shutdown) = AppState::new( + config, + db, + redis_pool, + audit, + pubsub, + auth, + search, + workflow_engine, + nostr::Keys::generate(), + media_storage, + ); + Arc::new(state) + } + + /// Announce `repo_id` with the given tags, then push to it as its own + /// announcement author and return the response. + async fn owner_push_response( + state: &Arc, + community: buzz_core::CommunityId, + keys: &nostr::Keys, + repo_id: &str, + binding_tags: Vec, + ) -> axum::response::Response { + use nostr::{EventBuilder, Kind, Tag}; + + let mut tags = vec![Tag::parse(["d", repo_id]).unwrap()]; + tags.extend(binding_tags); + let event = EventBuilder::new(Kind::Custom(30617), "") + .tags(tags) + .sign_with_keys(keys) + .expect("sign 30617"); + state + .db + .insert_event(community, &event, None) + .await + .expect("insert 30617"); + + let owner_hex = keys.public_key().to_hex(); + let mut req = HookCallbackRequest { + repo_id: repo_id.to_string(), + repo_owner: owner_hex.clone(), + community_id: community.as_uuid().to_string(), + pusher_pubkey: owner_hex, + ref_updates: vec![HookRefUpdate { + old_oid: "0".repeat(40), + new_oid: "2".repeat(40), + ref_name: "refs/heads/main".to_string(), + is_ancestor: false, + }], + timestamp: SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_secs(), + signature: String::new(), + }; + let secret = state.config.git_hook_hmac_secret.clone(); + sign_request(&mut req, secret.as_bytes()); + hook_policy_check(State(Arc::clone(state)), Json(req)).await + } + + async fn body_string(response: axum::response::Response) -> (StatusCode, String) { + let status = response.status(); + let bytes = axum::body::to_bytes(response.into_body(), usize::MAX) + .await + .expect("read body"); + (status, String::from_utf8(bytes.to_vec()).expect("utf-8")) + } + + /// The tri-state trap the resolver exists to prevent: a broken (malformed + /// or ambiguous-first) binding must fail closed for EVERYONE on push — + /// including the announcement author — *before* the owner short-circuit + /// grants `MemberRole::Owner`. Collapsing `Broken` into "unbound" hands + /// the owner a push path through a binding the read gate refuses to + /// honor. The remediation token stays reserved for genuinely NotBound. + #[tokio::test] + #[ignore = "requires Postgres"] + async fn push_gate_denies_owner_through_broken_binding() { + use nostr::{Keys, Tag}; + + let state = policy_test_state().await; + let host = format!("policy-{}.example", uuid::Uuid::new_v4().simple()); + let community = state + .db + .ensure_configured_community(&host) + .await + .expect("community") + .id; + let keys = Keys::generate(); + + // Malformed first + valid-looking second: the ambiguity must deny, + // and the parseable duplicate must not rescue the push. + let response = owner_push_response( + &state, + community, + &keys, + &format!("repo-{}", uuid::Uuid::new_v4().simple()), + vec![ + Tag::parse(["buzz-channel", "not-a-uuid"]).unwrap(), + Tag::parse(["buzz-channel", &uuid::Uuid::new_v4().to_string()]).unwrap(), + ], + ) + .await; + let (status, body) = body_string(response).await; + assert_eq!(status, StatusCode::FORBIDDEN); + assert_eq!( + body, "invalid channel binding", + "owner pushing through a broken binding must be denied generically" + ); + assert!( + !body.contains(buzz_core::git_perms::GIT_NO_CHANNEL_BINDING_TOKEN), + "remediation token is NotBound-only; Broken must never earn it" + ); + + // Control: the same owner pushing a genuinely NEVER-BOUND repo is + // allowed (owner authority over an unbound announcement is the + // long-standing push semantics). This pins the denial above to + // Broken specifically, not to some broader regression. + let response = owner_push_response( + &state, + community, + &keys, + &format!("repo-{}", uuid::Uuid::new_v4().simple()), + vec![], + ) + .await; + let (status, body) = body_string(response).await; + assert_eq!( + status, + StatusCode::OK, + "owner push to a never-bound repo must remain allowed (got body: {body})" + ); + } }