diff --git a/NOSTR.md b/NOSTR.md index d47e5c479b..1450c0cdf3 100644 --- a/NOSTR.md +++ b/NOSTR.md @@ -53,11 +53,11 @@ PGPASSWORD=buzz_dev psql -h localhost -U buzz -d buzz -c \ | **Group creation (kind:9007)** | ✅ | NIP-29; include `name` tag, optional `visibility`, `channel_type`, label `t` and `unlisted` tags | | **Add user (kind:9000)** | ✅ | Open: any user, subject to target's `channel_add_policy` (`owner_only`/`nobody` can block). Private: owner/admin only. Self-add bypasses agent policy but not private-channel auth. A guest cannot add anyone. A new member with no role tag gets the channel's `join_role`; a non-member adding themselves may ask for `join_role` or a lower role. One event may name up to 100 people: one `p` tag each, with an optional role as the `p` tag's third element (NIP-29), falling back to the event's `role` tag. Each person is checked and added on their own. When the event names several people, the OK message is `members: ` followed by JSON listing the non-empty `added`, `updated` (role changed), `already_member` and `rejected` (`[{"reason", "pubkeys"}]`) results. The event is refused only when it is malformed or nobody it names may be added; then one person keeps the single-add error, and several get `invalid: members: {…}`. The event and its membership changes are stored together, so an `error:` rejection stores nothing and the same event can be resent; a resent stored event returns `duplicate:` without results. 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`/`unlisted` tags: owner/admin. `topic`/`purpose` tags: any member except a guest. Buzz applies 9002 as a partial update: an absent tag leaves that setting unchanged. | +| **Edit group metadata (kind:9002)** | ✅ | `name`/`about`/label `t`/`visibility`/`ttl`/`archived`/`join_role`/`unlisted` tags: owner/admin. `topic`/`purpose` tags: any member except a guest. `auto_join` tag: relay owner/admin (see [Default channels](#default-channels)). Buzz applies 9002 as a partial update: an absent tag leaves that setting unchanged. | | **Admin delete event (kind:9005)** | ✅ | Event author can always delete own. Otherwise owner/admin required. Target must be in same channel. | | **Group deletion (kind:9008)** | ✅ | Owner only. | | **Leave group (kind:9022)** | ✅ | Any member. Last-owner guard prevents orphaned groups. | -| **Group metadata (kind:39000)** | ✅ | Relay-signed; always `d`, `name`, `t`, `P`, `created_at`, `join_role`; `public` or `private`; `restricted` and `closed` per the access flags below; `unlisted` when the channel is unlisted; `about` only if description non-empty; `hidden` for DM channels and private system channels | +| **Group metadata (kind:39000)** | ✅ | Relay-signed; always `d`, `name`, `t`, `P`, `created_at`, `join_role`; `public` or `private`; `restricted` and `closed` per the access flags below; `unlisted` when the channel is unlisted; `about` only if description non-empty; `hidden` for DM channels and private system channels; `auto_join` while the channel is a default channel | | **Unlisted channels** | ✅ | kind:9002 `["unlisted", "true"\|"false"]`, owner/admin, or at creation on kind:9007. An unlisted open channel is left out of every read that does not name it, for non-members: channel lists, kind:39000 without `#d`, messages, COUNT, NIP-50 search and artifact queries. Naming it by `#h` (or `#d` on 39000–39003) reads it, and kind:9021 joins it, as for any open channel. See [Unlisted channels](#unlisted-channels). | | **Group admins (kind:39001)** | ✅ | Relay-signed; `d`, channel type `t` and creator `P` tags + `p` tags with roles (`owner`, `admin`) | | **Group members (kind:39002)** | ✅ | Relay-signed; `d`, channel type `t` and creator `P` tags + `p` tags for all members | @@ -72,7 +72,7 @@ PGPASSWORD=buzz_dev psql -h localhost -U buzz -d buzz -c \ | **NIP-10 threads** | ✅ | WS-submitted replies with `["e","","","reply"]` tags create `thread_metadata` atomically. Visible in REST thread queries. Unknown parents rejected. | | **NIP-17 DMs (gift wrap)** | ✅ | kind:1059 accepted with ephemeral signing keys. Stored community-globally (`channel_id=None` inside the connected community). Delivered via `#p`-filtered subscriptions. Not indexed in search. | | **DM discovery** | ✅ | DM creation emits kind:39000 (with `hidden` tag) + kind:44100 membership notifications. NIP-29 clients discover DMs via standard group discovery flow. | -| **Join request (kind:9021)** | ✅ | Open channels only. Adds the joiner with the channel's `join_role` (`member` by default), emits system message + group discovery events + kind:44100 membership notification. Private channels rejected at ingest. | +| **Join request (kind:9021)** | ✅ | Open channels only. Adds the joiner with the channel's `join_role` (`member` by default), emits system message + group discovery events + kind:44100 membership notification. Private channels rejected at ingest. The same body joins new community members to [default channels](#default-channels). | | **Edits (kind:40003)** | ⚠️ | Works on the wire but Buzz-only — no standard NIP-29 client renders these | | **Rich content (kind:40002)** | ⚠️ | Works on the wire but Buzz-only — no standard NIP-29 client renders these | @@ -192,6 +192,68 @@ and join it, but it does not show up for people who are not members. channel as unlisted. - Unlisted is not an access control. Anyone with the id can read the channel. +### Default channels + +A community's owners and admins can mark a channel as a **default channel**: +one the relay joins every new community member into. The setting is +`auto_join` on the channel. + +- Only a *relay* owner or admin (the NIP-43 roster role, not a channel role) + can change it, with kind:9002 `["auto_join", "true"]` or + `["auto_join", "false"]`. The actor need not be a member of the channel. + A missing tag means "no change". +- It is accepted only on a `stream` channel the event leaves open, and `true` + also needs it unarchived. `false` is refused on a private channel too: the + actor need not be a member, so neither value may write into a channel they + cannot read. The relay decides this in the same transaction as the other + kind:9002 changes, so one event may open a channel and flag it. Making a + flagged channel private or archiving it is allowed and leaves the stored + value in place; to take a private channel out of the defaults, clear the + flag before making it private or once it is open again. +- kind:39000 carries a bare `["auto_join"]` tag only while the setting is + effective: set, open and unarchived. What the event says is what the relay + does, so a client never has to combine the flag with the access flags. +- Nothing is a default channel until someone asks for it: new channels and + existing channels start with `auto_join` off. `buzz channels update + --channel --auto-join true` sets it. + +Every change is announced by one kind:40099 `auto_join_changed` system +message in the channel. + +When someone becomes a community member (an invite claim or a kind:9030 add) +the relay joins them to every effective default channel as a self-join: the +body of kind:9021, so the channel's `join_role` applies and the channel gets +its `member_joined` kind:40099, kind:39002 and kind:44100. There is no cap on +how many channels that is: whatever carries the kind:39000 tag is joined, and +the work a new member costs grows with the number of channels an owner or +admin flags. On a community that requires relay membership, the relay does +this once, at that moment, and never again on login or reconnect, so a later +kind:9022 leave stands. A kind:9030 add is +made by someone else, so the new member's `channel_add_policy` applies first, +as it would to that actor's kind:9000: if it refuses them, no default channel +is joined. The claim response waits for the joins, and a claimer who +disconnects before it arrives does not cut them short. A channel that fails +to join, or an add the member's policy refuses, is logged and counted +(`buzz_auto_join_failures_total`), not retried: the kind:39000 tag is the +record a client reconciles against. That includes a channel archived or made +private while the join runs: the membership write decides both again under +the channel's locks, so the member is not added and nothing is announced. +`buzz-admin add-member` writes the roster row only and +does not join default channels. + +A community that does not require relay membership has no roster insert to +join people at, so there the relay joins a person when it admits their +WebSocket AUTH, after the OK, to every effective default channel they have +never been a member of. "Never a member" is the channel's own membership +history: a kind:9022 leave or a kind:9001 removal keeps the row, so the +person is not joined back, and a new device or another relay pod finds the +row and does nothing. The check runs again under the channel's membership +lock, so two admissions of the same person join them once. A channel flagged +later is joined on each person's next admission. Agents are skipped: an AUTH +with a NIP-OA owner, a NIP-FI agent token, or a stored agent owner joins +nothing. A membership write that fails leaves no row, so the next admission +tries that channel again. + ### Group Discovery The relay emits NIP-29 group state events when channels are created, updated, or membership changes. @@ -201,7 +263,7 @@ All discovery events include a `d` tag set to the channel UUID (NIP-29 addressab | Kind | Tags | Content | |------|------|---------| -| **39000** | `d=`, `name`, `t=`, `P=`, `created_at=`, `join_role` (always); `public` or `private`; `restricted` (private channels, and `join_role=guest`); `closed` (private channels only); `unlisted` (unlisted channels); `about` (if description non-empty); `hidden` (DM channels and private system channels) | Group metadata. The access flags follow the channel settings (see [Announce channels](#announce-channels)). | +| **39000** | `d=`, `name`, `t=`, `P=`, `created_at=`, `join_role` (always); `public` or `private`; `restricted` (private channels, and `join_role=guest`); `closed` (private channels only); `unlisted` (unlisted channels); `about` (if description non-empty); `hidden` (DM channels and private system channels); `auto_join` (open, unarchived channels with the setting on, see [Default channels](#default-channels)) | Group metadata. The access flags follow the channel settings (see [Announce channels](#announce-channels)). | | **39001** | `d=`, `t=`, `P=`, `p` tags with role label (`owner`, `admin`) | Admin list | | **39002** | `d=`, `t=`, `P=`, `p` tags for all members | Member list | diff --git a/crates/buzz-admin/src/main.rs b/crates/buzz-admin/src/main.rs index 13e7f6df82..b286037ac1 100644 --- a/crates/buzz-admin/src/main.rs +++ b/crates/buzz-admin/src/main.rs @@ -56,7 +56,9 @@ enum Command { /// /// Accepts a bech32 npub or 64-char hex pubkey. After inserting the DB row, /// publishes a kind:13534 membership roster via Redis so live clients see - /// the updated list immediately. + /// the updated list immediately. Does not join the member to the + /// community's default (`auto_join`) channels: the relay does that on an + /// invite claim or a kind:9030 add, which this command bypasses. AddMember { /// Nostr public key — bech32 npub or 64-char hex. #[arg(long)] diff --git a/crates/buzz-cli/src/commands/channels.rs b/crates/buzz-cli/src/commands/channels.rs index d4a8d6fe93..40de86b686 100644 --- a/crates/buzz-cli/src/commands/channels.rs +++ b/crates/buzz-cli/src/commands/channels.rs @@ -1640,6 +1640,7 @@ fn validate_update_channel_fields( ttl_change: Option>, join_role: Option, unlisted: Option, + auto_join: Option, ) -> Result<(), CliError> { if name.is_none() && description.is_none() @@ -1647,9 +1648,10 @@ fn validate_update_channel_fields( && ttl_change.is_none() && join_role.is_none() && unlisted.is_none() + && auto_join.is_none() { return Err(CliError::Usage( - "at least one field required (--name, --description, --visibility, --ttl, --no-ttl, --join-role, --unlisted)" + "at least one field required (--name, --description, --visibility, --ttl, --no-ttl, --join-role, --unlisted, --auto-join)" .into(), )); } @@ -1667,6 +1669,7 @@ pub async fn cmd_update_channel( no_ttl: bool, join_role: Option, unlisted: Option, + auto_join: Option, ) -> Result<(), CliError> { // Outer Option: None leaves TTL unchanged. Inner: Some(secs) sets it, // None (from --no-ttl) clears it, making the channel permanent. @@ -1683,6 +1686,7 @@ pub async fn cmd_update_channel( ttl_change, join_role, unlisted, + auto_join, )?; let channel_uuid = parse_uuid(channel_id)?; // `--unlisted false` needs no check: a relay without the extension lists @@ -1699,6 +1703,7 @@ pub async fn cmd_update_channel( ttl_change, join_role, unlisted, + auto_join, ) .map_err(|e| CliError::Other(format!("build_update_channel failed: {e}")))?; @@ -2006,6 +2011,7 @@ pub async fn dispatch( no_ttl, join_role, unlisted, + auto_join, } => { let visibility = visibility.as_ref().map(|v| v.to_string()); cmd_update_channel( @@ -2018,6 +2024,7 @@ pub async fn dispatch( no_ttl, join_role.map(Into::into), unlisted, + auto_join, ) .await } @@ -2207,7 +2214,7 @@ mod tests { #[test] fn update_channel_fields_rejects_empty_update() { - let result = validate_update_channel_fields(None, None, None, None, None, None); + let result = validate_update_channel_fields(None, None, None, None, None, None, None); assert!(matches!(result, Err(CliError::Usage(_)))); let msg = result.unwrap_err().to_string(); assert!(msg.contains("at least one field required")); @@ -2216,7 +2223,8 @@ mod tests { #[test] fn update_channel_fields_accepts_visibility_only_update() { - let result = validate_update_channel_fields(None, None, Some("open"), None, None, None); + let result = + validate_update_channel_fields(None, None, Some("open"), None, None, None, None); assert!(result.is_ok(), "visibility-only update should be accepted"); } @@ -4823,6 +4831,7 @@ mod unlisted_support_tests { false, None, Some(true), + None, ) .await .unwrap_err(); @@ -4849,6 +4858,7 @@ mod unlisted_support_tests { false, None, Some(true), + None, ) .await .unwrap(); @@ -4877,6 +4887,7 @@ mod unlisted_support_tests { false, None, Some(false), + None, ) .await .unwrap(); diff --git a/crates/buzz-cli/src/lib.rs b/crates/buzz-cli/src/lib.rs index 1dc1438e1b..c529db3e69 100644 --- a/crates/buzz-cli/src/lib.rs +++ b/crates/buzz-cli/src/lib.rs @@ -699,9 +699,9 @@ pub enum ChannelsCmd { #[arg(long, value_name = "PATH")] templates_file: Option, }, - /// Update channel name, description, visibility, join role, unlisted, or ephemeral TTL + /// Update channel name, description, visibility, join role, unlisted, auto-join, or ephemeral TTL #[command( - after_help = "Examples:\n buzz channels update --channel --name general\n buzz channels update --channel --visibility open\n buzz channels update --channel --visibility private\n buzz channels update --channel --join-role guest\n buzz channels update --channel --visibility open --unlisted true" + after_help = "Examples:\n buzz channels update --channel --name general\n buzz channels update --channel --visibility open\n buzz channels update --channel --visibility private\n buzz channels update --channel --join-role guest\n buzz channels update --channel --visibility open --unlisted true\n buzz channels update --channel --auto-join true" )] Update { /// Channel UUID @@ -735,6 +735,11 @@ pub enum ChannelsCmd { /// does not advertise `buzz-unlisted-channels`. #[arg(long, value_name = "BOOL", action = clap::ArgAction::Set)] unlisted: Option, + /// Whether the relay joins every new community member into this + /// channel (a default channel). Relay owner/admin only; accepted only + /// on an open, unarchived stream channel. + #[arg(long, value_name = "true|false")] + auto_join: Option, }, /// Set the channel topic Topic { diff --git a/crates/buzz-db/src/runtime/migration.rs b/crates/buzz-db/src/runtime/migration.rs index 41357dbc90..c78a99eab5 100644 --- a/crates/buzz-db/src/runtime/migration.rs +++ b/crates/buzz-db/src/runtime/migration.rs @@ -777,10 +777,14 @@ mod postgres_tests { let mut migrations: Vec<_> = MIGRATOR.iter().collect(); migrations.sort_by_key(|migration| migration.version); - assert_eq!(migrations.len(), 79); + assert_eq!(migrations.len(), 80); for (index, migration) in migrations.iter().enumerate() { assert_eq!(migration.version, (index + 1) as i64); } + assert!(migrations[79] + .sql + .as_str() + .contains("ADD COLUMN auto_join BOOLEAN NOT NULL DEFAULT false")); assert!(migrations[70] .sql .as_str() diff --git a/crates/buzz-db/src/store/channel.rs b/crates/buzz-db/src/store/channel.rs index cbe9ce2053..36ae4a1dc6 100644 --- a/crates/buzz-db/src/store/channel.rs +++ b/crates/buzz-db/src/store/channel.rs @@ -89,6 +89,10 @@ pub struct ChannelRecord { /// join an unlisted open channel by naming it, and private channels are /// never listed to non-members. pub unlisted: bool, + /// Whether the relay joins new community members into this channel. Only + /// effective while the channel is open and unarchived; see + /// [`group_metadata_tags`] and [`list_auto_join_channels`]. + pub auto_join: bool, } impl ChannelRecord { @@ -183,7 +187,7 @@ pub async fn create_channel( nip29_group_id, topic_required, max_members, topic, topic_set_by, topic_set_at, purpose, purpose_set_by, purpose_set_at, - ttl_seconds, ttl_deadline, labels, join_role, unlisted + ttl_seconds, ttl_deadline, labels, join_role, unlisted, auto_join FROM channels WHERE community_id = $1 AND id = $2 "#, ) @@ -246,7 +250,7 @@ pub async fn create_channel_with_id( nip29_group_id, topic_required, max_members, topic, topic_set_by, topic_set_at, purpose, purpose_set_by, purpose_set_at, - ttl_seconds, ttl_deadline, labels, join_role, unlisted + ttl_seconds, ttl_deadline, labels, join_role, unlisted, auto_join FROM channels WHERE community_id = $1 AND id = $2 "#, ) @@ -490,7 +494,7 @@ async fn get_channel_with_operation( nip29_group_id, topic_required, max_members, topic, topic_set_by, topic_set_at, purpose, purpose_set_by, purpose_set_at, - ttl_seconds, ttl_deadline, labels, join_role, unlisted + ttl_seconds, ttl_deadline, labels, join_role, unlisted, auto_join FROM channels WHERE community_id = $1 AND id = $2 AND deleted_at IS NULL "#, ) @@ -579,7 +583,7 @@ async fn list_channels_with_operation( nip29_group_id, topic_required, max_members, topic, topic_set_by, topic_set_at, purpose, purpose_set_by, purpose_set_at, - ttl_seconds, ttl_deadline, labels, join_role, unlisted + ttl_seconds, ttl_deadline, labels, join_role, unlisted, auto_join FROM channels WHERE community_id = $1 AND deleted_at IS NULL AND visibility::text = $2 ORDER BY created_at DESC @@ -599,7 +603,7 @@ async fn list_channels_with_operation( nip29_group_id, topic_required, max_members, topic, topic_set_by, topic_set_at, purpose, purpose_set_by, purpose_set_at, - ttl_seconds, ttl_deadline, labels, join_role, unlisted + ttl_seconds, ttl_deadline, labels, join_role, unlisted, auto_join FROM channels WHERE community_id = $1 AND deleted_at IS NULL ORDER BY created_at DESC @@ -614,6 +618,83 @@ async fn list_channels_with_operation( rows.into_iter().map(row_to_channel_record).collect() } +/// Lists the channels whose `auto_join` is effective: set on a stream channel +/// that is open, unarchived and not deleted. Oldest first, so the first +/// default channel a member sees is the one that was flagged first. +/// +/// Every effective channel, not a page of them: kind:9002 accepts the flag on +/// any number of channels, and the kind:39000 `auto_join` tag promises the +/// relay joins new members there, so a cap here would silently break that +/// promise for the channels past it. The set is bounded by the community's +/// channels, each flagged by a relay owner or admin. +pub async fn list_auto_join_channels( + pool: &PgPool, + community_id: CommunityId, +) -> Result> { + let mut connection = crate::observability::acquire_writer( + pool, + crate::observability::WriterOperation::Authorization, + ) + .await?; + let rows = sqlx::query( + r#" + SELECT id, name, channel_type::text AS channel_type, visibility::text AS visibility, + description, canvas, + created_by, created_at, updated_at, archived_at, deleted_at, + nip29_group_id, topic_required, max_members, + topic, topic_set_by, topic_set_at, + purpose, purpose_set_by, purpose_set_at, + ttl_seconds, ttl_deadline, labels, join_role, unlisted, auto_join + FROM channels + WHERE community_id = $1 AND auto_join AND channel_type = 'stream' + AND visibility = 'open' AND archived_at IS NULL AND deleted_at IS NULL + ORDER BY created_at ASC + "#, + ) + .bind(community_id.as_uuid()) + .fetch_all(&mut *connection) + .await?; + rows.into_iter().map(row_to_channel_record).collect() +} + +/// The effective `auto_join` channels (see [`list_auto_join_channels`]) where +/// `pubkey` has never had a membership row, active or removed; oldest first. +/// +/// A kind:9022 leave or a kind:9001 removal keeps the row with `removed_at` +/// set, so a channel the person left is not listed. Read without the +/// membership lock: [`crate::channel_members::put_first_member`] decides +/// again under it. +pub async fn list_never_joined_auto_join_channels( + pool: &PgPool, + community_id: CommunityId, + pubkey: &[u8], +) -> Result> { + let mut connection = crate::observability::acquire_writer( + pool, + crate::observability::WriterOperation::Authorization, + ) + .await?; + let ids = sqlx::query_scalar( + r#" + SELECT c.id + FROM channels c + WHERE c.community_id = $1 AND c.auto_join AND c.channel_type = 'stream' + AND c.visibility = 'open' AND c.archived_at IS NULL AND c.deleted_at IS NULL + AND NOT EXISTS ( + SELECT 1 FROM channel_members m + WHERE m.community_id = c.community_id AND m.channel_id = c.id + AND m.pubkey = $2 + ) + ORDER BY c.created_at ASC + "#, + ) + .bind(community_id.as_uuid()) + .bind(pubkey) + .fetch_all(&mut *connection) + .await?; + Ok(ids) +} + /// A channel archived by the ephemeral-channel reaper. #[derive(Debug, Clone, PartialEq, Eq)] pub struct ReapedEphemeralChannel { @@ -669,6 +750,7 @@ pub(crate) fn row_to_channel_record(row: sqlx::postgres::PgRow) -> Result Result Vec> { let tag = |parts: &[&str]| parts.iter().map(|p| (*p).to_owned()).collect::>(); let mut tags = vec![ @@ -748,6 +834,9 @@ pub fn group_metadata_tags(channel: &ChannelRecord, members: &[MemberRecord]) -> &channel.created_at.timestamp().to_string(), ])); tags.push(tag(&["join_role", join_role.as_str()])); + if channel.auto_join && open && channel.archived_at.is_none() { + tags.push(tag(&["auto_join"])); + } // Optional topic / purpose for richer client UX if let Some(topic) = channel.topic.as_deref().filter(|t| !t.is_empty()) { tags.push(tag(&["topic", topic])); @@ -791,6 +880,10 @@ pub struct ChannelUpdate { /// New unlisted flag, or `None` to leave unchanged. It decides listing, /// not admission, so it does not take the membership lock. pub unlisted: Option, + /// Whether the relay joins new community members into the channel, or + /// `None` to leave unchanged. `Some(true)` needs an open, unarchived + /// channel; `apply_channel_metadata_event` refuses it otherwise. + pub auto_join: Option, } impl ChannelUpdate { @@ -803,6 +896,7 @@ impl ChannelUpdate { && self.labels.is_none() && self.join_role.is_none() && self.unlisted.is_none() + && self.auto_join.is_none() } /// True when the update changes a setting that decides admission: @@ -934,9 +1028,11 @@ impl ChannelSettingsWriter<'_> { /// 3. the channel row, `FOR NO KEY UPDATE`. /// /// Roster publication holds the membership lock and then, at commit, takes -/// the TTL lock and updates the row. A huddle join takes the membership -/// lock, then the TTL lock, then the row. A writer that took these locks in -/// any other order could wait for one of them while it waits for this one. +/// the TTL lock and updates the row. A huddle join and a `put_members` +/// membership write take the membership lock, then the TTL lock, then the +/// row; the row lock is what orders them against an archive, which does not +/// take the membership lock. A writer that took these locks in any other +/// order could wait for one of them while it waits for this one. /// /// Returns `ChannelNotFound` when the channel is missing or deleted. pub(crate) async fn lock_channel_for_settings_write<'tx>( @@ -1043,6 +1139,10 @@ async fn update_channel_on( set_parts.push(format!("unlisted = ${param_idx}")); param_idx += 1; } + if updates.auto_join.is_some() { + set_parts.push(format!("auto_join = ${param_idx}")); + param_idx += 1; + } if let Some(ref ttl) = updates.ttl_seconds { // Set ttl_seconds, then reset the deadline from now (or clear both). set_parts.push(format!("ttl_seconds = ${param_idx}")); @@ -1081,6 +1181,9 @@ async fn update_channel_on( if let Some(unlisted) = updates.unlisted { q = q.bind(unlisted); } + if let Some(auto_join) = updates.auto_join { + q = q.bind(auto_join); + } if let Some(ref ttl) = updates.ttl_seconds { q = q.bind(*ttl); } @@ -1297,7 +1400,7 @@ async fn set_archived_on(w: &mut ChannelSettingsWriter<'_>, archive: bool) -> Re /// unchanged. #[derive(Default)] pub struct ChannelMetadataEdit { - /// Name, description, visibility, TTL and labels. + /// Name, description, visibility, TTL, labels, `join_role` and `auto_join`. pub columns: ChannelUpdate, /// New topic, recorded as set by the event's author. pub topic: Option, @@ -1367,6 +1470,29 @@ pub(crate) async fn apply_channel_metadata_event( return Err(DbError::AccessDenied("channel is not archived".to_string())); } } + // `auto_join` only makes sense where a kind:9021 self-join would work: + // an open, unarchived channel. Decide it here, under the row lock and + // after the archive decision, on the state this event leaves behind, so + // one event may open a channel and flag it, and a concurrent archive + // cannot slip past a check made before the lock. Either value needs the + // event to leave the channel open: the relay role that authorizes + // `auto_join` needs no channel membership, so a `false` on a private + // channel would store and fan out an event, with its mentions and + // notice, in a channel its sender cannot read. One refusal for both + // values also keeps the response from telling them apart. + if let Some(auto_join) = edit.columns.auto_join { + let visibility = edit + .columns + .visibility + .as_deref() + .unwrap_or(locked.previous_visibility()); + let archived = edit.archived.unwrap_or(locked.archived()); + if visibility != ChannelVisibility::Open.as_str() || (auto_join && archived) { + return Err(DbError::AccessDenied( + "auto_join requires an open, unarchived channel".to_string(), + )); + } + } crate::insert_mentions_in_transaction(locked.tx(), event, Some(channel_id)).await?; let ChannelMetadataEdit { @@ -1721,6 +1847,28 @@ impl Db { list_channels(&self.pool, community_id, visibility).await } + /// Lists the channels the relay joins new community members into: + /// `auto_join` set, open, unarchived and not deleted; oldest first. + #[datastore_span(name = "list_auto_join_channels", system = "postgresql")] + pub async fn list_auto_join_channels( + &self, + community_id: CommunityId, + ) -> Result> { + list_auto_join_channels(&self.pool, community_id).await + } + + /// Lists the effective `auto_join` channels `pubkey` has never been a + /// member of, active or removed (see + /// [`list_never_joined_auto_join_channels`]). + #[datastore_span(name = "list_never_joined_auto_join_channels", system = "postgresql")] + pub async fn list_never_joined_auto_join_channels( + &self, + community_id: CommunityId, + pubkey: &[u8], + ) -> Result> { + list_never_joined_auto_join_channels(&self.pool, community_id, pubkey).await + } + /// Lists channels during startup reconciliation. #[datastore_span(name = "list_channels_for_bootstrap", system = "postgresql")] pub async fn list_channels_for_bootstrap( @@ -1845,6 +1993,7 @@ mod group_metadata_tag_tests { labels: Vec::new(), join_role: join_role.into(), unlisted: false, + auto_join: false, } } @@ -1888,6 +2037,26 @@ mod group_metadata_tag_tests { assert!(tags.contains(&vec!["join_role".to_string(), "guest".to_string()])); } + /// The bare `auto_join` tag is effective-state: published only while the + /// column is set and the channel is open and unarchived. + #[test] + fn publishes_auto_join_only_while_effective() { + let has_tag = |channel: &ChannelRecord| { + group_metadata_tags(channel, &[]).contains(&vec!["auto_join".to_string()]) + }; + let mut flagged = channel("open", "member"); + assert!(!has_tag(&flagged), "unset"); + flagged.auto_join = true; + assert!(has_tag(&flagged), "open, unarchived"); + let mut archived = channel("open", "member"); + archived.auto_join = true; + archived.archived_at = Some(Utc::now()); + assert!(!has_tag(&archived), "archived"); + let mut private = channel("private", "member"); + private.auto_join = true; + assert!(!has_tag(&private), "private"); + } + /// The type `t` tag comes first among `t` tags, then the creator `P`, /// as on 39001 and 39002; kind:39000 also carries the creation time. #[test] diff --git a/crates/buzz-db/src/store/channel_members.rs b/crates/buzz-db/src/store/channel_members.rs index 75a4d8693d..2c139228a8 100644 --- a/crates/buzz-db/src/store/channel_members.rs +++ b/crates/buzz-db/src/store/channel_members.rs @@ -41,9 +41,11 @@ pub struct MemberRecord { /// concurrent demotions (or a demotion racing a removal) to each observe two /// owners, each pass, and together leave zero — the exact governance loss the /// guards exist to prevent. An advisory key rather than `SELECT ... FOR UPDATE` -/// on the channel row: membership is its own contention domain and must not -/// serialize against unrelated channel metadata writers (`update_channel`, -/// `set_topic`, the TTL transition). Distinct key domain from +/// on the channel row, so a removal, which takes only this key, does not +/// serialize against the row's writers that leave admission alone +/// (`set_topic`, `set_canvas`, the TTL transition). Admissions also lock the +/// row, after this key (see [`lock_channel_for_member_put`]), because the row +/// is what orders an archive against them. Distinct key domain from /// `buzz_channel_ttl:`. const CHANNEL_MEMBERSHIP_LOCK_NAMESPACE: &str = "buzz_channel_membership:"; @@ -652,6 +654,11 @@ pub async fn add_member( /// neither a membership write nor a setting change can slip between the /// decision and the write. /// +/// An archived channel admits nobody new: a target who is not an active +/// member is refused with `AccessDenied` while `archived_at` is set. The +/// archive state is read under the channel row lock, which every archive +/// write needs too (see [`lock_channel_for_member_put`]). +/// /// There is no creator exception: channel creation installs the creator as /// owner in the same transaction, so a creator without an active row was /// removed and is admitted like anyone else. @@ -719,14 +726,71 @@ pub async fn put_members( ) .await?; - // First statement: serialize the whole decision / owner-count / upsert - // sequence against concurrent membership writes on this channel. - acquire_channel_membership_lock(tx.conn(), community_id, channel_id).await?; + // First statements: serialize the whole decision / owner-count / upsert + // sequence against concurrent membership writes and archives of this + // channel. + lock_channel_for_member_put(&mut tx, channel_id).await?; let results = put_members_on(&mut tx, channel_id, targets, invited_by).await?; tx.commit().await?; Ok(results) } +/// Self-join `pubkey` to `channel_id` as [`put_member`] with no actor and no +/// requested role (so the channel's `join_role` applies), but only if +/// `pubkey` has never had a membership row in the channel, active or removed. +/// Returns `None`, writing nothing, when such a row exists. +/// +/// The row is checked under the [`put_member`] locks, so a leave, a join or +/// another first join of the same person commits wholly before or after this +/// write: two sign-ins racing each other write once, and a leave that commits +/// first keeps the person out. A refusal (an archived or private channel) is +/// the [`put_member`] error. +pub async fn put_first_member( + pool: &PgPool, + community_id: CommunityId, + channel_id: Uuid, + pubkey: &[u8], +) -> Result> { + let mut tx = crate::begin_community_write_transaction( + pool, + community_id, + crate::observability::WriterOperation::EventWrite, + ) + .await?; + lock_channel_for_member_put(&mut tx, channel_id).await?; + let seen: bool = sqlx::query_scalar( + "SELECT EXISTS (SELECT 1 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?; + if seen { + tx.rollback().await?; + return Ok(None); + } + let target = MemberPut { + pubkey, + requested: None, + }; + let put = put_members_on(&mut tx, channel_id, &[target], None) + .await? + .pop() + .ok_or_else(|| DbError::InvalidData("membership put returned no result".to_string()))?; + match put { + Ok(put) => { + tx.commit().await?; + Ok(Some(put.member)) + } + Err(refused) => { + tx.rollback().await?; + Err(refused) + } + } +} + /// The result of [`Db::put_members_with_event`]. pub enum MemberPutEventWrite { /// The event was stored and at least one person was written. One result @@ -748,9 +812,8 @@ pub enum MemberPutEventWrite { /// transaction. If any write fails, nothing is stored, so the client can /// resend the same event and have it applied. /// -/// The membership lock comes first, as in [`put_members`] and the channel -/// settings writers, so a second copy of the event waits for the first and -/// then finds it stored. +/// The locks of [`put_members`] come first, so a second copy of the event +/// waits for the first and then finds it stored. pub(crate) async fn put_members_with_event( pool: &PgPool, push_enqueue: Option, @@ -768,7 +831,7 @@ pub(crate) async fn put_members_with_event( ) .await?; tx.set_push_enqueue(push_enqueue); - acquire_channel_membership_lock(tx.conn(), community_id, channel_id).await?; + lock_channel_for_member_put(&mut tx, channel_id).await?; // The same writes as `Db::insert_event_with_thread_metadata`. let (stored, inserted) = crate::event::insert_event_with_thread_metadata_tx( @@ -796,6 +859,23 @@ pub(crate) async fn put_members_with_event( Ok(MemberPutEventWrite::Applied { stored, results }) } +/// Take the locks of a [`put_members`] write on `tx`, in the channel lock +/// order (see `channel::lock_channel_for_settings_write`): the membership +/// lock, the shared TTL lock, then the channel row `FOR NO KEY UPDATE`. +/// +/// The row lock is what orders the write against an archive. Every archive +/// and unarchive updates that row (`archive_channel`, a kind:9002 `archived` +/// tag, the TTL reaper's single UPDATE), and none of them takes the +/// membership lock. So an archive either commits before [`put_members_on`] +/// reads `archived_at`, or waits until this write commits. +async fn lock_channel_for_member_put(tx: &mut AdmittedTx, channel_id: Uuid) -> Result<()> { + let community_id = tx.community(); + acquire_channel_membership_lock(tx.conn(), community_id, channel_id).await?; + lock_channel_ttl_shared_in_transaction(tx, channel_id).await?; + lock_channel_archived_at_in_transaction(tx, channel_id).await?; + Ok(()) +} + fn check_distinct_targets(targets: &[MemberPut<'_>]) -> Result<()> { let mut seen = std::collections::HashSet::with_capacity(targets.len()); match targets.iter().find(|t| !seen.insert(t.pubkey)) { @@ -808,7 +888,8 @@ fn check_distinct_targets(targets: &[MemberPut<'_>]) -> Result<()> { } /// The decisions and upsert of [`put_members`], on a transaction that holds -/// `channel_id`'s membership lock. The caller owns the commit. +/// `channel_id`'s [`lock_channel_for_member_put`] locks. The caller owns the +/// commit. async fn put_members_on( tx: &mut AdmittedTx, channel_id: Uuid, @@ -817,6 +898,7 @@ async fn put_members_on( ) -> Result>> { let community_id = tx.community(); let channel = get_channel_tx(tx.conn(), community_id, channel_id).await?; + let archived = channel.archived_at.is_some(); let private = channel.visibility == "private"; let join_role = buzz_core::channel::ChannelJoinRole::parse_fail_closed(&channel.join_role); @@ -854,6 +936,12 @@ async fn put_members_on( } let actor = invited_by.unwrap_or(target.pubkey); let current_role = roles.get(target.pubkey).cloned(); + if archived && current_role.is_none() { + decided.push(Err(DbError::AccessDenied( + "channel is archived".to_string(), + ))); + continue; + } let actor_role: Option = roles .get(actor) .map(|r| { @@ -1741,7 +1829,7 @@ async fn get_channel_tx( nip29_group_id, topic_required, max_members, topic, topic_set_by, topic_set_at, purpose, purpose_set_by, purpose_set_at, - ttl_seconds, ttl_deadline, labels, join_role, unlisted + ttl_seconds, ttl_deadline, labels, join_role, unlisted, auto_join FROM channels WHERE community_id = $1 AND id = $2 AND deleted_at IS NULL "#, ) @@ -1839,7 +1927,7 @@ pub async fn get_accessible_channels( c.nip29_group_id, c.topic_required, c.max_members, c.topic, c.topic_set_by, c.topic_set_at, c.purpose, c.purpose_set_by, c.purpose_set_at, - c.ttl_seconds, c.ttl_deadline, c.labels, c.join_role, c.unlisted, + c.ttl_seconds, c.ttl_deadline, c.labels, c.join_role, c.unlisted, c.auto_join, (cm.channel_id IS NOT NULL) AS is_member FROM channels c LEFT JOIN channel_members cm @@ -2142,6 +2230,18 @@ impl Db { .await } + /// Self-joins `pubkey` with the channel's `join_role` only if they have + /// never been a member of the channel (see [`put_first_member`]). + #[datastore_span(name = "put_first_member", system = "postgresql")] + pub async fn put_first_member( + &self, + community_id: CommunityId, + channel_id: Uuid, + pubkey: &[u8], + ) -> Result> { + put_first_member(&self.pool, community_id, channel_id, pubkey).await + } + /// Stores a kind:9000 `event` and applies its [`put_members`] writes, /// with the event's author as the actor, in one transaction (see /// [`put_members_with_event`]). @@ -2520,6 +2620,113 @@ mod postgres_tests { crate::channel::get_channel(pool, CommunityId::from_uuid(community_id), id).await } + /// A person who has never been in a flagged channel is listed for it and + /// joined once, with the channel's `join_role`; a second put finds the + /// row; after a leave the channel is neither listed nor joined again. An + /// unflagged channel is never listed. + /// + /// Two racing first joins write once. + /// + /// Mutations: drop the row check from `put_first_member` → the leave is + /// undone (RED); take the locks after the row check → racing joins both + /// write (RED); drop the `NOT EXISTS` from + /// `list_never_joined_auto_join_channels` → the left channel is still + /// listed (RED). + #[tokio::test] + #[ignore = "requires Postgres"] + async fn first_member_put_joins_once_and_never_undoes_a_leave() { + use crate::channel::{list_never_joined_auto_join_channels, update_channel, ChannelUpdate}; + let pool = setup_pool().await; + let community = CommunityId::from_uuid(make_test_community(&pool).await); + let (owner, person) = (random_pubkey(), random_pubkey()); + let mut channels = Vec::new(); + for name in ["general", "announcements", "random"] { + let channel = create_test_channel( + &pool, + *community.as_uuid(), + name, + ChannelType::Stream, + ChannelVisibility::Open, + None, + &owner, + None, + ) + .await + .expect("create channel"); + channels.push(channel.id); + } + let (general, announcements, unflagged) = (channels[0], channels[1], channels[2]); + for (channel, join_role) in [(general, None), (announcements, Some("guest"))] { + update_channel( + &pool, + community, + channel, + ChannelUpdate { + auto_join: Some(true), + join_role: join_role.map(str::to_string), + ..Default::default() + }, + ) + .await + .expect("flag channel"); + } + + let never_joined = || list_never_joined_auto_join_channels(&pool, community, &person); + assert_eq!( + never_joined().await.expect("list"), + [general, announcements] + ); + + let put_first = |channel| put_first_member(&pool, community, channel, &person); + assert_eq!( + put_first(general).await.expect("first put").map(|m| m.role), + Some("member".to_string()) + ); + assert_eq!( + put_first(announcements) + .await + .expect("first put") + .map(|m| m.role), + Some("guest".to_string()), + "the channel's join_role applies" + ); + assert!(put_first(general).await.expect("second put").is_none()); + assert!(never_joined().await.expect("list").is_empty()); + + // Two first joins of the same person racing each other write once. + for _ in 0..5 { + let racer = random_pubkey(); + let (a, b) = tokio::join!( + put_first_member(&pool, community, general, &racer), + put_first_member(&pool, community, general, &racer), + ); + let written = [a.expect("racing put"), b.expect("racing put")] + .iter() + .filter(|put| put.is_some()) + .count(); + assert_eq!(written, 1, "racing first joins write once"); + } + + remove_member(&pool, community, general, &person, &person) + .await + .expect("leave"); + assert!(never_joined().await.expect("list").is_empty()); + assert!(put_first(general).await.expect("put after leave").is_none()); + assert_eq!( + get_member_role(&pool, community, general, &person) + .await + .expect("role"), + None, + "a leave stands" + ); + assert_eq!( + get_member_role(&pool, community, unflagged, &person) + .await + .expect("role"), + None + ); + } + #[tokio::test] #[ignore = "requires Postgres"] async fn get_users_bulk_is_scoped_when_pubkey_exists_in_multiple_communities() { @@ -3855,6 +4062,205 @@ mod postgres_tests { .expect("join_role change"); } + /// An archive and an admission are ordered by the channel row lock, and an + /// archived channel admits nobody new. A join that waits on an uncommitted + /// archive is refused once it commits, with no row, and re-adding a + /// removed member is refused too. A kind:9000 add that waits on an + /// uncommitted archive is refused the same way, and its event is not + /// stored. The other order: an archive waits for an admission that holds + /// the locks. + /// The archive here is the TTL reaper's single UPDATE, which takes no + /// advisory lock. The join and the kind:9000 add each wait on their own + /// archive, so neither queues behind the other's membership lock. + /// Mutations: drop the row lock from `lock_channel_for_member_put`, take + /// only the membership lock in `put_members_with_event`, or drop the + /// archived refusal from `put_members_on` → RED. + #[tokio::test] + #[ignore = "requires Postgres"] + async fn put_member_waits_for_a_concurrent_archive() { + let pool = setup_pool().await; + let community_id = make_test_community(&pool).await; + let community = CommunityId::from_uuid(community_id); + let owner_keys = Keys::generate(); + let owner = owner_keys.public_key().to_bytes().to_vec(); + let joiner = random_pubkey(); + let removed = random_pubkey(); + let invitee = random_pubkey(); + for pk in [&owner, &joiner, &removed, &invitee] { + ensure_user(&pool, community, pk) + .await + .expect("ensure user"); + } + let channel = create_test_channel( + &pool, + community_id, + "put-member-archive-race", + ChannelType::Stream, + ChannelVisibility::Open, + None, + &owner, + None, + ) + .await + .expect("create channel"); + put_member(&pool, community, channel.id, &removed, None, None) + .await + .expect("join before the archive"); + remove_member(&pool, community, channel.id, &removed, &removed) + .await + .expect("leave before the archive"); + + let mut archive = pool.begin().await.expect("begin archive"); + sqlx::query( + "UPDATE channels SET archived_at = NOW() \ + WHERE community_id = $1 AND id = $2 AND archived_at IS NULL", + ) + .bind(community_id) + .bind(channel.id) + .execute(&mut *archive) + .await + .expect("archive"); + + let join_pool = crate::test_support::named_pool("put-member-archive-race").await; + let join = tokio::spawn({ + let channel_id = channel.id; + let joiner = joiner.clone(); + async move { put_member(&join_pool, community, channel_id, &joiner, None, None).await } + }); + crate::test_support::wait_for_lock_wait(&pool, "put-member-archive-race").await; + assert!(!join.is_finished(), "the join must wait for the archive"); + archive.commit().await.expect("commit archive"); + + let err = join + .await + .expect("join task") + .expect_err("the join is decided on the archived channel"); + assert!( + matches!(&err, DbError::AccessDenied(reason) if reason == "channel is archived"), + "{err:?}" + ); + let readd = put_member(&pool, community, channel.id, &removed, None, Some(&owner)) + .await + .expect_err("an archived channel does not reactivate a removed member"); + assert!( + matches!(&readd, DbError::AccessDenied(reason) if reason == "channel is archived"), + "{readd:?}" + ); + + // A kind:9000 add stores its event in the same transaction as the + // write, so it takes the same locks: it waits on an uncommitted + // archive, then is refused and stores nothing. + crate::channel::unarchive_channel(&pool, community, channel.id) + .await + .expect("unarchive before the kind:9000 race"); + let mut archive = pool.begin().await.expect("begin second archive"); + sqlx::query( + "UPDATE channels SET archived_at = NOW() \ + WHERE community_id = $1 AND id = $2 AND archived_at IS NULL", + ) + .bind(community_id) + .bind(channel.id) + .execute(&mut *archive) + .await + .expect("archive again"); + let add = nostr::EventBuilder::new(nostr::Kind::Custom(9000), "") + .tags([ + nostr::Tag::parse(["h", &channel.id.to_string()]).unwrap(), + nostr::Tag::parse(["p", &hex::encode(&invitee)]).unwrap(), + ]) + .sign_with_keys(&owner_keys) + .expect("sign 9000"); + let add_pool = crate::test_support::named_pool("put-members-event-archive-race").await; + let add_write = tokio::spawn({ + let channel_id = channel.id; + let add = add.clone(); + let invitee = invitee.clone(); + async move { + let targets = [MemberPut { + pubkey: &invitee, + requested: None, + }]; + put_members_with_event(&add_pool, None, community, channel_id, &add, &targets, None) + .await + } + }); + crate::test_support::wait_for_lock_wait(&pool, "put-members-event-archive-race").await; + assert!( + !add_write.is_finished(), + "the kind:9000 add must wait for the archive" + ); + archive.commit().await.expect("commit second archive"); + let write = add_write + .await + .expect("kind:9000 task") + .expect("kind:9000 write"); + assert!( + matches!(&write, MemberPutEventWrite::Refused(errors) + if matches!(errors.as_slice(), + [DbError::AccessDenied(reason)] if reason == "channel is archived")), + "expected an archived refusal" + ); + assert!( + crate::event::get_event_by_id(&pool, community, add.id.as_bytes()) + .await + .expect("read event") + .is_none(), + "a refused kind:9000 is not stored" + ); + + let mut roles = Vec::new(); + for pk in [&owner, &joiner, &removed, &invitee] { + roles.push( + get_member_role(&pool, community, channel.id, pk) + .await + .expect("read role"), + ); + } + assert_eq!(roles, [Some("owner".to_string()), None, None, None]); + + // The other order: an archive waits for an admission that holds the + // locks, and the member it admitted stays a member. + crate::channel::unarchive_channel(&pool, community, channel.id) + .await + .expect("unarchive"); + let mut admission = crate::begin_community_write_transaction( + &pool, + community, + crate::observability::WriterOperation::EventWrite, + ) + .await + .expect("begin admission"); + lock_channel_for_member_put(&mut admission, channel.id) + .await + .expect("lock for the admission"); + let targets = [MemberPut { + pubkey: &joiner, + requested: None, + }]; + let admitted = put_members_on(&mut admission, channel.id, &targets, None) + .await + .expect("admission"); + assert!(admitted[0].is_ok(), "{admitted:?}"); + let archive_pool = crate::test_support::named_pool("archive-vs-admission").await; + let archive = tokio::spawn({ + let channel_id = channel.id; + async move { crate::channel::archive_channel(&archive_pool, community, channel_id).await } + }); + crate::test_support::wait_for_lock_wait(&pool, "archive-vs-admission").await; + assert!( + !archive.is_finished(), + "an archive must wait for an admission in progress" + ); + admission.commit().await.expect("commit admission"); + archive.await.expect("archive task").expect("archive"); + assert_eq!( + get_member_role(&pool, community, channel.id, &joiner) + .await + .expect("read role"), + Some("member".to_string()) + ); + } + /// A `join_role` change on an ephemeral channel and a roster publication /// take the channel's locks in one order. The roster holds the membership /// lock and refreshes the channel's TTL (TTL lock, then row) at commit. A diff --git a/crates/buzz-db/src/store/dm.rs b/crates/buzz-db/src/store/dm.rs index a9c93477ae..ebdbc45d27 100644 --- a/crates/buzz-db/src/store/dm.rs +++ b/crates/buzz-db/src/store/dm.rs @@ -76,7 +76,7 @@ pub async fn find_dm_by_participants( created_by, created_at, updated_at, archived_at, deleted_at, nip29_group_id, topic_required, max_members, topic, topic_set_by, topic_set_at, - purpose, purpose_set_by, purpose_set_at, labels, join_role, unlisted + purpose, purpose_set_by, purpose_set_at, labels, join_role, unlisted, auto_join FROM channels WHERE community_id = $1 AND participant_hash = $2 @@ -142,7 +142,7 @@ pub async fn create_dm( created_by, created_at, updated_at, archived_at, deleted_at, nip29_group_id, topic_required, max_members, topic, topic_set_by, topic_set_at, - purpose, purpose_set_by, purpose_set_at, labels, join_role, unlisted + purpose, purpose_set_by, purpose_set_at, labels, join_role, unlisted, auto_join FROM channels WHERE community_id = $1 AND participant_hash = $2 @@ -213,7 +213,7 @@ pub async fn create_dm( created_by, created_at, updated_at, archived_at, deleted_at, nip29_group_id, topic_required, max_members, topic, topic_set_by, topic_set_at, - purpose, purpose_set_by, purpose_set_at, labels, join_role, unlisted + purpose, purpose_set_by, purpose_set_at, labels, join_role, unlisted, auto_join FROM channels WHERE community_id = $1 AND id = $2 "#, ) @@ -536,6 +536,7 @@ fn row_to_channel_record(row: sqlx::postgres::PgRow) -> Result { labels: row.try_get("labels")?, join_role: row.try_get("join_role")?, unlisted: row.try_get("unlisted")?, + auto_join: row.try_get("auto_join")?, }) } diff --git a/crates/buzz-relay/src/api/invites.rs b/crates/buzz-relay/src/api/invites.rs index e184a35c41..3710f5747e 100644 --- a/crates/buzz-relay/src/api/invites.rs +++ b/crates/buzz-relay/src/api/invites.rs @@ -24,7 +24,9 @@ use axum::{ use serde::Deserialize; use serde_json::Value; -use crate::handlers::side_effects::{fan_out_nip43_membership_list, publish_nip43_member_added}; +use crate::handlers::side_effects::{ + auto_join_default_channels, fan_out_nip43_membership_list, publish_nip43_member_added, +}; use crate::nip_fi_http::admit_nip_fi_http_on_state; use buzz_core::invite::{ hash_v2_code, validate_v2_code, DEFAULT_INVITE_TTL_SECS, MAX_INVITE_TTL_SECS, MAX_INVITE_USES, @@ -482,7 +484,13 @@ pub async fn claim_invite( if write.outcome.owner_link_created { state.disconnect_unowned_agent_clusterwide(&tenant, pubkey.as_bytes()); } - fan_out_nip43_membership_list(&tenant, &state, write.snapshot).await; + // The member-added delta and the auto-join only on Joined, never on + // other outcomes; the membership-list fan-out on every outcome. + let joined = matches!( + write.outcome.outcome, + buzz_db::relay_invite::ClaimOutcome::Joined { .. } + ); + finish_committed_claim(&tenant, &state, &claimer_hex, write.snapshot, joined).await; return match write.outcome.outcome { buzz_db::relay_invite::ClaimOutcome::Joined { .. } => { tracing::info!( @@ -490,12 +498,6 @@ pub async fn claim_invite( member = %claimer_hex, "relay member added via v2 invite" ); - // NIP-43 side effects only on Joined, never on other outcomes. - if let Err(e) = publish_nip43_member_added(&tenant, &state, &claimer_hex).await { - tracing::warn!( - "failed to publish NIP-43 member-added delta after v2 claim: {e}" - ); - } Ok(Json(serde_json::json!({ "status": "joined", "community_id": tenant.community().to_string(), @@ -578,7 +580,7 @@ pub async fn claim_invite( state.disconnect_unowned_agent_clusterwide(&tenant, pubkey.as_bytes()); } let was_inserted = write.outcome.inserted; - fan_out_nip43_membership_list(&tenant, &state, write.snapshot).await; + finish_committed_claim(&tenant, &state, &claimer_hex, write.snapshot, was_inserted).await; if was_inserted { tracing::info!( @@ -586,9 +588,6 @@ pub async fn claim_invite( member = %claimer_hex, "relay member added via invite" ); - if let Err(e) = publish_nip43_member_added(&tenant, &state, &claimer_hex).await { - tracing::warn!("failed to publish NIP-43 member-added delta after claim: {e}"); - } } Ok(Json(serde_json::json!({ @@ -599,6 +598,42 @@ pub async fn claim_invite( }))) } +/// The follow-up of a claim whose roster write committed: the NIP-43 +/// membership-list fan-out and, when the claim added the member (`joined`), +/// the member-added delta and the default-channel auto-join. +/// +/// It runs on its own task, which the handler awaits, so the response still +/// waits for the joins. A client that disconnects first makes the server +/// drop the handler's future: that detaches the task rather than cancelling +/// it partway, which nothing would retry, because a re-claim is +/// `already_member`. +async fn finish_committed_claim( + tenant: &buzz_core::TenantContext, + state: &Arc, + claimer_hex: &str, + snapshot: Option, + joined: bool, +) { + let follow_up = tokio::spawn({ + let (tenant, state, claimer_hex) = + (tenant.clone(), Arc::clone(state), claimer_hex.to_owned()); + async move { + fan_out_nip43_membership_list(&tenant, &state, snapshot).await; + if !joined { + return; + } + if let Err(e) = publish_nip43_member_added(&tenant, &state, &claimer_hex).await { + tracing::warn!("failed to publish NIP-43 member-added delta after claim: {e}"); + } + // A claimer joins for themselves: no third-party add policy. + auto_join_default_channels(&tenant, &state, &claimer_hex, None).await; + } + }); + if let Err(e) = follow_up.await { + tracing::error!(member = %claimer_hex, error = %e, "invite claim follow-up task failed"); + } +} + /// Fixed-window rate limit on claim attempts, keyed by community and claimer /// pubkey so traffic for one tenant cannot consume another tenant's allowance. /// @@ -1205,6 +1240,505 @@ mod postgres_tests { ); } + /// How many kind:44100 notifications name `member_hex` across the + /// community, one per channel the relay joined them to. + async fn added_notification_count( + state: &AppState, + community: buzz_core::CommunityId, + member_hex: &str, + ) -> i64 { + state + .db + .count_events(&buzz_db::EventQuery { + kinds: Some(vec![buzz_core::kind::KIND_MEMBER_ADDED_NOTIFICATION as i32]), + p_tag_hex: Some(member_hex.to_string()), + global_only: true, + ..buzz_db::EventQuery::for_community(community) + }) + .await + .expect("count 44100") + } + + /// How many `member_joined` kind:40099 notices in `channel_id` target + /// `member_hex`. + async fn member_joined_notice_count( + state: &AppState, + community: buzz_core::CommunityId, + channel_id: Uuid, + member_hex: &str, + ) -> usize { + state + .db + .query_events(&buzz_db::EventQuery { + kinds: Some(vec![40099]), + channel_id: Some(channel_id), + ..buzz_db::EventQuery::for_community(community) + }) + .await + .expect("query 40099") + .iter() + .filter_map(|stored| serde_json::from_str::(&stored.event.content).ok()) + .filter(|content| content["type"] == "member_joined" && content["target"] == member_hex) + .count() + } + + /// The channel roles the relay holds for `keys` in `channels`, in order. + async fn channel_roles( + state: &AppState, + community: buzz_core::CommunityId, + channels: &[Uuid], + keys: &Keys, + ) -> Vec> { + let mut roles = Vec::new(); + for channel_id in channels { + roles.push( + state + .db + .get_member_role(community, *channel_id, &keys.public_key().to_bytes()) + .await + .expect("member role"), + ); + } + roles + } + + /// Wait until a session blocked by `blocker_pid` waits for the channel + /// row lock `put_member` takes, failing if `task` finishes first. + async fn wait_for_put_member_behind( + pool: &sqlx::PgPool, + blocker_pid: i32, + task: &tokio::task::JoinHandle, + ) { + let deadline = std::time::Instant::now() + Duration::from_secs(10); + loop { + let waiting: bool = sqlx::query_scalar( + "SELECT EXISTS (SELECT 1 FROM pg_stat_activity \ + WHERE wait_event_type = 'Lock' \ + AND $1 = ANY(pg_blocking_pids(pid)) \ + AND query LIKE 'SELECT archived_at FROM channels %FOR NO KEY UPDATE')", + ) + .bind(blocker_pid) + .fetch_one(pool) + .await + .expect("read lock waiters"); + if waiting { + return; + } + assert!( + !task.is_finished(), + "the join finished without waiting for the held channel row" + ); + assert!( + std::time::Instant::now() < deadline, + "the join never waited for the held channel row" + ); + tokio::time::sleep(Duration::from_millis(10)).await; + } + } + + /// Claiming an invite joins the new member to the community's effective + /// default channels, once. Four channels flagged by the relay owner: + /// one open, one archived after flagging, one made private after + /// flagging, plus an unflagged control. A v2 claim joins the open one + /// only, with its kind:39002 roster, one kind:44100 and one + /// `member_joined` notice; a kind:9022 leave followed by a re-claim is + /// `already_member` and does not re-add. The v1 token path joins the same + /// way. The archived guard the shared join body owns is bound directly. + /// Mutation: delete `auto_join_default_channels` at either claim site → + /// RED. + #[tokio::test] + #[ignore = "requires Postgres"] + async fn claim_auto_joins_default_channels_once() { + use crate::handlers::group_state_postgres_tests::{ + create_channel, edit_channel, group_state_tags, http_auth, + }; + use crate::handlers::ingest::ingest_event; + use crate::handlers::side_effects::join_channel_as_self; + + let host = format!("invites-auto-join-{}.example", Uuid::new_v4().simple()); + let owner = Keys::generate(); + let state = invite_test_state(&host) + .await + .expect("requires reachable Postgres and relay test state"); + let community = state + .db + .lookup_community_by_host(&host) + .await + .expect("lookup") + .expect("community exists") + .id; + let tenant = buzz_core::TenantContext::resolved(community, &host); + state + .db + .add_relay_member( + community, + &owner.public_key().to_hex(), + "owner", + None, + &Keys::generate(), + ) + .await + .map(|write| write.outcome) + .expect("seed owner"); + + let flagged = create_channel(&state, &tenant, &owner, &[]).await; + let archived = create_channel(&state, &tenant, &owner, &[]).await; + let private = create_channel(&state, &tenant, &owner, &[]).await; + let control = create_channel(&state, &tenant, &owner, &[]).await; + for channel_id in [flagged, archived, private] { + edit_channel( + &state, + &tenant, + &owner, + channel_id, + &[&["auto_join", "true"]], + ) + .await + .expect("relay owner flags channel"); + } + edit_channel(&state, &tenant, &owner, archived, &[&["archived", "true"]]) + .await + .expect("archive after flagging"); + edit_channel( + &state, + &tenant, + &owner, + private, + &[&["visibility", "private"]], + ) + .await + .expect("make private after flagging"); + let channels = [flagged, archived, private, control]; + + let joiner = Keys::generate(); + let joiner_hex = joiner.public_key().to_hex(); + let archived_refusal = + join_channel_as_self(&tenant, &state, archived, &joiner.public_key().to_bytes()) + .await + .expect_err("self-join of an archived channel"); + assert!( + archived_refusal.to_string().contains("archived"), + "{archived_refusal}" + ); + assert_eq!( + added_notification_count(&state, community, &joiner_hex).await, + 0 + ); + + let code = mint_code( + state.clone(), + &host, + &owner, + serde_json::json!({ "max_uses": 1 }), + ) + .await; + let claim_body = serde_json::json!({ "code": code }).to_string(); + let response = post_json( + state.clone(), + &host, + "/api/invites/claim", + &joiner, + claim_body.clone(), + ) + .await; + assert_eq!(response.status(), StatusCode::OK); + assert_eq!(read_json(response).await["status"], "joined"); + assert_eq!( + channel_roles(&state, community, &channels, &joiner).await, + [Some("member".to_string()), None, None, None], + "joined to the effective default channel only" + ); + assert!( + group_state_tags(&state, &tenant, flagged, 39002) + .await + .iter() + .any(|tag| tag[0] == "p" && tag[1] == joiner_hex), + "kind:39002 of the default channel lists the new member" + ); + assert_eq!( + added_notification_count(&state, community, &joiner_hex).await, + 1 + ); + assert_eq!( + member_joined_notice_count(&state, community, flagged, &joiner_hex).await, + 1 + ); + + // Leaving is respected: a re-claim is a no-op, not a re-add. + let leave = EventBuilder::new(Kind::Custom(9022), "") + .tags([Tag::parse(["h", &flagged.to_string()]).unwrap()]) + .sign_with_keys(&joiner) + .expect("sign 9022"); + let left = ingest_event(&state, &tenant, leave, http_auth(&joiner)) + .await + .expect("ingest 9022"); + assert!(left.accepted, "{:?}", left.message); + assert_eq!( + channel_roles(&state, community, &[flagged], &joiner).await, + [None] + ); + let response = post_json( + state.clone(), + &host, + "/api/invites/claim", + &joiner, + claim_body, + ) + .await; + assert_eq!(response.status(), StatusCode::OK); + assert_eq!(read_json(response).await["status"], "already_member"); + assert_eq!( + channel_roles(&state, community, &channels, &joiner).await, + [None, None, None, None], + "a re-claim must not resurrect a kind:9022 leave" + ); + assert_eq!( + added_notification_count(&state, community, &joiner_hex).await, + 1 + ); + assert_eq!( + member_joined_notice_count(&state, community, flagged, &joiner_hex).await, + 1 + ); + + // The v1 HMAC token path joins the same way. + let v1_joiner = Keys::generate(); + let v1_joiner_hex = v1_joiner.public_key().to_hex(); + let v1_code = forge_v1_invite_code(&state, community, unix_now() + 3600); + let response = post_json( + state.clone(), + &host, + "/api/invites/claim", + &v1_joiner, + serde_json::json!({ "code": v1_code }).to_string(), + ) + .await; + assert_eq!(response.status(), StatusCode::OK); + assert_eq!(read_json(response).await["status"], "joined"); + assert_eq!( + channel_roles(&state, community, &channels, &v1_joiner).await, + [Some("member".to_string()), None, None, None] + ); + assert_eq!( + added_notification_count(&state, community, &v1_joiner_hex).await, + 1 + ); + } + + /// A default channel archived after the auto-join listed it is skipped: + /// no membership row, no kind:39002 roster entry, no kind:44100 and no + /// `member_joined` notice. The test holds an uncommitted archive of the + /// listed channel, so the auto-join passes the list and the shared join + /// body's open/unarchived check on the committed state and then waits in + /// `put_member`; the archive commits and the join resumes on an archived + /// channel. Mutation: drop the channel row lock or the archived refusal + /// from `put_members` → RED. + #[tokio::test] + #[ignore = "requires Postgres"] + async fn auto_join_skips_a_default_channel_archived_while_it_joins() { + use crate::handlers::group_state_postgres_tests::{ + create_channel, edit_channel, group_state_tags, + }; + use crate::handlers::side_effects::auto_join_default_channels; + + let host = format!( + "invites-auto-join-archive-{}.example", + Uuid::new_v4().simple() + ); + let owner = Keys::generate(); + let state = invite_test_state(&host) + .await + .expect("requires reachable Postgres and relay test state"); + let community = state + .db + .lookup_community_by_host(&host) + .await + .expect("lookup") + .expect("community exists") + .id; + let tenant = buzz_core::TenantContext::resolved(community, &host); + let joiner = Keys::generate(); + let joiner_hex = joiner.public_key().to_hex(); + for (keys, role) in [(&owner, "owner"), (&joiner, "member")] { + state + .db + .add_relay_member( + community, + &keys.public_key().to_hex(), + role, + None, + &Keys::generate(), + ) + .await + .map(|write| write.outcome) + .expect("seed relay member"); + } + let starter = create_channel(&state, &tenant, &owner, &[]).await; + edit_channel(&state, &tenant, &owner, starter, &[&["auto_join", "true"]]) + .await + .expect("relay owner flags channel"); + + let pool = state.db.pool().clone(); + let mut archive = pool.begin().await.expect("begin archive"); + let archive_pid: i32 = sqlx::query_scalar("SELECT pg_backend_pid()") + .fetch_one(&mut *archive) + .await + .expect("archive backend pid"); + sqlx::query("UPDATE channels SET archived_at = NOW() WHERE community_id = $1 AND id = $2") + .bind(community.as_uuid()) + .bind(starter) + .execute(&mut *archive) + .await + .expect("archive the default channel"); + + let auto_join = tokio::spawn({ + let state = Arc::clone(&state); + let tenant = tenant.clone(); + let joiner_hex = joiner_hex.clone(); + async move { auto_join_default_channels(&tenant, &state, &joiner_hex, None).await } + }); + // Wait until the join is blocked on the channel row lock in + // `put_member`, behind the archive, so it has already listed the + // channel and passed the join body's check. + wait_for_put_member_behind(&pool, archive_pid, &auto_join).await; + archive.commit().await.expect("commit archive"); + tokio::time::timeout(Duration::from_secs(10), auto_join) + .await + .expect("the auto-join returns after the archive commits") + .expect("auto-join task must not panic"); + + assert_eq!( + channel_roles(&state, community, &[starter], &joiner).await, + [None], + "an archived default channel must not admit the new member" + ); + assert!( + !group_state_tags(&state, &tenant, starter, 39002) + .await + .iter() + .any(|tag| tag[0] == "p" && tag[1] == joiner_hex), + "kind:39002 must not list the new member" + ); + assert_eq!( + added_notification_count(&state, community, &joiner_hex).await, + 0 + ); + assert_eq!( + member_joined_notice_count(&state, community, starter, &joiner_hex).await, + 0 + ); + } + + /// A client that disconnects mid-claim does not cut the auto-join short. + /// The server drops the handler's future on a disconnect; the test does + /// the same by aborting the request while the auto-join waits in + /// `put_member` behind a held lock on the first default channel's row. + /// Once the lock is released, the claimer still joins both default + /// channels, for a v2 and a v1 claim. Mutation: run the follow-up inline + /// in the handler again → RED (the claimer joins no channel). + #[tokio::test] + #[ignore = "requires Postgres"] + async fn claim_follow_up_survives_a_client_disconnect() { + use crate::handlers::group_state_postgres_tests::{create_channel, edit_channel}; + + let host = format!( + "invites-auto-join-disconnect-{}.example", + Uuid::new_v4().simple() + ); + let owner = Keys::generate(); + let state = invite_test_state(&host) + .await + .expect("requires reachable Postgres and relay test state"); + let community = state + .db + .lookup_community_by_host(&host) + .await + .expect("lookup") + .expect("community exists") + .id; + let tenant = buzz_core::TenantContext::resolved(community, &host); + state + .db + .add_relay_member( + community, + &owner.public_key().to_hex(), + "owner", + None, + &Keys::generate(), + ) + .await + .map(|write| write.outcome) + .expect("seed owner"); + let first = create_channel(&state, &tenant, &owner, &[]).await; + let second = create_channel(&state, &tenant, &owner, &[]).await; + for channel_id in [first, second] { + edit_channel( + &state, + &tenant, + &owner, + channel_id, + &[&["auto_join", "true"]], + ) + .await + .expect("relay owner flags channel"); + } + + let v2_code = mint_code( + state.clone(), + &host, + &owner, + serde_json::json!({ "max_uses": 1 }), + ) + .await; + let v1_code = forge_v1_invite_code(&state, community, unix_now() + 3600); + let pool = state.db.pool().clone(); + for code in [v2_code, v1_code] { + let joiner = Keys::generate(); + let joiner_hex = joiner.public_key().to_hex(); + let mut hold = pool.begin().await.expect("begin hold"); + let hold_pid: i32 = sqlx::query_scalar("SELECT pg_backend_pid()") + .fetch_one(&mut *hold) + .await + .expect("hold backend pid"); + sqlx::query("SELECT 1 FROM channels WHERE community_id = $1 AND id = $2 FOR UPDATE") + .bind(community.as_uuid()) + .bind(first) + .execute(&mut *hold) + .await + .expect("hold the first default channel's row"); + + let claim = tokio::spawn({ + let (state, host, joiner) = (state.clone(), host.clone(), joiner.clone()); + let body = serde_json::json!({ "code": code }).to_string(); + async move { post_json(state, &host, "/api/invites/claim", &joiner, body).await } + }); + wait_for_put_member_behind(&pool, hold_pid, &claim).await; + claim.abort(); + assert!( + claim + .await + .expect_err("the claim was still waiting") + .is_cancelled(), + "the claim request was dropped" + ); + hold.rollback().await.expect("release the held row"); + + let deadline = std::time::Instant::now() + Duration::from_secs(10); + while added_notification_count(&state, community, &joiner_hex).await < 2 { + assert!( + std::time::Instant::now() < deadline, + "the auto-join stopped with the dropped claim: {:?}", + channel_roles(&state, community, &[first, second], &joiner).await + ); + tokio::time::sleep(Duration::from_millis(10)).await; + } + assert_eq!( + channel_roles(&state, community, &[first, second], &joiner).await, + [Some("member".to_string()), Some("member".to_string())] + ); + } + } + #[tokio::test] #[ignore = "requires Postgres"] async fn owner_mints_and_new_pubkey_claims() { @@ -1618,24 +2152,27 @@ mod postgres_tests { assert_eq!(response.status(), StatusCode::FORBIDDEN); } - /// Forge an already-expired invite payload signed with the relay's derived - /// invite key. `mint_invite` clamps ttl to 60s minimum, so the only way to - /// produce an expired code is to build the payload by hand at the token - /// layer. - fn forge_expired_invite_code( - state: &AppState, - community: buzz_core::CommunityId, - seconds_ago: u64, - ) -> String { - let key = derive_invite_key(&state.relay_keypair); - let now = std::time::SystemTime::now() + fn unix_now() -> u64 { + std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .expect("time") - .as_secs(); + .as_secs() + } + + /// Forge a v1 HMAC invite payload expiring at `expires_at`, signed with + /// the relay's derived invite key. `mint_invite` only mints v2 codes now + /// and clamps ttl to 60s minimum, so v1 and expired codes are built by + /// hand at the token layer. + fn forge_v1_invite_code( + state: &AppState, + community: buzz_core::CommunityId, + expires_at: u64, + ) -> String { + let key = derive_invite_key(&state.relay_keypair); let payload = InvitePayload { c: community.as_uuid().to_string(), r: "member".to_string(), - e: now.saturating_sub(seconds_ago), + e: expires_at, n: "test-nonce".to_string(), }; let payload_bytes = serde_json::to_vec(&payload).expect("payload serializes"); @@ -1667,7 +2204,7 @@ mod postgres_tests { .await .expect("lookup") .expect("community exists"); - let code = forge_expired_invite_code(&state, community.id, 10); + let code = forge_v1_invite_code(&state, community.id, unix_now() - 10); let body = serde_json::json!({ "code": code }).to_string(); let response = post_json(state.clone(), &host, "/api/invites/claim", &joiner, body).await; diff --git a/crates/buzz-relay/src/handlers/auth.rs b/crates/buzz-relay/src/handlers/auth.rs index dd1111ee63..b2bd26efe7 100644 --- a/crates/buzz-relay/src/handlers/auth.rs +++ b/crates/buzz-relay/src/handlers/auth.rs @@ -750,14 +750,20 @@ pub async fn handle_auth(event: nostr::Event, conn: Arc, state: .as_ref() .map(crate::nip_fi_core::NipFiSession::from_assertion), ); - match admitted_owner(&state, conn.tenant.community(), pubkey, nip_oa_owner).await { - Ok(Some(owner)) => state.conn_manager.set_admitted_owner(conn_id, owner), - Ok(None) => {} - Err(denial) => { - deny_admission(&conn, &event_id_hex, denial); - return; - } - } + // An admitted owner (NIP-OA, a NIP-FI agent token, or a stored + // `agent_owner_pubkey`) makes this socket an agent's. + let admitted_agent = + match admitted_owner(&state, conn.tenant.community(), pubkey, nip_oa_owner).await { + Ok(Some(owner)) => { + state.conn_manager.set_admitted_owner(conn_id, owner); + true + } + Ok(None) => false, + Err(denial) => { + deny_admission(&conn, &event_id_hex, denial); + return; + } + }; let denial = match final_admission_denial( &state, conn.tenant.community(), @@ -830,6 +836,31 @@ pub async fn handle_auth(event: nostr::Event, conn: Arc, state: &conn.tenant, conn.nip_fi_assertion.as_ref(), ); + // A community without a membership roster has no roster insert to + // join default channels at, so a person is joined on admission to + // the ones they have never been in, under this socket's effect + // permits. Agents are not. + let auto_join = !state.config.require_relay_membership && !admitted_agent; + if auto_join { + let state = Arc::clone(&state); + let tenant = conn.tenant.clone(); + let gate = Arc::clone(&conn.nip_fi_gate); + tokio::spawn(async move { + crate::handlers::side_effects::auto_join_never_joined_channels( + &tenant, + &state, + &pubkey.to_bytes(), + &gate, + ) + .await; + }); + } + // A test waits on every admission's auto-join outcome, so a skip + // reports here as the spawned task does when it finishes. + #[cfg(test)] + if !auto_join { + crate::nip_fi_test_hooks::after_auto_join(conn.tenant.community()).await; + } // _auth_permit drops here — expiry's write guard may proceed. } Err(e) => { @@ -2346,6 +2377,211 @@ mod tests { assert!(!conns[2].cancel.is_cancelled(), "the bystander stays"); } + /// Flags `names` as default channels in `community`, oldest first. + async fn default_channels( + state: &crate::state::AppState, + community: buzz_core::tenant::CommunityId, + owner: &Keys, + names: &[&str], + ) -> Vec { + use buzz_db::channel::{ChannelType, ChannelUpdate, ChannelVisibility}; + state + .db + .ensure_user_for_authorization(community, owner.public_key().as_bytes()) + .await + .expect("seed owner"); + let mut channels = Vec::new(); + for name in names { + let channel = state + .db + .create_channel( + community, + name, + ChannelType::Stream, + ChannelVisibility::Open, + None, + &owner.public_key().to_bytes(), + None, + ) + .await + .expect("create channel") + .id; + state + .db + .update_channel( + community, + channel, + ChannelUpdate { + auto_join: Some(true), + ..Default::default() + }, + ) + .await + .expect("flag channel"); + channels.push(channel); + } + channels + } + + /// Admits `keys` on a fresh root socket and waits for the admission's + /// auto-join outcome: the spawned task finishing, or the skip. + /// Panics if neither arrives. Returns the socket. + async fn admit_and_settle( + state: &std::sync::Arc, + community: buzz_core::tenant::CommunityId, + keys: &Keys, + challenge: &str, + tag: Option>, + ) -> std::sync::Arc { + use crate::nip_fi_test_hooks::auto_join_done_hook; + let (done, release) = auto_join_done_hook::arm(community); + let (conn, _ctrl) = registered_pending_conn(state, community, challenge); + // A skip reports inside `handle_auth`, so wait alongside it. + tokio::join!( + handle_auth( + signed_auth(keys, challenge, tag), + std::sync::Arc::clone(&conn), + std::sync::Arc::clone(state), + ), + async { + tokio::time::timeout(std::time::Duration::from_secs(10), done) + .await + .unwrap_or_else(|_| panic!("{challenge}: no auto-join outcome")) + .expect("auto-join hook dropped"); + release.notify_one(); + }, + ); + assert!( + matches!(conn.auth_state_snapshot(), AuthState::Authenticated(_)), + "{challenge} is admitted" + ); + conn + } + + /// On a community without a membership roster, a person's admission + /// joins them to the default channels they have never been in. An + /// agent's (NIP-OA) admission runs no join, and a person who left is + /// not joined back when they are admitted again. Each check waits for + /// that admission's own auto-join outcome (skip or finished task). + /// + /// Mutations: delete the `auto_join_never_joined_channels` spawn → + /// no outcome arrives (RED); drop `!admitted_agent` → the agent is + /// joined (RED); drop the row check in `put_first_member` → the + /// leave is undone (RED). + #[tokio::test] + #[ignore = "requires Postgres — runs in postgres-ci nextest lane"] + async fn root_auth_joins_people_to_default_channels_without_a_roster() { + let state = auth_test_state_real_db_expect().await; + let community = seeded_community(&state).await; + let (owner, agent, person) = (Keys::generate(), Keys::generate(), Keys::generate()); + let channel = default_channels(&state, community, &owner, &["general"]).await[0]; + let role = |keys: &Keys| { + let state = std::sync::Arc::clone(&state); + let pubkey = keys.public_key().to_bytes(); + async move { + state + .db + .get_member_role(community, channel, &pubkey) + .await + .expect("member role") + } + }; + let auth_tag = buzz_sdk::nip_oa::compute_auth_tag(&owner, &agent.public_key(), "") + .expect("sign NIP-OA credential"); + let auth_tag: Vec = serde_json::from_str(&auth_tag).expect("tag JSON"); + + admit_and_settle(&state, community, &agent, "auto-join-agent", Some(auth_tag)).await; + assert_eq!(role(&agent).await, None, "an agent is not joined"); + + admit_and_settle(&state, community, &person, "auto-join-person", None).await; + assert_eq!(role(&person).await.as_deref(), Some("member")); + + let person_key = person.public_key().to_bytes(); + state + .db + .remove_member(community, channel, &person_key, &person_key) + .await + .expect("leave"); + admit_and_settle(&state, community, &person, "auto-join-again", None).await; + assert_eq!(role(&person).await, None, "a leave stands"); + } + + /// The admission auto-join holds the socket's effect permit for each + /// channel, so a session that ends before a channel's write joins + /// nothing more: cancelled before the first channel, nothing; between + /// the two channels, only the first. The next admission then joins + /// the channel that was skipped. + /// + /// Mutation: drop the per-channel `acquire_effect` → the cancelled + /// session is joined anyway (RED). + #[tokio::test] + #[ignore = "requires Postgres — runs in postgres-ci nextest lane"] + async fn admission_auto_join_stops_when_the_session_ends() { + use crate::nip_fi_test_hooks::{auto_join_channel_hook, auto_join_done_hook}; + let state = auth_test_state_real_db_expect().await; + let community = seeded_community(&state).await; + let owner = Keys::generate(); + let channels = + default_channels(&state, community, &owner, &["general", "announcements"]).await; + let roles = |keys: &Keys| { + let state = std::sync::Arc::clone(&state); + let pubkey = keys.public_key().to_bytes(); + let channels = channels.clone(); + async move { + let mut roles = Vec::new(); + for channel in channels { + roles.push( + state + .db + .get_member_role(community, channel, &pubkey) + .await + .expect("member role"), + ); + } + roles + } + }; + let member = Some("member".to_string()); + + // `stop_before` channels pass, then the socket is cancelled. + for (stop_before, expected) in [(0, [None, None]), (1, [member.clone(), None])] { + let person = Keys::generate(); + let (done, done_release) = auto_join_done_hook::arm(community); + let (mut reached, mut release) = auto_join_channel_hook::arm(community); + let (conn, _ctrl) = registered_pending_conn(&state, community, "auto-join-stop"); + handle_auth( + signed_auth(&person, "auto-join-stop", None), + std::sync::Arc::clone(&conn), + std::sync::Arc::clone(&state), + ) + .await; + assert!(matches!( + conn.auth_state_snapshot(), + AuthState::Authenticated(_) + )); + for _ in 0..stop_before { + reached.await.expect("task reaches a channel"); + // Re-arm before the task can run again: this task does + // not yield between the release and the arm. + release.notify_one(); + (reached, release) = auto_join_channel_hook::arm(community); + } + reached.await.expect("task reaches the stop channel"); + conn.cancel.cancel(); + release.notify_one(); + done.await.expect("task finishes"); + done_release.notify_one(); + assert_eq!(roles(&person).await, expected, "stop before {stop_before}"); + + admit_and_settle(&state, community, &person, "auto-join-resume", None).await; + assert_eq!( + roles(&person).await, + [member.clone(), member.clone()], + "the next admission joins what was skipped" + ); + } + } + /// AUTH with NIP-OA records a previously ownerless agent's owner. That /// closes the agent's earlier ownerless socket so it reconnects with /// the owner attached, but not the socket being admitted, even when diff --git a/crates/buzz-relay/src/handlers/edit_metadata_postgres_tests.rs b/crates/buzz-relay/src/handlers/edit_metadata_postgres_tests.rs index 806e740b9a..54283d5f86 100644 --- a/crates/buzz-relay/src/handlers/edit_metadata_postgres_tests.rs +++ b/crates/buzz-relay/src/handlers/edit_metadata_postgres_tests.rs @@ -6,7 +6,7 @@ use nostr::{EventBuilder, Keys, Kind, Tag}; use uuid::Uuid; use crate::handlers::group_state_postgres_tests::{ - community, create_channel, edit_channel, http_auth, state, + community, create_channel, edit_channel, group_state_tags, http_auth, state, }; use crate::handlers::ingest::ingest_event; @@ -350,6 +350,281 @@ async fn join_role_edit_is_applied_and_announced() { assert!(announced, "one join_role_changed system message"); } +/// `auto_join` is a community setting: only a relay owner or admin may set +/// it, on an open, unarchived stream channel, and kind:39000 advertises it +/// only while it is effective. Mutations: drop the relay-role tier → the +/// channel owner's edit is accepted (RED); drop the in-transaction guard → +/// `true` on a private or archived channel is accepted (RED); guard only +/// `true` again → the relay admin's `false` is stored in the private +/// channel (RED); mirror the column instead of the effective state in +/// `group_metadata_tags` → the tag survives the flip to private (RED). +#[tokio::test] +#[ignore = "requires Postgres"] +async fn auto_join_is_relay_admin_only_and_advertised_while_effective() { + let state = state().await; + let tenant = community(&state, "edit-auto-join").await; + let owner = Keys::generate(); + let channel_id = create_channel(&state, &tenant, &owner, &[]).await; + let notices_before = count_kind(&state, &tenant, channel_id, 40099).await; + let advertised = |tags: Vec>| tags.contains(&vec!["auto_join".to_string()]); + + // The channel owner is not a relay owner or admin: refused, nothing changes. + let refused = edit_channel( + &state, + &tenant, + &owner, + channel_id, + &[&["auto_join", "true"]], + ) + .await + .expect_err("channel owner must not set auto_join"); + assert!( + refused.contains("relay owner or admin"), + "channel owner refusal: {refused}" + ); + assert!( + !state + .db + .get_channel(tenant.community(), channel_id) + .await + .expect("channel") + .auto_join + ); + assert!(!advertised( + group_state_tags(&state, &tenant, channel_id, 39000).await + )); + + // A relay admin who is not a channel member sets it. One transaction + // stores the event, the column and one `auto_join_changed` notice, and + // the republished kind:39000 advertises it. + let admin = Keys::generate(); + state + .db + .add_relay_member( + tenant.community(), + &admin.public_key().to_hex(), + "admin", + None, + &Keys::generate(), + ) + .await + .map(|write| write.outcome) + .expect("seed relay admin"); + edit_channel( + &state, + &tenant, + &admin, + channel_id, + &[&["auto_join", "true"]], + ) + .await + .expect("relay admin sets auto_join"); + assert!( + state + .db + .get_channel(tenant.community(), channel_id) + .await + .expect("channel") + .auto_join + ); + assert!(advertised( + group_state_tags(&state, &tenant, channel_id, 39000).await + )); + let notices = state + .db + .query_events(&EventQuery { + kinds: Some(vec![40099]), + channel_id: Some(channel_id), + ..EventQuery::for_community(tenant.community()) + }) + .await + .expect("query events"); + assert_eq!(notices.len(), notices_before + 1); + assert!( + notices + .iter() + .filter_map( + |stored| serde_json::from_str::(&stored.event.content).ok() + ) + .any(|content| content["type"] == "auto_join_changed" && content["auto_join"] == true), + "one auto_join_changed system message" + ); + let listed = state + .db + .list_auto_join_channels(tenant.community()) + .await + .expect("list auto_join channels"); + assert_eq!( + listed.iter().map(|c| c.id).collect::>(), + [channel_id] + ); + + // Private: the stored value stays, but it is no longer effective, so the + // tag disappears and the relay would not join anyone. + edit_channel( + &state, + &tenant, + &owner, + channel_id, + &[&["visibility", "private"]], + ) + .await + .expect("make private"); + assert!( + state + .db + .get_channel(tenant.community(), channel_id) + .await + .expect("channel") + .auto_join + ); + assert!(!advertised( + group_state_tags(&state, &tenant, channel_id, 39000).await + )); + assert!(state + .db + .list_auto_join_channels(tenant.community()) + .await + .expect("list auto_join channels") + .is_empty()); + + // Both values are refused on a private channel, the same way, and + // nothing of either event is stored: the relay admin is not a member, + // so storing even a `false` would write into a channel they cannot + // read. Opening and flagging in one event is accepted: the guard sees + // the event's result. `visibility` still needs the channel owner, so + // that event comes from the owner once they are also on the relay + // roster: both tiers run. The nonce keeps the `true` from signing the + // same event id as the accepted edit above, which the relay would drop + // as a duplicate. + let (events_before, notices_before) = ( + count_kind(&state, &tenant, channel_id, 9002).await, + count_kind(&state, &tenant, channel_id, 40099).await, + ); + let refused_edits: [&[&[&str]]; 2] = [ + &[&["auto_join", "true"], &["nonce", "private"]], + &[&["auto_join", "false"]], + ]; + for tags in refused_edits { + let refused = edit_channel(&state, &tenant, &admin, channel_id, tags) + .await + .expect_err("auto_join on a private channel"); + assert!( + refused.contains("open, unarchived"), + "private refusal of {tags:?}: {refused}" + ); + } + assert_eq!( + count_kind(&state, &tenant, channel_id, 9002).await, + events_before, + "a refused auto_join event is not stored" + ); + assert_eq!( + count_kind(&state, &tenant, channel_id, 40099).await, + notices_before, + "a refused auto_join event announces nothing" + ); + assert!( + state + .db + .get_channel(tenant.community(), channel_id) + .await + .expect("channel") + .auto_join, + "the refused `false` leaves the stored value in place" + ); + state + .db + .add_relay_member( + tenant.community(), + &owner.public_key().to_hex(), + "owner", + None, + &Keys::generate(), + ) + .await + .map(|write| write.outcome) + .expect("seed relay owner"); + edit_channel( + &state, + &tenant, + &owner, + channel_id, + &[&["visibility", "open"], &["auto_join", "true"]], + ) + .await + .expect("open and flag in one event"); + assert!(advertised( + group_state_tags(&state, &tenant, channel_id, 39000).await + )); + + // Archived and forum channels cannot be default channels. Archiving and + // flagging in one event is refused by the guard on the event's result, + // and nothing of that event applies; on a channel that is already + // archived the archive fence refuses the edit first. + let archived_id = create_channel(&state, &tenant, &owner, &[]).await; + let refused = edit_channel( + &state, + &tenant, + &owner, + archived_id, + &[&["archived", "true"], &["auto_join", "true"]], + ) + .await + .expect_err("archive and flag in one event"); + assert!( + refused.contains("open, unarchived"), + "archive-and-flag refusal: {refused}" + ); + assert!( + state + .db + .get_channel(tenant.community(), archived_id) + .await + .expect("channel") + .archived_at + .is_none(), + "a refused event archives nothing" + ); + edit_channel( + &state, + &tenant, + &owner, + archived_id, + &[&["archived", "true"]], + ) + .await + .expect("archive"); + let refused = edit_channel( + &state, + &tenant, + &admin, + archived_id, + &[&["auto_join", "true"]], + ) + .await + .expect_err("auto_join=true on an archived channel"); + assert!(refused.contains("archived"), "archived refusal: {refused}"); + let forum_id = create_channel(&state, &tenant, &owner, &[&["channel_type", "forum"]]).await; + let refused = edit_channel(&state, &tenant, &admin, forum_id, &[&["auto_join", "true"]]) + .await + .expect_err("auto_join=true on a forum"); + assert!( + refused.contains("stream channels"), + "forum refusal: {refused}" + ); + for id in [archived_id, forum_id] { + assert!( + !state + .db + .get_channel(tenant.community(), id) + .await + .expect("channel") + .auto_join + ); + } +} + /// Sign a kind:9002 that sets `archived` to `value` on `channel_id`. fn archive_event(owner: &Keys, channel_id: Uuid, value: &str) -> nostr::Event { EventBuilder::new(Kind::Custom(9002), "") @@ -361,6 +636,55 @@ fn archive_event(owner: &Keys, channel_id: Uuid, value: &str) -> nostr::Event { .expect("sign 9002") } +/// The loader returns every effective default channel. It used to stop at 32 +/// while kind:9002 kept accepting the flag past that, so the kind:39000 tag +/// promised a join the relay never made for the newest channels. Mutation: +/// put a `LIMIT` back on `list_auto_join_channels` → RED. +#[tokio::test] +#[ignore = "requires Postgres"] +async fn every_default_channel_is_listed() { + let state = state().await; + let tenant = community(&state, "auto-join-all").await; + let admin = Keys::generate(); + state + .db + .add_relay_member( + tenant.community(), + &admin.public_key().to_hex(), + "admin", + None, + &Keys::generate(), + ) + .await + .map(|write| write.outcome) + .expect("seed relay admin"); + + let mut flagged = Vec::new(); + for _ in 0..33 { + let channel_id = create_channel(&state, &tenant, &admin, &[]).await; + edit_channel( + &state, + &tenant, + &admin, + channel_id, + &[&["auto_join", "true"]], + ) + .await + .expect("relay admin sets auto_join"); + flagged.push(channel_id); + } + + let listed: Vec = state + .db + .list_auto_join_channels(tenant.community()) + .await + .expect("list auto_join channels") + .iter() + .map(|channel| channel.id) + .collect(); + assert_eq!(listed, flagged, "every flagged channel, oldest first"); +} + /// A resend checked after its first copy committed is a duplicate. /// Validation runs on the new archive state and must not refuse the resend; /// the write must then report it as a duplicate, with no change and no diff --git a/crates/buzz-relay/src/handlers/ingest.rs b/crates/buzz-relay/src/handlers/ingest.rs index 43698154b0..70ebc75a9d 100644 --- a/crates/buzz-relay/src/handlers/ingest.rs +++ b/crates/buzz-relay/src/handlers/ingest.rs @@ -612,12 +612,13 @@ fn required_scope_for_kind(kind: u32, event: &Event) -> Result Ok(Scope::UsersWrite), KIND_NIP29_EDIT_METADATA => { - // kind:9002 scope split: archived tag → AdminChannels, else ChannelsWrite - let has_archived = event - .tags - .iter() - .any(|t| t.kind().to_string() == "archived"); - if has_archived { + // kind:9002 scope split: archived or auto_join tag → AdminChannels, + // else ChannelsWrite + let has_admin_tag = event.tags.iter().any(|t| { + let k = t.kind().to_string(); + k == "archived" || k == "auto_join" + }); + if has_admin_tag { Ok(Scope::AdminChannels) } else { Ok(Scope::ChannelsWrite) diff --git a/crates/buzz-relay/src/handlers/relay_admin.rs b/crates/buzz-relay/src/handlers/relay_admin.rs index 16648595f8..83c9138e1f 100644 --- a/crates/buzz-relay/src/handlers/relay_admin.rs +++ b/crates/buzz-relay/src/handlers/relay_admin.rs @@ -25,7 +25,8 @@ use buzz_core::tenant::TenantContext; use buzz_db::relay_members::RemoveResult; use crate::handlers::side_effects::{ - fan_out_nip43_membership_list, publish_nip43_member_added, publish_nip43_member_removed, + auto_join_default_channels, fan_out_nip43_membership_list, publish_nip43_member_added, + publish_nip43_member_removed, }; use crate::state::AppState; @@ -362,6 +363,15 @@ async fn execute_relay_admin_command( if let Err(e) = publish_nip43_member_added(tenant, state, &target_hex).await { warn!(error = %e, "failed to publish NIP-43 member added event"); } + // The sender adds someone else, so the target's + // `channel_add_policy` decides whether they are auto-joined. + auto_join_default_channels( + tenant, + state, + &target_hex, + Some(&event.pubkey.to_bytes()), + ) + .await; } fan_out_nip43_membership_list(tenant, state, write.snapshot).await; } @@ -988,6 +998,163 @@ mod postgres_tests { assert!(!bystander_socket.cancellation_token().is_cancelled()); } + /// A kind:9030 add joins the new relay member to the community's + /// effective default channels, once: the flagged open channel gains them + /// as a member with one kind:44100; the unflagged channel does not. A + /// kind:9022 leave followed by a repeat kind:9030 (an idempotent no-op on + /// the roster) does not re-add them. An added agent's `channel_add_policy` + /// holds against the adder. Mutations: delete the + /// `auto_join_default_channels` call → RED; pass no actor, or drop the + /// policy check → the `nobody` agent is joined (RED); check the policy + /// against anyone but the adder → the owned `owner_only` agent is not + /// (RED). + #[tokio::test] + #[ignore = "requires Postgres"] + async fn adding_a_member_auto_joins_default_channels() { + use crate::handlers::group_state_postgres_tests::{ + create_channel, edit_channel, http_auth, + }; + use crate::handlers::ingest::ingest_event; + + let host = format!("add-auto-join-{}.example", uuid::Uuid::new_v4().simple()); + let (state, tenant) = workspace_profile_test_state(&host, true).await; + let (owner, target) = (Keys::generate(), Keys::generate()); + state + .db + .add_relay_member( + tenant.community(), + &owner.public_key().to_hex(), + "owner", + None, + &nostr::Keys::generate(), + ) + .await + .map(|write| write.outcome) + .expect("seed owner"); + let flagged = create_channel(&state, &tenant, &owner, &[]).await; + let control = create_channel(&state, &tenant, &owner, &[]).await; + edit_channel(&state, &tenant, &owner, flagged, &[&["auto_join", "true"]]) + .await + .expect("relay owner flags channel"); + + let roles = |keys: &Keys| { + let state = state.clone(); + let community = tenant.community(); + let pubkey = keys.public_key().to_bytes(); + async move { + let mut roles = Vec::new(); + for channel_id in [flagged, control] { + roles.push( + state + .db + .get_member_role(community, channel_id, &pubkey) + .await + .expect("member role"), + ); + } + roles + } + }; + let added_notifications = || { + let state = state.clone(); + let community = tenant.community(); + let target_hex = target.public_key().to_hex(); + async move { + state + .db + .count_events(&buzz_db::EventQuery { + kinds: Some(vec![buzz_core::kind::KIND_MEMBER_ADDED_NOTIFICATION as i32]), + p_tag_hex: Some(target_hex), + global_only: true, + ..buzz_db::EventQuery::for_community(community) + }) + .await + .expect("count 44100") + } + }; + let add = || { + EventBuilder::new(Kind::Custom(RELAY_ADMIN_ADD_MEMBER as u16), "") + .tags(vec![Tag::public_key(target.public_key())]) + .sign_with_keys(&owner) + .expect("sign 9030") + }; + + handle_relay_admin_event(&tenant, &state, &add()) + .await + .expect("owner adds member"); + assert_eq!(roles(&target).await, [Some("member".to_string()), None]); + assert_eq!(added_notifications().await, 1); + + let leave = EventBuilder::new(Kind::Custom(9022), "") + .tags([Tag::parse(["h", &flagged.to_string()]).expect("h tag")]) + .sign_with_keys(&target) + .expect("sign 9022"); + let left = ingest_event(&state, &tenant, leave, http_auth(&target)) + .await + .expect("ingest 9022"); + assert!(left.accepted, "{:?}", left.message); + handle_relay_admin_event(&tenant, &state, &add()) + .await + .expect("repeat add is a roster no-op"); + assert_eq!( + roles(&target).await, + [None, None], + "a repeat kind:9030 must not resurrect a kind:9022 leave" + ); + assert_eq!(added_notifications().await, 1); + + // The adder acts for someone else, so the added agent's + // `channel_add_policy` decides, as for the same actor's kind:9000: + // `nobody` refuses every default channel; `owner_only` admits only + // the agent's owner. + let (refusing, owned) = (Keys::generate(), Keys::generate()); + for keys in [&owner, &refusing, &owned] { + state + .db + .ensure_user(tenant.community(), &keys.public_key().to_bytes()) + .await + .expect("user row"); + } + for (agent, policy) in [(&refusing, "nobody"), (&owned, "owner_only")] { + state + .db + .set_channel_add_policy(tenant.community(), &agent.public_key().to_bytes(), policy) + .await + .expect("agent policy"); + } + state + .db + .set_agent_owner( + tenant.community(), + &owned.public_key().to_bytes(), + &owner.public_key().to_bytes(), + ) + .await + .expect("agent owner"); + for (agent, expected) in [ + (&refusing, [None, None]), + (&owned, [Some("member".to_string()), None]), + ] { + let add = EventBuilder::new(Kind::Custom(RELAY_ADMIN_ADD_MEMBER as u16), "") + .tags(vec![Tag::public_key(agent.public_key())]) + .sign_with_keys(&owner) + .expect("sign 9030"); + handle_relay_admin_event(&tenant, &state, &add) + .await + .expect("owner adds agent"); + assert!( + state + .db + .get_relay_member(tenant.community(), &agent.public_key().to_hex()) + .await + .expect("relay member") + .is_some(), + "the roster add stands either way" + ); + assert_eq!(roles(agent).await, expected); + } + } + /// A pod-local root socket registered and bound to `keys`, as root AUTH /// leaves it. fn bound_root_socket( diff --git a/crates/buzz-relay/src/handlers/side_effects.rs b/crates/buzz-relay/src/handlers/side_effects.rs index 4a59c648aa..8f2977bb04 100644 --- a/crates/buzz-relay/src/handlers/side_effects.rs +++ b/crates/buzz-relay/src/handlers/side_effects.rs @@ -665,6 +665,7 @@ pub async fn validate_admin_event( "t", "join_role", "unlisted", + "auto_join", ]; let has_recognized = event .tags @@ -672,7 +673,7 @@ pub async fn validate_admin_event( .any(|t| RECOGNIZED_TAGS.contains(&t.kind().to_string().as_str())); if !has_recognized { return Err(anyhow::anyhow!( - "kind:9002 must include at least one metadata tag (name, about, archived, topic, purpose, visibility, ttl, t, join_role, unlisted)" + "kind:9002 must include at least one metadata tag (name, about, archived, topic, purpose, visibility, ttl, t, join_role, unlisted, auto_join)" )); } @@ -747,6 +748,48 @@ pub async fn validate_admin_event( } } + // `auto_join` is a community setting, not a channel one: the + // relay joins every new community member into the channel. So it + // takes the actor's *relay* role (owner or admin), not a channel + // role, and the actor need not be a channel member. Only a + // stream channel can be a default channel; the open-and- + // unarchived requirement (just open, for `false`) is decided in + // the write, under the channel row lock, on the state the event + // leaves behind. + let mut sets_auto_join = false; + for t in event.tags.iter() { + if t.kind().to_string() == "auto_join" { + match t.content() { + Some("true") | Some("false") => sets_auto_join = true, + Some(v) => { + return Err(anyhow::anyhow!( + "invalid auto_join value: {v} (must be \"true\" or \"false\")" + )); + } + None => { + return Err(anyhow::anyhow!("auto_join tag must have a value")); + } + } + } + } + if sets_auto_join { + let relay_role = state + .db + .get_relay_member(tenant.community(), &event.pubkey.to_hex()) + .await? + .map(|m| m.role); + if !matches!(relay_role.as_deref(), Some("owner" | "admin")) { + return Err(anyhow::anyhow!( + "actor not authorized: auto_join requires a relay owner or admin" + )); + } + if channel.channel_type != buzz_db::channel::ChannelType::Stream.as_str() { + return Err(anyhow::anyhow!( + "auto_join is only available on stream channels" + )); + } + } + // Validate ttl values before storage. Empty string clears the TTL // (channel becomes permanent); any other value must parse as a // positive integer number of seconds. A bare tag with no value is @@ -778,8 +821,13 @@ pub async fn validate_admin_event( } // name/about/archived/visibility/ttl/labels/join_role/unlisted - // require owner/admin; - // topic/purpose allow any member who may post. + // require owner/admin; topic/purpose allow any member who may + // post; auto_join was decided above by relay role, so an event + // carrying only that tag needs neither. + let has_member_tag = event.tags.iter().any(|t| { + let k = t.kind().to_string(); + k == "topic" || k == "purpose" + }); let has_privileged_tag = event.tags.iter().any(|t| { let k = t.kind().to_string(); k == "name" @@ -815,7 +863,7 @@ pub async fn validate_admin_event( )) } } - } else { + } else if has_member_tag { // topic/purpose: any member whose role may post (never a // guest). let is_member = state @@ -842,6 +890,15 @@ pub async fn validate_admin_event( } else { Err(anyhow::anyhow!("only members can change topic or purpose")) } + } else if sets_auto_join { + Ok(()) + } else { + // Every recognized tag must be authorized by one of the arms + // above; a tag added to `RECOGNIZED_TAGS` but to neither list + // is refused rather than accepted with no authorization. + Err(anyhow::anyhow!( + "kind:9002 metadata tag has no authorization rule" + )) } } 9005 => { @@ -2074,6 +2131,15 @@ pub(crate) async fn persist_edit_metadata( val.parse().map_err(|e: String| anyhow::anyhow!(e))?; edit.columns.join_role = Some(join_role.as_str().to_string()); } + "auto_join" => { + // Validated during authorization; fail closed on a resend + // that somehow carries another value. + edit.columns.auto_join = Some(match val { + "true" => true, + "false" => false, + _ => return Err(anyhow::anyhow!("invalid auto_join value: {val}")), + }); + } "ttl" => { // Empty string clears the TTL (permanent); otherwise it is a // positive integer of seconds, validated during authorization. @@ -2136,6 +2202,11 @@ pub(crate) async fn persist_edit_metadata( "type": "unlisted_changed", "actor": actor_hex, "unlisted": unlisted })); } + if let Some(auto_join) = edit.columns.auto_join { + notices.push(serde_json::json!({ + "type": "auto_join_changed", "actor": actor_hex, "auto_join": auto_join + })); + } if let Some(ttl_seconds) = edit.columns.ttl_seconds { notices.push(serde_json::json!({ "type": "ttl_changed", "actor": actor_hex, "ttl_seconds": ttl_seconds @@ -2639,15 +2710,25 @@ async fn handle_delete_group( Ok(()) } -async fn handle_join_request( - tenant: &TenantContext, - event: &Event, - state: &Arc, -) -> anyhow::Result<()> { - let channel_id = - extract_h_tag_channel(event).ok_or_else(|| anyhow::anyhow!("missing h tag"))?; - let actor_bytes = event.pubkey.to_bytes().to_vec(); +/// What [`join_channel_as_self`] did for the member. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum SelfJoinOutcome { + /// The member was added and every join side effect was emitted. + Joined, + /// The member was already active in the channel; nothing changed. + AlreadyMember, +} +/// Join `pubkey` to an open, unarchived channel as a self-join: no inviter, +/// the channel's `join_role`, and the kind:40099, 39000/39002 and 44100 +/// side effects of a kind:9021. Shared by the kind:9021 handler and the +/// default-channel auto-join on new community memberships. +pub(crate) async fn join_channel_as_self( + tenant: &TenantContext, + state: &Arc, + channel_id: Uuid, + pubkey: &[u8], +) -> anyhow::Result { // Only open channels allow self-join via kind:9021. let channel = state .db @@ -2659,27 +2740,45 @@ async fn handle_join_request( "channel is private — request an invitation" )); } + if channel.archived_at.is_some() { + return Err(anyhow::anyhow!("channel is archived")); + } // Skip if already an active member — prevents duplicate join notifications. // Fail closed on DB errors rather than falling through to add_member. if state - .is_member_cached(tenant.community(), channel_id, &actor_bytes) + .is_member_cached(tenant.community(), channel_id, pubkey) .await? { - info!(channel = %channel_id, "kind:9021 join — already a member, skipping"); - return Ok(()); + return Ok(SelfJoinOutcome::AlreadyMember); } // A self-join gets the channel's `join_role`, read by `put_member` under // the membership lock: member in a normal channel, guest in an announce - // channel, where a member must then make them a member. + // channel, where a member must then make them a member. `put_member` + // also decides visibility and archive state again under its locks, so a + // channel made private or archived since the checks above is refused + // here, with no side effects. state .db - .put_member(tenant.community(), channel_id, &actor_bytes, None, None) + .put_member(tenant.community(), channel_id, pubkey, None, None) .await?; - state.invalidate_membership(tenant, channel_id, &actor_bytes); + announce_self_join(tenant, state, channel_id, pubkey).await?; + Ok(SelfJoinOutcome::Joined) +} - let actor_hex = hex::encode(&actor_bytes); +/// The side effects of a committed self-join: the membership cache, the +/// `member_joined` kind:40099, kind:39000/39002 and kind:44100. Only the +/// system message is fatal, as for kind:9021. +async fn announce_self_join( + tenant: &TenantContext, + state: &Arc, + channel_id: Uuid, + pubkey: &[u8], +) -> anyhow::Result<()> { + state.invalidate_membership(tenant, channel_id, pubkey); + + let actor_hex = hex::encode(pubkey); emit_system_message( tenant, state, @@ -2701,8 +2800,8 @@ async fn handle_join_request( tenant, state, channel_id, - &actor_bytes, - &actor_bytes, + pubkey, + pubkey, buzz_core::kind::KIND_MEMBER_ADDED_NOTIFICATION, ) .await @@ -2710,7 +2809,175 @@ async fn handle_join_request( warn!("membership notification for join failed: {e}"); } - info!(channel = %channel_id, "kind:9021 join processed"); + Ok(()) +} + +/// Join a new community member to every effective `auto_join` channel, as +/// kind:9021 self-joins. Runs inline at the roster write sites (invite +/// claim, kind:9030) and nowhere else, so a later kind:9022 leave stands. A +/// community without a roster joins on admission instead +/// ([`auto_join_never_joined_channels`]). +/// +/// Best effort: the relay membership has already committed, so a channel +/// that fails is logged and counted, never retried, and never fails the +/// caller. The `auto_join` tag on kind:39000 is the durable record a client +/// reconciles against. A channel archived or made private after the list +/// was read is one such failure: `put_member` refuses it under its locks. +/// +/// `added_by` is the actor of a third-party add (kind:9030), `None` when the +/// member joined by themselves (an invite claim). A third-party add first +/// asks the member's `channel_add_policy`, once, as a kind:9000 from the same +/// actor would; a refusal skips every default channel, logged and counted. +pub(crate) async fn auto_join_default_channels( + tenant: &TenantContext, + state: &Arc, + pubkey_hex: &str, + added_by: Option<&[u8]>, +) { + let Ok(pubkey) = hex::decode(pubkey_hex) else { + warn!(member = %pubkey_hex, "auto-join skipped: member pubkey is not hex"); + return; + }; + let channels = match state.db.list_auto_join_channels(tenant.community()).await { + Ok(channels) => channels, + Err(e) => { + warn!(member = %pubkey_hex, error = %e, "auto-join skipped: listing default channels failed"); + metrics::counter!("buzz_auto_join_failures_total").increment(1); + return; + } + }; + if channels.is_empty() { + return; + } + if let Some(actor) = added_by.filter(|actor| *actor != pubkey.as_slice()) { + match state + .db + .get_agent_channel_policy(tenant.community(), &pubkey) + .await + { + Ok(None) => {} + Ok(Some((policy, owner))) => { + if let Err(denied) = + channel_authz::decide_channel_add_policy(&policy, owner.as_deref(), actor) + { + warn!(member = %pubkey_hex, error = %denied, "auto-join skipped: the member's channel_add_policy refuses the actor"); + metrics::counter!("buzz_auto_join_failures_total").increment(1); + return; + } + } + Err(e) => { + warn!(member = %pubkey_hex, error = %e, "auto-join skipped: reading the member's channel_add_policy failed"); + metrics::counter!("buzz_auto_join_failures_total").increment(1); + return; + } + } + } + for channel in channels { + match join_channel_as_self(tenant, state, channel.id, &pubkey).await { + Ok(SelfJoinOutcome::Joined) => { + metrics::counter!("buzz_auto_join_channels_total").increment(1); + info!(channel = %channel.id, member = %pubkey_hex, "auto-joined default channel"); + } + Ok(SelfJoinOutcome::AlreadyMember) => {} + Err(e) => { + metrics::counter!("buzz_auto_join_failures_total").increment(1); + warn!(channel = %channel.id, member = %pubkey_hex, error = %e, "auto-join of default channel failed"); + } + } + } +} + +/// Join a person admitted to a community that keeps no membership roster +/// (`require_relay_membership = false`) to every effective `auto_join` +/// channel they have never been a member of, as kind:9021 self-joins. +/// +/// Runs after every human WebSocket admission there, because such a +/// community has no roster insert to run [`auto_join_default_channels`] at. +/// "Never a member" is the channel's own membership history: a kind:9022 +/// leave or a kind:9001 removal keeps a removed row, so the person is not +/// joined back, and a channel flagged later is joined on their next +/// admission. +/// +/// Each channel's write and announcement hold an effect permit from the +/// admitting socket's `gate`, as every other session effect does, so nothing +/// is written once the session has expired or the socket is cancelled; the +/// task stops at the first refusal. A channel it stops before, or whose +/// membership write fails, has no row, so the next admission tries it again. +/// Failures are logged and counted (`buzz_auto_join_failures_total`). +pub(crate) async fn auto_join_never_joined_channels( + tenant: &TenantContext, + state: &Arc, + pubkey: &[u8], + gate: &Arc, +) { + join_never_joined_channels(tenant, state, pubkey, gate).await; + #[cfg(test)] + crate::nip_fi_test_hooks::after_auto_join(tenant.community()).await; +} + +async fn join_never_joined_channels( + tenant: &TenantContext, + state: &Arc, + pubkey: &[u8], + gate: &Arc, +) { + let member = hex::encode(pubkey); + let channels = match state + .db + .list_never_joined_auto_join_channels(tenant.community(), pubkey) + .await + { + Ok(channels) => channels, + Err(e) => { + warn!(%member, error = %e, "auto-join skipped: listing default channels failed"); + metrics::counter!("buzz_auto_join_failures_total").increment(1); + return; + } + }; + for channel_id in channels { + #[cfg(test)] + crate::nip_fi_test_hooks::before_auto_join_channel(tenant.community()).await; + let Ok(_permit) = gate.acquire_effect().await else { + info!(channel = %channel_id, %member, "auto-join stopped: the session ended"); + return; + }; + let joined = match state + .db + .put_first_member(tenant.community(), channel_id, pubkey) + .await + { + Ok(None) => continue, + Ok(Some(_)) => announce_self_join(tenant, state, channel_id, pubkey).await, + Err(e) => Err(e.into()), + }; + match joined { + Ok(()) => { + metrics::counter!("buzz_auto_join_channels_total").increment(1); + info!(channel = %channel_id, %member, "auto-joined default channel on admission"); + } + Err(e) => { + metrics::counter!("buzz_auto_join_failures_total").increment(1); + warn!(channel = %channel_id, %member, error = %e, "auto-join of default channel on admission failed"); + } + } + } +} + +async fn handle_join_request( + tenant: &TenantContext, + event: &Event, + state: &Arc, +) -> anyhow::Result<()> { + let channel_id = + extract_h_tag_channel(event).ok_or_else(|| anyhow::anyhow!("missing h tag"))?; + match join_channel_as_self(tenant, state, channel_id, &event.pubkey.to_bytes()).await? { + SelfJoinOutcome::AlreadyMember => { + info!(channel = %channel_id, "kind:9021 join — already a member, skipping"); + } + SelfJoinOutcome::Joined => { + info!(channel = %channel_id, "kind:9021 join processed"); + } + } Ok(()) } diff --git a/crates/buzz-relay/src/nip_fi_test_hooks.rs b/crates/buzz-relay/src/nip_fi_test_hooks.rs index cfcce6155b..b37209b451 100644 --- a/crates/buzz-relay/src/nip_fi_test_hooks.rs +++ b/crates/buzz-relay/src/nip_fi_test_hooks.rs @@ -133,6 +133,12 @@ macro_rules! make_hook { (rx, release) } + /// Remove an armed barrier that production code never reached. + #[allow(dead_code)] + pub(crate) fn disarm(community: CommunityId) { + GATE.lock().unwrap().remove(&community); + } + pub(crate) async fn trigger(community: CommunityId) { let gate = GATE.lock().unwrap().remove(&community); if let Some(g) = gate { @@ -198,6 +204,13 @@ make_hook!(audio_membership_check_hook, before_membership_check); // immediately before the relay membership lookup. Closing the pool here makes // that lookup fail with the tenant already bound. make_hook!(git_membership_hook, before_git_membership); +// `before_auto_join_channel`: fires in the admission auto-join before each +// default channel's effect permit. Cancelling the socket here proves that +// channel is not joined. +make_hook!(auto_join_channel_hook, before_auto_join_channel); +// `after_auto_join`: fires when an admission auto-join task has finished, so +// a test can assert what it did (or did not) write. +make_hook!(auto_join_done_hook, after_auto_join); // `before_http_ban_recheck`: fires in `buzz_v1` and Git settings after the // shared membership check passes, immediately before the fresh ban reread. // A ban or closed pool here makes only the reread refuse. diff --git a/crates/buzz-sdk/src/builders.rs b/crates/buzz-sdk/src/builders.rs index 0791ebaa98..d0fbf02dec 100644 --- a/crates/buzz-sdk/src/builders.rs +++ b/crates/buzz-sdk/src/builders.rs @@ -824,12 +824,17 @@ pub fn build_leave(channel_id: Uuid) -> Result { } /// Build a NIP-29 edit-metadata event for -/// name/about/visibility/ttl/join_role/unlisted (kind 9002). +/// name/about/visibility/ttl/join_role/unlisted/auto_join (kind 9002). /// /// `ttl`: outer `None` leaves it unchanged; `Some(Some(secs))` sets the /// ephemeral timeout; `Some(None)` clears it (emits `["ttl", ""]`). /// `unlisted`: `None` leaves it unchanged; `Some(b)` emits /// `["unlisted", "true"|"false"]`. +/// +/// `auto_join`: whether the relay joins new community members into the +/// channel. Relay owner/admin only; accepted only on an open, unarchived +/// stream channel. +#[allow(clippy::too_many_arguments)] pub fn build_update_channel( channel_id: Uuid, name: Option<&str>, @@ -838,6 +843,7 @@ pub fn build_update_channel( ttl: Option>, join_role: Option, unlisted: Option, + auto_join: Option, ) -> Result { if name.is_none() && about.is_none() @@ -845,9 +851,10 @@ pub fn build_update_channel( && ttl.is_none() && join_role.is_none() && unlisted.is_none() + && auto_join.is_none() { return Err(SdkError::InvalidTag( - "at least one of name, about, visibility, ttl, join_role, or unlisted must be provided" + "at least one of name, about, visibility, ttl, join_role, unlisted, or auto_join must be provided" .into(), )); } @@ -891,6 +898,12 @@ pub fn build_update_channel( if let Some(unlisted) = unlisted { tags.push(tag(&["unlisted", if unlisted { "true" } else { "false" }])?); } + if let Some(auto_join) = auto_join { + tags.push(tag(&[ + "auto_join", + if auto_join { "true" } else { "false" }, + ])?); + } Ok(EventBuilder::new(Kind::Custom(9002), "").tags(tags)) } @@ -3668,6 +3681,7 @@ mod tests { None, None, None, + None, ) .unwrap(), ); @@ -3687,6 +3701,7 @@ mod tests { None, None, None, + None, ) .unwrap(), ); @@ -3696,7 +3711,7 @@ mod tests { #[test] fn update_channel_rejects_hash_only_name() { assert!(matches!( - build_update_channel(uuid(), Some(" ### "), None, None, None, None, None), + build_update_channel(uuid(), Some(" ### "), None, None, None, None, None, None), Err(SdkError::InvalidTag(_)) )); } @@ -3713,6 +3728,7 @@ mod tests { Some(Some(3600)), None, None, + None, ) .unwrap(), ); @@ -3724,7 +3740,9 @@ mod tests { #[test] fn update_channel_clears_ttl() { let cid = uuid(); - let ev = sign(build_update_channel(cid, None, None, None, Some(None), None, None).unwrap()); + let ev = sign( + build_update_channel(cid, None, None, None, Some(None), None, None, None).unwrap(), + ); assert!(has_tag(&ev, "ttl", "")); } @@ -3732,7 +3750,7 @@ mod tests { fn update_channel_invalid_visibility_rejected() { let cid = uuid(); assert!(matches!( - build_update_channel(cid, None, None, Some("secret"), None, None, None), + build_update_channel(cid, None, None, Some("secret"), None, None, None, None), Err(SdkError::InvalidTag(_)) )); } @@ -3750,6 +3768,7 @@ mod tests { None, Some(ChannelJoinRole::Guest), None, + None, ) .unwrap(), ); @@ -3763,12 +3782,13 @@ mod tests { None, Some(ChannelJoinRole::Member), None, + None, ) .unwrap(), ); assert!(has_tag(&off, "join_role", "member")); let other = - sign(build_update_channel(cid, Some("n"), None, None, None, None, None).unwrap()); + sign(build_update_channel(cid, Some("n"), None, None, None, None, None, None).unwrap()); assert!(!other .tags .iter() @@ -3779,26 +3799,47 @@ mod tests { fn update_channel_unlisted_is_always_explicit() { let cid = uuid(); let on = sign( - build_update_channel(cid, None, None, Some("open"), None, None, Some(true)).unwrap(), + build_update_channel(cid, None, None, Some("open"), None, None, Some(true), None) + .unwrap(), ); assert!(has_tag(&on, "visibility", "open")); assert!(has_tag(&on, "unlisted", "true")); - let off = - sign(build_update_channel(cid, None, None, None, None, None, Some(false)).unwrap()); + let off = sign( + build_update_channel(cid, None, None, None, None, None, Some(false), None).unwrap(), + ); assert!(has_tag(&off, "unlisted", "false")); let other = - sign(build_update_channel(cid, Some("n"), None, None, None, None, None).unwrap()); + sign(build_update_channel(cid, Some("n"), None, None, None, None, None, None).unwrap()); assert!(!other .tags .iter() .any(|t| t.kind().to_string() == "unlisted")); } + #[test] + fn update_channel_auto_join_is_always_explicit() { + let cid = uuid(); + let on = sign( + build_update_channel(cid, None, None, None, None, None, None, Some(true)).unwrap(), + ); + assert!(has_tag(&on, "auto_join", "true")); + let off = sign( + build_update_channel(cid, None, None, None, None, None, None, Some(false)).unwrap(), + ); + assert!(has_tag(&off, "auto_join", "false")); + let other = + sign(build_update_channel(cid, Some("n"), None, None, None, None, None, None).unwrap()); + assert!(!other + .tags + .iter() + .any(|t| t.kind().to_string() == "auto_join")); + } + #[test] fn update_channel_no_fields_rejected() { let cid = uuid(); assert!(matches!( - build_update_channel(cid, None, None, None, None, None, None), + build_update_channel(cid, None, None, None, None, None, None, None), Err(SdkError::InvalidTag(_)) )); } diff --git a/crates/buzz-test-client/tests/e2e_relay.rs b/crates/buzz-test-client/tests/e2e_relay.rs index 1cdd80435e..39d7d74a6d 100644 --- a/crates/buzz-test-client/tests/e2e_relay.rs +++ b/crates/buzz-test-client/tests/e2e_relay.rs @@ -302,6 +302,102 @@ async fn test_invite_mint_and_claim_admits_new_pubkey() { ); } +/// Claiming an invite joins the new member to the community's default +/// channels: the relay owner flags one, the claimer receives its kind:44100 +/// and is on its kind:39002 roster, and a repeat claim is a no-op. +#[tokio::test] +#[ignore] +async fn test_invite_claim_auto_joins_default_channel() { + let url = relay_url(); + let relay_owner = Keys::generate(); + let joiner = Keys::generate(); + let joiner_hex = joiner.public_key().to_hex(); + seed_relay_owner(&relay_owner).await; + let channel_id = create_test_channel(&relay_owner).await; + assert_eq!( + set_metadata_ws(&url, &channel_id, &relay_owner, "auto_join", "true").await, + (true, String::new()) + ); + + let mut joiner_client = BuzzTestClient::connect(&url, &joiner) + .await + .expect("connect joiner"); + let sid = sub_id("auto-join-notif"); + let filter = Filter::new() + .kind(Kind::Custom(44100)) + .custom_tag(SingleLetterTag::lowercase(Alphabet::P), joiner_hex.as_str()) + .since(nostr::Timestamp::now() - 5u64); + joiner_client + .subscribe(&sid, vec![filter]) + .await + .expect("subscribe to membership notifications"); + joiner_client + .collect_until_eose(&sid, Duration::from_secs(5)) + .await + .expect("EOSE for membership sub"); + + let mint_response = invite_post(&relay_owner, "/api/invites", "{}").await; + assert_eq!(mint_response.status(), reqwest::StatusCode::OK); + let mint_json: serde_json::Value = mint_response.json().await.expect("mint JSON"); + let code = mint_json + .get("code") + .and_then(serde_json::Value::as_str) + .expect("mint response includes code"); + let claim_body = serde_json::json!({ "code": code }).to_string(); + let claim_response = invite_post(&joiner, "/api/invites/claim", &claim_body).await; + assert_eq!(claim_response.status(), reqwest::StatusCode::OK); + let claim_json: serde_json::Value = claim_response.json().await.expect("claim JSON"); + assert_eq!( + claim_json.get("status").and_then(serde_json::Value::as_str), + Some("joined") + ); + + // Other tests may leave their own default channels flagged on this + // shared relay; wait for the notification about ours. + let deadline = tokio::time::Instant::now() + Duration::from_secs(5); + loop { + let remaining = deadline.saturating_duration_since(tokio::time::Instant::now()); + let msg = joiner_client + .recv_event(remaining) + .await + .expect("recv kind:44100 notification for the default channel"); + if let RelayMessage::Event { event, .. } = msg { + let channel = event + .tags + .iter() + .find(|tag| tag.as_slice().first().map(String::as_str) == Some("h")) + .and_then(|tag| tag.as_slice().get(1).cloned()); + if channel.as_deref() == Some(channel_id.as_str()) { + break; + } + } + } + joiner_client.disconnect().await.ok(); + assert_eq!( + member_role(&url, &relay_owner, &channel_id, &joiner_hex) + .await + .as_deref(), + Some("member"), + "the relay-signed kind:39002 roster lists the claimer" + ); + + let repeat_response = invite_post(&joiner, "/api/invites/claim", &claim_body).await; + assert_eq!(repeat_response.status(), reqwest::StatusCode::OK); + let repeat_json: serde_json::Value = repeat_response.json().await.expect("repeat claim JSON"); + assert_eq!( + repeat_json + .get("status") + .and_then(serde_json::Value::as_str), + Some("already_member") + ); + + // Leave the shared relay as found. + assert_eq!( + set_metadata_ws(&url, &channel_id, &relay_owner, "auto_join", "false").await, + (true, String::new()) + ); +} + #[tokio::test] #[ignore] async fn test_invite_claim_rejects_invalid_code() { @@ -3132,20 +3228,31 @@ async fn set_join_role_ws( channel_id: &str, signer: &Keys, join_role: &str, +) -> (bool, String) { + set_metadata_ws(url, channel_id, signer, "join_role", join_role).await +} + +/// Submit a kind:9002 with one `[tag, value]` setting and return the OK frame. +async fn set_metadata_ws( + url: &str, + channel_id: &str, + signer: &Keys, + tag: &str, + value: &str, ) -> (bool, String) { // The nonce keeps a repeated setting within one second from signing the // same event id as the earlier one, which the relay drops as a duplicate. let event = EventBuilder::new(Kind::Custom(9002), "") .tags([ Tag::parse(["h", channel_id]).unwrap(), - Tag::parse(["join_role", join_role]).unwrap(), + Tag::parse([tag, value]).unwrap(), Tag::parse(["nonce", &uuid::Uuid::new_v4().to_string()]).unwrap(), ]) .sign_with_keys(signer) .unwrap(); let mut client = BuzzTestClient::connect(url, signer) .await - .expect("connect join_role actor"); + .expect("connect kind:9002 actor"); let ok = client.send_event(event).await.expect("send kind:9002"); client.disconnect().await.ok(); (ok.accepted, ok.message) @@ -3713,6 +3820,31 @@ async fn test_nip29_announce_channel_wire() { !tags.contains(&vec!["closed".to_string()]), "open channels accept kind:9021 joins: {tags:?}" ); + assert!( + !tags.contains(&vec!["auto_join".to_string()]), + "no channel is a default channel until a relay admin asks: {tags:?}" + ); + + // `auto_join` is a community setting: the channel owner, who is not on + // the relay roster, cannot set it; a relay owner can, and kind:39000 + // then advertises the bare tag. + let (accepted, msg) = set_metadata_ws(&url, &channel_id, &owner, "auto_join", "true").await; + assert!(!accepted, "channel owner must not set auto_join"); + assert!(msg.contains("relay owner or admin"), "{msg}"); + let relay_owner = Keys::generate(); + seed_relay_owner(&relay_owner).await; + assert_eq!( + set_metadata_ws(&url, &channel_id, &relay_owner, "auto_join", "true").await, + (true, String::new()) + ); + let tags = group_metadata_tags(&url, &owner, &channel_id).await; + assert!(tags.contains(&vec!["auto_join".to_string()]), "{tags:?}"); + assert_eq!( + set_metadata_ws(&url, &channel_id, &relay_owner, "auto_join", "false").await, + (true, String::new()) + ); + let tags = group_metadata_tags(&url, &owner, &channel_id).await; + assert!(!tags.contains(&vec!["auto_join".to_string()]), "{tags:?}"); // A non-member of an open announce channel cannot write, and cannot // self-add as a member. diff --git a/docs/nips/NIP-CL.md b/docs/nips/NIP-CL.md index 9b98829280..27ed3a8a40 100644 --- a/docs/nips/NIP-CL.md +++ b/docs/nips/NIP-CL.md @@ -64,6 +64,7 @@ Example kind:39000 (signing fields and unrelated tags omitted): | later `t` | 39000–39002 | Channel labels. | | `P` | 39000–39002 | Public key that signed the kind:9007 that created the channel. | | `created_at` | 39000 | Time the channel was created, in unix seconds, as a decimal string. | +| `auto_join` | 39000 | Present (bare, no value) while the relay joins new community members into this channel: the channel's `auto_join` setting is on and it is open and unarchived. | **Channel type.** Clients MUST read the channel type from the first `t` tag. Later `t` tags are labels. diff --git a/migrations/0080_channel_auto_join.sql b/migrations/0080_channel_auto_join.sql new file mode 100644 index 0000000000..43554b3369 --- /dev/null +++ b/migrations/0080_channel_auto_join.sql @@ -0,0 +1,11 @@ +-- Whether the relay joins every new community member into this channel. +-- Only a relay owner or admin sets it, with kind:9002 ["auto_join", "true"| +-- "false"]; it is accepted only on an open, unarchived stream channel. +-- Published in kind:39000 as a bare `auto_join` tag while it is effective +-- (set, open and unarchived), so what the event says is what the relay does. +-- The constant default fills existing rows without a table rewrite: no +-- channel is a default channel until someone asks for it. +SET LOCAL lock_timeout = '5s'; + +ALTER TABLE channels + ADD COLUMN auto_join BOOLEAN NOT NULL DEFAULT false; diff --git a/schema/tables/public/channels.sql b/schema/tables/public/channels.sql index 102a441e8f..859bd3d3d3 100644 --- a/schema/tables/public/channels.sql +++ b/schema/tables/public/channels.sql @@ -41,6 +41,7 @@ CREATE TABLE channels ( -- An unlisted open channel is readable and joinable by id but left out of -- channel listings for non-members. Matters only while the channel is open. unlisted BOOLEAN NOT NULL DEFAULT FALSE, + auto_join BOOLEAN NOT NULL DEFAULT false, PRIMARY KEY (community_id, id), CONSTRAINT chk_channels_id_not_nil CHECK (id <> '00000000-0000-0000-0000-000000000000'::uuid) );