diff --git a/NOSTR.md b/NOSTR.md index cce70f2f77..d45bcffeb9 100644 --- a/NOSTR.md +++ b/NOSTR.md @@ -49,6 +49,7 @@ PGPASSWORD=buzz_dev psql -h localhost -U buzz -d buzz -c \ | **Group chat (kind:9)** | ✅ | Send/receive messages with `#h ` tag | | **Reactions (kind:7)** | ✅ | Standard NIP-25; channel derived from target event's `#e` tag (client `#h` ignored) | | **Deletions (kind:5)** | ✅ | Standard NIP-09; self-authored only. `#h` optional, `#e` required | +| **Labels (kind:1985)** | ✅ | Standard NIP-32; `L` names the namespace, `l` carries the value, target is a `p`/`e`/`a`/`r`/`t` tag. An ordinary member write — stored and fanned out like any other event, with no moderation queue (unlike kind:1984 reports, which are a private signal to moderators). Include `#h` to scope a label to a channel; without it the label is community-global and will not match a channel-scoped subscription | | **User profiles (kind:0)** | ✅ | NIP-01 metadata; synced to users table (display_name, avatar, about, NIP-05). NIP-05 handles must canonicalize to this relay's domain — off-domain or invalid handles are silently cleared. If a NIP-05 handle collides with another user's (UNIQUE constraint), the handle is skipped but other profile fields (display_name, avatar, about) are still synced. | | **Group creation (kind:9007)** | ✅ | NIP-29; include `name` tag, optional `visibility` and `channel_type` | | **Add user (kind:9000)** | ✅ | Open: any user, subject to target's `channel_add_policy` (`owner_only`/`nobody` can block). Private: owner/admin only. Self-add bypasses agent policy but not private-channel auth. | diff --git a/crates/buzz-core/src/kind.rs b/crates/buzz-core/src/kind.rs index 3c6f1d5913..648fd7f77f 100644 --- a/crates/buzz-core/src/kind.rs +++ b/crates/buzz-core/src/kind.rs @@ -326,6 +326,21 @@ pub const KIND_TEAM_CATALOG: u32 = 30178; /// the relay never auto-actions on them (NIP-56). pub const KIND_REPORT: u32 = 1984; +// NIP-32 labelling +/// NIP-32: Label an event, pubkey, or address (kind:1985). +/// +/// The general attestation primitive: `L` names a namespace, `l` carries the +/// value, and the target is a `p` / `e` / `a` / `r` / `t` tag. Where kind:1984 +/// carries a complaint to moderators, kind:1985 carries an arbitrary claim, and +/// unlike a report it is an ordinary member write — stored and fanned out like +/// any other event, with no moderation queue and no special casing. +/// +/// Relevant here because a relay may label a pubkey *with its own key*: paired +/// with the NIP-11 `self` identity this relay already publishes, that makes a +/// label attributable to the relay that issued it rather than merely to some +/// keypair. That is the one thing a reader cannot otherwise establish. +pub const KIND_LABEL: u32 = 1985; + /// Buzz product feedback submission. Accepted at ingest, sidecarred to the /// deployment feedback table, and never stored or fanned out as an event. pub const KIND_PRODUCT_FEEDBACK: u32 = 42000; @@ -658,6 +673,7 @@ pub const ALL_KINDS: &[u32] = &[ KIND_TEAM_CATALOG, KIND_PRIVATE_MANAGED_AGENT, KIND_REPORT, + KIND_LABEL, KIND_PRODUCT_FEEDBACK, KIND_NIP29_PUT_USER, KIND_NIP29_REMOVE_USER, diff --git a/crates/buzz-relay/src/handlers/ingest.rs b/crates/buzz-relay/src/handlers/ingest.rs index cd9f20b5f4..490ed0702a 100644 --- a/crates/buzz-relay/src/handlers/ingest.rs +++ b/crates/buzz-relay/src/handlers/ingest.rs @@ -21,19 +21,20 @@ use buzz_core::kind::{ KIND_GIT_STATUS_CLOSED, KIND_GIT_STATUS_DRAFT, KIND_GIT_STATUS_MERGED, KIND_GIT_STATUS_OPEN, KIND_HUDDLE_ENDED, KIND_HUDDLE_GUIDELINES, KIND_HUDDLE_PARTICIPANT_JOINED, KIND_HUDDLE_PARTICIPANT_LEFT, KIND_HUDDLE_STARTED, KIND_IA_ARCHIVE_REQUEST, - KIND_IA_UNARCHIVE_REQUEST, KIND_LONG_FORM, KIND_MANAGED_AGENT, KIND_MEMBER_ADDED_NOTIFICATION, - KIND_MEMBER_REMOVED_NOTIFICATION, KIND_MODERATION_BAN, KIND_MODERATION_RESOLVE_REPORT, - KIND_MODERATION_TIMEOUT, KIND_MODERATION_UNBAN, KIND_MODERATION_UNTIMEOUT, KIND_MUTE_LIST, - 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_NIP43_LEAVE_REQUEST, - KIND_NIP65_RELAY_LIST_METADATA, KIND_PERSONA, KIND_PIN_LIST, KIND_PRESENCE_UPDATE, - KIND_PRODUCT_FEEDBACK, KIND_PROFILE, KIND_PROJECT, KIND_REACTION, KIND_READ_STATE, KIND_REPORT, - 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_TEAM, KIND_TEAM_CATALOG, KIND_TEXT_NOTE, - KIND_USER_STATUS, KIND_WORKFLOW_DEF, KIND_WORKFLOW_TRIGGER, RELAY_ADMIN_ADD_MEMBER, - RELAY_ADMIN_CHANGE_ROLE, RELAY_ADMIN_REMOVE_MEMBER, RELAY_ADMIN_SET_WORKSPACE_PROFILE, + KIND_IA_UNARCHIVE_REQUEST, KIND_LABEL, KIND_LONG_FORM, KIND_MANAGED_AGENT, + KIND_MEMBER_ADDED_NOTIFICATION, KIND_MEMBER_REMOVED_NOTIFICATION, KIND_MODERATION_BAN, + KIND_MODERATION_RESOLVE_REPORT, KIND_MODERATION_TIMEOUT, KIND_MODERATION_UNBAN, + KIND_MODERATION_UNTIMEOUT, KIND_MUTE_LIST, 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_NIP43_LEAVE_REQUEST, KIND_NIP65_RELAY_LIST_METADATA, KIND_PERSONA, KIND_PIN_LIST, + KIND_PRESENCE_UPDATE, KIND_PRODUCT_FEEDBACK, KIND_PROFILE, KIND_PROJECT, KIND_REACTION, + KIND_READ_STATE, KIND_REPORT, 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_TEAM, + KIND_TEAM_CATALOG, KIND_TEXT_NOTE, KIND_USER_STATUS, KIND_WORKFLOW_DEF, KIND_WORKFLOW_TRIGGER, + RELAY_ADMIN_ADD_MEMBER, RELAY_ADMIN_CHANGE_ROLE, RELAY_ADMIN_REMOVE_MEMBER, + RELAY_ADMIN_SET_WORKSPACE_PROFILE, }; use buzz_core::tenant::TenantContext; use buzz_core::verification::verify_event; @@ -272,6 +273,11 @@ fn required_scope_for_kind(kind: u32, event: &Event) -> Result Ok(Scope::MessagesWrite), + // NIP-32 labels are ordinary member writes. Deliberately NOT routed like + // kind:1984 above: a report is a private signal to moderators, a label is a + // public claim, so it takes the normal store-and-fanout path and is subject + // to the same membership gate as any other message write. + KIND_LABEL => Ok(Scope::MessagesWrite), // Community moderation commands are direct, mod-authz-gated writes. // Scope only proves the transport can submit message writes; the // command handler owns role/capability authorization. @@ -3584,6 +3590,51 @@ mod tests { ); } + /// NIP-32 labels are accepted as ordinary member writes. + /// + /// `required_scope_for_kind` returns `Err` for unknown kinds and the relay rejects them, + /// so without this arm a kind:1985 event is refused at ingest and the advertised NIP-32 + /// support in the NIP-11 document would be a lie. + #[test] + fn nip32_labels_are_accepted_as_member_writes() { + let dummy = make_dummy_event(); + assert_eq!( + required_scope_for_kind(KIND_LABEL, &dummy).unwrap(), + Scope::MessagesWrite, + "kind:1985 must be a normal message write — same gate as any other member event" + ); + } + + /// A label must not be swallowed by any of the special routing branches. + /// + /// Every diversion in `handle_event` is keyed on `kind_u32 == KIND_X` — reports go to a + /// moderation queue and are suppressed from fanout, gift wraps take a private path, and + /// so on. A label is a PUBLIC claim and must take the ordinary store-and-fanout path, so + /// it must collide with none of those kinds. + /// + /// LIMIT, stated rather than implied: this proves no EXISTING branch catches a label. It + /// cannot prove a future one will not — that needs an integration test over the full + /// ingest path, which this unit test is not. An earlier version of this test tried to + /// prove it by grepping its own source file with `include_str!`, which failed for the + /// funniest possible reason: the file contains the needle, inside the assertion itself. + #[test] + fn nip32_labels_are_not_swallowed_by_a_routing_branch() { + for (name, other) in [ + ("KIND_REPORT", KIND_REPORT), + ("KIND_PRODUCT_FEEDBACK", KIND_PRODUCT_FEEDBACK), + ("KIND_GIFT_WRAP", KIND_GIFT_WRAP), + ("KIND_PRESENCE_UPDATE", KIND_PRESENCE_UPDATE), + ("KIND_REACTION", KIND_REACTION), + ("KIND_AUTH", KIND_AUTH), + ] { + assert_ne!( + KIND_LABEL, other, + "kind:1985 collides with {name}, so labels would inherit its routing \ + instead of being stored and fanned out normally" + ); + } + } + #[test] fn diff_validation_rejects_missing_repo() { let event = make_event_with_tags( diff --git a/crates/buzz-relay/src/nip11.rs b/crates/buzz-relay/src/nip11.rs index 2575ddd7ba..061572cab2 100644 --- a/crates/buzz-relay/src/nip11.rs +++ b/crates/buzz-relay/src/nip11.rs @@ -12,7 +12,8 @@ use crate::config::DEFAULT_MAX_FRAME_BYTES; /// /// NIP-43 (relay membership) is advertised separately by [`RelayInfo::build`] /// only when membership enforcement is actually enabled — see that function. -pub(crate) const SUPPORTED_NIPS: &[u32] = &[1, 2, 10, 11, 16, 17, 23, 25, 29, 33, 38, 42, 50, 56]; +pub(crate) const SUPPORTED_NIPS: &[u32] = + &[1, 2, 10, 11, 16, 17, 23, 25, 29, 32, 33, 38, 42, 50, 56]; /// NIP-43 (relay membership). Advertised only when the relay actually /// enforces membership (`BUZZ_REQUIRE_RELAY_MEMBERSHIP=true`) AND has a @@ -381,6 +382,16 @@ mod tests { ); } + #[test] + fn supported_nips_includes_nip32() { + assert!( + SUPPORTED_NIPS.contains(&32), + "NIP-32 (labelling) must be advertised — kind:1985 ingest is live. A client \ + cannot discover label support any other way, and an unadvertised kind that is \ + nonetheless accepted is worse than one that is refused: it works by accident." + ); + } + #[test] fn supported_nips_includes_nip56() { assert!( diff --git a/crates/buzz-test-client/tests/e2e_nostr_interop.rs b/crates/buzz-test-client/tests/e2e_nostr_interop.rs index fce7877676..e0d86ab886 100644 --- a/crates/buzz-test-client/tests/e2e_nostr_interop.rs +++ b/crates/buzz-test-client/tests/e2e_nostr_interop.rs @@ -1985,3 +1985,101 @@ async fn test_channel_window_rejects_half_cursor_and_client_overlay_kinds() { } ws.disconnect().await.expect("disconnect"); } + +/// Query the relay for kind:1985 labels carrying `#h = channel_id`. +/// +/// Deliberately the same generic `POST /query` surface a third-party client would +/// use, not a label-specific route — a label is an ordinary event and must be +/// readable as one. Kinds are explicit because an open-ended query hits the +/// relay's p-gate and returns 403. +async fn query_channel_labels(keys: &Keys, channel_id: &str) -> Vec { + let client = reqwest::Client::new(); + let filters = serde_json::json!([{ + "kinds": [1985], + "#h": [channel_id], + "limit": 50, + }]); + let resp = client + .post(format!("{}/query", relay_http_url())) + .header("X-Pubkey", &keys.public_key().to_hex()) + .header("Content-Type", "application/json") + .body(serde_json::to_string(&filters).unwrap()) + .send() + .await + .expect("submit label query"); + assert!( + resp.status().is_success(), + "label query failed: {}", + resp.status() + ); + let body: serde_json::Value = resp.json().await.expect("parse label query response"); + body.as_array().cloned().unwrap_or_default() +} + +/// NIP-32: a kind:1985 label is accepted, stored, and readable as an ordinary event. +/// +/// `required_scope_for_kind` returns `Err` for unknown kinds and the relay refuses them, +/// so before kind:1985 was registered this test fails at the `accepted` assertion rather +/// than at the read-back — the failure mode is a refusal, not a silent drop. +/// +/// The read-back is the half that matters. Acceptance alone would also be satisfied by a +/// relay that ACKs and discards; requiring the event to come back through the same generic +/// query surface a third-party client uses, with its `L`/`l` pair intact, is what proves a +/// label is stored and served like any other event rather than special-cased. +#[tokio::test] +#[ignore] +async fn test_nip32_label_is_accepted_and_readable() { + let url = relay_url(); + let keys = Keys::generate(); + let channel = create_test_channel(&keys).await; + + // A unique namespace per run, so a stale label from an earlier run cannot + // satisfy the assertions below. + let namespace = format!("org.buzz.e2e.{}", uuid::Uuid::new_v4()); + let subject = Keys::generate().public_key().to_hex(); + + let mut client = BuzzTestClient::connect(&url, &keys).await.expect("connect"); + + let label = EventBuilder::new(Kind::Custom(1985), "") + .tags([ + Tag::parse(["L", &namespace]).expect("L tag"), + Tag::parse(["l", "completed", &namespace]).expect("l tag"), + Tag::parse(["p", &subject]).expect("p tag"), + Tag::parse(["h", &channel]).expect("h tag"), + ]) + .sign_with_keys(&keys) + .expect("sign label"); + let label_id = label.id.to_hex(); + + let ok = client.send_event(label).await.expect("send label"); + assert!( + ok.accepted, + "relay rejected kind:1985 — NIP-32 is advertised in NIP-11, so refusing it \ + makes that advertisement a lie: {}", + ok.message + ); + client.disconnect().await.expect("disconnect"); + + let labels = query_channel_labels(&keys, &channel).await; + let stored = labels + .iter() + .find(|e| e["id"].as_str() == Some(label_id.as_str())) + .unwrap_or_else(|| { + panic!("kind:1985 was accepted but is not readable back on #h={channel}") + }); + + // The tags are the whole payload of a label. An event stored with its L/l pair + // dropped would still round-trip by id and prove nothing. + let tags = stored["tags"].as_array().expect("tags array"); + let has = |name: &str, val: &str| { + tags.iter().any(|t| { + t.as_array().is_some_and(|t| { + t.first().and_then(|v| v.as_str()) == Some(name) + && t.get(1).and_then(|v| v.as_str()) == Some(val) + }) + }) + }; + assert!(has("L", &namespace), "namespace `L` tag lost: {tags:?}"); + assert!(has("l", "completed"), "label value `l` tag lost: {tags:?}"); + assert!(has("p", &subject), "subject `p` tag lost: {tags:?}"); +}