Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions 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 @@ -276,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
86 changes: 72 additions & 14 deletions crates/sprout-relay/src/handlers/ingest.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,13 +14,13 @@ use sprout_auth::Scope;
use sprout_core::kind::{
event_kind_u32, is_parameterized_replaceable, KIND_AUTH, KIND_CANVAS, KIND_CONTACT_LIST,
KIND_DELETION, KIND_FORUM_COMMENT, KIND_FORUM_POST, KIND_FORUM_VOTE, KIND_GIFT_WRAP,
KIND_MEMBER_ADDED_NOTIFICATION, KIND_MEMBER_REMOVED_NOTIFICATION, KIND_NIP29_CREATE_GROUP,
KIND_NIP29_DELETE_EVENT, KIND_NIP29_DELETE_GROUP, KIND_NIP29_EDIT_METADATA,
KIND_NIP29_JOIN_REQUEST, KIND_NIP29_LEAVE_REQUEST, KIND_NIP29_PUT_USER, KIND_NIP29_REMOVE_USER,
KIND_PRESENCE_UPDATE, KIND_PROFILE, KIND_REACTION, KIND_STREAM_MESSAGE,
KIND_STREAM_MESSAGE_BOOKMARKED, KIND_STREAM_MESSAGE_DIFF, KIND_STREAM_MESSAGE_EDIT,
KIND_STREAM_MESSAGE_PINNED, KIND_STREAM_MESSAGE_SCHEDULED, KIND_STREAM_MESSAGE_V2,
KIND_STREAM_REMINDER, KIND_TEXT_NOTE,
KIND_LONG_FORM, KIND_MEMBER_ADDED_NOTIFICATION, KIND_MEMBER_REMOVED_NOTIFICATION,
KIND_NIP29_CREATE_GROUP, KIND_NIP29_DELETE_EVENT, KIND_NIP29_DELETE_GROUP,
KIND_NIP29_EDIT_METADATA, KIND_NIP29_JOIN_REQUEST, KIND_NIP29_LEAVE_REQUEST,
KIND_NIP29_PUT_USER, KIND_NIP29_REMOVE_USER, KIND_PRESENCE_UPDATE, KIND_PROFILE, KIND_REACTION,
KIND_STREAM_MESSAGE, KIND_STREAM_MESSAGE_BOOKMARKED, KIND_STREAM_MESSAGE_DIFF,
KIND_STREAM_MESSAGE_EDIT, KIND_STREAM_MESSAGE_PINNED, KIND_STREAM_MESSAGE_SCHEDULED,
KIND_STREAM_MESSAGE_V2, KIND_STREAM_REMINDER, KIND_TEXT_NOTE,
};
use sprout_core::verification::verify_event;

