Skip to content
Open
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
1 change: 1 addition & 0 deletions NOSTR.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ PGPASSWORD=buzz_dev psql -h localhost -U buzz -d buzz -c \
| **Group chat (kind:9)** | ✅ | Send/receive messages with `#h <channel-uuid>` 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. |
Expand Down
16 changes: 16 additions & 0 deletions crates/buzz-core/src/kind.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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,
Expand Down
77 changes: 64 additions & 13 deletions crates/buzz-relay/src/handlers/ingest.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -272,6 +273,11 @@ fn required_scope_for_kind(kind: u32, event: &Event) -> Result<Scope, &'static s
// Ingest persists them to `moderation_reports` and suppresses public
// storage/fanout; reports are signals, never enforcement triggers.
KIND_REPORT | KIND_PRODUCT_FEEDBACK => 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.
Expand Down Expand Up @@ -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(
Expand Down
13 changes: 12 additions & 1 deletion crates/buzz-relay/src/nip11.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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!(
Expand Down
98 changes: 98 additions & 0 deletions crates/buzz-test-client/tests/e2e_nostr_interop.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<serde_json::Value> {
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:?}");
}