From 099f65681e25e252fa79fa276151b004a84322dc Mon Sep 17 00:00:00 2001 From: Logan Johnson Date: Thu, 8 Oct 2026 14:53:19 -0400 Subject: [PATCH] feat(relay): publish the channel creation time on kind:39000 kind:39000 is replaceable, so its own created_at is the time of the last edit. Readers that must pick the oldest of two channels need the time the channel was created. The relay and the admin backfill now write ["created_at", ] from channels.created_at. Signed-off-by: Logan Johnson Co-authored-by: Larry <627498bd4bd1f281a16431e3c6cce3b5c25b6692798c78672298aefbf2f8f8b5@buzz.block.builderlab.xyz> --- NOSTR.md | 8 ++- crates/buzz-admin/src/main.rs | 3 +- crates/buzz-core/src/channel.rs | 11 ++++ .../handlers/group_state_postgres_tests.rs | 56 +++++++++++++++++++ .../buzz-relay/src/handlers/side_effects.rs | 5 ++ 5 files changed, 80 insertions(+), 3 deletions(-) diff --git a/NOSTR.md b/NOSTR.md index cc3bbac001..228bbcb747 100644 --- a/NOSTR.md +++ b/NOSTR.md @@ -57,7 +57,7 @@ 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`, `["t", ]` and `["P", ]` tags; `about` only if description non-empty; `private` if applicable; `hidden` for DM channels | +| **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) | @@ -106,7 +106,7 @@ All discovery events include a `d` tag set to the channel UUID (NIP-29 addressab | Kind | Tags | Content | |------|------|---------| -| **39000** | `d=`, `name`, `closed`, `t=`, `P=` (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. | +| **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 | @@ -117,6 +117,10 @@ from the database, and it does not change when ownership moves. Uppercase 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 78f7124260..da337acdc6 100644 --- a/crates/buzz-admin/src/main.rs +++ b/crates/buzz-admin/src/main.rs @@ -739,7 +739,7 @@ async fn reconcile_channels( channel_arg: Option, relay_key_arg: Option, ) -> Result<()> { - use buzz_core::channel::group_state_identity_tags; + 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; @@ -844,6 +844,7 @@ async fn reconcile_channels( &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) diff --git a/crates/buzz-core/src/channel.rs b/crates/buzz-core/src/channel.rs index d42640a5ef..055d32faaf 100644 --- a/crates/buzz-core/src/channel.rs +++ b/crates/buzz-core/src/channel.rs @@ -39,6 +39,17 @@ pub fn group_state_identity_tags( ]) } +/// 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 { diff --git a/crates/buzz-relay/src/handlers/group_state_postgres_tests.rs b/crates/buzz-relay/src/handlers/group_state_postgres_tests.rs index 88e26e6e75..2df126d097 100644 --- a/crates/buzz-relay/src/handlers/group_state_postgres_tests.rs +++ b/crates/buzz-relay/src/handlers/group_state_postgres_tests.rs @@ -94,6 +94,62 @@ pub(crate) async fn group_state_tags( .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. diff --git a/crates/buzz-relay/src/handlers/side_effects.rs b/crates/buzz-relay/src/handlers/side_effects.rs index 20cd2a098e..8db4847080 100644 --- a/crates/buzz-relay/src/handlers/side_effects.rs +++ b/crates/buzz-relay/src/handlers/side_effects.rs @@ -1256,6 +1256,11 @@ pub async fn emit_group_discovery_events( // 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() {