From 0482aba696decd3cc518ff04d68147eac0f38d42 Mon Sep 17 00:00:00 2001 From: Krish Ray Date: Thu, 23 Jul 2026 00:56:45 -0400 Subject: [PATCH] feat(git): add channel-scoped private repositories --- NOSTR.md | 21 + README.md | 2 + crates/buzz-cli/README.md | 10 + crates/buzz-cli/src/commands/repos.rs | 238 ++++- crates/buzz-cli/src/lib.rs | 30 +- crates/buzz-relay/src/api/bridge.rs | 325 ++++++- crates/buzz-relay/src/api/git/access.rs | 826 ++++++++++++++++++ crates/buzz-relay/src/api/git/mod.rs | 1 + crates/buzz-relay/src/api/git/transport.rs | 298 ++++++- crates/buzz-relay/src/handlers/count.rs | 68 +- crates/buzz-relay/src/handlers/event.rs | 101 +++ crates/buzz-relay/src/handlers/ingest.rs | 6 + crates/buzz-relay/src/handlers/req.rs | 86 +- .../buzz-relay/src/handlers/side_effects.rs | 32 +- desktop/playwright.config.ts | 1 + desktop/src/features/projects/hooks.ts | 10 + .../projects/ui/CreateProjectDialog.tsx | 54 +- .../src/features/projects/ui/ProjectCards.tsx | 26 +- .../projects/ui/ProjectDetailScreen.tsx | 86 +- .../projects/ui/ProjectVisibilityControl.tsx | 272 ++++++ .../projects/ui/ProjectVisibilityFields.tsx | 193 ++++ .../projects/ui/ProjectsActivityFeed.tsx | 12 +- .../src/features/projects/useCreateProject.ts | 77 ++ desktop/src/testing/e2eBridge.ts | 132 ++- desktop/tests/e2e/project-visibility.spec.ts | 278 ++++++ desktop/tests/helpers/bridge.ts | 6 + web/src/features/repos/mock-repos.ts | 12 +- web/src/features/repos/ui/RepoDetailPage.tsx | 6 +- web/src/features/repos/use-repos.ts | 11 +- 29 files changed, 3098 insertions(+), 122 deletions(-) create mode 100644 crates/buzz-relay/src/api/git/access.rs create mode 100644 desktop/src/features/projects/ui/ProjectVisibilityControl.tsx create mode 100644 desktop/src/features/projects/ui/ProjectVisibilityFields.tsx create mode 100644 desktop/tests/e2e/project-visibility.spec.ts diff --git a/NOSTR.md b/NOSTR.md index 59df31b991..2c44be0241 100644 --- a/NOSTR.md +++ b/NOSTR.md @@ -125,6 +125,27 @@ nak req -k 39002 --tag "d=" --auth --sec 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", ""] +``` + +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: diff --git a/README.md b/README.md index 6aa3eeff0b..cd2fdb2287 100644 --- a/README.md +++ b/README.md @@ -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. + Please do not plan your compliance program around the đź’­ column yet. The VISION docs are the long version of what we think this becomes. --- diff --git a/crates/buzz-cli/README.md b/crates/buzz-cli/README.md index a8c668cf06..2d5cdfbb17 100644 --- a/crates/buzz-cli/README.md +++ b/crates/buzz-cli/README.md @@ -82,6 +82,13 @@ buzz mem set "my-value" buzz mem patch --base-hash < diff.patch # or --no-base-hash buzz mem rm +# 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 +buzz repos edit --id private-repo --visibility public +buzz repos edit --id public-repo --visibility private --channel + # 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 @@ -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 `. 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 | @@ -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 | diff --git a/crates/buzz-cli/src/commands/repos.rs b/crates/buzz-cli/src/commands/repos.rs index 0f570df1aa..5a0cec0027 100644 --- a/crates/buzz-cli/src/commands/repos.rs +++ b/crates/buzz-cli/src/commands/repos.rs @@ -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, 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 ".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 { + 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 = 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 { let raw_tags: Vec> = event .tags @@ -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 { + 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?; @@ -292,6 +363,17 @@ async fn current_repo(client: &BuzzClient, repo_id: &str) -> Result, +) -> 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)?); @@ -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 { @@ -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, content: &str, created_at: u64) -> nostr::Event { @@ -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( diff --git a/crates/buzz-cli/src/lib.rs b/crates/buzz-cli/src/lib.rs index d5c6b6f9ab..d85bf2ce2a 100644 --- a/crates/buzz-cli/src/lib.rs +++ b/crates/buzz-cli/src/lib.rs @@ -1079,6 +1079,14 @@ pub enum NotesCmd { }, } +#[derive(Clone, Copy, clap::ValueEnum)] +pub enum RepoVisibility { + /// Existing behavior: any authenticated relay member can discover and read. + Public, + /// Restrict discovery and Git access to the selected channel. + Private, +} + #[derive(Subcommand)] pub enum ReposCmd { /// Announce a git repository (NIP-34) @@ -1101,6 +1109,24 @@ pub enum ReposCmd { /// Preferred Nostr relay(s) for repo discovery — can be specified multiple times #[arg(long = "nostr-relay")] relays: Vec, + /// Repository read visibility (default: public) + #[arg(long, value_enum, default_value_t = RepoVisibility::Public)] + visibility: RepoVisibility, + /// Channel UUID that may discover/read a private repository + #[arg(long, requires_if("private", "visibility"))] + channel: Option, + }, + /// Change repository visibility and channel binding + Edit { + /// Repository identifier (d-tag) + #[arg(long)] + id: String, + /// New repository read visibility + #[arg(long, value_enum)] + visibility: RepoVisibility, + /// Channel UUID for private visibility. Omit with public to preserve the existing push binding + #[arg(long, requires_if("private", "visibility"))] + channel: Option, }, /// Get a repository announcement Get { @@ -1930,7 +1956,7 @@ mod tests { ); assert_eq!( names(&cmd, "repos"), - vec!["create", "get", "list", "protect"] + vec!["create", "edit", "get", "list", "protect"] ); let repos = cmd .get_subcommands() @@ -1993,7 +2019,7 @@ mod tests { ("patches", 4), ("pr", 5), ("reactions", 3), - ("repos", 4), + ("repos", 5), ("social", 7), ("upload", 1), ("users", 4), diff --git a/crates/buzz-relay/src/api/bridge.rs b/crates/buzz-relay/src/api/bridge.rs index 8372e49a2a..62c1befd26 100644 --- a/crates/buzz-relay/src/api/bridge.rs +++ b/crates/buzz-relay/src/api/bridge.rs @@ -1200,7 +1200,7 @@ async fn query_events_authed( // skips and the `before_id` BAD_REQUEST are decided here, before any DB // work is issued (validation errors are deterministic client mistakes, so // surfacing them ahead of transient DB errors is strictly more predictable). - let mut catchall_queries: Vec<(usize, buzz_db::EventQuery)> = Vec::new(); + let mut catchall_queries: Vec<(usize, buzz_db::EventQuery, usize, usize)> = Vec::new(); for (idx, (raw, filter)) in raw_filters.iter().zip(filters.iter()).enumerate() { if handled.contains(&idx) { continue; @@ -1244,6 +1244,10 @@ async fn query_events_authed( BeforeId::Absent => {} } + let requested_limit = filter.limit.unwrap_or(2_000); + let requested_offset = extract_page_offset(raw, query.limit) + .unwrap_or_default() + .max(0) as usize; // Honor `page` on non-search general queries so offset paging works for // the empty-query people directory (kind:0 listing). The FTS path // (`handle_bridge_search`) has its own `page`/`per_page`; a filter with @@ -1256,7 +1260,15 @@ async fn query_events_authed( query.offset = Some(offset); } - catchall_queries.push((idx, query)); + // Repository access is result-dependent. Apply limit/offset only after + // filtering so hidden private rows cannot consume a discovery page. + if crate::api::git::access::filter_can_match_repository_discovery(filter) { + query.limit = None; + query.offset = None; + query.max_limit = Some(2_000); + } + + catchall_queries.push((idx, query, requested_offset, requested_limit)); } // Phase 2 — DB reads, bounded-concurrent, order-preserving (`buffered`). @@ -1264,15 +1276,27 @@ async fn query_events_authed( // and error semantics match the previous serial loop. use futures_util::stream::{self, StreamExt}; let db = state.db.clone(); - let mut catchall_results = stream::iter(catchall_queries.into_iter().map(|(idx, query)| { - let db = db.clone(); - async move { (idx, db.query_events(&query).await) } - })) + let mut catchall_results = stream::iter(catchall_queries.into_iter().map( + |(idx, query, requested_offset, requested_limit)| { + let db = db.clone(); + async move { + ( + idx, + requested_offset, + requested_limit, + db.query_events(&query).await, + ) + } + }, + )) .buffered(crate::handlers::req::FILTER_QUERY_CONCURRENCY); // Phase 3 — post-processing, strictly in filter order. - while let Some((idx, filter_events)) = catchall_results.next().await { + while let Some((idx, requested_offset, requested_limit, filter_events)) = + catchall_results.next().await + { let filter = &filters[idx]; + let mut accepted_for_filter = 0usize; match filter_events { Ok(stored_events) => { for se in stored_events { @@ -1293,6 +1317,25 @@ async fn query_events_authed( if crate::handlers::req::is_author_only_event(&se.event, &pubkey_bytes) { continue; } + if !crate::api::git::access::requester_can_discover_repository_event( + state, + tenant, + &se.event, + &pubkey_bytes, + ) + .await + { + continue; + } + if crate::api::git::access::filter_can_match_repository_discovery(filter) { + let position = accepted_for_filter; + accepted_for_filter += 1; + if position < requested_offset + || position >= requested_offset.saturating_add(requested_limit) + { + continue; + } + } if let Ok(v) = serde_json::to_value(&se.event) { events.push(v); } @@ -1439,6 +1482,8 @@ async fn count_events_authed( filter, &authed_pubkey_hex, ); + let needs_repository_filtering = + crate::api::git::access::filter_can_match_repository_discovery(filter); // If filter targets a specific channel, verify access. if let Some(ch_id) = extract_channel_from_filter(filter) { @@ -1462,6 +1507,7 @@ async fn count_events_authed( if crate::handlers::req::filter_fully_pushable(filter) && (!needs_author_only_filtering || author_is_self) && !needs_result_gated_filtering + && !needs_repository_filtering { match state.db.count_events(&query).await { Ok(n) => total += n as u64, @@ -1470,12 +1516,30 @@ async fn count_events_authed( } } } else { - // Fallback: query + post-filter for non-pushable constraints. + // Repository discovery COUNT always needs the complete candidate + // set so the per-event access predicate can return an exact count. + // Other non-pushable filters retain the bounded abuse guard. let mut q = query; - crate::handlers::req::apply_count_fallback_limit(&mut q); + if needs_repository_filtering { + crate::handlers::req::apply_repository_discovery_count_limit(&mut q); + } else { + crate::handlers::req::apply_count_fallback_limit(&mut q); + } match state.db.query_events(&q).await { Ok(stored_events) => { - if crate::handlers::req::count_fallback_exceeded(stored_events.len()) { + if needs_repository_filtering + && crate::handlers::req::repository_discovery_count_exceeded( + stored_events.len(), + ) + { + return Err(api_error( + StatusCode::BAD_REQUEST, + "repository count requires narrower constraints", + )); + } + if !needs_repository_filtering + && crate::handlers::req::count_fallback_exceeded(stored_events.len()) + { metrics::counter!("buzz_count_fallback_rejections_total").increment(1); return Err(api_error( StatusCode::BAD_REQUEST, @@ -1497,6 +1561,16 @@ async fn count_events_authed( ) { continue; } + if !crate::api::git::access::requester_can_discover_repository_event( + state, + tenant, + &se.event, + &pubkey_bytes, + ) + .await + { + continue; + } total += 1; } } @@ -1526,6 +1600,7 @@ async fn count_events_authed( if crate::handlers::req::filter_fully_pushable(filter) && (!needs_author_only_filtering || author_is_self) && !needs_result_gated_filtering + && !needs_repository_filtering { query.limit = None; match state.db.count_events(&query).await { @@ -1535,11 +1610,28 @@ async fn count_events_authed( } } } else { - // Fallback: query a bounded candidate set + post-filter. - crate::handlers::req::apply_count_fallback_limit(&mut query); + // Repository discovery COUNT must not be truncated before the + // per-event access gate. Other fallbacks keep the abuse bound. + if needs_repository_filtering { + crate::handlers::req::apply_repository_discovery_count_limit(&mut query); + } else { + crate::handlers::req::apply_count_fallback_limit(&mut query); + } match state.db.query_events(&query).await { Ok(stored_events) => { - if crate::handlers::req::count_fallback_exceeded(stored_events.len()) { + if needs_repository_filtering + && crate::handlers::req::repository_discovery_count_exceeded( + stored_events.len(), + ) + { + return Err(api_error( + StatusCode::BAD_REQUEST, + "repository count requires narrower constraints", + )); + } + if !needs_repository_filtering + && crate::handlers::req::count_fallback_exceeded(stored_events.len()) + { metrics::counter!("buzz_count_fallback_rejections_total").increment(1); return Err(api_error( StatusCode::BAD_REQUEST, @@ -1561,6 +1653,16 @@ async fn count_events_authed( ) { continue; } + if !crate::api::git::access::requester_can_discover_repository_event( + state, + tenant, + &se.event, + &pubkey_bytes, + ) + .await + { + continue; + } total += 1; } } @@ -1735,6 +1837,16 @@ async fn handle_bridge_search( if crate::handlers::req::is_author_only_event(&stored.event, pubkey_bytes) { continue; } + if !crate::api::git::access::requester_can_discover_repository_event( + state, + tenant, + &stored.event, + pubkey_bytes, + ) + .await + { + continue; + } // Dedup across filters. if !seen_ids.insert(*id_array) { continue; @@ -3291,11 +3403,18 @@ mod tests { /// /// Returns `None` when local Postgres is not reachable. async fn bridge_handler_test_state() -> Option> { + bridge_handler_test_state_with_redis( + &std::env::var("REDIS_URL").unwrap_or_else(|_| "redis://127.0.0.1:6379".to_string()), + ) + .await + } + + async fn bridge_handler_test_state_with_redis( + redis_url: &str, + ) -> Option> { let mut config = crate::config::Config::from_env().ok()?; config.database_url = TEST_DB_URL.to_string(); - // Use the real local Redis so enforce_http_admission can pass. - config.redis_url = - std::env::var("REDIS_URL").unwrap_or_else(|_| "redis://127.0.0.1:6379".to_string()); + config.redis_url = redis_url.to_owned(); config.relay_url = "wss://bridge-test.local".to_string(); config.require_auth_token = false; config.require_relay_membership = false; @@ -3392,6 +3511,180 @@ mod tests { .collect() } + /// HTTP `/query` and `/count` must filter private kind:30617 and kind:30618 + /// events without hiding a legacy public repository that has only a + /// `buzz-channel` push binding. + #[tokio::test] + #[ignore = "requires Postgres"] + async fn bridge_repository_discovery_and_counts_follow_live_access() { + let Some(fixture) = + crate::api::git::access::tests::repository_access_fixture("redis://127.0.0.1:6379") + .await + else { + return; + }; + let filters = serde_json::to_vec(&[nostr::Filter::new().kinds([ + Kind::Custom(buzz_core::kind::KIND_GIT_REPO_ANNOUNCEMENT as u16), + Kind::Custom(buzz_core::kind::KIND_GIT_REPO_STATE as u16), + ])]) + .expect("serialize discovery filter"); + let headers = HeaderMap::new(); + + for requester in [&fixture.owner, &fixture.member] { + let Json(Value::Array(events)) = query_events_authed( + &fixture.state, + &fixture.tenant, + &headers, + &filters, + requester.public_key(), + [0u8; 32], + ) + .await + .expect("allowed repository query") else { + panic!("query response must be an event array"); + }; + let ids: std::collections::HashSet = events + .iter() + .filter_map(|event| { + event + .get("id") + .and_then(Value::as_str) + .map(ToOwned::to_owned) + }) + .collect(); + assert!(ids.contains(&fixture.private_announcement.id.to_hex())); + assert!(ids.contains(&fixture.private_state.id.to_hex())); + assert!(ids.contains(&fixture.public_announcement.id.to_hex())); + + let Json(count) = count_events_authed( + &fixture.state, + &fixture.tenant, + &headers, + &filters, + requester.public_key(), + [0u8; 32], + ) + .await + .expect("allowed repository count"); + assert_eq!(count["count"], 3); + } + + let Json(Value::Array(events)) = query_events_authed( + &fixture.state, + &fixture.tenant, + &headers, + &filters, + fixture.outsider.public_key(), + [0u8; 32], + ) + .await + .expect("outsider repository query") else { + panic!("query response must be an event array"); + }; + let ids: std::collections::HashSet = events + .iter() + .filter_map(|event| { + event + .get("id") + .and_then(Value::as_str) + .map(ToOwned::to_owned) + }) + .collect(); + assert!(!ids.contains(&fixture.private_announcement.id.to_hex())); + assert!(!ids.contains(&fixture.private_state.id.to_hex())); + assert!(ids.contains(&fixture.public_announcement.id.to_hex())); + + let Json(count) = count_events_authed( + &fixture.state, + &fixture.tenant, + &headers, + &filters, + fixture.outsider.public_key(), + [0u8; 32], + ) + .await + .expect("outsider repository count"); + assert_eq!(count["count"], 1); + } + + /// Repository discovery COUNT must reject an over-budget candidate set + /// instead of reporting a value truncated at the database fetch limit. + #[tokio::test] + #[ignore = "requires Postgres"] + async fn bridge_repository_discovery_count_rejects_more_than_twenty_thousand_candidates() { + let Some(fixture) = + crate::api::git::access::tests::repository_access_fixture("redis://127.0.0.1:6379") + .await + else { + return; + }; + let marker = format!( + "repository-count-overflow-{}", + uuid::Uuid::new_v4().simple() + ); + let tags = serde_json::to_value(&fixture.public_announcement.tags) + .expect("serialize repository tags"); + let sig = fixture.public_announcement.sig.serialize(); + let candidate_count = crate::handlers::req::REPOSITORY_DISCOVERY_COUNT_LIMIT + 1; + + sqlx::query( + r#" + INSERT INTO events + (community_id, id, pubkey, created_at, kind, tags, content, sig, d_tag) + SELECT + $1, + digest($2::bytea || int8send(n), 'sha256'), + $3, + clock_timestamp(), + $4, + $5, + $6, + $7, + concat($6, '-', n) + FROM generate_series(1::bigint, $8::bigint) AS generated(n) + "#, + ) + .bind(fixture.tenant.community().as_uuid()) + .bind(marker.as_bytes()) + .bind(fixture.owner.public_key().as_bytes().as_slice()) + .bind(buzz_core::kind::KIND_GIT_REPO_ANNOUNCEMENT as i32) + .bind(tags) + .bind(&marker) + .bind(sig.as_slice()) + .bind(candidate_count) + .execute(&fixture.pool) + .await + .expect("insert over-budget repository discovery candidates"); + + let filters = serde_json::to_vec(&[nostr::Filter::new().kind(Kind::Custom( + buzz_core::kind::KIND_GIT_REPO_ANNOUNCEMENT as u16, + ))]) + .expect("serialize repository count filter"); + let result = count_events_authed( + &fixture.state, + &fixture.tenant, + &HeaderMap::new(), + &filters, + fixture.outsider.public_key(), + [0u8; 32], + ) + .await; + + sqlx::query("DELETE FROM events WHERE community_id = $1 AND content = $2") + .bind(fixture.tenant.community().as_uuid()) + .bind(&marker) + .execute(&fixture.pool) + .await + .expect("remove repository count overflow candidates"); + + let (status, Json(error)) = result.expect_err("over-budget count must be rejected"); + assert_eq!(status, StatusCode::BAD_REQUEST); + assert_eq!( + error["error"], + "repository count requires narrower constraints" + ); + } + /// T2a — pre-parse 400 arm: a POST /events with an invalid JSON body must /// increment buzz_events_rejected_total{transport="http",reason="invalid"}. /// diff --git a/crates/buzz-relay/src/api/git/access.rs b/crates/buzz-relay/src/api/git/access.rs new file mode 100644 index 0000000000..8ffedbb19e --- /dev/null +++ b/crates/buzz-relay/src/api/git/access.rs @@ -0,0 +1,826 @@ +//! Repository visibility metadata and current-request access checks. +//! +//! Read privacy is explicit: only a kind:30617 announcement carrying exactly +//! one `["buzz-visibility", "private"]` tag and one valid `buzz-channel` UUID +//! is private. Existing `buzz-channel` tags without that opt-in continue to +//! affect push policy only and retain legacy public-read behavior. +//! +//! The current kind:30617 announcement is authoritative. Private discovery and +//! Git access allow the repository key, its verified managed-agent owner, or a +//! current member of the bound channel. Membership is read for every request so +//! removals take effect immediately. Malformed metadata and lookup errors fail +//! closed; Smart HTTP denials use the same 404 response as a missing repository. + +use anyhow::Context; +use nostr::Event; +use uuid::Uuid; + +use buzz_core::kind::{event_kind_u32, KIND_GIT_REPO_ANNOUNCEMENT, KIND_GIT_REPO_STATE}; +use buzz_core::TenantContext; +use buzz_db::EventQuery; + +use crate::state::AppState; + +/// Return the channel that gates an explicitly private repository. +/// +/// Private announcements must carry exactly one well-formed channel UUID. +/// Conflicting private metadata is rejected. An absent visibility tag, or a +/// visibility value other than `private`, preserves legacy public-read +/// behavior even when a `buzz-channel` push binding is present. +pub(crate) fn private_repository_channel(event: &Event) -> anyhow::Result> { + let visibility_tags: Vec<&[String]> = event + .tags + .iter() + .map(|tag| tag.as_slice()) + .filter(|tag| tag.first().map(String::as_str) == Some("buzz-visibility")) + .collect(); + if !visibility_tags.is_empty() { + if visibility_tags.len() != 1 || visibility_tags[0].len() != 2 { + anyhow::bail!("repository requires at most one two-part buzz-visibility tag"); + } + if visibility_tags[0][1] != "private" { + anyhow::bail!("unsupported buzz-visibility value"); + } + } + let private_visibility = !visibility_tags.is_empty(); + + if !private_visibility { + return Ok(None); + } + let channel_tags: Vec<&[String]> = event + .tags + .iter() + .map(|tag| tag.as_slice()) + .filter(|tag| tag.first().map(String::as_str) == Some("buzz-channel")) + .collect(); + if channel_tags.len() != 1 || channel_tags[0].len() != 2 { + anyhow::bail!("private repository requires exactly one buzz-channel UUID"); + } + + Uuid::parse_str(&channel_tags[0][1]) + .map(Some) + .map_err(|_| anyhow::anyhow!("private repository buzz-channel must be a valid UUID")) +} + +/// Validate the private binding on a kind:30617 announcement before storage. +/// +/// A private repository may only be bound to a channel its announcing owner +/// currently belongs to. Parsing and membership policy stay in this module so +/// ingest does not grow a second implementation of the access boundary. +pub(crate) async fn validate_private_repository_announcement( + state: &AppState, + tenant: &TenantContext, + event: &Event, +) -> anyhow::Result<()> { + let Some(channel_id) = private_repository_channel(event)? else { + return Ok(()); + }; + + let owner = event.pubkey.to_bytes(); + let role = state + .db + .get_member_role(tenant.community(), channel_id, &owner) + .await + .context("check private repository owner channel membership")?; + if role.is_none() { + anyhow::bail!("private repository owner must be a current member of buzz-channel"); + } + + Ok(()) +} + +/// Extract one exact two-element string tag. +/// +/// Duplicate, missing, or extended forms are rejected so authorization never +/// depends on whichever conflicting value happened to be observed first. +fn exact_tag_value<'a>(event: &'a Event, name: &str) -> anyhow::Result<&'a str> { + let tags: Vec<&[String]> = event + .tags + .iter() + .map(|tag| tag.as_slice()) + .filter(|tag| tag.first().map(String::as_str) == Some(name)) + .collect(); + if tags.len() != 1 || tags[0].len() != 2 { + anyhow::bail!("repository discovery event requires exactly one {name} tag"); + } + Ok(tags[0][1].as_str()) +} + +/// Resolve the kind:30617 repository key represented by a discovery event. +/// +/// A kind:30617 is authored by the repository key directly. Relay-signed +/// kind:30618 events instead identify that repository key in their only `p` +/// tag and reuse its `d` tag. Any malformed state fails closed. +fn discovery_repository_key(event: &Event) -> anyhow::Result, &str)>> { + let repo_id = match event_kind_u32(event) { + KIND_GIT_REPO_ANNOUNCEMENT | KIND_GIT_REPO_STATE => exact_tag_value(event, "d")?, + _ => return Ok(None), + }; + + let owner = if event_kind_u32(event) == KIND_GIT_REPO_ANNOUNCEMENT { + event.pubkey.to_bytes().to_vec() + } else { + let owner_hex = exact_tag_value(event, "p")?; + let owner = hex::decode(owner_hex).context("kind:30618 p tag is not hex")?; + if owner.len() != 32 { + anyhow::bail!("kind:30618 p tag must be a 32-byte public key"); + } + owner + }; + + Ok(Some((owner, repo_id))) +} + +/// Decide whether one kind:30617/30618 event is visible to `requester`. +/// +/// Non-repository events are unchanged. Repository discovery events reuse the +/// same live authorization boundary as Smart HTTP. Errors and malformed +/// repository-state links fail closed, preventing a query/fan-out bypass. +pub(crate) async fn requester_can_discover_repository_event( + state: &AppState, + tenant: &TenantContext, + event: &Event, + requester: &[u8], +) -> bool { + let key = match discovery_repository_key(event) { + Ok(None) => return true, + Ok(Some(key)) => key, + Err(error) => { + tracing::warn!( + event_id = %event.id.to_hex(), + error = %error, + "repository discovery event authorization failed closed" + ); + return false; + } + }; + + match requester_can_access_repository(state, tenant, &key.0, key.1, requester).await { + Ok(allowed) => allowed, + Err(error) => { + tracing::warn!( + event_id = %event.id.to_hex(), + error = %error, + "repository discovery event authorization failed closed" + ); + false + } + } +} + +/// Return whether a filter might match kind:30617 or kind:30618. +/// +/// COUNT must use per-event filtering whenever this is true; the fast SQL count +/// cannot subtract private repositories that the requester cannot discover. +pub(crate) fn filter_can_match_repository_discovery(filter: &nostr::Filter) -> bool { + filter.kinds.as_ref().is_none_or(|kinds| { + kinds.iter().any(|kind| { + matches!( + kind.as_u16() as u32, + KIND_GIT_REPO_ANNOUNCEMENT | KIND_GIT_REPO_STATE + ) + }) + }) +} + +/// Decide whether `requester` may discover or use the current repository. +/// +/// The current kind:30617 event is authoritative. Public repositories retain +/// existing relay-member access. Private repositories allow the repository +/// key, its verified managed-agent owner, or a current member of the bound +/// channel. Membership is queried on every request so removals take effect on +/// the next request. Missing or malformed announcements fail closed. +pub(crate) async fn requester_can_access_repository( + state: &AppState, + tenant: &TenantContext, + owner: &[u8], + repo_id: &str, + requester: &[u8], +) -> anyhow::Result { + let query = EventQuery { + kinds: Some(vec![KIND_GIT_REPO_ANNOUNCEMENT as i32]), + pubkey: Some(owner.to_vec()), + d_tag: Some(repo_id.to_owned()), + global_only: true, + limit: Some(1), + ..EventQuery::for_community(tenant.community()) + }; + let Some(repo_event) = state + .db + .query_events(&query) + .await + .context("query current repository announcement")? + .pop() + else { + return Ok(false); + }; + + let channel_id = match private_repository_channel(&repo_event.event) { + Ok(None) => return Ok(true), + Ok(Some(channel_id)) => channel_id, + Err(_) => return Ok(false), + }; + + if requester == owner { + return Ok(true); + } + if state + .db + .is_agent_owner(tenant.community(), owner, requester) + .await + .context("check managed-agent repository ownership")? + { + return Ok(true); + } + + state + .db + .get_member_role(tenant.community(), channel_id, requester) + .await + .map(|role| role.is_some()) + .context("check private repository channel membership") +} + +#[cfg(test)] +pub(crate) mod tests { + use std::sync::Arc; + + use super::*; + use nostr::{EventBuilder, Keys, Kind, Tag}; + + fn repo_announcement_with_keys(keys: &Keys, repo_id: &str, tags: Vec) -> Event { + let mut all_tags = vec![Tag::parse(["d", repo_id]).expect("d tag")]; + all_tags.extend(tags); + EventBuilder::new(Kind::Custom(KIND_GIT_REPO_ANNOUNCEMENT as u16), "") + .tags(all_tags) + .sign_with_keys(keys) + .expect("sign repo announcement") + } + + fn repo_announcement(tags: Vec) -> Event { + repo_announcement_with_keys(&Keys::generate(), "test-repo", tags) + } + + #[test] + fn buzz_channel_without_private_visibility_remains_public() { + let channel_id = Uuid::new_v4().to_string(); + let event = repo_announcement(vec![ + Tag::parse(["buzz-channel", channel_id.as_str()]).expect("channel tag") + ]); + + assert_eq!( + private_repository_channel(&event).expect("parse visibility"), + None + ); + } + + #[test] + fn private_visibility_requires_exactly_one_valid_channel() { + let missing_channel = repo_announcement(vec![ + Tag::parse(["buzz-visibility", "private"]).expect("visibility tag") + ]); + assert!(private_repository_channel(&missing_channel) + .expect_err("missing channel must fail") + .to_string() + .contains("exactly one buzz-channel UUID")); + + let invalid_channel = repo_announcement(vec![ + Tag::parse(["buzz-visibility", "private"]).expect("visibility tag"), + Tag::parse(["buzz-channel", "not-a-uuid"]).expect("channel tag"), + ]); + assert!(private_repository_channel(&invalid_channel) + .expect_err("invalid channel must fail") + .to_string() + .contains("valid UUID")); + + let channel_a = Uuid::new_v4().to_string(); + let channel_b = Uuid::new_v4().to_string(); + let conflicting_channels = repo_announcement(vec![ + Tag::parse(["buzz-visibility", "private"]).expect("visibility tag"), + Tag::parse(["buzz-channel", channel_a.as_str()]).expect("channel tag"), + Tag::parse(["buzz-channel", channel_b.as_str()]).expect("channel tag"), + ]); + assert!(private_repository_channel(&conflicting_channels) + .expect_err("conflicting channels must fail") + .to_string() + .contains("exactly one buzz-channel UUID")); + } + + #[test] + fn malformed_or_unsupported_visibility_fails_closed() { + let channel = Uuid::new_v4().to_string(); + for tags in [ + vec![Tag::parse(["buzz-visibility", "public"]).expect("visibility")], + vec![ + Tag::parse(["buzz-visibility", "private"]).expect("visibility"), + Tag::parse(["buzz-visibility", "public"]).expect("visibility"), + Tag::parse(["buzz-channel", channel.as_str()]).expect("channel"), + ], + vec![ + Tag::parse(["buzz-visibility", "private", "extra"]).expect("visibility"), + Tag::parse(["buzz-channel", channel.as_str()]).expect("channel"), + ], + ] { + assert!(private_repository_channel(&repo_announcement(tags)).is_err()); + } + } + + #[test] + fn private_visibility_resolves_channel() { + let channel_id = Uuid::new_v4(); + let channel_id_string = channel_id.to_string(); + let event = repo_announcement(vec![ + Tag::parse(["buzz-visibility", "private"]).expect("visibility tag"), + Tag::parse(["buzz-channel", channel_id_string.as_str()]).expect("channel tag"), + ]); + + assert_eq!( + private_repository_channel(&event).expect("parse private binding"), + Some(channel_id) + ); + } + #[test] + fn discovery_key_uses_announcement_author_and_state_p_tag() { + let owner = Keys::generate(); + let relay = Keys::generate(); + let announcement = repo_announcement_with_keys(&owner, "test-repo", Vec::new()); + let (announcement_owner, repo_id) = discovery_repository_key(&announcement) + .expect("announcement key") + .expect("repository discovery event"); + assert_eq!(announcement_owner, owner.public_key().to_bytes()); + assert_eq!(repo_id, "test-repo"); + + let owner_hex = owner.public_key().to_hex(); + let state = EventBuilder::new(Kind::Custom(KIND_GIT_REPO_STATE as u16), "") + .tags([ + Tag::parse(["d", "test-repo"]).expect("d tag"), + Tag::parse(["p", owner_hex.as_str()]).expect("p tag"), + ]) + .sign_with_keys(&relay) + .expect("sign state event"); + let (state_owner, repo_id) = discovery_repository_key(&state) + .expect("state key") + .expect("repository discovery event"); + assert_eq!(state_owner, owner.public_key().to_bytes()); + assert_eq!(repo_id, "test-repo"); + } + + #[test] + fn malformed_state_link_fails_closed() { + let relay = Keys::generate(); + let duplicate_owner_a = Keys::generate().public_key().to_hex(); + let duplicate_owner_b = Keys::generate().public_key().to_hex(); + for tags in [ + vec![Tag::parse(["d", "test-repo"]).expect("d tag")], + vec![ + Tag::parse(["d", "test-repo"]).expect("d tag"), + Tag::parse(["p", "not-a-pubkey"]).expect("p tag"), + ], + vec![ + Tag::parse(["d", "test-repo"]).expect("d tag"), + Tag::parse(["p", duplicate_owner_a.as_str()]).expect("p tag"), + Tag::parse(["p", duplicate_owner_b.as_str()]).expect("p tag"), + ], + ] { + let event = EventBuilder::new(Kind::Custom(KIND_GIT_REPO_STATE as u16), "") + .tags(tags) + .sign_with_keys(&relay) + .expect("sign state event"); + assert!(discovery_repository_key(&event).is_err()); + } + } + + #[test] + fn repository_discovery_filter_detection_covers_wildcard_and_both_kinds() { + assert!(filter_can_match_repository_discovery(&nostr::Filter::new())); + assert!(filter_can_match_repository_discovery( + &nostr::Filter::new().kind(Kind::Custom(KIND_GIT_REPO_ANNOUNCEMENT as u16)) + )); + assert!(filter_can_match_repository_discovery( + &nostr::Filter::new().kind(Kind::Custom(KIND_GIT_REPO_STATE as u16)) + )); + assert!(!filter_can_match_repository_discovery( + &nostr::Filter::new().kind(Kind::TextNote) + )); + } + + #[derive(Clone)] + pub(crate) struct RepositoryAccessFixture { + pub state: Arc, + pub pool: sqlx::PgPool, + pub tenant: TenantContext, + pub owner: Keys, + pub member: Keys, + pub outsider: Keys, + pub private_announcement: Event, + pub private_state: Event, + pub public_announcement: Event, + } + + pub(crate) async fn repository_access_fixture( + redis_url: &str, + ) -> Option { + use buzz_core::channel::MemberRole; + use buzz_db::channel::{ChannelType, ChannelVisibility}; + + let database_url = std::env::var("BUZZ_TEST_DATABASE_URL") + .or_else(|_| std::env::var("DATABASE_URL")) + .unwrap_or_else(|_| "postgres://buzz:buzz_dev@localhost:5432/buzz".to_owned()); // sadscan:disable np.postgres.1 + let pool = sqlx::PgPool::connect(&database_url).await.ok()?; + let db = buzz_db::Db::from_pool(pool.clone()); + db.migrate().await.ok()?; + let host = format!("git-discovery-test-{}.example", Uuid::new_v4().simple()); + let record = db.ensure_configured_community(&host).await.ok()?; + let tenant = TenantContext::resolved(record.id, host); + + let mut config = crate::config::Config::from_env().ok()?; + config.database_url = database_url; + config.redis_url = redis_url.to_owned(); + config.require_relay_membership = false; + let redis_pool = deadpool_redis::Config::from_url(redis_url) + .create_pool(Some(deadpool_redis::Runtime::Tokio1)) + .ok()?; + let pubsub = Arc::new( + buzz_pubsub::PubSubManager::new(redis_url, redis_pool.clone()) + .await + .ok()?, + ); + 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).ok()?; + let relay_keys = Keys::generate(); + let (state, _audit_shutdown) = AppState::new( + config, + db, + redis_pool, + audit, + pubsub, + auth, + search, + workflow_engine, + relay_keys.clone(), + media_storage, + ); + let state = Arc::new(state); + + let owner = Keys::generate(); + let member = Keys::generate(); + let outsider = Keys::generate(); + let channel = state + .db + .create_channel( + tenant.community(), + "repository-discovery-test", + ChannelType::Stream, + ChannelVisibility::Private, + None, + owner.public_key().as_bytes(), + None, + ) + .await + .ok()?; + state + .db + .add_member( + tenant.community(), + channel.id, + member.public_key().as_bytes(), + MemberRole::Member, + Some(owner.public_key().as_bytes()), + ) + .await + .ok()?; + + let private_announcement = repo_announcement_with_keys( + &owner, + "private-repo", + vec![ + Tag::parse(["buzz-visibility", "private"]).ok()?, + Tag::parse(["buzz-channel", channel.id.to_string().as_str()]).ok()?, + ], + ); + state + .db + .insert_event(tenant.community(), &private_announcement, None) + .await + .ok()?; + let owner_hex = owner.public_key().to_hex(); + let private_state = EventBuilder::new(Kind::Custom(KIND_GIT_REPO_STATE as u16), "") + .tags([ + Tag::parse(["d", "private-repo"]).ok()?, + Tag::parse(["p", owner_hex.as_str()]).ok()?, + ]) + .sign_with_keys(&relay_keys) + .ok()?; + state + .db + .insert_event(tenant.community(), &private_state, None) + .await + .ok()?; + + let public_announcement = repo_announcement_with_keys( + &owner, + "public-repo", + vec![Tag::parse(["buzz-channel", channel.id.to_string().as_str()]).ok()?], + ); + state + .db + .insert_event(tenant.community(), &public_announcement, None) + .await + .ok()?; + + Some(RepositoryAccessFixture { + state, + pool, + tenant, + owner, + member, + outsider, + private_announcement, + private_state, + public_announcement, + }) + } + + #[tokio::test] + #[ignore = "requires Postgres and Redis"] + async fn private_announcement_requires_current_owner_membership() { + let Some(fixture) = repository_access_fixture("redis://127.0.0.1:6379").await else { + return; + }; + + validate_private_repository_announcement( + &fixture.state, + &fixture.tenant, + &fixture.private_announcement, + ) + .await + .expect("channel owner may publish private announcement"); + + let channel_id = private_repository_channel(&fixture.private_announcement) + .expect("parse private binding") + .expect("private channel"); + let outsider_announcement = repo_announcement_with_keys( + &fixture.outsider, + "outsider-private-repo", + vec![ + Tag::parse(["buzz-visibility", "private"]).expect("visibility"), + Tag::parse(["buzz-channel", channel_id.to_string().as_str()]).expect("channel"), + ], + ); + let error = validate_private_repository_announcement( + &fixture.state, + &fixture.tenant, + &outsider_announcement, + ) + .await + .expect_err("non-member owner must be rejected"); + assert!(error + .to_string() + .contains("private repository owner must be a current member of buzz-channel")); + } + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn repository_access_tracks_membership_and_legacy_visibility() { + use buzz_core::channel::MemberRole; + use buzz_core::CommunityId; + use buzz_db::channel::{ChannelType, ChannelVisibility}; + + let database_url = std::env::var("BUZZ_TEST_DATABASE_URL") + .or_else(|_| std::env::var("DATABASE_URL")) + .unwrap_or_else(|_| "postgres://buzz:buzz_dev@localhost:5432/buzz".to_owned()); // sadscan:disable np.postgres.1 + let pool = sqlx::PgPool::connect(&database_url) + .await + .expect("connect to test Postgres"); + let db = buzz_db::Db::from_pool(pool.clone()); + db.migrate().await.expect("migrate test Postgres"); + let mut config = crate::config::Config::from_env().expect("default config"); + config.database_url = database_url; + config.redis_url = "redis://127.0.0.1:1".to_owned(); + config.require_relay_membership = false; + 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, + Keys::generate(), + media_storage, + ); + let state = Arc::new(state); + + let community_uuid = Uuid::new_v4(); + let community = CommunityId::from_uuid(community_uuid); + let host = format!("git-access-test-{}.example", community_uuid.simple()); + sqlx::query("INSERT INTO communities (id, host) VALUES ($1, $2)") + .bind(community_uuid) + .bind(&host) + .execute(&pool) + .await + .expect("insert community"); + let tenant = TenantContext::resolved(community, host); + + let owner = Keys::generate(); + let member = Keys::generate(); + let outsider = Keys::generate(); + let managed_agent = Keys::generate(); + let managed_agent_owner = Keys::generate(); + state + .db + .ensure_user(community, managed_agent.public_key().as_bytes()) + .await + .expect("ensure managed agent"); + state + .db + .ensure_user(community, managed_agent_owner.public_key().as_bytes()) + .await + .expect("ensure managed-agent owner"); + assert!(state + .db + .set_agent_owner( + community, + managed_agent.public_key().as_bytes(), + managed_agent_owner.public_key().as_bytes(), + ) + .await + .expect("set managed-agent owner")); + let channel = state + .db + .create_channel( + community, + "private-repo-test", + ChannelType::Stream, + ChannelVisibility::Private, + None, + owner.public_key().as_bytes(), + None, + ) + .await + .expect("create channel"); + state + .db + .add_member( + community, + channel.id, + member.public_key().as_bytes(), + MemberRole::Member, + Some(owner.public_key().as_bytes()), + ) + .await + .expect("add member"); + + let private_event = repo_announcement_with_keys( + &owner, + "private-repo", + vec![ + Tag::parse(["buzz-visibility", "private"]).expect("visibility"), + Tag::parse(["buzz-channel", channel.id.to_string().as_str()]).expect("channel"), + ], + ); + state + .db + .insert_event(community, &private_event, None) + .await + .expect("insert private announcement"); + + for requester in [owner.public_key(), member.public_key()] { + assert!(requester_can_access_repository( + &state, + &tenant, + owner.public_key().as_bytes(), + "private-repo", + requester.as_bytes(), + ) + .await + .expect("check allowed requester")); + } + assert!(!requester_can_access_repository( + &state, + &tenant, + owner.public_key().as_bytes(), + "private-repo", + outsider.public_key().as_bytes(), + ) + .await + .expect("check outsider")); + + let managed_private_event = repo_announcement_with_keys( + &managed_agent, + "managed-private-repo", + vec![ + Tag::parse(["buzz-visibility", "private"]).expect("visibility"), + Tag::parse(["buzz-channel", channel.id.to_string().as_str()]).expect("channel"), + ], + ); + state + .db + .insert_event(community, &managed_private_event, None) + .await + .expect("insert managed private announcement"); + assert!(requester_can_access_repository( + &state, + &tenant, + managed_agent.public_key().as_bytes(), + "managed-private-repo", + managed_agent_owner.public_key().as_bytes(), + ) + .await + .expect("managed-agent owner access")); + + let private_owner_hex = owner.public_key().to_hex(); + let private_state = EventBuilder::new(Kind::Custom(KIND_GIT_REPO_STATE as u16), "") + .tags([ + Tag::parse(["d", "private-repo"]).expect("d tag"), + Tag::parse(["p", private_owner_hex.as_str()]).expect("p tag"), + ]) + .sign_with_keys(&state.relay_keypair) + .expect("sign private state event"); + assert!( + requester_can_discover_repository_event( + &state, + &tenant, + &private_state, + member.public_key().as_bytes(), + ) + .await + ); + assert!( + !requester_can_discover_repository_event( + &state, + &tenant, + &private_state, + outsider.public_key().as_bytes(), + ) + .await + ); + + state + .db + .remove_member( + community, + channel.id, + member.public_key().as_bytes(), + owner.public_key().as_bytes(), + ) + .await + .expect("remove member"); + assert!(!requester_can_access_repository( + &state, + &tenant, + owner.public_key().as_bytes(), + "private-repo", + member.public_key().as_bytes(), + ) + .await + .expect("check revoked member")); + + let legacy_event = repo_announcement_with_keys( + &owner, + "legacy-repo", + vec![Tag::parse(["buzz-channel", channel.id.to_string().as_str()]).expect("channel")], + ); + state + .db + .insert_event(community, &legacy_event, None) + .await + .expect("insert legacy announcement"); + assert!(requester_can_access_repository( + &state, + &tenant, + owner.public_key().as_bytes(), + "legacy-repo", + outsider.public_key().as_bytes(), + ) + .await + .expect("check legacy public access")); + + // The test community uses a random host/UUID and remains isolated in + // the disposable integration-test database. Deleting it directly would + // violate the channel foreign key and production has no cascade here. + } +} diff --git a/crates/buzz-relay/src/api/git/mod.rs b/crates/buzz-relay/src/api/git/mod.rs index ab0510fbeb..2bcccf51e3 100644 --- a/crates/buzz-relay/src/api/git/mod.rs +++ b/crates/buzz-relay/src/api/git/mod.rs @@ -22,6 +22,7 @@ use tower_http::limit::RequestBodyLimitLayer; use crate::state::AppState; +pub(crate) mod access; pub mod cas_publish; pub mod hook; pub mod hydrate; diff --git a/crates/buzz-relay/src/api/git/transport.rs b/crates/buzz-relay/src/api/git/transport.rs index fcd86f7bd3..eee8a5350e 100644 --- a/crates/buzz-relay/src/api/git/transport.rs +++ b/crates/buzz-relay/src/api/git/transport.rs @@ -28,6 +28,7 @@ use tokio::process::Command; use tower_http::limit::RequestBodyLimitLayer; use tracing::{error, info, warn}; +use super::access::requester_can_access_repository; use super::cas_publish::{cas_publish, CasError, ParentState, PublishLimits}; use super::hook::install_hook; use super::hydrate::{ @@ -284,6 +285,45 @@ fn validate_repo_id<'a>(owner: &str, repo: &'a str) -> Result<&'a str, Response> Ok(repo_name) } +fn repository_not_found() -> Response { + (StatusCode::NOT_FOUND, "repository not found").into_response() +} + +/// Gate every Smart HTTP surface before manifest hydration or subprocess work. +/// +/// Denials intentionally use the same response as an absent repository so a +/// caller cannot distinguish private existence from a bad owner/repo URL. +#[allow(clippy::result_large_err)] +async fn authorize_repository_request( + state: &Arc, + auth: &GitAuth, + owner_hex: &str, + repo_id: &str, +) -> Result<(), Response> { + let owner = hex::decode(owner_hex).map_err(|_| repository_not_found())?; + let allowed = requester_can_access_repository( + state, + &auth.tenant, + &owner, + repo_id, + auth.pubkey.as_bytes(), + ) + .await + .map_err(|error| { + error!( + owner = %owner_hex, + repo = %repo_id, + error = %error, + "git repository authorization failed closed" + ); + repository_not_found() + })?; + if !allowed { + return Err(repository_not_found()); + } + Ok(()) +} + /// Apply hardened environment to a git subprocess command. /// /// Clears all inherited env vars, then sets only the minimum required: @@ -540,7 +580,8 @@ pub async fn info_refs( "git-upload-pack" | "git-receive-pack" => &query.service, _ => return Err((StatusCode::BAD_REQUEST, "invalid service").into_response()), }; - let _repo_name = validate_repo_id(¶ms.owner, ¶ms.repo)?; + let repo_name = validate_repo_id(¶ms.owner, ¶ms.repo)?; + authorize_repository_request(&state, &auth, ¶ms.owner, repo_name).await?; // Track C fast path: only for clone advertisement. The receive-pack // advertisement carries a different capability set (report-status, @@ -726,7 +767,8 @@ pub async fn upload_pack( AxumPath(params): AxumPath, body: Body, ) -> Result { - let _ = validate_repo_id(¶ms.owner, ¶ms.repo)?; + let repo_name = validate_repo_id(¶ms.owner, ¶ms.repo)?; + authorize_repository_request(&state, &auth, ¶ms.owner, repo_name).await?; let permit = acquire_git_permit(&state, "upload_pack")?; let repo = match hydrate_for_read( @@ -797,6 +839,7 @@ pub async fn receive_pack( body: Body, ) -> Result { let repo_name = validate_repo_id(¶ms.owner, ¶ms.repo)?; + authorize_repository_request(&state, &auth, ¶ms.owner, repo_name).await?; let pusher_hex = hex::encode(auth.pubkey.to_bytes()); let _permit = acquire_git_permit(&state, "receive_pack")?; @@ -2093,4 +2136,255 @@ mod track_c_tests { let caps = String::from_utf8_lossy(&body); assert!(caps.contains("object-format=sha256")); } + + async fn private_repo_endpoint_test_state( + host: &str, + ) -> Option<(Arc, TenantContext, Keys, Keys)> { + use buzz_db::channel::{ChannelType, ChannelVisibility}; + + let mut config = crate::config::Config::from_env().ok()?; + let database_url = std::env::var("BUZZ_TEST_DATABASE_URL") + .or_else(|_| std::env::var("DATABASE_URL")) + .unwrap_or_else(|_| "postgres://buzz:buzz_dev@localhost:5432/buzz".to_owned()); // sadscan:disable np.postgres.1 + config.database_url = database_url.clone(); + config.redis_url = "redis://127.0.0.1:1".to_owned(); + config.relay_url = format!("wss://{host}"); + config.require_relay_membership = false; + let scratch = + std::env::temp_dir().join(format!("buzz-git-access-{}", uuid::Uuid::new_v4())); + std::fs::create_dir_all(&scratch).ok()?; + config.git_repo_path = scratch.clone(); + config.git_pack_cache_path = scratch.join("pack-cache"); + + let pool = sqlx::PgPool::connect(&database_url).await.ok()?; + let db = buzz_db::Db::from_pool(pool.clone()); + db.migrate().await.ok()?; + db.ensure_configured_community(host).await.ok()?; + let community = db.lookup_community_by_host(host).await.ok()??.id; + let tenant = TenantContext::resolved(community, host); + + let redis_pool = deadpool_redis::Config::from_url(&config.redis_url) + .create_pool(Some(deadpool_redis::Runtime::Tokio1)) + .ok()?; + let pubsub = Arc::new( + buzz_pubsub::PubSubManager::new(&config.redis_url, redis_pool.clone()) + .await + .ok()?, + ); + 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).ok()?; + let (state, _audit_shutdown) = AppState::new( + config, + db, + redis_pool, + audit, + pubsub, + auth, + search, + workflow_engine, + Keys::generate(), + media_storage, + ); + let state = Arc::new(state); + + let owner = Keys::generate(); + let outsider = Keys::generate(); + let channel = state + .db + .create_channel( + community, + "private-repo-endpoint-test", + ChannelType::Stream, + ChannelVisibility::Private, + None, + owner.public_key().as_bytes(), + None, + ) + .await + .ok()?; + state + .db + .add_member( + community, + channel.id, + outsider.public_key().as_bytes(), + buzz_core::channel::MemberRole::Member, + Some(owner.public_key().as_bytes()), + ) + .await + .ok()?; + let channel_id = channel.id.to_string(); + let event = EventBuilder::new(Kind::Custom(30617), "") + .tags([ + Tag::parse(["d", "private-repo"]).ok()?, + Tag::parse(["buzz-visibility", "private"]).ok()?, + Tag::parse(["buzz-channel", channel_id.as_str()]).ok()?, + ]) + .sign_with_keys(&owner) + .ok()?; + state.db.insert_event(community, &event, None).await.ok()?; + + // Seed the same empty manifest pointer that announce side effects create + // in production so allowed advertisement/read paths can run end to end. + let manifest = super::super::manifest::Manifest { + version: super::super::manifest::MANIFEST_VERSION, + head: "refs/heads/main".to_owned(), + refs: std::collections::BTreeMap::new(), + packs: Vec::new(), + parent: None, + }; + let manifest_key = state + .git_store + .put_manifest(&manifest.canonical_bytes().ok()?) + .await + .ok()?; + let digest = manifest_key.strip_prefix("manifests/")?; + let pointer = super::super::manifest::pointer_key( + community, + &owner.public_key().to_hex(), + "private-repo", + ); + match state + .git_store + .put_pointer( + &pointer, + digest.as_bytes(), + super::super::store::Precond::IfNoneMatchStar, + ) + .await + .ok()? + { + super::super::store::CasOutcome::Won(_) => {} + super::super::store::CasOutcome::LostRace => return None, + } + + Some((state, tenant, owner, outsider)) + } + + fn test_git_auth(keys: &Keys, tenant: &TenantContext) -> GitAuth { + GitAuth { + pubkey: keys.public_key(), + tenant: tenant.clone(), + } + } + + fn private_repo_params(owner: &Keys) -> GitRepoParams { + GitRepoParams { + owner: owner.public_key().to_hex(), + // Exercise the exact clone URL shape while authorizing against the + // canonical d-tag value without `.git`. + repo: "private-repo.git".to_owned(), + } + } + + async fn assert_repository_not_found(result: Result) { + let response = match result { + Ok(_) => panic!("private repository endpoint unexpectedly allowed outsider"), + Err(response) => response, + }; + assert_eq!(response.status(), StatusCode::NOT_FOUND); + let body = axum::body::to_bytes(response.into_body(), 1024) + .await + .expect("read denial body"); + assert_eq!(body.as_ref(), b"repository not found"); + } + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn private_repo_outsider_is_hidden_on_every_smart_http_endpoint() { + let host = format!( + "git-private-endpoints-{}.example", + uuid::Uuid::new_v4().simple() + ); + let Some((state, tenant, owner, outsider)) = private_repo_endpoint_test_state(&host).await + else { + return; + }; + let community = tenant.community(); + let owner_advertisement = info_refs( + State(state.clone()), + test_git_auth(&owner, &tenant), + AxumPath(private_repo_params(&owner)), + Query(InfoRefsQuery { + service: "git-upload-pack".to_owned(), + }), + ) + .await + .expect("owner can advertise private repository"); + assert_eq!(owner_advertisement.status(), StatusCode::OK); + + let outsider_advertisement = info_refs( + State(state.clone()), + test_git_auth(&outsider, &tenant), + AxumPath(private_repo_params(&owner)), + Query(InfoRefsQuery { + service: "git-upload-pack".to_owned(), + }), + ) + .await + .expect("current channel member can advertise private repository"); + assert_eq!(outsider_advertisement.status(), StatusCode::OK); + + let channel_id = state + .db + .list_channels(community, None) + .await + .expect("list channels") + .into_iter() + .find(|channel| channel.name == "private-repo-endpoint-test") + .expect("private repository channel") + .id; + state + .db + .remove_member( + community, + channel_id, + outsider.public_key().as_bytes(), + owner.public_key().as_bytes(), + ) + .await + .expect("revoke outsider before requests"); + + for service in ["git-upload-pack", "git-receive-pack"] { + assert_repository_not_found( + info_refs( + State(state.clone()), + test_git_auth(&outsider, &tenant), + AxumPath(private_repo_params(&owner)), + Query(InfoRefsQuery { + service: service.to_owned(), + }), + ) + .await, + ) + .await; + } + + assert_repository_not_found( + upload_pack( + State(state.clone()), + test_git_auth(&outsider, &tenant), + AxumPath(private_repo_params(&owner)), + Body::empty(), + ) + .await, + ) + .await; + assert_repository_not_found( + receive_pack( + State(state), + test_git_auth(&outsider, &tenant), + AxumPath(private_repo_params(&owner)), + Body::empty(), + ) + .await, + ) + .await; + } } diff --git a/crates/buzz-relay/src/handlers/count.rs b/crates/buzz-relay/src/handlers/count.rs index 4689826f23..9d60a5fafa 100644 --- a/crates/buzz-relay/src/handlers/count.rs +++ b/crates/buzz-relay/src/handlers/count.rs @@ -110,6 +110,8 @@ pub async fn handle_count( // reader's own pubkey. let needs_result_gated_filtering = filter_can_match_result_gated_kinds(filter) && !result_gated_count_safe_for_pushdown(filter, &authed_pubkey_hex); + let needs_repository_filtering = + crate::api::git::access::filter_can_match_repository_discovery(filter); if let Some(ch_id) = extract_channel_from_filter(filter) { // Filter targets a specific channel — verify access. Mirrors the WS @@ -160,6 +162,7 @@ pub async fn handle_count( if super::req::filter_fully_pushable(filter) && (!needs_author_only_filtering || author_is_self) && !needs_result_gated_filtering + && !needs_repository_filtering { match state.db.count_events(&query).await { Ok(n) => total += n as u64, @@ -169,12 +172,28 @@ pub async fn handle_count( } } } else { - // Fallback: query + post-filter for non-pushable constraints. + // Repository discovery COUNT must inspect the complete matching + // set before applying its result-level access predicate. let mut q = query; - super::req::apply_count_fallback_limit(&mut q); + if needs_repository_filtering { + super::req::apply_repository_discovery_count_limit(&mut q); + } else { + super::req::apply_count_fallback_limit(&mut q); + } match state.db.query_events(&q).await { Ok(stored_events) => { - if super::req::count_fallback_exceeded(stored_events.len()) { + if needs_repository_filtering + && super::req::repository_discovery_count_exceeded(stored_events.len()) + { + conn.send(RelayMessage::closed( + &sub_id, + "restricted: repository count requires narrower constraints", + )); + return; + } + if !needs_repository_filtering + && super::req::count_fallback_exceeded(stored_events.len()) + { metrics::counter!("buzz_count_fallback_rejections_total").increment(1); conn.send(RelayMessage::closed( &sub_id, @@ -196,6 +215,16 @@ pub async fn handle_count( ) { continue; } + if !crate::api::git::access::requester_can_discover_repository_event( + &state, + &conn.tenant, + &se.event, + &pubkey_bytes, + ) + .await + { + continue; + } total += 1; } } @@ -230,6 +259,7 @@ pub async fn handle_count( if super::req::filter_fully_pushable(filter) && (!needs_author_only_filtering || author_is_self) && !needs_result_gated_filtering + && !needs_repository_filtering { query.limit = None; // COUNT doesn't need a row limit match state.db.count_events(&query).await { @@ -240,11 +270,27 @@ pub async fn handle_count( } } } else { - // Fallback: query a bounded candidate set + post-filter. - super::req::apply_count_fallback_limit(&mut query); + // Repository discovery COUNT must inspect the complete matching + // set before applying its result-level access predicate. + if needs_repository_filtering { + super::req::apply_repository_discovery_count_limit(&mut query); + } else { + super::req::apply_count_fallback_limit(&mut query); + } match state.db.query_events(&query).await { Ok(stored_events) => { - if super::req::count_fallback_exceeded(stored_events.len()) { + if needs_repository_filtering + && super::req::repository_discovery_count_exceeded(stored_events.len()) + { + conn.send(RelayMessage::closed( + &sub_id, + "restricted: repository count requires narrower constraints", + )); + return; + } + if !needs_repository_filtering + && super::req::count_fallback_exceeded(stored_events.len()) + { metrics::counter!("buzz_count_fallback_rejections_total").increment(1); conn.send(RelayMessage::closed( &sub_id, @@ -266,6 +312,16 @@ pub async fn handle_count( ) { continue; } + if !crate::api::git::access::requester_can_discover_repository_event( + &state, + &conn.tenant, + &se.event, + &pubkey_bytes, + ) + .await + { + continue; + } total += 1; } } diff --git a/crates/buzz-relay/src/handlers/event.rs b/crates/buzz-relay/src/handlers/event.rs index ecb3e41fcc..ce85503129 100644 --- a/crates/buzz-relay/src/handlers/event.rs +++ b/crates/buzz-relay/src/handlers/event.rs @@ -151,6 +151,46 @@ pub async fn filter_fanout_by_access( matches }; + // Repository discovery events are globally stored but carry their own + // live channel-membership boundary. Resolve each recipient independently + // before the ordinary channel visibility gate below. + let kind_u32 = event_kind_u32(&stored_event.event); + let matches = if matches!( + kind_u32, + buzz_core::kind::KIND_GIT_REPO_ANNOUNCEMENT | buzz_core::kind::KIND_GIT_REPO_STATE + ) { + let tenant = match state.db.lookup_community_host(community_id).await { + Ok(Some(host)) => TenantContext::resolved(community_id, host), + Ok(None) => { + warn!(%community_id, "repository fan-out access filter: community not found"); + return Vec::new(); + } + Err(error) => { + warn!(%community_id, %error, "repository fan-out access filter: community lookup failed"); + return Vec::new(); + } + }; + let mut allowed = Vec::with_capacity(matches.len()); + for (conn_id, sub_id) in matches { + let Some(pubkey) = state.conn_manager.pubkey_for_conn(conn_id) else { + continue; + }; + if crate::api::git::access::requester_can_discover_repository_event( + state, + &tenant, + &stored_event.event, + &pubkey, + ) + .await + { + allowed.push((conn_id, sub_id)); + } + } + allowed + } else { + matches + }; + let Some(channel_id) = stored_event.channel_id else { return matches; }; @@ -1957,6 +1997,67 @@ mod tests { } } + #[tokio::test] + #[ignore = "requires Postgres"] + async fn live_repository_fanout_hides_private_announcement_and_state() { + let Some(fixture) = + crate::api::git::access::tests::repository_access_fixture("redis://127.0.0.1:1").await + else { + return; + }; + let state = fixture.state; + let community = fixture.tenant.community(); + let make_match = |keys: &Keys| { + use std::sync::atomic::AtomicU8; + use tokio::sync::{mpsc, Mutex}; + use tokio_util::sync::CancellationToken; + + let conn_id = uuid::Uuid::new_v4(); + let (tx, _rx) = mpsc::channel(4); + let (ctrl_tx, _ctrl_rx) = mpsc::channel(4); + state.conn_manager.register( + conn_id, + tx, + ctrl_tx, + CancellationToken::new(), + community, + Arc::new(AtomicU8::new(0)), + Arc::new(Mutex::new(HashMap::new())), + 3, + ); + state + .conn_manager + .set_authenticated_pubkey(conn_id, keys.public_key().to_bytes().to_vec()); + (conn_id, format!("sub-{conn_id}")) + }; + let member_match = make_match(&fixture.member); + let outsider_match = make_match(&fixture.outsider); + + for event in [fixture.private_announcement, fixture.private_state] { + let stored = buzz_core::StoredEvent::new(event, None); + let matches = super::filter_fanout_by_access( + &state, + community, + &stored, + vec![member_match.clone(), outsider_match.clone()], + None, + ) + .await; + assert_eq!(matches, vec![member_match.clone()]); + } + + let public = buzz_core::StoredEvent::new(fixture.public_announcement, None); + let matches = super::filter_fanout_by_access( + &state, + community, + &public, + vec![member_match.clone(), outsider_match.clone()], + None, + ) + .await; + assert_eq!(matches, vec![member_match, outsider_match]); + } + mod fanout_access { use std::collections::HashMap; use std::sync::atomic::AtomicU8; diff --git a/crates/buzz-relay/src/handlers/ingest.rs b/crates/buzz-relay/src/handlers/ingest.rs index f53ea0f018..10a915dd1a 100644 --- a/crates/buzz-relay/src/handlers/ingest.rs +++ b/crates/buzz-relay/src/handlers/ingest.rs @@ -1902,6 +1902,12 @@ async fn ingest_event_inner( }); } + if kind_u32 == KIND_GIT_REPO_ANNOUNCEMENT { + crate::handlers::side_effects::validate_git_repo_announcement(tenant, &event, state) + .await + .map_err(|e| IngestError::Rejected(format!("invalid: {e}")))?; + } + if crate::handlers::side_effects::is_admin_kind(kind_u32) { crate::handlers::side_effects::validate_admin_event(tenant, kind_u32, &event, state) .await diff --git a/crates/buzz-relay/src/handlers/req.rs b/crates/buzz-relay/src/handlers/req.rs index ecc3ba6777..1905545dd0 100644 --- a/crates/buzz-relay/src/handlers/req.rs +++ b/crates/buzz-relay/src/handlers/req.rs @@ -267,7 +267,7 @@ pub async fn handle_req( let viewer_hex = hex::encode(&pubkey_bytes); // Phase 1 — pure query construction, in filter order. - let filter_queries: Vec<(usize, Option, EventQuery)> = filters + let filter_queries: Vec<(usize, Option, EventQuery, usize)> = filters .iter() .enumerate() .map(|(idx, filter)| { @@ -291,8 +291,21 @@ pub async fn handle_req( }; let mut params = filter_to_query_params(filter, per_filter_channel, conn.tenant.community()); + // Repository access is result-dependent. Fetch the full matching + // discovery set before applying the requester's live access gate; + // otherwise hidden rows can consume the SQL limit and suppress + // later public/member-visible repositories. + if crate::api::git::access::filter_can_match_repository_discovery(filter) { + params.limit = None; + params.max_limit = Some(MAX_HISTORICAL_LIMIT); + } apply_access_scope_to_query(&mut params, per_filter_channel, &accessible_channels); - (idx, per_filter_channel, params) + ( + idx, + per_filter_channel, + params, + filter.limit.unwrap_or(2_000), + ) }) .collect(); @@ -303,18 +316,18 @@ pub async fn handle_req( use futures_util::stream::{self, StreamExt}; let db = state.db.clone(); let mut results = stream::iter(filter_queries.into_iter().map( - |(idx, per_filter_channel, params)| { + |(idx, per_filter_channel, params, result_limit)| { let db = db.clone(); async move { let filter_events = db.query_events(¶ms).await; - (idx, per_filter_channel, filter_events) + (idx, per_filter_channel, result_limit, filter_events) } }, )) .buffered(FILTER_QUERY_CONCURRENCY); // Phase 3 — post-processing, strictly in filter order. - while let Some((idx, per_filter_channel, filter_events)) = results.next().await { + while let Some((idx, per_filter_channel, result_limit, filter_events)) = results.next().await { let filter = &filters[idx]; let events = match filter_events { Ok(evs) => evs, @@ -361,7 +374,13 @@ pub async fn handle_req( ); } + let mut per_filter_sent = 0usize; for stored in &events { + if crate::api::git::access::filter_can_match_repository_discovery(filter) + && per_filter_sent >= result_limit + { + break; + } // Per-filter NIP-01 matching — use the current filter only, not the // full filter set. OR semantics across filters are handled by the outer // loop (each filter gets its own DB query). @@ -388,6 +407,17 @@ pub async fn handle_req( continue; } + if !crate::api::git::access::requester_can_discover_repository_event( + &state, + &conn.tenant, + &stored.event, + &pubkey_bytes, + ) + .await + { + continue; + } + // Dedup AFTER acceptance — an event that fails filter A's constraints // must remain eligible for filter B (NIP-01 OR semantics). if !seen_ids.insert(stored.event.id) { @@ -399,6 +429,7 @@ pub async fn handle_req( return; } total_sent += 1; + per_filter_sent += 1; if total_sent.is_multiple_of(100) { tokio::task::yield_now().await; } @@ -709,6 +740,16 @@ async fn handle_search_req( if is_author_only_event(&stored.event, reader_pubkey_bytes) { continue; } + if !crate::api::git::access::requester_can_discover_repository_event( + state, + tenant, + &stored.event, + reader_pubkey_bytes, + ) + .await + { + continue; + } // Dedup AFTER acceptance — an event that fails filter A's constraints // must remain eligible for filter B (NIP-01 OR semantics). if !seen_ids.insert(stored.event.id) { @@ -764,6 +805,25 @@ pub(crate) fn count_fallback_exceeded(candidate_count: usize) -> bool { candidate_count > COUNT_FALLBACK_CANDIDATE_LIMIT as usize } +/// Maximum rows an exact repository-discovery COUNT may inspect. +/// +/// Unlike the ordinary fallback, repository COUNT cannot return a truncated +/// value without leaking or lying about visibility. Reject broader filters and +/// require the caller to add author/d-tag constraints. +pub(crate) const REPOSITORY_DISCOVERY_COUNT_LIMIT: i64 = 20_000; + +/// Apply the bounded exact-count budget for repository discovery. +pub(crate) fn apply_repository_discovery_count_limit(query: &mut EventQuery) { + let fetch_limit = REPOSITORY_DISCOVERY_COUNT_LIMIT + 1; + query.limit = Some(fetch_limit); + query.max_limit = Some(fetch_limit); +} + +/// Return whether repository discovery COUNT exceeded its exact-count budget. +pub(crate) fn repository_discovery_count_exceeded(candidate_count: usize) -> bool { + candidate_count > REPOSITORY_DISCOVERY_COUNT_LIMIT as usize +} + /// Returns `true` if all constraints in this filter can be fully represented /// in SQL by `filter_to_query_params` — meaning `count_events()` will produce /// an exact count without post-filtering. @@ -1386,6 +1446,22 @@ mod tests { )); } + #[test] + fn repository_discovery_count_fetches_one_extra_candidate() { + let mut query = + EventQuery::for_community(buzz_core::tenant::CommunityId::from_uuid(uuid::Uuid::nil())); + apply_repository_discovery_count_limit(&mut query); + assert_eq!(query.limit, Some(20_001)); + assert_eq!(query.max_limit, Some(20_001)); + assert_eq!(query.limit, Some(REPOSITORY_DISCOVERY_COUNT_LIMIT + 1)); + assert!(!repository_discovery_count_exceeded( + REPOSITORY_DISCOVERY_COUNT_LIMIT as usize + )); + assert!(repository_discovery_count_exceeded( + REPOSITORY_DISCOVERY_COUNT_LIMIT as usize + 1 + )); + } + #[test] fn nip43_only_filters_skip_channel_access_resolution() { let membership = Filter::new().kind(nostr::Kind::Custom(13_534)); diff --git a/crates/buzz-relay/src/handlers/side_effects.rs b/crates/buzz-relay/src/handlers/side_effects.rs index 36ce2254c9..8c45f7ff70 100644 --- a/crates/buzz-relay/src/handlers/side_effects.rs +++ b/crates/buzz-relay/src/handlers/side_effects.rs @@ -2382,6 +2382,29 @@ fn validate_repo_id(repo_id: &str) -> bool { .all(|c| c.is_ascii_alphanumeric() || c == '.' || c == '_' || c == '-') } +/// Validate a kind:30617 announcement before it is stored. +/// +/// Private repository metadata is an authorization boundary, so malformed +/// bindings and bindings to channels the announcer has not joined are rejected +/// instead of being persisted and left for a best-effort side effect. +pub async fn validate_git_repo_announcement( + tenant: &TenantContext, + event: &Event, + state: &Arc, +) -> anyhow::Result<()> { + let repo_id = + extract_tag_value(event, "d").ok_or_else(|| anyhow::anyhow!("kind:30617 missing d tag"))?; + if !validate_repo_id(&repo_id) { + return Err(anyhow::anyhow!( + "invalid repo identifier: must be [a-zA-Z0-9._-]{{1,64}}, no leading dots, no '..'" + )); + } + + crate::api::git::access::validate_private_repository_announcement(state, tenant, event).await?; + + Ok(()) +} + /// Handle kind:30617 (NIP-34 Git Repository Announcement). /// /// Reserves the repo name and seeds its empty-manifest pointer when a repo @@ -2398,15 +2421,12 @@ async fn handle_git_repo_announcement( event: &Event, state: &Arc, ) -> anyhow::Result<()> { - // Extract repo identifier from d tag (required for NIP-33 parameterized replaceable events). + // Validation runs before storage. This keeps authorization metadata out of + // the side-effect phase, whose failures are deliberately best-effort. let repo_id = extract_tag_value(event, "d").ok_or_else(|| anyhow::anyhow!("kind:30617 missing d tag"))?; - if !validate_repo_id(&repo_id) { - return Err(anyhow::anyhow!( - "invalid repo identifier: must be [a-zA-Z0-9._-]{{1,64}}, no leading dots, no '..'" - )); - } + debug_assert!(validate_repo_id(&repo_id)); let owner_hex = hex::encode(event.pubkey.to_bytes()); diff --git a/desktop/playwright.config.ts b/desktop/playwright.config.ts index e3745338a2..8b205397a1 100644 --- a/desktop/playwright.config.ts +++ b/desktop/playwright.config.ts @@ -95,6 +95,7 @@ export default defineConfig({ "**/send-channel-binding.spec.ts", "**/project-commit-detail.spec.ts", "**/project-pr-review.spec.ts", + "**/project-visibility.spec.ts", "**/persona-model-combobox-screenshots.spec.ts", "**/drafts-screenshots.spec.ts", "**/buzz-theme-screenshots.spec.ts", diff --git a/desktop/src/features/projects/hooks.ts b/desktop/src/features/projects/hooks.ts index a1761f0b11..3a4da13c9d 100644 --- a/desktop/src/features/projects/hooks.ts +++ b/desktop/src/features/projects/hooks.ts @@ -73,6 +73,9 @@ export type Project = { contributors: string[]; createdAt: number; projectChannelId: string | null; + visibility: "public" | "private"; + privateChannelId: string | null; + channelBindingId: string | null; status: string; defaultBranch: string; repoAddress: string; @@ -220,6 +223,13 @@ export function eventToProject( contributors, createdAt: event.created_at, projectChannelId, + visibility: + getTag(event, "buzz-visibility") === "private" ? "private" : "public", + privateChannelId: + getTag(event, "buzz-visibility") === "private" + ? (getTag(event, "buzz-channel") ?? null) + : null, + channelBindingId: getTag(event, "buzz-channel") ?? null, status: getTag(event, "status") ?? "active", defaultBranch: getTag(event, "default-branch") ?? "main", repoAddress: projectCoordinate({ owner: event.pubkey, dtag: d }), diff --git a/desktop/src/features/projects/ui/CreateProjectDialog.tsx b/desktop/src/features/projects/ui/CreateProjectDialog.tsx index c5e6c2670c..909b54c9eb 100644 --- a/desktop/src/features/projects/ui/CreateProjectDialog.tsx +++ b/desktop/src/features/projects/ui/CreateProjectDialog.tsx @@ -1,6 +1,12 @@ import * as React from "react"; +import { useChannelsQuery } from "@/features/channels/hooks"; import type { CreateProjectInput } from "@/features/projects/useCreateProject"; +import { + eligibleProjectChannels, + ProjectVisibilityFields, + type ProjectVisibility, +} from "@/features/projects/ui/ProjectVisibilityFields"; import { cn } from "@/shared/lib/cn"; import { Button } from "@/shared/ui/button"; import { ChooserDialogContent } from "@/shared/ui/chooser-dialog-content"; @@ -33,6 +39,11 @@ export function CreateProjectDialog({ const [description, setDescription] = React.useState(""); const [cloneUrl, setCloneUrl] = React.useState(""); const [webUrl, setWebUrl] = React.useState(""); + const [visibility, setVisibility] = + React.useState("public"); + const [channelId, setChannelId] = React.useState(""); + const channelsQuery = useChannelsQuery({ enabled: open }); + const channels = eligibleProjectChannels(channelsQuery.data); const [errorMessage, setErrorMessage] = React.useState(null); const nameInputRef = React.useRef(null); @@ -43,6 +54,8 @@ export function CreateProjectDialog({ setDescription(""); setCloneUrl(""); setWebUrl(""); + setVisibility("public"); + setChannelId(""); setErrorMessage(null); // Small delay to let the dialog animation start before focusing. @@ -66,6 +79,9 @@ export function CreateProjectDialog({ description: description.trim() || undefined, cloneUrl: cloneUrl.trim() || undefined, webUrl: webUrl.trim() || undefined, + visibility, + channelId: + visibility === "private" ? channelId || undefined : undefined, }); onOpenChange(false); @@ -93,7 +109,11 @@ export function CreateProjectDialog({
+ { + setChannelId(nextChannelId); + setErrorMessage(null); + }} + onRetryChannels={() => { + void channelsQuery.refetch(); + }} + onVisibilityChange={(nextVisibility) => { + setVisibility(nextVisibility); + if (nextVisibility === "public") setChannelId(""); + setErrorMessage(null); + }} + visibility={visibility} + /> + {errorMessage ? ( -

{errorMessage}

+

+ {errorMessage} +

) : null} diff --git a/desktop/src/features/projects/ui/ProjectCards.tsx b/desktop/src/features/projects/ui/ProjectCards.tsx index 52308f7a05..dfeec961f3 100644 --- a/desktop/src/features/projects/ui/ProjectCards.tsx +++ b/desktop/src/features/projects/ui/ProjectCards.tsx @@ -3,6 +3,7 @@ import { FolderGit2, GitCommit, GitPullRequest, + LockKeyhole, TerminalSquare, Trash2, } from "lucide-react"; @@ -434,6 +435,13 @@ export function ProjectGridCard({ {project.name} + {project.visibility === "private" ? ( + + ) : null}
@@ -506,6 +514,13 @@ export function ProjectListRow({ {project.name} + {project.visibility === "private" ? ( + + ) : null}

@@ -575,8 +590,15 @@ export function ProjectRailRow({

- - {project.name} + + {project.name} + {project.visibility === "private" ? ( + + ) : null} {/* Cap the bar so its right edge always lands at least one avatar slot (24px + 6px gap) short of the section edge, keeping it diff --git a/desktop/src/features/projects/ui/ProjectDetailScreen.tsx b/desktop/src/features/projects/ui/ProjectDetailScreen.tsx index 587dd0d11b..8f4507792c 100644 --- a/desktop/src/features/projects/ui/ProjectDetailScreen.tsx +++ b/desktop/src/features/projects/ui/ProjectDetailScreen.tsx @@ -70,6 +70,7 @@ import { resolveProjectDefaultBranch, } from "@/features/projects/lib/projectBranches"; import { normalizeRepositoryUrl } from "@/features/projects/lib/projectsViewHelpers"; +import { ProjectVisibilityControl } from "./ProjectVisibilityControl"; import { WorkspaceTabs } from "./ProjectWorkspaceTabs"; import type { RepoSourceHeaderControls } from "./ProjectRepositorySource"; import { @@ -97,7 +98,6 @@ const PROJECT_DETAIL_PANEL_SEARCH_KEYS = [ "profileTab", "profileView", ] as const; - export function ProjectDetailScreen(props: ProjectDetailScreenProps) { const { commitHash, projectId, pullRequestId, issueId } = props; const { goChannel, goProject, goProjects } = useAppNavigation(); @@ -859,54 +859,58 @@ export function ProjectDetailScreen(props: ProjectDetailScreenProps) { )} - {project.projectChannelId ? ( - - ) : null} +
+ + {project.projectChannelId ? ( + + ) : null} +
-
-
-
-
-

- {project.name} -

- {safeWebUrl ? ( - - ) : null} -
+ + + + ) : null}
-
+
channel.id === project.privateChannelId, + ); + const summary = visibilitySummary(project, boundChannel?.name ?? null); + const [menuOpen, setMenuOpen] = React.useState(false); + const [confirmPublicOpen, setConfirmPublicOpen] = React.useState(false); + const [errorMessage, setErrorMessage] = React.useState(null); + const Icon = project.visibility === "private" ? LockKeyhole : Globe2; + + async function updateVisibility( + visibility: ProjectVisibility, + channelId?: string, + ) { + if (!canEdit) return; + + setErrorMessage(null); + try { + await mutation.mutateAsync({ + project, + visibility, + channelId: visibility === "private" ? channelId : undefined, + }); + setConfirmPublicOpen(false); + toast.success( + visibility === "private" + ? "Project access updated." + : "Project is now public to workspace members.", + ); + } catch (error) { + const message = + error instanceof Error + ? error.message + : "Failed to update project visibility."; + setErrorMessage(message); + toast.error(message); + } + } + + if (!canEdit) { + const status = ( + + + ); + + if (project.visibility === "private" && boundChannel) { + return ( + + {status} + + Only members of #{boundChannel.name} and the owner can access this + project. + + + ); + } + + return status; + } + + return ( + { + if (mutation.isPending) return; + setConfirmPublicOpen(nextOpen); + }} + open={confirmPublicOpen} + > + { + if (mutation.isPending) return; + setMenuOpen(nextOpen); + if (nextOpen) setErrorMessage(null); + }} + open={menuOpen} + > + + + + + + Repository access + + { + if (project.visibility === "private") { + event.preventDefault(); + setMenuOpen(false); + setConfirmPublicOpen(true); + } + }} + > + + + + + + + Choose an access channel + + {channelsQuery.isLoading ? ( + + Loading joined channels… + + ) : channelsQuery.isError ? ( + { + event.preventDefault(); + void channelsQuery.refetch(); + }} + > + Couldn’t load channels. Retry + + ) : channels.length === 0 ? ( + + Join a channel to make this project private + + ) : ( + channels.map((channel) => ( + { + if ( + project.visibility === "private" && + project.privateChannelId === channel.id + ) { + return; + } + setMenuOpen(false); + void updateVisibility("private", channel.id); + }} + > + #{channel.name} + {project.visibility === "private" && + project.privateChannelId === channel.id ? ( + + )) + )} + + + + + + + + Make this project public? + + Anyone in the workspace will be able to find and clone it. + + + {errorMessage ? ( +

+ {errorMessage} +

+ ) : null} + + + Cancel + + + + + +
+
+ ); +} diff --git a/desktop/src/features/projects/ui/ProjectVisibilityFields.tsx b/desktop/src/features/projects/ui/ProjectVisibilityFields.tsx new file mode 100644 index 0000000000..87222c733e --- /dev/null +++ b/desktop/src/features/projects/ui/ProjectVisibilityFields.tsx @@ -0,0 +1,193 @@ +import { Check, Globe2, LockKeyhole, RefreshCw } from "lucide-react"; + +import type { Channel } from "@/shared/api/types"; +import { cn } from "@/shared/lib/cn"; +import { Button } from "@/shared/ui/button"; + +export type ProjectVisibility = "public" | "private"; + +type ProjectVisibilityFieldsProps = { + channelId: string; + channels: Channel[]; + channelsError: string | null; + channelsLoading: boolean; + disabled?: boolean; + idPrefix: string; + onChannelChange: (channelId: string) => void; + onRetryChannels: () => void; + onVisibilityChange: (visibility: ProjectVisibility) => void; + visibility: ProjectVisibility; +}; + +export function eligibleProjectChannels(channels: Channel[] | undefined) { + return (channels ?? []).filter( + (channel) => channel.channelType !== "dm" && channel.isMember, + ); +} + +const VISIBILITY_OPTIONS = [ + { + description: "Anyone in the workspace can find and clone", + icon: Globe2, + label: "Public", + value: "public", + }, + { + description: "Only members of one channel", + icon: LockKeyhole, + label: "Private", + value: "private", + }, +] as const; + +/** Shared, accessible repository visibility fields for create and edit flows. */ +export function ProjectVisibilityFields({ + channelId, + channels, + channelsError, + channelsLoading, + disabled = false, + idPrefix, + onChannelChange, + onRetryChannels, + onVisibilityChange, + visibility, +}: ProjectVisibilityFieldsProps) { + const selectedChannel = channels.find((channel) => channel.id === channelId); + const channelSelectId = `${idPrefix}-channel`; + const channelHelpId = `${idPrefix}-channel-help`; + + return ( +
+ + Visibility + +
+ {VISIBILITY_OPTIONS.map((option) => { + const Icon = option.icon; + const checked = visibility === option.value; + return ( + + ); + })} +
+ + {visibility === "private" ? ( +
+ + + + {channelsError ? ( +
+ Couldn’t load your joined channels. {channelsError} + +
+ ) : channelsLoading ? ( +

+ Loading channels you currently belong to… +

+ ) : channels.length === 0 ? ( +

+ Join a channel to create a private project +

+ ) : ( +

+ {selectedChannel + ? `Current members of #${selectedChannel.name} get access immediately; removed members lose access on their next request.` + : "Choose the channel whose current members should be able to discover and use this repository."} +

+ )} +
+ ) : null} +
+ ); +} diff --git a/desktop/src/features/projects/ui/ProjectsActivityFeed.tsx b/desktop/src/features/projects/ui/ProjectsActivityFeed.tsx index ba0c15b1c7..5fd8a6fadb 100644 --- a/desktop/src/features/projects/ui/ProjectsActivityFeed.tsx +++ b/desktop/src/features/projects/ui/ProjectsActivityFeed.tsx @@ -1,3 +1,4 @@ +import { LockKeyhole } from "lucide-react"; import { resolveUserLabel, type UserProfileLookup, @@ -313,11 +314,18 @@ function ActivityCard({ )}{" "} {item.action}{" "} diff --git a/desktop/src/features/projects/useCreateProject.ts b/desktop/src/features/projects/useCreateProject.ts index fce3e0fd8e..1fd83e4e6d 100644 --- a/desktop/src/features/projects/useCreateProject.ts +++ b/desktop/src/features/projects/useCreateProject.ts @@ -17,6 +17,8 @@ export type CreateProjectInput = { description?: string; cloneUrl?: string; webUrl?: string; + visibility: "public" | "private"; + channelId?: string; }; function projectDtagFromName(name: string): string { @@ -65,6 +67,13 @@ async function createProject(input: CreateProjectInput): Promise { if (webUrl) { tags.push(["web", webUrl]); } + if (input.visibility === "private") { + if (!input.channelId) { + throw new Error("Select a channel for a private project."); + } + tags.push(["buzz-visibility", "private"]); + tags.push(["buzz-channel", input.channelId]); + } const event = await signRelayEvent({ kind: KIND_REPO_ANNOUNCEMENT, @@ -81,6 +90,74 @@ async function createProject(input: CreateProjectInput): Promise { return eventToProject(event, getCachedRelayOrigin()); } +export type UpdateProjectVisibilityInput = { + project: Project; + visibility: "public" | "private"; + channelId?: string; +}; + +async function updateProjectVisibility( + input: UpdateProjectVisibilityInput, +): Promise { + const identity = await getIdentity(); + if (identity.pubkey.toLowerCase() !== input.project.owner.toLowerCase()) { + throw new Error("Only the repository owner can change visibility."); + } + if (input.visibility === "private" && !input.channelId) { + throw new Error("Select a channel for a private project."); + } + const currentEvents = await relayClient.fetchEvents({ + kinds: [KIND_REPO_ANNOUNCEMENT], + authors: [input.project.owner], + "#d": [input.project.dtag], + limit: 1, + }); + const current = currentEvents[0]; + if (!current) + throw new Error("The repository announcement no longer exists."); + + const tags = current.tags.filter( + (tag) => tag[0] !== "buzz-visibility" && tag[0] !== "buzz-channel", + ); + if (input.visibility === "private" && input.channelId) { + tags.push(["buzz-visibility", "private"]); + tags.push(["buzz-channel", input.channelId]); + } else if (input.project.channelBindingId) { + // Public read visibility is independent of the existing push channel. + // Preserve that binding so changing privacy never changes write policy. + tags.push(["buzz-channel", input.project.channelBindingId]); + } + const event = await signRelayEvent({ + kind: KIND_REPO_ANNOUNCEMENT, + content: current.content, + tags, + createdAt: current.created_at + 1, + }); + await relayClient.publishEvent( + event, + "Timed out updating project visibility.", + "Failed to update project visibility.", + ); + return eventToProject(event, getCachedRelayOrigin()); +} + +export function useUpdateProjectVisibilityMutation() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: updateProjectVisibility, + onSuccess: (project) => { + queryClient.setQueryData(projectsQueryKey, (current = []) => + current.map((item) => (item.id === project.id ? project : item)), + ); + queryClient.setQueryData( + ["project", project.id], + project, + ); + }, + }); +} + /** Mutation that creates a project and inserts it into the projects cache. */ export function useCreateProjectMutation() { const queryClient = useQueryClient(); diff --git a/desktop/src/testing/e2eBridge.ts b/desktop/src/testing/e2eBridge.ts index 33e9e02ba1..ce6e06e3d9 100644 --- a/desktop/src/testing/e2eBridge.ts +++ b/desktop/src/testing/e2eBridge.ts @@ -133,6 +133,12 @@ type MockSearchProfileSeed = { type E2eConfig = { mode?: "mock" | "relay"; mock?: { + /** Reject project publications once with these exact relay messages. */ + projectEventRejectionMessages?: string[]; + /** Expose only the named non-DM channels to the active mock identity. */ + projectEligibleChannelNames?: string[]; + /** Seed the first project as private to this channel name. */ + projectPrivateChannelName?: string; /** Advertised HEAD for the first mock project without adding that branch. */ projectHeadBranch?: string; /** Builderlab account returned by hosted-community onboarding. Null/omitted = signed out. */ @@ -976,6 +982,8 @@ declare global { kind: number; tags: string[][]; }>; + /** Accepted project announcements, including event pubkeys and final tags. */ + __BUZZ_E2E_PUBLISHED_PROJECT_EVENTS__?: RelayEvent[]; /** Project event kinds rejected once, in order, to exercise retry flows. */ __BUZZ_E2E_REJECT_PROJECT_EVENT_KINDS__?: number[]; /** Structured merge error returned by the mock native merge command. */ @@ -2224,7 +2232,19 @@ function listMockProfiles(): RawProfile[] { } function listMockChannels(config?: E2eConfig): RawChannelWithMembership[] { - return mockChannels.map((channel) => toRawChannel(channel, config)); + const eligibleNames = config?.mock?.projectEligibleChannelNames; + if (eligibleNames === undefined) { + return mockChannels.map((channel) => toRawChannel(channel, config)); + } + + const eligible = new Set(eligibleNames); + return mockChannels.map((channel) => { + const raw = toRawChannel(channel, config); + return { + ...raw, + is_member: raw.channel_type !== "dm" && eligible.has(raw.name), + }; + }); } function getMockChannel(channelId: string): MockChannel { @@ -4837,6 +4857,14 @@ function buildMockProjectEvents(): RelayEvent[] { : seed.owner; const repoAddress = `${KIND_REPO_ANNOUNCEMENT}:${owner}:${seed.dtag}`; const authors = [seed.owner, ...seed.contributors]; + const privateChannel = + projectIndex === 0 && getConfig()?.mock?.projectPrivateChannelName + ? mockChannels.find( + (channel) => + channel.name === getConfig()?.mock?.projectPrivateChannelName && + channel.channel_type !== "dm", + ) + : undefined; const random = mulberry32(projectIndex + 1); events.push( @@ -4849,6 +4877,12 @@ function buildMockProjectEvents(): RelayEvent[] { ["description", seed.description], ["clone", `https://relay.example.com/git/${owner}/${seed.dtag}`], ...seed.contributors.map((pubkey) => ["p", pubkey]), + ...(privateChannel + ? [ + ["buzz-visibility", "private"], + ["buzz-channel", privateChannel.id], + ] + : []), ], owner, now - (historyDays + 30 + projectIndex) * daySeconds, @@ -4932,10 +4966,75 @@ function getMockProjectEventStore(): RelayEvent[] { return mockProjectEventStore; } +function repositoryVisibilityMetadata(event: RelayEvent): { + channelId: string | null; + privateVisibility: boolean; +} { + const visibilityTags = event.tags.filter( + (tag) => tag[0] === "buzz-visibility", + ); + if (visibilityTags.length === 0) { + return { channelId: null, privateVisibility: false }; + } + if ( + visibilityTags.length !== 1 || + visibilityTags[0].length !== 2 || + visibilityTags[0][1] !== "private" + ) { + throw new Error("unsupported buzz-visibility value"); + } + + const channelTags = event.tags.filter((tag) => tag[0] === "buzz-channel"); + if (channelTags.length !== 1 || channelTags[0].length !== 2) { + throw new Error( + "private repository requires exactly one buzz-channel UUID", + ); + } + return { channelId: channelTags[0][1], privateVisibility: true }; +} + +function validateMockProjectAnnouncement( + event: RelayEvent, + config: E2eConfig | undefined, +): string | null { + if (event.kind !== KIND_REPO_ANNOUNCEMENT) return null; + + let visibility: ReturnType; + try { + visibility = repositoryVisibilityMetadata(event); + } catch (error) { + return error instanceof Error + ? error.message + : "invalid repository metadata"; + } + if (!visibility.privateVisibility || !visibility.channelId) return null; + + const channel = mockChannels.find( + (candidate) => candidate.id === visibility.channelId, + ); + const viewerCanUseChannel = listMockChannels(config).some( + (candidate) => + candidate.id === visibility.channelId && + candidate.channel_type !== "dm" && + candidate.is_member, + ); + if ( + !channel || + channel.channel_type === "dm" || + !viewerCanUseChannel || + event.pubkey.toLowerCase() !== getMockMemberPubkey(config).toLowerCase() + ) { + return "private repository owner must be a current member of buzz-channel"; + } + return null; +} + /** Project-scoped publishes (PR/issue comments, NIP-34 status events) carry * a repo-address `a` tag instead of a channel `h` tag — store them with the * seeded project events so refetches see them. */ function isMockProjectScopedEvent(event: RelayEvent): boolean { + if (event.kind === KIND_REPO_ANNOUNCEMENT) return true; + const hasRepoAddressTag = event.tags.some( (tag) => tag[0] === "a" && (tag[1] ?? "").startsWith("30617:"), ); @@ -8782,7 +8881,8 @@ function sendToMockSocket(args: { } if (isMockProjectScopedEvent(event)) { - if (event.pubkey !== DEFAULT_MOCK_IDENTITY.pubkey) { + const authenticatedPubkey = getMockMemberPubkey(getConfig()); + if (event.pubkey.toLowerCase() !== authenticatedPubkey.toLowerCase()) { sendWsText(socket.handler, [ "OK", event.id, @@ -8791,6 +8891,30 @@ function sendToMockSocket(args: { ]); return; } + const configuredRejection = + getConfig()?.mock?.projectEventRejectionMessages?.shift(); + if (configuredRejection) { + sendWsText(socket.handler, [ + "OK", + event.id, + false, + configuredRejection, + ]); + return; + } + const announcementRejection = validateMockProjectAnnouncement( + event, + getConfig(), + ); + if (announcementRejection) { + sendWsText(socket.handler, [ + "OK", + event.id, + false, + announcementRejection, + ]); + return; + } const rejectionIndex = window.__BUZZ_E2E_REJECT_PROJECT_EVENT_KINDS__?.indexOf(event.kind) ?? -1; @@ -8808,6 +8932,9 @@ function sendToMockSocket(args: { return; } getMockProjectEventStore().push(event); + if (event.kind === KIND_REPO_ANNOUNCEMENT) { + window.__BUZZ_E2E_PUBLISHED_PROJECT_EVENTS__?.push(event); + } sendWsText(socket.handler, ["OK", event.id, true, ""]); return; } @@ -8877,6 +9004,7 @@ export function maybeInstallE2eTauriMocks() { window.__BUZZ_E2E_COMMAND_PAYLOADS__ = []; window.__BUZZ_E2E_COMMAND_LOG__ = []; window.__BUZZ_E2E_SIGNED_EVENTS__ = []; + window.__BUZZ_E2E_PUBLISHED_PROJECT_EVENTS__ = []; window.__BUZZ_E2E_WEBVIEW_ZOOM__ = 1; window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__ = ({ channelName, diff --git a/desktop/tests/e2e/project-visibility.spec.ts b/desktop/tests/e2e/project-visibility.spec.ts new file mode 100644 index 0000000000..27b2f01e20 --- /dev/null +++ b/desktop/tests/e2e/project-visibility.spec.ts @@ -0,0 +1,278 @@ +import { expect, test, type Page } from "@playwright/test"; + +import { TEST_IDENTITIES, installMockBridge } from "../helpers/bridge"; + +const PRIVATE_CHANNEL_ID = "3c2d9f0a-1b44-5e77-9a21-6f8b0c4d2e91"; +const PRIVATE_CHANNEL_NAME = "secret-projects"; +const KIND_REPO_ANNOUNCEMENT = 30617; + +async function openProjects(page: Page) { + await page.goto("/", { waitUntil: "domcontentloaded" }); + await page.getByTestId("open-projects-view").click(); + await expect( + page.getByRole("heading", { level: 1, name: "Projects" }), + ).toBeVisible(); +} + +async function openCreateProjectDialog(page: Page) { + await page.getByTestId("projects-create-menu").click(); + await page.getByRole("menuitem", { name: "Repository" }).click(); + await expect(page.getByTestId("create-project-dialog")).toBeVisible(); +} + +async function openProject(page: Page, dtag = "buzz") { + await page.getByRole("button", { name: "Repositories", exact: true }).click(); + const projectEntry = page + .locator( + `[data-testid="project-card-${dtag}"], [data-testid="project-row-${dtag}"]`, + ) + .first(); + await expect(projectEntry).toBeVisible({ timeout: 10_000 }); + await projectEntry.click(); +} + +async function publishedProjectEvents(page: Page) { + return page.evaluate(() => + (window.__BUZZ_E2E_PUBLISHED_PROJECT_EVENTS__ ?? []).map((event) => ({ + kind: event.kind, + tags: event.tags, + })), + ); +} + +test("create dialog defaults public and publishes only the chosen non-DM channel", async ({ + page, +}) => { + await installMockBridge(page, { + projectEligibleChannelNames: [PRIVATE_CHANNEL_NAME, "alice-tyler"], + }); + await openProjects(page); + await openCreateProjectDialog(page); + + const publicChoice = page.getByTestId("create-project-visibility-public"); + const privateChoice = page.getByTestId("create-project-visibility-private"); + const channelPicker = page.getByTestId("create-project-channel"); + const submit = page.getByTestId("create-project-submit"); + + await expect(publicChoice).toBeChecked(); + await expect( + page.getByText("Anyone in the workspace can find and clone"), + ).toBeVisible(); + await expect(page.getByText("Only members of one channel")).toBeVisible(); + await expect(channelPicker).toHaveCount(0); + + await page.getByTestId("create-project-name").fill("private demo"); + await page.getByText("Private", { exact: true }).click(); + await expect(privateChoice).toBeChecked(); + await expect(channelPicker).toBeVisible(); + await expect(channelPicker.locator("option")).toHaveText([ + "Choose a joined channel…", + `#${PRIVATE_CHANNEL_NAME}`, + ]); + await expect(channelPicker).not.toContainText("alice-tyler"); + await expect(submit).toBeDisabled(); + + await channelPicker.selectOption(PRIVATE_CHANNEL_ID); + await expect(submit).toBeEnabled(); + await submit.click(); + await expect(page.getByTestId("create-project-dialog")).toHaveCount(0); + + const events = await publishedProjectEvents(page); + expect(events).toEqual([ + expect.objectContaining({ + kind: KIND_REPO_ANNOUNCEMENT, + tags: expect.arrayContaining([ + ["d", "private-demo"], + ["buzz-visibility", "private"], + ["buzz-channel", PRIVATE_CHANNEL_ID], + ]), + }), + ]); +}); + +test("create dialog explains when no access channels are available", async ({ + page, +}) => { + await installMockBridge(page, { projectEligibleChannelNames: [] }); + await openProjects(page); + await openCreateProjectDialog(page); + + await page.getByTestId("create-project-name").fill("blocked private demo"); + await page.getByText("Private", { exact: true }).click(); + await expect( + page.getByTestId("create-project-visibility-private"), + ).toBeChecked(); + + await expect( + page.getByText("Join a channel to create a private project"), + ).toBeVisible(); + await expect(page.getByTestId("create-project-channel")).toBeDisabled(); + await expect(page.getByTestId("create-project-submit")).toBeDisabled(); +}); + +test("create dialog surfaces the relay rejection message verbatim", async ({ + page, +}) => { + const relayMessage = + "private repository owner must be a current member of buzz-channel"; + await installMockBridge(page, { + projectEligibleChannelNames: [PRIVATE_CHANNEL_NAME], + projectEventRejectionMessages: [relayMessage], + }); + await openProjects(page); + await openCreateProjectDialog(page); + + await page.getByTestId("create-project-name").fill("rejected private demo"); + await page.getByText("Private", { exact: true }).click(); + await expect( + page.getByTestId("create-project-visibility-private"), + ).toBeChecked(); + await page + .getByTestId("create-project-channel") + .selectOption(PRIVATE_CHANNEL_ID); + await page.getByTestId("create-project-submit").click(); + + await expect(page.getByRole("alert")).toHaveText(relayMessage); + await expect(page.getByTestId("create-project-dialog")).toBeVisible(); + expect(await publishedProjectEvents(page)).toHaveLength(0); +}); + +test("owner visibility menu confirms only private to public", async ({ + page, +}) => { + await installMockBridge(page, { + projectEligibleChannelNames: [PRIVATE_CHANNEL_NAME, "engineering"], + projectPrivateChannelName: PRIVATE_CHANNEL_NAME, + }); + await openProjects(page); + + const privateCard = page.getByTestId("project-card-buzz"); + await page.getByRole("button", { name: "Repositories", exact: true }).click(); + await expect( + privateCard.getByTestId("project-private-indicator"), + ).toBeVisible(); + + await page.getByRole("button", { name: "List layout" }).click(); + const privateRow = page.getByTestId("project-row-buzz"); + await expect( + privateRow.getByTestId("project-private-indicator"), + ).toBeVisible(); + await privateRow.getByRole("button", { name: "View buzz" }).press("Enter"); + + const trigger = page.getByTestId("project-visibility-trigger"); + await expect(trigger).toContainText(`Private · #${PRIVATE_CHANNEL_NAME}`); + await trigger.click(); + await expect(page.getByTestId("project-visibility-menu")).toBeVisible(); + await expect(page.getByTestId("project-visibility-public")).toBeVisible(); + await expect(page.getByTestId("project-visibility-private")).toBeVisible(); + await page.getByTestId("project-visibility-private").hover(); + await expect( + page.getByTestId("project-visibility-channel-menu"), + ).toBeVisible(); + await expect( + page.getByTestId(`project-visibility-channel-${PRIVATE_CHANNEL_ID}`), + ).toBeVisible(); + await page.getByTestId("project-visibility-private").press("ArrowLeft"); + await expect(page.getByTestId("project-visibility-menu")).toBeVisible(); + + await page.getByTestId("project-visibility-public").click(); + const confirm = page.getByTestId("project-visibility-public-confirm"); + await expect(confirm).toContainText("Make this project public?"); + await expect(confirm).toContainText( + "Anyone in the workspace will be able to find and clone it.", + ); + await confirm.getByRole("button", { name: "Cancel" }).click(); + await expect(trigger).toContainText(`Private · #${PRIVATE_CHANNEL_NAME}`); + expect(await publishedProjectEvents(page)).toHaveLength(0); + + await trigger.click(); + await page.getByTestId("project-visibility-public").click(); + await page.getByTestId("project-visibility-public-confirm-button").click(); + await expect(trigger).toContainText("Public"); + + await trigger.click(); + await expect(page.getByTestId("project-visibility-menu")).toBeVisible(); + await page.getByTestId("project-visibility-private").hover(); + await expect( + page.getByTestId("project-visibility-channel-menu"), + ).toBeVisible(); + await expect( + page.getByTestId(`project-visibility-channel-${PRIVATE_CHANNEL_ID}`), + ).toBeVisible(); + await page + .getByTestId(`project-visibility-channel-${PRIVATE_CHANNEL_ID}`) + .click(); + await expect( + page.getByTestId("project-visibility-public-confirm"), + ).toHaveCount(0); + await expect(trigger).toContainText(`Private · #${PRIVATE_CHANNEL_NAME}`); + await expect + .poll(async () => (await publishedProjectEvents(page)).length) + .toBe(2); + await expect(trigger).toBeEnabled(); + await expect(trigger).toHaveAttribute("data-state", "closed"); + + await trigger.press("Enter"); + await expect(page.getByTestId("project-visibility-menu")).toBeVisible(); + await page.getByTestId("project-visibility-private").press("ArrowRight"); + await expect( + page.getByTestId("project-visibility-channel-menu"), + ).toBeVisible(); + const engineeringChannel = page.getByTestId( + "project-visibility-channel-1c7e1c02-87bb-5e88-b2da-5a7a9432d0c9", + ); + await expect(engineeringChannel).toBeVisible(); + await engineeringChannel.click(); + await expect( + page.getByTestId("project-visibility-public-confirm"), + ).toHaveCount(0); + await expect(trigger).toContainText("Private · #engineering"); + + const events = await publishedProjectEvents(page); + expect(events).toHaveLength(3); + expect(events[0]?.tags).not.toContainEqual(["buzz-visibility", "private"]); + expect(events[1]?.tags).toEqual( + expect.arrayContaining([ + ["buzz-visibility", "private"], + ["buzz-channel", PRIVATE_CHANNEL_ID], + ]), + ); + expect(events[2]?.tags).toEqual( + expect.arrayContaining([ + ["buzz-visibility", "private"], + ["buzz-channel", "1c7e1c02-87bb-5e88-b2da-5a7a9432d0c9"], + ]), + ); +}); + +test("overview rail and non-owner detail expose private state without edit controls", async ({ + page, +}) => { + await page.addInitScript((owner) => { + window.__BUZZ_E2E_PROJECT_OWNER_OVERRIDE__ = owner; + }, TEST_IDENTITIES.alice.pubkey); + await installMockBridge(page, { + projectEligibleChannelNames: [PRIVATE_CHANNEL_NAME], + projectPrivateChannelName: PRIVATE_CHANNEL_NAME, + }); + await openProjects(page); + + const privateActivity = page + .getByRole("button", { + name: "buzz Private project", + exact: true, + }) + .first(); + await expect( + privateActivity.getByTestId("project-private-indicator"), + ).toBeVisible(); + + await openProject(page); + const status = page.getByTestId("project-visibility-status"); + const tooltip = `Only members of #${PRIVATE_CHANNEL_NAME} and the owner can access this project.`; + await expect(status).toHaveText(`Private · #${PRIVATE_CHANNEL_NAME}`); + await expect(status).not.toHaveAttribute("title"); + await expect(page.getByTestId("project-visibility-trigger")).toHaveCount(0); + await status.hover(); + await expect(page.getByRole("tooltip")).toHaveText(tooltip); +}); diff --git a/desktop/tests/helpers/bridge.ts b/desktop/tests/helpers/bridge.ts index 15606e82c9..356be8354d 100644 --- a/desktop/tests/helpers/bridge.ts +++ b/desktop/tests/helpers/bridge.ts @@ -127,6 +127,12 @@ export type MockAgentMemoryListing = { }; type MockBridgeOptions = { + /** Reject project publications once with these exact relay messages. */ + projectEventRejectionMessages?: string[]; + /** Expose only the named non-DM channels to the active mock identity. */ + projectEligibleChannelNames?: string[]; + /** Seed the first project as private to this channel name. */ + projectPrivateChannelName?: string; /** Advertised HEAD for the first mock project without adding that branch. */ projectHeadBranch?: string; /** Relay NIP-11 identity used to sign authoritative repository state. */ diff --git a/web/src/features/repos/mock-repos.ts b/web/src/features/repos/mock-repos.ts index e05bd3440f..7e0bf650a1 100644 --- a/web/src/features/repos/mock-repos.ts +++ b/web/src/features/repos/mock-repos.ts @@ -23,7 +23,8 @@ export const mockRepos: Repo[] = [ "The desktop client for collaborating with people and agents across Buzz communities.", cloneUrls: ["https://example.com/buzz-desktop.git"], webUrl: null, - channelId: null, + privateChannelId: null, + visibility: "public", owner: people.ada, contributors: [people.grace, people.linus], createdAt: now - 60 * 24, @@ -35,7 +36,8 @@ export const mockRepos: Repo[] = [ "Tools and shared workflows for launching and coordinating coding agents.", cloneUrls: ["https://example.com/agent-harness.git"], webUrl: null, - channelId: null, + privateChannelId: null, + visibility: "public", owner: people.grace, contributors: [people.ada, people.margaret], createdAt: now - 60 * 60 * 3, @@ -47,7 +49,8 @@ export const mockRepos: Repo[] = [ "Infrastructure and deployment configuration for community relays.", cloneUrls: ["https://example.com/relay-infrastructure.git"], webUrl: null, - channelId: null, + privateChannelId: null, + visibility: "public", owner: people.linus, contributors: [people.margaret], createdAt: now - 60 * 60 * 24 * 2, @@ -58,7 +61,8 @@ export const mockRepos: Repo[] = [ description: "Shared foundations, components, and interaction patterns.", cloneUrls: ["https://example.com/design-system.git"], webUrl: null, - channelId: null, + privateChannelId: null, + visibility: "public", owner: people.margaret, contributors: [people.ada, people.grace, people.linus], createdAt: now - 60 * 60 * 24 * 8, diff --git a/web/src/features/repos/ui/RepoDetailPage.tsx b/web/src/features/repos/ui/RepoDetailPage.tsx index baedb3aadf..e54cbcfdb8 100644 --- a/web/src/features/repos/ui/RepoDetailPage.tsx +++ b/web/src/features/repos/ui/RepoDetailPage.tsx @@ -286,7 +286,7 @@ export function RepoDetailPage() { variant="outline" className="border-black/15 text-black/60 dark:border-white/15 dark:text-white/60" > - Public + {repo.visibility === "private" ? "Private" : "Public"}
{repo.description && ( @@ -367,14 +367,14 @@ export function RepoDetailPage() { })()} {/* Channel link */} - {repo.channelId && ( + {repo.privateChannelId && (