Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
70 changes: 66 additions & 4 deletions NOSTR.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand All @@ -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 |

Expand Down Expand Up @@ -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.
Expand All @@ -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 |

Expand Down
4 changes: 3 additions & 1 deletion crates/buzz-admin/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)]
Expand Down
17 changes: 14 additions & 3 deletions crates/buzz-cli/src/commands/channels.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1640,16 +1640,18 @@ 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()
&& visibility.is_none()
&& 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(),
));
}
Expand All @@ -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.
Expand All @@ -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
Expand All @@ -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}")))?;

Expand Down Expand Up @@ -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(
Expand All @@ -2018,6 +2024,7 @@ pub async fn dispatch(
no_ttl,
join_role.map(Into::into),
unlisted,
auto_join,
)
.await
}
Expand Down Expand Up @@ -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"));
Expand All @@ -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");
}

Expand Down Expand Up @@ -4823,6 +4831,7 @@ mod unlisted_support_tests {
false,
None,
Some(true),
None,
)
.await
.unwrap_err();
Expand All @@ -4849,6 +4858,7 @@ mod unlisted_support_tests {
false,
None,
Some(true),
None,
)
.await
.unwrap();
Expand Down Expand Up @@ -4877,6 +4887,7 @@ mod unlisted_support_tests {
false,
None,
Some(false),
None,
)
.await
.unwrap();
Expand Down
9 changes: 7 additions & 2 deletions crates/buzz-cli/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Expand Down
6 changes: 5 additions & 1 deletion crates/buzz-db/src/runtime/migration.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
Loading
Loading