mirror of
https://github.com/block/buzz.git
synced 2026-10-27 04:30:52 +00:00
feat(relay): publish channel type and creator on every channel-state event (#8174)
🤖 ## 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", <unix seconds>]`, 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 <loganj@squareup.com> Co-authored-by: Larry <627498bd4bd1f281a16431e3c6cce3b5c25b6692798c78672298aefbf2f8f8b5@buzz.block.builderlab.xyz>
This commit is contained in:
25
NOSTR.md
25
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", <channel_type>]`, `["P", <creator>]` and `["created_at", <unix seconds>]` 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=<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>`, `created_at=<unix seconds>` (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.
|
||||
|
||||
kind:39000 also carries `["created_at", <unix seconds>]`: 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:
|
||||
|
||||
@@ -739,6 +739,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::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<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 +880,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])?);
|
||||
|
||||
@@ -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", <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)])?,
|
||||
])
|
||||
}
|
||||
|
||||
/// 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, nostr::event::tag::Error> {
|
||||
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<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() {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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<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,
|
||||
|
||||
240
crates/buzz-relay/src/handlers/group_state_postgres_tests.rs
Normal file
240
crates/buzz-relay/src/handlers/group_state_postgres_tests.rs
Normal file
@@ -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<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()
|
||||
}
|
||||
|
||||
/// 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<String>]| -> Vec<String> {
|
||||
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<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:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -1165,9 +1165,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
|
||||
@@ -1182,7 +1200,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()
|
||||
@@ -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<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")
|
||||
@@ -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| {
|
||||
|
||||
101
docs/nips/NIP-CL.md
Normal file
101
docs/nips/NIP-CL.md
Normal file
@@ -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", <channel type>]`, for example `stream`, `forum`, `dm`, `workflow`.
|
||||
2. `["P", <creator public key, hex>]`.
|
||||
|
||||
kind:39000 also carries `["created_at", <unix seconds>]`.
|
||||
|
||||
Example kind:39000 (signing fields and unrelated tags omitted):
|
||||
|
||||
```json
|
||||
{
|
||||
"kind": 39000,
|
||||
"pubkey": "<relay public key>",
|
||||
"created_at": 1760025600,
|
||||
"tags": [
|
||||
["d", "9b353519-f4fe-4757-aef4-bec6cc0ae54c"],
|
||||
["name", "agent-config"],
|
||||
["t", "stream"],
|
||||
["P", "<creator public key>"],
|
||||
["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": ["<creator>"]}` |
|
||||
| Channels of one type | `{"kinds": [39000], "#t": ["<type>"]}` |
|
||||
|
||||
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`.
|
||||
Reference in New Issue
Block a user