From e04bd14f48a27caa178a0f5e360f9e89aa826756 Mon Sep 17 00:00:00 2001 From: Bradley Axen Date: Sun, 25 Oct 2026 15:53:51 -0700 Subject: [PATCH 1/6] 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 823d13902811ee7dfd0eb0ad6bf629b11ce899f2 Mon Sep 17 00:00:00 2001 From: Bradley Axen Date: Fri, 23 Oct 2026 13:12:44 -0700 Subject: [PATCH 2/6] feat(relay): add several people with one kind:9000 A NIP-29 put-user may now name up to 100 people, one `p` tag each. Each `["p", pubkey, role]` may carry its own role, falling back to the event's `role` tag. Every person gets the same checks a single add does (decide_put_user, the agent's channel_add_policy, then put_member under the channel's membership lock), so one refused person no longer refuses the others. An event naming several people reports each result in its OK message: `members: {"added", "updated", "already_member", "rejected": [{reason, pubkeys}]}`, listing only non-empty results. It is refused only when it is malformed or nobody it names may be added. One-person events keep their empty OK message and their error text. Re-adding someone at their current role no longer posts a second member_joined notice; the member-added notification still goes out. The group-state events are published once per event, not once per person. Co-authored-by: claude00 Co-Authored-By: Claude Opus 5.5 Signed-off-by: Bradley Axen --- .github/workflows/_ci-relay.yml | 1 + NOSTR.md | 2 +- crates/buzz-db/src/store/channel_members.rs | 61 ++- crates/buzz-relay/src/handlers/ingest.rs | 29 +- crates/buzz-relay/src/handlers/mod.rs | 2 + .../src/handlers/put_user_postgres_tests.rs | 425 ++++++++++++++++++ .../buzz-relay/src/handlers/side_effects.rs | 402 ++++++++++++----- crates/buzz-test-client/tests/e2e_relay.rs | 87 ++++ .../messages/lib/membershipGroupPayload.ts | 5 +- 9 files changed, 889 insertions(+), 125 deletions(-) create mode 100644 crates/buzz-relay/src/handlers/put_user_postgres_tests.rs diff --git a/.github/workflows/_ci-relay.yml b/.github/workflows/_ci-relay.yml index c02086dfda2..1092740293e 100644 --- a/.github/workflows/_ci-relay.yml +++ b/.github/workflows/_ci-relay.yml @@ -627,6 +627,7 @@ jobs: cargo test -p buzz-test-client --test e2e_relay invite -- --ignored --nocapture 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_batch_put_user_wire -- --ignored --nocapture cargo test -p buzz-test-client --test e2e_relay nip29_announce_channel_wire -- --ignored --nocapture cargo test -p buzz-test-client --test e2e_relay nip29_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 diff --git a/NOSTR.md b/NOSTR.md index 543f99b6bc7..dd6be47371b 100644 --- a/NOSTR.md +++ b/NOSTR.md @@ -51,7 +51,7 @@ PGPASSWORD=buzz_dev psql -h localhost -U buzz -d buzz -c \ | **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 | -| **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. | +| **Add user (kind:9000)** | ✅ | Open: any user, subject to target's `channel_add_policy` (`owner_only`/`nobody` can block). Private: owner/admin only. Self-add bypasses agent policy but not private-channel auth. A guest cannot add anyone. A new member with no role tag gets the channel's `join_role`; a non-member adding themselves may ask for `join_role` or a lower role. One event may name up to 100 people: one `p` tag each, with an optional role as the `p` tag's third element (NIP-29), falling back to the event's `role` tag. Each person is checked and added on their own. When the event names several people, the OK message is `members: ` followed by JSON listing the non-empty `added`, `updated` (role changed), `already_member` and `rejected` (`[{"reason", "pubkeys"}]`) results. The event is refused only when it is malformed or nobody it names may be added; then one person keeps the single-add error, and several get `invalid: members: {…}`. A resent event returns `duplicate:` without results; to retry an `error:` rejection, send a new event. Re-adding someone at their current role posts no second `member_joined` notice. | | **Remove user (kind:9001)** | ✅ | Self-remove allowed (with last-owner guard). Removing others: owner/admin only. | | **Edit group metadata (kind:9002)** | ✅ | `name`/`about`/label `t`/`visibility`/`ttl`/`archived`/`join_role` tags: owner/admin. `topic`/`purpose` tags: any member except a guest. Buzz applies 9002 as a partial update: an absent tag leaves that setting unchanged. | | **Admin delete event (kind:9005)** | ✅ | Event author can always delete own. Otherwise owner/admin required. Target must be in same channel. | diff --git a/crates/buzz-db/src/store/channel_members.rs b/crates/buzz-db/src/store/channel_members.rs index 6013ca869a3..a08fc351942 100644 --- a/crates/buzz-db/src/store/channel_members.rs +++ b/crates/buzz-db/src/store/channel_members.rs @@ -743,6 +743,38 @@ pub async fn put_member( requested: Option, invited_by: Option<&[u8]>, ) -> Result { + put_member_with_prior_role( + pool, + community_id, + channel_id, + pubkey, + requested, + invited_by, + ) + .await + .map(|put| put.member) +} + +/// A [`put_member`] write, and the role the member held just before it. +#[derive(Debug, Clone)] +pub struct PutMemberResult { + /// The membership row after the write. + pub member: MemberRecord, + /// The active role before the write; `None` when they were not an active + /// member. Read under the same lock as the write. + pub prior_role: Option, +} + +/// [`put_member`], also reporting the member's role before the write, so a +/// caller can tell an add from a role change from a no-op. +pub async fn put_member_with_prior_role( + pool: &PgPool, + community_id: CommunityId, + channel_id: Uuid, + pubkey: &[u8], + requested: Option, + invited_by: Option<&[u8]>, +) -> Result { if pubkey.len() != 32 { return Err(DbError::InvalidData(format!( "pubkey must be 32 bytes, got {}", @@ -838,9 +870,12 @@ pub async fn put_member( .fetch_one(tx.conn()) .await?; - let record = row_to_member_record(row)?; + let member = row_to_member_record(row)?; tx.commit().await?; - Ok(record) + Ok(PutMemberResult { + member, + prior_role: current_role, + }) } /// The client-visible reason for a [`buzz_core::channel::MemberPutDenial`]. @@ -1959,6 +1994,28 @@ impl Db { .await } + /// [`Db::put_member`], also reporting the member's role before the write + /// (see [`put_member_with_prior_role`]). + #[datastore_span(name = "put_member", system = "postgresql")] + pub async fn put_member_with_prior_role( + &self, + community_id: CommunityId, + channel_id: Uuid, + pubkey: &[u8], + requested: Option, + invited_by: Option<&[u8]>, + ) -> Result { + put_member_with_prior_role( + &self.pool, + community_id, + channel_id, + pubkey, + requested, + invited_by, + ) + .await + } + /// Removes a member from a channel. #[datastore_span(name = "remove_member", system = "postgresql")] pub async fn remove_member( diff --git a/crates/buzz-relay/src/handlers/ingest.rs b/crates/buzz-relay/src/handlers/ingest.rs index 1b162cc3293..b44b44c0250 100644 --- a/crates/buzz-relay/src/handlers/ingest.rs +++ b/crates/buzz-relay/src/handlers/ingest.rs @@ -2998,11 +2998,22 @@ async fn ingest_event_inner( validate_huddle_lifecycle_event(tenant, state, &event, kind_u32).await?; - if crate::handlers::side_effects::is_admin_kind(kind_u32) { - crate::handlers::side_effects::validate_admin_event(tenant, kind_u32, &event, state) - .await - .map_err(|e| IngestError::Rejected(format!("invalid: {e}")))?; - } + // kind:9000 is checked person by person; the plan carries each result + // through storage to `apply_put_user`. + let put_user = if kind_u32 == KIND_NIP29_PUT_USER { + Some( + crate::handlers::side_effects::validate_put_user(tenant, &event, state) + .await + .map_err(|e| IngestError::Rejected(format!("invalid: {e}")))?, + ) + } else { + if crate::handlers::side_effects::is_admin_kind(kind_u32) { + crate::handlers::side_effects::validate_admin_event(tenant, kind_u32, &event, state) + .await + .map_err(|e| IngestError::Rejected(format!("invalid: {e}")))?; + } + None + }; // Processed here (verify consent, mutate archived_identities, emit the // relay-signed 8002/8003 delta + 13535 snapshot), then — unlike the @@ -3628,7 +3639,11 @@ async fn ingest_event_inner( // Workflow deletion and kind:9002 applied their changes in the insert's // own transaction above. let persisted_atomically = workflow_deletion || kind_u32 == KIND_NIP29_EDIT_METADATA; - if !persisted_atomically && crate::handlers::side_effects::is_side_effect_kind(kind_u32) { + let mut message = String::new(); + if let Some(plan) = put_user { + message = crate::handlers::side_effects::apply_put_user(tenant, &event, state, plan).await; + } else if !persisted_atomically && crate::handlers::side_effects::is_side_effect_kind(kind_u32) + { if let Err(e) = crate::handlers::side_effects::handle_side_effects(tenant, kind_u32, &event, state) .await @@ -3701,7 +3716,7 @@ async fn ingest_event_inner( Ok(IngestResult { event_id: event_id_hex, accepted: true, - message: String::new(), + message, }) } diff --git a/crates/buzz-relay/src/handlers/mod.rs b/crates/buzz-relay/src/handlers/mod.rs index 222512aa0c5..3315ea8eb6b 100644 --- a/crates/buzz-relay/src/handlers/mod.rs +++ b/crates/buzz-relay/src/handlers/mod.rs @@ -36,6 +36,8 @@ pub mod moderation_notices; pub mod product_feedback; #[allow(dead_code, missing_docs)] pub mod push_lease; +#[cfg(test)] +mod put_user_postgres_tests; /// NIP-43 relay membership admin command handler (kinds 9030–9032). pub mod relay_admin; /// NIP-56 report (kind:1984) validation + moderation queue persistence. diff --git a/crates/buzz-relay/src/handlers/put_user_postgres_tests.rs b/crates/buzz-relay/src/handlers/put_user_postgres_tests.rs new file mode 100644 index 00000000000..ad93702f79f --- /dev/null +++ b/crates/buzz-relay/src/handlers/put_user_postgres_tests.rs @@ -0,0 +1,425 @@ +//! Postgres-backed tests for a kind:9000 that names several people: each is +//! checked and added on its own, and the OK message reports each result. + +use std::sync::Arc; + +use buzz_core::TenantContext; +use buzz_db::event::EventQuery; +use nostr::{Event, EventBuilder, Keys, Kind, Tag}; +use uuid::Uuid; + +use crate::handlers::group_state_postgres_tests::{ + community, create_channel, group_state_tags, http_auth, state, +}; +use crate::handlers::ingest::{ingest_event, IngestError, IngestResult}; +use crate::state::AppState; + +fn put_user(actor: &Keys, channel_id: Uuid, p_tags: &[&[&str]], extra: &[&[&str]]) -> Event { + let mut tags = vec![Tag::parse(["h", &channel_id.to_string()]).unwrap()]; + for tag in p_tags { + tags.push(Tag::parse(["p"].iter().chain(tag.iter()).copied()).unwrap()); + } + for tag in extra { + tags.push(Tag::parse(tag.iter().copied()).unwrap()); + } + EventBuilder::new(Kind::Custom(9000), "") + .tags(tags) + .sign_with_keys(actor) + .expect("sign 9000") +} + +async fn ingest( + state: &Arc, + tenant: &TenantContext, + event: Event, + keys: &Keys, +) -> Result { + ingest_event(state, tenant, event, http_auth(keys)).await +} + +/// The JSON after the `members: ` prefix of an OK message. +fn report(message: &str) -> serde_json::Value { + let json = message + .strip_prefix("members: ") + .unwrap_or_else(|| panic!("no members report: {message}")); + serde_json::from_str(json).expect("members report JSON") +} + +async fn role_of( + state: &AppState, + tenant: &TenantContext, + channel_id: Uuid, + keys: &Keys, +) -> Option { + state + .db + .get_members(tenant.community(), channel_id) + .await + .expect("members") + .into_iter() + .find(|member| member.pubkey == keys.public_key().to_bytes()) + .map(|member| member.role) +} + +async fn refuse_third_party_adds(state: &AppState, tenant: &TenantContext, agent: &Keys) { + let agent_bytes = agent.public_key().to_bytes().to_vec(); + state + .db + .ensure_user(tenant.community(), &agent_bytes) + .await + .expect("agent user"); + state + .db + .set_channel_add_policy(tenant.community(), &agent_bytes, "nobody") + .await + .expect("policy"); +} + +async fn joined_notices( + state: &AppState, + tenant: &TenantContext, + channel_id: Uuid, + target: &Keys, +) -> usize { + state + .db + .query_events(&EventQuery { + kinds: Some(vec![buzz_core::kind::KIND_SYSTEM_MESSAGE as i32]), + channel_id: Some(channel_id), + ..EventQuery::for_community(tenant.community()) + }) + .await + .expect("system messages") + .iter() + .filter(|stored| { + let content: serde_json::Value = + serde_json::from_str(&stored.event.content).unwrap_or_default(); + content["type"] == "member_joined" && content["target"] == target.public_key().to_hex() + }) + .count() +} + +/// One event adds a new person, leaves an existing member alone, takes a +/// per-`p` role, and reports a refused agent without refusing the others. +/// Mutations: refuse the event on any refused person → RED; read only the +/// first `p` → later people missing → RED; ignore the `p` role → agent is a +/// member, not a bot → RED; notify a no-op re-add → two notices → RED. +#[tokio::test] +#[ignore = "requires Postgres"] +async fn put_user_adds_each_person_and_reports_each_result() { + let state = state().await; + let tenant = community(&state, "put-user-batch").await; + let (owner, existing, person, bot, refusing) = ( + Keys::generate(), + Keys::generate(), + Keys::generate(), + Keys::generate(), + Keys::generate(), + ); + let channel_id = create_channel( + &state, + &tenant, + &owner, + &[ + &["visibility", "private"], + &["p", &existing.public_key().to_hex()], + ], + ) + .await; + refuse_third_party_adds(&state, &tenant, &refusing).await; + + let (existing_hex, person_hex, bot_hex, refusing_hex) = ( + existing.public_key().to_hex(), + person.public_key().to_hex(), + bot.public_key().to_hex(), + refusing.public_key().to_hex(), + ); + let result = ingest( + &state, + &tenant, + put_user( + &owner, + channel_id, + &[ + &[&existing_hex], + &[&person_hex], + &[&refusing_hex], + &[&bot_hex, "bot"], + &[&person_hex], + ], + &[], + ), + &owner, + ) + .await + .expect("put-user"); + assert!(result.accepted, "{}", result.message); + assert_eq!( + report(&result.message), + serde_json::json!({ + "added": [person_hex, bot_hex], + "already_member": [existing_hex], + "rejected": [{ + "reason": "policy:nobody — this agent has disabled external channel additions", + "pubkeys": [refusing_hex], + }], + }) + ); + + assert_eq!( + role_of(&state, &tenant, channel_id, &person) + .await + .as_deref(), + Some("member") + ); + assert_eq!( + role_of(&state, &tenant, channel_id, &bot).await.as_deref(), + Some("bot") + ); + assert_eq!(role_of(&state, &tenant, channel_id, &refusing).await, None); + assert_eq!( + joined_notices(&state, &tenant, channel_id, &person).await, + 1 + ); + assert_eq!( + joined_notices(&state, &tenant, channel_id, &existing).await, + 1 + ); + + let roster = group_state_tags(&state, &tenant, channel_id, 39002).await; + for hex in [&person_hex, &bot_hex] { + assert!( + roster.iter().any(|tag| tag[0] == "p" && &tag[1] == hex), + "{roster:?}" + ); + } +} + +/// A role change on an active member is reported as `updated`; the event's +/// `role` tag applies where a `p` has none. +/// Mutation: report every active member as already_member → RED. +#[tokio::test] +#[ignore = "requires Postgres"] +async fn put_user_reports_role_changes_as_updated() { + let state = state().await; + let tenant = community(&state, "put-user-updated").await; + let (owner, existing, person) = (Keys::generate(), Keys::generate(), Keys::generate()); + let channel_id = create_channel( + &state, + &tenant, + &owner, + &[&["p", &existing.public_key().to_hex()]], + ) + .await; + + let (existing_hex, person_hex) = (existing.public_key().to_hex(), person.public_key().to_hex()); + let result = ingest( + &state, + &tenant, + put_user( + &owner, + channel_id, + &[&[&existing_hex], &[&person_hex, ""]], + &[&["role", "admin"]], + ), + &owner, + ) + .await + .expect("put-user"); + assert_eq!( + report(&result.message), + serde_json::json!({ "added": [person_hex], "updated": [existing_hex] }) + ); + assert_eq!( + role_of(&state, &tenant, channel_id, &existing) + .await + .as_deref(), + Some("admin") + ); + assert_eq!( + role_of(&state, &tenant, channel_id, &person) + .await + .as_deref(), + Some("admin") + ); +} + +/// When nobody named may be added the event is refused: one person keeps the +/// single-add error, several get the grouped report. A malformed `p` refuses +/// the whole event. +/// Mutations: accept an all-refused event → RED; report a single refusal as +/// JSON → RED; skip malformed `p` tags → RED. +#[tokio::test] +#[ignore = "requires Postgres"] +async fn put_user_refuses_the_event_when_nobody_can_be_added() { + let state = state().await; + let tenant = community(&state, "put-user-refused").await; + let (owner, first, second) = (Keys::generate(), Keys::generate(), Keys::generate()); + let channel_id = create_channel(&state, &tenant, &owner, &[]).await; + refuse_third_party_adds(&state, &tenant, &first).await; + refuse_third_party_adds(&state, &tenant, &second).await; + let (first_hex, second_hex) = (first.public_key().to_hex(), second.public_key().to_hex()); + + let refusal = |result: Result| match result { + Err(IngestError::Rejected(message)) => message, + Err(error) => panic!("expected a rejection, got {error:?}"), + Ok(result) => panic!("expected a rejection, got OK {}", result.message), + }; + + let single = refusal( + ingest( + &state, + &tenant, + put_user(&owner, channel_id, &[&[&first_hex]], &[]), + &owner, + ) + .await, + ); + assert_eq!( + single, + "invalid: policy:nobody — this agent has disabled external channel additions" + ); + + let several = refusal( + ingest( + &state, + &tenant, + put_user(&owner, channel_id, &[&[&first_hex], &[&second_hex]], &[]), + &owner, + ) + .await, + ); + let json = several.strip_prefix("invalid: ").expect("invalid prefix"); + assert_eq!( + report(json), + serde_json::json!({ + "rejected": [{ + "reason": "policy:nobody — this agent has disabled external channel additions", + "pubkeys": [first_hex, second_hex], + }], + }) + ); + + let malformed = refusal( + ingest( + &state, + &tenant, + put_user(&owner, channel_id, &[&["not-hex"], &[&second_hex]], &[]), + &owner, + ) + .await, + ); + assert_eq!(malformed, "invalid: p tag must be a 32-byte hex pubkey"); +} + +/// Each person gets the checks a single add would: a plain member in an open +/// channel may add someone, but not grant one of them an elevated role. +/// Mutations: check the event-wide role instead of each `p` role → RED; report +/// JSON for a single add → RED. +#[tokio::test] +#[ignore = "requires Postgres"] +async fn put_user_checks_each_persons_role_for_the_actor() { + let state = state().await; + let tenant = community(&state, "put-user-member-actor").await; + let (owner, member, promoted, plain) = ( + Keys::generate(), + Keys::generate(), + Keys::generate(), + Keys::generate(), + ); + let channel_id = create_channel( + &state, + &tenant, + &owner, + &[ + &["visibility", "open"], + &["p", &member.public_key().to_hex()], + ], + ) + .await; + + let (promoted_hex, plain_hex) = (promoted.public_key().to_hex(), plain.public_key().to_hex()); + let result = ingest( + &state, + &tenant, + put_user( + &member, + channel_id, + &[&[&promoted_hex, "admin"], &[&plain_hex]], + &[], + ), + &member, + ) + .await + .expect("put-user"); + assert_eq!( + report(&result.message), + serde_json::json!({ + "added": [plain_hex], + "rejected": [{ + "reason": "only owners/admins may grant elevated roles", + "pubkeys": [promoted_hex], + }], + }) + ); + assert_eq!(role_of(&state, &tenant, channel_id, &promoted).await, None); + + // One person keeps a single add's empty OK message. + let single = ingest( + &state, + &tenant, + put_user(&member, channel_id, &[&[&promoted_hex]], &[]), + &member, + ) + .await + .expect("single put-user"); + assert!(single.accepted); + assert_eq!(single.message, ""); + assert_eq!( + role_of(&state, &tenant, channel_id, &promoted) + .await + .as_deref(), + Some("member") + ); +} + +/// One event may name at most 100 people. +/// Mutation: drop the cap → 101 accepted → RED; cap below 100 → RED. +#[tokio::test] +#[ignore = "requires Postgres"] +async fn put_user_names_at_most_one_hundred_people() { + let state = state().await; + let tenant = community(&state, "put-user-cap").await; + let owner = Keys::generate(); + let channel_id = create_channel(&state, &tenant, &owner, &[]).await; + let people: Vec = (0..101) + .map(|_| Keys::generate().public_key().to_hex()) + .collect(); + let tags = |count: usize| -> Vec> { + people[..count] + .iter() + .map(|hex| vec![hex.as_str()]) + .collect() + }; + let event = |count: usize| { + let tags = tags(count); + let refs: Vec<&[&str]> = tags.iter().map(Vec::as_slice).collect(); + put_user(&owner, channel_id, &refs, &[]) + }; + + match ingest(&state, &tenant, event(101), &owner).await { + Err(IngestError::Rejected(message)) => { + assert_eq!(message, "invalid: a put-user may name at most 100 people") + } + Err(error) => panic!("expected a rejection, got {error:?}"), + Ok(result) => panic!("expected a rejection, got OK {}", result.message), + } + + let result = ingest(&state, &tenant, event(100), &owner) + .await + .expect("put-user"); + assert_eq!( + report(&result.message)["added"].as_array().map(Vec::len), + Some(100) + ); +} diff --git a/crates/buzz-relay/src/handlers/side_effects.rs b/crates/buzz-relay/src/handlers/side_effects.rs index c5814055917..35f72fa5c87 100644 --- a/crates/buzz-relay/src/handlers/side_effects.rs +++ b/crates/buzz-relay/src/handlers/side_effects.rs @@ -335,7 +335,9 @@ pub async fn handle_side_effects( match kind { 0 => handle_kind0_profile(tenant, event, state).await, 5 => handle_standard_deletion_event(tenant, event, state).await, - 9000 => handle_put_user(tenant, event, state).await, + // kind:9000 belongs to the put-user path in ingest, which reports + // each person's result. + 9000 => Err(anyhow::anyhow!("kind:9000 requires apply_put_user")), 9001 => handle_remove_user(tenant, event, state).await, // kind:9002 belongs to the atomic persistence path in ingest. 9002 => Err(anyhow::anyhow!("kind:9002 requires atomic persistence")), @@ -594,64 +596,9 @@ pub async fn validate_admin_event( } match kind { - 9000 => { - // An absent role tag means "no role change requested": for an existing - // member that preserves the role they already hold, and only defaults - // to Member for a genuinely new member. Defaulting unconditionally to - // Member made a bare self-targeted PUT_USER silently demote an owner. - let role_str = extract_tag_value(event, "role"); - let requested_role = match role_str { - Some(ref s) => match s.parse::() { - Ok(r) => Some(r), - Err(_) => return Err(anyhow::anyhow!("invalid role: {s}")), - }, - None => None, - }; - - let members = state.db.get_members(tenant.community(), channel_id).await?; - let actor_role: Option = members - .iter() - .find(|m| m.pubkey == actor_bytes) - .and_then(|m| m.role.parse().ok()); - let target_pubkey = - extract_p_tag(event).ok_or_else(|| anyhow::anyhow!("missing p tag"))?; - - let join_role = - buzz_db::channel::ChannelJoinRole::parse_fail_closed(&channel.join_role); - - // Authorization policy — visibility gate, elevated-grant gate, - // active-member role-change gate, and last-owner demotion — lives in - // `channel_authz`, which is pure and table-tested. The database reads - // it depends on stay here. - match channel_authz::decide_put_user( - &channel.visibility, - join_role, - actor_role, - requested_role, - &members, - &target_pubkey, - &actor_bytes, - )? { - // Self-add: always allowed regardless of policy. - PutUserDecision::Allow => Ok(()), - // Third-party add: check channel_add_policy on the target. - PutUserDecision::CheckAddPolicy => { - if let Some((policy, owner)) = state - .db - .get_agent_channel_policy(tenant.community(), &target_pubkey) - .await? - { - channel_authz::decide_channel_add_policy( - &policy, - owner.as_deref(), - &actor_bytes, - )?; - } - - Ok(()) - } - } - } + // Ingest validates kind:9000 with `validate_put_user`, which keeps + // each person's result for `apply_put_user`. + 9000 => Err(anyhow::anyhow!("kind:9000 requires validate_put_user")), 9001 => { // REMOVE_USER: self-remove allowed unless actor is the last owner; removing others requires owner/admin let target_pubkey = @@ -1568,74 +1515,303 @@ async fn handle_kind0_profile( Ok(()) } -async fn handle_put_user( +/// Most people one kind:9000 may name. +pub(crate) const MAX_PUT_USER_TARGETS: usize = 100; + +/// One person a kind:9000 names. +struct PutUserTarget { + pubkey: Vec, + /// `None` asks for no role change: an active member keeps their role, and + /// a new member gets the channel's `join_role`. Defaulting to Member made a + /// bare self-targeted PUT_USER silently demote an owner. + role: Option, +} + +/// The people a kind:9000 names, in tag order without repeats. As in NIP-29, +/// each `["p", pubkey, role]` may carry its own role; the event's `role` tag +/// is the fallback. +fn put_user_targets(event: &Event) -> anyhow::Result> { + let parse_role = |value: &str| { + value + .parse::() + .map_err(|_| anyhow::anyhow!("invalid role: {value}")) + }; + let event_role = extract_tag_value(event, "role") + .map(|value| parse_role(&value)) + .transpose()?; + let mut targets: Vec = Vec::new(); + for tag in event + .tags + .iter() + .filter(|tag| tag.kind().to_string() == "p") + { + let values = tag.as_slice(); + let pubkey = values + .get(1) + .and_then(|value| hex::decode(value).ok()) + .filter(|bytes| bytes.len() == 32) + .ok_or_else(|| anyhow::anyhow!("p tag must be a 32-byte hex pubkey"))?; + let role = match values.get(2).filter(|value| !value.is_empty()) { + Some(value) => Some(parse_role(value)?), + None => event_role, + }; + if !targets.iter().any(|target| target.pubkey == pubkey) { + targets.push(PutUserTarget { pubkey, role }); + } + } + if targets.is_empty() { + anyhow::bail!("missing p tag"); + } + if targets.len() > MAX_PUT_USER_TARGETS { + anyhow::bail!("a put-user may name at most {MAX_PUT_USER_TARGETS} people"); + } + Ok(targets) +} + +/// A validated kind:9000: each person it names, with the reason they were +/// refused, if they were. At least one was not. +pub(crate) struct PutUserPlan { + channel_id: Uuid, + targets: Vec<(PutUserTarget, Option)>, +} + +/// Validate a kind:9000 before storage, person by person, so one refused +/// person does not refuse the others. Refuses the event only when the event +/// itself is bad or nobody it names may be added. +pub(crate) async fn validate_put_user( tenant: &TenantContext, event: &Event, state: &Arc, -) -> anyhow::Result<()> { +) -> anyhow::Result { let channel_id = - extract_h_tag_channel(event).ok_or_else(|| anyhow::anyhow!("missing h tag"))?; - let target_pubkey = extract_p_tag(event).ok_or_else(|| anyhow::anyhow!("missing p tag"))?; + extract_h_tag_channel(event).ok_or_else(|| anyhow::anyhow!("missing or invalid h tag"))?; + let channel = state + .db + .get_channel_for_event_write(tenant.community(), channel_id) + .await + .map_err(|_| anyhow::anyhow!("channel not found"))?; + if channel.archived_at.is_some() { + anyhow::bail!("channel is archived"); + } + let targets = put_user_targets(event)?; + let actor_bytes = event.pubkey.to_bytes().to_vec(); + let members = state.db.get_members(tenant.community(), channel_id).await?; + let actor_role: Option = members + .iter() + .find(|m| m.pubkey == actor_bytes) + .and_then(|m| m.role.parse().ok()); + let join_role = buzz_db::channel::ChannelJoinRole::parse_fail_closed(&channel.join_role); + + let mut checked = Vec::with_capacity(targets.len()); + for target in targets { + // Authorization policy — visibility gate, elevated-grant gate, + // active-member role-change gate, and last-owner demotion — lives in + // `channel_authz`, which is pure and table-tested. The database reads + // it depends on stay here. + let refusal = match channel_authz::decide_put_user( + &channel.visibility, + join_role, + actor_role, + target.role, + &members, + &target.pubkey, + &actor_bytes, + ) { + Err(denied) => Some(denied.to_string()), + // Self-add: always allowed regardless of policy. + Ok(PutUserDecision::Allow) => None, + // Third-party add: check channel_add_policy on the target. + Ok(PutUserDecision::CheckAddPolicy) => match state + .db + .get_agent_channel_policy(tenant.community(), &target.pubkey) + .await? + { + Some((policy, owner)) => channel_authz::decide_channel_add_policy( + &policy, + owner.as_deref(), + &actor_bytes, + ) + .err() + .map(|denied| denied.to_string()), + None => None, + }, + }; + checked.push((target, refusal)); + } - // No role tag = no role change: an existing member keeps their current - // role, and a new member gets the channel's `join_role`, whether they add - // themselves or someone else adds them. `put_member` resolves that under - // the channel's membership lock, together with every admission rule. - let requested: Option = extract_tag_value(event, "role") - .map(|role_str| { - role_str - .parse() - .map_err(|_| anyhow::anyhow!("invalid role: {role_str}")) - }) - .transpose()?; + if checked.iter().all(|(_, refusal)| refusal.is_some()) { + // One person keeps the single-add error text. + if let [(_, Some(reason))] = checked.as_slice() { + anyhow::bail!("{reason}"); + } + let mut report = PutUserReport::default(); + for (target, refusal) in checked { + report.reject(&target.pubkey, refusal.unwrap_or_default()); + } + anyhow::bail!("{}", report.message()); + } + Ok(PutUserPlan { + channel_id, + targets: checked, + }) +} - state - .db - .put_member( - tenant.community(), - channel_id, - &target_pubkey, - requested, - Some(&actor_bytes), - ) - .await?; - state.invalidate_membership(tenant, channel_id, &target_pubkey); +/// What a kind:9000 did for each person it named, as hex pubkeys. +#[derive(Default)] +struct PutUserReport { + added: Vec, + /// Already active members whose role changed. + updated: Vec, + /// Already active members at the requested role: nothing changed. + already_member: Vec, + /// Refused people, grouped by reason in first-seen order. + rejected: Vec<(String, Vec)>, +} + +impl PutUserReport { + fn reject(&mut self, pubkey: &[u8], reason: String) { + let pubkey = hex::encode(pubkey); + match self.rejected.iter_mut().find(|(seen, _)| *seen == reason) { + Some((_, pubkeys)) => pubkeys.push(pubkey), + None => self.rejected.push((reason, vec![pubkey])), + } + } + + /// The OK message: `members: ` and a JSON object of the non-empty lists. + /// Grouping refusals by reason keeps 100 people well under a client's + /// receipt size limit. + fn message(&self) -> String { + let mut report = serde_json::Map::new(); + for (key, pubkeys) in [ + ("added", &self.added), + ("updated", &self.updated), + ("already_member", &self.already_member), + ] { + if !pubkeys.is_empty() { + report.insert(key.into(), serde_json::json!(pubkeys)); + } + } + if !self.rejected.is_empty() { + let rejected: Vec<_> = self + .rejected + .iter() + .map( + |(reason, pubkeys)| serde_json::json!({ "reason": reason, "pubkeys": pubkeys }), + ) + .collect(); + report.insert("rejected".into(), rejected.into()); + } + format!("members: {}", serde_json::Value::Object(report)) + } +} +/// Apply a stored kind:9000 and return its OK message. An event naming +/// several people reports each person's result; one naming a single person +/// keeps the empty message a single add has always had. `put_member` decides +/// every admission rule again under the channel's membership lock, so a person +/// validation allowed can still be refused here. +pub(crate) async fn apply_put_user( + tenant: &TenantContext, + event: &Event, + state: &Arc, + plan: PutUserPlan, +) -> String { + let channel_id = plan.channel_id; + let batch = plan.targets.len() > 1; + let actor_bytes = event.pubkey.to_bytes().to_vec(); let actor_hex = hex::encode(&actor_bytes); - let target_hex = hex::encode(&target_pubkey); - emit_system_message( - tenant, - state, - channel_id, - serde_json::json!({ - "type": "member_joined", - "actor": actor_hex, - "target": target_hex, - }), - chrono::Utc::now(), - ) - .await?; + let mut report = PutUserReport::default(); + for (target, refusal) in plan.targets { + if let Some(reason) = refusal { + report.reject(&target.pubkey, reason); + continue; + } + let put = match state + .db + .put_member_with_prior_role( + tenant.community(), + channel_id, + &target.pubkey, + target.role, + Some(&actor_bytes), + ) + .await + { + Ok(put) => put, + Err(buzz_db::DbError::AccessDenied(reason)) => { + report.reject(&target.pubkey, reason); + continue; + } + Err(e) => { + error!(channel = %channel_id, error = %e, "NIP-29 PUT_USER write failed"); + report.reject(&target.pubkey, "error: membership write failed".into()); + continue; + } + }; + state.invalidate_membership(tenant, channel_id, &target.pubkey); + let target_hex = hex::encode(&target.pubkey); + match put.prior_role { + // A no-op re-add posts no second "joined" notice, but still sends + // the member-added notification, which re-signals an agent that + // missed the first one. + Some(prior) if prior == put.member.role => report.already_member.push(target_hex), + prior => { + if let Err(e) = emit_system_message( + tenant, + state, + channel_id, + serde_json::json!({ + "type": "member_joined", + "actor": actor_hex, + "target": target_hex, + }), + chrono::Utc::now(), + ) + .await + { + warn!(channel = %channel_id, error = %e, "member_joined system message failed"); + } + match prior { + Some(_) => report.updated.push(target_hex), + None => report.added.push(target_hex), + } + } + } - if let Err(e) = emit_group_discovery_events(tenant, state, channel_id).await { - warn!(channel = %channel_id, error = %e, "NIP-29 group discovery emission failed"); + if let Err(e) = emit_membership_notification( + tenant, + state, + channel_id, + &target.pubkey, + &actor_bytes, + KIND_MEMBER_ADDED_NOTIFICATION, + ) + .await + { + warn!(channel = %channel_id, error = %e, "membership notification emission failed"); + } } - if let Err(e) = emit_membership_notification( - tenant, - state, - channel_id, - &target_pubkey, - &actor_bytes, - KIND_MEMBER_ADDED_NOTIFICATION, - ) - .await - { - warn!(channel = %channel_id, error = %e, "membership notification emission failed"); + if !report.added.is_empty() || !report.updated.is_empty() || !report.already_member.is_empty() { + if let Err(e) = emit_group_discovery_events(tenant, state, channel_id).await { + warn!(channel = %channel_id, error = %e, "NIP-29 group discovery emission failed"); + } } - info!(channel = %channel_id, target = %target_hex, "NIP-29 PUT_USER processed"); - Ok(()) + info!( + channel = %channel_id, + added = report.added.len(), + updated = report.updated.len(), + already_member = report.already_member.len(), + rejected = report.rejected.iter().map(|(_, pubkeys)| pubkeys.len()).sum::(), + "NIP-29 PUT_USER processed" + ); + if batch { + report.message() + } else { + String::new() + } } async fn handle_remove_user( diff --git a/crates/buzz-test-client/tests/e2e_relay.rs b/crates/buzz-test-client/tests/e2e_relay.rs index 7f39eb7040f..af481b77c6d 100644 --- a/crates/buzz-test-client/tests/e2e_relay.rs +++ b/crates/buzz-test-client/tests/e2e_relay.rs @@ -1387,6 +1387,93 @@ async fn test_nip29_put_user_nobody_blocks() { ws.disconnect().await.expect("disconnect"); } +/// NIP-29 kind 9000 (PUT_USER) naming several people over the wire: each is +/// added on its own, a refused agent does not refuse the others, and the OK +/// message reports every result. +#[tokio::test] +#[ignore] +async fn test_nip29_batch_put_user_wire() { + let url = relay_url(); + let owner_keys = Keys::generate(); + let (person, bot, refusing) = (Keys::generate(), Keys::generate(), Keys::generate()); + let (person_hex, bot_hex, refusing_hex) = ( + person.public_key().to_hex(), + bot.public_key().to_hex(), + refusing.public_key().to_hex(), + ); + + let policy_event = EventBuilder::new( + Kind::Custom(10100), + serde_json::json!({ "channel_add_policy": "nobody" }).to_string(), + ) + .sign_with_keys(&refusing) + .expect("sign kind:10100"); + let resp = reqwest::Client::new() + .post(format!("{}/events", relay_http_url())) + .header("X-Pubkey", &refusing_hex) + .header("Content-Type", "application/json") + .body(serde_json::to_string(&policy_event).unwrap()) + .send() + .await + .expect("set policy request"); + assert!( + resp.status().is_success(), + "set policy failed: {}", + resp.status() + ); + + let channel_id = create_test_channel(&owner_keys).await; + let mut ws = BuzzTestClient::connect(&url, &owner_keys) + .await + .expect("connect as owner"); + let put_user = |p_tags: &[&[&str]]| { + let mut tags = vec![Tag::parse(["h", &channel_id]).unwrap()]; + for tag in p_tags { + tags.push(Tag::parse(["p"].iter().chain(tag.iter()).copied()).unwrap()); + } + EventBuilder::new(Kind::Custom(9000), "") + .tags(tags) + .sign_with_keys(&owner_keys) + .expect("sign kind 9000") + }; + let report = |message: &str| -> serde_json::Value { + serde_json::from_str(message.strip_prefix("members: ").expect("members report")) + .expect("members report JSON") + }; + + let ok = ws + .send_event(put_user(&[ + &[&person_hex], + &[&refusing_hex], + &[&bot_hex, "bot"], + ])) + .await + .expect("send batch kind 9000"); + assert!(ok.accepted, "batch put-user refused: {}", ok.message); + assert_eq!( + report(&ok.message), + serde_json::json!({ + "added": [person_hex, bot_hex], + "rejected": [{ + "reason": "policy:nobody — this agent has disabled external channel additions", + "pubkeys": [refusing_hex], + }], + }) + ); + + let ok = ws + .send_event(put_user(&[&[&person_hex], &[&bot_hex, "bot"]])) + .await + .expect("resend names in a new kind 9000"); + assert!(ok.accepted, "repeat put-user refused: {}", ok.message); + assert_eq!( + report(&ok.message), + serde_json::json!({ "already_member": [person_hex, bot_hex] }) + ); + + ws.disconnect().await.expect("disconnect"); +} + /// NIP-29 kind 9000 (PUT_USER): self-add bypasses "nobody" policy — an agent can always add itself. #[tokio::test] #[ignore] diff --git a/desktop/src/features/messages/lib/membershipGroupPayload.ts b/desktop/src/features/messages/lib/membershipGroupPayload.ts index cb22472077e..a375fb2a383 100644 --- a/desktop/src/features/messages/lib/membershipGroupPayload.ts +++ b/desktop/src/features/messages/lib/membershipGroupPayload.ts @@ -91,8 +91,9 @@ export function buildGroupedMembershipPayload( * Mirrors `membershipChangesCanGroup`: a departure only groups with self-arrivals * of the departing member, and the builder absorbs *every* contiguous one, not * just the nearest. Duplicate `member_joined` events are a real relay artifact — - * `handle_put_user` re-emits one on each PUT_USER — so pinning this to a 2-event - * pair dropped the departure from the timeline entirely. + * relays before batch PUT_USER re-emitted one on each PUT_USER, even a no-op + * re-add — so pinning this to a 2-event pair dropped the departure from the + * timeline entirely. */ function buildJoinedThenLeftPayload( payloads: readonly (SystemMessagePayload | null)[], From 83867ba02930beed51e2cfad3e9f3f52da6b24d4 Mon Sep 17 00:00:00 2001 From: Bradley Axen Date: Sat, 24 Oct 2026 12:59:14 -0700 Subject: [PATCH 3/6] perf(relay): write a kind:9000 batch in one membership transaction A put-user naming many people made one transaction, one membership lock and five or six round trips per person, plus a policy query and a channel-type read per person. buzz-db gains put_members: one transaction and lock, one read of every involved role, the owner count only when a demotion needs it, and one UNNEST upsert with RETURNING. People are decided in tag order, each seeing the roles earlier ones were given (including the actor's own) and the owners they add or remove, so the answers match one write per person. put_member is put_members with one person, so the admission and last-owner rules keep one implementation; it replaces put_member_with_prior_role. Validation reads every third-party target's channel_add_policy in one query, and apply reads the channel type once for all member-added notifications. Co-authored-by: mbot <3754f8729004d95654c46dbab3129e4ab9ef05cc2534e2a3fbfc155983bd637b@buzz.block.builderlab.xyz> Signed-off-by: Bradley Axen --- crates/buzz-db/src/store/channel_members.rs | 395 +++++++++++++----- crates/buzz-db/src/store/user.rs | 46 ++ .../buzz-relay/src/handlers/side_effects.rs | 197 ++++++--- 3 files changed, 471 insertions(+), 167 deletions(-) diff --git a/crates/buzz-db/src/store/channel_members.rs b/crates/buzz-db/src/store/channel_members.rs index a08fc351942..34340b45446 100644 --- a/crates/buzz-db/src/store/channel_members.rs +++ b/crates/buzz-db/src/store/channel_members.rs @@ -743,19 +743,24 @@ pub async fn put_member( requested: Option, invited_by: Option<&[u8]>, ) -> Result { - put_member_with_prior_role( - pool, - community_id, - channel_id, - pubkey, - requested, - invited_by, - ) - .await - .map(|put| put.member) + let target = MemberPut { pubkey, requested }; + let mut results = put_members(pool, community_id, channel_id, &[target], invited_by).await?; + results + .pop() + .expect("put_members returns one result per target") + .map(|put| put.member) +} + +/// One person a [`put_members`] call adds or changes. +#[derive(Debug, Clone, Copy)] +pub struct MemberPut<'a> { + /// The person's 32-byte pubkey. + pub pubkey: &'a [u8], + /// The role asked for; `None` asks for no role change (see [`put_member`]). + pub requested: Option, } -/// A [`put_member`] write, and the role the member held just before it. +/// One [`put_members`] write, and the role the member held just before it. #[derive(Debug, Clone)] pub struct PutMemberResult { /// The membership row after the write. @@ -765,23 +770,34 @@ pub struct PutMemberResult { pub prior_role: Option, } -/// [`put_member`], also reporting the member's role before the write, so a -/// caller can tell an add from a role change from a no-op. -pub async fn put_member_with_prior_role( +/// [`put_member`] for several people at once (kind:9000 naming several `p` +/// tags), in one transaction under one membership lock. +/// +/// Each person is decided exactly as [`put_member`] would decide them, in +/// order, as though each had been its own write: a decision sees the roles +/// the earlier people in the call were given, and the last-owner rule counts +/// the owners they leave. The roles involved and the owner count are read once +/// and the writes go out as one upsert, so the round trips do not grow with +/// the number of people. +/// +/// Returns one result per target, in order. A refused person +/// ([`DbError::AccessDenied`], or [`DbError::InvalidData`] for a malformed +/// pubkey) is not written and does not stop the others. The outer error means +/// nothing was written. A pubkey may appear only once. +pub async fn put_members( pool: &PgPool, community_id: CommunityId, channel_id: Uuid, - pubkey: &[u8], - requested: Option, + targets: &[MemberPut<'_>], invited_by: Option<&[u8]>, -) -> Result { - if pubkey.len() != 32 { +) -> Result>> { + let mut seen = std::collections::HashSet::with_capacity(targets.len()); + if let Some(dup) = targets.iter().find(|t| !seen.insert(t.pubkey)) { return Err(DbError::InvalidData(format!( - "pubkey must be 32 bytes, got {}", - pubkey.len() + "pubkey {} appears more than once", + hex::encode(dup.pubkey) ))); } - let actor = invited_by.unwrap_or(pubkey); let mut tx = crate::begin_community_write_transaction( pool, @@ -797,85 +813,151 @@ pub async fn put_member_with_prior_role( let channel = get_channel_tx(tx.conn(), community_id, channel_id).await?; let private = channel.visibility == "private"; let join_role = buzz_core::channel::ChannelJoinRole::parse_fail_closed(&channel.join_role); - let current_role = get_active_role_tx(tx.conn(), community_id, channel_id, pubkey).await?; - let actor_role = if actor == pubkey { - current_role.clone() - } else { - get_active_role_tx(tx.conn(), community_id, channel_id, actor).await? - }; - let actor_role: Option = actor_role - .map(|r| { - r.parse() - .map_err(|_| DbError::InvalidData(format!("invalid role in database: {r}"))) - }) - .transpose()?; - - let effective_role = buzz_core::channel::decide_member_put(buzz_core::channel::MemberPut { - private, - join_role, - actor_role, - target_is_actor: actor == pubkey, - target_role: current_role.as_deref(), - requested, - }) - .map_err(|denial| DbError::AccessDenied(member_put_denial_message(denial).to_string()))?; - // Defense-in-depth, mirroring `remove_member`: a demotion must not strip - // the channel of its last owner, which would leave nobody able to - // moderate, edit metadata, or re-grant ownership. - if current_role.as_deref() == Some("owner") && effective_role != MemberRole::Owner { - let row = sqlx::query( - "SELECT COUNT(*) as cnt FROM channel_members \ - WHERE community_id = $1 AND channel_id = $2 AND role = 'owner' AND removed_at IS NULL", - ) - .bind(community_id.as_uuid()) - .bind(channel_id) - .fetch_one(tx.conn()) - .await?; - let owner_count: i64 = row.try_get("cnt")?; - if owner_count <= 1 { - return Err(DbError::AccessDenied( - "cannot demote the last owner — transfer ownership first".to_string(), - )); - } + // Active roles of everyone the decisions read: the targets and the actor. + let mut involved: Vec> = targets.iter().map(|t| t.pubkey.to_vec()).collect(); + if let Some(actor) = invited_by { + involved.push(actor.to_vec()); } - - sqlx::query( - r#" - INSERT INTO channel_members (community_id, channel_id, pubkey, role, invited_by) - VALUES ($1, $2, $3, $4::member_role, $5) - ON CONFLICT (community_id, channel_id, pubkey) DO UPDATE SET - removed_at = NULL, - removed_by = NULL, - role = EXCLUDED.role - "#, + let mut roles: std::collections::HashMap, String> = sqlx::query( + "SELECT pubkey, role::text AS role FROM channel_members \ + WHERE community_id = $1 AND channel_id = $2 AND pubkey = ANY($3) AND removed_at IS NULL", ) .bind(community_id.as_uuid()) .bind(channel_id) - .bind(pubkey) - .bind(effective_role.as_str()) - .bind(invited_by) - .execute(tx.conn()) - .await?; + .bind(&involved) + .fetch_all(tx.conn()) + .await? + .into_iter() + .map(|row| Ok((row.try_get("pubkey")?, row.try_get("role")?))) + .collect::>()?; + + // Owners gained (+) or lost (-) by the decisions so far, against the count + // before this call, which is read only if a demotion needs it. + let mut owner_delta: i64 = 0; + let mut owners_before: Option = None; + + let mut decided: Vec, MemberRole)>> = Vec::with_capacity(targets.len()); + for target in targets { + if target.pubkey.len() != 32 { + decided.push(Err(DbError::InvalidData(format!( + "pubkey must be 32 bytes, got {}", + target.pubkey.len() + )))); + continue; + } + let actor = invited_by.unwrap_or(target.pubkey); + let current_role = roles.get(target.pubkey).cloned(); + let actor_role: Option = roles + .get(actor) + .map(|r| { + r.parse() + .map_err(|_| DbError::InvalidData(format!("invalid role in database: {r}"))) + }) + .transpose()?; + + let effective_role = + match buzz_core::channel::decide_member_put(buzz_core::channel::MemberPut { + private, + join_role, + actor_role, + target_is_actor: actor == target.pubkey, + target_role: current_role.as_deref(), + requested: target.requested, + }) { + Ok(role) => role, + Err(denial) => { + decided.push(Err(DbError::AccessDenied( + member_put_denial_message(denial).to_string(), + ))); + continue; + } + }; + + let was_owner = current_role.as_deref() == Some("owner"); + let is_owner = effective_role == MemberRole::Owner; + // Defense-in-depth, mirroring `remove_member`: a demotion must not + // strip the channel of its last owner, which would leave nobody able + // to moderate, edit metadata, or re-grant ownership. + if was_owner && !is_owner { + let before = match owners_before { + Some(count) => count, + None => { + let row = sqlx::query( + "SELECT COUNT(*) as cnt FROM channel_members \ + WHERE community_id = $1 AND channel_id = $2 AND role = 'owner' AND removed_at IS NULL", + ) + .bind(community_id.as_uuid()) + .bind(channel_id) + .fetch_one(tx.conn()) + .await?; + let count: i64 = row.try_get("cnt")?; + *owners_before.insert(count) + } + }; + if before + owner_delta <= 1 { + decided.push(Err(DbError::AccessDenied( + "cannot demote the last owner — transfer ownership first".to_string(), + ))); + continue; + } + owner_delta -= 1; + } else if is_owner && !was_owner { + owner_delta += 1; + } - let row = sqlx::query( - r#" - SELECT channel_id, pubkey, role::text AS role, joined_at, invited_by, removed_at - FROM channel_members WHERE community_id = $1 AND channel_id = $2 AND pubkey = $3 - "#, - ) - .bind(community_id.as_uuid()) - .bind(channel_id) - .bind(pubkey) - .fetch_one(tx.conn()) - .await?; + roles.insert(target.pubkey.to_vec(), effective_role.as_str().to_string()); + decided.push(Ok((current_role, effective_role))); + } - let member = row_to_member_record(row)?; + let (write_keys, write_roles): (Vec>, Vec<&str>) = targets + .iter() + .zip(&decided) + .filter_map(|(target, decision)| { + let (_, role) = decision.as_ref().ok()?; + Some((target.pubkey.to_vec(), role.as_str())) + }) + .unzip(); + let mut written: std::collections::HashMap, MemberRecord> = + std::collections::HashMap::with_capacity(write_keys.len()); + if !write_keys.is_empty() { + let rows = sqlx::query( + r#" + INSERT INTO channel_members (community_id, channel_id, pubkey, role, invited_by) + SELECT $1, $2, t.pubkey, t.role::member_role, $5 + FROM UNNEST($3::bytea[], $4::text[]) AS t(pubkey, role) + ON CONFLICT (community_id, channel_id, pubkey) DO UPDATE SET + removed_at = NULL, + removed_by = NULL, + role = EXCLUDED.role + RETURNING channel_id, pubkey, role::text AS role, joined_at, invited_by, removed_at + "#, + ) + .bind(community_id.as_uuid()) + .bind(channel_id) + .bind(&write_keys) + .bind(&write_roles) + .bind(invited_by) + .fetch_all(tx.conn()) + .await?; + for row in rows { + let member = row_to_member_record(row)?; + written.insert(member.pubkey.clone(), member); + } + } tx.commit().await?; - Ok(PutMemberResult { - member, - prior_role: current_role, - }) + + Ok(targets + .iter() + .zip(decided) + .map(|(target, decision)| { + let (prior_role, _) = decision?; + let member = written.remove(target.pubkey).ok_or_else(|| { + DbError::InvalidData("membership upsert returned no row".to_string()) + })?; + Ok(PutMemberResult { member, prior_role }) + }) + .collect()) } /// The client-visible reason for a [`buzz_core::channel::MemberPutDenial`]. @@ -1994,26 +2076,17 @@ impl Db { .await } - /// [`Db::put_member`], also reporting the member's role before the write - /// (see [`put_member_with_prior_role`]). - #[datastore_span(name = "put_member", system = "postgresql")] - pub async fn put_member_with_prior_role( + /// [`Db::put_member`] for several people in one transaction (see + /// [`put_members`]). + #[datastore_span(name = "put_members", system = "postgresql")] + pub async fn put_members( &self, community_id: CommunityId, channel_id: Uuid, - pubkey: &[u8], - requested: Option, + targets: &[MemberPut<'_>], invited_by: Option<&[u8]>, - ) -> Result { - put_member_with_prior_role( - &self.pool, - community_id, - channel_id, - pubkey, - requested, - invited_by, - ) - .await + ) -> Result>> { + put_members(&self.pool, community_id, channel_id, targets, invited_by).await } /// Removes a member from a channel. @@ -3256,6 +3329,112 @@ mod postgres_tests { assert_eq!(role.as_deref(), Some("member")); } + /// `put_members` decides each person as though each were its own write: + /// later decisions see the roles earlier people were given, including the + /// actor's own, and the last-owner rule counts owners the call gains and + /// loses. A refused person is not written and does not stop the others. + #[tokio::test] + #[ignore = "requires Postgres"] + async fn put_members_decides_each_person_in_order() { + let pool = setup_pool().await; + let community_id = make_test_community(&pool).await; + let community = CommunityId::from_uuid(community_id); + let owner = random_pubkey(); + let heir = random_pubkey(); + let late = random_pubkey(); + let member = random_pubkey(); + for pk in [&owner, &heir, &late, &member] { + ensure_user(&pool, community, pk) + .await + .expect("ensure user"); + } + let channel = create_test_channel( + &pool, + community_id, + "put-members-order", + ChannelType::Stream, + ChannelVisibility::Open, + None, + &owner, + None, + ) + .await + .expect("create channel"); + put_member(&pool, community, channel.id, &member, None, Some(&owner)) + .await + .expect("seed member"); + let put = |pubkey, requested| MemberPut { pubkey, requested }; + + // The sole owner cannot step down before an heir is named, but the + // heir is still added. + let results = put_members( + &pool, + community, + channel.id, + &[ + put(&owner, Some(MemberRole::Member)), + put(&heir, Some(MemberRole::Owner)), + ], + Some(&owner), + ) + .await + .expect("first batch"); + assert!( + matches!(&results[0], Err(DbError::AccessDenied(m)) if m.contains("last owner")), + "{:?}", + results[0] + ); + let heir_put = results[1].as_ref().expect("heir added"); + assert_eq!(heir_put.member.role, "owner"); + assert_eq!(heir_put.prior_role, None); + + // With the heir counted, the owner may step down in the same call. The + // owner is then a plain member, so the admin grant after it is + // refused, and the member re-added at their role reports that role. + let results = put_members( + &pool, + community, + channel.id, + &[ + put(&owner, Some(MemberRole::Member)), + put(&late, Some(MemberRole::Admin)), + put(&member, None), + ], + Some(&owner), + ) + .await + .expect("second batch"); + let stepped_down = results[0].as_ref().expect("owner steps down"); + assert_eq!(stepped_down.member.role, "member"); + assert_eq!(stepped_down.prior_role.as_deref(), Some("owner")); + assert!( + matches!(&results[1], Err(DbError::AccessDenied(_))), + "{:?}", + results[1] + ); + let readded = results[2].as_ref().expect("member re-added"); + assert_eq!(readded.prior_role.as_deref(), Some("member")); + assert_eq!(readded.member.role, "member"); + assert_eq!( + get_member_role(&pool, community, channel.id, &late) + .await + .expect("read role"), + None + ); + + // A repeated pubkey refuses the call. + let err = put_members( + &pool, + community, + channel.id, + &[put(&late, None), put(&late, None)], + Some(&heir), + ) + .await + .expect_err("duplicate pubkey"); + assert!(matches!(err, DbError::InvalidData(_)), "{err:?}"); + } + /// `put_member` enforces admission itself, so a caller that skips the /// relay validator still cannot join an announce channel above /// `join_role`, and a guest still cannot add anyone. No role tag keeps an diff --git a/crates/buzz-db/src/store/user.rs b/crates/buzz-db/src/store/user.rs index d767b56c756..2c161e8a026 100644 --- a/crates/buzz-db/src/store/user.rs +++ b/crates/buzz-db/src/store/user.rs @@ -415,6 +415,42 @@ pub async fn get_agent_channel_policy( .transpose() } +/// A user's `channel_add_policy`, and their `agent_owner_pubkey` if any. +pub type AgentChannelPolicy = (String, Option>); + +/// [`get_agent_channel_policy`] for several pubkeys in one query. A pubkey +/// missing from the map is not in the users table. +pub async fn get_agent_channel_policies( + pool: &PgPool, + community_id: CommunityId, + pubkeys: &[Vec], +) -> Result, AgentChannelPolicy>> { + if pubkeys.is_empty() { + return Ok(std::collections::HashMap::new()); + } + let mut connection = crate::observability::acquire_writer( + pool, + crate::observability::WriterOperation::Authorization, + ) + .await?; + let rows = sqlx::query( + r#"SELECT pubkey, channel_add_policy::text AS channel_add_policy, agent_owner_pubkey FROM users WHERE community_id = $1 AND pubkey = ANY($2)"#, + ) + .bind(community_id.as_uuid()) + .bind(pubkeys) + .fetch_all(&mut *connection) + .await?; + + rows.into_iter() + .map(|r| -> Result<(Vec, AgentChannelPolicy)> { + let pubkey: Vec = r.try_get("pubkey")?; + let policy: String = r.try_get("channel_add_policy")?; + let owner: Option> = r.try_get("agent_owner_pubkey").unwrap_or(None); + Ok((pubkey, (policy, owner))) + }) + .collect() +} + /// List the agents whose `agent_owner_pubkey` is `owner_pubkey` in one community. pub async fn list_agents_for_owner( pool: &PgPool, @@ -634,6 +670,16 @@ impl Db { crate::user::get_agent_channel_policy(&self.pool, community_id, pubkey).await } + /// [`Db::get_agent_channel_policy`] for several pubkeys in one query. + #[datastore_span(name = "get_agent_channel_policies", system = "postgresql")] + pub async fn get_agent_channel_policies( + &self, + community_id: CommunityId, + pubkeys: &[Vec], + ) -> Result, AgentChannelPolicy>> { + crate::user::get_agent_channel_policies(&self.pool, community_id, pubkeys).await + } + /// List the agents owned by `owner_pubkey` in one community. #[datastore_span(name = "list_agents_for_owner", system = "postgresql")] pub async fn list_agents_for_owner( diff --git a/crates/buzz-relay/src/handlers/side_effects.rs b/crates/buzz-relay/src/handlers/side_effects.rs index 35f72fa5c87..d26954d71ce 100644 --- a/crates/buzz-relay/src/handlers/side_effects.rs +++ b/crates/buzz-relay/src/handlers/side_effects.rs @@ -1095,6 +1095,29 @@ pub async fn emit_membership_notification( warn!(%channel_id, "membership notice skipped: channel not found"); return Ok(()); }; + emit_typed_membership_notification( + tenant, + state, + channel_id, + &channel_type, + target_pubkey, + actor_pubkey, + notification_kind, + ) + .await +} + +/// [`emit_membership_notification`] for a caller that already read the +/// channel's type, such as a kind:9000 notifying several people. +async fn emit_typed_membership_notification( + tenant: &TenantContext, + state: &Arc, + channel_id: Uuid, + channel_type: &str, + target_pubkey: &[u8], + actor_pubkey: &[u8], + notification_kind: u32, +) -> anyhow::Result<()> { let target_hex = hex::encode(target_pubkey); let actor_hex = hex::encode(actor_pubkey); let channel_id_str = channel_id.to_string(); @@ -1121,7 +1144,7 @@ pub async fn emit_membership_notification( }) .to_string(); - let type_tag = Tag::parse([buzz_core::filter::NOTICE_CHANNEL_TYPE_TAG, &channel_type]) + let type_tag = Tag::parse([buzz_core::filter::NOTICE_CHANNEL_TYPE_TAG, channel_type]) .map_err(|e| anyhow::anyhow!("failed to build channel type tag: {e}"))?; let event = EventBuilder::new(Kind::Custom(notification_kind as u16), content) .tags([p_tag, h_tag, type_tag]) @@ -1603,42 +1626,58 @@ pub(crate) async fn validate_put_user( .and_then(|m| m.role.parse().ok()); let join_role = buzz_db::channel::ChannelJoinRole::parse_fail_closed(&channel.join_role); - let mut checked = Vec::with_capacity(targets.len()); - for target in targets { - // Authorization policy — visibility gate, elevated-grant gate, - // active-member role-change gate, and last-owner demotion — lives in - // `channel_authz`, which is pure and table-tested. The database reads - // it depends on stay here. - let refusal = match channel_authz::decide_put_user( - &channel.visibility, - join_role, - actor_role, - target.role, - &members, - &target.pubkey, - &actor_bytes, - ) { - Err(denied) => Some(denied.to_string()), - // Self-add: always allowed regardless of policy. - Ok(PutUserDecision::Allow) => None, - // Third-party add: check channel_add_policy on the target. - Ok(PutUserDecision::CheckAddPolicy) => match state - .db - .get_agent_channel_policy(tenant.community(), &target.pubkey) - .await? - { - Some((policy, owner)) => channel_authz::decide_channel_add_policy( - &policy, - owner.as_deref(), - &actor_bytes, - ) - .err() - .map(|denied| denied.to_string()), - None => None, - }, - }; - checked.push((target, refusal)); - } + // Authorization policy — visibility gate, elevated-grant gate, + // active-member role-change gate, and last-owner demotion — lives in + // `channel_authz`, which is pure and table-tested. The database reads it + // depends on stay here, and are made once for the whole event. + let decisions: Vec<_> = targets + .iter() + .map(|target| { + channel_authz::decide_put_user( + &channel.visibility, + join_role, + actor_role, + target.role, + &members, + &target.pubkey, + &actor_bytes, + ) + }) + .collect(); + // A third-party add also checks the target's channel_add_policy. + let policy_keys: Vec> = targets + .iter() + .zip(&decisions) + .filter(|(_, decision)| matches!(decision, Ok(PutUserDecision::CheckAddPolicy))) + .map(|(target, _)| target.pubkey.clone()) + .collect(); + let policies = state + .db + .get_agent_channel_policies(tenant.community(), &policy_keys) + .await?; + let checked: Vec<_> = targets + .into_iter() + .zip(decisions) + .map(|(target, decision)| { + let refusal = match decision { + Err(denied) => Some(denied.to_string()), + // Self-add: always allowed regardless of policy. + Ok(PutUserDecision::Allow) => None, + Ok(PutUserDecision::CheckAddPolicy) => { + policies.get(&target.pubkey).and_then(|(policy, owner)| { + channel_authz::decide_channel_add_policy( + policy, + owner.as_deref(), + &actor_bytes, + ) + .err() + .map(|denied| denied.to_string()) + }) + } + }; + (target, refusal) + }) + .collect(); if checked.iter().all(|(_, refusal)| refusal.is_some()) { // One person keeps the single-add error text. @@ -1722,22 +1761,59 @@ pub(crate) async fn apply_put_user( let actor_bytes = event.pubkey.to_bytes().to_vec(); let actor_hex = hex::encode(&actor_bytes); let mut report = PutUserReport::default(); + let mut allowed = Vec::with_capacity(plan.targets.len()); for (target, refusal) in plan.targets { - if let Some(reason) = refusal { - report.reject(&target.pubkey, reason); - continue; + match refusal { + Some(reason) => report.reject(&target.pubkey, reason), + None => allowed.push(target), } - let put = match state + } + let puts: Vec<_> = allowed + .iter() + .map(|target| buzz_db::channel_members::MemberPut { + pubkey: &target.pubkey, + requested: target.role, + }) + .collect(); + let results = match state + .db + .put_members(tenant.community(), channel_id, &puts, Some(&actor_bytes)) + .await + { + Ok(results) => results, + // Nothing was written. + Err(e) => { + error!(channel = %channel_id, error = %e, "NIP-29 PUT_USER write failed"); + for target in &allowed { + report.reject(&target.pubkey, "error: membership write failed".into()); + } + Vec::new() + } + }; + // Every person's notification is tagged with the channel's type: read it + // once. No channel row means no type to tag, so no notifications. + let channel_type = if results.iter().any(Result::is_ok) { + match state .db - .put_member_with_prior_role( - tenant.community(), - channel_id, - &target.pubkey, - target.role, - Some(&actor_bytes), - ) + .channel_type_of(tenant.community(), channel_id) .await { + Ok(None) => { + warn!(%channel_id, "membership notice skipped: channel not found"); + None + } + Ok(channel_type) => channel_type, + Err(e) => { + warn!(channel = %channel_id, error = %e, "membership notification emission failed"); + None + } + } + } else { + None + }; + + for (target, result) in allowed.iter().zip(results) { + let put = match result { Ok(put) => put, Err(buzz_db::DbError::AccessDenied(reason)) => { report.reject(&target.pubkey, reason); @@ -1779,17 +1855,20 @@ pub(crate) async fn apply_put_user( } } - if let Err(e) = emit_membership_notification( - tenant, - state, - channel_id, - &target.pubkey, - &actor_bytes, - KIND_MEMBER_ADDED_NOTIFICATION, - ) - .await - { - warn!(channel = %channel_id, error = %e, "membership notification emission failed"); + if let Some(channel_type) = &channel_type { + if let Err(e) = emit_typed_membership_notification( + tenant, + state, + channel_id, + channel_type, + &target.pubkey, + &actor_bytes, + KIND_MEMBER_ADDED_NOTIFICATION, + ) + .await + { + warn!(channel = %channel_id, error = %e, "membership notification emission failed"); + } } } From 3f21d463428301742eafbf386b06bda24abf6652 Mon Sep 17 00:00:00 2001 From: Luke Tornquist Date: Mon, 26 Oct 2026 09:44:27 -0400 Subject: [PATCH 4/6] fix(relay): decide a kind:9000 batch against the roster each person leaves Validation checked every person against the roster as it was before the event, so a sole owner who named an heir and then stepped down was refused as the last owner, though `put_members` would apply both in order. Apply each allowed person to the validator's roster before deciding the next, and read every third party's add policy up front so a policy-refused heir is never counted. No extra database reads. Co-authored-by: Elrond <28d6302a099e5225b02c4155ac4236e4912603df2ab08dbfc2f4fef08ce598c8@buzz.block.builderlab.xyz> Co-Authored-By: Claude Opus 5.5 Signed-off-by: Luke Tornquist --- .../buzz-relay/src/handlers/channel_authz.rs | 15 ++- .../src/handlers/put_user_postgres_tests.rs | 114 ++++++++++++++++++ .../buzz-relay/src/handlers/side_effects.rs | 108 +++++++++-------- 3 files changed, 182 insertions(+), 55 deletions(-) diff --git a/crates/buzz-relay/src/handlers/channel_authz.rs b/crates/buzz-relay/src/handlers/channel_authz.rs index 1498a1857bd..1c1c6649924 100644 --- a/crates/buzz-relay/src/handlers/channel_authz.rs +++ b/crates/buzz-relay/src/handlers/channel_authz.rs @@ -129,7 +129,8 @@ pub enum PutUserDecision { /// /// The database read for the target's agent channel-add policy stays with the /// caller: this returns [`PutUserDecision::CheckAddPolicy`] when that read is -/// still required. +/// still required. It also returns the role the target would hold, so a +/// caller deciding several people in order can apply each to its roster. pub fn decide_put_user( visibility: &str, join_role: ChannelJoinRole, @@ -138,7 +139,7 @@ pub fn decide_put_user( members: &[MemberRecord], target: &[u8], actor: &[u8], -) -> Result { +) -> Result<(PutUserDecision, MemberRole), ChannelAuthzError> { // The admission rules are defined once, in `decide_member_put`. // `add_member` applies them again under the channel's membership lock (it // also covers callers that skip this validator); deciding here too means @@ -166,10 +167,10 @@ pub fn decide_put_user( // Self-add: always allowed regardless of the target's agent policy. if target == actor { - return Ok(PutUserDecision::Allow); + return Ok((PutUserDecision::Allow, role)); } - Ok(PutUserDecision::CheckAddPolicy) + Ok((PutUserDecision::CheckAddPolicy, role)) } /// Evaluate a target agent's `channel_add_policy` for a third-party add. @@ -657,7 +658,8 @@ mod tests { &members, &pk(*target), &pk(*actor), - ), + ) + .map(|(decision, _)| decision), *expected, "visibility {visibility} roster {entries:?} actor {actor} target {target} requested {requested_role:?}" ); @@ -757,7 +759,8 @@ mod tests { &members, &pk(*target), &pk(9), - ), + ) + .map(|(decision, _)| decision), *expected, "join_role {join_role} actor {actor_role:?} target {target} requested {requested:?}" ); diff --git a/crates/buzz-relay/src/handlers/put_user_postgres_tests.rs b/crates/buzz-relay/src/handlers/put_user_postgres_tests.rs index ad93702f79f..d0a69c6b356 100644 --- a/crates/buzz-relay/src/handlers/put_user_postgres_tests.rs +++ b/crates/buzz-relay/src/handlers/put_user_postgres_tests.rs @@ -423,3 +423,117 @@ async fn put_user_names_at_most_one_hundred_people() { Some(100) ); } + +/// The relay decides people in tag order, each against the roster the people +/// before them left, as `put_members` writes them. A sole owner who names an +/// heir first may then step down; stepping down first is refused, since no +/// heir exists yet; an heir refused by their add policy is never counted. +/// Mutations: decide everyone against the roster before the event → the +/// heir-first owner stays owner → RED; count a policy-refused heir → the +/// owner steps down with no heir → RED. +#[tokio::test] +#[ignore = "requires Postgres"] +async fn put_user_decides_each_person_after_the_ones_before() { + let state = state().await; + let tenant = community(&state, "put-user-ordered").await; + let (owner, heir, refusing) = (Keys::generate(), Keys::generate(), Keys::generate()); + refuse_third_party_adds(&state, &tenant, &refusing).await; + let (owner_hex, heir_hex, refusing_hex) = ( + owner.public_key().to_hex(), + heir.public_key().to_hex(), + refusing.public_key().to_hex(), + ); + let last_owner = "cannot demote the last owner — transfer ownership first"; + + // Heir first: ownership moves in one event. + let channel_id = create_channel(&state, &tenant, &owner, &[]).await; + let result = ingest( + &state, + &tenant, + put_user( + &owner, + channel_id, + &[&[&heir_hex, "owner"], &[&owner_hex, "member"]], + &[], + ), + &owner, + ) + .await + .expect("heir first"); + assert_eq!( + report(&result.message), + serde_json::json!({ "added": [heir_hex], "updated": [owner_hex] }) + ); + assert_eq!( + role_of(&state, &tenant, channel_id, &heir).await.as_deref(), + Some("owner") + ); + assert_eq!( + role_of(&state, &tenant, channel_id, &owner).await.as_deref(), + Some("member") + ); + + // Step down first: refused, the heir is still added. + let channel_id = create_channel(&state, &tenant, &owner, &[]).await; + let result = ingest( + &state, + &tenant, + put_user( + &owner, + channel_id, + &[&[&owner_hex, "member"], &[&heir_hex, "owner"]], + &[], + ), + &owner, + ) + .await + .expect("step down first"); + assert_eq!( + report(&result.message), + serde_json::json!({ + "added": [heir_hex], + "rejected": [{ "reason": last_owner, "pubkeys": [owner_hex] }], + }) + ); + assert_eq!( + role_of(&state, &tenant, channel_id, &owner).await.as_deref(), + Some("owner") + ); + + // A policy-refused heir doesn't count: nobody can be added. + let channel_id = create_channel(&state, &tenant, &owner, &[]).await; + let refused = match ingest( + &state, + &tenant, + put_user( + &owner, + channel_id, + &[&[&refusing_hex, "owner"], &[&owner_hex, "member"]], + &[], + ), + &owner, + ) + .await + { + Err(IngestError::Rejected(message)) => message, + Err(error) => panic!("expected a rejection, got {error:?}"), + Ok(result) => panic!("expected a rejection, got OK {}", result.message), + }; + let json = refused.strip_prefix("invalid: ").expect("invalid prefix"); + assert_eq!( + report(json), + serde_json::json!({ + "rejected": [ + { + "reason": "policy:nobody — this agent has disabled external channel additions", + "pubkeys": [refusing_hex], + }, + { "reason": last_owner, "pubkeys": [owner_hex] }, + ], + }) + ); + assert_eq!( + role_of(&state, &tenant, channel_id, &owner).await.as_deref(), + Some("owner") + ); +} diff --git a/crates/buzz-relay/src/handlers/side_effects.rs b/crates/buzz-relay/src/handlers/side_effects.rs index d26954d71ce..e0a8af8ecb0 100644 --- a/crates/buzz-relay/src/handlers/side_effects.rs +++ b/crates/buzz-relay/src/handlers/side_effects.rs @@ -1619,65 +1619,75 @@ pub(crate) async fn validate_put_user( let targets = put_user_targets(event)?; let actor_bytes = event.pubkey.to_bytes().to_vec(); - let members = state.db.get_members(tenant.community(), channel_id).await?; - let actor_role: Option = members - .iter() - .find(|m| m.pubkey == actor_bytes) - .and_then(|m| m.role.parse().ok()); + let mut members = state.db.get_members(tenant.community(), channel_id).await?; let join_role = buzz_db::channel::ChannelJoinRole::parse_fail_closed(&channel.join_role); - // Authorization policy — visibility gate, elevated-grant gate, - // active-member role-change gate, and last-owner demotion — lives in - // `channel_authz`, which is pure and table-tested. The database reads it - // depends on stay here, and are made once for the whole event. - let decisions: Vec<_> = targets - .iter() - .map(|target| { - channel_authz::decide_put_user( - &channel.visibility, - join_role, - actor_role, - target.role, - &members, - &target.pubkey, - &actor_bytes, - ) - }) - .collect(); - // A third-party add also checks the target's channel_add_policy. + // A third-party add also checks the target's channel_add_policy. Read + // every third party's policy in one query, before deciding anyone. let policy_keys: Vec> = targets .iter() - .zip(&decisions) - .filter(|(_, decision)| matches!(decision, Ok(PutUserDecision::CheckAddPolicy))) - .map(|(target, _)| target.pubkey.clone()) + .filter(|target| target.pubkey != actor_bytes) + .map(|target| target.pubkey.clone()) .collect(); let policies = state .db .get_agent_channel_policies(tenant.community(), &policy_keys) .await?; - let checked: Vec<_> = targets - .into_iter() - .zip(decisions) - .map(|(target, decision)| { - let refusal = match decision { - Err(denied) => Some(denied.to_string()), - // Self-add: always allowed regardless of policy. - Ok(PutUserDecision::Allow) => None, - Ok(PutUserDecision::CheckAddPolicy) => { - policies.get(&target.pubkey).and_then(|(policy, owner)| { - channel_authz::decide_channel_add_policy( - policy, - owner.as_deref(), - &actor_bytes, - ) - .err() - .map(|denied| denied.to_string()) - }) + + // Authorization policy — visibility gate, elevated-grant gate, + // active-member role-change gate, and last-owner demotion — lives in + // `channel_authz`, which is pure and table-tested. People are decided in + // tag order against a roster that each allowed person is applied to, the + // order `put_members` writes them in: an heir named first lets the sole + // owner step down after, and an owner who steps down can't then grant an + // elevated role. + let mut checked = Vec::with_capacity(targets.len()); + for target in targets { + let actor_role: Option = members + .iter() + .find(|m| m.pubkey == actor_bytes) + .and_then(|m| m.role.parse().ok()); + let decision = channel_authz::decide_put_user( + &channel.visibility, + join_role, + actor_role, + target.role, + &members, + &target.pubkey, + &actor_bytes, + ); + let outcome = match decision { + Err(denied) => Err(denied.to_string()), + // Self-add: always allowed regardless of policy. + Ok((PutUserDecision::Allow, role)) => Ok(role), + Ok((PutUserDecision::CheckAddPolicy, role)) => match policies.get(&target.pubkey) { + Some((policy, owner)) => { + channel_authz::decide_channel_add_policy(policy, owner.as_deref(), &actor_bytes) + .map(|()| role) + .map_err(|denied| denied.to_string()) } - }; - (target, refusal) - }) - .collect(); + None => Ok(role), + }, + }; + let refusal = match outcome { + Ok(role) => { + match members.iter_mut().find(|m| m.pubkey == target.pubkey) { + Some(member) => member.role = role.as_str().to_owned(), + None => members.push(MemberRecord { + channel_id, + pubkey: target.pubkey.clone(), + role: role.as_str().to_owned(), + joined_at: chrono::Utc::now(), + invited_by: Some(actor_bytes.clone()), + removed_at: None, + }), + } + None + } + Err(reason) => Some(reason), + }; + checked.push((target, refusal)); + } if checked.iter().all(|(_, refusal)| refusal.is_some()) { // One person keeps the single-add error text. From 3fdf9063e26d29909a93b5d27d8094559f033266 Mon Sep 17 00:00:00 2001 From: Luke Tornquist Date: Mon, 26 Oct 2026 09:54:15 -0400 Subject: [PATCH 5/6] style(relay): rustfmt the kind:9000 ordering test Co-authored-by: Elrond <28d6302a099e5225b02c4155ac4236e4912603df2ab08dbfc2f4fef08ce598c8@buzz.block.builderlab.xyz> Co-Authored-By: Claude Opus 5.5 Signed-off-by: Luke Tornquist --- .../src/handlers/put_user_postgres_tests.rs | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/crates/buzz-relay/src/handlers/put_user_postgres_tests.rs b/crates/buzz-relay/src/handlers/put_user_postgres_tests.rs index d0a69c6b356..b3a53426181 100644 --- a/crates/buzz-relay/src/handlers/put_user_postgres_tests.rs +++ b/crates/buzz-relay/src/handlers/put_user_postgres_tests.rs @@ -469,7 +469,9 @@ async fn put_user_decides_each_person_after_the_ones_before() { Some("owner") ); assert_eq!( - role_of(&state, &tenant, channel_id, &owner).await.as_deref(), + role_of(&state, &tenant, channel_id, &owner) + .await + .as_deref(), Some("member") ); @@ -496,7 +498,9 @@ async fn put_user_decides_each_person_after_the_ones_before() { }) ); assert_eq!( - role_of(&state, &tenant, channel_id, &owner).await.as_deref(), + role_of(&state, &tenant, channel_id, &owner) + .await + .as_deref(), Some("owner") ); @@ -533,7 +537,9 @@ async fn put_user_decides_each_person_after_the_ones_before() { }) ); assert_eq!( - role_of(&state, &tenant, channel_id, &owner).await.as_deref(), + role_of(&state, &tenant, channel_id, &owner) + .await + .as_deref(), Some("owner") ); } From e70b0027c0fa484404f59bfd58826358529e6767 Mon Sep 17 00:00:00 2001 From: Luke Tornquist Date: Mon, 26 Oct 2026 10:04:40 -0400 Subject: [PATCH 6/6] test(relay): let the kind:9000 test helper name its own signer nostr's EventBuilder drops a p tag for the signing key unless allow_self_tagging is set, so the ordering test's step-down p never reached the relay. Co-authored-by: Elrond <28d6302a099e5225b02c4155ac4236e4912603df2ab08dbfc2f4fef08ce598c8@buzz.block.builderlab.xyz> Co-Authored-By: Claude Opus 5.5 Signed-off-by: Luke Tornquist --- crates/buzz-relay/src/handlers/put_user_postgres_tests.rs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/crates/buzz-relay/src/handlers/put_user_postgres_tests.rs b/crates/buzz-relay/src/handlers/put_user_postgres_tests.rs index b3a53426181..07d4f298288 100644 --- a/crates/buzz-relay/src/handlers/put_user_postgres_tests.rs +++ b/crates/buzz-relay/src/handlers/put_user_postgres_tests.rs @@ -22,8 +22,11 @@ fn put_user(actor: &Keys, channel_id: Uuid, p_tags: &[&[&str]], extra: &[&[&str] for tag in extra { tags.push(Tag::parse(tag.iter().copied()).unwrap()); } + // An actor may name themselves (stepping down, or a self-add); the + // builder drops a `p` tag for its own key unless told not to. EventBuilder::new(Kind::Custom(9000), "") .tags(tags) + .allow_self_tagging() .sign_with_keys(actor) .expect("sign 9000") }