diff --git a/.github/workflows/_ci-relay.yml b/.github/workflows/_ci-relay.yml index 1092740293..e77df7312c 100644 --- a/.github/workflows/_ci-relay.yml +++ b/.github/workflows/_ci-relay.yml @@ -629,6 +629,8 @@ jobs: cargo test -p buzz-test-client --test e2e_relay nip29_departure_wire -- --ignored --nocapture cargo test -p buzz-test-client --test e2e_relay nip29_batch_put_user_wire -- --ignored --nocapture cargo test -p buzz-test-client --test e2e_relay nip29_announce_channel_wire -- --ignored --nocapture + cargo test -p buzz-test-client --test e2e_relay nip29_unlisted_channel_wire -- --ignored --nocapture + cargo test -p buzz-test-client --test e2e_relay nip29_solo_share_and_convert_wire -- --ignored --nocapture cargo test -p buzz-test-client --test e2e_relay nip29_member_raises_guest_agent -- --ignored --nocapture cargo test -p buzz-test-client --test e2e_relay nip29_guest_is_read_only_in_normal_channel -- --ignored --nocapture cargo test -p buzz-test-client --test e2e_relay nip29_relay_rejects_role_change_by_unprivileged_actor -- --ignored --nocapture diff --git a/NOSTR.md b/NOSTR.md index dd6be47371..507923d70c 100644 --- a/NOSTR.md +++ b/NOSTR.md @@ -50,14 +50,15 @@ PGPASSWORD=buzz_dev psql -h localhost -U buzz -d buzz -c \ | **Reactions (kind:7)** | ✅ | Standard NIP-25; channel derived from target event's `#e` tag (client `#h` ignored) | | **Deletions (kind:5)** | ✅ | Standard NIP-09; self-authored only. `#h` optional, `#e` required | | **User profiles (kind:0)** | ✅ | NIP-01 metadata; synced to users table (display_name, avatar, about, NIP-05). NIP-05 handles must canonicalize to this relay's domain — off-domain or invalid handles are silently cleared. If a NIP-05 handle collides with another user's (UNIQUE constraint), the handle is skipped but other profile fields (display_name, avatar, about) are still synced. | -| **Group creation (kind:9007)** | ✅ | NIP-29; include `name` tag, optional `visibility`, `channel_type` and label `t` tags | +| **Group creation (kind:9007)** | ✅ | NIP-29; include `name` tag, optional `visibility`, `channel_type`, label `t` and `unlisted` tags | | **Add user (kind:9000)** | ✅ | Open: any user, subject to target's `channel_add_policy` (`owner_only`/`nobody` can block). Private: owner/admin only. Self-add bypasses agent policy but not private-channel auth. A guest cannot add anyone. A new member with no role tag gets the channel's `join_role`; a non-member adding themselves may ask for `join_role` or a lower role. One event may name up to 100 people: one `p` tag each, with an optional role as the `p` tag's third element (NIP-29), falling back to the event's `role` tag. Each person is checked and added on their own. When the event names several people, the OK message is `members: ` followed by JSON listing the non-empty `added`, `updated` (role changed), `already_member` and `rejected` (`[{"reason", "pubkeys"}]`) results. The event is refused only when it is malformed or nobody it names may be added; then one person keeps the single-add error, and several get `invalid: members: {…}`. A resent event returns `duplicate:` without results; to retry an `error:` rejection, send a new event. Re-adding someone at their current role posts no second `member_joined` notice. | | **Remove user (kind:9001)** | ✅ | Self-remove allowed (with last-owner guard). Removing others: owner/admin only. | -| **Edit group metadata (kind:9002)** | ✅ | `name`/`about`/label `t`/`visibility`/`ttl`/`archived`/`join_role` tags: owner/admin. `topic`/`purpose` tags: any member except a guest. Buzz applies 9002 as a partial update: an absent tag leaves that setting unchanged. | +| **Edit group metadata (kind:9002)** | ✅ | `name`/`about`/label `t`/`visibility`/`ttl`/`archived`/`join_role`/`unlisted` tags: owner/admin. `topic`/`purpose` tags: any member except a guest. Buzz applies 9002 as a partial update: an absent tag leaves that setting unchanged. | | **Admin delete event (kind:9005)** | ✅ | Event author can always delete own. Otherwise owner/admin required. Target must be in same channel. | | **Group deletion (kind:9008)** | ✅ | Owner only. | | **Leave group (kind:9022)** | ✅ | Any member. Last-owner guard prevents orphaned groups. | -| **Group metadata (kind:39000)** | ✅ | Relay-signed; always `d`, `name`, `t`, `P`, `created_at`, `join_role`; `public` or `private`; `restricted` and `closed` per the access flags below; `about` only if description non-empty; `hidden` for DM channels and private system channels | +| **Group metadata (kind:39000)** | ✅ | Relay-signed; always `d`, `name`, `t`, `P`, `created_at`, `join_role`; `public` or `private`; `restricted` and `closed` per the access flags below; `unlisted` when the channel is unlisted; `about` only if description non-empty; `hidden` for DM channels and private system channels | +| **Unlisted channels** | ✅ | kind:9002 `["unlisted", "true"\|"false"]`, owner/admin, or at creation on kind:9007. An unlisted open channel is left out of every read that does not name it, for non-members: channel lists, kind:39000 without `#d`, messages, COUNT, NIP-50 search and artifact queries. Naming it by `#h` (or `#d` on 39000–39003) reads it, and kind:9021 joins it, as for any open channel. See [Unlisted channels](#unlisted-channels). | | **Group admins (kind:39001)** | ✅ | Relay-signed; `d`, channel type `t` and creator `P` tags + `p` tags with roles (`owner`, `admin`) | | **Group members (kind:39002)** | ✅ | Relay-signed; `d`, channel type `t` and creator `P` tags + `p` tags for all members | | **Membership notifications** | ✅ | kind:44100 (added) / kind:44101 (removed); relay-signed, community-global scope (`channel_id=None` inside the connected community) | @@ -122,7 +123,7 @@ What each role can do: | Topic, purpose (kind:9002) | No | Yes | Yes | | Add people (kind:9000) | No | Yes, as member, guest or bot | Yes, any role | | Push to the channel's git repositories | No | Yes | Yes | -| Name, about, visibility, TTL, `join_role` | No | No | Yes | +| Name, about, visibility, TTL, `join_role`, `unlisted` | No | No | Yes | A non-member may write only where joining would give a role that can write: an open channel with `join_role = member`. A refused write gets @@ -155,6 +156,42 @@ explicit `["join_role", ...]` tag so clients can tell what joining gives: | open | `guest` | | ✓ | ✓ | | | private | either | ✓ | | ✓ | ✓ | +### Unlisted channels + +An **unlisted** open channel works like a link-shared document: anyone in +the community who has its id (for example from a shared link) can read it +and join it, but it does not show up for people who are not members. + +- Owners and admins set it with kind:9002 `["unlisted", "true"]` and clear + it with `["unlisted", "false"]`. A missing tag means "no change". It can be + sent in the same event as `["visibility", "open"]`, so a private channel + becomes open and unlisted in one step. +- kind:9007 also takes `["unlisted", "true"|"false"]`, so a channel can + start unlisted; a missing tag means listed. Any other value is refused + before the event is stored, as on kind:9002. +- For a non-member, an unlisted open channel, of any type, is left out of + every read that does not name it: a filter without `#h` (including + kind:39000 discovery without `#d`, and the system-channel opt-in by `#P` + or `#t:["system"]`), COUNT, NIP-50 search, artifact queries and the + channel list. A filter that names it by `#h`, or by `#d` + on a filter whose kinds are all 39000–39003, reads it under the ordinary + open-channel rules. This is the same "named-only" rule that applies to + system channels ([NIP-CL](docs/nips/NIP-CL.md)). +- Joining (kind:9021) and posting work as on any open channel; `join_role` + still decides what a joiner gets. +- Members see it everywhere, as before. +- The flag is stored on its own and matters only while the channel is open. + Private channels are already invisible to non-members. A private channel + can be marked unlisted ahead of time, so opening it later never lists it. +- kind:39000 carries a bare `["unlisted"]` flag whenever the setting is on, + whatever the visibility. It does not add NIP-29 `hidden`: the metadata + stays readable by id. +- Relays that support it advertise `buzz-unlisted-channels` in NIP-11 + `supported_extensions`. An older relay applies `["visibility", "open"]` + and ignores `unlisted`, so a client must check for it before opening a + channel as unlisted. +- Unlisted is not an access control. Anyone with the id can read the channel. + ### Group Discovery The relay emits NIP-29 group state events when channels are created, updated, or membership changes. @@ -164,7 +201,7 @@ All discovery events include a `d` tag set to the channel UUID (NIP-29 addressab | Kind | Tags | Content | |------|------|---------| -| **39000** | `d=`, `name`, `t=`, `P=`, `created_at=`, `join_role` (always); `public` or `private`; `restricted` (private channels, and `join_role=guest`); `closed` (private channels only); `about` (if description non-empty); `hidden` (DM channels and private system channels) | Group metadata. The access flags follow the channel settings (see [Announce channels](#announce-channels)). | +| **39000** | `d=`, `name`, `t=`, `P=`, `created_at=`, `join_role` (always); `public` or `private`; `restricted` (private channels, and `join_role=guest`); `closed` (private channels only); `unlisted` (unlisted channels); `about` (if description non-empty); `hidden` (DM channels and private system channels) | Group metadata. The access flags follow the channel settings (see [Announce channels](#announce-channels)). | | **39001** | `d=`, `t=`, `P=`, `p` tags with role label (`owner`, `admin`) | Admin list | | **39002** | `d=`, `t=`, `P=`, `p` tags for all members | Member list | diff --git a/crates/buzz-cli/src/commands/channels.rs b/crates/buzz-cli/src/commands/channels.rs index 019a2fca6b..d4a8d6fe93 100644 --- a/crates/buzz-cli/src/commands/channels.rs +++ b/crates/buzz-cli/src/commands/channels.rs @@ -655,6 +655,30 @@ fn extract_head_expected_revision(head: &serde_json::Value) -> Option { .map(str::to_string) } +/// The NIP-11 extension a relay advertises when it honors the `unlisted` tag. +const UNLISTED_EXTENSION: &str = "buzz-unlisted-channels"; + +/// Refuse to unlist a channel on a relay that does not advertise +/// [`UNLISTED_EXTENSION`]. Such a relay applies the rest of the event and +/// ignores `unlisted`, so an open channel would end up listed. Call it before +/// anything is written. +async fn require_unlisted_support(client: &BuzzClient) -> Result<(), CliError> { + let raw = client.get_public("/info").await?; + let info: serde_json::Value = serde_json::from_str(&raw) + .map_err(|e| CliError::Other(format!("invalid NIP-11 response: {e}")))?; + let supported = info + .get("supported_extensions") + .and_then(|v| v.as_array()) + .is_some_and(|exts| exts.iter().any(|e| e.as_str() == Some(UNLISTED_EXTENSION))); + if supported { + Ok(()) + } else { + Err(CliError::Other(format!( + "this relay does not support unlisted channels (missing \"{UNLISTED_EXTENSION}\" in supported_extensions); it would ignore --unlisted and list the channel" + ))) + } +} + pub async fn cmd_create_channel( client: &BuzzClient, name: &str, @@ -662,6 +686,7 @@ pub async fn cmd_create_channel( visibility: &str, description: Option<&str>, ttl: Option, + unlisted: bool, ) -> Result<(), CliError> { match channel_type { "stream" | "forum" => {} @@ -681,6 +706,9 @@ pub async fn cmd_create_channel( } let ttl = ttl.map(validate_ttl_seconds).transpose()?; + if unlisted { + require_unlisted_support(client).await?; + } let channel_uuid = Uuid::new_v4(); @@ -694,9 +722,16 @@ pub async fn cmd_create_channel( "forum" => buzz_sdk::ChannelKind::Forum, _ => unreachable!(), }; - let builder = - buzz_sdk::build_create_channel(channel_uuid, name, Some(vis), Some(ct), description, ttl) - .map_err(|e| CliError::Other(format!("build_create_channel failed: {e}")))?; + let builder = buzz_sdk::build_create_channel( + channel_uuid, + name, + Some(vis), + Some(ct), + description, + ttl, + unlisted, + ) + .map_err(|e| CliError::Other(format!("build_create_channel failed: {e}")))?; let event = client.sign_event(builder)?; let resp = client.submit_event(event).await?; @@ -1423,6 +1458,7 @@ pub async fn cmd_create_channel_from_template( visibility_override: Option<&str>, description: Option<&str>, ttl: Option, + unlisted: bool, ) -> Result<(), CliError> { let templates_path = channel_templates::resolve_templates_path(templates_file)?; let template: ChannelTemplateRecord = @@ -1447,6 +1483,9 @@ pub async fn cmd_create_channel_from_template( } } let ttl = ttl.map(validate_ttl_seconds).transpose()?; + if unlisted { + require_unlisted_support(client).await?; + } // Owner invariant (F1): the auth-tag owner (already verified against the // signer at startup) if present, else the signing pubkey. No sole-author @@ -1477,6 +1516,7 @@ pub async fn cmd_create_channel_from_template( Some(ct), effective_description, ttl, + unlisted, ) .map_err(|e| CliError::Other(format!("build_create_channel failed: {e}")))?; let event = client.sign_event(builder)?; @@ -1599,15 +1639,17 @@ fn validate_update_channel_fields( visibility: Option<&str>, ttl_change: Option>, join_role: Option, + unlisted: Option, ) -> Result<(), CliError> { if name.is_none() && description.is_none() && visibility.is_none() && ttl_change.is_none() && join_role.is_none() + && unlisted.is_none() { return Err(CliError::Usage( - "at least one field required (--name, --description, --visibility, --ttl, --no-ttl, --join-role)" + "at least one field required (--name, --description, --visibility, --ttl, --no-ttl, --join-role, --unlisted)" .into(), )); } @@ -1624,6 +1666,7 @@ pub async fn cmd_update_channel( ttl: Option, no_ttl: bool, join_role: Option, + unlisted: Option, ) -> Result<(), CliError> { // Outer Option: None leaves TTL unchanged. Inner: Some(secs) sets it, // None (from --no-ttl) clears it, making the channel permanent. @@ -1633,8 +1676,20 @@ pub async fn cmd_update_channel( (None, false) => None, }; - validate_update_channel_fields(name, description, visibility, ttl_change, join_role)?; + validate_update_channel_fields( + name, + description, + visibility, + ttl_change, + join_role, + unlisted, + )?; let channel_uuid = parse_uuid(channel_id)?; + // `--unlisted false` needs no check: a relay without the extension lists + // every open channel already. + if unlisted == Some(true) { + require_unlisted_support(client).await?; + } let builder = buzz_sdk::build_update_channel( channel_uuid, @@ -1643,6 +1698,7 @@ pub async fn cmd_update_channel( visibility, ttl_change, join_role, + unlisted, ) .map_err(|e| CliError::Other(format!("build_update_channel failed: {e}")))?; @@ -1905,6 +1961,7 @@ pub async fn dispatch( visibility, description, ttl, + unlisted, template, templates_file, } => { @@ -1918,6 +1975,7 @@ pub async fn dispatch( visibility.as_ref().map(|v| v.to_string()).as_deref(), description.as_deref(), ttl, + unlisted, ) .await } else { @@ -1934,6 +1992,7 @@ pub async fn dispatch( &visibility.to_string(), description.as_deref(), ttl, + unlisted, ) .await } @@ -1946,6 +2005,7 @@ pub async fn dispatch( ttl, no_ttl, join_role, + unlisted, } => { let visibility = visibility.as_ref().map(|v| v.to_string()); cmd_update_channel( @@ -1957,6 +2017,7 @@ pub async fn dispatch( ttl, no_ttl, join_role.map(Into::into), + unlisted, ) .await } @@ -2146,7 +2207,7 @@ mod tests { #[test] fn update_channel_fields_rejects_empty_update() { - let result = validate_update_channel_fields(None, None, None, None, None); + let result = validate_update_channel_fields(None, None, None, None, None, None); assert!(matches!(result, Err(CliError::Usage(_)))); let msg = result.unwrap_err().to_string(); assert!(msg.contains("at least one field required")); @@ -2155,7 +2216,7 @@ mod tests { #[test] fn update_channel_fields_accepts_visibility_only_update() { - let result = validate_update_channel_fields(None, None, Some("open"), None, None); + let result = validate_update_channel_fields(None, None, Some("open"), None, None, None); assert!(result.is_ok(), "visibility-only update should be accepted"); } @@ -4681,3 +4742,145 @@ mod restore_canvas_already_current_tests { ); } } + +#[cfg(test)] +mod unlisted_support_tests { + use std::sync::{Arc, Mutex}; + + use axum::body::Bytes; + use axum::extract::State; + use axum::routing::{get, post}; + use axum::Router; + use nostr::Keys; + use serde_json::Value; + use tokio::net::TcpListener; + + use super::{cmd_create_channel, cmd_update_channel}; + use crate::client::BuzzClient; + + const CHANNEL: &str = "326d56bc-c96c-4af0-86a1-5e804cd1b467"; + + /// A relay whose NIP-11 advertises `extensions`, recording every event + /// posted to `/events`. + async fn relay(extensions: &'static str) -> (String, Arc>>) { + let submitted: Arc>> = Arc::new(Mutex::new(Vec::new())); + let app = Router::new() + .route( + "/info", + get(move || async move { + ( + [("content-type", "application/nostr+json")], + format!(r#"{{"supported_extensions":{extensions}}}"#), + ) + }), + ) + .route( + "/events", + post( + |State(s): State>>>, body: Bytes| async move { + let event: Value = serde_json::from_slice(&body).expect("event json"); + s.lock().unwrap().push(event); + r#"{"event_id":"deadbeef","accepted":true,"message":""}"#.to_string() + }, + ), + ) + .with_state(submitted.clone()); + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + tokio::spawn(async move { axum::serve(listener, app).await.unwrap() }); + (format!("http://{addr}"), submitted) + } + + fn client(base_url: &str) -> BuzzClient { + BuzzClient::new(base_url.to_string(), Keys::generate(), None, None).unwrap() + } + + fn has_unlisted_true(event: &Value) -> bool { + event["tags"] + .as_array() + .unwrap() + .iter() + .any(|t| t[0] == "unlisted" && t[1] == "true") + } + + #[tokio::test] + async fn unlisting_is_refused_without_the_extension() { + let (url, submitted) = relay(r#"["buzz-gif"]"#).await; + let client = client(&url); + + let err = cmd_create_channel(&client, "solo", "stream", "open", None, None, true) + .await + .unwrap_err(); + assert!(err.to_string().contains("buzz-unlisted-channels"), "{err}"); + + let err = cmd_update_channel( + &client, + CHANNEL, + None, + None, + Some("open"), + None, + false, + None, + Some(true), + ) + .await + .unwrap_err(); + assert!(err.to_string().contains("buzz-unlisted-channels"), "{err}"); + + assert!(submitted.lock().unwrap().is_empty(), "nothing is sent"); + } + + #[tokio::test] + async fn unlisting_is_sent_with_the_extension() { + let (url, submitted) = relay(r#"["buzz-unlisted-channels"]"#).await; + let client = client(&url); + + cmd_create_channel(&client, "solo", "stream", "open", None, None, true) + .await + .unwrap(); + cmd_update_channel( + &client, + CHANNEL, + None, + None, + None, + None, + false, + None, + Some(true), + ) + .await + .unwrap(); + + let submitted = submitted.lock().unwrap(); + assert_eq!(submitted.len(), 2); + assert!(submitted.iter().all(has_unlisted_true)); + } + + #[tokio::test] + async fn listed_writes_skip_the_check() { + // The relay advertises no extensions, so a check would fail the command. + let (url, submitted) = relay(r#"[]"#).await; + let client = client(&url); + + cmd_create_channel(&client, "solo", "stream", "open", None, None, false) + .await + .unwrap(); + cmd_update_channel( + &client, + CHANNEL, + None, + None, + None, + None, + false, + None, + Some(false), + ) + .await + .unwrap(); + + assert_eq!(submitted.lock().unwrap().len(), 2); + } +} diff --git a/crates/buzz-cli/src/lib.rs b/crates/buzz-cli/src/lib.rs index c33afa78c2..1dc1438e1b 100644 --- a/crates/buzz-cli/src/lib.rs +++ b/crates/buzz-cli/src/lib.rs @@ -662,7 +662,7 @@ pub enum ChannelsCmd { }, /// Create a new channel #[command( - after_help = "Examples:\n buzz channels create --name general --type stream --visibility open\n buzz channels create --name design --type forum --visibility open --description \"Design discussions\"\n buzz channels create --name standup --type stream --visibility open --ttl 3600 # ephemeral, archived after 1h idle\n buzz channels create --name project-x --template \"Buzz Team\" # type/visibility/canvas/roster from the template; explicit flags override" + after_help = "Examples:\n buzz channels create --name general --type stream --visibility open\n buzz channels create --name design --type forum --visibility open --description \"Design discussions\"\n buzz channels create --name standup --type stream --visibility open --ttl 3600 # ephemeral, archived after 1h idle\n buzz channels create --name project-x --template \"Buzz Team\" # type/visibility/canvas/roster from the template; explicit flags override\n buzz channels create --name notes --type stream --visibility private --unlisted # never listed, even after it is made open" )] Create { /// Channel name @@ -682,6 +682,13 @@ pub enum ChannelsCmd { /// many seconds pass without a new message. #[arg(long, value_name = "SECONDS")] ttl: Option, + /// Create the channel unlisted: left out of channel lists for people + /// who are not members. Anyone can still read and join an unlisted + /// open channel by its id. Change it later with `channels update + /// --unlisted`. Refused if the relay does not advertise + /// `buzz-unlisted-channels`. + #[arg(long)] + unlisted: bool, /// Apply a desktop-local channel template by name (case-insensitive): /// supplies default type/visibility/description/canvas, and resolves /// its agent roster against the relay to add as members. @@ -692,9 +699,9 @@ pub enum ChannelsCmd { #[arg(long, value_name = "PATH")] templates_file: Option, }, - /// Update channel name, description, visibility, join role, or ephemeral TTL + /// Update channel name, description, visibility, join role, unlisted, or ephemeral TTL #[command( - after_help = "Examples:\n buzz channels update --channel --name general\n buzz channels update --channel --visibility open\n buzz channels update --channel --visibility private\n buzz channels update --channel --join-role guest" + after_help = "Examples:\n buzz channels update --channel --name general\n buzz channels update --channel --visibility open\n buzz channels update --channel --visibility private\n buzz channels update --channel --join-role guest\n buzz channels update --channel --visibility open --unlisted true" )] Update { /// Channel UUID @@ -721,6 +728,13 @@ pub enum ChannelsCmd { /// but not write. Does not change existing members. Owner/admin only. #[arg(long, value_enum)] join_role: Option, + /// Leave the channel out of channel lists for people who are not + /// members (`true`), or list it again (`false`). Anyone can still read + /// and join an unlisted open channel by its id. Matters only while the + /// channel is open. Owner/admin only. `true` is refused if the relay + /// does not advertise `buzz-unlisted-channels`. + #[arg(long, value_name = "BOOL", action = clap::ArgAction::Set)] + unlisted: Option, }, /// Set the channel topic Topic { diff --git a/crates/buzz-db/src/runtime/migration.rs b/crates/buzz-db/src/runtime/migration.rs index f1c804cf4f..9caa4aa7e9 100644 --- a/crates/buzz-db/src/runtime/migration.rs +++ b/crates/buzz-db/src/runtime/migration.rs @@ -772,7 +772,12 @@ mod postgres_tests { let mut migrations: Vec<_> = MIGRATOR.iter().collect(); migrations.sort_by_key(|migration| migration.version); - assert_eq!(migrations.len(), 70); + assert_eq!(migrations.len(), 71); + assert_eq!(migrations[70].version, 71); + assert!(migrations[70] + .sql + .as_str() + .contains("ADD COLUMN unlisted BOOLEAN NOT NULL DEFAULT FALSE")); assert_eq!(migrations[69].version, 70); assert_eq!(migrations[68].version, 69); assert_eq!(migrations[67].version, 68); @@ -2743,7 +2748,8 @@ mod postgres_tests { /// test-relay launchers take — and migrates another through the latest /// migration, then asserts the admin and entity-intake tables have identical /// column definitions (name, type, nullability, default) and index shapes, including each - /// key's catalog sort/null options (`pg_index.indoption`). Columns are keyed + /// key's catalog sort/null options (`pg_index.indoption`), and that the + /// `channels` columns match. Columns are keyed /// by name, not ordinal, because migrations append via `ALTER TABLE` while /// `schema.sql` declares them inline — positions legitimately differ, shapes /// must not. Entity-intake constraints are also compared in the live catalog. @@ -2910,6 +2916,15 @@ mod postgres_tests { migration and schema.sql must both use a representable shape." ); } + // `channels` columns only: pgschema re-emits `idx_channels_id_live` + // without its `INCLUDE (community_id)`, an index drift that predates + // this check. + assert_eq!( + columns(&desired, "channels").await, + columns(&migrated, "channels").await, + "column parity mismatch for channels: schema.sql desired state has drifted \ + from the migrations; update schema/tables/public/channels.sql to match" + ); assert_eq!( events_key_defs(&desired).await, events_key_defs(&migrated).await, diff --git a/crates/buzz-db/src/store/admission_postgres_tests.rs b/crates/buzz-db/src/store/admission_postgres_tests.rs index 6b04723302..c5b23f41e6 100644 --- a/crates/buzz-db/src/store/admission_postgres_tests.rs +++ b/crates/buzz-db/src/store/admission_postgres_tests.rs @@ -435,6 +435,7 @@ async fn channel_and_membership_writers_require_application_admission() { &creator, None, &[], + false, ) .await .expect_err("channel create must reject"), @@ -467,6 +468,7 @@ async fn channel_and_membership_writers_require_application_admission() { &creator, None, &[], + false, ) .await .expect("active channel create succeeds"); @@ -538,6 +540,7 @@ async fn thread_metadata_writer_requires_application_admission() { &[1; 32], None, &[], + false, ) .await .expect("active channel"); @@ -585,6 +588,7 @@ async fn relay_admin_domain_writer_requires_application_admission() { &[1; 32], None, &[], + false, ) .await .expect("active channel"); @@ -633,6 +637,7 @@ async fn personal_read_writer_requires_application_admission() { &actor.public_key().to_bytes(), None, &[], + false, ) .await .expect("active channel"); diff --git a/crates/buzz-db/src/store/artifact_postgres_tests.rs b/crates/buzz-db/src/store/artifact_postgres_tests.rs index a6ea1d0e09..97534792e7 100644 --- a/crates/buzz-db/src/store/artifact_postgres_tests.rs +++ b/crates/buzz-db/src/store/artifact_postgres_tests.rs @@ -300,6 +300,61 @@ async fn system_channel_artifacts_are_read_only_by_name() { } } +/// An artifact in an unlisted open channel is read by a non-member only +/// through a query whose `#h` names that channel; a member reads it as usual. +/// Mutation: drop the unlisted condition from `query_artifacts` → RED. +#[tokio::test] +#[ignore = "requires Postgres"] +async fn unlisted_channel_artifacts_are_read_by_name_for_non_members() { + let f = Fixture::new().await; + let unlisted = Uuid::new_v4(); + sqlx::query("INSERT INTO channels(community_id,id,name,visibility,channel_type,created_by,unlisted) VALUES($1,$2,$3,'open','stream',$4,TRUE)") + .bind(f.community.as_uuid()) + .bind(unlisted) + .bind(unlisted.to_string()) + .bind(f.owner.public_key().to_bytes().as_slice()) + .execute(&f.db.pool) + .await + .unwrap(); + sqlx::query( + "INSERT INTO channel_members(community_id,channel_id,pubkey,role) VALUES($1,$2,$3,'owner')", + ) + .bind(f.community.as_uuid()) + .bind(unlisted) + .bind(f.owner.public_key().to_bytes().as_slice()) + .execute(&f.db.pool) + .await + .unwrap(); + for home in [f.a, unlisted] { + let create = f.revision(Uuid::new_v4(), "create", home, None, &f.owner, vec![]); + assert!(matches!( + f.accept(&create, None).await, + ArtifactOutcome::Accepted(_) + )); + } + let unnamed = serde_json::json!({"artifact":"current"}); + let named = serde_json::json!({"artifact":"current","#h":[unlisted.to_string()]}); + let homes = |events: Vec| { + let mut homes: Vec<_> = events.iter().map(|e| e.channel_id).collect(); + homes.sort(); + homes + }; + + // The member reads both without naming either. + let (events, _) = f.query(&f.owner, unnamed.clone(), false).await; + let mut both = vec![Some(f.a), Some(unlisted)]; + both.sort(); + assert_eq!(homes(events), both, "member without #h"); + + // A non-member reads the unlisted one only by naming it. + let (events, _) = f.query(&f.peer, unnamed.clone(), false).await; + assert_eq!(homes(events), [Some(f.a)], "non-member without #h"); + assert_eq!(f.count(&f.peer, unnamed).await, 1); + let (events, _) = f.query(&f.peer, named.clone(), false).await; + assert_eq!(homes(events), [Some(unlisted)], "non-member #h names it"); + assert_eq!(f.count(&f.peer, named).await, 1); +} + /// An artifact in a channel of a type added later is read only by a query /// whose `#h` names that channel, like a system channel (NIP-CL). /// Mutation: put back `c.channel_type <> 'system'` in `query_artifacts` → RED. diff --git a/crates/buzz-db/src/store/artifact_query.rs b/crates/buzz-db/src/store/artifact_query.rs index c0658a0b68..f359726d92 100644 --- a/crates/buzz-db/src/store/artifact_query.rs +++ b/crates/buzz-db/src/store/artifact_query.rs @@ -36,8 +36,9 @@ impl Db { if query.view == ArtifactView::Current { qb.push("JOIN artifact_heads a ON a.community_id=e.community_id AND a.event_id=e.id AND NOT a.deleted "); } - // A system channel is read only by a query whose `#h` names it - // (NIP-CL); malformed `#h` values name no channel. + // A system channel, or an unlisted open channel the reader is not a + // member of, is read only by a query whose `#h` names it (NIP-CL); + // malformed `#h` values name no channel. let named_channels: Vec = query .tags .iter() @@ -49,8 +50,10 @@ impl Db { qb.push("WHERE e.community_id=").push_bind(community.as_uuid()).push(" AND e.kind=45010 AND e.deleted_at IS NULL AND EXISTS (SELECT 1 FROM channels c WHERE c.community_id=e.community_id AND c.id=e.channel_id AND c.deleted_at IS NULL AND (") .push(crate::store::channel::legacy_channel_type_sql("c.channel_type")) .push(" OR c.id=ANY(") + .push_bind(named_channels.clone()) + .push(")) AND ((c.visibility='open' AND (NOT c.unlisted OR c.id=ANY(") .push_bind(named_channels) - .push(")) AND (c.visibility='open' OR EXISTS (SELECT 1 FROM channel_members m WHERE m.community_id=c.community_id AND m.channel_id=c.id AND m.pubkey=") + .push("))) OR EXISTS (SELECT 1 FROM channel_members m WHERE m.community_id=c.community_id AND m.channel_id=c.id AND m.pubkey=") .push_bind(reader.to_vec()).push(" AND m.removed_at IS NULL)))"); for (name, values) in &query.tags { // GIN-usable necessary prefilter; retain the positional recheck diff --git a/crates/buzz-db/src/store/channel.rs b/crates/buzz-db/src/store/channel.rs index 6b548da4f9..e577113ef9 100644 --- a/crates/buzz-db/src/store/channel.rs +++ b/crates/buzz-db/src/store/channel.rs @@ -84,6 +84,11 @@ pub struct ChannelRecord { /// The role a new person gets (`"member"` or `"guest"`). Parse with /// [`buzz_core::channel::ChannelJoinRole::parse_fail_closed`]. pub join_role: String, + /// Whether the channel is left out of channel listings for non-members. + /// It matters only while the channel is open: anyone can still read and + /// join an unlisted open channel by naming it, and private channels are + /// never listed to non-members. + pub unlisted: bool, } impl ChannelRecord { @@ -109,6 +114,7 @@ pub async fn create_channel( created_by: &[u8], ttl_seconds: Option, labels: &[String], + unlisted: bool, ) -> Result { if created_by.len() != 32 { return Err(DbError::InvalidData(format!( @@ -133,9 +139,9 @@ pub async fn create_channel( sqlx::query( r#" - INSERT INTO channels (id, community_id, name, channel_type, visibility, description, created_by, ttl_seconds, ttl_deadline, labels) + INSERT INTO channels (id, community_id, name, channel_type, visibility, description, created_by, ttl_seconds, ttl_deadline, labels, unlisted) VALUES ($1, $2, $3, $4::channel_type, $5::channel_visibility, $6, $7, $8, - CASE WHEN $8 IS NOT NULL THEN NOW() + ($8 || ' seconds')::interval ELSE NULL END, $9) + CASE WHEN $8 IS NOT NULL THEN NOW() + ($8 || ' seconds')::interval ELSE NULL END, $9, $10) "#, ) .bind(id) @@ -147,6 +153,7 @@ pub async fn create_channel( .bind(created_by) .bind(ttl_seconds) .bind(labels) + .bind(unlisted) .execute(tx.conn()) .await?; @@ -175,7 +182,7 @@ pub async fn create_channel( nip29_group_id, topic_required, max_members, topic, topic_set_by, topic_set_at, purpose, purpose_set_by, purpose_set_at, - ttl_seconds, ttl_deadline, labels, join_role + ttl_seconds, ttl_deadline, labels, join_role, unlisted FROM channels WHERE community_id = $1 AND id = $2 "#, ) @@ -224,6 +231,7 @@ pub async fn create_channel_with_id( created_by, ttl_seconds, labels, + unlisted: false, members: &[], }, ) @@ -237,7 +245,7 @@ pub async fn create_channel_with_id( nip29_group_id, topic_required, max_members, topic, topic_set_by, topic_set_at, purpose, purpose_set_by, purpose_set_at, - ttl_seconds, ttl_deadline, labels, join_role + ttl_seconds, ttl_deadline, labels, join_role, unlisted FROM channels WHERE community_id = $1 AND id = $2 "#, ) @@ -267,6 +275,8 @@ pub struct NewChannel<'a> { pub ttl_seconds: Option, /// Labels validated by `buzz_core::channel::parse_channel_labels`. pub labels: &'a [String], + /// Leave the channel out of listings for non-members from the start. + pub unlisted: bool, /// 32-byte pubkeys added as ordinary members invited by the creator. The /// caller has authorized each addition. pub members: &'a [Vec], @@ -340,6 +350,7 @@ async fn insert_channel_rows( created_by, ttl_seconds, labels, + unlisted, members, } = channel; let community_id = tx.community(); @@ -363,9 +374,9 @@ async fn insert_channel_rows( let rows_affected = sqlx::query( r#" - INSERT INTO channels (id, community_id, name, channel_type, visibility, description, created_by, ttl_seconds, ttl_deadline, labels) + INSERT INTO channels (id, community_id, name, channel_type, visibility, description, created_by, ttl_seconds, ttl_deadline, labels, unlisted) VALUES ($1, $2, $3, $4::channel_type, $5::channel_visibility, $6, $7, $8, - CASE WHEN $8 IS NOT NULL THEN NOW() + ($8 || ' seconds')::interval ELSE NULL END, $9) + CASE WHEN $8 IS NOT NULL THEN NOW() + ($8 || ' seconds')::interval ELSE NULL END, $9, $10) ON CONFLICT (community_id, id) DO NOTHING "#, ) @@ -378,6 +389,7 @@ async fn insert_channel_rows( .bind(created_by) .bind(ttl_seconds) .bind(labels) + .bind(unlisted) .execute(tx.conn()) .await? .rows_affected(); @@ -476,7 +488,7 @@ async fn get_channel_with_operation( nip29_group_id, topic_required, max_members, topic, topic_set_by, topic_set_at, purpose, purpose_set_by, purpose_set_at, - ttl_seconds, ttl_deadline, labels, join_role + ttl_seconds, ttl_deadline, labels, join_role, unlisted FROM channels WHERE community_id = $1 AND id = $2 AND deleted_at IS NULL "#, ) @@ -565,7 +577,7 @@ async fn list_channels_with_operation( nip29_group_id, topic_required, max_members, topic, topic_set_by, topic_set_at, purpose, purpose_set_by, purpose_set_at, - ttl_seconds, ttl_deadline, labels, join_role + ttl_seconds, ttl_deadline, labels, join_role, unlisted FROM channels WHERE community_id = $1 AND deleted_at IS NULL AND visibility::text = $2 ORDER BY created_at DESC @@ -585,7 +597,7 @@ async fn list_channels_with_operation( nip29_group_id, topic_required, max_members, topic, topic_set_by, topic_set_at, purpose, purpose_set_by, purpose_set_at, - ttl_seconds, ttl_deadline, labels, join_role + ttl_seconds, ttl_deadline, labels, join_role, unlisted FROM channels WHERE community_id = $1 AND deleted_at IS NULL ORDER BY created_at DESC @@ -653,6 +665,8 @@ pub(crate) fn row_to_channel_record(row: sqlx::postgres::PgRow) -> Result Result Vec> { let tag = |parts: &[&str]| parts.iter().map(|p| (*p).to_owned()).collect::>(); let mut tags = vec![ @@ -690,6 +707,9 @@ pub fn group_metadata_tags(channel: &ChannelRecord, members: &[MemberRecord]) -> } else { tags.push(tag(&["private"])); } + if channel.unlisted { + tags.push(tag(&["unlisted"])); + } let join_role = ChannelJoinRole::parse_fail_closed(&channel.join_role); if !open || join_role == ChannelJoinRole::Guest { tags.push(tag(&["restricted"])); @@ -766,6 +786,9 @@ pub struct ChannelUpdate { pub labels: Option>, /// New join role (`"member"`/`"guest"`), or `None` to leave unchanged. pub join_role: Option, + /// New unlisted flag, or `None` to leave unchanged. It decides listing, + /// not admission, so it does not take the membership lock. + pub unlisted: Option, } impl ChannelUpdate { @@ -777,6 +800,7 @@ impl ChannelUpdate { && self.ttl_seconds.is_none() && self.labels.is_none() && self.join_role.is_none() + && self.unlisted.is_none() } /// True when the update changes a setting that decides admission: @@ -1013,6 +1037,10 @@ async fn update_channel_on( set_parts.push(format!("join_role = ${param_idx}")); param_idx += 1; } + if updates.unlisted.is_some() { + set_parts.push(format!("unlisted = ${param_idx}")); + param_idx += 1; + } if let Some(ref ttl) = updates.ttl_seconds { // Set ttl_seconds, then reset the deadline from now (or clear both). set_parts.push(format!("ttl_seconds = ${param_idx}")); @@ -1048,6 +1076,9 @@ async fn update_channel_on( if let Some(ref join_role) = updates.join_role { q = q.bind(join_role); } + if let Some(unlisted) = updates.unlisted { + q = q.bind(unlisted); + } if let Some(ref ttl) = updates.ttl_seconds { q = q.bind(*ttl); } @@ -1488,6 +1519,7 @@ impl Db { created_by, ttl_seconds, &[], + false, ) .await } @@ -1575,7 +1607,8 @@ impl Db { .await } - /// Creates a channel with a relay-chosen UUID and an initial label set. + /// Creates a channel with a relay-chosen UUID, an initial label set and + /// an initial unlisted flag. #[allow(clippy::too_many_arguments)] #[datastore_span(name = "create_labeled_channel", system = "postgresql")] pub async fn create_labeled_channel( @@ -1588,6 +1621,7 @@ impl Db { created_by: &[u8], ttl_seconds: Option, labels: &[String], + unlisted: bool, ) -> Result { create_channel( &self.pool, @@ -1599,6 +1633,7 @@ impl Db { created_by, ttl_seconds, labels, + unlisted, ) .await } @@ -1784,6 +1819,7 @@ mod group_metadata_tag_tests { ttl_deadline: None, labels: Vec::new(), join_role: join_role.into(), + unlisted: false, } } @@ -1867,6 +1903,23 @@ mod group_metadata_tag_tests { assert_eq!(identity, expected.iter().collect::>()); } + /// `unlisted` is a bare flag, published as stored for either visibility, + /// and absent for a listed channel. It never adds NIP-29 `hidden`: an + /// unlisted channel's metadata stays readable by id. + #[test] + fn publishes_the_unlisted_flag_as_stored() { + let unlisted = vec!["unlisted".to_string()]; + let hidden = vec!["hidden".to_string()]; + for visibility in ["open", "private"] { + let mut channel = channel(visibility, "member"); + assert!(!group_metadata_tags(&channel, &[]).contains(&unlisted)); + channel.unlisted = true; + let tags = group_metadata_tags(&channel, &[]); + assert!(tags.contains(&unlisted), "{visibility}"); + assert!(!tags.contains(&hidden), "{visibility}"); + } + } + /// Private system channels are hidden, like DMs; open ones are not. #[test] fn hides_private_system_channels() { @@ -2218,6 +2271,7 @@ mod postgres_tests { created_by, ttl_seconds: None, labels: &[], + unlisted: false, members, } } diff --git a/crates/buzz-db/src/store/channel_members.rs b/crates/buzz-db/src/store/channel_members.rs index 34340b4544..0922be5948 100644 --- a/crates/buzz-db/src/store/channel_members.rs +++ b/crates/buzz-db/src/store/channel_members.rs @@ -1396,14 +1396,16 @@ pub async fn get_members_bulk( /// Get all channel IDs accessible to a pubkey. /// -/// Includes channels where the pubkey is an active member AND all open channels. -/// Open channels must be included in REQ filter resolution. +/// Includes channels where the pubkey is an active member AND all listed open +/// channels. Open channels must be included in REQ filter resolution. /// /// Only legacy channel types are in either half /// ([`buzz_core::channel::LEGACY_CHANNEL_TYPES`]): this is the implicit scope /// of queries that do not name a channel, so system channels and any type -/// added later stay out. A query that names such a channel, or opts in to -/// system channels, adds them from [`get_readable_named_only_channels`]. +/// added later stay out. Unlisted open channels stay out of the open half for +/// the same reason; a member still has them through the member half. A query +/// that names such a channel, or opts in to system channels, adds them from +/// [`get_readable_named_only_channels`]. pub async fn get_accessible_channel_ids( pool: &PgPool, community_id: CommunityId, @@ -1424,7 +1426,7 @@ pub async fn get_accessible_channel_ids( UNION SELECT id AS channel_id FROM channels - WHERE community_id = $1 AND visibility = 'open' AND deleted_at IS NULL + WHERE community_id = $1 AND visibility = 'open' AND NOT unlisted AND deleted_at IS NULL AND {open_types} "#, member_types = crate::store::channel::legacy_channel_type_sql("c.channel_type"), @@ -1445,19 +1447,40 @@ pub async fn get_accessible_channel_ids( .collect() } -/// The channels of non-legacy types (system channels, and any type added -/// later) that a pubkey can read: those where it is an active member, and -/// every open one. Each comes with its type. Uncached, so a caller can trust -/// it for a channel the request names. +/// A channel that [`get_readable_named_only_channels`] found readable. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ReadableNamedOnlyChannel { + /// The channel id. + pub id: Uuid, + /// Its channel type, as text. + pub channel_type: String, + /// Whether the system-channel opt-in (`#t:["system"]` or `#P`) may add + /// it. False for an unlisted channel the reader is not a member of: only + /// a filter that names it reads it. + pub opt_in: bool, +} + +/// The channels that a pubkey can read but that [`get_accessible_channel_ids`] +/// leaves out: +/// - channels of non-legacy types (system channels, and any type added later) +/// where it is an active member, and every open one that is listed or among +/// `named`, the channels the request names; +/// - each unlisted open channel of a legacy type among `named`. A member of +/// one also has it through [`get_accessible_channel_ids`]; listing it here +/// as well is harmless, since a filter's scope is deduplicated. /// -/// These are the channels that [`get_accessible_channel_ids`] leaves out. A +/// Uncached, so a caller can trust it for a channel the request names. A /// read reaches one only by naming it, or, for system channels, by opting in -/// to them. +/// to them. The opt-in needs every listed system channel, so those are all +/// loaded; an unlisted channel is loaded only when named, since a community +/// can have many, and it is marked so the opt-in never adds it for a +/// non-member ([`ReadableNamedOnlyChannel::opt_in`]). pub async fn get_readable_named_only_channels( pool: &PgPool, community_id: CommunityId, pubkey: &[u8], -) -> Result> { + named: &[Uuid], +) -> Result> { let mut connection = crate::observability::acquire_writer( pool, crate::observability::WriterOperation::Authorization, @@ -1465,30 +1488,44 @@ pub async fn get_readable_named_only_channels( .await?; let sql = format!( r#" - SELECT c.id AS channel_id, c.channel_type::text AS channel_type + SELECT c.id AS channel_id, c.channel_type::text AS channel_type, + (NOT c.unlisted OR {is_member}) AS opt_in FROM channels c WHERE c.community_id = $1 AND c.deleted_at IS NULL - AND NOT {legacy_types} AND ( - c.visibility = 'open' - OR EXISTS ( - SELECT 1 FROM channel_members cm - WHERE cm.community_id = c.community_id AND cm.channel_id = c.id - AND cm.pubkey = $2 AND cm.removed_at IS NULL + ( + NOT {legacy_types} + AND ( + (c.visibility = 'open' AND (NOT c.unlisted OR c.id = ANY($3))) + OR {is_member} + ) ) + OR ({legacy_types} AND c.visibility = 'open' AND c.unlisted AND c.id = ANY($3)) ) "#, legacy_types = crate::store::channel::legacy_channel_type_sql("c.channel_type"), + is_member = "EXISTS ( + SELECT 1 FROM channel_members cm + WHERE cm.community_id = c.community_id AND cm.channel_id = c.id + AND cm.pubkey = $2 AND cm.removed_at IS NULL + )", ); - // Built only from the constant type list. + // Built only from constants. let rows = sqlx::query(sqlx::AssertSqlSafe(sql)) .bind(community_id.as_uuid()) .bind(pubkey) + .bind(named) .fetch_all(&mut *connection) .await?; rows.into_iter() - .map(|r| Ok((r.try_get("channel_id")?, r.try_get("channel_type")?))) + .map(|r| { + Ok(ReadableNamedOnlyChannel { + id: r.try_get("channel_id")?, + channel_type: r.try_get("channel_type")?, + opt_in: r.try_get("opt_in")?, + }) + }) .collect() } @@ -1663,7 +1700,7 @@ async fn get_channel_tx( nip29_group_id, topic_required, max_members, topic, topic_set_by, topic_set_at, purpose, purpose_set_by, purpose_set_at, - ttl_seconds, ttl_deadline, labels, join_role + ttl_seconds, ttl_deadline, labels, join_role, unlisted FROM channels WHERE community_id = $1 AND id = $2 AND deleted_at IS NULL "#, ) @@ -1722,7 +1759,8 @@ pub struct AccessibleChannel { } /// Returns full channel records for all channels a user can access: -/// open channels (visible to everyone) plus channels where the user is an active member. +/// listed open channels (visible to everyone) plus channels where the user is +/// an active member. An unlisted open channel is returned only to its members. /// /// Uses a LEFT JOIN on channel_members (PK: channel_id + pubkey) which produces at /// most one row per channel. Results are ordered stream -> forum -> dm, then by name. @@ -1748,7 +1786,8 @@ pub async fn get_accessible_channels( let membership_clause = if member_only == Some(true) { "AND cm.channel_id IS NOT NULL" } else { - "AND (c.visibility = 'open' OR cm.channel_id IS NOT NULL)" + // Unlisted open channels are listed to their members only. + "AND ((c.visibility = 'open' AND NOT c.unlisted) OR cm.channel_id IS NOT NULL)" }; let base = format!( @@ -1759,7 +1798,7 @@ pub async fn get_accessible_channels( c.nip29_group_id, c.topic_required, c.max_members, c.topic, c.topic_set_by, c.topic_set_at, c.purpose, c.purpose_set_by, c.purpose_set_at, - c.ttl_seconds, c.ttl_deadline, c.labels, c.join_role, + c.ttl_seconds, c.ttl_deadline, c.labels, c.join_role, c.unlisted, (cm.channel_id IS NOT NULL) AS is_member FROM channels c LEFT JOIN channel_members cm @@ -2209,15 +2248,17 @@ impl Db { get_accessible_channel_ids(&self.pool, community_id, pubkey).await } - /// The channels of non-legacy types a pubkey can read (member, or open), - /// each with its type. + /// The channels of non-legacy types a pubkey can read (member, or open + /// and listed or among `named`), and the unlisted open channels among + /// `named`. See [`get_readable_named_only_channels`]. #[datastore_span(name = "get_readable_named_only_channels", system = "postgresql")] pub async fn get_readable_named_only_channels( &self, community_id: CommunityId, pubkey: &[u8], - ) -> Result> { - get_readable_named_only_channels(&self.pool, community_id, pubkey).await + named: &[Uuid], + ) -> Result> { + get_readable_named_only_channels(&self.pool, community_id, pubkey, named).await } /// The type of channel `channel_id`, deleted or not, or `None` when no diff --git a/crates/buzz-db/src/store/dm.rs b/crates/buzz-db/src/store/dm.rs index 860584aec4..0c86e13f05 100644 --- a/crates/buzz-db/src/store/dm.rs +++ b/crates/buzz-db/src/store/dm.rs @@ -76,7 +76,7 @@ pub async fn find_dm_by_participants( created_by, created_at, updated_at, archived_at, deleted_at, nip29_group_id, topic_required, max_members, topic, topic_set_by, topic_set_at, - purpose, purpose_set_by, purpose_set_at, labels, join_role + purpose, purpose_set_by, purpose_set_at, labels, join_role, unlisted FROM channels WHERE community_id = $1 AND participant_hash = $2 @@ -142,7 +142,7 @@ pub async fn create_dm( created_by, created_at, updated_at, archived_at, deleted_at, nip29_group_id, topic_required, max_members, topic, topic_set_by, topic_set_at, - purpose, purpose_set_by, purpose_set_at, labels, join_role + purpose, purpose_set_by, purpose_set_at, labels, join_role, unlisted FROM channels WHERE community_id = $1 AND participant_hash = $2 @@ -212,7 +212,7 @@ pub async fn create_dm( created_by, created_at, updated_at, archived_at, deleted_at, nip29_group_id, topic_required, max_members, topic, topic_set_by, topic_set_at, - purpose, purpose_set_by, purpose_set_at, labels, join_role + purpose, purpose_set_by, purpose_set_at, labels, join_role, unlisted FROM channels WHERE community_id = $1 AND id = $2 "#, ) @@ -534,6 +534,7 @@ fn row_to_channel_record(row: sqlx::postgres::PgRow) -> Result { ttl_deadline: row.try_get("ttl_deadline").unwrap_or(None), labels: row.try_get("labels")?, join_role: row.try_get("join_role")?, + unlisted: row.try_get("unlisted")?, }) } diff --git a/crates/buzz-db/src/store/mod.rs b/crates/buzz-db/src/store/mod.rs index da1bdba444..f3ea643ba4 100644 --- a/crates/buzz-db/src/store/mod.rs +++ b/crates/buzz-db/src/store/mod.rs @@ -88,5 +88,8 @@ mod artifact_postgres_tests; #[cfg(test)] mod legacy_channel_types_postgres_tests; +#[cfg(test)] +mod unlisted_channels_postgres_tests; + #[cfg(test)] mod admission_postgres_tests; diff --git a/crates/buzz-db/src/store/unlisted_channels_postgres_tests.rs b/crates/buzz-db/src/store/unlisted_channels_postgres_tests.rs new file mode 100644 index 0000000000..a47647ce3b --- /dev/null +++ b/crates/buzz-db/src/store/unlisted_channels_postgres_tests.rs @@ -0,0 +1,422 @@ +//! An unlisted open channel is left out of the reads that list channels to +//! non-members, but stays readable and joinable by id. Members still list it. +//! The flag is set through the real settings write (`update_channel`). + +use crate::channel::ChannelUpdate; +use crate::Db; +use buzz_core::channel::{ChannelType, ChannelVisibility}; +use buzz_core::CommunityId; +use nostr::Keys; +use sqlx::PgPool; +use uuid::Uuid; + +struct Fixture { + db: Db, + community: CommunityId, + owner: Keys, + outsider: Keys, +} + +impl Fixture { + async fn new() -> Self { + let pool = PgPool::connect(&crate::test_support::database_url()) + .await + .expect("connect to test DB"); + if std::env::var("BUZZ_TEST_SCHEMA_MODE").as_deref() != Ok("desired") { + crate::migration::run_migrations(&pool) + .await + .expect("migrate test DB"); + } + let community = CommunityId::from_uuid(Uuid::new_v4()); + sqlx::query("INSERT INTO communities(id,host) VALUES($1,$2)") + .bind(community.as_uuid()) + .bind(format!("unlisted-{}.test", community.as_uuid())) + .execute(&pool) + .await + .expect("insert community"); + Self { + db: Db::from_pool(pool), + community, + owner: Keys::generate(), + outsider: Keys::generate(), + } + } + + /// A channel created by `owner`, who becomes its owner member. + async fn channel(&self, name: &str, visibility: ChannelVisibility) -> Uuid { + self.typed_channel(name, ChannelType::Stream, visibility) + .await + } + + /// [`Self::channel`] of `channel_type`. + async fn typed_channel( + &self, + name: &str, + channel_type: ChannelType, + visibility: ChannelVisibility, + ) -> Uuid { + self.db + .create_channel( + self.community, + name, + channel_type, + visibility, + None, + &self.owner.public_key().to_bytes(), + None, + ) + .await + .expect("create channel") + .id + } + + async fn set_unlisted(&self, channel: Uuid, unlisted: bool) -> crate::channel::ChannelRecord { + self.db + .update_channel( + self.community, + channel, + ChannelUpdate { + unlisted: Some(unlisted), + ..Default::default() + }, + ) + .await + .expect("set unlisted") + } + + async fn implicit(&self, reader: &Keys) -> Vec { + self.db + .get_accessible_channel_ids(self.community, &reader.public_key().to_bytes()) + .await + .expect("accessible channel ids") + } + + async fn listed(&self, reader: &Keys) -> Vec { + self.db + .get_accessible_channels(self.community, &reader.public_key().to_bytes(), None, None) + .await + .expect("accessible channels") + .into_iter() + .map(|channel| channel.channel.id) + .collect() + } + + /// The named-only channels `reader` can read when a request names + /// `named`, each with its type. + async fn named_only(&self, reader: &Keys, named: &[Uuid]) -> Vec<(Uuid, String)> { + self.readable(reader, named) + .await + .into_iter() + .map(|channel| (channel.id, channel.channel_type)) + .collect() + } + + /// The named-only channels `reader` can read when a request names + /// `named`, each with whether the system-channel opt-in may add it. + async fn opt_in(&self, reader: &Keys, named: &[Uuid]) -> Vec<(Uuid, bool)> { + self.readable(reader, named) + .await + .into_iter() + .map(|channel| (channel.id, channel.opt_in)) + .collect() + } + + async fn readable( + &self, + reader: &Keys, + named: &[Uuid], + ) -> Vec { + self.db + .get_readable_named_only_channels( + self.community, + &reader.public_key().to_bytes(), + named, + ) + .await + .expect("readable named-only channels") + } +} + +/// Mutation: drop `NOT unlisted` from `get_accessible_channel_ids` or +/// `get_accessible_channels`, or the unlisted branch from +/// `get_readable_named_only_channels` → RED. +#[tokio::test] +#[ignore = "requires Postgres"] +async fn unlisted_open_channel_is_named_only_for_non_members() { + let f = Fixture::new().await; + let listed = f.channel("listed", ChannelVisibility::Open).await; + let unlisted = f.channel("unlisted", ChannelVisibility::Open).await; + let record = f.set_unlisted(unlisted, true).await; + assert!(record.unlisted, "update_channel returns the new setting"); + assert!( + f.db.get_channel(f.community, unlisted) + .await + .expect("get channel") + .unlisted, + "the setting is stored" + ); + + // A non-member does not list it, but can name it. + let implicit = f.implicit(&f.outsider).await; + assert!(implicit.contains(&listed)); + assert!(!implicit.contains(&unlisted), "implicit scope"); + let shown = f.listed(&f.outsider).await; + assert!(shown.contains(&listed)); + assert!(!shown.contains(&unlisted), "channel list"); + let named = f.named_only(&f.outsider, &[listed, unlisted]).await; + assert!( + named.contains(&(unlisted, "stream".to_owned())), + "readable by name: {named:?}" + ); + assert!( + !named.iter().any(|(id, _)| *id == listed), + "a listed channel is not named-only" + ); + + // A member still lists it. + assert!(f.implicit(&f.owner).await.contains(&unlisted)); + assert!(f.listed(&f.owner).await.contains(&unlisted)); + + // Joining by id works as on any open channel (kind:9021 → put_member), + // and the joiner then lists it like any member. + let joined = + f.db.put_member( + f.community, + unlisted, + &f.outsider.public_key().to_bytes(), + None, + None, + ) + .await + .expect("self-join an unlisted open channel"); + assert_eq!(joined.role, "member"); + assert!(f.implicit(&f.outsider).await.contains(&unlisted)); + assert!(f.listed(&f.outsider).await.contains(&unlisted)); + + // Listing it again puts it back for everyone. + let stranger = Keys::generate(); + assert!(!f.implicit(&stranger).await.contains(&unlisted)); + assert!(!f.set_unlisted(unlisted, false).await.unlisted); + assert!(f.implicit(&stranger).await.contains(&unlisted)); + assert!(f.listed(&stranger).await.contains(&unlisted)); + assert!(!f + .named_only(&stranger, &[unlisted]) + .await + .iter() + .any(|(id, _)| *id == unlisted)); +} + +/// Only the unlisted channels a request names are loaded, so the lookup stays +/// small however many unlisted chats a community has. +/// Mutation: drop `c.id = ANY($3)` from the unlisted branch of +/// `get_readable_named_only_channels` → RED. +#[tokio::test] +#[ignore = "requires Postgres"] +async fn only_the_named_unlisted_channels_are_loaded() { + let f = Fixture::new().await; + let a = f.channel("a", ChannelVisibility::Open).await; + let b = f.channel("b", ChannelVisibility::Open).await; + f.set_unlisted(a, true).await; + f.set_unlisted(b, true).await; + + assert_eq!( + f.named_only(&f.outsider, &[a]).await, + [(a, "stream".to_owned())], + "naming A loads A and not B" + ); + assert!( + f.named_only(&f.outsider, &[]).await.is_empty(), + "naming nothing loads no unlisted channel" + ); + let both = f.named_only(&f.outsider, &[a, b]).await; + assert!(both.contains(&(a, "stream".to_owned()))); + assert!(both.contains(&(b, "stream".to_owned()))); +} + +/// An unlisted open system channel is read only by naming it, for a +/// non-member: the system-channel opt-in (`#t:["system"]`, `#P`) needs every +/// readable system channel, but never gets this one. Its members keep it as +/// before, and a listed open system channel is loaded and opted in to as +/// before. +/// Mutation: drop `NOT c.unlisted OR c.id = ANY($3)` from the system branch +/// of `get_readable_named_only_channels` → RED (naming nothing loads it); make +/// its `opt_in` always true → RED (the outsider may opt in to it). +#[tokio::test] +#[ignore = "requires Postgres"] +async fn an_unlisted_system_channel_is_read_only_by_name() { + let f = Fixture::new().await; + let listed = f + .typed_channel("listed", ChannelType::System, ChannelVisibility::Open) + .await; + let unlisted = f + .typed_channel("unlisted", ChannelType::System, ChannelVisibility::Open) + .await; + f.set_unlisted(unlisted, true).await; + + // A non-member that names nothing (an opt-in alone) gets only the listed one. + assert_eq!( + f.opt_in(&f.outsider, &[]).await, + [(listed, true)], + "opt-in discovery leaves the unlisted system channel out" + ); + // Naming it reads it, but the opt-in still may not add it. + let named = f.opt_in(&f.outsider, &[unlisted]).await; + assert_eq!(named.len(), 2, "{named:?}"); + assert!(named.contains(&(listed, true))); + assert!(named.contains(&(unlisted, false)), "readable by name only"); + + // Its member keeps it, for the opt-in too, whether or not it is named. + for names in [&[][..], &[unlisted][..]] { + let member = f.opt_in(&f.owner, names).await; + assert_eq!(member.len(), 2, "{member:?}"); + assert!(member.contains(&(listed, true))); + assert!(member.contains(&(unlisted, true)), "a member opts in to it"); + } + + // Listing it again puts it back in discovery. + f.set_unlisted(unlisted, false).await; + let relisted = f.opt_in(&f.outsider, &[]).await; + assert_eq!(relisted.len(), 2, "{relisted:?}"); + assert!(relisted.contains(&(unlisted, true))); +} + +/// The flag is stored on its own and matters only while the channel is open: +/// a private unlisted channel stays invisible and unreadable to non-members, +/// and opening it keeps it unlisted. +/// Mutation: drop `c.visibility = 'open'` from the unlisted branch of +/// `get_readable_named_only_channels` → RED. +#[tokio::test] +#[ignore = "requires Postgres"] +async fn unlisted_private_channel_stays_private_and_opens_unlisted() { + let f = Fixture::new().await; + let chat = f.channel("chat", ChannelVisibility::Private).await; + assert!(f.set_unlisted(chat, true).await.unlisted); + + assert!(!f.implicit(&f.outsider).await.contains(&chat)); + assert!(!f.listed(&f.outsider).await.contains(&chat)); + assert!( + !f.named_only(&f.outsider, &[chat]) + .await + .iter() + .any(|(id, _)| *id == chat), + "a private channel is never readable by name" + ); + assert!(f.implicit(&f.owner).await.contains(&chat)); + + let opened = + f.db.update_channel( + f.community, + chat, + ChannelUpdate { + visibility: Some("open".into()), + ..Default::default() + }, + ) + .await + .expect("open the channel"); + assert!(opened.unlisted, "opening keeps the stored flag"); + assert!(!f.implicit(&f.outsider).await.contains(&chat)); + assert!(f + .named_only(&f.outsider, &[chat]) + .await + .contains(&(chat, "stream".to_owned()))); +} + +/// kind:39000 publishes the flag from the stored row. +#[tokio::test] +#[ignore = "requires Postgres"] +async fn group_metadata_publishes_the_stored_unlisted_flag() { + let f = Fixture::new().await; + let chat = f.channel("chat", ChannelVisibility::Open).await; + let flag = vec!["unlisted".to_string()]; + let before = f.db.get_channel(f.community, chat).await.expect("channel"); + assert!(!crate::channel::group_metadata_tags(&before, &[]).contains(&flag)); + let after = f.set_unlisted(chat, true).await; + assert!(crate::channel::group_metadata_tags(&after, &[]).contains(&flag)); +} + +/// A channel can start unlisted (kind:9007 `["unlisted", "true"]`), through +/// both create paths: the event-backed create a client UUID takes, and the +/// relay-chosen-UUID create. It is never listed to non-members, even for a +/// moment, and stays named-only once it is opened. +/// Mutation: drop `unlisted` from either `INSERT INTO channels` → RED. +#[tokio::test] +#[ignore = "requires Postgres"] +async fn channel_created_unlisted_is_never_listed() { + let f = Fixture::new().await; + let owner = f.owner.public_key().to_bytes(); + + let with_event = Uuid::new_v4(); + let event = nostr::EventBuilder::new(nostr::Kind::Custom(9007), "") + .tags([ + nostr::Tag::parse(["h", &with_event.to_string()]).expect("h tag"), + nostr::Tag::parse(["name", "solo"]).expect("name tag"), + nostr::Tag::parse(["visibility", "open"]).expect("visibility tag"), + nostr::Tag::parse(["unlisted", "true"]).expect("unlisted tag"), + ]) + .sign_with_keys(&f.owner) + .expect("sign 9007"); + let created = + f.db.create_channel_with_event( + f.community, + with_event, + crate::channel::NewChannel { + name: "solo", + channel_type: ChannelType::Stream, + visibility: ChannelVisibility::Open, + description: None, + created_by: &owner, + ttl_seconds: None, + labels: &[], + unlisted: true, + members: &[], + }, + &event, + ) + .await + .expect("create with event"); + assert!(matches!(created, crate::channel::ChannelCreate::Created(_))); + + let relay_chosen = + f.db.create_labeled_channel( + f.community, + "solo-2", + ChannelType::Stream, + ChannelVisibility::Open, + None, + &owner, + None, + &[], + true, + ) + .await + .expect("create labeled channel") + .id; + + let listed = f.channel("listed", ChannelVisibility::Open).await; + assert!( + !f.db + .get_channel(f.community, listed) + .await + .expect("channel") + .unlisted, + "channels start listed by default" + ); + + for chat in [with_event, relay_chosen] { + assert!( + f.db.get_channel(f.community, chat) + .await + .expect("channel") + .unlisted, + "the setting is stored at creation" + ); + assert!(!f.implicit(&f.outsider).await.contains(&chat)); + assert!(!f.listed(&f.outsider).await.contains(&chat)); + assert!(f + .named_only(&f.outsider, &[chat]) + .await + .contains(&(chat, "stream".to_owned()))); + assert!(f.implicit(&f.owner).await.contains(&chat)); + } +} diff --git a/crates/buzz-relay/src/api/bridge/thread_window.rs b/crates/buzz-relay/src/api/bridge/thread_window.rs index 06436d8cc8..fc5989172b 100644 --- a/crates/buzz-relay/src/api/bridge/thread_window.rs +++ b/crates/buzz-relay/src/api/bridge/thread_window.rs @@ -100,17 +100,21 @@ async fn access_sets( .get_accessible_channel_ids(tenant.community(), &reader.to_bytes()) .await .map_err(|e| database_error(context, e))?; - let system = if requests + let mut named: Vec = requests .iter() - .any(|request| !implicit.contains(&request.channel)) - { + .map(|request| request.channel) + .filter(|channel| !implicit.contains(channel)) + .collect(); + named.sort_unstable(); + named.dedup(); + let system = if !named.is_empty() { state .db - .get_readable_named_only_channels(tenant.community(), &reader.to_bytes()) + .get_readable_named_only_channels(tenant.community(), &reader.to_bytes(), &named) .await .map_err(|e| database_error(context, e))? .into_iter() - .map(|(id, _)| id) + .map(|channel| channel.id) .collect() } else { Vec::new() diff --git a/crates/buzz-relay/src/api/workflows.rs b/crates/buzz-relay/src/api/workflows.rs index 1bc43dfa11..bd33493191 100644 --- a/crates/buzz-relay/src/api/workflows.rs +++ b/crates/buzz-relay/src/api/workflows.rs @@ -129,7 +129,7 @@ async fn authorize_workflow_read( let readable = accessible.contains(&channel_id) || state .db - .get_readable_named_only_channels(tenant.community(), &pubkey_bytes) + .get_readable_named_only_channels(tenant.community(), &pubkey_bytes, &[channel_id]) .await .map_err(|error| { internal_error(&format!( @@ -138,7 +138,7 @@ async fn authorize_workflow_read( .into_response() })? .iter() - .any(|(id, _)| *id == channel_id); + .any(|channel| channel.id == channel_id); if !readable { return Err(not_found()); } diff --git a/crates/buzz-relay/src/handlers/ingest.rs b/crates/buzz-relay/src/handlers/ingest.rs index 4985951019..f886b900b0 100644 --- a/crates/buzz-relay/src/handlers/ingest.rs +++ b/crates/buzz-relay/src/handlers/ingest.rs @@ -2526,6 +2526,7 @@ struct ChannelCreateRequest { description: Option, ttl_seconds: Option, labels: Vec, + unlisted: bool, members: Vec>, } @@ -3266,6 +3267,8 @@ async fn ingest_event_inner( .transpose() .map_err(|e| IngestError::Rejected(format!("invalid: {e}")))? .unwrap_or_default(); + let unlisted = super::side_effects::create_unlisted_from_tags(&event) + .map_err(|e| IngestError::Rejected(format!("invalid: {e}")))?; if let Some(client_uuid) = channel_id { let members = super::side_effects::initial_members(&event) @@ -3284,6 +3287,7 @@ async fn ingest_event_inner( }), ttl_seconds: super::resolve_ttl(&event, state.config.ephemeral_ttl_override), labels, + unlisted, members, }); } @@ -3546,6 +3550,7 @@ async fn ingest_event_inner( created_by: &creator, ttl_seconds: create.ttl_seconds, labels: &create.labels, + unlisted: create.unlisted, members: &create.members, }; match state diff --git a/crates/buzz-relay/src/handlers/side_effects.rs b/crates/buzz-relay/src/handlers/side_effects.rs index e0a8af8ecb..3fa7036b40 100644 --- a/crates/buzz-relay/src/handlers/side_effects.rs +++ b/crates/buzz-relay/src/handlers/side_effects.rs @@ -641,6 +641,7 @@ pub async fn validate_admin_event( "ttl", "t", "join_role", + "unlisted", ]; let has_recognized = event .tags @@ -648,22 +649,24 @@ pub async fn validate_admin_event( .any(|t| RECOGNIZED_TAGS.contains(&t.kind().to_string().as_str())); if !has_recognized { return Err(anyhow::anyhow!( - "kind:9002 must include at least one metadata tag (name, about, archived, topic, purpose, visibility, ttl, t, join_role)" + "kind:9002 must include at least one metadata tag (name, about, archived, topic, purpose, visibility, ttl, t, join_role, unlisted)" )); } - // Validate archived values before storage. + // Validate archived and unlisted values before storage. Both are + // partial updates, so turning one off must be explicit. for t in event.tags.iter() { - if t.kind().to_string() == "archived" { + let name = t.kind().to_string(); + if name == "archived" || name == "unlisted" { match t.content() { Some("true") | Some("false") => {} Some(v) => { return Err(anyhow::anyhow!( - "invalid archived value: {v} (must be \"true\" or \"false\")" + "invalid {name} value: {v} (must be \"true\" or \"false\")" )); } None => { - return Err(anyhow::anyhow!("archived tag must have a value")); + return Err(anyhow::anyhow!("{name} tag must have a value")); } } } @@ -751,7 +754,8 @@ pub async fn validate_admin_event( labels.map_err(|e| anyhow::anyhow!("{e}"))?; } - // name/about/archived/visibility/ttl/labels/join_role require owner/admin; + // name/about/archived/visibility/ttl/labels/join_role/unlisted + // require owner/admin; // topic/purpose allow any member who may post. let has_privileged_tag = event.tags.iter().any(|t| { let k = t.kind().to_string(); @@ -762,6 +766,7 @@ pub async fn validate_admin_event( || k == "ttl" || k == "t" || k == "join_role" + || k == "unlisted" }); if has_privileged_tag { let members = state.db.get_members(tenant.community(), channel_id).await?; @@ -783,7 +788,7 @@ pub async fn validate_admin_event( return Ok(()); } Err(anyhow::anyhow!( - "actor not authorized for name/about/archived/visibility/ttl/label/join_role changes" + "actor not authorized for name/about/archived/visibility/ttl/label/join_role/unlisted changes" )) } } @@ -2025,6 +2030,15 @@ pub(crate) async fn persist_edit_metadata( "false" => edit.archived = Some(false), _ => {} // refused during authorization }, + "unlisted" => { + // Validated during authorization; fail closed so a bad value + // can never reach the column. + edit.columns.unlisted = Some(match val { + "true" => true, + "false" => false, + _ => return Err(anyhow::anyhow!("invalid unlisted value: {val}")), + }); + } _ => {} } } @@ -2055,6 +2069,11 @@ pub(crate) async fn persist_edit_metadata( "type": "join_role_changed", "actor": actor_hex, "join_role": join_role })); } + if let Some(unlisted) = edit.columns.unlisted { + notices.push(serde_json::json!({ + "type": "unlisted_changed", "actor": actor_hex, "unlisted": unlisted + })); + } if let Some(ttl_seconds) = edit.columns.ttl_seconds { notices.push(serde_json::json!({ "type": "ttl_changed", "actor": actor_hex, "ttl_seconds": ttl_seconds @@ -2075,6 +2094,7 @@ pub(crate) async fn persist_edit_metadata( .collect::>>()?; let new_visibility = edit.columns.visibility.clone(); + let unlisted_changed = edit.columns.unlisted.is_some(); let unarchived = edit.archived == Some(false); let write = state .db @@ -2092,6 +2112,11 @@ pub(crate) async fn persist_edit_metadata( // The changes are committed. What follows only updates live state, so a // failure is logged; the next read sees the committed channel. + if unlisted_changed && new_visibility.is_none() { + // Unlisting changes which channels a read that names none can see; + // nobody loses access, so no subscription is evicted. + state.invalidate_all_accessible_channels(tenant); + } if let Some(visibility) = new_visibility.as_deref() { // A visibility flip changes who can see the channel, so the // accessible-channels and visibility caches must be cleared @@ -2200,6 +2225,27 @@ pub(crate) fn channel_labels_from_tags( } } +/// The kind:9007 `["unlisted", "true"|"false"]` setting: whether a new +/// channel starts out of channel listings for non-members. A missing tag means +/// listed. Any other value is refused, as kind:9002 refuses it; the last tag +/// wins, as in kind:9002. +pub(crate) fn create_unlisted_from_tags(event: &Event) -> anyhow::Result { + let mut unlisted = false; + for tag in event + .tags + .iter() + .filter(|tag| tag.kind().to_string() == "unlisted") + { + unlisted = match tag.content() { + Some("true") => true, + Some("false") => false, + Some(v) => anyhow::bail!("invalid unlisted value: {v} (must be \"true\" or \"false\")"), + None => anyhow::bail!("unlisted tag must have a value"), + }; + } + Ok(unlisted) +} + async fn handle_delete_event_side_effect( tenant: &TenantContext, event: &Event, @@ -2327,6 +2373,7 @@ async fn handle_create_group( .transpose() .map_err(|e| anyhow::anyhow!("{e}"))? .unwrap_or_default(); + let unlisted = create_unlisted_from_tags(event)?; // If the event has an h-tag UUID, ingest_event() already created the channel // via create_channel_with_id(). Fetch it rather than creating a duplicate. @@ -2361,6 +2408,7 @@ async fn handle_create_group( &actor_bytes, ttl_seconds, &labels, + unlisted, ) .await?; metrics::counter!( @@ -2384,6 +2432,7 @@ async fn handle_create_group( &actor_bytes, ttl_seconds, &labels, + unlisted, ) .await?; metrics::counter!( diff --git a/crates/buzz-relay/src/handlers/system_channel_postgres_tests.rs b/crates/buzz-relay/src/handlers/system_channel_postgres_tests.rs index 326c637a03..710fbdbe90 100644 --- a/crates/buzz-relay/src/handlers/system_channel_postgres_tests.rs +++ b/crates/buzz-relay/src/handlers/system_channel_postgres_tests.rs @@ -1217,6 +1217,110 @@ async fn system_channels_are_found_by_type_or_creator() { ); } +/// An unlisted open system channel is read only by naming it, for a +/// non-member: `#t:["system"]` and `#P` discovery leave it out, also when a +/// sibling filter in the same request names it, and naming it by `#h` or +/// `#d` reads it. Its members still find it by the opt-in (NIP-CL). +/// Mutation: make `opt_in` always true in `get_readable_named_only_channels`, +/// or ignore it in `NamedOnlyChannels::from_readable` → RED (the sibling of a +/// filter that names it finds it). The branch that loads it only when named +/// is covered in buzz-db (`an_unlisted_system_channel_is_read_only_by_name`). +#[tokio::test] +#[ignore = "requires Postgres and Redis"] +async fn unlisted_system_channels_are_found_only_by_name() { + let f = fixture().await; + let owner = Keys::generate(); + let outsider = Keys::generate(); + let owner_hex = owner.public_key().to_hex(); + + let listed = create_channel(&f.state, &f.tenant, &owner, &[&["channel_type", "system"]]).await; + let unlisted = + create_channel(&f.state, &f.tenant, &owner, &[&["channel_type", "system"]]).await; + edit_channel( + &f.state, + &f.tenant, + &owner, + listed, + &[&["visibility", "open"]], + ) + .await + .expect("open the listed system channel"); + edit_channel( + &f.state, + &f.tenant, + &owner, + unlisted, + &[&["visibility", "open"], &["unlisted", "true"]], + ) + .await + .expect("open the system channel unlisted"); + let h = unlisted.to_string(); + let message = publish(&f, &owner, 9, "hello", &[&["h", &h]]).await; + let listed_meta = group_state_id(&f, listed, 39000).await; + let unlisted_meta = group_state_id(&f, unlisted, 39000).await; + + // Opt-in discovery: members find both, outsiders only the listed one. + for opt_in in [ + json!({"kinds": [39000], "#t": ["system"]}), + json!({"kinds": [39000], "#P": [owner_hex]}), + ] { + assert_eq!( + sorted(req(&f, &owner, opt_in.clone()).await), + sorted(vec![listed_meta.clone(), unlisted_meta.clone()]), + "member {opt_in}" + ); + assert_eq!( + req(&f, &outsider, opt_in.clone()).await, + vec![listed_meta.clone()], + "outsider {opt_in}" + ); + assert_eq!( + count(&f, &outsider, opt_in.clone()).await, + 1, + "outsider COUNT {opt_in}" + ); + assert_eq!( + bridge_ids(bridge(&f, &outsider, "/query", json!([opt_in])).await), + vec![listed_meta.clone()], + "outsider bridge /query {opt_in}" + ); + } + + // Naming it reads it. + assert_eq!( + req(&f, &outsider, json!({"kinds": [9], "#h": [h]})).await, + vec![message.clone()], + "outsider #h" + ); + assert_eq!( + req(&f, &outsider, json!({"kinds": [39000], "#d": [h]})).await, + vec![unlisted_meta.clone()], + "outsider #d" + ); + + // A sibling that opts in does not gain the channel the first filter names. + let filters = json!([ + {"kinds": [9], "#h": [h]}, + {"kinds": [39000], "#t": ["system"]}, + ]); + let expected = sorted(vec![message, listed_meta]); + assert_eq!( + sorted(req_all(&f, &outsider, filters.clone()).await), + expected, + "outsider REQ with a naming sibling" + ); + assert_eq!( + count_all(&f, &outsider, filters.clone()).await, + 2, + "outsider COUNT with a naming sibling" + ); + assert_eq!( + sorted(bridge_ids(bridge(&f, &outsider, "/query", filters).await)), + expected, + "outsider bridge /query with a naming sibling" + ); +} + /// GET a workflow's runs as `reader`, returning the response status. async fn workflow_runs_status(f: &Fixture, reader: &Keys, workflow: Uuid) -> u16 { use axum::body::Body; diff --git a/crates/buzz-relay/src/handlers/system_channels.rs b/crates/buzz-relay/src/handlers/system_channels.rs index 13ad914fd3..9ff25ce20d 100644 --- a/crates/buzz-relay/src/handlers/system_channels.rs +++ b/crates/buzz-relay/src/handlers/system_channels.rs @@ -34,8 +34,10 @@ use crate::state::AppState; /// The named-only channels a request may add to a filter's scope: the /// readable channels of non-legacy types, and which of them are system -/// channels. A filter reads one only by naming it; a filter that opts in also -/// reads every system one (see the module docs). +/// channels the opt-in may add. A filter reads one only by naming it; a +/// filter that opts in also reads every such system one (see the module +/// docs). An unlisted system channel the reader is not a member of is never +/// one of them, so only a filter that names it reads it. #[derive(Debug, Clone, Default, PartialEq, Eq)] pub(crate) struct NamedOnlyChannels { ids: Vec, @@ -52,12 +54,28 @@ impl NamedOnlyChannels { channels } + /// From [`buzz_db::channel_members::get_readable_named_only_channels`], + /// which says which channels the opt-in may add. + pub(crate) fn from_readable( + rows: impl IntoIterator, + ) -> Self { + let mut channels = Self::default(); + for row in rows { + channels.insert_with_opt_in(row.id, &row.channel_type, row.opt_in); + } + channels + } + fn insert(&mut self, id: Uuid, channel_type: &str) { + self.insert_with_opt_in(id, channel_type, true); + } + + fn insert_with_opt_in(&mut self, id: Uuid, channel_type: &str, opt_in: bool) { if self.ids.contains(&id) { return; } self.ids.push(id); - if channel_type == buzz_core::channel::ChannelType::System.as_str() { + if opt_in && channel_type == buzz_core::channel::ChannelType::System.as_str() { self.system.push(id); } } @@ -139,6 +157,17 @@ fn filter_needs_named_only_channels(filter: &Filter, implicit: &[Uuid]) -> bool named_channels(filter).any(|id| !implicit.contains(&id)) } +/// The channels that `filters` name outside the implicit set, deduplicated. +/// Only these can be unlisted channels the request reaches. +fn named_outside(filters: &[Filter], implicit: &[Uuid]) -> Vec { + let mut seen: HashSet = implicit.iter().copied().collect(); + filters + .iter() + .flat_map(named_channels) + .filter(|id| seen.insert(*id)) + .collect() +} + /// The channels `filter` may read: the implicit set, plus the readable /// named-only channels the filter names, plus every readable system channel /// when it opts in. @@ -186,10 +215,11 @@ pub(crate) async fn readable_named_only_channels_for_filters( { return Ok(NamedOnlyChannels::default()); } - let mut readable = NamedOnlyChannels::from_typed( + let named = named_outside(filters, implicit); + let mut readable = NamedOnlyChannels::from_readable( state .db - .get_readable_named_only_channels(community, pubkey) + .get_readable_named_only_channels(community, pubkey, &named) .await?, ); if let Some(allowed) = token_channel_ids { diff --git a/crates/buzz-relay/src/nip11.rs b/crates/buzz-relay/src/nip11.rs index d61566efbb..761c9e8d9e 100644 --- a/crates/buzz-relay/src/nip11.rs +++ b/crates/buzz-relay/src/nip11.rs @@ -249,6 +249,10 @@ impl RelayInfo { "nip-cl".to_string(), // Bridge `/query` search accepts `search_sort: "newest"`. "buzz-search-sort".to_string(), + // kind:9002 `unlisted` (NOSTR.md, Unlisted channels). A relay + // without it ignores the tag beside `visibility`, so a client + // checks this before opening a channel as unlisted. + "buzz-unlisted-channels".to_string(), ]; let gif = gif_provider.map(|provider| { supported_extensions.push("buzz-gif".to_string()); @@ -668,11 +672,11 @@ mod tests { .contains(&"buzz-gif".to_string())); } - /// Clients refuse NIP-CL labels and system channels, and newest-first - /// search, unless the relay says it supports them, so every document must - /// advertise both. + /// Clients refuse NIP-CL labels and system channels, newest-first + /// search, and unlisted channels unless the relay says it supports them, + /// so every document must advertise all three. #[test] - fn advertises_nip_cl_and_search_sort_extensions() { + fn advertises_nip_cl_search_sort_and_unlisted_extensions() { let info = RelayInfo::build( None, None, @@ -685,6 +689,7 @@ mod tests { let extensions = info.supported_extensions.expect("extensions"); assert!(extensions.contains(&"nip-cl".to_string())); assert!(extensions.contains(&"buzz-search-sort".to_string())); + assert!(extensions.contains(&"buzz-unlisted-channels".to_string())); } /// NIP-WP → NIP-11 mirror: a set workspace icon is served in the standard diff --git a/crates/buzz-sdk/src/builders.rs b/crates/buzz-sdk/src/builders.rs index e9ec760956..0791ebaa98 100644 --- a/crates/buzz-sdk/src/builders.rs +++ b/crates/buzz-sdk/src/builders.rs @@ -823,10 +823,13 @@ pub fn build_leave(channel_id: Uuid) -> Result { Ok(EventBuilder::new(Kind::Custom(9022), "").tags(tags)) } -/// Build a NIP-29 edit-metadata event for name/about/visibility/ttl/join_role (kind 9002). +/// Build a NIP-29 edit-metadata event for +/// name/about/visibility/ttl/join_role/unlisted (kind 9002). /// /// `ttl`: outer `None` leaves it unchanged; `Some(Some(secs))` sets the /// ephemeral timeout; `Some(None)` clears it (emits `["ttl", ""]`). +/// `unlisted`: `None` leaves it unchanged; `Some(b)` emits +/// `["unlisted", "true"|"false"]`. pub fn build_update_channel( channel_id: Uuid, name: Option<&str>, @@ -834,15 +837,18 @@ pub fn build_update_channel( visibility: Option<&str>, ttl: Option>, join_role: Option, + unlisted: Option, ) -> Result { if name.is_none() && about.is_none() && visibility.is_none() && ttl.is_none() && join_role.is_none() + && unlisted.is_none() { return Err(SdkError::InvalidTag( - "at least one of name, about, visibility, ttl, or join_role must be provided".into(), + "at least one of name, about, visibility, ttl, join_role, or unlisted must be provided" + .into(), )); } if let Some(v) = visibility { @@ -882,6 +888,9 @@ pub fn build_update_channel( if let Some(join_role) = join_role { tags.push(tag(&["join_role", join_role.as_str()])?); } + if let Some(unlisted) = unlisted { + tags.push(tag(&["unlisted", if unlisted { "true" } else { "false" }])?); + } Ok(EventBuilder::new(Kind::Custom(9002), "").tags(tags)) } @@ -908,6 +917,11 @@ pub fn build_set_purpose(channel_id: Uuid, purpose: &str) -> Result, about: Option<&str>, ttl: Option, + unlisted: bool, ) -> Result { let name = buzz_core::channel::canonical_channel_name(name); if name.trim().is_empty() { @@ -933,6 +948,9 @@ pub fn build_create_channel( if let Some(secs) = ttl { tags.push(tag(&["ttl", &secs.to_string()])?); } + if unlisted { + tags.push(tag(&["unlisted", "true"])?); + } Ok(EventBuilder::new(Kind::Custom(9007), "").tags(tags)) } @@ -3642,8 +3660,16 @@ mod tests { fn update_channel_name_and_about() { let cid = uuid(); let ev = sign( - build_update_channel(cid, Some("new-name"), Some("new about"), None, None, None) - .unwrap(), + build_update_channel( + cid, + Some("new-name"), + Some("new about"), + None, + None, + None, + None, + ) + .unwrap(), ); assert_eq!(ev.kind.as_u16(), 9002); assert!(has_tag(&ev, "name", "new-name")); @@ -3653,7 +3679,16 @@ mod tests { #[test] fn update_channel_strips_all_leading_hashes_from_name() { let ev = sign( - build_update_channel(uuid(), Some(" ###new-name "), None, None, None, None).unwrap(), + build_update_channel( + uuid(), + Some(" ###new-name "), + None, + None, + None, + None, + None, + ) + .unwrap(), ); assert!(has_tag(&ev, "name", "new-name")); } @@ -3661,7 +3696,7 @@ mod tests { #[test] fn update_channel_rejects_hash_only_name() { assert!(matches!( - build_update_channel(uuid(), Some(" ### "), None, None, None, None), + build_update_channel(uuid(), Some(" ### "), None, None, None, None, None), Err(SdkError::InvalidTag(_)) )); } @@ -3670,7 +3705,16 @@ mod tests { fn update_channel_visibility_and_ttl() { let cid = uuid(); let ev = sign( - build_update_channel(cid, None, None, Some("private"), Some(Some(3600)), None).unwrap(), + build_update_channel( + cid, + None, + None, + Some("private"), + Some(Some(3600)), + None, + None, + ) + .unwrap(), ); assert_eq!(ev.kind.as_u16(), 9002); assert!(has_tag(&ev, "visibility", "private")); @@ -3680,7 +3724,7 @@ mod tests { #[test] fn update_channel_clears_ttl() { let cid = uuid(); - let ev = sign(build_update_channel(cid, None, None, None, Some(None), None).unwrap()); + let ev = sign(build_update_channel(cid, None, None, None, Some(None), None, None).unwrap()); assert!(has_tag(&ev, "ttl", "")); } @@ -3688,7 +3732,7 @@ mod tests { fn update_channel_invalid_visibility_rejected() { let cid = uuid(); assert!(matches!( - build_update_channel(cid, None, None, Some("secret"), None, None), + build_update_channel(cid, None, None, Some("secret"), None, None, None), Err(SdkError::InvalidTag(_)) )); } @@ -3698,27 +3742,63 @@ mod tests { use buzz_core::channel::ChannelJoinRole; let cid = uuid(); let on = sign( - build_update_channel(cid, None, None, None, None, Some(ChannelJoinRole::Guest)) - .unwrap(), + build_update_channel( + cid, + None, + None, + None, + None, + Some(ChannelJoinRole::Guest), + None, + ) + .unwrap(), ); assert!(has_tag(&on, "join_role", "guest")); let off = sign( - build_update_channel(cid, None, None, None, None, Some(ChannelJoinRole::Member)) - .unwrap(), + build_update_channel( + cid, + None, + None, + None, + None, + Some(ChannelJoinRole::Member), + None, + ) + .unwrap(), ); assert!(has_tag(&off, "join_role", "member")); - let other = sign(build_update_channel(cid, Some("n"), None, None, None, None).unwrap()); + let other = + sign(build_update_channel(cid, Some("n"), None, None, None, None, None).unwrap()); assert!(!other .tags .iter() .any(|t| t.kind().to_string() == "join_role")); } + #[test] + fn update_channel_unlisted_is_always_explicit() { + let cid = uuid(); + let on = sign( + build_update_channel(cid, None, None, Some("open"), None, None, Some(true)).unwrap(), + ); + assert!(has_tag(&on, "visibility", "open")); + assert!(has_tag(&on, "unlisted", "true")); + let off = + sign(build_update_channel(cid, None, None, None, None, None, Some(false)).unwrap()); + assert!(has_tag(&off, "unlisted", "false")); + let other = + sign(build_update_channel(cid, Some("n"), None, None, None, None, None).unwrap()); + assert!(!other + .tags + .iter() + .any(|t| t.kind().to_string() == "unlisted")); + } + #[test] fn update_channel_no_fields_rejected() { let cid = uuid(); assert!(matches!( - build_update_channel(cid, None, None, None, None, None), + build_update_channel(cid, None, None, None, None, None, None), Err(SdkError::InvalidTag(_)) )); } @@ -3750,6 +3830,7 @@ mod tests { Some(ChannelKind::Stream), Some("General chat"), None, + false, ) .unwrap(), ); @@ -3771,6 +3852,7 @@ mod tests { None::, None, None, + false, ) .unwrap(), ); @@ -3788,6 +3870,7 @@ mod tests { None::, None, None, + false, ) .unwrap(), ); @@ -3804,11 +3887,35 @@ mod tests { None::, None, None, + false, ), Err(SdkError::InvalidTag(_)) )); } + #[test] + fn create_channel_unlisted_emits_tag_only_when_set() { + let build = |unlisted| { + sign( + build_create_channel( + uuid(), + "solo", + Some(Visibility::Private), + Some(ChannelKind::Stream), + None, + None, + unlisted, + ) + .unwrap(), + ) + }; + assert!(has_tag(&build(true), "unlisted", "true")); + assert!(!build(false) + .tags + .iter() + .any(|t| t.kind().to_string() == "unlisted")); + } + #[test] fn create_channel_ephemeral_emits_ttl() { let cid = uuid(); @@ -3820,6 +3927,7 @@ mod tests { Some(ChannelKind::Stream), None, Some(3600), + false, ) .unwrap(), ); diff --git a/crates/buzz-test-client/tests/e2e_relay.rs b/crates/buzz-test-client/tests/e2e_relay.rs index af481b77c6..1cdd80435e 100644 --- a/crates/buzz-test-client/tests/e2e_relay.rs +++ b/crates/buzz-test-client/tests/e2e_relay.rs @@ -3200,6 +3200,454 @@ async fn post_ws(url: &str, channel_id: &str, signer: &Keys, text: &str) -> (boo (ok.accepted, ok.message) } +/// Submit a kind:9002 `unlisted` change and return the OK frame. +async fn set_unlisted_ws( + url: &str, + channel_id: &str, + signer: &Keys, + value: &str, +) -> (bool, String) { + let event = EventBuilder::new(Kind::Custom(9002), "") + .tags([ + Tag::parse(["h", channel_id]).unwrap(), + Tag::parse(["unlisted", value]).unwrap(), + Tag::parse(["nonce", &uuid::Uuid::new_v4().to_string()]).unwrap(), + ]) + .sign_with_keys(signer) + .unwrap(); + let mut client = BuzzTestClient::connect(url, signer) + .await + .expect("connect unlisted actor"); + let ok = client.send_event(event).await.expect("send kind:9002"); + client.disconnect().await.ok(); + (ok.accepted, ok.message) +} + +/// The events one REQ returns for `keys`. +async fn req_events(url: &str, keys: &Keys, filter: Filter) -> Vec { + let mut ws = BuzzTestClient::connect(url, keys).await.expect("connect"); + let sid = sub_id("unlisted"); + ws.subscribe(&sid, vec![filter]).await.expect("subscribe"); + let events = ws + .collect_until_eose(&sid, Duration::from_secs(5)) + .await + .expect("EOSE"); + ws.disconnect().await.ok(); + events +} + +/// The channel ids of the kind:39000 events `keys` sees without naming any +/// channel, since `since`. +async fn listed_channel_ids(url: &str, keys: &Keys, since: nostr::Timestamp) -> Vec { + req_events( + url, + keys, + Filter::new().kind(Kind::Custom(39000)).since(since), + ) + .await + .iter() + .filter_map(|e| { + e.tags + .iter() + .find(|t| t.kind().to_string() == "d") + .and_then(|t| t.content().map(str::to_owned)) + }) + .collect() +} + +/// An unlisted open channel through the real relay: only owners/admins set +/// it; non-members do not see it in kind:39000 discovery, history or search +/// unless they name it, and then read it; anyone can join it by id and then +/// lists it; listing it again shows it to everyone. A reader whose channel set +/// was cached before a change sees the change at once. +#[tokio::test] +#[ignore] +async fn test_nip29_unlisted_channel_wire() { + let url = relay_url(); + let owner = Keys::generate(); + let outsider = Keys::generate(); + // 39000 timestamps are whole seconds; start one second early. + let since = nostr::Timestamp::from(nostr::Timestamp::now().as_secs() - 1); + let listed = create_test_channel(&owner).await; + let channel_id = create_test_channel(&owner).await; + // A non-member whose accessible channels are cached while it is listed. + let stranger = Keys::generate(); + assert!(listed_channel_ids(&url, &stranger, since) + .await + .contains(&channel_id)); + + // Only owners/admins may change the setting; only true/false are valid. + let (accepted, msg) = set_unlisted_ws(&url, &channel_id, &outsider, "true").await; + assert!(!accepted, "a non-member must not change unlisted"); + assert!(msg.contains("not authorized"), "{msg}"); + let (accepted, msg) = set_unlisted_ws(&url, &channel_id, &owner, "yes").await; + assert!(!accepted, "an unknown unlisted value must be refused"); + assert!(msg.contains("invalid unlisted value"), "{msg}"); + assert_eq!( + set_unlisted_ws(&url, &channel_id, &owner, "true").await, + (true, String::new()) + ); + + // Named by `#d`, the metadata is readable and carries the flag. + let tags = group_metadata_tags(&url, &outsider, &channel_id).await; + assert!(tags.contains(&vec!["unlisted".to_string()]), "{tags:?}"); + assert!(tags.contains(&vec!["public".to_string()]), "{tags:?}"); + assert!(!tags.contains(&vec!["hidden".to_string()]), "{tags:?}"); + + // Discovery without `#d`: members list it, non-members do not, including + // one whose channel set was cached before the change. + assert!(!listed_channel_ids(&url, &stranger, since) + .await + .contains(&channel_id)); + let seen = listed_channel_ids(&url, &outsider, since).await; + assert!( + seen.contains(&listed), + "a listed channel is listed: {seen:?}" + ); + assert!(!seen.contains(&channel_id), "unlisted to a non-member"); + assert!(listed_channel_ids(&url, &owner, since) + .await + .contains(&channel_id)); + + // History and search: a non-member reads it only by naming it. + let token = format!("unlisted{}", uuid::Uuid::new_v4().simple()); + let (accepted, msg) = post_ws(&url, &channel_id, &owner, &format!("hello {token}")).await; + assert!(accepted, "owner post: {msg}"); + let h = SingleLetterTag::lowercase(Alphabet::H); + let messages = Filter::new().kind(Kind::Custom(9)).since(since); + let search = Filter::new().kind(Kind::Custom(9)).search(&token); + for (filter, what) in [(&messages, "history"), (&search, "search")] { + let in_channel = |events: &[nostr::Event]| { + events.iter().any(|e| { + e.tags.iter().any(|t| { + t.kind().to_string() == "h" && t.content() == Some(channel_id.as_str()) + }) + }) + }; + let unnamed = req_events(&url, &outsider, filter.clone()).await; + assert!(!in_channel(&unnamed), "{what}: unnamed, non-member"); + let named = req_events( + &url, + &outsider, + filter.clone().custom_tags(h, [channel_id.as_str()]), + ) + .await; + assert!(in_channel(&named), "{what}: named, non-member"); + let member = req_events(&url, &owner, filter.clone()).await; + assert!(in_channel(&member), "{what}: unnamed, member"); + } + + // Anyone can join by id, and then lists it like any member. + let (accepted, msg) = join_ws(&url, &channel_id, &outsider).await; + assert!(accepted, "join an unlisted channel: {msg}"); + assert!(listed_channel_ids(&url, &outsider, since) + .await + .contains(&channel_id)); + + // Listing it again shows it to everyone, at once. + assert!(!listed_channel_ids(&url, &stranger, since) + .await + .contains(&channel_id)); + assert_eq!( + set_unlisted_ws(&url, &channel_id, &owner, "false").await, + (true, String::new()) + ); + let tags = group_metadata_tags(&url, &stranger, &channel_id).await; + assert!(!tags.contains(&vec!["unlisted".to_string()]), "{tags:?}"); + assert!(listed_channel_ids(&url, &stranger, since) + .await + .contains(&channel_id)); +} + +/// Submit one signed event over WebSocket and return the OK frame. +async fn submit_ws(url: &str, signer: &Keys, kind: u16, tags: &[&[&str]]) -> (bool, String) { + let mut tags: Vec = tags + .iter() + .map(|t| Tag::parse(t.iter().copied()).unwrap()) + .collect(); + tags.push(Tag::parse(["nonce", &uuid::Uuid::new_v4().to_string()]).unwrap()); + let event = EventBuilder::new(Kind::Custom(kind), "") + .tags(tags) + .sign_with_keys(signer) + .unwrap(); + let mut client = BuzzTestClient::connect(url, signer) + .await + .expect("connect submitter"); + let ok = client.send_event(event).await.expect("send event"); + client.disconnect().await.ok(); + (ok.accepted, ok.message) +} + +/// Create a private Solo-style chat (a private stream labelled `buzz.solo`) +/// with kind:9007, adding `extra` tags, and return its id. +async fn create_solo_chat_ws(url: &str, owner: &Keys, extra: &[&[&str]]) -> String { + let channel_id = uuid::Uuid::new_v4().to_string(); + let name = format!("solo-e2e-{channel_id}"); + let base: [&[&str]; 5] = [ + &["h", &channel_id], + &["name", &name], + &["channel_type", "stream"], + &["visibility", "private"], + &["t", "buzz.solo"], + ]; + let tags: Vec<&[&str]> = base.into_iter().chain(extra.iter().copied()).collect(); + let (accepted, msg) = submit_ws(url, owner, 9007, &tags).await; + assert!(accepted, "create Solo chat: {msg}"); + channel_id +} + +/// Whether any event in `events` belongs to `channel_id`. +fn any_in_channel(events: &[nostr::Event], channel_id: &str) -> bool { + events.iter().any(|e| { + e.tags + .iter() + .any(|t| t.kind().to_string() == "h" && t.content() == Some(channel_id)) + }) +} + +/// The Solo sharing model through the real relay, one event per user action: +/// +/// - **Create unlisted:** kind:9007 `["unlisted", "true"]` starts a private +/// chat unlisted, and a later plain `["visibility", "open"]` keeps it +/// unlisted. +/// - **Share:** one kind:9002 with `visibility open` + `unlisted true` + +/// `join_role guest`. A non-member does not list it, reads it by `#h` +/// (history and live), and cannot post. A kind:9021 join (pin) lists it for +/// them, still as a read-only guest; a kind:9022 leave (unpin) unlists it +/// again and it stays readable by `#h`. +/// - **Unshare:** kind:9002 `visibility private` closes the non-member's live +/// subscription and refuses a new `#h` read. +/// - **Convert to a channel:** one kind:9002 with `["t", ""]` + +/// `unlisted false`. A non-member then finds it in discovery. +#[tokio::test] +#[ignore] +async fn test_nip29_solo_share_and_convert_wire() { + let url = relay_url(); + let owner = Keys::generate(); + let viewer = Keys::generate(); + // A non-member whose accessible channels are cached before each change. + let stranger = Keys::generate(); + let since = nostr::Timestamp::from(nostr::Timestamp::now().as_secs() - 1); + let h = SingleLetterTag::lowercase(Alphabet::H); + let messages = Filter::new().kind(Kind::Custom(9)).since(since); + + // ── Create unlisted (kind:9007) ───────────────────────────────────────── + let (accepted, msg) = submit_ws( + &url, + &owner, + 9007, + &[ + &["h", &uuid::Uuid::new_v4().to_string()], + &["name", "solo-bad-unlisted"], + &["visibility", "private"], + &["unlisted", "yes"], + ], + ) + .await; + assert!(!accepted, "an unknown unlisted value must be refused"); + assert!(msg.contains("invalid unlisted value"), "{msg}"); + + let born_unlisted = create_solo_chat_ws(&url, &owner, &[&["unlisted", "true"]]).await; + let tags = group_metadata_tags(&url, &owner, &born_unlisted).await; + assert!(tags.contains(&vec!["unlisted".to_string()]), "{tags:?}"); + assert!(tags.contains(&vec!["private".to_string()]), "{tags:?}"); + assert!(!listed_channel_ids(&url, &stranger, since) + .await + .contains(&born_unlisted)); + let (accepted, msg) = submit_ws( + &url, + &owner, + 9002, + &[&["h", &born_unlisted], &["visibility", "open"]], + ) + .await; + assert!(accepted, "open a chat created unlisted: {msg}"); + assert!( + !listed_channel_ids(&url, &stranger, since) + .await + .contains(&born_unlisted), + "opening a chat created unlisted keeps it unlisted" + ); + let tags = group_metadata_tags(&url, &stranger, &born_unlisted).await; + assert!(tags.contains(&vec!["unlisted".to_string()]), "{tags:?}"); + assert!(tags.contains(&vec!["public".to_string()]), "{tags:?}"); + + // ── Share: one kind:9002 on an existing (listed) private chat ─────────── + let chat = create_solo_chat_ws(&url, &owner, &[]).await; + let (accepted, msg) = post_ws(&url, &chat, &owner, "before sharing").await; + assert!(accepted, "owner post: {msg}"); + assert!(!listed_channel_ids(&url, &stranger, since) + .await + .contains(&chat)); + let (accepted, msg) = submit_ws( + &url, + &owner, + 9002, + &[ + &["h", &chat], + &["visibility", "open"], + &["unlisted", "true"], + &["join_role", "guest"], + ], + ) + .await; + assert!(accepted, "share in one event: {msg}"); + let tags = group_metadata_tags(&url, &viewer, &chat).await; + for flag in ["public", "unlisted", "restricted"] { + assert!(tags.contains(&vec![flag.to_string()]), "{flag}: {tags:?}"); + } + assert!( + tags.contains(&vec!["join_role".to_string(), "guest".to_string()]), + "{tags:?}" + ); + assert!( + !listed_channel_ids(&url, &stranger, since) + .await + .contains(&chat), + "a shared chat is not listed to a non-member" + ); + + // A non-member viewer reads it by `#h`, history and live, but can't post. + let named = messages.clone().custom_tags(h, [chat.as_str()]); + assert!(any_in_channel( + &req_events(&url, &viewer, named.clone()).await, + &chat + )); + assert!(!any_in_channel( + &req_events(&url, &viewer, messages.clone()).await, + &chat + )); + let mut live = BuzzTestClient::connect(&url, &viewer) + .await + .expect("connect viewer"); + let live_sub = sub_id("solo-live"); + live.subscribe(&live_sub, vec![named.clone()]) + .await + .expect("subscribe by #h"); + live.collect_until_eose(&live_sub, Duration::from_secs(5)) + .await + .expect("EOSE"); + let token = format!("live-{}", uuid::Uuid::new_v4().simple()); + let (accepted, msg) = post_ws(&url, &chat, &owner, &token).await; + assert!(accepted, "owner post: {msg}"); + let deadline = tokio::time::Instant::now() + Duration::from_secs(5); + loop { + let remaining = deadline.saturating_duration_since(tokio::time::Instant::now()); + match live.recv_event(remaining).await.expect("live event") { + RelayMessage::Event { + subscription_id, + event, + } if subscription_id == live_sub && event.content == token => break, + RelayMessage::Closed { message, .. } => panic!("live sub closed: {message}"), + _ => {} + } + } + let (accepted, msg) = post_ws(&url, &chat, &viewer, "viewer post").await; + assert!(!accepted, "a guest-join chat is read-only to a non-member"); + assert!(msg.contains("only members can post"), "{msg}"); + + // Pin (kind:9021): listed for the viewer, still a read-only guest. + let (accepted, msg) = join_ws(&url, &chat, &viewer).await; + assert!(accepted, "pin: {msg}"); + assert!(listed_channel_ids(&url, &viewer, since) + .await + .contains(&chat)); + assert!(any_in_channel( + &req_events(&url, &viewer, messages.clone()).await, + &chat + )); + let (accepted, msg) = post_ws(&url, &chat, &viewer, "guest post").await; + assert!(!accepted, "a pinned guest still cannot post"); + assert!(msg.contains("only members can post"), "{msg}"); + assert!(!listed_channel_ids(&url, &stranger, since) + .await + .contains(&chat)); + + // Unpin (kind:9022): unlisted for the viewer again, still readable by id. + let (accepted, msg) = self_departure_ws(&url, &chat, &viewer, 9022).await; + assert!(accepted, "unpin: {msg}"); + assert!( + !listed_channel_ids(&url, &viewer, since) + .await + .contains(&chat), + "an unpinned chat leaves the viewer's list" + ); + assert!(any_in_channel( + &req_events(&url, &viewer, named.clone()).await, + &chat + )); + + // Unshare: the viewer's live subscription closes and a new read is refused. + let (accepted, msg) = submit_ws( + &url, + &owner, + 9002, + &[&["h", &chat], &["visibility", "private"]], + ) + .await; + assert!(accepted, "unshare: {msg}"); + let deadline = tokio::time::Instant::now() + Duration::from_secs(5); + loop { + let remaining = deadline.saturating_duration_since(tokio::time::Instant::now()); + match live.recv_event(remaining).await.expect("CLOSED on unshare") { + RelayMessage::Closed { + subscription_id, .. + } if subscription_id == live_sub => break, + RelayMessage::Event { + subscription_id, + event, + } if subscription_id == live_sub && event.kind == Kind::Custom(9) => { + panic!("a message reached the viewer after unsharing: {event:?}") + } + _ => {} + } + } + live.disconnect().await.ok(); + let mut ws = BuzzTestClient::connect(&url, &viewer) + .await + .expect("connect"); + let sid = sub_id("solo-unshared"); + ws.subscribe(&sid, vec![named]).await.expect("subscribe"); + match ws + .recv_event(Duration::from_secs(5)) + .await + .expect("reply to #h read") + { + RelayMessage::Closed { message, .. } => { + assert!(message.contains("restricted"), "{message}") + } + other => panic!("an unshared chat must refuse a non-member: {other:?}"), + } + ws.disconnect().await.ok(); + + // ── Convert to a channel: one kind:9002 ───────────────────────────────── + let (accepted, msg) = submit_ws( + &url, + &owner, + 9002, + &[ + &["h", &born_unlisted], + &["t", ""], + &["unlisted", "false"], + &["visibility", "open"], + ], + ) + .await; + assert!(accepted, "convert in one event: {msg}"); + let tags = group_metadata_tags(&url, &stranger, &born_unlisted).await; + assert!(!tags.contains(&vec!["unlisted".to_string()]), "{tags:?}"); + assert!( + !tags.contains(&vec!["t".to_string(), "buzz.solo".to_string()]), + "converting clears the Solo label: {tags:?}" + ); + assert!( + listed_channel_ids(&url, &stranger, since) + .await + .contains(&born_unlisted), + "a converted chat is listed to non-members" + ); +} + /// Record `owner` as the NIP-OA owner of `agent`, by authenticating once. async fn record_agent_owner(url: &str, agent: &Keys, owner: &Keys) { let auth_tag = buzz_sdk::nip_oa::parse_auth_tag( diff --git a/docs/nips/NIP-CL.md b/docs/nips/NIP-CL.md index 843d33f485..9b98829280 100644 --- a/docs/nips/NIP-CL.md +++ b/docs/nips/NIP-CL.md @@ -198,6 +198,11 @@ a system channel stays an ordinary NIP-29 group. The relay decides this for each filter. A filter that names a channel does not add it to another filter in the same request. +An **unlisted** open channel (Buzz's `unlisted` setting, see NOSTR.md) is +named-only in the same way for readers who are not members, whatever its +type. Its members read it like any other channel. The system-channel opt-in +never adds it. + Push notifications (NIP-PL) use the same rule. A lease filter wakes its author for an event in a system channel or a channel of a type added later only when the filter names that channel. A lease filter cannot have `#t` or diff --git a/migrations/0071_channel_unlisted.sql b/migrations/0071_channel_unlisted.sql new file mode 100644 index 0000000000..bd0f0556d2 --- /dev/null +++ b/migrations/0071_channel_unlisted.sql @@ -0,0 +1,13 @@ +-- Whether an open channel is left out of channel listings for non-members. +-- An unlisted open channel can still be read and joined by anyone who names +-- it by id (a shared link); it is only left out of what lists open channels: +-- the implicit scope of a read that does not name a channel, and the channel +-- list. Members still list it. The flag is stored on its own and matters only +-- while the channel is open: private channels are already invisible to +-- non-members. Published in kind:39000 as an `unlisted` tag. The constant +-- default fills existing rows without a table rewrite and keeps every existing +-- channel listed. +SET LOCAL lock_timeout = '5s'; + +ALTER TABLE channels + ADD COLUMN unlisted BOOLEAN NOT NULL DEFAULT FALSE; diff --git a/schema/tables/public/channels.sql b/schema/tables/public/channels.sql index 4d55c294ed..102a441e8f 100644 --- a/schema/tables/public/channels.sql +++ b/schema/tables/public/channels.sql @@ -38,6 +38,9 @@ CREATE TABLE channels ( CONSTRAINT channels_labels_bounded CHECK (cardinality(labels) <= 8), join_role TEXT NOT NULL DEFAULT 'member' CHECK (join_role IN ('member', 'guest')), + -- An unlisted open channel is readable and joinable by id but left out of + -- channel listings for non-members. Matters only while the channel is open. + unlisted BOOLEAN NOT NULL DEFAULT FALSE, PRIMARY KEY (community_id, id), CONSTRAINT chk_channels_id_not_nil CHECK (id <> '00000000-0000-0000-0000-000000000000'::uuid) );