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() {