From e04bd14f48a27caa178a0f5e360f9e89aa826756 Mon Sep 17 00:00:00 2001 From: Bradley Axen Date: Sun, 25 Oct 2026 15:53:51 -0700 Subject: [PATCH 1/8] fix(deps): bump p256 to 0.14 for RUSTSEC-2026-0387 RustSec published RUSTSEC-2026-0387 against crypto-bigint: some operations meant to be constant-time could branch on secret values. cargo-deny now fails the Security job on main and every PR. crypto-bigint 0.5.5 came in only through p256 0.13 in buzz-push-gateway. p256 0.14 uses crypto-bigint 0.7.9, which is patched. The only API change the gateway hits is the SEC1 encoder, renamed from ToEncodedPoint::to_encoded_point to ToSec1Point::to_sec1_point. Co-authored-by: ART Co-Authored-By: Claude Opus 5.5 Signed-off-by: Bradley Axen --- Cargo.lock | 183 +++++++++--------- crates/buzz-push-gateway/Cargo.toml | 2 +- .../src/app_attest/authenticator.rs | 4 +- .../src/app_attest/certificate.rs | 4 +- .../src/app_attest_assertion_tests.rs | 16 +- 5 files changed, 98 insertions(+), 111 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 467774efcdb..fd257fd6c0a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2039,12 +2039,6 @@ dependencies = [ "serde_core", ] -[[package]] -name = "const-oid" -version = "0.9.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" - [[package]] name = "const-oid" version = "0.10.2" @@ -2304,12 +2298,16 @@ checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" [[package]] name = "crypto-bigint" -version = "0.5.5" +version = "0.7.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76" +checksum = "d811ea23fdfbcce2e2d5132344d471abdb8f901ac496a0893a3df92e70525e52" dependencies = [ - "generic-array", - "rand_core 0.6.4", + "cpubits", + "ctutils", + "getrandom 0.4.3", + "hybrid-array", + "num-traits", + "rand_core 0.10.1", "subtle", "zeroize", ] @@ -2401,6 +2399,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "38d28fb6aa7dd680b3ffd1a3e64e115c177bdba63c0ae7f9cd48129ff05c4fa0" dependencies = [ "cmov", + "subtle", ] [[package]] @@ -2596,7 +2595,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ccc2776f0c61eca1ca32528f85548abd1a4be8fb53d1b21c013e4f18da1e7090" dependencies = [ "data-encoding", - "syn 1.0.109", + "syn 2.0.117", ] [[package]] @@ -2666,25 +2665,14 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5729f5117e208430e437df2f4843f5e5952997175992d1414f94c57d61e270b4" -[[package]] -name = "der" -version = "0.7.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" -dependencies = [ - "const-oid 0.9.6", - "pem-rfc7468 0.7.0", - "zeroize", -] - [[package]] name = "der" version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a878c850e9e421b20262e9b41f9c860e4785fa07541c266b62ff9d1ef998a80a" dependencies = [ - "const-oid 0.10.2", - "pem-rfc7468 1.0.0", + "const-oid", + "pem-rfc7468", "zeroize", ] @@ -2788,7 +2776,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ "block-buffer 0.10.4", - "const-oid 0.9.6", "crypto-common 0.1.7", "subtle", ] @@ -2800,7 +2787,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" dependencies = [ "block-buffer 0.12.0", - "const-oid 0.10.2", + "const-oid", "crypto-common 0.2.2", "ctutils", ] @@ -2898,16 +2885,17 @@ checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" [[package]] name = "ecdsa" -version = "0.16.9" +version = "0.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca" +checksum = "c0681a4fc24c767085329728d8dfba959af91228aa4610cca4f8ce317ba46ae0" dependencies = [ - "der 0.7.10", - "digest 0.10.7", + "der", + "digest 0.11.3", "elliptic-curve", "rfc6979", - "signature 2.2.0", - "spki 0.7.3", + "signature 3.0.0", + "spki", + "zeroize", ] [[package]] @@ -2916,7 +2904,7 @@ version = "3.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "29fcf32e6c73d1079f83ab4d782de2d81620346a5f38c6237a86a22f8368980a" dependencies = [ - "pkcs8 0.11.0", + "pkcs8", "serdect", "signature 3.0.0", ] @@ -2948,19 +2936,20 @@ dependencies = [ [[package]] name = "elliptic-curve" -version = "0.13.8" +version = "0.14.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47" +checksum = "900f37e7e18d74a2347389af6cd69e3a30d5cc9e697f40004865679aa75e9c7c" dependencies = [ - "base16ct 0.2.0", + "base16ct 1.0.0", "crypto-bigint", - "digest 0.10.7", + "crypto-common 0.2.2", + "digest 0.11.3", "ff", - "generic-array", "group", - "pem-rfc7468 0.7.0", - "pkcs8 0.10.2", - "rand_core 0.6.4", + "hybrid-array", + "pem-rfc7468", + "pkcs8", + "rand_core 0.10.1", "sec1", "subtle", "zeroize", @@ -3199,11 +3188,11 @@ dependencies = [ [[package]] name = "ff" -version = "0.13.1" +version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c0b50bfb653653f9ca9095b427bed08ab8d75a137839d9ad64eb11810d5b6393" +checksum = "a1f686ab92a9fb0eaf188f6c6c87b89490baa6fdb0db4544ba4dc47f7942489f" dependencies = [ - "rand_core 0.6.4", + "rand_core 0.10.1", "subtle", ] @@ -3633,12 +3622,12 @@ dependencies = [ [[package]] name = "group" -version = "0.13.0" +version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63" +checksum = "7fd1a1c7a5206c5b7a3f5a0d7ccd3ff85d0c8f5133d62a02680255b0004af5f4" dependencies = [ "ff", - "rand_core 0.6.4", + "rand_core 0.10.1", "subtle", ] @@ -4018,7 +4007,9 @@ version = "0.4.13" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "818356c5132c1fede50f837ca96afbe78ff42413047f4abb886217845e1b6c8c" dependencies = [ + "subtle", "typenum", + "zeroize", ] [[package]] @@ -7138,14 +7129,15 @@ dependencies = [ [[package]] name = "p256" -version = "0.13.2" +version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c9863ad85fa8f4460f9c48cb909d38a0d689dba1f6f6988a5e3e0d31071bcd4b" +checksum = "d2c9239b2dbc807adbbe147e8cf72ea7450c3a0aabe62cb8e75ff4ec22e1f72a" dependencies = [ "ecdsa", "elliptic-curve", + "primefield", "primeorder", - "sha2 0.10.9", + "sha2 0.11.0", ] [[package]] @@ -7270,15 +7262,6 @@ dependencies = [ "serde_core", ] -[[package]] -name = "pem-rfc7468" -version = "0.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412" -dependencies = [ - "base64ct", -] - [[package]] name = "pem-rfc7468" version = "1.0.0" @@ -7467,24 +7450,14 @@ dependencies = [ "futures-io", ] -[[package]] -name = "pkcs8" -version = "0.10.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" -dependencies = [ - "der 0.7.10", - "spki 0.7.3", -] - [[package]] name = "pkcs8" version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "451913da69c775a56034ea8d9003d27ee8948e12443eae7c038ba100a4f21cb7" dependencies = [ - "der 0.8.2", - "spki 0.8.0", + "der", + "spki", ] [[package]] @@ -7682,13 +7655,31 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "primefield" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c555a6e4eb7d4e158fcb028c835c3b8642206ddc279b5c6b202ef9a8bdb592f4" +dependencies = [ + "crypto-bigint", + "crypto-common 0.2.2", + "ff", + "rand_core 0.10.1", + "subtle", + "zeroize", +] + [[package]] name = "primeorder" -version = "0.13.6" +version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "353e1ca18966c16d9deb1c69278edbc5f194139612772bd9537af60ac231e1e6" +checksum = "5c9f42978c78a00e3d68f69fc03e57a234debae69da4020a4fb588fcdcd07b06" dependencies = [ "elliptic-curve", + "once_cell", + "primefield", + "serdect", + "wnaf", ] [[package]] @@ -8617,12 +8608,12 @@ checksum = "1e061d1b48cb8d38042de4ae0a7a6401009d6143dc80d2e2d6f31f0bdd6470c7" [[package]] name = "rfc6979" -version = "0.4.0" +version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8dd2a808d456c4a54e300a23e9f5a67e122c3024119acbfd73e3bf664491cb2" +checksum = "b4a459cddafb3fe76b31fd8f1108007566c40301feb64dc7b54656eb7388172b" dependencies = [ - "hmac 0.12.1", - "subtle", + "crypto-bigint", + "hmac 0.13.0", ] [[package]] @@ -9061,14 +9052,14 @@ dependencies = [ [[package]] name = "sec1" -version = "0.7.3" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc" +checksum = "d56d437c2f19203ce5f7122e507831de96f3d2d4d3be5af44a0b0a09d8a80e4d" dependencies = [ - "base16ct 0.2.0", - "der 0.7.10", - "generic-array", - "pkcs8 0.10.2", + "base16ct 1.0.0", + "ctutils", + "der", + "hybrid-array", "subtle", "zeroize", ] @@ -9517,7 +9508,6 @@ version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" dependencies = [ - "digest 0.10.7", "rand_core 0.6.4", ] @@ -9527,6 +9517,7 @@ version = "3.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "28d567dcbaf0049cb8ac2608a76cd95ff9e4412e1899d389ee400918ca7537f5" dependencies = [ + "digest 0.11.3", "rand_core 0.10.1", ] @@ -9808,16 +9799,6 @@ version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "023a211cb3138dbc438680b32560ad89f699977624c9f8dbb95a47d5b4c07dd3" -[[package]] -name = "spki" -version = "0.7.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" -dependencies = [ - "base64ct", - "der 0.7.10", -] - [[package]] name = "spki" version = "0.8.0" @@ -9825,7 +9806,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d9efca8738c78ee9484207732f728b1ef517bbb1833d6fc0879ca898a522f6f" dependencies = [ "base64ct", - "der 0.8.2", + "der", ] [[package]] @@ -12266,6 +12247,18 @@ dependencies = [ "windows-core 0.62.2", ] +[[package]] +name = "wnaf" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "795ca18b3fdb5e62bf982199278341ddcf7ebf7d32e25e212ad05d496e95f6fa" +dependencies = [ + "ff", + "group", + "hybrid-array", + "primefield", +] + [[package]] name = "writeable" version = "0.6.3" diff --git a/crates/buzz-push-gateway/Cargo.toml b/crates/buzz-push-gateway/Cargo.toml index 29dc8d21372..2a0884d01cc 100644 --- a/crates/buzz-push-gateway/Cargo.toml +++ b/crates/buzz-push-gateway/Cargo.toml @@ -23,7 +23,7 @@ personal-dev-app-attest = [] aes-gcm = "0.10" aws-lc-rs = { version = "1", default-features = false } minicbor = { version = "0.25", features = ["std", "half"] } -p256 = { version = "0.13", features = ["ecdsa"] } +p256 = { version = "0.14", features = ["ecdsa"] } rustls-pki-types = { version = "1", features = ["std"] } webpki = { package = "rustls-webpki", version = "0.103.9", default-features = false, features = ["aws-lc-rs", "alloc"] } x509-parser = { version = "0.18.1", default-features = false } diff --git a/crates/buzz-push-gateway/src/app_attest/authenticator.rs b/crates/buzz-push-gateway/src/app_attest/authenticator.rs index 7bc4ed060dd..a73c4e27266 100644 --- a/crates/buzz-push-gateway/src/app_attest/authenticator.rs +++ b/crates/buzz-push-gateway/src/app_attest/authenticator.rs @@ -5,7 +5,7 @@ use super::{ properties::ClientProperties, }; use aws_lc_rs::digest::{digest, SHA256}; -use p256::elliptic_curve::sec1::ToEncodedPoint; +use p256::elliptic_curve::sec1::ToSec1Point; pub(super) struct Checked { pub counter: u32, @@ -130,7 +130,7 @@ fn credential_key(tail: &[u8]) -> Result<([u8; 65], usize), Error> { let key = p256::PublicKey::from_sec1_bytes(&encoded).map_err(|_| Error::InvalidPublicKey)?; Ok(( - key.to_encoded_point(false) + key.to_sec1_point(false) .as_bytes() .try_into() .map_err(|_| Error::InvalidPublicKey)?, diff --git a/crates/buzz-push-gateway/src/app_attest/certificate.rs b/crates/buzz-push-gateway/src/app_attest/certificate.rs index 4d60c412749..6a7656df017 100644 --- a/crates/buzz-push-gateway/src/app_attest/certificate.rs +++ b/crates/buzz-push-gateway/src/app_attest/certificate.rs @@ -1,7 +1,7 @@ //! Apple certificate adapters; path and signature validation remain in webpki. use super::error::AppAttestError as Error; use aws_lc_rs::digest::{digest, SHA256}; -use p256::elliptic_curve::sec1::ToEncodedPoint; +use p256::elliptic_curve::sec1::ToSec1Point; use rustls_pki_types::{CertificateDer, UnixTime}; use x509_parser::{certificate::X509Certificate, parse_x509_certificate}; @@ -86,7 +86,7 @@ pub(super) fn public_key(bytes: &[u8]) -> Result<[u8; 65], Error> { } let key = p256::PublicKey::from_sec1_bytes(spki.subject_public_key.data.as_ref()) .map_err(|_| Error::InvalidPublicKey)?; - key.to_encoded_point(false) + key.to_sec1_point(false) .as_bytes() .try_into() .map_err(|_| Error::InvalidPublicKey) diff --git a/crates/buzz-push-gateway/src/app_attest_assertion_tests.rs b/crates/buzz-push-gateway/src/app_attest_assertion_tests.rs index 6e85e4958d8..6fbb55dfbb2 100644 --- a/crates/buzz-push-gateway/src/app_attest_assertion_tests.rs +++ b/crates/buzz-push-gateway/src/app_attest_assertion_tests.rs @@ -48,10 +48,7 @@ fn verify(cbor: &[u8]) -> Result { verifier.verify_assertion( &STANDARD.encode(cbor), TRANSCRIPT, - signing_key() - .verifying_key() - .to_encoded_point(false) - .as_ref(), + signing_key().verifying_key().to_sec1_point(false).as_ref(), 0, "challenge", "challenge", @@ -117,10 +114,7 @@ mod conformance { cbor, TRANSCRIPT, APP_ID, - signing_key() - .verifying_key() - .to_encoded_point(false) - .as_ref(), + signing_key().verifying_key().to_sec1_point(false).as_ref(), 0, ) } @@ -179,7 +173,7 @@ mod conformance { #[test] fn signed_bytes_identity_key_and_replay_preserved() { let cbor = signed_assertion(&auth_data(b"\xa1\x61x\x01")); - let key = signing_key().verifying_key().to_encoded_point(false); + let key = signing_key().verifying_key().to_sec1_point(false); assert!(verify_bytes(&cbor, b"changed", APP_ID, key.as_ref(), 0).is_err()); assert!(verify_bytes(&cbor, TRANSCRIPT, "other.app", key.as_ref(), 0).is_err()); assert!(verify_bytes(&cbor, TRANSCRIPT, APP_ID, key.as_ref(), 1).is_err()); @@ -189,7 +183,7 @@ mod conformance { &cbor, TRANSCRIPT, APP_ID, - other.verifying_key().to_encoded_point(false).as_ref(), + other.verifying_key().to_sec1_point(false).as_ref(), 0 ) .is_err()); @@ -438,7 +432,7 @@ mod properties { #[test] fn maximum_counter_is_accepted_once_and_cannot_wrap() { - let key = signing_key().verifying_key().to_encoded_point(false); + let key = signing_key().verifying_key().to_sec1_point(false); let mut auth = auth_data(b"\xa0"); auth[33..37].copy_from_slice(&u32::MAX.to_be_bytes()); let signed = signed_assertion(&auth); From ad87995aaf154355568573e33ff04744f63e0a22 Mon Sep 17 00:00:00 2001 From: Bradley Axen Date: Sat, 24 Oct 2026 17:46:48 -0700 Subject: [PATCH 2/8] feat(relay): add an unlisted channel setting An unlisted open channel can be read and joined by anyone who names it by id, as with a link-shared document, but it is left out of every read that lists channels to non-members: kind:39000 discovery without #d, messages, COUNT, NIP-50 search, artifact queries and the channel list. Members still see it everywhere. - Migration 0069 adds channels.unlisted BOOLEAN NOT NULL DEFAULT FALSE. - kind:9002 ["unlisted", "true"|"false"], owner/admin only, posts an unlisted_changed notice and clears the accessible-channels cache. - The open half of get_accessible_channel_ids and get_accessible_channels leaves unlisted channels out; get_readable_named_only_channels adds them, so a filter that names one reads it, as for system channels. - kind:39000 carries a bare ["unlisted"] flag; NIP-11 advertises buzz-unlisted-channels. - buzz channels update --unlisted true|false. Co-authored-by: mbot <3754f8729004d95654c46dbab3129e4ab9ef05cc2534e2a3fbfc155983bd637b@buzz.block.builderlab.xyz> Signed-off-by: Bradley Axen --- .github/workflows/_ci-relay.yml | 1 + NOSTR.md | 41 +++- crates/buzz-cli/src/commands/channels.rs | 21 +- crates/buzz-cli/src/lib.rs | 10 +- crates/buzz-db/src/runtime/migration.rs | 19 +- .../src/store/artifact_postgres_tests.rs | 55 +++++ crates/buzz-db/src/store/artifact_query.rs | 9 +- crates/buzz-db/src/store/channel.rs | 54 ++++- crates/buzz-db/src/store/channel_members.rs | 55 +++-- crates/buzz-db/src/store/dm.rs | 7 +- crates/buzz-db/src/store/mod.rs | 3 + .../store/unlisted_channels_postgres_tests.rs | 223 ++++++++++++++++++ .../buzz-relay/src/handlers/side_effects.rs | 39 ++- crates/buzz-relay/src/nip11.rs | 13 +- crates/buzz-sdk/src/builders.rs | 101 ++++++-- crates/buzz-test-client/tests/e2e_relay.rs | 159 +++++++++++++ migrations/0069_channel_unlisted.sql | 13 + schema/tables/public/channels.sql | 3 + 18 files changed, 755 insertions(+), 71 deletions(-) create mode 100644 crates/buzz-db/src/store/unlisted_channels_postgres_tests.rs create mode 100644 migrations/0069_channel_unlisted.sql diff --git a/.github/workflows/_ci-relay.yml b/.github/workflows/_ci-relay.yml index c02086dfda2..69d4bc478cc 100644 --- a/.github/workflows/_ci-relay.yml +++ b/.github/workflows/_ci-relay.yml @@ -628,6 +628,7 @@ jobs: cargo test -p buzz-test-client --test e2e_relay nip43_membership_snapshots_are_rejected -- --ignored --nocapture cargo test -p buzz-test-client --test e2e_relay nip29_departure_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_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 543f99b6bc7..6948fe18d76 100644 --- a/NOSTR.md +++ b/NOSTR.md @@ -53,11 +53,12 @@ PGPASSWORD=buzz_dev psql -h localhost -U buzz -d buzz -c \ | **Group creation (kind:9007)** | ✅ | NIP-29; include `name` tag, optional `visibility`, `channel_type` and label `t` 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. | | **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. 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,38 @@ 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. +- For a non-member, an unlisted open channel is left out of every read that + does not name it: a filter without `#h` (including kind:39000 discovery + without `#d`, and `#P` creator queries), 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 +197,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 019a2fca6b2..1ee8f6a11fd 100644 --- a/crates/buzz-cli/src/commands/channels.rs +++ b/crates/buzz-cli/src/commands/channels.rs @@ -1599,15 +1599,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 +1626,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,7 +1636,14 @@ 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)?; let builder = buzz_sdk::build_update_channel( @@ -1643,6 +1653,7 @@ pub async fn cmd_update_channel( visibility, ttl_change, join_role, + unlisted, ) .map_err(|e| CliError::Other(format!("build_update_channel failed: {e}")))?; @@ -1946,6 +1957,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 +1969,7 @@ pub async fn dispatch( ttl, no_ttl, join_role.map(Into::into), + unlisted, ) .await } @@ -2146,7 +2159,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 +2168,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"); } diff --git a/crates/buzz-cli/src/lib.rs b/crates/buzz-cli/src/lib.rs index c33afa78c27..e3b33976528 100644 --- a/crates/buzz-cli/src/lib.rs +++ b/crates/buzz-cli/src/lib.rs @@ -692,9 +692,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 +721,12 @@ 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. + #[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 b59455b5028..3b32debe91b 100644 --- a/crates/buzz-db/src/runtime/migration.rs +++ b/crates/buzz-db/src/runtime/migration.rs @@ -770,7 +770,12 @@ mod postgres_tests { let mut migrations: Vec<_> = MIGRATOR.iter().collect(); migrations.sort_by_key(|migration| migration.version); - assert_eq!(migrations.len(), 68); + assert_eq!(migrations.len(), 69); + assert_eq!(migrations[68].version, 69); + assert!(migrations[68] + .sql + .as_str() + .contains("ADD COLUMN unlisted BOOLEAN NOT NULL DEFAULT FALSE")); assert_eq!(migrations[67].version, 68); assert!(migrations[67] .sql @@ -2739,7 +2744,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. @@ -2904,6 +2910,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/artifact_postgres_tests.rs b/crates/buzz-db/src/store/artifact_postgres_tests.rs index a6ea1d0e09e..97534792e76 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 c0658a0b685..f359726d925 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 6b548da4f99..b4343ba67ac 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 { @@ -175,7 +180,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 "#, ) @@ -237,7 +242,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 "#, ) @@ -476,7 +481,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 +570,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 +590,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 +658,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 +700,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 +779,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 +793,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 +1030,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 +1069,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); } @@ -1784,6 +1808,7 @@ mod group_metadata_tag_tests { ttl_deadline: None, labels: Vec::new(), join_role: join_role.into(), + unlisted: false, } } @@ -1867,6 +1892,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() { diff --git a/crates/buzz-db/src/store/channel_members.rs b/crates/buzz-db/src/store/channel_members.rs index 6013ca869a3..6b142110761 100644 --- a/crates/buzz-db/src/store/channel_members.rs +++ b/crates/buzz-db/src/store/channel_members.rs @@ -1279,14 +1279,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, @@ -1307,7 +1309,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"), @@ -1328,14 +1330,18 @@ 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. +/// The channels that a pubkey can read but that [`get_accessible_channel_ids`] +/// leaves out, each with its type: +/// - channels of non-legacy types (system channels, and any type added later) +/// where it is an active member, and every open one; +/// - every unlisted open channel of a legacy type. 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. An unlisted channel has a legacy type, so a system opt-in never +/// adds it. pub async fn get_readable_named_only_channels( pool: &PgPool, community_id: CommunityId, @@ -1351,14 +1357,19 @@ pub async fn get_readable_named_only_channels( SELECT c.id AS channel_id, c.channel_type::text AS channel_type 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' + 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 + ) + ) ) + OR ({legacy_types} AND c.visibility = 'open' AND c.unlisted) ) "#, legacy_types = crate::store::channel::legacy_channel_type_sql("c.channel_type"), @@ -1546,7 +1557,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 "#, ) @@ -1605,7 +1616,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. @@ -1631,7 +1643,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!( @@ -1642,7 +1655,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 diff --git a/crates/buzz-db/src/store/dm.rs b/crates/buzz-db/src/store/dm.rs index 860584aec46..0c86e13f050 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 198bb6f407e..d2e1ccf6b67 100644 --- a/crates/buzz-db/src/store/mod.rs +++ b/crates/buzz-db/src/store/mod.rs @@ -86,5 +86,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 00000000000..061aa3b4bf7 --- /dev/null +++ b/crates/buzz-db/src/store/unlisted_channels_postgres_tests.rs @@ -0,0 +1,223 @@ +//! 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.db + .create_channel( + self.community, + name, + ChannelType::Stream, + 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() + } + + async fn named_only(&self, reader: &Keys) -> Vec<(Uuid, String)> { + self.db + .get_readable_named_only_channels(self.community, &reader.public_key().to_bytes()) + .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).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) + .await + .iter() + .any(|(id, _)| *id == unlisted)); +} + +/// 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) + .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) + .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)); +} diff --git a/crates/buzz-relay/src/handlers/side_effects.rs b/crates/buzz-relay/src/handlers/side_effects.rs index c5814055917..4f7287440a1 100644 --- a/crates/buzz-relay/src/handlers/side_effects.rs +++ b/crates/buzz-relay/src/handlers/side_effects.rs @@ -694,6 +694,7 @@ pub async fn validate_admin_event( "ttl", "t", "join_role", + "unlisted", ]; let has_recognized = event .tags @@ -701,22 +702,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")); } } } @@ -804,7 +807,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(); @@ -815,6 +819,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?; @@ -836,7 +841,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" )) } } @@ -1760,6 +1765,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}")), + }); + } _ => {} } } @@ -1790,6 +1804,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 @@ -1810,6 +1829,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 @@ -1827,6 +1847,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 diff --git a/crates/buzz-relay/src/nip11.rs b/crates/buzz-relay/src/nip11.rs index d61566efbb7..761c9e8d9e6 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 e9ec7609566..86256d3eb90 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)) } @@ -3642,8 +3651,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 +3670,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 +3687,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 +3696,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 +3715,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 +3723,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 +3733,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(_)) )); } diff --git a/crates/buzz-test-client/tests/e2e_relay.rs b/crates/buzz-test-client/tests/e2e_relay.rs index 7f39eb7040f..8bdd9dca9d6 100644 --- a/crates/buzz-test-client/tests/e2e_relay.rs +++ b/crates/buzz-test-client/tests/e2e_relay.rs @@ -3113,6 +3113,165 @@ 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)); +} + /// 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/migrations/0069_channel_unlisted.sql b/migrations/0069_channel_unlisted.sql new file mode 100644 index 00000000000..bd0f0556d2e --- /dev/null +++ b/migrations/0069_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 4d55c294ed6..102a441e8fb 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) ); From d3e0e44cebab4ccf8c330e24661f5a062b335fcd Mon Sep 17 00:00:00 2001 From: Bradley Axen Date: Sat, 24 Oct 2026 18:06:19 -0700 Subject: [PATCH 3/8] docs(nip-cl): note unlisted open channels as named-only Co-authored-by: mbot <3754f8729004d95654c46dbab3129e4ab9ef05cc2534e2a3fbfc155983bd637b@buzz.block.builderlab.xyz> Signed-off-by: Bradley Axen --- docs/nips/NIP-CL.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/docs/nips/NIP-CL.md b/docs/nips/NIP-CL.md index 843d33f485c..9b988292803 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 From 3e6b7b47075590cbaebefc5d44eaa8d4ec218c8a Mon Sep 17 00:00:00 2001 From: Bradley Axen Date: Sun, 25 Oct 2026 17:45:25 -0700 Subject: [PATCH 4/8] feat(relay): accept unlisted on kind:9007 A channel can now start unlisted, so a new Solo chat stays unlisted when any client later opens it, and sharing never lists it even for a moment. - kind:9007 takes ["unlisted", "true"|"false"]; a missing tag means listed. Any other value is refused before the event is stored, as on kind:9002. The flag is written with the channel row on both create paths (client UUID via create_channel_with_event, relay-chosen UUID via create_labeled_channel). - buzz_sdk::build_create_channel takes `unlisted: bool`; buzz channels create --unlisted. - test_nip29_solo_share_and_convert_wire exercises the Solo flows as single events: create unlisted, share (open + unlisted + join_role guest), read by #h (history and live) as a non-member, pin (9021), unpin (9022), unshare (private), and convert (["t", ""] + unlisted false). Added to the CI relay E2E step. Co-authored-by: mbot <3754f8729004d95654c46dbab3129e4ab9ef05cc2534e2a3fbfc155983bd637b@buzz.block.builderlab.xyz> Signed-off-by: Bradley Axen --- .github/workflows/_ci-relay.yml | 1 + NOSTR.md | 7 +- crates/buzz-cli/src/commands/channels.rs | 19 +- crates/buzz-cli/src/lib.rs | 8 +- .../src/store/admission_postgres_tests.rs | 5 + crates/buzz-db/src/store/channel.rs | 22 +- .../store/unlisted_channels_postgres_tests.rs | 86 ++++++ crates/buzz-relay/src/handlers/ingest.rs | 5 + .../buzz-relay/src/handlers/side_effects.rs | 24 ++ crates/buzz-sdk/src/builders.rs | 37 +++ crates/buzz-test-client/tests/e2e_relay.rs | 287 ++++++++++++++++++ 11 files changed, 490 insertions(+), 11 deletions(-) diff --git a/.github/workflows/_ci-relay.yml b/.github/workflows/_ci-relay.yml index 69d4bc478cc..fe39aa15a65 100644 --- a/.github/workflows/_ci-relay.yml +++ b/.github/workflows/_ci-relay.yml @@ -629,6 +629,7 @@ 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_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 6948fe18d76..60ef879ff38 100644 --- a/NOSTR.md +++ b/NOSTR.md @@ -50,7 +50,7 @@ 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. | | **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`/`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. | @@ -58,7 +58,7 @@ PGPASSWORD=buzz_dev psql -h localhost -U buzz -d buzz -c \ | **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; `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. 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). | +| **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) | @@ -166,6 +166,9 @@ and join it, but it does not show up for people who are not members. 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 is left out of every read that does not name it: a filter without `#h` (including kind:39000 discovery without `#d`, and `#P` creator queries), COUNT, NIP-50 search, artifact diff --git a/crates/buzz-cli/src/commands/channels.rs b/crates/buzz-cli/src/commands/channels.rs index 1ee8f6a11fd..2321fc02575 100644 --- a/crates/buzz-cli/src/commands/channels.rs +++ b/crates/buzz-cli/src/commands/channels.rs @@ -662,6 +662,7 @@ pub async fn cmd_create_channel( visibility: &str, description: Option<&str>, ttl: Option, + unlisted: bool, ) -> Result<(), CliError> { match channel_type { "stream" | "forum" => {} @@ -694,9 +695,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 +1431,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 = @@ -1477,6 +1486,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)?; @@ -1916,6 +1926,7 @@ pub async fn dispatch( visibility, description, ttl, + unlisted, template, templates_file, } => { @@ -1929,6 +1940,7 @@ pub async fn dispatch( visibility.as_ref().map(|v| v.to_string()).as_deref(), description.as_deref(), ttl, + unlisted, ) .await } else { @@ -1945,6 +1957,7 @@ pub async fn dispatch( &visibility.to_string(), description.as_deref(), ttl, + unlisted, ) .await } diff --git a/crates/buzz-cli/src/lib.rs b/crates/buzz-cli/src/lib.rs index e3b33976528..caa5b6f9fcb 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,12 @@ 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`. + #[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. diff --git a/crates/buzz-db/src/store/admission_postgres_tests.rs b/crates/buzz-db/src/store/admission_postgres_tests.rs index 6b047233024..c5b23f41e6a 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/channel.rs b/crates/buzz-db/src/store/channel.rs index b4343ba67ac..e577113ef9f 100644 --- a/crates/buzz-db/src/store/channel.rs +++ b/crates/buzz-db/src/store/channel.rs @@ -114,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!( @@ -138,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) @@ -152,6 +153,7 @@ pub async fn create_channel( .bind(created_by) .bind(ttl_seconds) .bind(labels) + .bind(unlisted) .execute(tx.conn()) .await?; @@ -229,6 +231,7 @@ pub async fn create_channel_with_id( created_by, ttl_seconds, labels, + unlisted: false, members: &[], }, ) @@ -272,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], @@ -345,6 +350,7 @@ async fn insert_channel_rows( created_by, ttl_seconds, labels, + unlisted, members, } = channel; let community_id = tx.community(); @@ -368,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 "#, ) @@ -383,6 +389,7 @@ async fn insert_channel_rows( .bind(created_by) .bind(ttl_seconds) .bind(labels) + .bind(unlisted) .execute(tx.conn()) .await? .rows_affected(); @@ -1512,6 +1519,7 @@ impl Db { created_by, ttl_seconds, &[], + false, ) .await } @@ -1599,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( @@ -1612,6 +1621,7 @@ impl Db { created_by: &[u8], ttl_seconds: Option, labels: &[String], + unlisted: bool, ) -> Result { create_channel( &self.pool, @@ -1623,6 +1633,7 @@ impl Db { created_by, ttl_seconds, labels, + unlisted, ) .await } @@ -2260,6 +2271,7 @@ mod postgres_tests { created_by, ttl_seconds: None, labels: &[], + unlisted: false, members, } } diff --git a/crates/buzz-db/src/store/unlisted_channels_postgres_tests.rs b/crates/buzz-db/src/store/unlisted_channels_postgres_tests.rs index 061aa3b4bf7..6ce1b4e822a 100644 --- a/crates/buzz-db/src/store/unlisted_channels_postgres_tests.rs +++ b/crates/buzz-db/src/store/unlisted_channels_postgres_tests.rs @@ -221,3 +221,89 @@ async fn group_metadata_publishes_the_stored_unlisted_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) + .await + .contains(&(chat, "stream".to_owned()))); + assert!(f.implicit(&f.owner).await.contains(&chat)); + } +} diff --git a/crates/buzz-relay/src/handlers/ingest.rs b/crates/buzz-relay/src/handlers/ingest.rs index 1b162cc3293..6486f9f2a15 100644 --- a/crates/buzz-relay/src/handlers/ingest.rs +++ b/crates/buzz-relay/src/handlers/ingest.rs @@ -2471,6 +2471,7 @@ struct ChannelCreateRequest { description: Option, ttl_seconds: Option, labels: Vec, + unlisted: bool, members: Vec>, } @@ -3202,6 +3203,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) @@ -3220,6 +3223,7 @@ async fn ingest_event_inner( }), ttl_seconds: super::resolve_ttl(&event, state.config.ephemeral_ttl_override), labels, + unlisted, members, }); } @@ -3482,6 +3486,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 4f7287440a1..32214e8f2cf 100644 --- a/crates/buzz-relay/src/handlers/side_effects.rs +++ b/crates/buzz-relay/src/handlers/side_effects.rs @@ -1960,6 +1960,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, @@ -2087,6 +2108,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. @@ -2121,6 +2143,7 @@ async fn handle_create_group( &actor_bytes, ttl_seconds, &labels, + unlisted, ) .await?; metrics::counter!( @@ -2144,6 +2167,7 @@ async fn handle_create_group( &actor_bytes, ttl_seconds, &labels, + unlisted, ) .await?; metrics::counter!( diff --git a/crates/buzz-sdk/src/builders.rs b/crates/buzz-sdk/src/builders.rs index 86256d3eb90..0791ebaa98d 100644 --- a/crates/buzz-sdk/src/builders.rs +++ b/crates/buzz-sdk/src/builders.rs @@ -917,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() { @@ -942,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)) } @@ -3821,6 +3830,7 @@ mod tests { Some(ChannelKind::Stream), Some("General chat"), None, + false, ) .unwrap(), ); @@ -3842,6 +3852,7 @@ mod tests { None::, None, None, + false, ) .unwrap(), ); @@ -3859,6 +3870,7 @@ mod tests { None::, None, None, + false, ) .unwrap(), ); @@ -3875,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(); @@ -3891,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 8bdd9dca9d6..f49fbf2909a 100644 --- a/crates/buzz-test-client/tests/e2e_relay.rs +++ b/crates/buzz-test-client/tests/e2e_relay.rs @@ -3272,6 +3272,293 @@ async fn test_nip29_unlisted_channel_wire() { .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.is_empty()); + 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( From 62ddd49d0c2c6ee8cbd7f53ae0b4e633c0cbdd5d Mon Sep 17 00:00:00 2001 From: Bradley Axen Date: Sun, 25 Oct 2026 18:19:45 -0700 Subject: [PATCH 5/8] test(relay): scope the Solo wire test's listing check to its chat CI runs each wire test as its own cargo test against one shared relay, and `since` starts a second early, so the stranger could list open channels the previous test had just created. Assert that the chat created unlisted is not listed instead of asserting an empty list. Co-authored-by: mbot <3754f8729004d95654c46dbab3129e4ab9ef05cc2534e2a3fbfc155983bd637b@buzz.block.builderlab.xyz> Signed-off-by: Bradley Axen --- crates/buzz-test-client/tests/e2e_relay.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/crates/buzz-test-client/tests/e2e_relay.rs b/crates/buzz-test-client/tests/e2e_relay.rs index f49fbf2909a..c0bb0826aa3 100644 --- a/crates/buzz-test-client/tests/e2e_relay.rs +++ b/crates/buzz-test-client/tests/e2e_relay.rs @@ -3364,7 +3364,9 @@ async fn test_nip29_solo_share_and_convert_wire() { 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.is_empty()); + assert!(!listed_channel_ids(&url, &stranger, since) + .await + .contains(&born_unlisted)); let (accepted, msg) = submit_ws( &url, &owner, From 61bb10ab0933bde8d6ed3edc898dba282885f4d6 Mon Sep 17 00:00:00 2001 From: Bradley Axen Date: Sun, 25 Oct 2026 19:34:20 -0700 Subject: [PATCH 6/8] perf(relay): load only the named unlisted channels A non-member who opens a pinned Solo chat by id names it on every sync, and each such request ran get_readable_named_only_channels, which returned every unlisted open chat in the community, uncached, on the writer pool. Its unlisted branch now takes the channels the request names: the filters' #h/#d values outside the implicit set, a thread window's channels, or a workflow's channel. The system-channel branch is unchanged, since #t:system/#P opt-in still needs the full set. Co-authored-by: mbot <3754f8729004d95654c46dbab3129e4ab9ef05cc2534e2a3fbfc155983bd637b@buzz.block.builderlab.xyz> Signed-off-by: Bradley Axen --- crates/buzz-db/src/store/channel_members.rs | 18 +++++--- .../store/unlisted_channels_postgres_tests.rs | 46 ++++++++++++++++--- .../src/api/bridge/thread_window.rs | 12 +++-- crates/buzz-relay/src/api/workflows.rs | 2 +- .../src/handlers/system_channels.rs | 14 +++++- 5 files changed, 72 insertions(+), 20 deletions(-) diff --git a/crates/buzz-db/src/store/channel_members.rs b/crates/buzz-db/src/store/channel_members.rs index 6b142110761..795c331d047 100644 --- a/crates/buzz-db/src/store/channel_members.rs +++ b/crates/buzz-db/src/store/channel_members.rs @@ -1334,18 +1334,20 @@ pub async fn get_accessible_channel_ids( /// leaves out, each with its type: /// - channels of non-legacy types (system channels, and any type added later) /// where it is an active member, and every open one; -/// - every unlisted open channel of a legacy type. 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. +/// - each unlisted open channel of a legacy type among `named`, the channels +/// the request names. 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. /// /// 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. An unlisted channel has a legacy type, so a system opt-in never -/// adds it. +/// adds it, and only the named ones are loaded: a community can have many. pub async fn get_readable_named_only_channels( pool: &PgPool, community_id: CommunityId, pubkey: &[u8], + named: &[Uuid], ) -> Result> { let mut connection = crate::observability::acquire_writer( pool, @@ -1369,7 +1371,7 @@ pub async fn get_readable_named_only_channels( ) ) ) - OR ({legacy_types} AND c.visibility = 'open' AND c.unlisted) + 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"), @@ -1378,6 +1380,7 @@ pub async fn get_readable_named_only_channels( let rows = sqlx::query(sqlx::AssertSqlSafe(sql)) .bind(community_id.as_uuid()) .bind(pubkey) + .bind(named) .fetch_all(&mut *connection) .await?; @@ -2093,14 +2096,15 @@ impl Db { } /// The channels of non-legacy types a pubkey can read (member, or open), - /// each with its type. + /// and the unlisted open channels among `named`, each with its type. #[datastore_span(name = "get_readable_named_only_channels", system = "postgresql")] pub async fn get_readable_named_only_channels( &self, community_id: CommunityId, pubkey: &[u8], + named: &[Uuid], ) -> Result> { - get_readable_named_only_channels(&self.pool, community_id, pubkey).await + 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/unlisted_channels_postgres_tests.rs b/crates/buzz-db/src/store/unlisted_channels_postgres_tests.rs index 6ce1b4e822a..5facb186bdd 100644 --- a/crates/buzz-db/src/store/unlisted_channels_postgres_tests.rs +++ b/crates/buzz-db/src/store/unlisted_channels_postgres_tests.rs @@ -90,9 +90,14 @@ impl Fixture { .collect() } - async fn named_only(&self, reader: &Keys) -> Vec<(Uuid, String)> { + /// The named-only channels `reader` can read when a request names `named`. + async fn named_only(&self, reader: &Keys, named: &[Uuid]) -> Vec<(Uuid, String)> { self.db - .get_readable_named_only_channels(self.community, &reader.public_key().to_bytes()) + .get_readable_named_only_channels( + self.community, + &reader.public_key().to_bytes(), + named, + ) .await .expect("readable named-only channels") } @@ -124,7 +129,7 @@ async fn unlisted_open_channel_is_named_only_for_non_members() { let shown = f.listed(&f.outsider).await; assert!(shown.contains(&listed)); assert!(!shown.contains(&unlisted), "channel list"); - let named = f.named_only(&f.outsider).await; + let named = f.named_only(&f.outsider, &[listed, unlisted]).await; assert!( named.contains(&(unlisted, "stream".to_owned())), "readable by name: {named:?}" @@ -161,12 +166,39 @@ async fn unlisted_open_channel_is_named_only_for_non_members() { assert!(f.implicit(&stranger).await.contains(&unlisted)); assert!(f.listed(&stranger).await.contains(&unlisted)); assert!(!f - .named_only(&stranger) + .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()))); +} + /// 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. @@ -182,7 +214,7 @@ async fn unlisted_private_channel_stays_private_and_opens_unlisted() { assert!(!f.implicit(&f.outsider).await.contains(&chat)); assert!(!f.listed(&f.outsider).await.contains(&chat)); assert!( - !f.named_only(&f.outsider) + !f.named_only(&f.outsider, &[chat]) .await .iter() .any(|(id, _)| *id == chat), @@ -204,7 +236,7 @@ async fn unlisted_private_channel_stays_private_and_opens_unlisted() { assert!(opened.unlisted, "opening keeps the stored flag"); assert!(!f.implicit(&f.outsider).await.contains(&chat)); assert!(f - .named_only(&f.outsider) + .named_only(&f.outsider, &[chat]) .await .contains(&(chat, "stream".to_owned()))); } @@ -301,7 +333,7 @@ async fn channel_created_unlisted_is_never_listed() { assert!(!f.implicit(&f.outsider).await.contains(&chat)); assert!(!f.listed(&f.outsider).await.contains(&chat)); assert!(f - .named_only(&f.outsider) + .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 06436d8cc85..efe1ab8d8ff 100644 --- a/crates/buzz-relay/src/api/bridge/thread_window.rs +++ b/crates/buzz-relay/src/api/bridge/thread_window.rs @@ -100,13 +100,17 @@ 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() diff --git a/crates/buzz-relay/src/api/workflows.rs b/crates/buzz-relay/src/api/workflows.rs index 5fb6494bfd9..8909d2f55be 100644 --- a/crates/buzz-relay/src/api/workflows.rs +++ b/crates/buzz-relay/src/api/workflows.rs @@ -127,7 +127,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!( diff --git a/crates/buzz-relay/src/handlers/system_channels.rs b/crates/buzz-relay/src/handlers/system_channels.rs index 13ad914fd3d..b5e0393c533 100644 --- a/crates/buzz-relay/src/handlers/system_channels.rs +++ b/crates/buzz-relay/src/handlers/system_channels.rs @@ -139,6 +139,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 +197,11 @@ pub(crate) async fn readable_named_only_channels_for_filters( { return Ok(NamedOnlyChannels::default()); } + let named = named_outside(filters, implicit); let mut readable = NamedOnlyChannels::from_typed( 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 { From 9760e6c4f1451bb9a441dc59e48ad7b48d812644 Mon Sep 17 00:00:00 2001 From: Bradley Axen Date: Mon, 26 Oct 2026 06:25:01 -0700 Subject: [PATCH 7/8] fix(cli): refuse --unlisted on relays without buzz-unlisted-channels An older relay applies the rest of a kind:9007 or kind:9002 and ignores unlisted, so an open channel created or updated with --unlisted ended up listed. channels create --unlisted (with or without --template) and channels update --unlisted true now read the relay's NIP-11 first and refuse, before anything is written, when supported_extensions lacks buzz-unlisted-channels. --unlisted false needs no check: such a relay lists every open channel already. Co-authored-by: mbot <3754f8729004d95654c46dbab3129e4ab9ef05cc2534e2a3fbfc155983bd637b@buzz.block.builderlab.xyz> Signed-off-by: Bradley Axen --- crates/buzz-cli/src/commands/channels.rs | 177 +++++++++++++++++++++++ crates/buzz-cli/src/lib.rs | 6 +- 2 files changed, 181 insertions(+), 2 deletions(-) diff --git a/crates/buzz-cli/src/commands/channels.rs b/crates/buzz-cli/src/commands/channels.rs index 2321fc02575..d4a8d6fe936 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, @@ -682,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(); @@ -1456,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 @@ -1655,6 +1685,11 @@ pub async fn cmd_update_channel( 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, @@ -4707,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 caa5b6f9fcb..1dc1438e1b0 100644 --- a/crates/buzz-cli/src/lib.rs +++ b/crates/buzz-cli/src/lib.rs @@ -685,7 +685,8 @@ pub enum ChannelsCmd { /// 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`. + /// --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): @@ -730,7 +731,8 @@ pub enum ChannelsCmd { /// 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. + /// 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, }, From a5a65264bdf13c848e823ea20337a19aa11e566e Mon Sep 17 00:00:00 2001 From: Bradley Axen Date: Mon, 26 Oct 2026 06:25:04 -0700 Subject: [PATCH 8/8] fix(relay): leave unlisted system channels out of opt-in discovery The system branch of get_readable_named_only_channels loaded every open system channel whatever its unlisted flag, so #t:["system"] and #P discovery showed a non-member an unlisted open system channel. An open system channel now counts for a non-member only when it is listed or the request names it, and each row says whether the opt-in may add it: not for an unlisted channel the reader is not a member of, so a sibling of a filter that names it does not gain it either (NIP-CL). Members keep it as before. Co-authored-by: mbot <3754f8729004d95654c46dbab3129e4ab9ef05cc2534e2a3fbfc155983bd637b@buzz.block.builderlab.xyz> Signed-off-by: Bradley Axen --- NOSTR.md | 9 +- crates/buzz-db/src/store/channel_members.rs | 66 +++++++---- .../store/unlisted_channels_postgres_tests.rs | 85 +++++++++++++- .../src/api/bridge/thread_window.rs | 2 +- crates/buzz-relay/src/api/workflows.rs | 2 +- .../handlers/system_channel_postgres_tests.rs | 104 ++++++++++++++++++ .../src/handlers/system_channels.rs | 26 ++++- 7 files changed, 261 insertions(+), 33 deletions(-) diff --git a/NOSTR.md b/NOSTR.md index 60ef879ff38..e8cdab80007 100644 --- a/NOSTR.md +++ b/NOSTR.md @@ -169,10 +169,11 @@ and join it, but it does not show up for people who are not members. - 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 is left out of every read that - does not name it: a filter without `#h` (including kind:39000 discovery - without `#d`, and `#P` creator queries), COUNT, NIP-50 search, artifact - queries and the channel list. A filter that names it by `#h`, or by `#d` +- 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)). diff --git a/crates/buzz-db/src/store/channel_members.rs b/crates/buzz-db/src/store/channel_members.rs index 795c331d047..0f84a5b5ab4 100644 --- a/crates/buzz-db/src/store/channel_members.rs +++ b/crates/buzz-db/src/store/channel_members.rs @@ -1330,25 +1330,40 @@ pub async fn get_accessible_channel_ids( .collect() } +/// 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, each with its type: +/// leaves out: /// - channels of non-legacy types (system channels, and any type added later) -/// where it is an active member, and every open one; -/// - each unlisted open channel of a legacy type among `named`, the channels -/// the request names. 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. +/// 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. /// /// 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. An unlisted channel has a legacy type, so a system opt-in never -/// adds it, and only the named ones are loaded: a community can have many. +/// 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], named: &[Uuid], -) -> Result> { +) -> Result> { let mut connection = crate::observability::acquire_writer( pool, crate::observability::WriterOperation::Authorization, @@ -1356,27 +1371,29 @@ 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 - ) + (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) @@ -1385,7 +1402,13 @@ pub async fn get_readable_named_only_channels( .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() } @@ -2095,15 +2118,16 @@ 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), - /// and the unlisted open channels among `named`, 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], named: &[Uuid], - ) -> Result> { + ) -> Result> { get_readable_named_only_channels(&self.pool, community_id, pubkey, named).await } diff --git a/crates/buzz-db/src/store/unlisted_channels_postgres_tests.rs b/crates/buzz-db/src/store/unlisted_channels_postgres_tests.rs index 5facb186bdd..a47647ce3b0 100644 --- a/crates/buzz-db/src/store/unlisted_channels_postgres_tests.rs +++ b/crates/buzz-db/src/store/unlisted_channels_postgres_tests.rs @@ -44,11 +44,22 @@ impl Fixture { /// 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, - ChannelType::Stream, + channel_type, visibility, None, &self.owner.public_key().to_bytes(), @@ -90,8 +101,31 @@ impl Fixture { .collect() } - /// The named-only channels `reader` can read when a request names `named`. + /// 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, @@ -199,6 +233,53 @@ async fn only_the_named_unlisted_channels_are_loaded() { 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. diff --git a/crates/buzz-relay/src/api/bridge/thread_window.rs b/crates/buzz-relay/src/api/bridge/thread_window.rs index efe1ab8d8ff..fc5989172b4 100644 --- a/crates/buzz-relay/src/api/bridge/thread_window.rs +++ b/crates/buzz-relay/src/api/bridge/thread_window.rs @@ -114,7 +114,7 @@ async fn access_sets( .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 8909d2f55be..ed33f956f90 100644 --- a/crates/buzz-relay/src/api/workflows.rs +++ b/crates/buzz-relay/src/api/workflows.rs @@ -136,7 +136,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/system_channel_postgres_tests.rs b/crates/buzz-relay/src/handlers/system_channel_postgres_tests.rs index 326c637a033..710fbdbe90b 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 b5e0393c533..9ff25ce20d4 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); } } @@ -198,7 +216,7 @@ pub(crate) async fn readable_named_only_channels_for_filters( return Ok(NamedOnlyChannels::default()); } let named = named_outside(filters, implicit); - let mut readable = NamedOnlyChannels::from_typed( + let mut readable = NamedOnlyChannels::from_readable( state .db .get_readable_named_only_channels(community, pubkey, &named)