feat(relay): auto-join new community members to default channels (#8594)

## Summary

Adds **default channels**: relay owners and admins can flag channels
that every new community member is automatically joined into.

**Setting (contract)**
- New `channels.auto_join` column (migration `0080`, constant default
`false`, so no table rewrite).
- kind:9002 accepts `["auto_join", "true"|"false"]`. Authorization comes
from the actor's **relay** roster role (owner/admin), not a channel
role, so the actor doesn't need to be a channel member. API tokens need
`AdminChannels`.
- Only open, unarchived `stream` channels qualify. This is checked under
the channel row lock, so one event can open and flag a channel in one
step, and a concurrent archive can't slip past the check.
- kind:39000 carries a bare `auto_join` tag only while the setting is
effective (on, open and unarchived). Every change posts one
`auto_join_changed` kind:40099 notice.
- SDK `build_update_channel(.., auto_join)`, `buzz channels update
--auto-join <true|false>`, a NOSTR.md "Default channels" section, and a
NIP-CL tag-table row.

**Behaviour**
- kind:9021's body is pulled out into `join_channel_as_self`, so
auto-join uses the same path as a self-join: the channel's `join_role`
applies, and it emits `member_joined` 40099, 39000/39002 and 44100.
- `auto_join_default_channels` runs inline right after a member is added
at three sites: the v2 invite claim, the v1 invite claim, and kind:9030
add. It does not run on login or reconnect, so a later kind:9022 leave
stands.
- Best effort: each failure is logged and counted
(`buzz_auto_join_failures_total`) but never retried, because the roster
membership has already committed. This deliberately departs from
Review-Proven Rule 1 and is documented in NOSTR.md. `buzz-admin
add-member` writes the roster row only.

**Communities without a membership roster**
(`BUZZ_REQUIRE_RELAY_MEMBERSHIP=false`)
- These have no invite claim or kind:9030 to hook, so the three sites
above join nobody there. Instead, after the AUTH `OK`, the relay joins a
person to every effective default channel they have **never had a
membership row in** (`auto_join_never_joined_channels`, spawned so
admission never waits on it).
- The channel's own membership history is the once-only record, so this
adds no new table, column or migration. A kind:9022 leave or a kind:9001
removal keeps the row with `removed_at`, so the person stays out. A new
device or another relay pod finds the row and does nothing.
`put_first_member` checks the row again under the `put_member` locks, so
two admissions racing each other join once.
- A channel flagged later is joined on each person's next admission. A
failed membership write leaves no row, so the next admission retries it.
- Each channel's join holds the session's NIP-FI effect permit, so once
the socket closes or its admission expires, no further channels are
joined.
- Agents are skipped: any socket with an admitted owner (a NIP-OA tag, a
NIP-FI agent token, or a stored `agent_owner_pubkey`).
- The kind:9021 side effects moved into `announce_self_join`, so both
join paths emit the same `member_joined`, 39000/39002 and 44100.

**Review follow-ups**
- `get_accessible_channels` now selects `auto_join`. Without it, every
call failed with `ColumnNotFound`.
- The final authorization branch of the 9002 validator now refuses by
default. `list_auto_join_channels` filters to `stream` channels itself.

## Testing
- Postgres-lane journeys:
- `edit_metadata_postgres_tests`: who may set `auto_join`, the 40099
notice, the effective-state tag, and the refusals.
- `claim_auto_joins_default_channels_once`: v2 and v1 claims, and leave
then re-claim.
  - `adding_a_member_auto_joins_default_channels`: kind:9030 add.
- Removing the auto-join call at any one of the three sites fails the
matching test (checked by mutation).
- `root_auth_joins_people_to_default_channels_without_a_roster`: through
`handle_auth` on a roster-less community. A person is joined, a NIP-OA
agent is not, and a leave holds when the person is admitted again.
Mutations checked: removing the spawn, and dropping the agent check.
Each check waits for that admission's own auto-join outcome (a skip
reports through the same test hook as a finished task) and fails if none
arrives. A third mutation drops the agent check and delays the task 3s;
it still fails.
- `admission_auto_join_stops_when_the_session_ends`: closing the socket
before the first or second channel stops the remaining joins; a later
admission joins what was skipped. Mutation checked: dropping the effect
permit.
- `first_member_put_joins_once_and_never_undoes_a_leave` (buzz-db): the
person is joined once with the channel's `join_role` (`guest` on an
announce channel), a second put does nothing, after a leave the channel
is neither listed nor joined, and two racing puts write exactly once.
Mutations checked: removing the row check, removing the channel lock,
and removing the `NOT EXISTS`.
- e2e `test_invite_claim_auto_joins_default_channel` against a local
relay.
- Unit coverage in the SDK and CLI builders and `group_metadata_tags`.