Expand Down Expand Up @@ -142,7 +142,7 @@ pub enum IngestError {
fn required_scope_for_kind(kind: u32, event: &Event) -> Result<Scope, &'static str> {
match kind {
KIND_PROFILE => Ok(Scope::UsersWrite),
KIND_TEXT_NOTE => Ok(Scope::MessagesWrite),
KIND_TEXT_NOTE | KIND_LONG_FORM => Ok(Scope::MessagesWrite),
KIND_CONTACT_LIST => Ok(Scope::UsersWrite),
KIND_DELETION
| KIND_REACTION
Expand Down Expand Up @@ -242,6 +242,24 @@ pub(crate) async fn derive_reaction_channel(
}
}

/// Kinds that are always global (`channel_id = NULL`).
///
/// If a client includes a stray `h` tag on these kinds, the ingest pipeline
/// sets `channel_id = None` — these events are never channel-scoped.
///
/// Note: the raw `h` tag remains on the stored event (Nostr events are signed,
/// so tags cannot be stripped without invalidating the signature). The read-path
/// filter matching in `filter.rs` treats explicit `h` tags as authoritative,
/// which means a stray `h` tag can still match `#h` queries. This is a known
/// limitation affecting all global-only kinds and should be addressed in the
/// filter layer as a follow-up.
pub(crate) fn is_global_only_kind(kind: u32) -> bool {
matches!(
kind,
KIND_PROFILE | KIND_TEXT_NOTE | KIND_CONTACT_LIST | KIND_LONG_FORM
)
}

/// Kinds that require an `h` tag for channel scoping.
pub(crate) fn requires_h_channel_scope(kind: u32) -> bool {
matches!(
Expand Down Expand Up @@ -844,9 +862,7 @@ pub async fn ingest_event(
};

// ── 5b. Global-only kinds ignore h-tags ─────────────────────────────
// kind:0 (profile), kind:1 (text note), kind:3 (contact list) are always global.
// If a client includes a stray h-tag, ignore it — these kinds are never channel-scoped.
if matches!(kind_u32, KIND_PROFILE | KIND_TEXT_NOTE | KIND_CONTACT_LIST) {
if is_global_only_kind(kind_u32) {
channel_id = None;
}

Expand Down Expand Up @@ -1314,8 +1330,8 @@ pub async fn ingest_event(
mod tests {
use super::*;
use sprout_core::kind::{
KIND_CANVAS, KIND_FORUM_COMMENT, KIND_FORUM_POST, KIND_FORUM_VOTE, KIND_PRESENCE_UPDATE,
KIND_STREAM_MESSAGE, KIND_STREAM_MESSAGE_DIFF,
KIND_CANVAS, KIND_FORUM_COMMENT, KIND_FORUM_POST, KIND_FORUM_VOTE, KIND_LONG_FORM,
KIND_PRESENCE_UPDATE, KIND_STREAM_MESSAGE, KIND_STREAM_MESSAGE_DIFF,
};

#[test]
Expand Down Expand Up @@ -1371,13 +1387,54 @@ mod tests {
assert!(!requires_h_channel_scope(KIND_REACTION));
}

#[test]
fn long_form_is_in_scope_allowlist() {
let dummy = make_dummy_event();
assert!(
required_scope_for_kind(KIND_LONG_FORM, &dummy).is_ok(),
"KIND_LONG_FORM (30023) should be accepted"
);
}

#[test]
fn long_form_requires_messages_write_scope() {
let dummy = make_dummy_event();
assert_eq!(
required_scope_for_kind(KIND_LONG_FORM, &dummy).unwrap(),
Scope::MessagesWrite,
);
}

#[test]
fn long_form_does_not_require_h_tag() {
// kind:30023 is global (author-owned, not channel-scoped)
assert!(!requires_h_channel_scope(KIND_LONG_FORM));
}

#[test]
fn long_form_is_global_only() {
// kind:30023 is always global — ingest nulls channel_id even if an h-tag is present
assert!(is_global_only_kind(KIND_LONG_FORM));
}

#[test]
fn global_only_and_channel_scoped_are_disjoint() {
// A kind cannot be both global-only and channel-scoped
for kind in 0..=65535u32 {
assert!(
!(is_global_only_kind(kind) && requires_h_channel_scope(kind)),
"kind {kind} is both global-only and channel-scoped"
);
}
}

#[test]
fn ephemeral_kinds_not_in_scope_allowlist() {
assert!(required_scope_for_kind(KIND_PRESENCE_UPDATE, &make_dummy_event()).is_err());
}

#[test]
fn per_kind_scope_allowlist_covers_all_18_migrated_kinds() {
fn per_kind_scope_allowlist_covers_all_migrated_kinds() {
let dummy = make_dummy_event();
let migrated = [
KIND_PROFILE,
Expand All @@ -1398,6 +1455,7 @@ mod tests {
KIND_FORUM_POST,
KIND_FORUM_VOTE,
KIND_FORUM_COMMENT,
KIND_LONG_FORM,
];
for kind in migrated {
assert!(
Expand Down
37 changes: 36 additions & 1 deletion crates/sprout-relay/src/nip11.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,11 @@ use serde::{Deserialize, Serialize};

use crate::connection::MAX_FRAME_BYTES;

/// NIPs supported by this relay, advertised in the NIP-11 document.
/// Kept as a module-level constant so tests can verify it without constructing
/// a full `Config` (which reads env vars and races with config.rs tests).
pub(crate) const SUPPORTED_NIPS: &[u32] = &[1, 2, 10, 11, 16, 17, 23, 25, 29, 33, 42, 50];

/// Relay information document served at `GET /` with `Accept: application/nostr+json`.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RelayInfo {
Expand Down Expand Up @@ -56,7 +61,7 @@ impl RelayInfo {
description: "Sprout — private team communication relay".to_string(),
pubkey: None,
contact: None,
supported_nips: vec![1, 2, 10, 11, 16, 17, 25, 29, 33, 42, 50],
supported_nips: SUPPORTED_NIPS.to_vec(),
software: "https://github.com/sprout-rs/sprout".to_string(),
version: env!("CARGO_PKG_VERSION").to_string(),
limitation: Some(RelayLimitation {
Expand All @@ -80,3 +85,33 @@ pub async fn relay_info_handler(
) -> axum::response::Json<RelayInfo> {
axum::response::Json(RelayInfo::from_config(&state.config))
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn supported_nips_includes_nip23_and_nip33() {
// Tests the production SUPPORTED_NIPS constant directly — no Config::from_env()
// needed, avoiding the env-var race with config.rs tests.
assert!(
SUPPORTED_NIPS.contains(&23),
"NIP-23 (long-form content) must be advertised"
);
assert!(
SUPPORTED_NIPS.contains(&33),
"NIP-33 (parameterized replaceable) must be advertised"
);
}

#[test]
fn supported_nips_are_sorted() {
let mut sorted = SUPPORTED_NIPS.to_vec();
sorted.sort();
assert_eq!(
SUPPORTED_NIPS,
&sorted[..],
"supported_nips should be sorted"
);
}
}
Loading
Loading