Skip to content
Merged
39 changes: 38 additions & 1 deletion crates/sprout-core/src/kind.rs
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,10 @@ pub const KIND_REACTION: u32 = 7;
pub const KIND_GIFT_WRAP: u32 = 1059;
/// NIP-94: File metadata attachment.
pub const KIND_FILE_METADATA: u32 = 1063;
/// NIP-23: Long-form content (articles, blog posts, RFCs).
/// Parameterized replaceable (NIP-33, 30000–39999 range) — keyed by `(pubkey, kind, d_tag)`.
/// Stored globally (channel_id = NULL); author-owned, not channel-scoped.
pub const KIND_LONG_FORM: u32 = 30023;
/// NIP-42 auth event — never stored (carries bearer tokens).
pub const KIND_AUTH: u32 = 22242;

Expand Down Expand Up @@ -62,6 +66,11 @@ pub const KIND_NIP29_GROUP_MEMBERS: u32 = 39002;
/// NIP-29: Addressable group roles definition.
pub const KIND_NIP29_GROUP_ROLES: u32 = 39003;

/// Lower bound of the NIP-33 parameterized replaceable range (30000–39999).
pub const PARAM_REPLACEABLE_KIND_MIN: u32 = 30000;
/// Upper bound of the NIP-33 parameterized replaceable range (30000–39999).
pub const PARAM_REPLACEABLE_KIND_MAX: u32 = 39999;

