Skip to content
Open
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
21 changes: 21 additions & 0 deletions NOSTR.md
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,27 @@ nak req -k 39002 --tag "d=<channel-uuid>" --auth --sec <privkey> ws://localhost:
> receive these via fan-out. Clients discover groups via historical REQ queries. Live push for
> open-channel discovery is a future enhancement.

### NIP-34 Repository Visibility

Buzz repository announcements use NIP-34 kind `30617`. Repositories are public to authenticated relay members by default. To make a repository private to a channel, its current kind `30617` announcement must include exactly these Buzz extension tags:

```json
["buzz-visibility", "private"]
["buzz-channel", "<channel-uuid>"]
```

A private announcement must contain exactly one two-element `buzz-visibility` tag and exactly one two-element `buzz-channel` tag with a valid channel UUID. The repository owner must be a current member of that channel when the announcement is published. Invalid or conflicting privacy metadata is rejected before storage.

The current kind `30617` announcement is authoritative for access. A private repository can be discovered and read by:

- the repository key that authored the announcement;
- the verified owner of that key when it belongs to a Buzz-managed agent; or
- a current member of the bound channel.

Channel membership is checked live on each request, so adding or removing a member changes access on the next request. The same gate applies to kind `30617` repository announcements, relay-signed kind `30618` repository-state announcements, WebSocket `REQ`/`COUNT`/search and live fan-out, HTTP query/count/search, and all Git Smart HTTP endpoints (`info/refs`, `git-upload-pack`, and `git-receive-pack`). Unauthorized Git requests return the same `404 repository not found` response as an absent repository so callers cannot use the endpoint to enumerate private repositories.

The `buzz-visibility` tag is an explicit opt-in. Existing announcements without `["buzz-visibility", "private"]` keep their previous public-read behavior, including announcements that already use `buzz-channel` to bind push policy. Changing read visibility does not implicitly change that existing push binding.

### Membership Notifications

The relay emits relay-signed notifications when members are added or removed:
Expand Down
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,8 @@ Agents are part of the room, not haunted cron jobs.
| Git events (NIP-34: patches, repo announcements, status) | | |
| Git hosting backend | | |

