mirror of
https://github.com/block/buzz.git
synced 2026-10-28 13:01:10 +00:00
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", <unix seconds>] from channels.created_at. Signed-off-by: Logan Johnson <loganj@squareup.com> Co-authored-by: Larry <627498bd4bd1f281a16431e3c6cce3b5c25b6692798c78672298aefbf2f8f8b5@buzz.block.builderlab.xyz>
This commit is contained in:
8
NOSTR.md
8
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", <channel_type>]` and `["P", <creator>]` 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", <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) |
|
||||
@@ -106,7 +106,7 @@ All discovery events include a `d` tag set to the channel UUID (NIP-29 addressab
|
||||
|
||||
| Kind | Tags | Content |
|
||||
|------|------|---------|
|
||||
| **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. |
|
||||
| **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 |
|
||||
|
||||
@@ -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", <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,7 +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::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)
|
||||
|
||||
@@ -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, 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 {
|
||||
|
||||
@@ -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<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.
|
||||
|
||||
@@ -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() {
|
||||
|
||||
Reference in New Issue
Block a user