## What got simpler

The roster-less path adds no schema and no new concept: it reuses the
channel's membership history as its record. It also shares the kind:9021
join code. It replaces the per-device client workarounds,
[buzz-app#2029](https://github.com/block/buzz-app/pull/2029) and
[buzz#8795](https://github.com/block/buzz/pull/8795), which are now
closed.

## Related

- Client half (buzz-app): https://github.com/block/buzz-app/pull/1624

## Demo

Recorded against a local relay built from this branch with throwaway
identities (Alice = owner, Bob = new member); no real accounts or hosted
relays involved. Relay at 141aa01d4, client at buzz-app bb0d97d86
(browser dev build).

**Owner turns on "Starter channel for new members" on two channels.**
Alice creates `#general` and `#welcome`, then flips the switch on each
(~0:25 and ~0:41). The switch greys out while the kind 9002 is in flight
and only turns on once the republished 39000 carries the `auto_join`
tag.


https://github.com/user-attachments/assets/e590d343-11d3-4385-af43-2cfaf19f8468

**A brand-new member joins and lands in a starter channel.** Bob claims
Alice's invite with empty storage; from ~0:18 he is reading `#welcome`
with both `#general` and `#welcome` in the sidebar, and at 0:27 opens
`#general` as a member with no Join prompt. The relay logged one
`auto-joined default channel` line per flagged channel.


https://github.com/user-attachments/assets/037a0aaa-b25d-474d-a316-8d25e5e54f89

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Signed-off-by: Matt Toohey <contact@matttoohey.com>
Signed-off-by: Luke Tornquist <tornquist@squareup.com>
Signed-off-by: tornquist <tornquist@squareup.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
Co-authored-by: Luke Tornquist <tornquist@squareup.com>
Co-authored-by: Elrond <28d6302a099e5225b02c4155ac4236e4912603df2ab08dbfc2f4fef08ce598c8@buzz.block.builderlab.xyz>
This commit is contained in:
Matt Toohey
2026-11-02 06:17:41 +11:00
committed by GitHub
parent 21a9839826
commit cde6c1bf5b
20 changed files with 2508 additions and 117 deletions

View File

@@ -1640,6 +1640,7 @@ fn validate_update_channel_fields(
ttl_change: Option<Option<i32>>,
join_role: Option<buzz_sdk::ChannelJoinRole>,
unlisted: Option<bool>,
auto_join: Option<bool>,
) -> 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<buzz_sdk::ChannelJoinRole>,
unlisted: Option<bool>,
auto_join: Option<bool>,
) -> 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();

View File

@@ -699,9 +699,9 @@ pub enum ChannelsCmd {
#[arg(long, value_name = "PATH")]
templates_file: Option<String>,
},
/// 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 <uuid> --name general\n buzz channels update --channel <uuid> --visibility open\n buzz channels update --channel <uuid> --visibility private\n buzz channels update --channel <uuid> --join-role guest\n buzz channels update --channel <uuid> --visibility open --unlisted true"
after_help = "Examples:\n buzz channels update --channel <uuid> --name general\n buzz channels update --channel <uuid> --visibility open\n buzz channels update --channel <uuid> --visibility private\n buzz channels update --channel <uuid> --join-role guest\n buzz channels update --channel <uuid> --visibility open --unlisted true\n buzz channels update --channel <uuid> --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<bool>,
/// 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<bool>,
},
/// Set the channel topic
Topic {