Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
4 changes: 4 additions & 0 deletions .github/workflows/_ci-relay.yml
Original file line number Diff line number Diff line change
Expand Up @@ -627,6 +627,10 @@ jobs:
cargo test -p buzz-test-client --test e2e_relay invite -- --ignored --nocapture
cargo test -p buzz-test-client --test e2e_relay nip43_membership_snapshots_are_rejected -- --ignored --nocapture
cargo test -p buzz-test-client --test e2e_relay nip29_departure_wire -- --ignored --nocapture
cargo test -p buzz-test-client --test e2e_relay nip29_announce_channel_wire -- --ignored --nocapture
cargo test -p buzz-test-client --test e2e_relay nip29_member_raises_guest_agent -- --ignored --nocapture
cargo test -p buzz-test-client --test e2e_relay nip29_guest_is_read_only_in_normal_channel -- --ignored --nocapture
cargo test -p buzz-test-client --test e2e_relay nip29_relay_rejects_role_change_by_unprivileged_actor -- --ignored --nocapture
env:
RELAY_URL: ws://localhost:3000
GIT_CREDENTIAL_NOSTR_BIN: ${{ github.workspace }}/target/ci/git-credential-nostr
Expand Down
65 changes: 60 additions & 5 deletions NOSTR.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,13 +51,13 @@ PGPASSWORD=buzz_dev psql -h localhost -U buzz -d buzz -c \
| **Deletions (kind:5)** | βœ… | Standard NIP-09; self-authored only. `#h` optional, `#e` required |
| **User profiles (kind:0)** | βœ… | NIP-01 metadata; synced to users table (display_name, avatar, about, NIP-05). NIP-05 handles must canonicalize to this relay's domain β€” off-domain or invalid handles are silently cleared. If a NIP-05 handle collides with another user's (UNIQUE constraint), the handle is skipped but other profile fields (display_name, avatar, about) are still synced. |
| **Group creation (kind:9007)** | βœ… | NIP-29; include `name` tag, optional `visibility`, `channel_type` and label `t` 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. |
| **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. |
| **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` tags: owner/admin. `topic`/`purpose` tags: any member. |
| **Edit group metadata (kind:9002)** | βœ… | `name`/`about`/label `t`/`visibility`/`ttl`/`archived`/`join_role` 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. |
| **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`, `closed`, `["t", <channel_type>]`, `["P", <creator>]` and `["created_at", <unix seconds>]` tags; `about` only if description non-empty; `private` if applicable; `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; `about` only if description non-empty; `hidden` for DM channels and private system 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 |
| **Membership notifications** | βœ… | kind:44100 (added) / kind:44101 (removed); relay-signed, community-global scope (`channel_id=None` inside the connected community) |
Expand All @@ -71,7 +71,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 member, 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. |
| **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 @@ -100,6 +100,61 @@ relay to specific external Nostr identities without granting full access.
SELECT encode(pubkey, 'hex'), added_at, note FROM pubkey_allowlist;
```

### Announce channels

Roles have the same permissions in every channel. A channel's settings
decide only who can join and which role a new person gets:

- `visibility` (`open` | `private`) decides who can find, join and read.
- `join_role` (`member` | `guest`) is the role a person gets when nobody
picks one: a self-join (kind:9021, or a kind:9000 self-add), or a kind:9000
add with no `role` tag. The default, `member`, keeps today's behavior.

An open channel with `join_role = guest` is an **announce channel**: anyone
can find, read and join it, but people who join are guests and cannot write.

What each role can do:

| Action | Guest | Member or bot | Admin / owner |
|--------|-------|---------------|---------------|
| Read, leave | Yes | Yes | Yes |
| Post, reply, edit, delete own, react, artifacts, canvas, forum, pins, typing, workflow messages, huddles | No | Yes | Yes |
| Topic, purpose (kind:9002) | No | Yes | Yes |
| Add people (kind:9000) | No | Yes, as member, guest or bot | Yes, any role |
| Push to the channel's git repositories | No | Yes | Yes |
| Name, about, visibility, TTL, `join_role` | No | No | Yes |

A non-member may write only where joining would give a role that can write:
an open channel with `join_role = member`. A refused write gets
`restricted: only members can post in this channel`.

Other rules:

- A non-member adding themselves with kind:9000 may ask for `join_role` or a
lower role. A higher role is refused.
- Any member who can write (member, bot, owner or admin) can make a guest a
member with kind:9000 (`role` `member`). This is the same for a person and
an agent. Only owners and admins can make any other role change.
- The human owner of an agent can edit (kind:40003) and delete (kind:9005)
that agent's messages, also as a guest or a non-member. This is existing
behavior. It lets a person correct or remove what their agent posted; it
does not let them post.
- Owners and admins change the setting with kind:9002
`["join_role", "guest"]` or `["join_role", "member"]`. A missing tag means
"no change". Changing it does not change anyone's role: existing members
can still write after a change to `guest`, and existing guests stay
read-only after a change to `member`. A stored value the relay does not
know is treated as `guest`.

kind:39000 publishes the NIP-29 access flags from these settings, and an
explicit `["join_role", ...]` tag so clients can tell what joining gives:

| `visibility` | `join_role` | `private` | `public` | `restricted` | `closed` |
|--------------|-------------|-----------|----------|--------------|----------|
| open | `member` | | βœ“ | | |
| open | `guest` | | βœ“ | βœ“ | |
| private | either | βœ“ | | βœ“ | βœ“ |

### Group Discovery

The relay emits NIP-29 group state events when channels are created, updated, or membership changes.
Expand All @@ -109,7 +164,7 @@ All discovery events include a `d` tag set to the channel UUID (NIP-29 addressab

| Kind | Tags | Content |
|------|------|---------|
| **39000** | `d=<uuid>`, `name`, `closed`, `t=<channel_type>`, `P=<creator>`, `created_at=<unix seconds>` (always); `about` (if description non-empty); `private` (if applicable); `hidden` (DM channels and private system channels) | Group metadata. **Note:** `closed` is always emitted per NIP-29 convention (Buzz channels require explicit membership), but open channels are still readable/writable by non-members at runtime. The tag reflects the membership model, not access enforcement. |
| **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); `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)). |
| **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
31 changes: 5 additions & 26 deletions crates/buzz-admin/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -716,7 +716,7 @@ async fn reconcile_channels(
channel_arg: Option<String>,
relay_key_arg: Option<String>,
) -> Result<()> {
use buzz_core::channel::{channel_created_at_tag, group_state_identity_tags};
use buzz_core::channel::group_state_identity_tags;
use buzz_core::kind::KIND_NIP29_GROUP_ADMINS;
use buzz_db::event::EventQuery;

Expand Down Expand Up @@ -801,31 +801,10 @@ async fn reconcile_channels(
if target_channel.is_none() {
// kind:39000 β€” channel metadata
{
let mut tags: Vec<Tag> = vec![Tag::parse(["d", &channel_id_str])?];
tags.push(Tag::parse(["name", &channel.name])?);
if let Some(ref desc) = channel.description {
if !desc.is_empty() {
tags.push(Tag::parse(["about", desc])?);
}
}
if channel.visibility == "private" {
tags.push(Tag::parse(["private"])?);
} else {
tags.push(Tag::parse(["public"])?);
}
if channel.channel_type == "dm"
|| (channel.channel_type == "system" && channel.visibility == "private")
{
tags.push(Tag::parse(["hidden"])?);
}
tags.push(Tag::parse(["closed"])?);
tags.extend(group_state_identity_tags(
&channel.channel_type,
&channel.labels,
&channel.created_by,
)?);
tags.push(channel_created_at_tag(channel.created_at.timestamp())?);

let tags = buzz_db::channel::group_metadata_tags(channel, &members)
.into_iter()
.map(Tag::parse)
.collect::<Result<Vec<_>, _>>()?;
let event = EventBuilder::new(Kind::Custom(39000), "")
.tags(tags)
.sign_with_keys(&relay_keys)
Expand Down
32 changes: 24 additions & 8 deletions crates/buzz-cli/src/commands/channels.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1598,16 +1598,23 @@ fn validate_update_channel_fields(
description: Option<&str>,
visibility: Option<&str>,
ttl_change: Option<Option<i32>>,
join_role: Option<buzz_sdk::ChannelJoinRole>,
) -> Result<(), CliError> {
if name.is_none() && description.is_none() && visibility.is_none() && ttl_change.is_none() {
if name.is_none()
&& description.is_none()
&& visibility.is_none()
&& ttl_change.is_none()
&& join_role.is_none()
{
return Err(CliError::Usage(
"at least one field required (--name, --description, --visibility, --ttl, --no-ttl)"
"at least one field required (--name, --description, --visibility, --ttl, --no-ttl, --join-role)"
.into(),
));
}
Ok(())
}

#[allow(clippy::too_many_arguments)]
pub async fn cmd_update_channel(
client: &BuzzClient,
channel_id: &str,
Expand All @@ -1616,6 +1623,7 @@ pub async fn cmd_update_channel(
visibility: Option<&str>,
ttl: Option<i64>,
no_ttl: bool,
join_role: Option<buzz_sdk::ChannelJoinRole>,
) -> 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 @@ -1625,12 +1633,18 @@ pub async fn cmd_update_channel(
(None, false) => None,
};

validate_update_channel_fields(name, description, visibility, ttl_change)?;
validate_update_channel_fields(name, description, visibility, ttl_change, join_role)?;
let channel_uuid = parse_uuid(channel_id)?;

let builder =
buzz_sdk::build_update_channel(channel_uuid, name, description, visibility, ttl_change)
.map_err(|e| CliError::Other(format!("build_update_channel failed: {e}")))?;
let builder = buzz_sdk::build_update_channel(
channel_uuid,
name,
description,
visibility,
ttl_change,
join_role,
)
.map_err(|e| CliError::Other(format!("build_update_channel failed: {e}")))?;

let event = client.sign_event(builder)?;
let resp = client.submit_event(event).await?;
Expand Down Expand Up @@ -1931,6 +1945,7 @@ pub async fn dispatch(
visibility,
ttl,
no_ttl,
join_role,
} => {
let visibility = visibility.as_ref().map(|v| v.to_string());
cmd_update_channel(
Expand All @@ -1941,6 +1956,7 @@ pub async fn dispatch(
visibility.as_deref(),
ttl,
no_ttl,
join_role.map(Into::into),
)
.await
}
Expand Down Expand Up @@ -2130,7 +2146,7 @@ mod tests {

#[test]
fn update_channel_fields_rejects_empty_update() {
let result = validate_update_channel_fields(None, None, None, None);
let result = validate_update_channel_fields(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 @@ -2139,7 +2155,7 @@ mod tests {

#[test]
fn update_channel_fields_accepts_visibility_only_update() {
let result = validate_update_channel_fields(None, None, Some("open"), None);
let result = validate_update_channel_fields(None, None, Some("open"), None, None);
assert!(result.is_ok(), "visibility-only update should be accepted");
}

Expand Down
30 changes: 28 additions & 2 deletions crates/buzz-cli/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -191,6 +191,27 @@ impl std::fmt::Display for ChannelVisibility {
}
}

/// The role a new person gets in a channel (`join_role`).
#[derive(Clone, Copy, clap::ValueEnum)]
pub enum ChannelJoinRoleArg {
/// New people become members (default).
#[value(name = "member")]
Member,
/// New people become guests, who can read but not write. On an open
/// channel this makes an announce channel.
#[value(name = "guest")]
Guest,
}

impl From<ChannelJoinRoleArg> for buzz_sdk::ChannelJoinRole {
fn from(value: ChannelJoinRoleArg) -> Self {
match value {
ChannelJoinRoleArg::Member => Self::Member,
ChannelJoinRoleArg::Guest => Self::Guest,
}
}
}

#[derive(Clone, clap::ValueEnum)]
pub enum PresenceStatus {
#[value(name = "online")]
Expand Down Expand Up @@ -671,9 +692,9 @@ pub enum ChannelsCmd {
#[arg(long, value_name = "PATH")]
templates_file: Option<String>,
},
/// Update channel name, description, visibility, or ephemeral TTL
/// Update channel name, description, visibility, join role, 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"
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"
)]
Update {
/// Channel UUID
Expand All @@ -695,6 +716,11 @@ pub enum ChannelsCmd {
/// Clear an existing TTL, making the channel permanent.
#[arg(long)]
no_ttl: bool,
/// The role a new person gets when nobody picks one. `guest` on an
/// open channel makes an announce channel: people who join can read
/// but not write. Does not change existing members. Owner/admin only.
#[arg(long, value_enum)]
join_role: Option<ChannelJoinRoleArg>,
},
/// Set the channel topic
Topic {
Expand Down
Loading
Loading