From 8bb49a1b6a5ffb5b89eb2e860673ee7bff2fd29b Mon Sep 17 00:00:00 2001 From: Logan Johnson Date: Wed, 14 Oct 2026 09:22:42 -0400 Subject: [PATCH] feat(relay): publish channel type and creator on every channel-state event (#8174) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 ## Summary Clients cannot tell who created a channel, and only the channel-metadata event says what type of channel it is. This PR makes every relay-signed channel-state event carry both facts, so a client can find "the channel that this key created" with one ordinary Nostr filter. - Channel metadata, admin list and member list events (kinds 39000, 39001 and 39002) now all carry the channel type as their first `t` tag, then the creator as a `P` tag. - The creator comes from the relay's own record of who signed the channel's create request. No client can set it, and it does not change when ownership moves. - Channel metadata (kind:39000) also carries `["created_at", ]`, the time the channel was created. The event's own `created_at` changes on every edit, so before this a client could not tell which of two channels is older. This is part 1 of a 3-part stack (channel labels and creator lookup). The system channels stack (#8180–#8182) builds on it. ### Related issue None found. ### Details - **Why `P`.** Several Nostr specs (NIP-22, NIP-34, NIP-72) use uppercase `P` for the author of the root object, and lowercase `p` for people involved. Lowercase `p` is already used on these events for members and DM participants, and a filter matches tag values, not their meaning, so `#p` would mix them. - **Trust.** A client that trusts a channel because of its creator must also check that the relay key signed the event. - **Order.** The type `t` tag comes first, so clients that read the first `t` tag still get the type after part 2 adds labels as more `t` tags. - **Creation time.** The relay reads it from `channels.created_at`, so an edit does not change it. #8176 uses it to pick one channel when two creates race. - `buzz-admin reconcile` writes the same tags when it rebuilds these events. `NOSTR.md` documents the tags. - **Spec.** New `docs/nips/NIP-CL.md` defines these tags, the trust rule and lookup by creator. Later PRs in both stacks add their parts to it. `NOSTR.md` links to it. ### Testing - Relay tests in `group_state_postgres_tests.rs`: each of the three kinds carries the type first, then the creator; the creator stays the same after an ownership transfer; kind:39000 carries the stored creation time, and still does after an edit. - Unit test for the shared tag builder that both the relay and `buzz-admin reconcile` use. ### Stack These are two stacks. Merge them in this order. Client support (creating, labeling and finding these channels) will be in block/buzz-app, not the buzz SDK and CLI. **Stack 1: channel labels and creator lookup (#8174–#8176)** 1. #8174 channel type and creator tags, and the channel creation time ← this PR 2. #8175 channel labels 3. #8176 database-side label and creator match, and the rule for choosing one channel **Stack 2: system channels (#8180–#8182)**, stacked on stack 1 because it uses labels and the creator tag 4. #8180 the system channel type 5. #8181 leave system channels out of reads that do not name them 6. #8182 channel-state filters opt in to system channels --------- Signed-off-by: Logan Johnson Co-authored-by: Larry <627498bd4bd1f281a16431e3c6cce3b5c25b6692798c78672298aefbf2f8f8b5@buzz.block.builderlab.xyz> --- NOSTR.md | 25 +- crates/buzz-admin/src/main.rs | 15 +- crates/buzz-core/src/channel.rs | 52 +++- crates/buzz-db/src/store/channel.rs | 12 +- crates/buzz-db/src/store/channel_members.rs | 26 ++ .../handlers/group_state_postgres_tests.rs | 240 ++++++++++++++++++ crates/buzz-relay/src/handlers/mod.rs | 2 + .../buzz-relay/src/handlers/side_effects.rs | 54 +++- docs/nips/NIP-CL.md | 101 ++++++++ 9 files changed, 511 insertions(+), 16 deletions(-) create mode 100644 crates/buzz-relay/src/handlers/group_state_postgres_tests.rs create mode 100644 docs/nips/NIP-CL.md diff --git a/NOSTR.md b/NOSTR.md index cb1f20e84f..a3a021e490 100644 --- a/NOSTR.md +++ b/NOSTR.md @@ -57,9 +57,9 @@ PGPASSWORD=buzz_dev psql -h localhost -U buzz -d buzz -c \ | **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` tags; `about` only if description non-empty; `private` if applicable; `hidden` for DM channels | -| **Group admins (kind:39001)** | ✅ | Relay-signed; `d` tag + `p` tags with roles (`owner`, `admin`) | -| **Group members (kind:39002)** | ✅ | Relay-signed; `d` tag + `p` tags for all members | +| **Group metadata (kind:39000)** | ✅ | Relay-signed; always `d`, `name`, `closed`, `["t", ]`, `["P", ]` and `["created_at", ]` tags; `about` only if description non-empty; `private` if applicable; `hidden` for DM 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) | | **Presence (kind:20001)** | ✅ | Ephemeral; arbitrary status string (truncated to 128 chars); writes to Redis (`set_presence`/`clear_presence` on `"offline"`), then fan-out to local subscribers. In multi-community mode presence is scoped to the connected community. | | **Agent presence (kind:20003)** | ✅ | Ephemeral; an owner's presence for the agents it owns, named by `p` tags (at most 256); `online`/`away` set and `offline` clears each agent's presence as its own kind:20001 would. Not fanned out. | @@ -103,13 +103,26 @@ relay to specific external Nostr identities without granting full access. ### Group Discovery The relay emits NIP-29 group state events when channels are created, updated, or membership changes. +[NIP-CL](docs/nips/NIP-CL.md) defines the channel identity tags on these events and how clients +use them to find channels. All discovery events include a `d` tag set to the channel UUID (NIP-29 addressable event convention): | Kind | Tags | Content | |------|------|---------| -| **39000** | `d=`, `name`, `closed` (always); `about` (if description non-empty); `private` (if applicable); `hidden` (DM channels only) | 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. | -| **39001** | `d=`, `p` tags with role label (`owner`, `admin`) | Admin list | -| **39002** | `d=`, `p` tags for all members | Member list | +| **39000** | `d=`, `name`, `closed`, `t=`, `P=`, `created_at=` (always); `about` (if description non-empty); `private` (if applicable); `hidden` (DM channels only) | 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. | +| **39001** | `d=`, `t=`, `P=`, `p` tags with role label (`owner`, `admin`) | Admin list | +| **39002** | `d=`, `t=`, `P=`, `p` tags for all members | Member list | + +On all three kinds the channel type `t` tag comes first, then `P`. `P` is +the public key that signed the channel's kind:9007. The relay writes it +from the database, and it does not change when ownership moves. Uppercase +`P` follows NIP-22, NIP-34 and NIP-72, where it names the author of the root +object; lowercase `p` already lists members and DM participants on these +kinds. + +kind:39000 also carries `["created_at", ]`: the time the +channel was created. The event's own `created_at` is the time of the last +edit, so use the tag to find the older of two channels. Events are stored **channel-scoped** so access control applies — private channel member lists are only visible to members. Discover groups via historical REQ: diff --git a/crates/buzz-admin/src/main.rs b/crates/buzz-admin/src/main.rs index 33fc74b6d1..9c4177b701 100644 --- a/crates/buzz-admin/src/main.rs +++ b/crates/buzz-admin/src/main.rs @@ -739,6 +739,7 @@ async fn reconcile_channels( channel_arg: Option, relay_key_arg: Option, ) -> Result<()> { + use buzz_core::channel::{channel_created_at_tag, group_state_identity_tags}; use buzz_core::kind::KIND_NIP29_GROUP_ADMINS; use buzz_db::event::EventQuery; @@ -839,7 +840,11 @@ async fn reconcile_channels( tags.push(Tag::parse(["hidden"])?); } tags.push(Tag::parse(["closed"])?); - tags.push(Tag::parse(["t", &channel.channel_type])?); + tags.extend(group_state_identity_tags( + &channel.channel_type, + &channel.created_by, + )?); + tags.push(channel_created_at_tag(channel.created_at.timestamp())?); let event = EventBuilder::new(Kind::Custom(39000), "") .tags(tags) @@ -852,6 +857,10 @@ async fn reconcile_channels( // kind:39001 — admins { let mut tags: Vec = vec![Tag::parse(["d", &channel_id_str])?]; + tags.extend(group_state_identity_tags( + &channel.channel_type, + &channel.created_by, + )?); for m in members .iter() .filter(|m| m.role == "owner" || m.role == "admin") @@ -871,6 +880,10 @@ async fn reconcile_channels( // kind:39002 — members { let mut tags: Vec = vec![Tag::parse(["d", &channel_id_str])?]; + tags.extend(group_state_identity_tags( + &channel.channel_type, + &channel.created_by, + )?); for m in &members { let pk = hex::encode(&m.pubkey); tags.push(Tag::parse(["p", &pk, "", &m.role])?); diff --git a/crates/buzz-core/src/channel.rs b/crates/buzz-core/src/channel.rs index 5c20afefd4..41ba48670c 100644 --- a/crates/buzz-core/src/channel.rs +++ b/crates/buzz-core/src/channel.rs @@ -17,6 +17,39 @@ pub fn canonical_channel_name(name: &str) -> &str { .trim_end() } +/// Tags that identify a channel on every NIP-29 group-state event the relay +/// signs (kinds 39000–39002), in this order: +/// +/// 1. `["t", ]`. It comes first, so a client that reads the +/// first `t` tag gets the channel type. +/// 2. `["P", ]`: the key that signed the channel's kind:9007. +/// It comes from the database and does not change when ownership moves. +/// Uppercase `P` names the author of the root object, as in NIP-22, +/// NIP-34 and NIP-72; lowercase `p` already means members and DM +/// participants on these kinds. +/// +/// Filters such as `{kinds:[39000], #P:[]}` select by these tags. +pub fn group_state_identity_tags( + channel_type: &str, + created_by: &[u8], +) -> Result, nostr::event::tag::Error> { + Ok(vec![ + nostr::Tag::parse(["t", channel_type])?, + nostr::Tag::parse(["P", &hex::encode(created_by)])?, + ]) +} + +/// Tag on kind:39000 that holds the channel's creation time in Unix seconds. +/// +/// The event's own `created_at` is the time of the last edit, so it cannot +/// rank channels by age. +pub const CHANNEL_CREATED_AT_TAG: &str = "created_at"; + +/// The [`CHANNEL_CREATED_AT_TAG`] tag for a channel created at `unix_seconds`. +pub fn channel_created_at_tag(unix_seconds: i64) -> Result { + nostr::Tag::parse([CHANNEL_CREATED_AT_TAG, &unix_seconds.to_string()]) +} + /// Whether a channel is publicly visible or invite-only. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum ChannelVisibility { @@ -180,7 +213,24 @@ impl FromStr for MemberRole { #[cfg(test)] mod tests { - use super::canonical_channel_name; + use super::{canonical_channel_name, group_state_identity_tags}; + + #[test] + fn group_state_identity_tags_put_the_type_first_then_the_creator() { + let creator = [0xab; 32]; + let tags: Vec> = group_state_identity_tags("forum", &creator) + .expect("valid tags") + .into_iter() + .map(|tag| tag.to_vec()) + .collect(); + assert_eq!( + tags, + vec![ + vec!["t".to_string(), "forum".to_string()], + vec!["P".to_string(), "ab".repeat(32)], + ] + ); + } #[test] fn channel_names_trim_whitespace_and_drop_all_leading_hashes() { diff --git a/crates/buzz-db/src/store/channel.rs b/crates/buzz-db/src/store/channel.rs index 8d43473d50..46375595cf 100644 --- a/crates/buzz-db/src/store/channel.rs +++ b/crates/buzz-db/src/store/channel.rs @@ -26,7 +26,7 @@ pub use crate::channel_members::{ get_users_bulk, is_member, list_large_channel_rosters_needing_reconciliation, lock_member_snapshot, membership_pairs, remove_member, verify_channel_roster_fence_behavior, verify_channel_roster_fence_catalog, AccessibleChannel, BotChannelEntry, BotMemberRecord, - LargeChannelRoster, LockedMemberSnapshot, MemberRecord, UserRecord, + ChannelIdentity, LargeChannelRoster, LockedMemberSnapshot, MemberRecord, UserRecord, }; async fn begin_event_write_transaction( @@ -99,6 +99,16 @@ pub struct ChannelRecord { pub ttl_deadline: Option>, } +impl ChannelRecord { + /// The facts every group-state event publishes about this channel. + pub fn identity(&self) -> ChannelIdentity { + ChannelIdentity { + channel_type: self.channel_type.clone(), + created_by: self.created_by.clone(), + } + } +} + /// Creates a new channel, bootstraps the creator as owner, and returns the record. #[allow(clippy::too_many_arguments)] pub async fn create_channel( diff --git a/crates/buzz-db/src/store/channel_members.rs b/crates/buzz-db/src/store/channel_members.rs index 5cf3ed40dc..7ff5146775 100644 --- a/crates/buzz-db/src/store/channel_members.rs +++ b/crates/buzz-db/src/store/channel_members.rs @@ -301,11 +301,25 @@ pub async fn insert_auto_membership_in_transaction( // ── End transaction-level helpers ───────────────────────────────────────────── +/// The immutable facts that every relay-signed group-state event +/// (kinds 39000–39002) publishes about its channel. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ChannelIdentity { + /// Channel type string (e.g. `"stream"`, `"forum"`, `"dm"`). + pub channel_type: String, + /// Public key bytes of the channel creator (the kind:9007 signer). + pub created_by: Vec, +} + /// An active member roster captured while holding the channel's membership /// serialization lock on one writer connection. pub struct LockedMemberSnapshot { /// Canonical active members captured behind the lock. pub members: Vec, + /// The channel's type and creator, read on the same connection, so the + /// roster carries the same identity tags as the channel's other + /// group-state events. `None` when no channel row exists. + pub identity: Option, channel_id: Uuid, relay_pubkey: Vec, tx: AdmittedTx, @@ -477,8 +491,20 @@ pub async fn lock_member_snapshot( .into_iter() .map(row_to_member_record) .collect::>>()?; + let identity = sqlx::query_as::<_, (String, Vec)>( + "SELECT channel_type::text, created_by FROM channels WHERE community_id = $1 AND id = $2", + ) + .bind(community_id.as_uuid()) + .bind(channel_id) + .fetch_optional(tx.conn()) + .await? + .map(|(channel_type, created_by)| ChannelIdentity { + channel_type, + created_by, + }); Ok(LockedMemberSnapshot { members, + identity, channel_id, relay_pubkey: relay_pubkey.to_vec(), tx, diff --git a/crates/buzz-relay/src/handlers/group_state_postgres_tests.rs b/crates/buzz-relay/src/handlers/group_state_postgres_tests.rs new file mode 100644 index 0000000000..2df126d097 --- /dev/null +++ b/crates/buzz-relay/src/handlers/group_state_postgres_tests.rs @@ -0,0 +1,240 @@ +//! Postgres-backed tests for the relay-signed NIP-29 group-state events +//! (kinds 39000–39002) that channel creation and edits publish. + +use std::sync::Arc; + +use buzz_core::TenantContext; +use buzz_db::event::EventQuery; +use nostr::{EventBuilder, Keys, Kind, Tag}; +use uuid::Uuid; + +use crate::handlers::ingest::{ingest_event, HttpAuthMethod, IngestAuth}; +use crate::state::AppState; +use buzz_auth::Scope; + +pub(crate) async fn state() -> Arc { + crate::state::tests::test_state_with_database_url(&crate::test_support::database_url()).await +} + +pub(crate) async fn community(state: &AppState, label: &str) -> TenantContext { + let host = format!("{label}-{}.test", Uuid::new_v4().simple()); + let community = state + .db + .ensure_configured_community(&host) + .await + .expect("ensure community") + .id; + TenantContext::resolved(community, &host) +} + +pub(crate) fn http_auth(keys: &Keys) -> IngestAuth { + IngestAuth::Http { + pubkey: keys.public_key(), + scopes: vec![ + Scope::ChannelsWrite, + Scope::MessagesWrite, + Scope::AdminChannels, + Scope::ChannelsRead, + ], + auth_method: HttpAuthMethod::Nip98, + } +} + +/// Ingest a kind:9007 that `keys` signs, with `extra` tags after the ID and +/// name, and return the new channel's ID. +pub(crate) async fn create_channel( + state: &Arc, + tenant: &TenantContext, + keys: &Keys, + extra: &[&[&str]], +) -> Uuid { + let channel_id = Uuid::new_v4(); + let mut tags = vec![ + Tag::parse(["h", &channel_id.to_string()]).unwrap(), + Tag::parse(["name", &format!("group-state-{}", channel_id.simple())]).unwrap(), + ]; + for tag in extra { + tags.push(Tag::parse(tag.iter().copied()).unwrap()); + } + let event = EventBuilder::new(Kind::Custom(9007), "") + .tags(tags) + .sign_with_keys(keys) + .expect("sign 9007"); + ingest_event(state, tenant, event, http_auth(keys)) + .await + .unwrap_or_else(|error| panic!("create channel: {error:?}")); + channel_id +} + +/// The current relay-signed group-state event of `kind` for `channel_id`. +pub(crate) async fn group_state_tags( + state: &AppState, + tenant: &TenantContext, + channel_id: Uuid, + kind: i32, +) -> Vec> { + let events = state + .db + .query_events(&EventQuery { + kinds: Some(vec![kind]), + d_tag: Some(channel_id.to_string()), + limit: Some(1), + ..EventQuery::for_community(tenant.community()) + }) + .await + .expect("query group state"); + let event = events + .first() + .unwrap_or_else(|| panic!("no kind:{kind} for {channel_id}")); + event + .event + .tags + .iter() + .map(|tag| tag.as_slice().to_vec()) + .collect() +} + +/// kind:39000 carries the channel's creation time, and an edit keeps it. +/// Mutation: use the edit time instead of `channels.created_at` → RED. +#[tokio::test] +#[ignore = "requires Postgres"] +async fn metadata_carries_the_channel_creation_time() { + let state = state().await; + let tenant = community(&state, "group-state-created-at").await; + let creator = Keys::generate(); + let channel_id = create_channel(&state, &tenant, &creator, &[]).await; + + let created_at = |tags: &[Vec]| -> Vec { + tags.iter() + .filter(|tag| tag[0] == "created_at") + .map(|tag| tag[1].clone()) + .collect() + }; + let stored = state + .db + .get_channel_for_event_write(tenant.community(), channel_id) + .await + .expect("channel") + .created_at + .timestamp(); + let tags = group_state_tags(&state, &tenant, channel_id, 39000).await; + assert_eq!(created_at(&tags), vec![stored.to_string()], "{tags:?}"); + + // Move the creation time far into the past, then edit. The new 39000 + // must still report the creation time, not the time of the edit. + sqlx::query("UPDATE channels SET created_at = to_timestamp(1577836800) WHERE id = $1") + .bind(channel_id) + .execute(state.db.pool()) + .await + .expect("backdate channel"); + let edit = EventBuilder::new(Kind::Custom(9002), "") + .tags([ + Tag::parse(["h", &channel_id.to_string()]).unwrap(), + Tag::parse(["name", &format!("renamed-{}", channel_id.simple())]).unwrap(), + ]) + .sign_with_keys(&creator) + .unwrap(); + ingest_event(&state, &tenant, edit, http_auth(&creator)) + .await + .expect("edit channel"); + let tags = group_state_tags(&state, &tenant, channel_id, 39000).await; + assert!( + tags.iter() + .any(|tag| tag[0] == "name" && tag[1].starts_with("renamed-")), + "edit published: {tags:?}" + ); + assert_eq!( + created_at(&tags), + vec!["1577836800".to_string()], + "{tags:?}" + ); +} + +/// Every group-state kind carries the channel type and the creator, so +/// `#t` and `#P` filters match 39001 and 39002 as well as 39000. +/// Mutation: drop the identity tags from 39001 or 39002 → RED. +#[tokio::test] +#[ignore = "requires Postgres"] +async fn group_state_events_carry_type_then_creator() { + let state = state().await; + let tenant = community(&state, "group-state-identity").await; + let creator = Keys::generate(); + let channel_id = create_channel( + &state, + &tenant, + &creator, + &[&["channel_type", "forum"], &["visibility", "private"]], + ) + .await; + + let creator_hex = creator.public_key().to_hex(); + for kind in [39000, 39001, 39002] { + let tags = group_state_tags(&state, &tenant, channel_id, kind).await; + let t_tags: Vec<&Vec> = tags.iter().filter(|tag| tag[0] == "t").collect(); + assert_eq!( + t_tags.first().map(|tag| tag[1].as_str()), + Some("forum"), + "kind:{kind} must lead with the channel type: {tags:?}" + ); + let creators: Vec<&str> = tags + .iter() + .filter(|tag| tag[0] == "P") + .map(|tag| tag[1].as_str()) + .collect(); + assert_eq!( + creators, + vec![creator_hex.as_str()], + "kind:{kind}: {tags:?}" + ); + } +} + +/// The creator tag names the kind:9007 signer, not the current owner. +/// Mutation: derive `P` from the owner role → RED after the transfer. +#[tokio::test] +#[ignore = "requires Postgres"] +async fn creator_tag_survives_ownership_transfer() { + let state = state().await; + let tenant = community(&state, "group-state-creator").await; + let creator = Keys::generate(); + let successor = Keys::generate(); + let channel_id = create_channel(&state, &tenant, &creator, &[]).await; + + // Add the successor as owner, then the creator leaves. + let channel_hex = channel_id.to_string(); + let add = EventBuilder::new(Kind::Custom(9000), "") + .tags([ + Tag::parse(["h", &channel_hex]).unwrap(), + Tag::parse(["p", &successor.public_key().to_hex()]).unwrap(), + Tag::parse(["role", "owner"]).unwrap(), + ]) + .sign_with_keys(&creator) + .unwrap(); + ingest_event(&state, &tenant, add, http_auth(&creator)) + .await + .expect("add successor"); + let leave = EventBuilder::new(Kind::Custom(9022), "") + .tags([Tag::parse(["h", &channel_hex]).unwrap()]) + .sign_with_keys(&creator) + .unwrap(); + ingest_event(&state, &tenant, leave, http_auth(&creator)) + .await + .expect("creator leaves"); + + let roster = group_state_tags(&state, &tenant, channel_id, 39002).await; + assert!( + !roster + .iter() + .any(|tag| tag[0] == "p" && tag[1] == creator.public_key().to_hex()), + "creator left the roster: {roster:?}" + ); + let creator_hex = creator.public_key().to_hex(); + for kind in [39001, 39002] { + let tags = group_state_tags(&state, &tenant, channel_id, kind).await; + assert!( + tags.iter() + .any(|tag| tag[0] == "P" && tag[1] == creator_hex), + "kind:{kind} keeps the creator: {tags:?}" + ); + } +} diff --git a/crates/buzz-relay/src/handlers/mod.rs b/crates/buzz-relay/src/handlers/mod.rs index e4eea46906..8d5175aa45 100644 --- a/crates/buzz-relay/src/handlers/mod.rs +++ b/crates/buzz-relay/src/handlers/mod.rs @@ -14,6 +14,8 @@ pub mod community_provisioning; pub mod count; /// EVENT handler — WS dispatcher → ingest pipeline → fan-out. pub mod event; +#[cfg(test)] +pub(crate) mod group_state_postgres_tests; /// NIP-IA identity archive request handler (kinds 9035–9036). pub mod identity_archive; /// imeta tag validation helpers. diff --git a/crates/buzz-relay/src/handlers/side_effects.rs b/crates/buzz-relay/src/handlers/side_effects.rs index 8cc7d3abc6..d821a46516 100644 --- a/crates/buzz-relay/src/handlers/side_effects.rs +++ b/crates/buzz-relay/src/handlers/side_effects.rs @@ -1165,9 +1165,27 @@ async fn emit_addressable_discovery_event( Ok(()) } -fn group_members_tags(group_id: &str, members: &[MemberRecord]) -> anyhow::Result> { - let mut tags: Vec = Vec::with_capacity(members.len() + 1); +/// Tags that identify the channel on every relay-signed group-state event: +/// the channel type `t` first, then the creator `P`. +fn group_state_identity_tags( + identity: &buzz_db::channel::ChannelIdentity, +) -> anyhow::Result> { + Ok(buzz_core::channel::group_state_identity_tags( + &identity.channel_type, + &identity.created_by, + )?) +} + +fn group_members_tags( + group_id: &str, + identity: Option<&buzz_db::channel::ChannelIdentity>, + members: &[MemberRecord], +) -> anyhow::Result> { + let mut tags: Vec = Vec::with_capacity(members.len() + 3); tags.push(Tag::parse(["d", group_id])?); + if let Some(identity) = identity { + tags.extend(group_state_identity_tags(identity)?); + } for member in members { let pubkey_hex = hex::encode(&member.pubkey); // NIP-29 convention: ["p", pubkey, relay_url, role]. Empty relay_url @@ -1182,7 +1200,11 @@ async fn store_group_members_event( member_snapshot: &mut buzz_db::channel::LockedMemberSnapshot, ) -> anyhow::Result> { let group_id = member_snapshot.channel_id().to_string(); - let tags = group_members_tags(&group_id, &member_snapshot.members)?; + let tags = group_members_tags( + &group_id, + member_snapshot.identity.as_ref(), + &member_snapshot.members, + )?; let now = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .unwrap_or_default() @@ -1282,8 +1304,14 @@ pub async fn emit_group_discovery_events( } // Buzz channels always require explicit membership tags.push(Tag::parse(["closed"])?); - // Channel type tag so clients can distinguish stream/forum/dm without inference - tags.push(Tag::parse(["t", &channel.channel_type])?); + // Channel type tag so clients can distinguish stream/forum/dm without + // inference, then the creator. The same tags lead 39001 and 39002. + tags.extend(group_state_identity_tags(&channel.identity())?); + // Creation time, so readers can pick the oldest of two channels. The + // event's own created_at changes on every edit. + tags.push(buzz_core::channel::channel_created_at_tag( + channel.created_at.timestamp(), + )?); // Optional topic / purpose for richer client UX if let Some(ref topic) = channel.topic { if !topic.is_empty() { @@ -1319,6 +1347,7 @@ pub async fn emit_group_discovery_events( { let mut tags: Vec = vec![Tag::parse(["d", &group_id])?]; + tags.extend(group_state_identity_tags(&channel.identity())?); for m in members .iter() .filter(|m| m.role == "owner" || m.role == "admin") @@ -3991,8 +4020,19 @@ mod tests { }) .collect(); - let tags = group_members_tags(&channel_id.to_string(), &members).expect("build tags"); - assert_eq!(tags.len(), 1_502, "d tag plus every member p tag"); + let identity = buzz_db::channel::ChannelIdentity { + channel_type: "forum".to_string(), + created_by: vec![0xab; 32], + }; + let tags = group_members_tags(&channel_id.to_string(), Some(&identity), &members) + .expect("build tags"); + assert_eq!( + tags.len(), + 1_504, + "d, type and creator tags plus every member p tag" + ); + assert_eq!(tags[1].as_slice(), ["t", "forum"]); + assert_eq!(tags[2].as_slice(), ["P".to_string(), "ab".repeat(32)]); let late_pubkey = hex::encode(&members[1_500].pubkey); assert!(tags.iter().any(|tag| { diff --git a/docs/nips/NIP-CL.md b/docs/nips/NIP-CL.md new file mode 100644 index 0000000000..f533d0b4e3 --- /dev/null +++ b/docs/nips/NIP-CL.md @@ -0,0 +1,101 @@ +NIP-CL +====== + +Channel Identity and Discovery +------------------------------ + +`draft` `optional` `relay` + +This NIP adds identity facts to the relay-signed NIP-29 channel-state events +(kinds 39000–39002). With these facts, a client can find channels with +ordinary NIP-01 filters. It needs no new filter key and no relay-specific API. + +## Motivation + +Plugins and agents often create a channel for their own use and must find it +again later, for example "the config channel that this agent created". With +plain NIP-29, a client cannot: + +- tell who created a channel; +- read the channel type from any event except kind:39000; or +- tell which of two channels is older. The `created_at` of a kind:39000 event + is the time of the last edit, not the time of creation. + +## Channel identity tags + +On every channel-state event that the relay signs, the relay MUST put these +tags in this order, before any other `t` or `P` tag: + +1. `["t", ]`, for example `stream`, `forum`, `dm`, `workflow`. +2. `["P", ]`. + +kind:39000 also carries `["created_at", ]`. + +Example kind:39000 (signing fields and unrelated tags omitted): + +```json +{ + "kind": 39000, + "pubkey": "", + "created_at": 1760025600, + "tags": [ + ["d", "9b353519-f4fe-4757-aef4-bec6cc0ae54c"], + ["name", "agent-config"], + ["t", "stream"], + ["P", ""], + ["created_at", "1759939200"] + ] +} +``` + +| Tag | Kinds | Meaning | +| --- | --- | --- | +| first `t` | 39000–39002 | Channel type. | +| `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. | + +**Channel type.** Clients MUST read the channel type from the first `t` tag. +A relay can add other `t` tags after it. + +**Creator.** The relay writes `P` from its own record of the kind:9007 that +created the channel. No client can set it. It does not change when ownership +moves to another member or when the channel is edited. + +Uppercase `P` follows NIP-22, NIP-34 and NIP-72, where `P` names the author of +the root object. On these kinds, lowercase `p` already lists members, admins +and DM participants. A filter matches tag values, not their roles, so `#p` +would mix creators with members. + +**Creation time.** The `created_at` tag does not change when the channel is +edited. Use it, not the event's `created_at`, to compare the age of channels. + +**Older events.** A relay adds these tags only when it publishes a +channel-state event. An event that a relay version without this NIP published +can lack them, including one published during an upgrade. Such an event stays +until the channel next changes, for example on an edit or a membership +change. A client MUST treat a missing tag as unknown. A missing `P` does not +mean that a key did not create the channel. + +## Finding channels + +Use ordinary NIP-01 filters on the channel-state kinds. + +| To find | Filter | +| --- | --- | +| Channels that a key created | `{"kinds": [39000], "#P": [""]}` | +| Channels of one type | `{"kinds": [39000], "#t": [""]}` | + +A `#P` filter does not return a channel whose events lack `P` (see +**Older events**). So it can miss channels that a key created before the relay +supported this NIP. + +## Trust + +A client that relies on these tags MUST check that the relay's key signed the +event, as for all NIP-29 group-state events. + +## Relation to other NIPs + +- NIP-29: the group-state events that this NIP adds tags to. +- NIP-01: the filters that find channels. +- NIP-22, NIP-34 and NIP-72: the convention for uppercase `P`.