Repositories are visible to workspace members by default and can optionally be made private to a channel. In Desktop, choose **Private** when creating a project and select an access channel, or use the Public/Private badge menu on a project you own. Private projects are discoverable and readable only by the repository key, its verified owner when the key belongs to a managed agent, and current members of the selected channel. See [NIP-34 Repository Visibility](NOSTR.md#nip-34-repository-visibility) for the wire format and access rules.

<sub>Please do not plan your compliance program around the 💭 column yet. The <a href="VISION.md">VISION docs</a> are the long version of what we think this becomes.</sub>

---
Expand Down
10 changes: 10 additions & 0 deletions crates/buzz-cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,13 @@ buzz mem set <slug> "my-value"
buzz mem patch <slug> --base-hash <hex> < diff.patch # or --no-base-hash
buzz mem rm <slug>

# Repository visibility (public is the default)
buzz repos create --id public-repo --name "Public repository"
buzz repos create --id private-repo --name "Private repository" \
--visibility private --channel <channel-uuid>
buzz repos edit --id private-repo --visibility public
buzz repos edit --id public-repo --visibility private --channel <channel-uuid>

# Repository protection
buzz repos protect list --id my-repo
buzz repos protect set --id my-repo --ref refs/heads/main --push admin --no-force-push --no-delete
Expand All @@ -95,6 +102,8 @@ buzz channels list | jq '.[].name'
constraint omitted from the command is removed. `protect list` reports malformed
stored rules in `validation_error` so an owner can remove and repair them.

Private repositories require `--visibility private` and `--channel <uuid>`. The repository owner must currently belong to that channel. `repos edit --visibility public` removes private-read gating while preserving an existing `buzz-channel` push-policy binding. Relay rejections, including a stale or invalid channel membership, are returned with the relay's specific error message.

## Commands

| Group | Subcommand | Description |
Expand Down Expand Up @@ -148,6 +157,7 @@ stored rules in `validation_error` so an owner can remove and repair them.
| | `notes` | Get notes for a user |
| | `contacts` | Get NIP-02 contact list |
| `repos` | `create` | Announce a git repository (NIP-34) |
| | `edit` | Change repository visibility and private channel binding |
| | `get` | Get a repository announcement |
| | `list` | List repository announcements |
| | `protect list` | List branch and tag protection rules |
Expand Down
238 changes: 212 additions & 26 deletions crates/buzz-cli/src/commands/repos.rs
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,69 @@ fn build_updated_repo_announcement(
.map(|builder| builder.custom_created_at(Timestamp::from(next_created_at)))
}

fn visibility_tags(
visibility: crate::RepoVisibility,
channel: Option<&str>,
) -> Result<Vec<Tag>, CliError> {
match visibility {
crate::RepoVisibility::Public => {
if channel.is_some() {
return Err(CliError::Usage(
"--channel is only valid with --visibility private".into(),
));
}
Ok(Vec::new())
}
crate::RepoVisibility::Private => {
let channel = channel.ok_or_else(|| {
CliError::Usage("--visibility private requires --channel <uuid>".into())
})?;
uuid::Uuid::parse_str(channel)
.map_err(|_| CliError::Usage("--channel must be a valid UUID".into()))?;
Ok(vec![
Tag::parse(["buzz-visibility", "private"])
.map_err(|error| CliError::Other(format!("build visibility tag: {error}")))?,
Tag::parse(["buzz-channel", channel])
.map_err(|error| CliError::Other(format!("build channel tag: {error}")))?,
])
}
}
}

fn build_updated_repo_metadata(
existing: &Event,
visibility: crate::RepoVisibility,
channel: Option<&str>,
) -> Result<EventBuilder, CliError> {
let repo_id = repo_id_from_event(existing)?;
let existing_channel = existing
.tags
.iter()
.find(|tag| has_tag_name(tag, "buzz-channel"))
.cloned();
let mut tags: Vec<Tag> = existing
.tags
.iter()
.filter(|tag| !has_tag_name(tag, "buzz-visibility") && !has_tag_name(tag, "buzz-channel"))
.cloned()
.collect();
tags.extend(visibility_tags(visibility, channel)?);
if matches!(visibility, crate::RepoVisibility::Public) {
if let Some(existing_channel) = existing_channel {
tags.push(existing_channel);
}
}

let next_created_at = existing
.created_at
.as_secs()
.checked_add(1)
.ok_or_else(|| CliError::Other("repository timestamp cannot be advanced".into()))?;
buzz_sdk::build_repo_announcement_with_tags(repo_id, &existing.content, tags)
.map_err(|error| CliError::Other(format!("failed to build repository update: {error}")))
.map(|builder| builder.custom_created_at(Timestamp::from(next_created_at)))
}

fn protection_rules_json(event: &Event) -> Result<serde_json::Value, CliError> {
let raw_tags: Vec<Vec<String>> = event
.tags
Expand Down Expand Up @@ -199,29 +262,37 @@ async fn submit_repo_update(client: &BuzzClient, builder: EventBuilder) -> Resul
Ok(())
}

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],
) -> Result<(), CliError> {
validate_repo_id(repo_id)?;
struct CreateRepoArgs<'a> {
repo_id: &'a str,
name: Option<&'a str>,
description: Option<&'a str>,
clone_urls: &'a [String],
web_url: Option<&'a str>,
relays: &'a [String],
visibility: crate::RepoVisibility,
channel: Option<&'a str>,
}

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();
fn build_create_repo_announcement(args: &CreateRepoArgs<'_>) -> Result<EventBuilder, CliError> {
let clone_refs: Vec<&str> = args.clone_urls.iter().map(String::as_str).collect();
let relay_refs: Vec<&str> = args.relays.iter().map(String::as_str).collect();
let visibility_tags = visibility_tags(args.visibility, args.channel)?;

let builder = buzz_sdk::build_repo_announcement(
repo_id,
name,
description,
buzz_sdk::build_repo_announcement(
args.repo_id,
args.name,
args.description,
&clone_refs,
web_url,
args.web_url,
&relay_refs,
)
.map_err(|e| CliError::Other(format!("build_repo_announcement failed: {e}")))?;
.map(|builder| builder.tags(visibility_tags))
.map_err(|error| CliError::Other(format!("build_repo_announcement failed: {error}")))
}

async fn cmd_create_repo(client: &BuzzClient, args: CreateRepoArgs<'_>) -> Result<(), CliError> {
validate_repo_id(args.repo_id)?;
let builder = build_create_repo_announcement(&args)?;

let event = client.sign_event(builder)?;
let resp = client.submit_event(event).await?;
Expand Down Expand Up @@ -292,6 +363,17 @@ async fn current_repo(client: &BuzzClient, repo_id: &str) -> Result<Event, CliEr
})
}

async fn cmd_edit_repo(
client: &BuzzClient,
repo_id: &str,
visibility: crate::RepoVisibility,
channel: Option<&str>,
) -> Result<(), CliError> {
let event = current_repo(client, repo_id).await?;
let builder = build_updated_repo_metadata(&event, visibility, channel)?;
submit_repo_update(client, builder).await
}

