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 @@ -25,6 +25,10 @@ pub const KIND_FILE_METADATA: u32 = 1063;
/// 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-38: User status (general, music, or custom d-tag).
/// Parameterized replaceable (NIP-33, 30000–39999 range) — keyed by `(pubkey, kind, d_tag)`.
/// Stored globally (channel_id = NULL); user-owned personal data, not channel-scoped.
pub const KIND_USER_STATUS: u32 = 30315;
/// NIP-78 / NIP-RS: Per-client read state blob for cross-device read position sync.
/// Parameterized replaceable (NIP-33, 30000–39999 range) — keyed by `(pubkey, kind, d_tag)`.
/// Stored globally (channel_id = NULL); user-owned personal data, not channel-scoped.
Expand Down Expand Up @@ -316,6 +320,7 @@ pub const ALL_KINDS: &[u32] = &[
KIND_MEMBER_ADDED_NOTIFICATION,
KIND_MEMBER_REMOVED_NOTIFICATION,
KIND_LONG_FORM,
KIND_USER_STATUS,
KIND_READ_STATE,
KIND_FORUM_POST,
KIND_FORUM_VOTE,
Expand Down
30 changes: 26 additions & 4 deletions crates/sprout-relay/src/api/events.rs
Original file line number Diff line number Diff line change
Expand Up @@ -22,12 +22,16 @@ use super::{

use sprout_core::kind::{
event_kind_u32, KIND_CONTACT_LIST, KIND_LONG_FORM, KIND_PROFILE, KIND_READ_STATE,
KIND_TEXT_NOTE,
KIND_TEXT_NOTE, KIND_USER_STATUS,
};

/// Global event kinds that require `UsersRead` scope.
pub(crate) const GLOBAL_USER_DATA_KINDS: [u32; 3] =
[KIND_PROFILE, KIND_CONTACT_LIST, KIND_READ_STATE];
pub(crate) const GLOBAL_USER_DATA_KINDS: [u32; 4] = [
KIND_PROFILE,
KIND_CONTACT_LIST,
KIND_READ_STATE,
KIND_USER_STATUS,
];
/// Global event kinds that require `MessagesRead` scope.
pub(crate) const GLOBAL_MESSAGE_KINDS: [u32; 2] = [KIND_TEXT_NOTE, KIND_LONG_FORM];

Expand Down Expand Up @@ -151,7 +155,9 @@ pub async fn submit_event(

#[cfg(test)]
mod tests {
use sprout_core::kind::{KIND_CONTACT_LIST, KIND_LONG_FORM, KIND_PROFILE, KIND_TEXT_NOTE};
use sprout_core::kind::{
KIND_CONTACT_LIST, KIND_LONG_FORM, KIND_PROFILE, KIND_TEXT_NOTE, KIND_USER_STATUS,
};

use super::{GLOBAL_MESSAGE_KINDS, GLOBAL_USER_DATA_KINDS};

Expand Down Expand Up @@ -201,6 +207,14 @@ mod tests {
));
}

#[test]
fn kind30315_user_status_allowed_with_users_read() {
assert!(scope_check_for_global_event(
KIND_USER_STATUS,
&[sprout_auth::Scope::UsersRead],
));
}

// ── Negative cases: wrong scope is denied ────────────────────────────

#[test]
Expand Down Expand Up @@ -235,6 +249,14 @@ mod tests {
));
}

#[test]
fn kind30315_user_status_denied_with_only_messages_read() {
assert!(!scope_check_for_global_event(
KIND_USER_STATUS,
&[sprout_auth::Scope::MessagesRead],
));
}

// ── Closed-default: unknown kinds are always denied ──────────────────