/// Lower bound of the ephemeral event range (20000–29999). Never stored.
pub const EPHEMERAL_KIND_MIN: u32 = 20000;
/// Upper bound of the ephemeral event range (20000–29999). Never stored.
Expand Down Expand Up @@ -271,6 +280,7 @@ pub const ALL_KINDS: &[u32] = &[
KIND_SUBSCRIPTION_RESUMED,
KIND_MEMBER_ADDED_NOTIFICATION,
KIND_MEMBER_REMOVED_NOTIFICATION,
KIND_LONG_FORM,
KIND_FORUM_POST,
KIND_FORUM_VOTE,
KIND_FORUM_COMMENT,
Expand Down Expand Up @@ -308,11 +318,18 @@ pub const fn is_ephemeral(kind: u32) -> bool {

/// Returns `true` if `kind` is replaceable (NIP-01: kinds 0, 3, 41, 10000–19999).
/// NIP-33 parameterized-replaceable kinds (30000–39999) use a different replacement
/// key (includes `d`-tag) and are handled separately via `replace_addressable_event`.
/// key (includes `d`-tag) and are handled separately via `replace_parameterized_event`.
pub const fn is_replaceable(kind: u32) -> bool {
matches!(kind, 0 | 3 | KIND_CHANNEL_METADATA | 10000..=19999)
}

/// Returns `true` if `kind` is in the NIP-33 parameterized replaceable range (30000–39999).
///
/// These events are keyed by `(pubkey, kind, d_tag)` — the latest `created_at` wins.
pub const fn is_parameterized_replaceable(kind: u32) -> bool {
kind >= PARAM_REPLACEABLE_KIND_MIN && kind <= PARAM_REPLACEABLE_KIND_MAX
}

/// Returns `true` if `kind` is a workflow execution event (46001–46012).
/// These must not trigger workflows (prevents infinite loops).
pub const fn is_workflow_execution_kind(kind: u32) -> bool {
Expand Down Expand Up @@ -348,4 +365,24 @@ mod tests {
assert!(seen.insert(k), "duplicate kind value: {k}");
}
}

#[test]
fn parameterized_replaceable_range() {
assert!(!is_parameterized_replaceable(29999));
assert!(is_parameterized_replaceable(30000));
assert!(is_parameterized_replaceable(30023)); // NIP-23 long-form
assert!(is_parameterized_replaceable(39000)); // NIP-29 group metadata
assert!(is_parameterized_replaceable(39999));
assert!(!is_parameterized_replaceable(40000));
}

#[test]
fn replaceable_and_parameterized_are_disjoint() {
for kind in 0..=65535u32 {
assert!(
!(is_replaceable(kind) && is_parameterized_replaceable(kind)),
"kind {kind} is both replaceable and parameterized replaceable"
);
}
}
}
155 changes: 150 additions & 5 deletions crates/sprout-db/src/event.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ use nostr::Event;
use sqlx::{PgPool, QueryBuilder, Row};
use uuid::Uuid;

use sprout_core::kind::{event_kind_i32, is_ephemeral, KIND_AUTH};
use sprout_core::kind::{event_kind_i32, is_ephemeral, is_parameterized_replaceable, KIND_AUTH};
use sprout_core::StoredEvent;

use crate::error::{DbError, Result};
Expand All @@ -34,6 +34,38 @@ pub struct EventQuery {
/// Restrict to events with a `p` tag mentioning this hex pubkey.
/// Joins against `event_mentions` table (indexed).
pub p_tag_hex: Option<String>,
/// Restrict to events with this exact `d_tag` value (NIP-33).
/// Pushed into SQL via the `idx_events_parameterized` index.
pub d_tag: Option<String>,
}

/// Maximum length for a `d_tag` value (bytes). NIP-33 d-tags are short identifiers;
/// anything beyond this is either a bug or abuse.
pub const D_TAG_MAX_LEN: usize = 1024;

/// Extract the `d_tag` value for storage.
///
/// For NIP-33 parameterized replaceable events (kind 30000–39999): returns the first
/// `d` tag's value, or `""` if no `d` tag is present (per NIP-33 spec).
/// For all other events: returns `None` (column stays NULL).
pub fn extract_d_tag(event: &Event) -> Option<String> {
let kind_u32 = event.kind.as_u16() as u32;
if !is_parameterized_replaceable(kind_u32) {
return None;
}
let val = event
.tags
.iter()
.find_map(|tag| {
let parts = tag.as_slice();
if parts.len() >= 2 && parts[0] == "d" {
Some(parts[1].to_string())
} else {
None
}
})
.unwrap_or_default(); // Missing d tag → empty string per NIP-33
Some(val)
}

/// Insert a Nostr event. Rejects AUTH and ephemeral kinds.
Expand Down Expand Up @@ -64,10 +96,11 @@ pub async fn insert_event(
let created_at = DateTime::from_timestamp(created_at_secs, 0)
.ok_or(DbError::InvalidTimestamp(created_at_secs))?;
let received_at = Utc::now();
let d_tag = extract_d_tag(event);
let result = sqlx::query(
r#"
INSERT INTO events (id, pubkey, created_at, kind, tags, content, sig, received_at, channel_id)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
INSERT INTO events (id, pubkey, created_at, kind, tags, content, sig, received_at, channel_id, d_tag)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
ON CONFLICT DO NOTHING
"#,
)
Expand All @@ -80,6 +113,7 @@ pub async fn insert_event(
.bind(sig_bytes.as_slice())
.bind(received_at)
.bind(channel_id)
.bind(d_tag.as_deref())
.execute(pool)
.await?;

Expand Down Expand Up @@ -152,6 +186,11 @@ pub async fn query_events(pool: &PgPool, q: &EventQuery) -> Result<Vec<StoredEve
.push_bind(u);
}

if let Some(ref d) = q.d_tag {
qb.push(format!(" AND {col_prefix}d_tag = "))
.push_bind(d.clone());
}

qb.push(format!(" ORDER BY {col_prefix}created_at DESC LIMIT "))
.push_bind(limit_val);
qb.push(" OFFSET ").push_bind(offset_val);
Expand Down Expand Up @@ -457,13 +496,14 @@ pub async fn insert_event_with_thread_metadata(
let created_at = DateTime::from_timestamp(created_at_secs, 0)
.ok_or(DbError::InvalidTimestamp(created_at_secs))?;
let received_at = Utc::now();
let d_tag = extract_d_tag(event);
let mut tx = pool.begin().await?;

// ── Insert event ──────────────────────────────────────────────────────────
let result = sqlx::query(
r#"
INSERT INTO events (id, pubkey, created_at, kind, tags, content, sig, received_at, channel_id)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
INSERT INTO events (id, pubkey, created_at, kind, tags, content, sig, received_at, channel_id, d_tag)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
ON CONFLICT DO NOTHING
"#,
)
Expand All @@ -476,6 +516,7 @@ pub async fn insert_event_with_thread_metadata(
.bind(sig_bytes.as_slice())
.bind(received_at)
.bind(channel_id)
.bind(d_tag.as_deref())
.execute(&mut *tx)
.await?;

Expand Down Expand Up @@ -594,3 +635,107 @@ pub async fn insert_event_with_thread_metadata(
was_inserted,
))
}

#[cfg(test)]
mod tests {
use super::*;
use nostr::{EventBuilder, Keys, Kind, Tag};

fn make_event_with_kind_and_tags(kind: u16, tags: Vec<Tag>) -> nostr::Event {
let keys = Keys::generate();
EventBuilder::new(Kind::Custom(kind), "test", tags)
.sign_with_keys(&keys)
.expect("sign")
}

#[test]
fn extract_d_tag_from_nip33_event() {
let event = make_event_with_kind_and_tags(
30023,
vec![Tag::parse(&["d", "my-article-slug"]).unwrap()],
);
assert_eq!(extract_d_tag(&event), Some("my-article-slug".to_string()));
}

#[test]
fn extract_d_tag_first_d_wins() {
let event = make_event_with_kind_and_tags(
30023,
vec![
Tag::parse(&["d", "first"]).unwrap(),
Tag::parse(&["d", "second"]).unwrap(),
],
);
assert_eq!(extract_d_tag(&event), Some("first".to_string()));
}

#[test]
fn extract_d_tag_missing_becomes_empty_string() {
// NIP-33: "if there is no d tag, the d tag is considered to be ''"
let event =
make_event_with_kind_and_tags(30023, vec![Tag::parse(&["p", "abc123"]).unwrap()]);
assert_eq!(extract_d_tag(&event), Some(String::new()));
}

#[test]
fn extract_d_tag_empty_value_preserved() {
let event = make_event_with_kind_and_tags(30023, vec![Tag::parse(&["d", ""]).unwrap()]);
assert_eq!(extract_d_tag(&event), Some(String::new()));
}

#[test]
fn extract_d_tag_non_nip33_returns_none() {
// kind:1 (text note) — not parameterized replaceable
let event = make_event_with_kind_and_tags(
1,
vec![Tag::parse(&["d", "should-be-ignored"]).unwrap()],
);
assert_eq!(extract_d_tag(&event), None);
}

#[test]
fn extract_d_tag_nip29_group_metadata() {
// kind:39000 is in the 30000–39999 range — d_tag should be extracted
let event =
make_event_with_kind_and_tags(39000, vec![Tag::parse(&["d", "group-id"]).unwrap()]);
assert_eq!(extract_d_tag(&event), Some("group-id".to_string()));
}

#[test]
fn extract_d_tag_boundary_kinds() {
// kind:29999 — just below range
let below = make_event_with_kind_and_tags(29999, vec![Tag::parse(&["d", "val"]).unwrap()]);
assert_eq!(extract_d_tag(&below), None);

// kind:30000 — lower bound
let lower = make_event_with_kind_and_tags(30000, vec![Tag::parse(&["d", "val"]).unwrap()]);
assert_eq!(extract_d_tag(&lower), Some("val".to_string()));

// kind:39999 — upper bound
let upper = make_event_with_kind_and_tags(39999, vec![Tag::parse(&["d", "val"]).unwrap()]);
assert_eq!(extract_d_tag(&upper), Some("val".to_string()));

// kind:40000 — just above range
let above = make_event_with_kind_and_tags(40000, vec![Tag::parse(&["d", "val"]).unwrap()]);
assert_eq!(extract_d_tag(&above), None);
}

#[test]
fn extract_d_tag_single_element_d_tag_ignored() {
// A d tag with only one element (no value) should not match — parts.len() < 2
let event = make_event_with_kind_and_tags(30023, vec![Tag::parse(&["d"]).unwrap()]);
// No d tag with a value → empty string per NIP-33
assert_eq!(extract_d_tag(&event), Some(String::new()));
}

#[test]
fn extract_d_tag_preserves_full_value() {
// extract_d_tag returns the full value — length enforcement is at the ingest layer.
let long_val = "x".repeat(2048);
let event =
make_event_with_kind_and_tags(30023, vec![Tag::parse(&["d", &long_val]).unwrap()]);
let result = extract_d_tag(&event).unwrap();
assert_eq!(result.len(), 2048);
assert_eq!(result, long_val);
}
}
Loading
Loading