async fn cmd_protect_list(client: &BuzzClient, repo_id: &str) -> Result<(), CliError> {
let event = current_repo(client, repo_id).await?;
println!("{}", protection_rules_json(&event)?);
Expand Down Expand Up @@ -356,18 +438,29 @@ pub async fn dispatch(cmd: crate::ReposCmd, client: &BuzzClient) -> Result<(), C
clone_urls,
web,
relays,
visibility,
channel,
} => {
cmd_create_repo(
client,
&id,
name.as_deref(),
description.as_deref(),
&clone_urls,
web.as_deref(),
&relays,
CreateRepoArgs {
repo_id: &id,
name: name.as_deref(),
description: description.as_deref(),
clone_urls: &clone_urls,
web_url: web.as_deref(),
relays: &relays,
visibility,
channel: channel.as_deref(),
},
)
.await
}
ReposCmd::Edit {
id,
visibility,
channel,
} => cmd_edit_repo(client, &id, visibility, channel.as_deref()).await,
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::Protect(command) => match command {
Expand Down Expand Up @@ -403,8 +496,9 @@ mod tests {
use nostr::{EventBuilder, Keys, Kind, Tag, Timestamp};

use super::{
build_protection_tag, build_updated_repo_announcement, protection_rules_json,
validate_write_response, ProtectionChange,
build_create_repo_announcement, build_protection_tag, build_updated_repo_announcement,
build_updated_repo_metadata, has_tag_name, protection_rules_json, validate_write_response,
visibility_tags, CreateRepoArgs, ProtectionChange,
};

fn signed_repo(tags: Vec<Tag>, content: &str, created_at: u64) -> nostr::Event {
Expand All @@ -419,6 +513,98 @@ mod tests {
Tag::parse(parts.iter().copied()).expect("valid test tag")
}

#[test]
fn create_builds_metadata_and_visibility_tags_in_one_pass() {
let clone_urls = vec!["https://relay.example/git/owner/demo".to_string()];
let relays = vec!["wss://relay.example".to_string()];
let channel = uuid::Uuid::new_v4().to_string();
let event = build_create_repo_announcement(&CreateRepoArgs {
repo_id: "demo",
name: Some("Demo"),
description: Some("Private demo repository"),
clone_urls: &clone_urls,
web_url: Some("https://relay.example/repos/demo"),
relays: &relays,
visibility: crate::RepoVisibility::Private,
channel: Some(&channel),
})
.expect("build repository announcement")
.sign_with_keys(&Keys::generate())
.expect("sign repository announcement");

for expected in [
vec!["d", "demo"],
vec!["name", "Demo"],
vec!["description", "Private demo repository"],
vec!["clone", "https://relay.example/git/owner/demo"],
vec!["web", "https://relay.example/repos/demo"],
vec!["relays", "wss://relay.example"],
vec!["buzz-visibility", "private"],
vec!["buzz-channel", channel.as_str()],
] {
assert!(
event.tags.iter().any(|tag| tag.as_slice() == expected),
"missing tag {expected:?}"
);
}
assert!(event.content.is_empty());
}

#[test]
fn visibility_tags_require_private_channel_and_reject_public_channel() {
let channel = uuid::Uuid::new_v4().to_string();
let private = visibility_tags(crate::RepoVisibility::Private, Some(&channel))
.expect("private visibility tags");
assert!(private
.iter()
.any(|tag| tag.as_slice() == ["buzz-visibility", "private"]));
assert!(private
.iter()
.any(|tag| tag.as_slice() == ["buzz-channel", channel.as_str()]));
assert!(visibility_tags(crate::RepoVisibility::Private, None).is_err());
assert!(visibility_tags(crate::RepoVisibility::Private, Some("invalid")).is_err());
assert!(visibility_tags(crate::RepoVisibility::Public, Some(&channel)).is_err());
assert!(visibility_tags(crate::RepoVisibility::Public, None)
.expect("public visibility tags")
.is_empty());
}

#[test]
fn visibility_update_preserves_metadata_and_push_binding_for_public() {
let channel = uuid::Uuid::new_v4().to_string();
let existing = signed_repo(
vec![
tag(&["d", "demo"]),
tag(&["name", "Demo"]),
tag(&["buzz-visibility", "private"]),
tag(&["buzz-channel", &channel]),
tag(&["future-metadata", "preserve-me"]),
tag(&["auth", &"a".repeat(64), "kind=30617", &"b".repeat(128)]),
],
"repository content",
100,
);
let updated = build_updated_repo_metadata(&existing, crate::RepoVisibility::Public, None)
.expect("build public update")
.sign_with_keys(&Keys::generate())
.expect("sign update");
assert_eq!(updated.created_at.as_secs(), 101);
assert_eq!(updated.content, "repository content");
assert!(!updated
.tags
.iter()
.any(|tag| has_tag_name(tag, "buzz-visibility")));
assert!(updated
.tags
.iter()
.any(|tag| tag.as_slice() == ["buzz-channel", channel.as_str()]));
assert!(updated
.tags
.iter()
.any(|tag| tag.as_slice() == ["future-metadata", "preserve-me"]));
assert!(updated.tags.iter().any(|tag| has_tag_name(tag, "auth")));
}

#[test]
fn protection_update_preserves_metadata_and_replaces_only_matching_pattern() {
let existing = signed_repo(
Expand Down
Loading