mirror of
https://github.com/block/buzz.git
synced 2026-11-02 07:22:55 +00:00
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:
70
NOSTR.md
70
NOSTR.md
@@ -53,11 +53,11 @@ PGPASSWORD=buzz_dev psql -h localhost -U buzz -d buzz -c \
|
||||
| **Group creation (kind:9007)** | ✅ | NIP-29; include `name` tag, optional `visibility`, `channel_type`, label `t` and `unlisted` tags |
|
||||
| **Add user (kind:9000)** | ✅ | Open: any user, subject to target's `channel_add_policy` (`owner_only`/`nobody` can block). Private: owner/admin only. Self-add bypasses agent policy but not private-channel auth. A guest cannot add anyone. A new member with no role tag gets the channel's `join_role`; a non-member adding themselves may ask for `join_role` or a lower role. One event may name up to 100 people: one `p` tag each, with an optional role as the `p` tag's third element (NIP-29), falling back to the event's `role` tag. Each person is checked and added on their own. When the event names several people, the OK message is `members: ` followed by JSON listing the non-empty `added`, `updated` (role changed), `already_member` and `rejected` (`[{"reason", "pubkeys"}]`) results. The event is refused only when it is malformed or nobody it names may be added; then one person keeps the single-add error, and several get `invalid: members: {…}`. The event and its membership changes are stored together, so an `error:` rejection stores nothing and the same event can be resent; a resent stored event returns `duplicate:` without results. Re-adding someone at their current role posts no second `member_joined` notice. |
|
||||
| **Remove user (kind:9001)** | ✅ | Self-remove allowed (with last-owner guard). Removing others: owner/admin only. |
|
||||
| **Edit group metadata (kind:9002)** | ✅ | `name`/`about`/label `t`/`visibility`/`ttl`/`archived`/`join_role`/`unlisted` tags: owner/admin. `topic`/`purpose` tags: any member except a guest. Buzz applies 9002 as a partial update: an absent tag leaves that setting unchanged. |
|
||||
| **Edit group metadata (kind:9002)** | ✅ | `name`/`about`/label `t`/`visibility`/`ttl`/`archived`/`join_role`/`unlisted` tags: owner/admin. `topic`/`purpose` tags: any member except a guest. `auto_join` tag: relay owner/admin (see [Default channels](#default-channels)). Buzz applies 9002 as a partial update: an absent tag leaves that setting unchanged. |
|
||||
| **Admin delete event (kind:9005)** | ✅ | Event author can always delete own. Otherwise owner/admin required. Target must be in same channel. |
|
||||
| **Group deletion (kind:9008)** | ✅ | Owner only. |
|
||||
| **Leave group (kind:9022)** | ✅ | Any member. Last-owner guard prevents orphaned groups. |
|
||||
| **Group metadata (kind:39000)** | ✅ | Relay-signed; always `d`, `name`, `t`, `P`, `created_at`, `join_role`; `public` or `private`; `restricted` and `closed` per the access flags below; `unlisted` when the channel is unlisted; `about` only if description non-empty; `hidden` for DM channels and private system channels |
|
||||
| **Group metadata (kind:39000)** | ✅ | Relay-signed; always `d`, `name`, `t`, `P`, `created_at`, `join_role`; `public` or `private`; `restricted` and `closed` per the access flags below; `unlisted` when the channel is unlisted; `about` only if description non-empty; `hidden` for DM channels and private system channels; `auto_join` while the channel is a default channel |
|
||||
| **Unlisted channels** | ✅ | kind:9002 `["unlisted", "true"\|"false"]`, owner/admin, or at creation on kind:9007. An unlisted open channel is left out of every read that does not name it, for non-members: channel lists, kind:39000 without `#d`, messages, COUNT, NIP-50 search and artifact queries. Naming it by `#h` (or `#d` on 39000–39003) reads it, and kind:9021 joins it, as for any open channel. See [Unlisted channels](#unlisted-channels). |
|
||||
| **Group admins (kind:39001)** | ✅ | Relay-signed; `d`, channel type `t` and creator `P` tags + `p` tags with roles (`owner`, `admin`) |
|
||||
| **Group members (kind:39002)** | ✅ | Relay-signed; `d`, channel type `t` and creator `P` tags + `p` tags for all members |
|
||||
@@ -72,7 +72,7 @@ PGPASSWORD=buzz_dev psql -h localhost -U buzz -d buzz -c \
|
||||
| **NIP-10 threads** | ✅ | WS-submitted replies with `["e","<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 |
|
||||
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")?,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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(_))
|
||||
));
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
11
migrations/0080_channel_auto_join.sql
Normal file
11
migrations/0080_channel_auto_join.sql
Normal 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;
|
||||
@@ -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)
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user