#[test]
Expand Down
2 changes: 1 addition & 1 deletion crates/sprout-relay/src/handlers/event.rs
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ fn bounded_kind_label(kind: u32) -> String {
0..=9 | 1059 | 1063 => kind.to_string(),
9000..=9022 | 9100 | 9110 | 9900 => kind.to_string(),
20000..=29999 => kind.to_string(),
30023 | 39000..=39003 => kind.to_string(),
30023 | 30315 | 39000..=39003 => kind.to_string(),
40002..=40100 => kind.to_string(),
41001..=41003 => kind.to_string(),
42001..=42003 => kind.to_string(),
Expand Down
27 changes: 24 additions & 3 deletions crates/sprout-relay/src/handlers/ingest.rs
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ use sprout_core::kind::{
KIND_PRESENCE_UPDATE, KIND_PROFILE, KIND_REACTION, KIND_READ_STATE, 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_STREAM_REMINDER, KIND_TEXT_NOTE, KIND_USER_STATUS,
};
use sprout_core::verification::verify_event;

Expand Down Expand Up @@ -154,7 +154,7 @@ fn required_scope_for_kind(kind: u32, event: &Event) -> Result<Scope, &'static s
match kind {
KIND_PROFILE => Ok(Scope::UsersWrite),
KIND_TEXT_NOTE | KIND_LONG_FORM => Ok(Scope::MessagesWrite),
KIND_CONTACT_LIST | KIND_READ_STATE => Ok(Scope::UsersWrite),
KIND_CONTACT_LIST | KIND_READ_STATE | KIND_USER_STATUS => Ok(Scope::UsersWrite),
KIND_DELETION
| KIND_REACTION
| KIND_GIFT_WRAP
Expand Down Expand Up @@ -289,6 +289,7 @@ pub(crate) fn is_global_only_kind(kind: u32) -> bool {
| KIND_TEXT_NOTE
| KIND_CONTACT_LIST
| KIND_LONG_FORM
| KIND_USER_STATUS
| KIND_READ_STATE
// NIP-34: git events use `a` tags (repo reference), not `h` tags (channel scope).
// Parameterized replaceable kinds are keyed by (pubkey, kind, d_tag).
Expand Down Expand Up @@ -1393,7 +1394,7 @@ mod tests {
use super::*;
use sprout_core::kind::{
KIND_CANVAS, KIND_FORUM_COMMENT, KIND_FORUM_POST, KIND_FORUM_VOTE, KIND_LONG_FORM,
KIND_PRESENCE_UPDATE, KIND_STREAM_MESSAGE, KIND_STREAM_MESSAGE_DIFF,
KIND_PRESENCE_UPDATE, KIND_STREAM_MESSAGE, KIND_STREAM_MESSAGE_DIFF, KIND_USER_STATUS,
};

#[test]
Expand Down Expand Up @@ -1479,6 +1480,25 @@ mod tests {
assert!(is_global_only_kind(KIND_LONG_FORM));
}

#[test]
fn user_status_requires_users_write_scope() {
let dummy = make_dummy_event();
assert_eq!(
required_scope_for_kind(KIND_USER_STATUS, &dummy).unwrap(),
Scope::UsersWrite,
);
}

#[test]
fn user_status_is_global_only() {
assert!(is_global_only_kind(KIND_USER_STATUS));
}

#[test]
fn user_status_does_not_require_h_tag() {
assert!(!requires_h_channel_scope(KIND_USER_STATUS));
}

#[test]
fn global_only_and_channel_scoped_are_disjoint() {
// A kind cannot be both global-only and channel-scoped
Expand Down Expand Up @@ -1518,6 +1538,7 @@ mod tests {
KIND_FORUM_VOTE,
KIND_FORUM_COMMENT,
KIND_LONG_FORM,
KIND_USER_STATUS,
];
for kind in migrated {
assert!(
Expand Down
10 changes: 9 additions & 1 deletion crates/sprout-relay/src/nip11.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ 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];
pub(crate) const SUPPORTED_NIPS: &[u32] = &[1, 2, 10, 11, 16, 17, 23, 25, 29, 33, 38, 42, 50];

/// Relay information document served at `GET /` with `Accept: application/nostr+json`.
#[derive(Debug, Clone, Serialize, Deserialize)]
Expand Down Expand Up @@ -104,6 +104,14 @@ mod tests {
);
}

#[test]
fn supported_nips_includes_nip38() {
assert!(
SUPPORTED_NIPS.contains(&38),
"NIP-38 (user statuses) must be advertised"
);
}

#[test]
fn supported_nips_are_sorted() {
let mut sorted = SUPPORTED_NIPS.to_vec();
Expand Down
Loading