feat(relay): publish channel type and creator on every group-state event

Kinds 39000, 39001 and 39002 now carry ["t", <channel_type>] first and then ["P", <creator hex>]. Before this change only 39000 had the type tag and no event named the creator. The creator comes from channels.created_by (the kind:9007 signer) and does not change when ownership moves. The kind:39002 roster reads the type and creator on its locked snapshot connection. buzz-admin reconcile writes the same tags.

Signed-off-by: Logan Johnson <loganj@squareup.com>
Co-authored-by: Larry <627498bd4bd1f281a16431e3c6cce3b5c25b6692798c78672298aefbf2f8f8b5@buzz.block.builderlab.xyz>
This commit is contained in:
Logan Johnson
2026-10-08 12:16:30 -04:00
parent 868e2f8f32
commit 6d86c85bf3
8 changed files with 331 additions and 16 deletions

View File

@@ -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", <channel_type>]` and `["P", <creator>]` 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. |
| **Typing indicators (kind:20002)** | ✅ | Ephemeral, not stored; published via Redis pub/sub (multi-node capable unlike presence fan-out) |
@@ -106,9 +106,16 @@ All discovery events include a `d` tag set to the channel UUID (NIP-29 addressab
| Kind | Tags | Content |
|------|------|---------|
| **39000** | `d=<uuid>`, `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=<uuid>`, `p` tags with role label (`owner`, `admin`) | Admin list |
| **39002** | `d=<uuid>`, `p` tags for all members | Member list |
| **39000** | `d=<uuid>`, `name`, `closed`, `t=<channel_type>`, `P=<creator>` (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=<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 |
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.
Events are stored **channel-scoped** so access control applies — private channel member lists are
only visible to members. Discover groups via historical REQ:

View File

@@ -739,6 +739,7 @@ async fn reconcile_channels(
channel_arg: Option<String>,
relay_key_arg: Option<String>,
) -> Result<()> {
use buzz_core::channel::group_state_identity_tags;
use buzz_core::kind::KIND_NIP29_GROUP_ADMINS;
use buzz_db::event::EventQuery;
@@ -839,7 +840,10 @@ 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,
)?);
let event = EventBuilder::new(Kind::Custom(39000), "")
.tags(tags)
@@ -852,6 +856,10 @@ async fn reconcile_channels(
// kind:39001 — admins
{
let mut tags: Vec<Tag> = 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 +879,10 @@ async fn reconcile_channels(
// kind:39002 — members
{
let mut tags: Vec<Tag> = 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])?);

View File

@@ -17,6 +17,28 @@ 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–39003), in this order:
///
/// 1. `["t", <channel_type>]`. It comes first, so a client that reads the
/// first `t` tag gets the channel type.
/// 2. `["P", <creator hex>]`: 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:[<key>]}` select by these tags.
pub fn group_state_identity_tags(
channel_type: &str,
created_by: &[u8],
) -> Result<Vec<nostr::Tag>, nostr::event::tag::Error> {
Ok(vec![
nostr::Tag::parse(["t", channel_type])?,
nostr::Tag::parse(["P", &hex::encode(created_by)])?,
])
}
/// Whether a channel is publicly visible or invite-only.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ChannelVisibility {
@@ -180,7 +202,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<Vec<String>> = 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() {

View File

@@ -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<DateTime<Utc>>,
}
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(

View File

@@ -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–39003) 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<u8>,
}
/// 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<MemberRecord>,
/// 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<ChannelIdentity>,
channel_id: Uuid,
relay_pubkey: Vec<u8>,
tx: AdmittedTx,
@@ -477,8 +491,20 @@ pub async fn lock_member_snapshot(
.into_iter()
.map(row_to_member_record)
.collect::<Result<Vec<_>>>()?;
let identity = sqlx::query_as::<_, (String, Vec<u8>)>(
"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,

View File

@@ -0,0 +1,184 @@
//! 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<AppState> {
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<AppState>,
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<Vec<String>> {
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()
}
/// 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<String>> = 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:?}"
);
}
}

View File

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

View File

@@ -1114,9 +1114,27 @@ async fn emit_addressable_discovery_event(
Ok(())
}
fn group_members_tags(group_id: &str, members: &[MemberRecord]) -> anyhow::Result<Vec<Tag>> {
let mut tags: Vec<Tag> = 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<Vec<Tag>> {
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<Vec<Tag>> {
let mut tags: Vec<Tag> = 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
@@ -1131,7 +1149,11 @@ async fn store_group_members_event(
member_snapshot: &mut buzz_db::channel::LockedMemberSnapshot,
) -> anyhow::Result<Option<buzz_core::StoredEvent>> {
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()
@@ -1231,8 +1253,9 @@ 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())?);
// Optional topic / purpose for richer client UX
if let Some(ref topic) = channel.topic {
if !topic.is_empty() {
@@ -1268,6 +1291,7 @@ pub async fn emit_group_discovery_events(
{
let mut tags: Vec<Tag> = 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")
@@ -3940,8 +3964,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| {