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

@@ -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","<root>","","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 <uuid> --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=<uuid>`, `name`, `t=<channel_type>`, `P=<creator>`, `created_at=<unix seconds>`, `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=<uuid>`, `name`, `t=<channel_type>`, `P=<creator>`, `created_at=<unix seconds>`, `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=<uuid>`, `t=<channel_type>`, `P=<creator>`, `p` tags with role label (`owner`, `admin`) | Admin list |
| **39002** | `d=<uuid>`, `t=<channel_type>`, `P=<creator>`, `p` tags for all members | Member list |

View File

@@ -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)]

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 {

View File

@@ -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()

View File

@@ -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<Vec<ChannelRecord>> {
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<Vec<Uuid>> {
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<Channe
join_role: row.try_get("join_role")?,
// Selected by every channel-record query, like `join_role`.
unlisted: row.try_get("unlisted")?,
auto_join: row.try_get("auto_join")?,
})
}
@@ -688,10 +770,14 @@ pub(crate) fn row_to_channel_record(row: sqlx::postgres::PgRow) -> Result<Channe
///
/// Buzz also publishes the explicit `["join_role", ...]` value, so clients
/// can tell what joining gives (show Join or Follow) without inferring it
/// from the flags, and a bare `["unlisted"]` flag when the channel is
/// unlisted. The flag is published as stored, whatever the visibility, so
/// clients see the setting; it only changes listings while the channel is
/// open.
/// from the flags, a bare `["unlisted"]` flag when the channel is
/// unlisted, and a bare `auto_join` tag while the relay joins new
/// community members into the channel. The `unlisted` flag is published as
/// stored, whatever the visibility, so clients see the setting; it only
/// changes listings while the channel is open. `auto_join` is published only
/// while the column is set and the channel is open and unarchived: a stale
/// `true` on a private or archived channel is not published, so what
/// kind:39000 says is what the relay does.
pub fn group_metadata_tags(channel: &ChannelRecord, members: &[MemberRecord]) -> Vec<Vec<String>> {
let tag = |parts: &[&str]| parts.iter().map(|p| (*p).to_owned()).collect::<Vec<_>>();
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<bool>,
/// 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<bool>,
}
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<String>,
@@ -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<Vec<ChannelRecord>> {
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<Vec<Uuid>> {
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]

View File

@@ -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<Option<MemberRecord>> {
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<crate::runtime::push_enqueue::PushEnqueue>,
@@ -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<Vec<Result<PutMemberResult>>> {
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<MemberRole> = 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<Option<MemberRecord>> {
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

View File

@@ -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<ChannelRecord> {
labels: row.try_get("labels")?,
join_role: row.try_get("join_role")?,
unlisted: row.try_get("unlisted")?,
auto_join: row.try_get("auto_join")?,
})
}

View File

@@ -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<AppState>,
claimer_hex: &str,
snapshot: Option<buzz_core::StoredEvent>,
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::<Value>(&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<Option<String>> {
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<T>(
pool: &sqlx::PgPool,
blocker_pid: i32,
task: &tokio::task::JoinHandle<T>,
) {
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;

View File

@@ -750,14 +750,20 @@ pub async fn handle_auth(event: nostr::Event, conn: Arc<ConnectionState>, 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<ConnectionState>, 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<uuid::Uuid> {
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<crate::state::AppState>,
community: buzz_core::tenant::CommunityId,
keys: &Keys,
challenge: &str,
tag: Option<Vec<String>>,
) -> std::sync::Arc<crate::connection::ConnectionState> {
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<String> = 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

View File

@@ -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<Vec<String>>| 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::<serde_json::Value>(&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::<Vec<_>>(),
[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<Uuid> = 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

View File

@@ -612,12 +612,13 @@ fn required_scope_for_kind(kind: u32, event: &Event) -> Result<Scope, &'static s
// profile-publishing user already holds.
KIND_IA_ARCHIVE_REQUEST | KIND_IA_UNARCHIVE_REQUEST => 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)

View File

@@ -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(

View File

@@ -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<AppState>,
) -> 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<AppState>,
channel_id: Uuid,
pubkey: &[u8],
) -> anyhow::Result<SelfJoinOutcome> {
// 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<AppState>,
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<AppState>,
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<AppState>,
pubkey: &[u8],
gate: &Arc<crate::nip_fi_gate::SessionAdmissionGate>,
) {
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<AppState>,
pubkey: &[u8],
gate: &Arc<crate::nip_fi_gate::SessionAdmissionGate>,
) {
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<AppState>,
) -> 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(())
}

View File

@@ -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.

View File

@@ -824,12 +824,17 @@ pub fn build_leave(channel_id: Uuid) -> Result<EventBuilder, SdkError> {
}
/// 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<Option<i32>>,
join_role: Option<buzz_core::channel::ChannelJoinRole>,
unlisted: Option<bool>,
auto_join: Option<bool>,
) -> Result<EventBuilder, SdkError> {
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(_))
));
}

View File

@@ -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.

View File

@@ -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.

View File

@@ -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;

View File

@@ -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)
);