fix(git): explain and repair unbound repo announcements (#3527)

A kind:30617 repo announcement without a buzz-channel tag — e.g. from a
vanilla NIP-34 client, or buzz repos create before #3594 — made the repo
permanently 404 for everyone including its own author, with no
explanation and no tool to fix it. The channel binding IS the git ACL
(SEC-005), so the deny is correct; the silence and the dead end were the
bug.

Relay:
- New shared resolver api/git/binding.rs: tri-state RepoBinding
  (Bound/NotBound/Broken) with first-tag fail-closed semantics, now used
  by both the read gate and the push policy so their parses cannot
  drift. Malformed-vs-absent is distinguished; ambiguous duplicate tags
  still deny.
- Read gate: the announcement AUTHOR of a never-bound repo now gets a
  404 whose text/plain body leads with the fix
  (run: buzz repos bind --id <repo> --channel <uuid>). Status stays 404
  and everyone else keeps the generic body, so denial class remains
  unprobeable; a broken binding stays generic even for the author.
- Push deny body for unbound repos is now the declared contract
  no_channel_binding: repository has no channel binding (consts in
  buzz-core::git_perms) — the token for structured consumers, the legacy
  phrase for desktops already in the field; a test pins both matchers.
- Side-effect failures at ingest now log at error! (production runs
  RUST_LOG=error; warn! made the #3527 triage blind).

CLI:
- New buzz repos bind --id <repo> --channel <uuid>: re-announces with
  exactly one buzz-channel tag (replacing any duplicates), preserving
  content, protections, and unknown metadata.

Desktop:
- Merge-error conversion maps the token to a structured
  no_channel_binding code with remediation copy (moved with its tests to
  commands/project_git_merge_error.rs to satisfy the file-size ratchet).
- Branch-dialog copy matcher lifted to lib/projectBranchErrors.ts,
  dual-accepting token and legacy phrase, with node-test coverage.

e2e: git fixtures now create and bind a channel, matching what the CLI
emits since #3594.

Fixes #3527

Co-authored-by: Tyler Longwell <tlongwell@block.xyz>
Signed-off-by: Tyler Longwell <tlongwell@block.xyz>
This commit is contained in:
npub1qyvc0c5kl4gqv2fd97fsk46tu378sqgy35vc83rvgfwne90sel7s0ed67d
2026-07-29 17:16:14 -04:00
co-authored by Tyler Longwell
parent 005b5b819a
commit ede1065227
15 changed files with 787 additions and 253 deletions
+134 -17
View File
@@ -83,27 +83,36 @@ fn build_protection_tag(
Tag::parse(values).map_err(tag_error)
}
enum ProtectionChange {
Set(Box<Tag>),
Remove(String),
enum RepoChange {
SetProtection(Box<Tag>),
RemoveProtection(String),
/// Bind (or rebind) the repo to a channel: replaces every existing
/// `buzz-channel` tag with exactly one carrying the validated UUID.
BindChannel(String),
}
fn build_updated_repo_announcement(
existing: &Event,
change: ProtectionChange,
change: RepoChange,
) -> Result<EventBuilder, CliError> {
let repo_id = repo_id_from_event(existing)?;
let (pattern, replacement) = match change {
ProtectionChange::Set(tag) => {
// What to strip beyond `auth` (always stripped), and what to append.
let (removed_pattern, removed_channel, replacement) = match change {
RepoChange::SetProtection(tag) => {
let pattern = protection_pattern(&tag)
.ok_or_else(|| CliError::Other("replacement is not a protection tag".into()))?
.to_string();
(pattern, Some(*tag))
(Some(pattern), false, Some(*tag))
}
ProtectionChange::Remove(pattern) => {
RepoChange::RemoveProtection(pattern) => {
RefPattern::parse(&pattern)
.map_err(|error| CliError::Usage(format!("invalid ref pattern: {error}")))?;
(pattern, None)
(Some(pattern), false, None)
}
RepoChange::BindChannel(channel) => {
crate::validate::validate_uuid(&channel)?;
let tag = Tag::parse(["buzz-channel", channel.as_str()]).map_err(tag_error)?;
(None, true, Some(tag))
}
};
@@ -111,7 +120,13 @@ fn build_updated_repo_announcement(
.tags
.iter()
.filter(|tag| {
!has_tag_name(tag, "auth") && protection_pattern(tag) != Some(pattern.as_str())
if has_tag_name(tag, "auth") {
return false;
}
if removed_channel && has_tag_name(tag, "buzz-channel") {
return false;
}
removed_pattern.is_none() || protection_pattern(tag) != removed_pattern.as_deref()
})
.cloned()
.collect();
@@ -320,7 +335,8 @@ async fn cmd_protect_set(
require_patch,
)?;
let event = current_repo(client, repo_id).await?;
let builder = build_updated_repo_announcement(&event, ProtectionChange::Set(Box::new(tag)))?;
let builder =
build_updated_repo_announcement(&event, RepoChange::SetProtection(Box::new(tag)))?;
submit_repo_update(client, builder).await
}
@@ -341,8 +357,27 @@ async fn cmd_protect_remove(
"repository {repo_id:?} has no protection rule for {ref_pattern:?}"
)));
}
let builder = build_updated_repo_announcement(
&event,
RepoChange::RemoveProtection(ref_pattern.to_string()),
)?;
submit_repo_update(client, builder).await
}
/// Bind (or rebind) a repository to a channel — the fix path for issue
/// #3527's permanently-404 repos. Publishes a read-modify-write update of
/// the caller's own kind:30617 with exactly one `buzz-channel` tag; all
/// other metadata (protections, name, description, future tags) is
/// preserved by the same machinery `repos protect` uses.
///
/// The UUID is validated for *shape* only — deliberately. Channel existence
/// and the caller's membership are the relay's authority at git-access
/// time; a CLI-side network pre-check would just be TOCTOU with extra
/// latency.
async fn cmd_bind_repo(client: &BuzzClient, repo_id: &str, channel: &str) -> Result<(), CliError> {
let event = current_repo(client, repo_id).await?;
let builder =
build_updated_repo_announcement(&event, ProtectionChange::Remove(ref_pattern.to_string()))?;
build_updated_repo_announcement(&event, RepoChange::BindChannel(channel.to_string()))?;
submit_repo_update(client, builder).await
}
@@ -370,6 +405,7 @@ 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::Protect(command) => match command {
ReposProtectCmd::List { id } => cmd_protect_list(client, &id).await,
ReposProtectCmd::Set {
@@ -404,7 +440,7 @@ mod tests {
use super::{
build_protection_tag, build_updated_repo_announcement, protection_rules_json,
validate_write_response, ProtectionChange,
validate_write_response, RepoChange,
};
fn signed_repo(tags: Vec<Tag>, content: &str, created_at: u64) -> nostr::Event {
@@ -439,7 +475,7 @@ mod tests {
let updated = build_updated_repo_announcement(
&existing,
ProtectionChange::Set(Box::new(replacement)),
RepoChange::SetProtection(Box::new(replacement)),
)
.expect("build update")
.sign_with_keys(&Keys::generate())
@@ -501,7 +537,7 @@ mod tests {
let updated = build_updated_repo_announcement(
&existing,
ProtectionChange::Remove("refs/heads/main".into()),
RepoChange::RemoveProtection("refs/heads/main".into()),
)
.expect("build removal")
.sign_with_keys(&Keys::generate())
@@ -538,7 +574,7 @@ mod tests {
let error = build_updated_repo_announcement(
&existing,
ProtectionChange::Set(Box::new(replacement)),
RepoChange::SetProtection(Box::new(replacement)),
)
.expect_err("malformed existing rule must fail closed");
@@ -564,7 +600,7 @@ mod tests {
let error = build_updated_repo_announcement(
&existing,
ProtectionChange::Set(Box::new(replacement)),
RepoChange::SetProtection(Box::new(replacement)),
)
.expect_err("the 51st rule must be rejected");
@@ -615,6 +651,87 @@ mod tests {
.is_some_and(|error| error.contains("needs pattern + at least one rule")));
}
#[test]
fn bind_channel_replaces_duplicates_and_preserves_everything_else() {
let channel = uuid::Uuid::new_v4().to_string();
let existing = signed_repo(
vec![
tag(&["d", "demo"]),
tag(&["name", "Demo"]),
// Two stale bindings — e.g. from a buggy or vanilla client.
tag(&["buzz-channel", "old-and-broken"]),
tag(&["buzz-channel", &uuid::Uuid::new_v4().to_string()]),
tag(&["auth", &"a".repeat(64), "kind=30617", &"b".repeat(128)]),
tag(&["buzz-protect", "refs/heads/main", "push:admin"]),
tag(&["future-metadata", "preserve-me"]),
],
"repository content",
100,
);
let updated =
build_updated_repo_announcement(&existing, RepoChange::BindChannel(channel.clone()))
.expect("build bind update")
.sign_with_keys(&Keys::generate())
.expect("sign bind update");
assert_eq!(updated.content, "repository content");
assert_eq!(updated.created_at.as_secs(), 101);
// Exactly one binding remains, and it is the requested one.
let bindings: Vec<_> = updated
.tags
.iter()
.filter(|tag| tag.as_slice().first().map(String::as_str) == Some("buzz-channel"))
.collect();
assert_eq!(bindings.len(), 1);
assert_eq!(bindings[0].as_slice(), ["buzz-channel", channel.as_str()]);
// Auth stripped (relay re-stamps); everything else preserved.
assert!(!updated
.tags
.iter()
.any(|tag| tag.as_slice().first().map(String::as_str) == Some("auth")));
assert!(updated
.tags
.iter()
.any(|tag| tag.as_slice() == ["buzz-protect", "refs/heads/main", "push:admin"]));
assert!(updated
.tags
.iter()
.any(|tag| tag.as_slice() == ["future-metadata", "preserve-me"]));
assert!(updated
.tags
.iter()
.any(|tag| tag.as_slice() == ["name", "Demo"]));
}
#[test]
fn bind_channel_adds_binding_to_unbound_repo() {
let channel = uuid::Uuid::new_v4().to_string();
let existing = signed_repo(vec![tag(&["d", "demo"])], "", 10);
let updated =
build_updated_repo_announcement(&existing, RepoChange::BindChannel(channel.clone()))
.expect("build bind update")
.sign_with_keys(&Keys::generate())
.expect("sign bind update");
assert!(updated
.tags
.iter()
.any(|tag| tag.as_slice() == ["buzz-channel", channel.as_str()]));
}
#[test]
fn bind_channel_rejects_malformed_uuid() {
let existing = signed_repo(vec![tag(&["d", "demo"])], "", 10);
let error =
build_updated_repo_announcement(&existing, RepoChange::BindChannel("nope".into()))
.expect_err("malformed channel id must not build an update");
assert!(matches!(error, crate::error::CliError::Usage(_)));
}
#[test]
fn duplicate_write_response_is_a_conflict() {
let error = validate_write_response(
+16 -2
View File
@@ -1148,6 +1148,20 @@ pub enum ReposCmd {
#[arg(long)]
limit: Option<u32>,
},
/// Bind (or rebind) one of your repositories to a channel.
///
/// The `buzz-channel` tag on the announcement is the git ACL: the relay
/// authorizes clone/fetch/push by membership in the bound channel. A
/// repo announced without it (e.g. by a vanilla NIP-34 client) returns
/// 404 for everyone until its author binds it here.
Bind {
/// Repository identifier (d-tag).
#[arg(long)]
id: String,
/// Channel UUID to bind. Replaces any existing binding.
#[arg(long)]
channel: String,
},
/// Manage branch and tag protection rules on one of your repositories.
#[command(subcommand)]
Protect(ReposProtectCmd),
@@ -1991,7 +2005,7 @@ mod tests {
);
assert_eq!(
names(&cmd, "repos"),
vec!["create", "get", "list", "protect"]
vec!["bind", "create", "get", "list", "protect"]
);
let repos = cmd
.get_subcommands()
@@ -2054,7 +2068,7 @@ mod tests {
("patches", 4),
("pr", 5),
("reactions", 3),
("repos", 4),
("repos", 5),
("social", 7),
("upload", 1),
("users", 5),
+24
View File
@@ -15,6 +15,30 @@
use crate::channel::MemberRole;
use std::fmt;
/// Machine-readable token prefixing the push-policy denial for a kind:30617
/// announcement with no `buzz-channel` binding.
///
/// This is a **declared cross-component contract**, not a log string. Known
/// consumers switch on it:
/// - relay `api/git/policy.rs` — produces [`GIT_NO_CHANNEL_BINDING_BODY`]
/// - desktop `src-tauri/commands/project_git_workflow.rs` — merge-failure
/// classifier maps it to a structured `no_channel_binding` error code
/// - desktop `src/features/projects/lib/projectBranchErrors.ts` — dialog
/// copy matcher (TS re-types the literal; its test pins the value)
pub const GIT_NO_CHANNEL_BINDING_TOKEN: &str = "no_channel_binding";
/// Full push-policy denial body for an unbound repository.
///
/// Format: `<token>: <legacy phrase>`. The trailing prose deliberately
/// repeats the token's meaning because desktops already in the field match
/// the exact phrase `no channel binding` (spaces, not underscores — the
/// token alone would NOT satisfy that matcher). Do not "fix" the redundancy:
/// removing the phrase silently breaks every shipped desktop, and removing
/// the token breaks the structured consumers above. A relay-side test pins
/// both matchers.
pub const GIT_NO_CHANNEL_BINDING_BODY: &str =
"no_channel_binding: repository has no channel binding";
/// Maximum number of `buzz-protect` tags per repo.
pub const MAX_PROTECTION_RULES: usize = 50;
/// Maximum character length of a ref pattern.
+128
View File
@@ -0,0 +1,128 @@
//! Repo → channel binding resolution, shared by the read gate and push policy.
//!
//! The `buzz-channel` tag on a kind:30617 announcement IS the git ACL: the
//! read gate (SEC-005, `transport::authorize_git_read`) and the push policy
//! endpoint (`policy::hook_callback`) both authorize against membership in
//! the bound channel. Before this module they each parsed the tag with their
//! own code that agreed only by coincidence; the resolver makes the
//! agreement structural.
//!
//! # First-tag, fail-closed semantics
//!
//! Only the *first* `buzz-channel` tag is considered, and it must carry a
//! valid UUID. A malformed first binding resolves to [`RepoBinding::Broken`]
//! even if a later duplicate tag is valid — an ambiguous announcement must
//! fail closed, not silently resolve to whichever duplicate happens to
//! parse. If this ever became "find the first *parseable* tag", an author
//! who can append a second `buzz-channel` tag would pick the channel.
//!
//! # What this deliberately does NOT do
//!
//! No DB access. A well-formed UUID that names a nonexistent or deleted
//! channel still resolves to [`RepoBinding::Bound`]; each gate's own
//! membership lookup then denies (`get_member_role` joins
//! `channels … deleted_at IS NULL`, so a dead channel is indistinguishable
//! from a non-member — the info-leak-safe posture). Likewise each gate keeps
//! its own archived-channel policy: push denies on archived channels, read
//! does not, and this resolver must not unify that asymmetry as a side
//! effect.
use uuid::Uuid;
/// How a kind:30617 announcement binds (or fails to bind) a channel.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RepoBinding {
/// No `buzz-channel` tag at all. The announcement author (the only
/// identity that can rebind — 30617 is keyed by `(author, d)`) may be
/// offered remediation; everyone else gets the generic denial.
NotBound,
/// First `buzz-channel` tag carries a valid UUID.
Bound(Uuid),
/// First `buzz-channel` tag exists but its value is not a UUID.
/// Fail closed with the generic denial — never remediation, which
/// would leak that the repo exists.
Broken,
}
/// Resolve the channel binding of a kind:30617 announcement from its tags.
pub fn resolve_repo_binding(event: &nostr::Event) -> RepoBinding {
let Some(first) = event
.tags
.iter()
.find(|t| t.as_slice().first().map(String::as_str) == Some("buzz-channel"))
else {
return RepoBinding::NotBound;
};
match first.as_slice().get(1).map(|v| Uuid::parse_str(v)) {
Some(Ok(id)) => RepoBinding::Bound(id),
_ => RepoBinding::Broken,
}
}
#[cfg(test)]
mod tests {
use nostr::{EventBuilder, Keys, Kind, Tag};
use super::{resolve_repo_binding, RepoBinding};
fn announcement(tags: Vec<Tag>) -> nostr::Event {
EventBuilder::new(Kind::Custom(30617), "")
.tags(tags)
.sign_with_keys(&Keys::generate())
.expect("sign 30617")
}
#[test]
fn extracts_valid_uuid() {
let ch = uuid::Uuid::new_v4();
let event = announcement(vec![
Tag::parse(["d", "repo"]).unwrap(),
Tag::parse(["buzz-channel", &ch.to_string()]).unwrap(),
]);
assert_eq!(resolve_repo_binding(&event), RepoBinding::Bound(ch));
}
#[test]
fn absent_tag_is_not_bound() {
let event = announcement(vec![Tag::parse(["d", "repo"]).unwrap()]);
assert_eq!(resolve_repo_binding(&event), RepoBinding::NotBound);
}
#[test]
fn malformed_and_empty_values_are_broken_not_absent() {
let malformed = announcement(vec![
Tag::parse(["d", "repo"]).unwrap(),
Tag::parse(["buzz-channel", "not-a-uuid"]).unwrap(),
]);
assert_eq!(resolve_repo_binding(&malformed), RepoBinding::Broken);
let empty = announcement(vec![
Tag::parse(["d", "repo"]).unwrap(),
Tag::parse(["buzz-channel"]).unwrap(),
]);
assert_eq!(resolve_repo_binding(&empty), RepoBinding::Broken);
}
#[test]
fn fails_closed_on_ambiguous_duplicate_bindings() {
let ch = uuid::Uuid::new_v4();
let other = uuid::Uuid::new_v4();
// Malformed first + valid second: the ambiguity denies; the valid
// duplicate must NOT win, or the duplicate picks the channel.
let malformed_first = announcement(vec![
Tag::parse(["d", "repo"]).unwrap(),
Tag::parse(["buzz-channel", "not-a-uuid"]).unwrap(),
Tag::parse(["buzz-channel", &ch.to_string()]).unwrap(),
]);
assert_eq!(resolve_repo_binding(&malformed_first), RepoBinding::Broken);
// Valid first + different second: first wins deterministically.
let valid_first = announcement(vec![
Tag::parse(["d", "repo"]).unwrap(),
Tag::parse(["buzz-channel", &ch.to_string()]).unwrap(),
Tag::parse(["buzz-channel", &other.to_string()]).unwrap(),
]);
assert_eq!(resolve_repo_binding(&valid_first), RepoBinding::Bound(ch));
}
}
+1
View File
@@ -22,6 +22,7 @@ use tower_http::limit::RequestBodyLimitLayer;
use crate::state::AppState;
pub mod binding;
pub mod cas_publish;
pub mod hook;
pub mod hydrate;
+44 -8
View File
@@ -42,7 +42,10 @@ use tracing::{error, warn};
use uuid::Uuid;
use buzz_core::channel::MemberRole;
use buzz_core::git_perms::{evaluate_push, parse_protection_tags, Denial, RefUpdate, UpdateKind};
use buzz_core::git_perms::{
evaluate_push, parse_protection_tags, Denial, RefUpdate, UpdateKind,
GIT_NO_CHANNEL_BINDING_BODY,
};
use buzz_db::EventQuery;
use crate::state::AppState;
@@ -297,12 +300,18 @@ pub async fn hook_policy_check(
}
};
// 6. Resolve channel and check archived state (applies to ALL pushers including owner).
let channel_id = tags
.iter()
.find(|t| t.first().map(|s| s.as_str()) == Some("buzz-channel"))
.and_then(|t| t.get(1))
.and_then(|id| Uuid::parse_str(id).ok());
// 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).
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,
};
if let Some(ch_id) = channel_id {
match state.db.get_channel(community, ch_id).await {
@@ -350,7 +359,10 @@ pub async fn hook_policy_check(
match channel_id {
None => {
warn!(repo = %req.repo_id, "hook callback: no buzz-channel binding");
return (StatusCode::FORBIDDEN, "no channel binding").into_response();
// Declared cross-component contract — see the const docs in
// buzz-core::git_perms for who consumes the token and why
// the body also repeats the legacy phrase.
return (StatusCode::FORBIDDEN, GIT_NO_CHANNEL_BINDING_BODY).into_response();
}
Some(ch_id) => {
match state
@@ -482,6 +494,30 @@ mod tests {
assert!(!verify_hmac(b"wrong-secret", &req));
}
/// Deploy-skew guard for the unbound-repo deny body. The token
/// (`no_channel_binding`, underscores) and the legacy phrase
/// (`no channel binding`, spaces) do NOT contain each other, so the body
/// must carry both: the token for structured consumers (Desktop's merge
/// classifier and dialog matcher), the phrase for desktops already in
/// the field that prose-match it. Relay ships continuously and Desktop
/// on release cadence — dropping the phrase strands every old desktop
/// on a new relay. Asserted against the shared consts, not re-typed
/// literals, so the const and this test cannot drift apart separately.
#[test]
fn no_channel_binding_body_satisfies_old_and_new_matchers() {
assert!(
GIT_NO_CHANNEL_BINDING_BODY.starts_with(&format!(
"{}: ",
buzz_core::git_perms::GIT_NO_CHANNEL_BINDING_TOKEN
)),
"new structured consumers match the token prefix"
);
assert!(
GIT_NO_CHANNEL_BINDING_BODY.contains("no channel binding"),
"shipped desktops prose-match this exact phrase (spaces, not underscores)"
);
}
#[test]
fn hmac_tampered_repo_id_rejected() {
let secret = b"test-secret";
+162 -107
View File
@@ -28,6 +28,7 @@ use tokio::process::Command;
use tower_http::limit::RequestBodyLimitLayer;
use tracing::{error, info, warn};
use super::binding::{resolve_repo_binding, RepoBinding};
use super::cas_publish::{cas_publish, CasError, ParentState, PublishLimits};
use super::hook::install_hook;
use super::hydrate::{
@@ -377,7 +378,15 @@ fn hydrate_error_to_response(owner: &str, repo: &str, err: HydrateError) -> Resp
/// error all deny. There is deliberately **no repo-owner bypass**: an owner
/// removed from the bound channel loses read access, which is the exact
/// exploit shape this gate closes. Every denial is the same generic 404 as a
/// nonexistent repo so membership cannot be probed through the git endpoints.
/// nonexistent repo so membership cannot be probed through the git endpoints
/// — with exactly one carve-out: a **never-bound** repo read by its own
/// **announcement author** returns a 404 whose body tells the author how to
/// bind it (issue #3527: a vanilla NIP-34 client can announce without a
/// `buzz-channel` tag, and the repo then 404s forever with no explanation
/// for anyone). The author already knows the repo exists — they announced it
/// — 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(
db: &buzz_db::Db,
community: buzz_core::CommunityId,
@@ -415,9 +424,32 @@ async fn authorize_git_read(
}
};
let Some(channel_id) = repo_bound_channel_id(&repo_event.event) else {
warn!(repo = %repo_name, "git read gate: missing/malformed buzz-channel binding (deny)");
return Err(denied());
let channel_id = match resolve_repo_binding(&repo_event.event) {
RepoBinding::Bound(id) => id,
RepoBinding::NotBound => {
// Remediation carve-out: author of a never-bound announcement.
// Status stays 404 — byte-identical to every other denial at the
// status level — so denial *class* is still unprobeable; only
// the body differs, and only for the one identity that already
// knows the repo exists. The body is a single verb-first line:
// Desktop error paths that keep one line keep the instruction.
if repo_event.event.pubkey == *caller {
warn!(repo = %repo_name, "git read gate: unbound repo read by its author (deny with remediation)");
return Err((
StatusCode::NOT_FOUND,
format!(
"run: buzz repos bind --id {repo_name} --channel <channel-uuid> — repository {repo_name:?} has no channel binding, so the relay cannot authorize access"
),
)
.into_response());
}
warn!(repo = %repo_name, "git read gate: missing buzz-channel binding (deny)");
return Err(denied());
}
RepoBinding::Broken => {
warn!(repo = %repo_name, "git read gate: malformed buzz-channel binding (deny)");
return Err(denied());
}
};
match db
@@ -433,24 +465,6 @@ async fn authorize_git_read(
}
}
/// Extract the `buzz-channel` UUID from a kind:30617 announcement.
///
/// First-tag semantics, matching the push policy endpoint: only the *first*
/// `buzz-channel` tag is considered, and it must carry a valid UUID. A
/// malformed first binding denies even if a later duplicate tag is valid —
/// an ambiguous announcement must fail closed, not silently resolve to
/// whichever duplicate happens to parse.
fn repo_bound_channel_id(event: &nostr::Event) -> Option<uuid::Uuid> {
let first = event
.tags
.iter()
.find(|t| t.as_slice().first().map(String::as_str) == Some("buzz-channel"))?;
first
.as_slice()
.get(1)
.and_then(|v| uuid::Uuid::parse_str(v).ok())
}
/// Pure decision for [`authorize_git_read`]: a read requires a current
/// active membership row whose role the relay recognizes.
///
@@ -2454,75 +2468,30 @@ mod sec005_read_gate_tests {
.expect("sign 30617")
}
#[test]
fn repo_bound_channel_id_extracts_valid_uuid() {
let keys = Keys::generate();
let ch = uuid::Uuid::new_v4();
let event = announcement(
&keys,
vec![
Tag::parse(["d", "r"]).unwrap(),
Tag::parse(["buzz-channel", &ch.to_string()]).unwrap(),
],
);
assert_eq!(repo_bound_channel_id(&event), Some(ch));
// Binding *parse* semantics (first-tag fails-closed, duplicate-tag
// ambiguity, malformed vs. absent) are unit-tested where the resolver
// lives: `super::super::binding`. The tests below prove the *gate* wires
// each resolver outcome to the right response — allow, generic denial
// body, or the author remediation body — which the resolver tests
// cannot see.
/// Collapse an `authorize_git_read` denial to `(status, body)` so 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) {
let response = result.expect_err("expected a denial");
let status = response.status();
let bytes = axum::body::to_bytes(response.into_body(), usize::MAX)
.await
.expect("read denial body");
(
status,
String::from_utf8(bytes.to_vec()).expect("utf-8 body"),
)
}
#[test]
fn repo_bound_channel_id_rejects_absent_and_malformed_bindings() {
let keys = Keys::generate();
let absent = announcement(&keys, vec![Tag::parse(["d", "r"]).unwrap()]);
assert_eq!(repo_bound_channel_id(&absent), None);
let malformed = announcement(
&keys,
vec![
Tag::parse(["d", "r"]).unwrap(),
Tag::parse(["buzz-channel", "not-a-uuid"]).unwrap(),
],
);
assert_eq!(repo_bound_channel_id(&malformed), None);
let empty = announcement(
&keys,
vec![
Tag::parse(["d", "r"]).unwrap(),
Tag::parse(["buzz-channel"]).unwrap(),
],
);
assert_eq!(repo_bound_channel_id(&empty), None);
}
#[test]
fn repo_bound_channel_id_fails_closed_on_ambiguous_duplicate_bindings() {
// First-tag semantics: a malformed first binding must deny even when
// a later duplicate tag is valid. An ambiguous announcement must not
// silently resolve to whichever duplicate happens to parse.
let keys = Keys::generate();
let ch = uuid::Uuid::new_v4();
let malformed_first = announcement(
&keys,
vec![
Tag::parse(["d", "r"]).unwrap(),
Tag::parse(["buzz-channel", "not-a-uuid"]).unwrap(),
Tag::parse(["buzz-channel", &ch.to_string()]).unwrap(),
],
);
assert_eq!(repo_bound_channel_id(&malformed_first), None);
// And the mirror image: a valid first binding wins, matching the
// push policy endpoint's first-tag resolution.
let other = uuid::Uuid::new_v4();
let valid_first = announcement(
&keys,
vec![
Tag::parse(["d", "r"]).unwrap(),
Tag::parse(["buzz-channel", &ch.to_string()]).unwrap(),
Tag::parse(["buzz-channel", &other.to_string()]).unwrap(),
],
);
assert_eq!(repo_bound_channel_id(&valid_first), Some(ch));
}
const GENERIC_DENIAL: &str = "repository not found";
// ── authorize_git_read matrix (requires Postgres) ────────────────────
@@ -2544,6 +2513,11 @@ mod sec005_read_gate_tests {
Missing,
/// `buzz-channel` tag whose value is not a UUID.
Malformed,
/// `buzz-channel` tag carrying a well-formed UUID that names no
/// channel. The resolver reports `Bound`; the membership lookup
/// (whose SQL joins `channels … deleted_at IS NULL`) then returns
/// no role — the deliberate phase-1 posture for dead bindings.
UnknownChannel,
}
struct RepoFixture {
@@ -2609,6 +2583,9 @@ mod sec005_read_gate_tests {
Binding::Malformed => {
tags.push(Tag::parse(["buzz-channel", "not-a-uuid"]).unwrap());
}
Binding::UnknownChannel => {
tags.push(Tag::parse(["buzz-channel", &uuid::Uuid::new_v4().to_string()]).unwrap());
}
}
let event = announcement(&owner_keys, tags);
db.insert_event(community, &event, None)
@@ -2676,32 +2653,63 @@ mod sec005_read_gate_tests {
#[tokio::test]
#[ignore = "requires Postgres"]
async fn read_gate_denies_missing_or_malformed_binding_and_absent_repo() {
// Missing buzz-channel tag → deny even for a channel member.
// Missing buzz-channel tag → deny even for a channel member, with
// the generic body: the remediation carve-out is author-only.
let f = setup_repo(Binding::Missing).await;
let member = f.member_keys.public_key();
assert!(
authorize_git_read(&f.db, f.community, &member, &f.owner_hex, &f.repo)
.await
.is_err(),
"announcement without buzz-channel binding must deny"
let (status, body) = denial_parts(
authorize_git_read(&f.db, f.community, &member, &f.owner_hex, &f.repo).await,
)
.await;
assert_eq!(status, StatusCode::NOT_FOUND);
assert_eq!(
body, GENERIC_DENIAL,
"unbound repo read by a NON-author must get the generic body — \
remediation for anyone but the announcement author leaks repo existence"
);
// Malformed buzz-channel tag → deny.
// Malformed buzz-channel tag → deny with the generic body EVEN FOR
// THE AUTHOR. This is the assertion that pins the carve-out to
// NotBound: if it ever fires on Broken, this fails on bytes, not
// on Ok/Err (which cannot see the difference).
let g = setup_repo(Binding::Malformed).await;
let member_g = g.member_keys.public_key();
assert!(
authorize_git_read(&g.db, g.community, &member_g, &g.owner_hex, &g.repo)
.await
.is_err(),
"announcement with malformed buzz-channel binding must deny"
let g_owner = g.owner_keys.public_key();
let (status, body) = denial_parts(
authorize_git_read(&g.db, g.community, &g_owner, &g.owner_hex, &g.repo).await,
)
.await;
assert_eq!(status, StatusCode::NOT_FOUND);
assert_eq!(
body, GENERIC_DENIAL,
"broken binding must stay generic even for the author (ambiguity fails closed)"
);
// Well-formed UUID naming a nonexistent channel → resolver says
// Bound, membership lookup finds nothing → generic denial for
// everyone, author included. The dead-channel case must be
// indistinguishable from non-membership (phase-1 posture; ingest
// validation closes the front door in phase 2).
let u = setup_repo(Binding::UnknownChannel).await;
let u_owner = u.owner_keys.public_key();
let (status, body) = denial_parts(
authorize_git_read(&u.db, u.community, &u_owner, &u.owner_hex, &u.repo).await,
)
.await;
assert_eq!(status, StatusCode::NOT_FOUND);
assert_eq!(
body, GENERIC_DENIAL,
"binding to a nonexistent channel must deny generically, even for the author"
);
// Nonexistent announcement → deny.
assert!(
authorize_git_read(&f.db, f.community, &member, &f.owner_hex, "no-such-repo")
.await
.is_err(),
"nonexistent repo must deny"
let (status, body) = denial_parts(
authorize_git_read(&f.db, f.community, &member, &f.owner_hex, "no-such-repo").await,
)
.await;
assert_eq!(status, StatusCode::NOT_FOUND);
assert_eq!(
body, GENERIC_DENIAL,
"nonexistent repo must deny generically"
);
// Owner-mismatch: URL owner differs from announcement author → deny.
@@ -2722,6 +2730,53 @@ mod sec005_read_gate_tests {
);
}
#[tokio::test]
#[ignore = "requires Postgres"]
async fn read_gate_gives_author_of_unbound_repo_remediation_body() {
// Issue #3527: the author of a never-bound announcement is the one
// identity that can fix it (30617 is keyed by (author, d)) and the
// one identity remediation cannot leak anything to. Status must stay
// 404 — identical to every other denial — with the bind command in
// the body.
let f = setup_repo(Binding::Missing).await;
let author = f.owner_keys.public_key();
let response = authorize_git_read(&f.db, f.community, &author, &f.owner_hex, &f.repo)
.await
.expect_err("unbound repo must still deny its author");
assert_eq!(response.status(), StatusCode::NOT_FOUND);
// Guard against a future "tidy" into Json(...) or a custom
// IntoResponse: git prints `remote:` lines only for text/plain
// bodies — any other content-type makes the remediation silently
// invisible in the user's terminal with no failing assertion.
assert_eq!(
response
.headers()
.get(axum::http::header::CONTENT_TYPE)
.and_then(|v| v.to_str().ok()),
Some("text/plain; charset=utf-8"),
"remediation body must stay text/plain or git clients will swallow it"
);
let bytes = axum::body::to_bytes(response.into_body(), usize::MAX)
.await
.expect("read remediation body");
let body = String::from_utf8(bytes.to_vec()).expect("utf-8 body");
assert!(
body.starts_with(&format!("run: buzz repos bind --id {}", f.repo)),
"remediation must lead with the actionable command (got {body:?})"
);
assert_ne!(body, GENERIC_DENIAL);
// Same repo, same state, different caller: a member of some channel
// who is not the author still gets the generic body.
let member = f.member_keys.public_key();
let (_, body) = denial_parts(
authorize_git_read(&f.db, f.community, &member, &f.owner_hex, &f.repo).await,
)
.await;
assert_eq!(body, GENERIC_DENIAL, "remediation is author-only");
}
#[tokio::test]
#[ignore = "requires Postgres"]
async fn read_gate_follows_current_announcement_not_stale_registry() {
+6 -1
View File
@@ -2481,7 +2481,12 @@ async fn ingest_event_inner(
crate::handlers::side_effects::handle_side_effects(tenant, kind_u32, &event, state)
.await
{
warn!(event_id = %event_id_hex, kind = kind_u32, "Side effect failed: {e}");
// error!, not warn!: the event was accepted but its side effects
// (channel creation, git repo seeding, …) did not run — the relay
// is now in a state the client believes it isn't. Production runs
// RUST_LOG=error, so warn! made these failures invisible during
// the #3527 triage.
error!(event_id = %event_id_hex, kind = kind_u32, "Side effect failed: {e}");
}
}
+28
View File
@@ -61,6 +61,27 @@ async fn post_event(event: &nostr::Event) {
);
}
/// Create a channel (kind:9007) owned by `keys` and return its UUID.
///
/// The git read gate (SEC-005) authorizes against membership in the channel
/// named by the announcement's `buzz-channel` tag, so every repo these tests
/// announce must be bound to a channel its owner belongs to — creating the
/// channel makes the creator its owner-member.
async fn create_test_channel(keys: &Keys) -> String {
let channel_uuid = uuid::Uuid::new_v4().to_string();
let event = EventBuilder::new(Kind::Custom(9007), "")
.tags(vec![
Tag::parse(["h", &channel_uuid]).unwrap(),
Tag::parse(["name", &format!("git-e2e-{channel_uuid}")]).unwrap(),
Tag::parse(["channel_type", "stream"]).unwrap(),
Tag::parse(["visibility", "open"]).unwrap(),
])
.sign_with_keys(keys)
.unwrap();
post_event(&event).await;
channel_uuid
}
/// Run `git` with the Buzz credential helper and isolated config.
fn git_status(args: &[&str], cwd: &Path, owner_nsec: &str) -> std::process::Output {
let helper = credential_helper();
@@ -204,10 +225,15 @@ async fn git_clone_push_fetch_force_roundtrip() {
let s3 = GitS3Probe::from_env();
// Announce the repo (kind:30617) so the relay creates the bare repo + hook.
// The `buzz-channel` binding is the repo's ACL: without it the read gate
// 404s even for the owner (issue #3527), so bind to a channel the owner
// just created (and therefore belongs to).
let channel = create_test_channel(&owner).await;
let announce = EventBuilder::new(Kind::from(30617), "")
.tags(vec![
Tag::parse(["d", &repo]).unwrap(),
Tag::parse(["name", "e2e git repo"]).unwrap(),
Tag::parse(["buzz-channel", &channel]).unwrap(),
])
.sign_with_keys(&owner)
.unwrap();
@@ -341,10 +367,12 @@ async fn git_concurrent_push_one_wins_and_repo_recovers() {
let repo = format!("e2e-git-concurrent-{}", std::process::id());
let s3 = GitS3Probe::from_env();
let channel = create_test_channel(&owner).await;
let announce = EventBuilder::new(Kind::from(30617), "")
.tags(vec![
Tag::parse(["d", &repo]).unwrap(),
Tag::parse(["name", "e2e concurrent git repo"]).unwrap(),
Tag::parse(["buzz-channel", &channel]).unwrap(),
])
.sign_with_keys(&owner)
.unwrap();
+1
View File
@@ -44,6 +44,7 @@ mod project_git;
mod project_git_branches;
mod project_git_diff;
mod project_git_exec;
mod project_git_merge_error;
mod project_git_push;
mod project_git_workflow;
mod project_repo_paths;
@@ -0,0 +1,152 @@
//! Structured pull-request merge failures returned across the Tauri boundary.
use serde::Serialize;
/// Machine-readable recovery metadata for a failed pull-request merge.
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ProjectPullRequestMergeRecovery {
action: String,
target_branch: String,
source_branch: String,
}
/// Structured pull-request merge failure returned across the Tauri boundary.
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ProjectPullRequestMergeError {
code: String,
message: String,
recovery: Option<ProjectPullRequestMergeRecovery>,
}
impl ProjectPullRequestMergeError {
pub(crate) fn new(code: &str, message: impl Into<String>) -> Self {
Self {
code: code.to_string(),
message: message.into(),
recovery: None,
}
}
fn conflict(target_branch: String, source_branch: String) -> Self {
Self {
code: "merge_conflict".to_string(),
message: "Pull request has merge conflicts.".to_string(),
recovery: Some(ProjectPullRequestMergeRecovery {
action: "open_terminal".to_string(),
target_branch,
source_branch,
}),
}
}
}
impl From<String> for ProjectPullRequestMergeError {
fn from(message: String) -> Self {
// Relay push-policy denial for a repo with no `buzz-channel` binding.
// The stable token is declared in `buzz-core::git_perms`
// (GIT_NO_CHANNEL_BINDING_TOKEN); the relay guarantees the denial body
// starts with it. Push failures reach this conversion as raw
// stderr/`remote:` text, so match the token anywhere in the message.
if message.contains(buzz_core_pkg::git_perms::GIT_NO_CHANNEL_BINDING_TOKEN) {
return Self::new(
buzz_core_pkg::git_perms::GIT_NO_CHANNEL_BINDING_TOKEN,
"This repository is not bound to a channel, so the relay cannot \
authorize pushes. Bind it with: buzz repos bind --id <repo> \
--channel <channel-uuid>",
);
}
Self::new("merge_failed", message)
}
}
pub(crate) fn classify_merge_error(
message: String,
has_conflicts: bool,
target_branch: &str,
source_branch: &str,
) -> ProjectPullRequestMergeError {
if has_conflicts {
ProjectPullRequestMergeError::conflict(target_branch.to_string(), source_branch.to_string())
} else {
ProjectPullRequestMergeError::new(
"merge_failed",
format!("Pull request merge failed: {message}"),
)
}
}
#[cfg(test)]
mod tests {
use super::{classify_merge_error, ProjectPullRequestMergeError};
#[test]
fn merge_conflict_error_has_stable_recovery_metadata() {
let error =
ProjectPullRequestMergeError::conflict("main".to_string(), "feature/demo".to_string());
assert_eq!(error.code, "merge_conflict");
assert_eq!(error.message, "Pull request has merge conflicts.");
let recovery = error.recovery.expect("conflict recovery");
assert_eq!(recovery.action, "open_terminal");
assert_eq!(recovery.target_branch, "main");
assert_eq!(recovery.source_branch, "feature/demo");
}
#[test]
fn merge_conflict_error_serializes_for_tauri_clients() {
let error =
ProjectPullRequestMergeError::conflict("main".to_string(), "feature/demo".to_string());
let value = serde_json::to_value(error).expect("serialize merge conflict");
assert_eq!(value["code"], "merge_conflict");
assert_eq!(value["recovery"]["targetBranch"], "main");
assert_eq!(value["recovery"]["sourceBranch"], "feature/demo");
}
#[test]
fn merge_error_classification_only_recovers_conflicts() {
let conflict = classify_merge_error(
"CONFLICT (content): Merge conflict in src/main.rs".to_string(),
true,
"main",
"feature/demo",
);
assert_eq!(conflict.code, "merge_conflict");
assert!(conflict.recovery.is_some());
let other = classify_merge_error(
"fatal: refusing to merge unrelated histories".to_string(),
false,
"main",
"feature/demo",
);
assert_eq!(other.code, "merge_failed");
assert!(other.recovery.is_none());
}
#[test]
fn no_channel_binding_denial_converts_to_structured_code() {
// The relay's push-policy denial arrives as raw git stderr with
// `remote:` framing; the stable token must be recognized wherever it
// sits in the message.
let remote_stderr = format!(
"remote: {}\nerror: failed to push some refs",
buzz_core_pkg::git_perms::GIT_NO_CHANNEL_BINDING_BODY
);
let error = ProjectPullRequestMergeError::from(remote_stderr);
assert_eq!(
error.code,
buzz_core_pkg::git_perms::GIT_NO_CHANNEL_BINDING_TOKEN
);
assert!(error.message.contains("buzz repos bind"));
assert!(error.recovery.is_none());
// Unrelated push failures keep the generic code and original text.
let generic = ProjectPullRequestMergeError::from("connection reset".to_string());
assert_eq!(generic.code, "merge_failed");
assert_eq!(generic.message, "connection reset");
}
}
@@ -32,67 +32,10 @@ pub struct ProjectRepoMergeResult {
pub status_publication_error: Option<String>,
}
/// Machine-readable recovery metadata for a failed pull-request merge.
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ProjectPullRequestMergeRecovery {
action: String,
target_branch: String,
source_branch: String,
}
/// Structured pull-request merge failure returned across the Tauri boundary.
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ProjectPullRequestMergeError {
code: String,
message: String,
recovery: Option<ProjectPullRequestMergeRecovery>,
}
impl ProjectPullRequestMergeError {
fn new(code: &str, message: impl Into<String>) -> Self {
Self {
code: code.to_string(),
message: message.into(),
recovery: None,
}
}
fn conflict(target_branch: String, source_branch: String) -> Self {
Self {
code: "merge_conflict".to_string(),
message: "Pull request has merge conflicts.".to_string(),
recovery: Some(ProjectPullRequestMergeRecovery {
action: "open_terminal".to_string(),
target_branch,
source_branch,
}),
}
}
}
impl From<String> for ProjectPullRequestMergeError {
fn from(message: String) -> Self {
Self::new("merge_failed", message)
}
}
fn classify_merge_error(
message: String,
has_conflicts: bool,
target_branch: &str,
source_branch: &str,
) -> ProjectPullRequestMergeError {
if has_conflicts {
ProjectPullRequestMergeError::conflict(target_branch.to_string(), source_branch.to_string())
} else {
ProjectPullRequestMergeError::new(
"merge_failed",
format!("Pull request merge failed: {message}"),
)
}
}
/// Machine-readable pull-request merge failure types live in
/// [`super::project_git_merge_error`]; re-imported here for the merge
/// workflow below.
use super::project_git_merge_error::{classify_merge_error, ProjectPullRequestMergeError};
struct ProjectRepoMergeGitResult {
message: String,
@@ -739,8 +682,8 @@ pub async fn merge_project_pull_request(
mod tests {
use super::{
align_unborn_head_branch, build_merged_status_event, build_pull_request_status_event,
build_review_request_event, classify_merge_error, normalize_commit, same_repository,
validate_merge_status_metadata, ProjectPullRequestMergeError,
build_review_request_event, normalize_commit, same_repository,
validate_merge_status_metadata,
};
use crate::commands::project_git_exec::{build_test_git_auth_config, run_git};
use nostr::{Event, JsonUtil, Keys, Timestamp};
@@ -785,51 +728,6 @@ mod tests {
));
}
#[test]
fn merge_conflict_error_has_stable_recovery_metadata() {
let error =
ProjectPullRequestMergeError::conflict("main".to_string(), "feature/demo".to_string());
assert_eq!(error.code, "merge_conflict");
assert_eq!(error.message, "Pull request has merge conflicts.");
let recovery = error.recovery.expect("conflict recovery");
assert_eq!(recovery.action, "open_terminal");
assert_eq!(recovery.target_branch, "main");
assert_eq!(recovery.source_branch, "feature/demo");
}
#[test]
fn merge_conflict_error_serializes_for_tauri_clients() {
let error =
ProjectPullRequestMergeError::conflict("main".to_string(), "feature/demo".to_string());
let value = serde_json::to_value(error).expect("serialize merge conflict");
assert_eq!(value["code"], "merge_conflict");
assert_eq!(value["recovery"]["targetBranch"], "main");
assert_eq!(value["recovery"]["sourceBranch"], "feature/demo");
}
#[test]
fn merge_error_classification_only_recovers_conflicts() {
let conflict = classify_merge_error(
"CONFLICT (content): Merge conflict in src/main.rs".to_string(),
true,
"main",
"feature/demo",
);
assert_eq!(conflict.code, "merge_conflict");
assert!(conflict.recovery.is_some());
let other = classify_merge_error(
"fatal: refusing to merge unrelated histories".to_string(),
false,
"main",
"feature/demo",
);
assert_eq!(other.code, "merge_failed");
assert!(other.recovery.is_none());
}
#[test]
fn merged_status_is_signed_by_repository_owner() {
let keys = Keys::generate();
@@ -0,0 +1,43 @@
import assert from "node:assert/strict";
import { test } from "node:test";
import {
isNoChannelBindingError,
projectBranchErrorMessage,
} from "./projectBranchErrors.ts";
test("recognizes the relay's stable denial token", () => {
// Body produced by the relay push policy (buzz-core
// GIT_NO_CHANNEL_BINDING_BODY), as it arrives wrapped in git stderr.
assert.ok(
isNoChannelBindingError(
"remote: no_channel_binding: repository has no channel binding\nerror: failed to push some refs",
),
);
});
test("recognizes the legacy spaced phrase from older relays", () => {
assert.ok(isNoChannelBindingError("push denied: no channel binding"));
});
test("does not match unrelated errors", () => {
assert.ok(!isNoChannelBindingError("connection reset by peer"));
assert.ok(!isNoChannelBindingError("no channel"));
});
test("maps binding denials to remediation copy", () => {
const message = projectBranchErrorMessage(
new Error("remote: no_channel_binding: repository has no channel binding"),
"Failed to create branch.",
);
assert.ok(message.includes("buzz repos bind"));
});
test("passes through other errors and falls back for non-errors", () => {
assert.equal(
projectBranchErrorMessage(new Error("boom"), "fallback"),
"boom",
);
assert.equal(projectBranchErrorMessage("boom", "fallback"), "fallback");
assert.equal(projectBranchErrorMessage(null, "fallback"), "fallback");
});
@@ -0,0 +1,35 @@
/**
* Relay push-policy denial token for a repository with no `buzz-channel`
* binding. Declared in Rust as `buzz-core::git_perms::
* GIT_NO_CHANNEL_BINDING_TOKEN`; the relay's denial body starts with it
* ("no_channel_binding: repository has no channel binding"). The legacy
* spaced phrase is kept as a second matcher so this build also recognizes
* denials from relays deployed before the token existed.
*/
const NO_CHANNEL_BINDING_TOKEN = "no_channel_binding";
const NO_CHANNEL_BINDING_LEGACY_PHRASE = "no channel binding";
const NO_CHANNEL_BINDING_COPY =
"This repository is not linked to a project channel, so the relay cannot " +
"authorize access. The repository owner can link it with: " +
"buzz repos bind --id <repo> --channel <channel-uuid>";
/** True when a git/relay error text is the unbound-repository denial. */
export function isNoChannelBindingError(message: string): boolean {
return (
message.includes(NO_CHANNEL_BINDING_TOKEN) ||
message.includes(NO_CHANNEL_BINDING_LEGACY_PHRASE)
);
}
/** Map a thrown branch-operation error to user-facing dialog copy. */
export function projectBranchErrorMessage(
error: unknown,
fallback: string,
): string {
if (!(error instanceof Error)) return fallback;
if (isNoChannelBindingError(error.message)) {
return NO_CHANNEL_BINDING_COPY;
}
return error.message;
}
@@ -5,6 +5,7 @@ import {
normalizeProjectBranchName,
projectBranchNameError,
} from "@/features/projects/lib/projectBranches";
import { projectBranchErrorMessage } from "@/features/projects/lib/projectBranchErrors";
import {
AlertDialog,
AlertDialogCancel,
@@ -25,14 +26,6 @@ import {
} from "@/shared/ui/dialog";
import { Input } from "@/shared/ui/input";
function errorMessage(error: unknown, fallback: string) {
if (!(error instanceof Error)) return fallback;
if (error.message.includes("no channel binding")) {
return "This repository is owned by another identity and is not linked to a project channel.";
}
return error.message;
}
export function CreateProjectBranchDialog({
existingBranches,
onCreate,
@@ -69,7 +62,9 @@ export function CreateProjectBranchDialog({
await onCreate(branch);
onOpenChange(false);
} catch (error) {
setSubmitError(errorMessage(error, "Failed to create branch."));
setSubmitError(
projectBranchErrorMessage(error, "Failed to create branch."),
);
}
}
@@ -165,7 +160,9 @@ export function DeleteProjectBranchDialog({
await onDelete();
onOpenChange(false);
} catch (error) {
setSubmitError(errorMessage(error, "Failed to delete branch."));
setSubmitError(
projectBranchErrorMessage(error, "Failed to delete branch."),
);
}
}