Files
buzz/crates/buzz-sdk/src/builders.rs
Taylor Ho edc4a09aaa feat(workflows): add responsive library card actions (#6008)
**Category:** improvement
**User Impact:** Users can scan what each workflow does and trigger,
edit, duplicate, enable, disable, or delete it directly from the
library.
**Problem:** The workflow list buried common actions and did not expose
each automation's trigger-to-action shape at a glance.
**Solution:** Add a responsive workflow library with a persistent create
tile, compact trigger/action diagrams, prominent workflow titles with
supporting descriptions, and shared card actions while preserving
existing detail, editor, and run-history entry points. Card toggles
refresh both list and open-detail caches so status and definition stay
consistent.

<details>
<summary>File changes</summary>

**desktop/src/features/workflows/ui/WorkflowActionsMenu.tsx**
Adds a shared card menu for trigger, edit, duplicate, enable/disable,
and delete actions.

**desktop/src/features/workflows/ui/WorkflowCard.tsx**
Reworks cards around the prototype's visual hierarchy: color-coded
trigger, action flow, sentence-case eyebrow, prominent title, supporting
description, status, channel, and update date without a footer clock
icon.

**desktop/src/features/workflows/ui/WorkflowsView.tsx**
Adds the responsive grid, create tile, mutation wiring, and list/detail
cache invalidation. Container breakpoints keep cards two-across at
medium widths and three-across in the 1280px desktop layout.

**desktop/src/features/workflows/ui/workflowDefinition.ts**
Adds immutable enabled-state updates plus narrow trigger and
first-action readers used only to select card icons.

**desktop/src/features/workflows/ui/workflowDefinition.test.mjs**
Covers neutral icon selection, enabled-state immutability, and status
presentation.

**desktop/tests/e2e/workflows.spec.ts**
Covers the create tile, title/description hierarchy, selected-card
enable/disable consistency, and deterministic narrow/medium/wide
captures while retaining existing action coverage.

</details>

## Reproduction steps

1. Open **Workflows** and confirm the create tile stays first as cards
flow from one to three columns with available width.
2. Confirm each card shows a sentence-case trigger eyebrow, prominent
workflow title, supporting description when present, status, channel,
and update date without a clock icon.
3. Open a card's overflow menu and trigger, edit, duplicate,
enable/disable, or delete the workflow.
4. Leave the detail panel open while toggling and confirm its badge and
JSON definition update with the card.

## Screenshots

Real built E2E UI with representative workflow data at three viewport
sizes.

### Narrow — 800 × 720

![Workflow library at 800 by
720](https://d24qwcpro867f5.cloudfront.net/repos/buzz/prs/6008/workflow-library-narrow-482d1b4c8.png)

### Medium — 1024 × 720

![Workflow library at 1024 by
720](https://d24qwcpro867f5.cloudfront.net/repos/buzz/prs/6008/workflow-library-medium-482d1b4c8.png)

### Wide — 1280 × 720

![Workflow library at 1280 by
720](https://d24qwcpro867f5.cloudfront.net/repos/buzz/prs/6008/workflow-library-wide-482d1b4c8.png)

### Card actions

![Workflow library actions at 1280 by
720](https://d24qwcpro867f5.cloudfront.net/repos/buzz/prs/6008/workflow-library-wide-actions-482d1b4c8.png)

---------

Signed-off-by: Taylor Ho <taylorkmho@gmail.com>
2026-08-17 16:56:34 +00:00

4871 lines
172 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//! Typed event builder functions (38 builders).
//!
//! All functions return `Result<nostr::EventBuilder, SdkError>`.
//! The caller signs: `builder.sign_with_keys(&keys)?`.
use buzz_core::{
kind::{
KIND_AGENT_OBSERVER_FRAME, KIND_APPROVAL_DENY, KIND_APPROVAL_GRANT, KIND_DELETION,
KIND_DM_ADD_MEMBER, KIND_DM_OPEN, KIND_EMOJI_SET, KIND_GIT_ISSUE, KIND_GIT_PATCH,
KIND_GIT_PR_UPDATE, KIND_GIT_PULL_REQUEST, KIND_GIT_REPO_ANNOUNCEMENT,
KIND_GIT_STATUS_CLOSED, KIND_GIT_STATUS_DRAFT, KIND_GIT_STATUS_MERGED,
KIND_GIT_STATUS_OPEN, KIND_IA_ARCHIVE_REQUEST, KIND_IA_UNARCHIVE_REQUEST,
KIND_MODERATION_BAN, KIND_MODERATION_RESOLVE_REPORT, KIND_MODERATION_TIMEOUT,
KIND_MODERATION_UNBAN, KIND_MODERATION_UNTIMEOUT, KIND_PRESENCE_UPDATE, KIND_PROJECT,
KIND_USER_STATUS, KIND_WORKFLOW_DEF, KIND_WORKFLOW_TRIGGER,
},
observer::{
content_looks_like_nip44, OBSERVER_AGENT_TAG, OBSERVER_FRAME_CONTROL, OBSERVER_FRAME_TAG,
OBSERVER_FRAME_TELEMETRY,
},
};
use nostr::{EventBuilder, Kind, Tag};
use uuid::Uuid;
use crate::{
ChannelKind, CustomEmoji, DiffMeta, MemberRole, SdkError, ThreadRef, Visibility, VoteDirection,
};
/// Parse a tag slice, mapping errors to `SdkError::InvalidTag`.
fn tag(parts: &[&str]) -> Result<Tag, SdkError> {
Tag::parse(parts.iter().copied()).map_err(|e| SdkError::InvalidTag(e.to_string()))
}
/// Validate content byte length.
fn check_content(content: &str, max: usize) -> Result<(), SdkError> {
let got = content.len();
if got > max {
return Err(SdkError::ContentTooLarge { max, got });
}
Ok(())
}
/// Validate hex string has at least `min_len` hex characters.
fn check_hex_len(s: &str, min_len: usize, field: &str) -> Result<(), SdkError> {
if s.len() < min_len || !s.chars().all(|c| c.is_ascii_hexdigit()) {
return Err(SdkError::InvalidDiffMeta(format!(
"{field} must be at least {min_len} hex characters (got {:?})",
s
)));
}
Ok(())
}
/// Validate a git commit-like hex id (commit, parent-commit, euc,
/// merge-commit, applied-as-commit). Git object ids are full SHA-1 (40 hex
/// chars) or SHA-256 (64 hex chars) — anything shorter is an abbreviated
/// ref that NIP-34 canonical tags shouldn't carry, since consumers resolve
/// these against the actual repo.
fn check_commit_hex(s: &str, field: &str) -> Result<(), SdkError> {
if (s.len() != 40 && s.len() != 64) || !s.chars().all(|c| c.is_ascii_hexdigit()) {
return Err(SdkError::InvalidInput(format!(
"{field} must be a full 40-character (SHA-1) or 64-character (SHA-256) hex commit id (got {:?})",
s
)));
}
Ok(())
}
fn check_pubkey_hex(s: &str, field: &str) -> Result<String, SdkError> {
if s.len() != 64 || !s.chars().all(|c| c.is_ascii_hexdigit()) {
return Err(SdkError::InvalidInput(format!(
"{field} must be a 64-character hex pubkey"
)));
}
Ok(s.to_ascii_lowercase())
}
/// Validate an exact-length hex string (event ids), returning it lowercased.
fn check_hex_exact(s: &str, len: usize, field: &str) -> Result<String, SdkError> {
if s.len() != len || !s.chars().all(|c| c.is_ascii_hexdigit()) {
return Err(SdkError::InvalidInput(format!(
"{field} must be a {len}-character hex string"
)));
}
Ok(s.to_ascii_lowercase())
}
/// Validate a git repo identifier: `[a-zA-Z0-9._-]{1,64}`, no leading dot,
/// no `..`. Shared by `build_repo_announcement` and `GitRepoCoord` so a
/// repo coordinate built directly through the SDK (bypassing CLI-side
/// `validate_repo_id`) can't slip an invalid `d`-tag into an `a`-tag value.
fn check_repo_id(repo_id: &str) -> Result<(), SdkError> {
if repo_id.is_empty() {
return Err(SdkError::InvalidInput("repo_id must not be empty".into()));
}
if repo_id.len() > 64 {
return Err(SdkError::InvalidInput(format!(
"repo_id exceeds 64 characters (got {})",
repo_id.len()
)));
}
if !repo_id
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '.' || c == '_' || c == '-')
{
return Err(SdkError::InvalidInput(
"repo_id may only contain [a-zA-Z0-9._-]".into(),
));
}
if repo_id.starts_with('.') {
return Err(SdkError::InvalidInput(
"repo_id must not start with a dot".into(),
));
}
if repo_id.contains("..") {
return Err(SdkError::InvalidInput(
"repo_id must not contain '..'".into(),
));
}
Ok(())
}
/// Maximum length of a custom emoji shortcode.
pub const MAX_CUSTOM_EMOJI_SHORTCODE_LEN: usize = 64;
/// Maximum reaction payload length for a colon-wrapped custom emoji shortcode.
pub const MAX_CUSTOM_EMOJI_REACTION_LEN: usize = MAX_CUSTOM_EMOJI_SHORTCODE_LEN + 2;
/// Validate and normalize a NIP-30 custom emoji shortcode.
///
/// Shortcodes are case-insensitive in Buzz's relay-global set; lowercase
/// normalization prevents `party_parrot` and `Party_Parrot` from colliding.
pub fn normalize_custom_emoji_shortcode(shortcode: &str) -> Result<String, SdkError> {
let trimmed = shortcode.trim().trim_matches(':');
if trimmed.is_empty() {
return Err(SdkError::InvalidInput(
"emoji shortcode must not be empty".into(),
));
}
if trimmed.len() > MAX_CUSTOM_EMOJI_SHORTCODE_LEN {
return Err(SdkError::InvalidInput(format!(
"emoji shortcode exceeds {MAX_CUSTOM_EMOJI_SHORTCODE_LEN} bytes (got {})",
trimmed.len()
)));
}
if !trimmed
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')
{
return Err(SdkError::InvalidInput(
"emoji shortcode may only contain ASCII letters, digits, hyphens, and underscores"
.into(),
));
}
Ok(trimmed.to_ascii_lowercase())
}
fn check_custom_emoji_url(url: &str) -> Result<(), SdkError> {
if url.is_empty() {
return Err(SdkError::InvalidInput(
"emoji image URL must not be empty".into(),
));
}
if url.len() > 2048 {
return Err(SdkError::InvalidInput(format!(
"emoji image URL exceeds 2048 bytes (got {})",
url.len()
)));
}
if !url.starts_with("http://") && !url.starts_with("https://") {
return Err(SdkError::InvalidInput(
"emoji image URL must start with http:// or https://".into(),
));
}
Ok(())
}
/// Emit NIP-10 e-tags for a `ThreadRef`.
fn thread_tags(thread_ref: &ThreadRef, tags: &mut Vec<Tag>) -> Result<(), SdkError> {
let root = thread_ref.root_event_id.to_hex();
let parent = thread_ref.parent_event_id.to_hex();
if root == parent {
// Direct reply
tags.push(tag(&["e", &root, "", "reply"])?);
} else {
// Nested reply
tags.push(tag(&["e", &root, "", "root"])?);
tags.push(tag(&["e", &parent, "", "reply"])?);
}
Ok(())
}
/// Deduplicate and cap mentions, emitting p-tags.
fn mention_tags(mentions: &[&str], tags: &mut Vec<Tag>) -> Result<(), SdkError> {
if mentions.len() > crate::mentions::MENTION_CAP {
return Err(SdkError::TooManyMentions);
}
let mut seen = std::collections::HashSet::new();
for &hex in mentions {
let lower = hex.to_ascii_lowercase();
if seen.insert(lower.clone()) {
tags.push(tag(&["p", &lower])?);
}
}
Ok(())
}
/// Emit imeta tags from raw tag vectors.
fn imeta_tags(media_tags: &[Vec<String>], tags: &mut Vec<Tag>) -> Result<(), SdkError> {
for mt in media_tags {
let parts: Vec<&str> = mt.iter().map(String::as_str).collect();
tags.push(Tag::parse(parts).map_err(|e| SdkError::InvalidTag(e.to_string()))?);
}
Ok(())
}
/// Build a stream message (kind 9).
///
/// - `channel_id`: target channel UUID
/// - `content`: message text (max 64 KiB)
/// - `thread_ref`: optional NIP-10 reply context
/// - `mentions`: pubkey hex strings to p-tag (deduped, max 50)
/// - `broadcast`: if true, adds `["broadcast", "1"]` tag
/// - `media_tags`: raw imeta tag vectors
pub fn build_message(
channel_id: Uuid,
content: &str,
thread_ref: Option<&ThreadRef>,
mentions: &[&str],
broadcast: bool,
media_tags: &[Vec<String>],
) -> Result<EventBuilder, SdkError> {
check_content(content, 64 * 1024)?;
let mut tags = vec![tag(&["h", &channel_id.to_string()])?];
if let Some(tr) = thread_ref {
thread_tags(tr, &mut tags)?;
}
mention_tags(mentions, &mut tags)?;
if broadcast {
tags.push(tag(&["broadcast", "1"])?);
}
imeta_tags(media_tags, &mut tags)?;
Ok(EventBuilder::new(Kind::Custom(9), content)
.tags(tags)
.allow_self_tagging())
}
/// Build an encrypted agent observer frame (kind 24200).
///
/// `recipient_pubkey` is the cleartext `p` tag used by the relay for owner-only
/// routing. `agent_pubkey` identifies the managed agent whose observer stream
/// this frame belongs to. `encrypted_content` must be NIP-44 v2 ciphertext.
pub fn build_agent_observer_frame(
recipient_pubkey: &str,
agent_pubkey: &str,
frame: &str,
encrypted_content: &str,
) -> Result<EventBuilder, SdkError> {
if frame != OBSERVER_FRAME_TELEMETRY && frame != OBSERVER_FRAME_CONTROL {
return Err(SdkError::InvalidInput(format!(
"observer frame must be {OBSERVER_FRAME_TELEMETRY:?} or {OBSERVER_FRAME_CONTROL:?}"
)));
}
if !content_looks_like_nip44(encrypted_content) {
return Err(SdkError::InvalidInput(
"observer frame content must be NIP-44 v2 ciphertext".into(),
));
}
let recipient_pubkey = check_pubkey_hex(recipient_pubkey, "recipient_pubkey")?;
let agent_pubkey = check_pubkey_hex(agent_pubkey, "agent_pubkey")?;
let tags = vec![
tag(&["p", &recipient_pubkey])?,
tag(&[OBSERVER_AGENT_TAG, &agent_pubkey])?,
tag(&[OBSERVER_FRAME_TAG, frame])?,
];
Ok(EventBuilder::new(
Kind::Custom(KIND_AGENT_OBSERVER_FRAME as u16),
encrypted_content,
)
.tags(tags))
}
/// Build a forum post thread root (kind 45001).
pub fn build_forum_post(
channel_id: Uuid,
content: &str,
mentions: &[&str],
media_tags: &[Vec<String>],
) -> Result<EventBuilder, SdkError> {
check_content(content, 64 * 1024)?;
let mut tags = vec![tag(&["h", &channel_id.to_string()])?];
mention_tags(mentions, &mut tags)?;
imeta_tags(media_tags, &mut tags)?;
Ok(EventBuilder::new(Kind::Custom(45001), content)
.tags(tags)
.allow_self_tagging())
}
/// Build a forum comment reply (kind 45003).
pub fn build_forum_comment(
channel_id: Uuid,
content: &str,
thread_ref: &ThreadRef,
mentions: &[&str],
media_tags: &[Vec<String>],
) -> Result<EventBuilder, SdkError> {
check_content(content, 64 * 1024)?;
let mut tags = vec![tag(&["h", &channel_id.to_string()])?];
thread_tags(thread_ref, &mut tags)?;
mention_tags(mentions, &mut tags)?;
imeta_tags(media_tags, &mut tags)?;
Ok(EventBuilder::new(Kind::Custom(45003), content)
.tags(tags)
.allow_self_tagging())
}
/// Build a diff/patch message (kind 40008).
pub fn build_diff_message(
channel_id: Uuid,
content: &str,
diff_meta: &DiffMeta,
thread_ref: Option<&ThreadRef>,
) -> Result<EventBuilder, SdkError> {
check_content(content, 60 * 1024)?;
// Validate DiffMeta
if !diff_meta.repo_url.starts_with("http://") && !diff_meta.repo_url.starts_with("https://") {
return Err(SdkError::InvalidDiffMeta(
"repo_url must start with http:// or https://".into(),
));
}
check_hex_len(&diff_meta.commit_sha, 7, "commit_sha")?;
if let Some(ref pc) = diff_meta.parent_commit {
check_hex_len(pc, 7, "parent_commit")?;
}
match &diff_meta.branch {
Some((src, tgt)) if src.is_empty() || tgt.is_empty() => {
return Err(SdkError::InvalidDiffMeta(
"branch requires both source and target to be non-empty".into(),
));
}
_ => {}
}
if let Some(pr) = diff_meta.pr_number {
if pr == 0 {
return Err(SdkError::InvalidDiffMeta(
"pr_number must be positive".into(),
));
}
}
let mut tags = vec![
tag(&["h", &channel_id.to_string()])?,
tag(&["repo", &diff_meta.repo_url])?,
tag(&["commit", &diff_meta.commit_sha])?,
];
if let Some(ref fp) = diff_meta.file_path {
tags.push(tag(&["file", fp])?);
}
if let Some(ref pc) = diff_meta.parent_commit {
tags.push(tag(&["parent-commit", pc])?);
}
if let Some((ref src, ref tgt)) = diff_meta.branch {
tags.push(tag(&["branch", src, tgt])?);
}
if let Some(pr) = diff_meta.pr_number {
tags.push(tag(&["pr", &pr.to_string()])?);
}
if let Some(ref lang) = diff_meta.language {
tags.push(tag(&["l", lang])?);
}
if let Some(ref desc) = diff_meta.description {
tags.push(tag(&["description", desc])?);
}
if diff_meta.truncated {
tags.push(tag(&["truncated", "true"])?);
}
if let Some(ref alt) = diff_meta.alt_text {
tags.push(tag(&["alt", alt])?);
}
if let Some(tr) = thread_ref {
thread_tags(tr, &mut tags)?;
}
Ok(EventBuilder::new(Kind::Custom(40008), content).tags(tags))
}
/// Build an edit event targeting an existing message (kind 40003).
pub fn build_edit(
channel_id: Uuid,
target_event_id: nostr::EventId,
new_content: &str,
) -> Result<EventBuilder, SdkError> {
check_content(new_content, 64 * 1024)?;
let tags = vec![
tag(&["h", &channel_id.to_string()])?,
tag(&["e", &target_event_id.to_hex()])?,
];
Ok(EventBuilder::new(Kind::Custom(40003), new_content).tags(tags))
}
/// Optional metadata for moderator delete tombstones (kind 9005).
#[derive(Debug, Clone, Default)]
pub struct DeleteMessageOptions<'a> {
/// Audit action UUID to link from the public tombstone.
pub action_id: Option<Uuid>,
/// Machine-readable, public-safe reason code.
pub reason_code: Option<&'a str>,
/// Human-readable reason safe for the room-facing tombstone.
pub public_reason: Option<&'a str>,
}
/// Build a Buzz-native delete event (kind 9005).
pub fn build_delete_message(
channel_id: Uuid,
target_event_id: nostr::EventId,
) -> Result<EventBuilder, SdkError> {
build_delete_message_with_options(channel_id, target_event_id, DeleteMessageOptions::default())
}
/// Build a Buzz-native delete event (kind 9005) with optional moderation metadata.
pub fn build_delete_message_with_options(
channel_id: Uuid,
target_event_id: nostr::EventId,
options: DeleteMessageOptions<'_>,
) -> Result<EventBuilder, SdkError> {
let mut tags = vec![
tag(&["h", &channel_id.to_string()])?,
tag(&["e", &target_event_id.to_hex()])?,
];
if let Some(action_id) = options.action_id {
tags.push(tag(&["action_id", &action_id.to_string()])?);
}
if let Some(reason_code) = options.reason_code {
tags.push(tag(&["reason_code", reason_code])?);
}
if let Some(public_reason) = options.public_reason {
tags.push(tag(&["public_reason", public_reason])?);
}
Ok(EventBuilder::new(Kind::Custom(9005), "").tags(tags))
}
/// Build a NIP-09 deletion event (kind 5). The `h` tag is non-standard for
/// NIP-09 but is required so channel-scoped subscriptions observe the delete.
pub fn build_delete_compat(
channel_id: Uuid,
target_event_id: nostr::EventId,
) -> Result<EventBuilder, SdkError> {
let tags = vec![
tag(&["h", &channel_id.to_string()])?,
tag(&["e", &target_event_id.to_hex()])?,
];
Ok(EventBuilder::new(Kind::Custom(5), "").tags(tags))
}
/// Build a forum vote event (kind 45002). Content is `"+"` or `"-"`.
pub fn build_vote(
channel_id: Uuid,
target_event_id: nostr::EventId,
direction: VoteDirection,
) -> Result<EventBuilder, SdkError> {
let content = match direction {
VoteDirection::Up => "+",
VoteDirection::Down => "-",
};
let tags = vec![
tag(&["h", &channel_id.to_string()])?,
tag(&["e", &target_event_id.to_hex()])?,
];
Ok(EventBuilder::new(Kind::Custom(45002), content).tags(tags))
}
/// Build a NIP-25 reaction event (kind 7). Emoji max 64 chars.
pub fn build_reaction(
target_event_id: nostr::EventId,
emoji: &str,
) -> Result<EventBuilder, SdkError> {
if emoji.chars().count() > 64 {
return Err(SdkError::EmojiTooLong);
}
let tags = vec![tag(&["e", &target_event_id.to_hex()])?];
Ok(EventBuilder::new(Kind::Custom(7), emoji).tags(tags))
}
/// Build a NIP-25 reaction event using a NIP-30 custom emoji.
///
/// The reaction content is `:shortcode:` and the event carries exactly one
/// `["emoji", shortcode, url]` tag, matching NIP-25's custom emoji reaction
/// guidance.
pub fn build_custom_emoji_reaction(
target_event_id: nostr::EventId,
shortcode: &str,
url: &str,
) -> Result<EventBuilder, SdkError> {
let shortcode = normalize_custom_emoji_shortcode(shortcode)?;
check_custom_emoji_url(url)?;
let content = format!(":{shortcode}:");
let tags = vec![
tag(&["e", &target_event_id.to_hex()])?,
tag(&["emoji", &shortcode, url])?,
];
Ok(EventBuilder::new(Kind::Custom(7), content).tags(tags))
}
/// Build a deletion event targeting a reaction (kind 5).
pub fn build_remove_reaction(reaction_event_id: nostr::EventId) -> Result<EventBuilder, SdkError> {
let tags = vec![tag(&["e", &reaction_event_id.to_hex()])?];
Ok(EventBuilder::new(Kind::Custom(5), "").tags(tags))
}
/// d-tag for a member's own custom emoji set. Each member publishes one
/// user-signed kind:30030 under this d-tag; the workspace palette is the
/// client-side union of every member's set.
pub const CUSTOM_EMOJI_SET_D_TAG: &str = "buzz:custom-emoji";
/// Build a member's own custom emoji set event (kind:30030, NIP-30/NIP-51).
///
/// User-signed and parameterized-replaceable, keyed by `(pubkey, 30030,
/// "buzz:custom-emoji")`. Replaces the caller's prior set. The workspace
/// palette shown in clients is the union of every member's set, deduped by
/// `(shortcode, url)` on read. Add/remove is read-own-set → mutate → rebuild.
pub fn build_custom_emoji_set(emojis: &[CustomEmoji]) -> Result<EventBuilder, SdkError> {
let mut seen = std::collections::HashSet::with_capacity(emojis.len());
let mut tags = Vec::with_capacity(emojis.len() + 1);
tags.push(tag(&["d", CUSTOM_EMOJI_SET_D_TAG])?);
for emoji in emojis {
let shortcode = normalize_custom_emoji_shortcode(&emoji.shortcode)?;
check_custom_emoji_url(&emoji.url)?;
if !seen.insert(shortcode.clone()) {
return Err(SdkError::InvalidInput(format!(
"duplicate emoji shortcode: {shortcode}"
)));
}
tags.push(tag(&["emoji", &shortcode, &emoji.url])?);
}
Ok(EventBuilder::new(Kind::Custom(KIND_EMOJI_SET as u16), "").tags(tags))
}
/// Build a canvas update event (kind 40100).
pub fn build_set_canvas(channel_id: Uuid, content: &str) -> Result<EventBuilder, SdkError> {
let tags = vec![tag(&["h", &channel_id.to_string()])?];
Ok(EventBuilder::new(Kind::Custom(40100), content).tags(tags))
}
/// Build a NIP-01 profile metadata event (kind 0).
///
/// Only present (Some) fields are included in the JSON object.
pub fn build_profile(
display_name: Option<&str>,
name: Option<&str>,
picture: Option<&str>,
about: Option<&str>,
nip05: Option<&str>,
) -> Result<EventBuilder, SdkError> {
let mut map = serde_json::Map::new();
if let Some(v) = display_name {
map.insert("display_name".into(), serde_json::Value::String(v.into()));
}
if let Some(v) = name {
map.insert("name".into(), serde_json::Value::String(v.into()));
}
if let Some(v) = picture {
map.insert("picture".into(), serde_json::Value::String(v.into()));
}
if let Some(v) = about {
map.insert("about".into(), serde_json::Value::String(v.into()));
}
if let Some(v) = nip05 {
map.insert("nip05".into(), serde_json::Value::String(v.into()));
}
let content = serde_json::Value::Object(map).to_string();
Ok(EventBuilder::new(Kind::Custom(0), content).tags([]))
}
/// Build a NIP-29 add-member event (kind 9000).
pub fn build_add_member(
channel_id: Uuid,
target_pubkey: &str,
role: Option<MemberRole>,
) -> Result<EventBuilder, SdkError> {
check_hex_len(target_pubkey, 64, "target_pubkey")?;
let mut tags = vec![
tag(&["h", &channel_id.to_string()])?,
tag(&["p", &target_pubkey.to_ascii_lowercase()])?,
];
if let Some(r) = role {
tags.push(tag(&["role", r.as_str()])?);
}
Ok(EventBuilder::new(Kind::Custom(9000), "").tags(tags))
}
/// Build a NIP-29 remove-member event (kind 9001).
pub fn build_remove_member(
channel_id: Uuid,
target_pubkey: &str,
) -> Result<EventBuilder, SdkError> {
check_hex_len(target_pubkey, 64, "target_pubkey")?;
let tags = vec![
tag(&["h", &channel_id.to_string()])?,
tag(&["p", &target_pubkey.to_ascii_lowercase()])?,
];
Ok(EventBuilder::new(Kind::Custom(9001), "").tags(tags))
}
/// Build a NIP-29 leave-request event (kind 9022).
pub fn build_leave(channel_id: Uuid) -> Result<EventBuilder, SdkError> {
let tags = vec![tag(&["h", &channel_id.to_string()])?];
Ok(EventBuilder::new(Kind::Custom(9022), "").tags(tags))
}
/// Build a NIP-29 edit-metadata event for name/about/visibility/ttl (kind 9002).
///
/// `ttl`: outer `None` leaves it unchanged; `Some(Some(secs))` sets the
/// ephemeral timeout; `Some(None)` clears it (emits `["ttl", ""]`).
pub fn build_update_channel(
channel_id: Uuid,
name: Option<&str>,
about: Option<&str>,
visibility: Option<&str>,
ttl: Option<Option<i32>>,
) -> Result<EventBuilder, SdkError> {
if name.is_none() && about.is_none() && visibility.is_none() && ttl.is_none() {
return Err(SdkError::InvalidTag(
"at least one of name, about, visibility, or ttl must be provided".into(),
));
}
if let Some(v) = visibility {
if v != "open" && v != "private" {
return Err(SdkError::InvalidTag(
"visibility must be \"open\" or \"private\"".into(),
));
}
}
if name
.map(buzz_core::channel::canonical_channel_name)
.is_some_and(|name| name.trim().is_empty())
{
return Err(SdkError::InvalidTag("channel name is required".into()));
}
let mut tags = vec![tag(&["h", &channel_id.to_string()])?];
if let Some(n) = name {
tags.push(tag(&[
"name",
buzz_core::channel::canonical_channel_name(n),
])?);
}
if let Some(a) = about {
tags.push(tag(&["about", a])?);
}
if let Some(v) = visibility {
tags.push(tag(&["visibility", v])?);
}
if let Some(ttl) = ttl {
match ttl {
Some(secs) => tags.push(tag(&["ttl", &secs.to_string()])?),
None => tags.push(tag(&["ttl", ""])?),
}
}
Ok(EventBuilder::new(Kind::Custom(9002), "").tags(tags))
}
/// Build a NIP-29 edit-metadata event for topic (kind 9002).
pub fn build_set_topic(channel_id: Uuid, topic: &str) -> Result<EventBuilder, SdkError> {
let tags = vec![
tag(&["h", &channel_id.to_string()])?,
tag(&["topic", topic])?,
];
Ok(EventBuilder::new(Kind::Custom(9002), "").tags(tags))
}
/// Build a NIP-29 edit-metadata event for purpose (kind 9002).
pub fn build_set_purpose(channel_id: Uuid, purpose: &str) -> Result<EventBuilder, SdkError> {
let tags = vec![
tag(&["h", &channel_id.to_string()])?,
tag(&["purpose", purpose])?,
];
Ok(EventBuilder::new(Kind::Custom(9002), "").tags(tags))
}
/// Build a NIP-29 create-group event (kind 9007).
///
/// `ttl`: `Some(secs)` makes the channel ephemeral with that lifetime in
/// seconds (the relay archives it once the deadline passes without activity);
/// `None` leaves it permanent.
pub fn build_create_channel(
channel_id: Uuid,
name: &str,
visibility: Option<Visibility>,
channel_type: Option<ChannelKind>,
about: Option<&str>,
ttl: Option<i32>,
) -> Result<EventBuilder, SdkError> {
let name = buzz_core::channel::canonical_channel_name(name);
if name.trim().is_empty() {
return Err(SdkError::InvalidTag("channel name is required".into()));
}
let mut tags = vec![tag(&["h", &channel_id.to_string()])?, tag(&["name", name])?];
if let Some(v) = visibility {
tags.push(tag(&["visibility", v.as_str()])?);
}
if let Some(ct) = channel_type {
tags.push(tag(&["channel_type", ct.as_str()])?);
}
if let Some(a) = about {
tags.push(tag(&["about", a])?);
}
if let Some(secs) = ttl {
tags.push(tag(&["ttl", &secs.to_string()])?);
}
Ok(EventBuilder::new(Kind::Custom(9007), "").tags(tags))
}
/// Build a NIP-29 join-request event (kind 9021).
pub fn build_join(channel_id: Uuid) -> Result<EventBuilder, SdkError> {
let tags = vec![tag(&["h", &channel_id.to_string()])?];
Ok(EventBuilder::new(Kind::Custom(9021), "").tags(tags))
}
/// Build a NIP-29 archive event (kind 9002, `["archived", "true"]`).
pub fn build_archive(channel_id: Uuid) -> Result<EventBuilder, SdkError> {
let tags = vec![
tag(&["h", &channel_id.to_string()])?,
tag(&["archived", "true"])?,
];
Ok(EventBuilder::new(Kind::Custom(9002), "").tags(tags))
}
/// Build a NIP-29 unarchive event (kind 9002, `["archived", "false"]`).
pub fn build_unarchive(channel_id: Uuid) -> Result<EventBuilder, SdkError> {
let tags = vec![
tag(&["h", &channel_id.to_string()])?,
tag(&["archived", "false"])?,
];
Ok(EventBuilder::new(Kind::Custom(9002), "").tags(tags))
}
/// Build a NIP-29 delete-group event (kind 9008).
pub fn build_delete_channel(channel_id: Uuid) -> Result<EventBuilder, SdkError> {
let tags = vec![tag(&["h", &channel_id.to_string()])?];
Ok(EventBuilder::new(Kind::Custom(9008), "").tags(tags))
}
/// Build a global text note (kind:1, NIP-01).
///
/// `reply_to_event_id`: adds a single `["e", <id>, "", "reply"]` tag.
/// This is intentionally simpler than the full `ThreadRef` mechanism used
/// for channel messages — social notes use a flat reply model for now.
/// Full NIP-10 threading (root + reply + p-tags) is deferred.
pub fn build_note(
content: &str,
reply_to_event_id: Option<nostr::EventId>,
) -> Result<EventBuilder, SdkError> {
check_content(content, 64 * 1024)?;
let mut tags = vec![];
if let Some(reply_id) = reply_to_event_id {
tags.push(tag(&["e", &reply_id.to_hex(), "", "reply"])?);
}
Ok(EventBuilder::new(Kind::Custom(1), content).tags(tags))
}
/// Maximum number of contacts allowed in a single contact list event.
const MAX_CONTACTS: usize = 10_000;
/// Build a contact list replacement event (kind:3, NIP-02).
///
/// Each contact is `(pubkey_hex, relay_url, petname)`.
/// `pubkey_hex` must be exactly 64 hex characters (any case accepted, normalized
/// to lowercase before storage). Non-hex or wrong-length pubkeys are rejected
/// with `SdkError::InvalidInput`.
/// `relay_url` and `petname` may be `None` (stored as empty string per NIP-02).
///
/// Duplicate pubkeys are silently deduplicated — the first occurrence is kept.
///
/// Replaces the entire contact list — callers must read-before-write for deltas.
pub fn build_contact_list(
contacts: &[(&str, Option<&str>, Option<&str>)],
) -> Result<EventBuilder, SdkError> {
if contacts.len() > MAX_CONTACTS {
return Err(SdkError::InvalidInput(format!(
"contact list exceeds maximum of {} contacts (got {})",
MAX_CONTACTS,
contacts.len()
)));
}
let mut seen = std::collections::HashSet::with_capacity(contacts.len());
let mut tags = Vec::with_capacity(contacts.len());
for &(pubkey_hex, relay_url, petname) in contacts {
if pubkey_hex.len() != 64 || !pubkey_hex.chars().all(|ch| ch.is_ascii_hexdigit()) {
return Err(SdkError::InvalidInput(format!(
"contact pubkey must be exactly 64 hex chars, got len={}",
pubkey_hex.len()
)));
}
if let Some(url) = relay_url {
if url.len() > 2048 {
return Err(SdkError::InvalidInput(format!(
"relay_url exceeds 2048 bytes (got {})",
url.len()
)));
}
}
if let Some(name) = petname {
if name.len() > 256 {
return Err(SdkError::InvalidInput(format!(
"petname exceeds 256 bytes (got {})",
name.len()
)));
}
}
let lower = pubkey_hex.to_ascii_lowercase();
if !seen.insert(lower.clone()) {
continue;
}
tags.push(tag(&[
"p",
&lower,
relay_url.unwrap_or(""),
petname.unwrap_or(""),
])?);
}
Ok(EventBuilder::new(Kind::Custom(3), "").tags(tags))
}
/// Extract the channel UUID from an event's `h` tag.
///
/// Returns `None` if no `h` tag is present or the value is not a valid UUID.
pub fn extract_channel_id(event: &nostr::Event) -> Option<Uuid> {
event.tags.iter().find_map(|t| {
let vec = t.as_slice();
if vec.first().map(|s| s.as_str()) == Some("h") {
vec.get(1).and_then(|v| Uuid::parse_str(v.as_str()).ok())
} else {
None
}
})
}
/// Build a git repository announcement event (kind:30617, NIP-34).
///
/// Creates or updates a repository. The `repo_id` is the unique identifier
/// (d-tag) — must be `[a-zA-Z0-9._-]{1,64}`, no leading dots, no `..`.
///
/// This is a parameterized replaceable event: publishing again with the same
/// `repo_id` updates the announcement (relay overwrites the previous one).
pub fn build_repo_announcement(
repo_id: &str,
name: Option<&str>,
description: Option<&str>,
clone_urls: &[&str],
web_url: Option<&str>,
relays: &[&str],
) -> Result<EventBuilder, SdkError> {
// Validate repo_id
check_repo_id(repo_id)?;
// Validate optional name
if let Some(n) = name {
if n.len() > 128 {
return Err(SdkError::InvalidInput(format!(
"name exceeds 128 characters (got {})",
n.len()
)));
}
}
// Validate optional description
if let Some(d) = description {
if d.len() > 1024 {
return Err(SdkError::InvalidInput(format!(
"description exceeds 1024 characters (got {})",
d.len()
)));
}
}
// Validate clone_urls
if clone_urls.len() > 5 {
return Err(SdkError::InvalidInput(format!(
"too many clone_urls (max 5, got {})",
clone_urls.len()
)));
}
for url in clone_urls {
if url.is_empty() {
return Err(SdkError::InvalidInput("clone_url must not be empty".into()));
}
if url.len() > 512 {
return Err(SdkError::InvalidInput(format!(
"clone_url exceeds 512 characters (got {})",
url.len()
)));
}
}
// Validate web_url
if let Some(url) = web_url {
if !url.starts_with("http://") && !url.starts_with("https://") {
return Err(SdkError::InvalidInput(format!(
"web_url must start with http:// or https:// (got {:?})",
url
)));
}
if url.len() > 512 {
return Err(SdkError::InvalidInput(format!(
"web_url exceeds 512 characters (got {})",
url.len()
)));
}
}
// Validate relays
if relays.len() > 10 {
return Err(SdkError::InvalidInput(format!(
"too many relays (max 10, got {})",
relays.len()
)));
}
for relay in relays {
if !relay.starts_with("ws://") && !relay.starts_with("wss://") {
return Err(SdkError::InvalidInput(format!(
"relay must start with ws:// or wss:// (got {:?})",
relay
)));
}
if relay.len() > 256 {
return Err(SdkError::InvalidInput(format!(
"relay exceeds 256 characters (got {})",
relay.len()
)));
}
}
// Build tags
let mut tags = vec![tag(&["d", repo_id])?];
if let Some(n) = name {
tags.push(tag(&["name", n])?);
}
if let Some(d) = description {
tags.push(tag(&["description", d])?);
}
if !clone_urls.is_empty() {
let mut clone_tag = vec!["clone"];
clone_tag.extend_from_slice(clone_urls);
tags.push(tag(&clone_tag)?);
}
if let Some(url) = web_url {
tags.push(tag(&["web", url])?);
}
if !relays.is_empty() {
let mut relay_tag = vec!["relays"];
relay_tag.extend_from_slice(relays);
tags.push(tag(&relay_tag)?);
}
Ok(EventBuilder::new(Kind::Custom(KIND_GIT_REPO_ANNOUNCEMENT as u16), "").tags(tags))
}
/// Build a repository announcement while preserving caller-supplied metadata.
///
/// This is intended for read-modify-write updates to kind:30617 announcements.
/// Every supplied tag is retained except `d`; all existing `d` tags are replaced
/// with exactly one validated canonical repository identifier.
pub fn build_repo_announcement_with_tags(
repo_id: &str,
content: &str,
mut tags: Vec<Tag>,
) -> Result<EventBuilder, SdkError> {
check_repo_id(repo_id)?;
tags.retain(|tag| tag.as_slice().first().map(String::as_str) != Some("d"));
tags.insert(0, tag(&["d", repo_id])?);
Ok(EventBuilder::new(Kind::Custom(KIND_GIT_REPO_ANNOUNCEMENT as u16), content).tags(tags))
}
/// Repository coordinate — owner pubkey + `d`-tag identifier.
///
/// Renders as the `a`-tag value clients use to address a kind:30617
/// announcement: `30617:<owner>:<id>`.
pub struct GitRepoCoord {
/// 64-char hex pubkey of the repo's announcing owner.
pub owner: String,
/// The repo's `d`-tag identifier.
pub id: String,
}
impl GitRepoCoord {
fn to_a_tag_value(&self) -> Result<String, SdkError> {
let owner = check_pubkey_hex(&self.owner, "repo owner")?;
check_repo_id(&self.id)?;
Ok(format!("30617:{owner}:{}", self.id))
}
}
/// Metadata for a git patch event (kind:1617, NIP-34).
#[derive(Default)]
pub struct GitPatchMeta {
/// Earliest-unique-commit of the repo (`r` tag, `euc` marker).
pub euc: Option<String>,
/// Additional pubkeys to `p`-tag besides the repo owner.
pub recipients: Vec<String>,
/// Previous patch in a series, or the original root patch when this is
/// the first patch of a revision — emits `["e", id, "", "reply"]`.
pub reply_to: Option<String>,
/// First patch in a new series — emits `["t", "root"]`.
pub root: bool,
/// First patch in a revision of an existing series — emits `["t", "root-revision"]`.
pub root_revision: bool,
/// Commit ID this patch produces when applied (`commit` tag + `r` tag).
pub commit: Option<String>,
/// Parent commit ID (`parent-commit` tag).
pub parent_commit: Option<String>,
/// PGP signature of the commit, or `Some("")` for an explicitly unsigned commit.
pub commit_pgp_sig: Option<String>,
/// Committer identity: `(name, email, unix-timestamp, tz-offset-minutes)`.
pub committer: Option<(String, String, String, String)>,
}
/// Build a git patch event (kind:1617, NIP-34).
///
/// `content` is the verbatim output of `git format-patch` — not truncated.
/// NIP-34 says patches SHOULD be used when under 60KB (PRs otherwise); this
/// builder enforces that bound rather than silently truncating a patch that
/// must remain applyable.
pub fn build_git_patch(
repo: &GitRepoCoord,
content: &str,
meta: &GitPatchMeta,
) -> Result<EventBuilder, SdkError> {
if content.trim().is_empty() {
return Err(SdkError::InvalidInput(
"patch content must not be empty — refusing to publish an unappliable patch".into(),
));
}
check_content(content, 60 * 1024)?;
let a_value = repo.to_a_tag_value()?;
let owner = check_pubkey_hex(&repo.owner, "repo owner")?;
let mut tags = vec![tag(&["a", &a_value])?];
if let Some(ref euc) = meta.euc {
check_commit_hex(euc, "euc")?;
tags.push(tag(&["r", euc, "euc"])?);
}
tags.push(tag(&["p", &owner])?);
for recipient in &meta.recipients {
let pk = check_pubkey_hex(recipient, "recipient")?;
tags.push(tag(&["p", &pk])?);
}
if let Some(ref prev) = meta.reply_to {
let event_id = check_hex_exact(prev, 64, "reply_to")?;
tags.push(tag(&["e", &event_id, "", "reply"])?);
}
if meta.root && meta.root_revision {
return Err(SdkError::InvalidInput(
"patch cannot be both --root and --root-revision".into(),
));
}
if meta.root {
tags.push(tag(&["t", "root"])?);
}
if meta.root_revision {
tags.push(tag(&["t", "root-revision"])?);
}
if let Some(ref commit) = meta.commit {
check_commit_hex(commit, "commit")?;
tags.push(tag(&["commit", commit])?);
tags.push(tag(&["r", commit])?);
}
if let Some(ref parent) = meta.parent_commit {
check_commit_hex(parent, "parent_commit")?;
tags.push(tag(&["parent-commit", parent])?);
}
if let Some(ref sig) = meta.commit_pgp_sig {
tags.push(tag(&["commit-pgp-sig", sig])?);
}
if let Some((ref name, ref email, ref ts, ref tz)) = meta.committer {
tags.push(tag(&["committer", name, email, ts, tz])?);
}
Ok(EventBuilder::new(Kind::Custom(KIND_GIT_PATCH as u16), content).tags(tags))
}
/// Metadata for a git issue event (kind:1621, NIP-34).
#[derive(Default)]
pub struct GitIssueMeta {
/// Labels (`t` tags).
pub labels: Vec<String>,
/// Additional pubkeys to `p`-tag besides the repo owner.
pub recipients: Vec<String>,
}
/// Build a git issue event (kind:1621, NIP-34). `content` is the markdown body.
pub fn build_git_issue(
repo: &GitRepoCoord,
subject: &str,
content: &str,
meta: &GitIssueMeta,
) -> Result<EventBuilder, SdkError> {
check_content(content, 64 * 1024)?;
if subject.is_empty() {
return Err(SdkError::InvalidInput("subject must not be empty".into()));
}
if subject.len() > 256 {
return Err(SdkError::InvalidInput(format!(
"subject exceeds 256 characters (got {})",
subject.len()
)));
}
let a_value = repo.to_a_tag_value()?;
let owner = check_pubkey_hex(&repo.owner, "repo owner")?;
let mut tags = vec![tag(&["a", &a_value])?, tag(&["p", &owner])?];
for recipient in &meta.recipients {
let pk = check_pubkey_hex(recipient, "recipient")?;
tags.push(tag(&["p", &pk])?);
}
tags.push(tag(&["subject", subject])?);
for label in &meta.labels {
tags.push(tag(&["t", label])?);
}
Ok(EventBuilder::new(Kind::Custom(KIND_GIT_ISSUE as u16), content).tags(tags))
}
/// Build an issue assignment note (kind:1) — a labeled comment whose `p`
/// tags are the assignees, mirroring the Desktop app's assignment events.
///
/// Tag layout: `["e", <issue>, "", "root"]`, `["a", <repo>]`, one `["p", ..]`
/// per assignee, and `["t", "assignment"]`.
///
/// Clients only trust assignments signed by the issue author or the repo
/// owner (who may assign anyone), or a self-assignment whose sole assignee
/// is the signer. Assignments from other signers are ignored on read.
pub fn build_git_issue_assignment(
repo: &GitRepoCoord,
issue_id: &str,
assignees: &[String],
content: &str,
) -> Result<EventBuilder, SdkError> {
build_git_issue_assignment_with_prior(repo, issue_id, assignees, content, None)
}
/// Build an issue assignment note with an optional causal assignment-operation
/// event ID in a `["prior", <event-id>]` tag.
///
/// `prior`, when present, must be a 64-character hexadecimal event ID.
pub fn build_git_issue_assignment_with_prior(
repo: &GitRepoCoord,
issue_id: &str,
assignees: &[String],
content: &str,
prior: Option<&str>,
) -> Result<EventBuilder, SdkError> {
build_git_issue_assignee_operation(
repo,
issue_id,
assignees,
content,
GitIssueAssigneeOperation::Assign,
prior,
)
}
/// Build an issue unassignment note (kind:1) whose `p` tags name the people
/// being removed and whose operation label is `t: unassignment`.
///
/// Clients trust unassignments signed by the issue author or repository owner,
/// or a self-unassignment whose sole `p` tag is the signer.
pub fn build_git_issue_unassignment(
repo: &GitRepoCoord,
issue_id: &str,
assignees: &[String],
content: &str,
) -> Result<EventBuilder, SdkError> {
build_git_issue_unassignment_with_prior(repo, issue_id, assignees, content, None)
}
/// Build an issue unassignment note with an optional causal
/// assignment-operation event ID in a `["prior", <event-id>]` tag.
///
/// `prior`, when present, must be a 64-character hexadecimal event ID.
pub fn build_git_issue_unassignment_with_prior(
repo: &GitRepoCoord,
issue_id: &str,
assignees: &[String],
content: &str,
prior: Option<&str>,
) -> Result<EventBuilder, SdkError> {
build_git_issue_assignee_operation(
repo,
issue_id,
assignees,
content,
GitIssueAssigneeOperation::Unassign,
prior,
)
}
#[derive(Clone, Copy)]
enum GitIssueAssigneeOperation {
Assign,
Unassign,
}
impl GitIssueAssigneeOperation {
fn label(self) -> &'static str {
match self {
Self::Assign => "assignment",
Self::Unassign => "unassignment",
}
}
}
fn build_git_issue_assignee_operation(
repo: &GitRepoCoord,
issue_id: &str,
assignees: &[String],
content: &str,
operation: GitIssueAssigneeOperation,
prior: Option<&str>,
) -> Result<EventBuilder, SdkError> {
check_content(content, 64 * 1024)?;
let issue = check_hex_exact(issue_id, 64, "issue")?;
let a_value = repo.to_a_tag_value()?;
if assignees.is_empty() || assignees.len() > 50 {
return Err(SdkError::InvalidInput(
"between 1 and 50 assignees are required".into(),
));
}
let mut normalized = assignees
.iter()
.map(|assignee| check_pubkey_hex(assignee, "assignee"))
.collect::<Result<Vec<_>, _>>()?;
normalized.sort();
normalized.dedup();
let mut tags = vec![tag(&["e", &issue, "", "root"])?, tag(&["a", &a_value])?];
for assignee in &normalized {
tags.push(tag(&["p", assignee])?);
}
tags.push(tag(&["t", operation.label()])?);
if let Some(prior) = prior {
let prior = check_hex_exact(prior, 64, "prior assignment operation")?;
tags.push(tag(&["prior", &prior])?);
}
Ok(EventBuilder::new(Kind::Custom(1), content).tags(tags))
}
/// Status to apply to a patch or issue root (kind:1630/1631/1632/1633, NIP-34).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum GitStatus {
/// 1630 — Open (the default state).
Open,
/// 1631 — Applied/Merged for patches; Resolved for issues.
AppliedOrResolved,
/// 1632 — Closed.
Closed,
/// 1633 — Draft.
Draft,
}
/// A reference to an applied/merged patch event for a status `q` tag,
/// optionally carrying a relay-url and/or pubkey hint per NIP-34:
/// `['q', <id>, <relay-url>, <pubkey>]`.
///
/// Parsed from the CLI's `--q <id>[:<relay-url>[:<pubkey>]]` syntax via
/// [`GitAppliedPatchRef::parse`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct GitAppliedPatchRef {
/// The applied/merged patch event id (64-char hex).
pub id: String,
/// Optional relay-url hint where the patch event can be found.
pub relay: Option<String>,
/// Optional pubkey hint of the patch's author. Only meaningful when
/// `relay` is also set, per NIP-34's `['q', id, relay, pubkey]` shape.
pub pubkey: Option<String>,
}
impl GitAppliedPatchRef {
/// Parse `<id>`, `<id>:<relay-url>`, or `<id>:<relay-url>:<pubkey>`.
///
/// The relay-url segment may itself contain `:` (e.g. `wss://host:port`),
/// so splitting is bounded to at most 3 parts rather than splitting on
/// every colon.
pub fn parse(spec: &str) -> Result<Self, SdkError> {
let mut parts = spec.splitn(3, ':');
let id = parts.next().unwrap_or_default().to_string();
let rest = parts.next();
match rest {
None => Ok(GitAppliedPatchRef {
id,
relay: None,
pubkey: None,
}),
Some(_) => {
// Re-split with the relay url glued back together, since a
// relay URL itself contains colons (`wss://host:port`); the
// pubkey, if present, is always the last `:`-delimited
// segment.
let rest_str = &spec[id.len() + 1..];
if let Some(idx) = rest_str.rfind(':') {
let candidate_pubkey = &rest_str[idx + 1..];
if candidate_pubkey.len() == 64
&& candidate_pubkey.chars().all(|c| c.is_ascii_hexdigit())
{
return Ok(GitAppliedPatchRef {
id,
relay: Some(rest_str[..idx].to_string()),
pubkey: Some(candidate_pubkey.to_ascii_lowercase()),
});
}
}
Ok(GitAppliedPatchRef {
id,
relay: Some(rest_str.to_string()),
pubkey: None,
})
}
}
}
}
impl GitStatus {
fn kind(self) -> u16 {
match self {
GitStatus::Open => KIND_GIT_STATUS_OPEN as u16,
GitStatus::AppliedOrResolved => KIND_GIT_STATUS_MERGED as u16,
GitStatus::Closed => KIND_GIT_STATUS_CLOSED as u16,
GitStatus::Draft => KIND_GIT_STATUS_DRAFT as u16,
}
}
}
/// Metadata for a git status event (kind:1630-1633, NIP-34). Applies to a
/// patch root, a patch revision root, an issue, or a PR.
#[derive(Default)]
pub struct GitStatusMeta {
/// The issue/PR/original-root-patch event being given a status — required.
pub root_event: String,
/// When a revision was the one applied/merged, its root id.
pub accepted_revision_root: Option<String>,
/// Repo coordinate, included as an `a` tag for subscription efficiency.
pub repo: Option<GitRepoCoord>,
/// Earliest-unique-commit of the repo (`r` tag, no marker).
pub euc: Option<String>,
/// Additional `p` tags (root author, revision author, etc.) besides the repo owner.
pub recipients: Vec<String>,
/// Applied/merged patch event references (`q` tags) — kind:1631 only.
pub applied_patches: Vec<GitAppliedPatchRef>,
/// Merge commit id — kind:1631 only.
pub merge_commit: Option<String>,
/// Commit ids applied to the target branch — kind:1631 only.
pub applied_as_commits: Vec<String>,
}
/// Build a git status event (kind:1630/1631/1632/1633, NIP-34).
/// `content` is optional markdown context for the status change.
pub fn build_git_status(
status: GitStatus,
content: &str,
meta: &GitStatusMeta,
) -> Result<EventBuilder, SdkError> {
check_content(content, 64 * 1024)?;
let root = check_hex_exact(&meta.root_event, 64, "root_event")?;
let mut tags = vec![tag(&["e", &root, "", "root"])?];
if let Some(ref revision) = meta.accepted_revision_root {
let revision = check_hex_exact(revision, 64, "accepted_revision_root")?;
tags.push(tag(&["e", &revision, "", "reply"])?);
}
for recipient in &meta.recipients {
let pk = check_pubkey_hex(recipient, "recipient")?;
tags.push(tag(&["p", &pk])?);
}
if let Some(ref repo) = meta.repo {
let a_value = repo.to_a_tag_value()?;
tags.push(tag(&["a", &a_value])?);
}
if let Some(ref euc) = meta.euc {
check_commit_hex(euc, "euc")?;
tags.push(tag(&["r", euc])?);
}
if status != GitStatus::AppliedOrResolved
&& (!meta.applied_patches.is_empty()
|| meta.merge_commit.is_some()
|| !meta.applied_as_commits.is_empty())
{
return Err(SdkError::InvalidInput(
"applied_patches/merge_commit/applied_as_commits only apply to the merged/resolved status".into(),
));
}
for patch_ref in &meta.applied_patches {
let patch_id = check_hex_exact(&patch_ref.id, 64, "applied_patch")?;
match (&patch_ref.relay, &patch_ref.pubkey) {
(None, None) => tags.push(tag(&["q", &patch_id])?),
(Some(relay), None) => tags.push(tag(&["q", &patch_id, relay])?),
(Some(relay), Some(pubkey)) => {
let pubkey = check_pubkey_hex(pubkey, "applied_patch pubkey")?;
tags.push(tag(&["q", &patch_id, relay, &pubkey])?)
}
(None, Some(_)) => {
return Err(SdkError::InvalidInput(
"applied_patch pubkey hint requires a relay-url hint".into(),
))
}
}
}
if let Some(ref merge_commit) = meta.merge_commit {
check_commit_hex(merge_commit, "merge_commit")?;
tags.push(tag(&["merge-commit", merge_commit])?);
tags.push(tag(&["r", merge_commit])?);
}
if !meta.applied_as_commits.is_empty() {
let mut commits_tag = vec!["applied-as-commits"];
for commit in &meta.applied_as_commits {
check_commit_hex(commit, "applied_as_commit")?;
}
commits_tag.extend(meta.applied_as_commits.iter().map(String::as_str));
tags.push(tag(&commits_tag)?);
for commit in &meta.applied_as_commits {
tags.push(tag(&["r", commit])?);
}
}
Ok(EventBuilder::new(Kind::Custom(status.kind()), content).tags(tags))
}
/// Metadata for a git pull-request event (kind:1618, NIP-34).
///
/// A PR points reviewers at a branch tip they can fetch — `commit` (the tip)
/// plus at least one `clone_urls` entry where that commit is reachable. Unlike
/// a [`build_git_patch`], the change is *not* inlined; the diff is whatever the
/// tip introduces over its merge base. Per NIP-34 the tip SHOULD already be
/// pushed to `refs/nostr/<pr-event-id>` (or otherwise reachable) in the clone
/// repos before the event is signed — this builder does no network work and
/// does not verify reachability, mirroring [`build_git_patch`]'s philosophy.
#[derive(Default)]
pub struct GitPullRequestMeta {
/// Earliest-unique-commit of the repo (`r` tag) — lets clients subscribe
/// to all PRs against a local repo.
pub euc: Option<String>,
/// Additional pubkeys to `p`-tag besides the repo owner.
pub recipients: Vec<String>,
/// NIP-29 channel where the pull request originated (`h` tag).
pub channel_id: Option<String>,
/// PR subject line (`subject` tag) — required, used as the header.
pub subject: String,
/// Labels (`t` tags).
pub labels: Vec<String>,
/// Tip commit id of the PR branch (`c` tag) — required.
pub commit: String,
/// Clone URL(s) where the tip can be fetched (`clone` tag) — at least one.
pub clone_urls: Vec<String>,
/// Recommended branch name (`branch-name` tag).
pub branch_name: Option<String>,
/// Most recent common ancestor with the target branch (`merge-base` tag).
pub merge_base: Option<String>,
/// Root patch event this PR revises, which should then be closed
/// (`e` tag) — optional.
pub revision_of: Option<String>,
}
/// Build a git pull-request event (kind:1618, NIP-34). `content` is the
/// markdown PR description.
pub fn build_git_pull_request(
repo: &GitRepoCoord,
content: &str,
meta: &GitPullRequestMeta,
) -> Result<EventBuilder, SdkError> {
check_content(content, 64 * 1024)?;
if meta.subject.is_empty() {
return Err(SdkError::InvalidInput("subject must not be empty".into()));
}
if meta.subject.len() > 256 {
return Err(SdkError::InvalidInput(format!(
"subject exceeds 256 characters (got {})",
meta.subject.len()
)));
}
check_commit_hex(&meta.commit, "commit")?;
if meta.clone_urls.is_empty() {
return Err(SdkError::InvalidInput(
"a pull request needs at least one --clone url where the tip commit can be fetched"
.into(),
));
}
let a_value = repo.to_a_tag_value()?;
let owner = check_pubkey_hex(&repo.owner, "repo owner")?;
let mut tags = vec![tag(&["a", &a_value])?];
if let Some(ref euc) = meta.euc {
check_commit_hex(euc, "euc")?;
tags.push(tag(&["r", euc])?);
}
tags.push(tag(&["p", &owner])?);
for recipient in &meta.recipients {
let pk = check_pubkey_hex(recipient, "recipient")?;
tags.push(tag(&["p", &pk])?);
}
tags.push(tag(&["subject", &meta.subject])?);
for label in &meta.labels {
tags.push(tag(&["t", label])?);
}
tags.push(tag(&["c", &meta.commit])?);
if let Some(ref channel_id) = meta.channel_id {
let channel_id = Uuid::parse_str(channel_id)
.map_err(|e| SdkError::InvalidInput(format!("channel_id must be a valid UUID: {e}")))?;
tags.push(tag(&["h", &channel_id.to_string()])?);
}
let mut clone_tag = vec!["clone"];
clone_tag.extend(meta.clone_urls.iter().map(String::as_str));
tags.push(tag(&clone_tag)?);
if let Some(ref branch) = meta.branch_name {
tags.push(tag(&["branch-name", branch])?);
}
if let Some(ref base) = meta.merge_base {
check_commit_hex(base, "merge_base")?;
tags.push(tag(&["merge-base", base])?);
}
if let Some(ref patch) = meta.revision_of {
let patch_id = check_hex_exact(patch, 64, "revision_of")?;
tags.push(tag(&["e", &patch_id])?);
}
Ok(EventBuilder::new(Kind::Custom(KIND_GIT_PULL_REQUEST as u16), content).tags(tags))
}
/// Metadata for a git pull-request update event (kind:1619, NIP-34). A PR
/// update changes the tip commit of an existing PR; it references the PR via
/// NIP-22 uppercase root tags (`E`/`P`).
#[derive(Default)]
pub struct GitPrUpdateMeta {
/// Earliest-unique-commit of the repo (`r` tag).
pub euc: Option<String>,
/// Additional pubkeys to `p`-tag besides the repo owner.
pub recipients: Vec<String>,
/// The pull-request event being updated (`E` tag, NIP-22 root) — required.
pub pr_event: String,
/// The pull-request author (`P` tag, NIP-22 root) — required.
pub pr_author: String,
/// Updated tip commit id (`c` tag) — required.
pub commit: String,
/// Clone URL(s) where the new tip can be fetched (`clone` tag) — at least one.
pub clone_urls: Vec<String>,
/// Most recent common ancestor with the target branch (`merge-base` tag).
pub merge_base: Option<String>,
}
/// Build a git pull-request update event (kind:1619, NIP-34). `content` is
/// optional markdown context for the update.
pub fn build_git_pr_update(
repo: &GitRepoCoord,
content: &str,
meta: &GitPrUpdateMeta,
) -> Result<EventBuilder, SdkError> {
check_content(content, 64 * 1024)?;
let pr_event = check_hex_exact(&meta.pr_event, 64, "pr_event")?;
let pr_author = check_pubkey_hex(&meta.pr_author, "pr_author")?;
check_commit_hex(&meta.commit, "commit")?;
if meta.clone_urls.is_empty() {
return Err(SdkError::InvalidInput(
"a pull request update needs at least one --clone url where the tip commit can be fetched"
.into(),
));
}
let a_value = repo.to_a_tag_value()?;
let owner = check_pubkey_hex(&repo.owner, "repo owner")?;
let mut tags = vec![tag(&["a", &a_value])?];
if let Some(ref euc) = meta.euc {
check_commit_hex(euc, "euc")?;
tags.push(tag(&["r", euc])?);
}
tags.push(tag(&["p", &owner])?);
for recipient in &meta.recipients {
let pk = check_pubkey_hex(recipient, "recipient")?;
tags.push(tag(&["p", &pk])?);
}
tags.push(tag(&["E", &pr_event])?);
tags.push(tag(&["P", &pr_author])?);
tags.push(tag(&["c", &meta.commit])?);
let mut clone_tag = vec!["clone"];
clone_tag.extend(meta.clone_urls.iter().map(String::as_str));
tags.push(tag(&clone_tag)?);
if let Some(ref base) = meta.merge_base {
check_commit_hex(base, "merge_base")?;
tags.push(tag(&["merge-base", base])?);
}
Ok(EventBuilder::new(Kind::Custom(KIND_GIT_PR_UPDATE as u16), content).tags(tags))
}
/// Build a workflow definition event (kind 30620).
///
/// - `channel_id`: the channel this workflow belongs to (h-tag)
/// - `workflow_id`: unique workflow UUID (d-tag)
/// - `yaml`: workflow YAML definition as content
pub fn build_workflow_def(
channel_id: Uuid,
workflow_id: Uuid,
yaml: &str,
) -> Result<EventBuilder, SdkError> {
check_content(yaml, 64 * 1024)?;
let tags = vec![
tag(&["d", &workflow_id.to_string()])?,
tag(&["h", &channel_id.to_string()])?,
];
Ok(EventBuilder::new(Kind::Custom(KIND_WORKFLOW_DEF as u16), yaml).tags(tags))
}
/// Build a workflow update event (kind 30620) for an existing workflow.
///
/// Updates an existing workflow definition in-place via the parameterized
/// replaceable event mechanism — same d-tag overwrites the previous version.
/// The h-tag (channel scope) is required by the relay for authorization.
pub fn build_workflow_update(
channel_id: Uuid,
workflow_id: Uuid,
yaml: &str,
expected_revision: &str,
) -> Result<EventBuilder, SdkError> {
check_content(yaml, 64 * 1024)?;
let tags = vec![
tag(&["d", &workflow_id.to_string()])?,
tag(&["h", &channel_id.to_string()])?,
tag(&["expected-revision", expected_revision])?,
];
Ok(EventBuilder::new(Kind::Custom(KIND_WORKFLOW_DEF as u16), yaml).tags(tags))
}
/// Build a NIP-09 deletion event targeting a workflow definition (kind 5).
///
/// The `a`-tag addresses the parameterized replaceable event
/// `<KIND_WORKFLOW_DEF>:<pubkey>:<workflow_id>`.
pub fn build_workflow_delete(
author_pubkey: &str,
workflow_id: Uuid,
) -> Result<EventBuilder, SdkError> {
build_delete_addressable(KIND_WORKFLOW_DEF, author_pubkey, &workflow_id.to_string())
}
/// Build a workflow trigger event (kind 46020).
pub fn build_workflow_trigger(workflow_id: Uuid) -> Result<EventBuilder, SdkError> {
let tags = vec![tag(&["d", &workflow_id.to_string()])?];
Ok(EventBuilder::new(Kind::Custom(KIND_WORKFLOW_TRIGGER as u16), "").tags(tags))
}
/// Build a workflow approval event — kind 46030 (grant) or 46031 (deny).
///
/// - `token_hash`: hex-encoded SHA-256 of the approval token UUID (d-tag).
/// Must be exactly 64 hex characters.
/// - `approved`: `true` emits kind 46030 (grant), `false` emits kind 46031 (deny)
/// - `note`: optional human-readable note as event content
pub fn build_workflow_approval(
token_hash: &str,
approved: bool,
note: &str,
) -> Result<EventBuilder, SdkError> {
if token_hash.len() != 64 || !token_hash.chars().all(|c| c.is_ascii_hexdigit()) {
return Err(SdkError::InvalidInput(
"token_hash must be a 64-character hex SHA-256 digest".into(),
));
}
let kind = if approved {
KIND_APPROVAL_GRANT
} else {
KIND_APPROVAL_DENY
};
let tags = vec![tag(&["d", token_hash])?];
Ok(EventBuilder::new(Kind::Custom(kind as u16), note).tags(tags))
}
/// Build a DM open event (kind 41010).
///
/// `pubkeys` must be 1–8 hex-encoded pubkeys to include in the DM conversation.
pub fn build_dm_open(pubkeys: &[&str]) -> Result<EventBuilder, SdkError> {
if pubkeys.is_empty() || pubkeys.len() > 8 {
return Err(SdkError::InvalidInput(
"dm open requires 1-8 pubkeys".into(),
));
}
let mut tags = Vec::with_capacity(pubkeys.len());
for pk in pubkeys {
let validated = check_pubkey_hex(pk, "pubkey")?;
tags.push(tag(&["p", &validated])?);
}
Ok(EventBuilder::new(Kind::Custom(KIND_DM_OPEN as u16), "").tags(tags))
}
/// Build a DM add-member event (kind 41011).
pub fn build_dm_add_member(channel_id: Uuid, pubkey: &str) -> Result<EventBuilder, SdkError> {
let pk = check_pubkey_hex(pubkey, "pubkey")?;
let tags = vec![tag(&["h", &channel_id.to_string()])?, tag(&["p", &pk])?];
Ok(EventBuilder::new(Kind::Custom(KIND_DM_ADD_MEMBER as u16), "").tags(tags))
}
/// Build a presence update event (kind 20001).
///
/// `status` must be one of: `"online"`, `"away"`, `"offline"`.
/// The status is placed in `event.content` (relay reads it there) and also
/// in a `["status", ...]` tag for structured access.
pub fn build_presence_update(status: &str) -> Result<EventBuilder, SdkError> {
match status {
"online" | "away" | "offline" => {}
_ => {
return Err(SdkError::InvalidInput(format!(
"status must be online, away, or offline (got: {status})"
)))
}
}
let tags = vec![tag(&["status", status])?];
Ok(EventBuilder::new(Kind::Custom(KIND_PRESENCE_UPDATE as u16), status).tags(tags))
}
/// Build a NIP-38 user status event (kind 30315) on the `d:general` coordinate.
///
/// `text` becomes the event content and `emoji`, when non-blank, an
/// `["emoji", ...]` tag; both are trimmed. Blank text with no emoji clears the
/// status — kind 30315 is parameterized-replaceable, so an event carrying
/// neither is what clients read as "no status".
pub fn build_user_status(text: &str, emoji: Option<&str>) -> Result<EventBuilder, SdkError> {
let text = text.trim();
check_content(text, 64 * 1024)?;
let mut tags = vec![tag(&["d", "general"])?];
if let Some(emoji) = emoji.map(str::trim).filter(|e| !e.is_empty()) {
tags.push(tag(&["emoji", emoji])?);
}
Ok(EventBuilder::new(Kind::Custom(KIND_USER_STATUS as u16), text).tags(tags))
}
// ---------------------------------------------------------------------------
// Community moderation commands (kinds 9040–9044).
//
// These mirror the NIP-43 relay-admin 9030-series: mod-signed command events
// that the relay validates + executes directly and never stores. The tenant
// (community) is bound by the connection host, so no `h` tag is carried — a
// stray `h` would be rejected as channel-scoping a global-only command. The
// tag vocabulary below is pinned by `moderation_commands.rs` (relay and CLI
// must agree).
// ---------------------------------------------------------------------------
/// Build a community ban command (kind 9040).
///
/// `expires_at`: `None` ⇒ permanent; `Some(unix_secs)` ⇒ ban lifts at that time.
pub fn build_moderation_ban(
target_pubkey: &str,
expires_at: Option<u64>,
reason: Option<&str>,
) -> Result<EventBuilder, SdkError> {
let target_pubkey = check_pubkey_hex(target_pubkey, "target_pubkey")?;
let mut tags = vec![tag(&["p", &target_pubkey])?];
if let Some(exp) = expires_at {
tags.push(tag(&["expiration", &exp.to_string()])?);
}
if let Some(r) = reason {
tags.push(tag(&["reason", r])?);
}
Ok(EventBuilder::new(Kind::Custom(KIND_MODERATION_BAN as u16), "").tags(tags))
}
/// Build a community unban command (kind 9041).
pub fn build_moderation_unban(target_pubkey: &str) -> Result<EventBuilder, SdkError> {
let target_pubkey = check_pubkey_hex(target_pubkey, "target_pubkey")?;
let tags = vec![tag(&["p", &target_pubkey])?];
Ok(EventBuilder::new(Kind::Custom(KIND_MODERATION_UNBAN as u16), "").tags(tags))
}
/// Build a community timeout (write-block) command (kind 9042).
///
/// `expires_at` (required) is the unix-seconds timestamp the timeout lifts at.
pub fn build_moderation_timeout(
target_pubkey: &str,
expires_at: u64,
reason: Option<&str>,
) -> Result<EventBuilder, SdkError> {
let target_pubkey = check_pubkey_hex(target_pubkey, "target_pubkey")?;
let mut tags = vec![
tag(&["p", &target_pubkey])?,
tag(&["expiration", &expires_at.to_string()])?,
];
if let Some(r) = reason {
tags.push(tag(&["reason", r])?);
}
Ok(EventBuilder::new(Kind::Custom(KIND_MODERATION_TIMEOUT as u16), "").tags(tags))
}
/// Build a community untimeout command (kind 9043).
pub fn build_moderation_untimeout(target_pubkey: &str) -> Result<EventBuilder, SdkError> {
let target_pubkey = check_pubkey_hex(target_pubkey, "target_pubkey")?;
let tags = vec![tag(&["p", &target_pubkey])?];
Ok(EventBuilder::new(Kind::Custom(KIND_MODERATION_UNTIMEOUT as u16), "").tags(tags))
}
/// Build a resolve-report command (kind 9044).
///
/// `report_event_id`: hex event id of the kind:1984 report being resolved.
/// `status`: `resolved` | `dismissed`. `action`: `delete` | `kick` | `ban` |
/// `timeout` | `dismiss` | `escalate` (`dismiss` pairs with `dismissed`,
/// everything else with `resolved` — the relay enforces the pairing).
/// `reason`: optional; audited into `public_reason` and relayed in the
/// reporter notice DM, so it must be safe for the reporter to read.
pub fn build_moderation_resolve_report(
report_event_id: &str,
status: &str,
action: &str,
reason: Option<&str>,
) -> Result<EventBuilder, SdkError> {
let report_event_id = check_hex_exact(report_event_id, 64, "report_event_id")?;
match status {
"resolved" | "dismissed" => {}
_ => {
return Err(SdkError::InvalidInput(format!(
"status must be resolved or dismissed (got: {status})"
)))
}
}
match action {
"delete" | "kick" | "ban" | "timeout" | "dismiss" | "escalate" => {}
_ => {
return Err(SdkError::InvalidInput(format!(
"action must be delete, kick, ban, timeout, dismiss, or escalate (got: {action})"
)))
}
}
let mut tags = vec![
tag(&["report", &report_event_id])?,
tag(&["status", status])?,
tag(&["action", action])?,
];
if let Some(r) = reason {
tags.push(tag(&["reason", r])?);
}
Ok(EventBuilder::new(Kind::Custom(KIND_MODERATION_RESOLVE_REPORT as u16), "").tags(tags))
}
// ---------------------------------------------------------------------------
// NIP-IA identity archival (kinds 9035/9036).
//
// kind:9035 archive request, kind:9036 unarchive request. Both protected by
// NIP-70 (`["-"]`), p-tag the target, and may carry an optional machine-
// readable `reason` code, a `replaced-by` rotation pointer (9035 only), and a
// NIP-OA `auth` tag for owner-of-agent requests. The relay verifies; this
// builder's job is to produce a well-formed, signed request — the relay
// selects the consent path (self / admin / owner). Mirrors the desktop's
// `identity_archive_tags`/`build_archive_identity_request` (events.rs:624-743)
// so both clients emit the same wire form.
// ---------------------------------------------------------------------------
/// Maximum `reason` length in UTF-8 bytes (not chars — see
/// `desktop/src-tauri/src/events.rs:635-647`, whose `.len()` check is
/// already byte-based despite its error text saying "chars").
const MAX_REASON_BYTES: usize = 64;
fn check_reason(reason: &str) -> Result<(), SdkError> {
if reason.len() > MAX_REASON_BYTES {
return Err(SdkError::InvalidInput(format!(
"reason code exceeds maximum length of {MAX_REASON_BYTES} UTF-8 bytes (got {})",
reason.len()
)));
}
if reason.chars().any(|c| c.is_control()) {
return Err(SdkError::InvalidInput(
"reason code must not contain control characters".into(),
));
}
Ok(())
}
/// Structural check only — the relay performs full NIP-OA verification.
/// Requires the `auth` label, a 64-hex owner pubkey, and a 128-hex signature.
fn check_auth_tag_shape(auth: &[String; 4]) -> Result<(), SdkError> {
if auth[0] != "auth" {
return Err(SdkError::InvalidInput(format!(
"auth tag label must be \"auth\" (got \"{}\")",
auth[0]
)));
}
check_pubkey_hex(&auth[1], "auth tag owner pubkey")?;
if auth[3].len() != 128 || !auth[3].chars().all(|c| c.is_ascii_hexdigit()) {
return Err(SdkError::InvalidInput(
"auth tag signature must be 128-character hex".into(),
));
}
Ok(())
}
fn identity_archive_tags(
target_pubkey: &str,
reason: Option<&str>,
replaced_by: Option<&str>,
auth: Option<&[String; 4]>,
) -> Result<Vec<Tag>, SdkError> {
let target_lower = check_pubkey_hex(target_pubkey, "target_pubkey")?;
// NIP-70: mark as protected administrative state.
let mut tags = vec![tag(&["-"])?, tag(&["p", &target_lower])?];
if let Some(r) = reason {
check_reason(r)?;
tags.push(tag(&["reason", r])?);
}
if let Some(rb) = replaced_by {
let rb_lower = check_pubkey_hex(rb, "replaced_by")?;
if rb_lower == target_lower {
return Err(SdkError::InvalidInput(
"replaced-by must differ from the target".into(),
));
}
tags.push(tag(&["replaced-by", &rb_lower])?);
}
if let Some(auth_tag) = auth {
check_auth_tag_shape(auth_tag)?;
tags.push(tag(&["auth", &auth_tag[1], &auth_tag[2], &auth_tag[3]])?);
}
Ok(tags)
}
/// Build a NIP-IA archive request (kind 9035).
///
/// `content` is an optional human-readable reason (clients MUST NOT parse
/// authorization semantics from it; capped at 64 KiB like other content
/// fields). `reason` is a machine-readable code (`rotated`, `retired`,
/// `bot-rebuilt`, `left-organization`, `spam`, ... — unknown codes are
/// allowed per spec), capped at 64 UTF-8 bytes. `replaced_by` is the
/// rotation pointer and must differ from `target_pubkey`. `auth` is a
/// NIP-OA owner-attestation tag required only for the owner-of-agent
/// consent path; full verification happens relay-side.
///
/// `.allow_self_tagging()` is required: NIP-IA's self path has
/// `actor == target`, so the request's `["p", target]` matches the signer.
/// nostr 0.44 strips matching `p` tags by default — this keeps the wire
/// form intact.
pub fn build_archive_identity_request(
target_pubkey: &str,
content: &str,
reason: Option<&str>,
replaced_by: Option<&str>,
auth: Option<&[String; 4]>,
) -> Result<EventBuilder, SdkError> {
check_content(content, 64 * 1024)?;
let tags = identity_archive_tags(target_pubkey, reason, replaced_by, auth)?;
Ok(
EventBuilder::new(Kind::Custom(KIND_IA_ARCHIVE_REQUEST as u16), content)
.tags(tags)
.allow_self_tagging(),
)
}
/// Build a NIP-IA unarchive request (kind 9036).
///
/// Same shape as [`build_archive_identity_request`] minus `replaced-by`
/// (which has no defined meaning on unarchive per spec). `auth` is used for
/// owner-of-agent unarchive paths. See that function for the rationale on
/// `.allow_self_tagging()`.
pub fn build_unarchive_identity_request(
target_pubkey: &str,
content: &str,
reason: Option<&str>,
auth: Option<&[String; 4]>,
) -> Result<EventBuilder, SdkError> {
check_content(content, 64 * 1024)?;
let tags = identity_archive_tags(target_pubkey, reason, None, auth)?;
Ok(
EventBuilder::new(Kind::Custom(KIND_IA_UNARCHIVE_REQUEST as u16), content)
.tags(tags)
.allow_self_tagging(),
)
}
// ─── NIP-MP: Multi-repo projects (kind:30621) ────────────────────────────────
//
// Public surface:
// • `validate_project_envelope` — Layer A protocol validator (8 ingest rules)
// • `build_project_with_tags` — Layer A raw builder (content + tags, no canonicalization)
// • `ProjectMemberCoord` — parsed member coordinate + optional relay hint
// • `build_project` — Layer B writer-policy builder
// • `build_delete_addressable` — generic NIP-09 kind:5 coordinate delete
//
// Byte-length bounds from NIP-MP §Relay Processing:
/// Maximum byte length of a project `d` tag value.
pub const PROJECT_D_MAX_LEN: usize = 1024;
/// Maximum byte length of a project `name` tag value.
pub const PROJECT_NAME_MAX: usize = 256;
/// Maximum byte length of a project `description` tag value.
pub const PROJECT_DESCRIPTION_MAX: usize = 2048;
/// Maximum byte length of a project `buzz-channel` tag value.
pub const PROJECT_CHANNEL_MAX: usize = 256;
/// Maximum byte length of a project `buzz-visibility` tag value.
pub const PROJECT_VISIBILITY_MAX: usize = 256;
/// Maximum number of `a` member tags per project event (checked before dedup).
pub const PROJECT_MEMBER_CAP: usize = 64;
/// A validated NIP-MP member `a`-tag coordinate with an optional relay hint.
///
/// Equality and `Hash` are by `coord` only (per spec: duplicate detection ignores hint).
#[derive(Clone, Debug)]
pub struct ProjectMemberCoord {
/// The full `30617:<owner-hex>:<repo-d>` coordinate string.
pub coord: String,
/// Optional opaque relay hint (third `a`-tag element, never validated by content).
pub hint: Option<String>,
}
impl PartialEq for ProjectMemberCoord {
fn eq(&self, other: &Self) -> bool {
self.coord == other.coord
}
}
impl Eq for ProjectMemberCoord {}
impl std::hash::Hash for ProjectMemberCoord {
fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
self.coord.hash(state);
}
}
impl ProjectMemberCoord {
/// Parse a full `30617:<owner-hex>:<repo-d>` coordinate string.
///
/// Accepts an optional relay hint as the third colon-separated element
/// after the split, but the split is always first-two-colons: kind, owner,
/// everything-else-as-repo-d.
///
/// Rules enforced:
/// - Exactly three segments after splitting on the first two colons
/// - First segment must be the literal string `"30617"`
/// - Second segment must be exactly 64 lowercase hex characters
/// - Third segment (repo-d) must be non-empty
/// - Uppercase owners are rejected (never normalized)
pub fn parse_full(coord: &str) -> Result<Self, SdkError> {
// Split on first two colons only: kind:owner:rest
let mut parts = coord.splitn(3, ':');
let kind_part = parts.next().unwrap_or("");
let owner_part = parts.next().unwrap_or("");
let rest = parts.next().unwrap_or("");
if kind_part != "30617" {
return Err(SdkError::InvalidInput(format!(
"member coordinate must start with '30617:' (got kind {kind_part:?})"
)));
}
if owner_part.len() != 64 || !owner_part.chars().all(|c| c.is_ascii_hexdigit()) {
return Err(SdkError::InvalidInput(format!(
"member owner must be a 64-character hex pubkey (got {owner_part:?})"
)));
}
// Reject uppercase (spec: lowercase hex required)
if owner_part.chars().any(|c| c.is_ascii_uppercase()) {
return Err(SdkError::InvalidInput(
"member owner hex must be lowercase".into(),
));
}
if rest.is_empty() {
return Err(SdkError::InvalidInput(
"member coordinate repo-d must not be empty".into(),
));
}
Ok(ProjectMemberCoord {
coord: format!("30617:{owner_part}:{rest}"),
hint: None,
})
}
/// Returns the `a`-tag element slice: `[coord]` or `[coord, hint]`.
pub fn to_tag_parts(&self) -> Vec<String> {
let mut parts = vec!["a".to_string(), self.coord.clone()];
if let Some(h) = &self.hint {
parts.push(h.clone());
}
parts
}
}
/// **Layer A**: Validate a complete kind:30621 envelope against the 8 NIP-MP
/// ingest rules. This is the single source of protocol truth used by both
/// `build_project_with_tags` (raw path) and `build_project` (policy path).
///
/// Rules enforced (matches relay `buzz-db` ingest logic):
/// 1. `d` cardinality: exactly one `d` tag.
/// 2. `d` value: non-empty, ≤1024 bytes.
/// 3. Member cap: raw count of every `a` tag ≤ 64 (checked **before** per-tag
/// parsing, matching relay rule order).
/// 4. Member tag arity: every `a` tag has 2 or 3 elements (no more, no fewer).
/// 5. Member coordinate grammar: first-two-colons split; kind literal `"30617"`;
/// owner lowercase 64-hex; repo-d non-empty verbatim.
/// 6. Member deduplication: coordinate equality only (hint ignored); any
/// coordinate that appears more than once is a duplicate.
/// 7. Singleton metadata: each of `name`, `description`, `buzz-channel`,
/// `buzz-visibility` appears at most once.
/// 8. Metadata byte lengths: `name` ≤256, `description` ≤2048,
/// `buzz-channel` ≤256, `buzz-visibility` ≤256.
pub fn validate_project_envelope(tags: &[Tag], _content: &str) -> Result<(), SdkError> {
// --- Rule 1 & 2: d tag ---
let d_tags: Vec<&Tag> = tags.iter().filter(|t| tag_name(t) == Some("d")).collect();
match d_tags.len() {
0 => {
return Err(SdkError::InvalidInput(
"project must have exactly one 'd' tag (rule: d-cardinality)".into(),
))
}
1 => {}
_ => {
return Err(SdkError::InvalidInput(
"project must have exactly one 'd' tag (rule: d-cardinality)".into(),
))
}
}
let d_val = tag_value(d_tags[0]).unwrap_or("");
if d_val.is_empty() {
return Err(SdkError::InvalidInput(
"project 'd' tag must not be empty (rule: d-empty)".into(),
));
}
if d_val.len() > PROJECT_D_MAX_LEN {
return Err(SdkError::InvalidInput(format!(
"project 'd' tag exceeds {PROJECT_D_MAX_LEN} bytes (rule: d-empty)"
)));
}
let a_tags: Vec<&Tag> = tags.iter().filter(|t| tag_name(t) == Some("a")).collect();
// --- Rule 3: member cap (checked before per-tag parsing, matching relay rule order) ---
if a_tags.len() > PROJECT_MEMBER_CAP {
return Err(SdkError::InvalidInput(format!(
"project exceeds member cap of {PROJECT_MEMBER_CAP} (got {}) (rule: member-cap)",
a_tags.len()
)));
}
// --- Rule 4: member arity ---
for a in &a_tags {
let len = a.as_slice().len() - 1; // exclude the "a" name element
if !(1..=2).contains(&len) {
return Err(SdkError::InvalidInput(format!(
"member 'a' tag must have 1 or 2 value elements (got {len}) (rule: member-tag-arity)"
)));
}
}
// --- Rules 5 & 6: coordinate grammar + deduplication ---
let mut seen_coords: std::collections::HashSet<String> = std::collections::HashSet::new();
for a in &a_tags {
let coord_val = tag_value(a).unwrap_or("");
ProjectMemberCoord::parse_full(coord_val).map_err(|e| {
SdkError::InvalidInput(format!("{e} (rule: member-coordinate-malformed)"))
})?;
if !seen_coords.insert(coord_val.to_string()) {
return Err(SdkError::InvalidInput(format!(
"duplicate member coordinate {coord_val:?} (rule: member-duplicate)"
)));
}
}
// --- Rules 7 & 8: singleton metadata + byte bounds ---
let singleton_fields = [
(
"name",
PROJECT_NAME_MAX,
"metadata-cardinality",
"metadata-length",
),
(
"description",
PROJECT_DESCRIPTION_MAX,
"metadata-cardinality",
"metadata-length",
),
(
"buzz-channel",
PROJECT_CHANNEL_MAX,
"metadata-cardinality",
"metadata-length",
),
(
"buzz-visibility",
PROJECT_VISIBILITY_MAX,
"metadata-cardinality",
"metadata-length",
),
];
for (field, max_bytes, card_rule, len_rule) in singleton_fields {
let matches: Vec<&Tag> = tags.iter().filter(|t| tag_name(t) == Some(field)).collect();
if matches.len() > 1 {
return Err(SdkError::InvalidInput(format!(
"project must have at most one '{field}' tag (rule: {card_rule})"
)));
}
if let Some(t) = matches.first() {
let val = tag_value(t).unwrap_or("");
if val.len() > max_bytes {
return Err(SdkError::InvalidInput(format!(
"'{field}' tag exceeds {max_bytes} bytes (rule: {len_rule})"
)));
}
}
}
Ok(())
}
/// Helper: tag name (first element).
fn tag_name(tag: &Tag) -> Option<&str> {
tag.as_slice().first().map(String::as_str)
}
/// Helper: tag value (second element).
fn tag_value(tag: &Tag) -> Option<&str> {
tag.as_slice().get(1).map(String::as_str)
}
/// **Layer A raw builder**: Build a kind:30621 project event from a raw
/// `content` string and a raw `tags` slice, without any canonicalization.
///
/// Validates the entire envelope through `validate_project_envelope` before
/// accepting it. The caller is responsible for supplying the correct `d` tag.
/// This is the path exercised by fixture conformance tests and by read-modify-
/// write mutations in the CLI.
pub fn build_project_with_tags(content: &str, tags: Vec<Tag>) -> Result<EventBuilder, SdkError> {
validate_project_envelope(&tags, content)?;
Ok(EventBuilder::new(Kind::Custom(KIND_PROJECT as u16), content).tags(tags))
}
/// **Layer B writer-policy builder**: Build a kind:30621 project event with
/// enforced writer policy:
/// - The `d` tag is constructed from `slug`; `check_project_slug` rejects
/// an empty or over-length slug.
/// - `channel` must be a valid UUID string.
/// - `visibility` must be `"listed"` or `"unlisted"`.
/// - Content is always empty.
/// - Member coordinates are parsed through `ProjectMemberCoord::parse_full`.
///
/// The resulting envelope is validated through Layer A before the builder is
/// returned.
pub fn build_project(
slug: &str,
name: Option<&str>,
description: Option<&str>,
members: &[ProjectMemberCoord],
channel: Option<&str>,
visibility: Option<&str>,
) -> Result<EventBuilder, SdkError> {
// Slug validation
if slug.is_empty() {
return Err(SdkError::InvalidInput(
"project slug must not be empty".into(),
));
}
if slug.len() > PROJECT_D_MAX_LEN {
return Err(SdkError::InvalidInput(format!(
"project slug must not exceed {PROJECT_D_MAX_LEN} bytes (got {})",
slug.len()
)));
}
// Channel UUID validation
if let Some(ch) = channel {
uuid::Uuid::parse_str(ch).map_err(|_| {
SdkError::InvalidInput(format!("buzz-channel must be a valid UUID (got {ch:?})"))
})?;
}
// Visibility enum validation
if let Some(vis) = visibility {
if vis != "listed" && vis != "unlisted" {
return Err(SdkError::InvalidInput(format!(
"buzz-visibility must be 'listed' or 'unlisted' (got {vis:?})"
)));
}
}
let mut tags: Vec<Tag> = Vec::new();
tags.push(tag(&["d", slug])?);
if let Some(n) = name {
tags.push(tag(&["name", n])?);
}
if let Some(d) = description {
tags.push(tag(&["description", d])?);
}
for m in members {
let tag_parts = m.to_tag_parts();
let parts: Vec<&str> = tag_parts.iter().map(|s| s.as_str()).collect();
// Safety: to_tag_parts always produces ["a", coord, ...hint]
tags.push(
Tag::parse(parts.iter().copied()).map_err(|e| SdkError::InvalidTag(e.to_string()))?,
);
}
if let Some(ch) = channel {
tags.push(tag(&["buzz-channel", ch])?);
}
if let Some(vis) = visibility {
tags.push(tag(&["buzz-visibility", vis])?);
}
build_project_with_tags("", tags)
}
/// **Generic NIP-09 coordinate delete**: Build a kind:5 deletion event with
/// a single `a`-tag addressing `<kind>:<pubkey>:<d>`.
///
/// Validates:
/// - `kind` is an addressable kind (10000–19999 or 30000–39999).
/// - `pubkey` is a 64-character lowercase hex string.
/// - `d` is non-empty.
///
/// `build_workflow_delete` delegates to this function.
pub fn build_delete_addressable(
kind: u32,
pubkey: &str,
d: &str,
) -> Result<EventBuilder, SdkError> {
let is_addressable = (10000..20000).contains(&kind) || (30000..40000).contains(&kind);
if !is_addressable {
return Err(SdkError::InvalidInput(format!(
"kind {kind} is not an addressable kind (must be 10000–19999 or 30000–39999)"
)));
}
let pk = check_pubkey_hex(pubkey, "pubkey")?;
if d.is_empty() {
return Err(SdkError::InvalidInput("d must not be empty".into()));
}
let coord = format!("{kind}:{pk}:{d}");
let tags = vec![tag(&["a", &coord])?];
Ok(EventBuilder::new(Kind::Custom(KIND_DELETION as u16), "").tags(tags))
}
#[cfg(test)]
mod tests {
use super::*;
use nostr::{EventId, Keys};
fn keys() -> Keys {
Keys::generate()
}
fn sign(b: EventBuilder) -> nostr::Event {
b.sign_with_keys(&keys()).expect("sign")
}
fn event_id() -> EventId {
let k = keys();
EventBuilder::new(Kind::Custom(1), "x")
.tags([])
.sign_with_keys(&k)
.expect("sign")
.id
}
fn uuid() -> Uuid {
Uuid::new_v4()
}
fn tag_values(event: &nostr::Event, key: &str) -> Vec<String> {
event
.tags
.iter()
.filter_map(|t| {
let s = t.as_slice();
if s.first().map(|v| v.as_str()) == Some(key) {
s.get(1).map(|v| v.to_string())
} else {
None
}
})
.collect()
}
fn has_tag(event: &nostr::Event, key: &str, val: &str) -> bool {
event.tags.iter().any(|t| {
let s = t.as_slice();
s.first().map(|v| v.as_str()) == Some(key) && s.get(1).map(|v| v.as_str()) == Some(val)
})
}
#[test]
fn message_happy_path() {
let cid = uuid();
let ev = sign(build_message(cid, "hello", None, &[], false, &[]).unwrap());
assert_eq!(ev.kind.as_u16(), 9);
assert_eq!(ev.content, "hello");
assert!(has_tag(&ev, "h", &cid.to_string()));
}
#[test]
fn message_preserves_self_mention_p_tag() {
// nostr 0.44 strips p tags matching the signer by default.
// build_message must opt in via allow_self_tagging() so that
// explicit self-mentions survive signing. See #4906.
let cid = uuid();
let sender = keys();
let self_pk = sender.public_key().to_hex();
let builder = build_message(cid, "self-canary", None, &[&self_pk], false, &[]).unwrap();
let ev = builder.sign_with_keys(&sender).expect("sign");
assert!(
has_tag(&ev, "p", &self_pk),
"self-mention p tag must survive signing"
);
}
#[test]
fn forum_post_preserves_self_mention_p_tag() {
let cid = uuid();
let sender = keys();
let self_pk = sender.public_key().to_hex();
let builder = build_forum_post(cid, "self-canary", &[&self_pk], &[]).unwrap();
let ev = builder.sign_with_keys(&sender).expect("sign");
assert!(
has_tag(&ev, "p", &self_pk),
"self-mention p tag must survive signing"
);
}
#[test]
fn forum_comment_preserves_self_mention_p_tag() {
let cid = uuid();
let sender = keys();
let self_pk = sender.public_key().to_hex();
let root = event_id();
let tr = ThreadRef {
root_event_id: root,
parent_event_id: root,
};
let builder = build_forum_comment(cid, "self-canary", &tr, &[&self_pk], &[]).unwrap();
let ev = builder.sign_with_keys(&sender).expect("sign");
assert!(
has_tag(&ev, "p", &self_pk),
"self-mention p tag must survive signing"
);
}
#[test]
fn agent_observer_frame_happy_path() {
let sender = keys();
let recipient = keys();
let agent = keys();
let encrypted = buzz_core::observer::encrypt_observer_payload(
&sender,
&recipient.public_key(),
&serde_json::json!({"type": "acp_read"}),
)
.unwrap();
let ev = sign(
build_agent_observer_frame(
&recipient.public_key().to_hex(),
&agent.public_key().to_hex(),
OBSERVER_FRAME_TELEMETRY,
&encrypted,
)
.unwrap(),
);
assert_eq!(ev.kind.as_u16(), KIND_AGENT_OBSERVER_FRAME as u16);
assert_eq!(ev.content, encrypted);
assert!(has_tag(&ev, "p", &recipient.public_key().to_hex()));
assert!(has_tag(
&ev,
OBSERVER_AGENT_TAG,
&agent.public_key().to_hex()
));
assert!(has_tag(&ev, OBSERVER_FRAME_TAG, OBSERVER_FRAME_TELEMETRY));
}
#[test]
fn agent_observer_frame_rejects_plaintext_content() {
let err = build_agent_observer_frame(
&"a".repeat(64),
&"b".repeat(64),
OBSERVER_FRAME_TELEMETRY,
"not encrypted",
)
.unwrap_err();
assert!(matches!(err, SdkError::InvalidInput(_)));
}
#[test]
fn message_direct_reply() {
let cid = uuid();
let eid = event_id();
let tr = ThreadRef {
root_event_id: eid,
parent_event_id: eid,
};
let ev = sign(build_message(cid, "reply", Some(&tr), &[], false, &[]).unwrap());
// Direct reply: only one e-tag with "reply" marker
let e_tags: Vec<_> = ev
.tags
.iter()
.filter(|t| t.as_slice().first().map(|v| v.as_str()) == Some("e"))
.collect();
assert_eq!(e_tags.len(), 1);
assert_eq!(
e_tags[0].as_slice().get(3).map(|v| v.as_str()),
Some("reply")
);
}
#[test]
fn message_nested_reply() {
let cid = uuid();
let root = event_id();
let parent = event_id();
let tr = ThreadRef {
root_event_id: root,
parent_event_id: parent,
};
let ev = sign(build_message(cid, "nested", Some(&tr), &[], false, &[]).unwrap());
let e_tags: Vec<_> = ev
.tags
.iter()
.filter(|t| t.as_slice().first().map(|v| v.as_str()) == Some("e"))
.collect();
assert_eq!(e_tags.len(), 2);
let markers: Vec<_> = e_tags
.iter()
.filter_map(|t| t.as_slice().get(3).map(|v| v.as_str()))
.collect();
assert!(markers.contains(&"root"));
assert!(markers.contains(&"reply"));
}
#[test]
fn message_broadcast_flag() {
let cid = uuid();
let ev = sign(build_message(cid, "hi", None, &[], true, &[]).unwrap());
assert!(has_tag(&ev, "broadcast", "1"));
}
#[test]
fn message_mentions_deduped() {
let cid = uuid();
let hex = "abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234";
let ev = sign(build_message(cid, "hi", None, &[hex, hex], false, &[]).unwrap());
let p_tags = tag_values(&ev, "p");
assert_eq!(p_tags.len(), 1);
}
#[test]
fn message_too_many_mentions() {
let cid = uuid();
let hex = "abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234";
let _mentions: Vec<&str> = (0..51).map(|_| hex).collect();
// All same hex so dedup would reduce to 1, but the check is on raw len
// Let's use 51 distinct-ish values by varying the first char
let hexes: Vec<String> = (0..51u8)
.map(|i| {
format!(
"{:02x}cd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd12",
i
)
})
.collect();
let refs: Vec<&str> = hexes.iter().map(|s| s.as_str()).collect();
let result = build_message(cid, "hi", None, &refs, false, &[]);
assert!(matches!(result, Err(SdkError::TooManyMentions)));
}
#[test]
fn message_content_too_large() {
let cid = uuid();
let big = "x".repeat(64 * 1024 + 1);
let result = build_message(cid, &big, None, &[], false, &[]);
assert!(matches!(result, Err(SdkError::ContentTooLarge { .. })));
}
#[test]
fn message_max_content_ok() {
let cid = uuid();
let max = "x".repeat(64 * 1024);
assert!(build_message(cid, &max, None, &[], false, &[]).is_ok());
}
#[test]
fn forum_post_happy_path() {
let cid = uuid();
let ev = sign(build_forum_post(cid, "post body", &[], &[]).unwrap());
assert_eq!(ev.kind.as_u16(), 45001);
assert!(has_tag(&ev, "h", &cid.to_string()));
}
#[test]
fn forum_post_content_too_large() {
let cid = uuid();
let big = "x".repeat(64 * 1024 + 1);
assert!(matches!(
build_forum_post(cid, &big, &[], &[]),
Err(SdkError::ContentTooLarge { .. })
));
}
#[test]
fn forum_comment_happy_path() {
let cid = uuid();
let eid = event_id();
let tr = ThreadRef {
root_event_id: eid,
parent_event_id: eid,
};
let ev = sign(build_forum_comment(cid, "comment", &tr, &[], &[]).unwrap());
assert_eq!(ev.kind.as_u16(), 45003);
assert!(has_tag(&ev, "h", &cid.to_string()));
}
fn good_diff_meta() -> DiffMeta {
DiffMeta {
repo_url: "https://github.com/example/repo".into(),
commit_sha: "abc1234".into(),
file_path: Some("src/main.rs".into()),
parent_commit: None,
branch: None,
pr_number: None,
language: Some("rust".into()),
description: None,
truncated: false,
alt_text: None,
}
}
#[test]
fn diff_message_happy_path() {
let cid = uuid();
let ev = sign(build_diff_message(cid, "diff content", &good_diff_meta(), None).unwrap());
assert_eq!(ev.kind.as_u16(), 40008);
assert!(has_tag(&ev, "repo", "https://github.com/example/repo"));
assert!(has_tag(&ev, "commit", "abc1234"));
assert!(has_tag(&ev, "l", "rust"));
}
#[test]
fn diff_message_bad_repo_url() {
let cid = uuid();
let mut meta = good_diff_meta();
meta.repo_url = "ftp://bad.url".into();
assert!(matches!(
build_diff_message(cid, "x", &meta, None),
Err(SdkError::InvalidDiffMeta(_))
));
}
#[test]
fn diff_message_short_commit_sha() {
let cid = uuid();
let mut meta = good_diff_meta();
meta.commit_sha = "abc12".into(); // only 5 chars
assert!(matches!(
build_diff_message(cid, "x", &meta, None),
Err(SdkError::InvalidDiffMeta(_))
));
}
#[test]
fn diff_message_invalid_commit_sha_chars() {
let cid = uuid();
let mut meta = good_diff_meta();
meta.commit_sha = "xyz1234".into(); // 'x', 'y', 'z' not hex
assert!(matches!(
build_diff_message(cid, "x", &meta, None),
Err(SdkError::InvalidDiffMeta(_))
));
}
#[test]
fn diff_message_branch_only_source() {
let cid = uuid();
let mut meta = good_diff_meta();
meta.branch = Some(("main".into(), "".into())); // target empty
assert!(matches!(
build_diff_message(cid, "x", &meta, None),
Err(SdkError::InvalidDiffMeta(_))
));
}
#[test]
fn diff_message_pr_zero() {
let cid = uuid();
let mut meta = good_diff_meta();
meta.pr_number = Some(0);
assert!(matches!(
build_diff_message(cid, "x", &meta, None),
Err(SdkError::InvalidDiffMeta(_))
));
}
#[test]
fn diff_message_content_too_large() {
let cid = uuid();
let big = "x".repeat(60 * 1024 + 1);
assert!(matches!(
build_diff_message(cid, &big, &good_diff_meta(), None),
Err(SdkError::ContentTooLarge { .. })
));
}
#[test]
fn diff_message_all_optional_fields() {
let cid = uuid();
let meta = DiffMeta {
repo_url: "https://github.com/example/repo".into(),
commit_sha: "abc1234def".into(),
file_path: Some("src/lib.rs".into()),
parent_commit: Some("1234567".into()),
branch: Some(("feature".into(), "main".into())),
pr_number: Some(42),
language: Some("rust".into()),
description: Some("fix bug".into()),
truncated: true,
alt_text: Some("patch for bug fix".into()),
};
let ev = sign(build_diff_message(cid, "diff", &meta, None).unwrap());
assert!(has_tag(&ev, "file", "src/lib.rs"));
assert!(has_tag(&ev, "parent-commit", "1234567"));
assert!(has_tag(&ev, "pr", "42"));
assert!(has_tag(&ev, "truncated", "true"));
assert!(has_tag(&ev, "alt", "patch for bug fix"));
}
#[test]
fn edit_happy_path() {
let cid = uuid();
let eid = event_id();
let ev = sign(build_edit(cid, eid, "new content").unwrap());
assert_eq!(ev.kind.as_u16(), 40003);
assert!(has_tag(&ev, "e", &eid.to_hex()));
}
#[test]
fn edit_content_too_large() {
let cid = uuid();
let eid = event_id();
let big = "x".repeat(64 * 1024 + 1);
assert!(matches!(
build_edit(cid, eid, &big),
Err(SdkError::ContentTooLarge { .. })
));
}
#[test]
fn delete_message_happy_path() {
let cid = uuid();
let eid = event_id();
let ev = sign(build_delete_message(cid, eid).unwrap());
assert_eq!(ev.kind.as_u16(), 9005);
assert!(has_tag(&ev, "h", &cid.to_string()));
assert!(has_tag(&ev, "e", &eid.to_hex()));
assert_eq!(ev.content, "");
}
#[test]
fn delete_message_with_moderation_metadata() {
let cid = uuid();
let eid = event_id();
let action_id = Uuid::new_v4();
let ev = sign(
build_delete_message_with_options(
cid,
eid,
DeleteMessageOptions {
action_id: Some(action_id),
reason_code: Some("spam"),
public_reason: Some("Removed for spam."),
},
)
.unwrap(),
);
assert_eq!(ev.kind.as_u16(), 9005);
assert!(has_tag(&ev, "h", &cid.to_string()));
assert!(has_tag(&ev, "e", &eid.to_hex()));
assert!(has_tag(&ev, "action_id", &action_id.to_string()));
assert!(has_tag(&ev, "reason_code", "spam"));
assert!(has_tag(&ev, "public_reason", "Removed for spam."));
}
#[test]
fn delete_compat_happy_path() {
let cid = uuid();
let eid = event_id();
let ev = sign(build_delete_compat(cid, eid).unwrap());
assert_eq!(ev.kind.as_u16(), 5);
assert!(has_tag(&ev, "h", &cid.to_string()));
assert!(has_tag(&ev, "e", &eid.to_hex()));
assert_eq!(ev.content, "");
}
#[test]
fn vote_up() {
let cid = uuid();
let eid = event_id();
let ev = sign(build_vote(cid, eid, VoteDirection::Up).unwrap());
assert_eq!(ev.kind.as_u16(), 45002);
assert_eq!(ev.content, "+");
}
#[test]
fn vote_down() {
let cid = uuid();
let eid = event_id();
let ev = sign(build_vote(cid, eid, VoteDirection::Down).unwrap());
assert_eq!(ev.content, "-");
}
#[test]
fn reaction_happy_path() {
let eid = event_id();
let ev = sign(build_reaction(eid, "👍").unwrap());
assert_eq!(ev.kind.as_u16(), 7);
assert_eq!(ev.content, "👍");
}
#[test]
fn reaction_emoji_too_long() {
let eid = event_id();
let long_emoji = "a".repeat(65);
assert!(matches!(
build_reaction(eid, &long_emoji),
Err(SdkError::EmojiTooLong)
));
}
#[test]
fn reaction_emoji_max_len_ok() {
let eid = event_id();
let max_emoji = "a".repeat(64);
assert!(build_reaction(eid, &max_emoji).is_ok());
}
#[test]
fn custom_emoji_reaction_happy_path() {
let eid = event_id();
let ev = sign(
build_custom_emoji_reaction(eid, ":Party_Parrot:", "https://example.com/parrot.png")
.unwrap(),
);
assert_eq!(ev.kind.as_u16(), 7);
assert_eq!(ev.content, ":party_parrot:");
assert!(has_tag(&ev, "emoji", "party_parrot"));
}
#[test]
fn custom_emoji_reaction_accepts_max_shortcode_length() {
let eid = event_id();
let shortcode = "a".repeat(MAX_CUSTOM_EMOJI_SHORTCODE_LEN);
let ev = sign(
build_custom_emoji_reaction(eid, &shortcode, "https://example.com/max.png").unwrap(),
);
assert_eq!(ev.content, format!(":{shortcode}:"));
assert_eq!(ev.content.chars().count(), MAX_CUSTOM_EMOJI_REACTION_LEN);
assert!(has_tag(&ev, "emoji", &shortcode));
}
#[test]
fn custom_emoji_reaction_rejects_overlong_shortcode() {
let eid = event_id();
let shortcode = "a".repeat(MAX_CUSTOM_EMOJI_SHORTCODE_LEN + 1);
assert!(matches!(
build_custom_emoji_reaction(eid, &shortcode, "https://example.com/too-long.png"),
Err(SdkError::InvalidInput(message)) if message.contains("exceeds 64 bytes")
));
}
#[test]
fn custom_emoji_set_happy_path() {
let ev = sign(
build_custom_emoji_set(&[CustomEmoji {
shortcode: "party".to_string(),
url: "https://example.com/party.png".to_string(),
}])
.unwrap(),
);
assert_eq!(ev.kind.as_u16(), 30030);
assert!(has_tag(&ev, "d", CUSTOM_EMOJI_SET_D_TAG));
assert!(has_tag(&ev, "emoji", "party"));
}
#[test]
fn remove_reaction_happy_path() {
let eid = event_id();
let ev = sign(build_remove_reaction(eid).unwrap());
assert_eq!(ev.kind.as_u16(), 5);
assert!(has_tag(&ev, "e", &eid.to_hex()));
}
#[test]
fn set_canvas_happy_path() {
let cid = uuid();
let ev = sign(build_set_canvas(cid, "# Canvas\nHello").unwrap());
assert_eq!(ev.kind.as_u16(), 40100);
assert!(has_tag(&ev, "h", &cid.to_string()));
assert_eq!(ev.content, "# Canvas\nHello");
}
#[test]
fn profile_all_fields() {
let ev = sign(
build_profile(
Some("Alice"),
Some("alice"),
Some("https://example.com/pic.jpg"),
Some("Hello world"),
Some("alice@example.com"),
)
.unwrap(),
);
assert_eq!(ev.kind.as_u16(), 0);
let v: serde_json::Value = serde_json::from_str(&ev.content).unwrap();
assert_eq!(v["display_name"], "Alice");
assert_eq!(v["name"], "alice");
assert_eq!(v["nip05"], "alice@example.com");
}
#[test]
fn profile_some_fields() {
let ev = sign(build_profile(Some("Bob"), None, None, None, None).unwrap());
let v: serde_json::Value = serde_json::from_str(&ev.content).unwrap();
assert_eq!(v["display_name"], "Bob");
assert!(
v.get("name").is_none()
|| !v["name"].is_null() && v.get("name") == Some(&serde_json::Value::Null)
|| !v.as_object().unwrap().contains_key("name")
);
}
#[test]
fn profile_no_fields() {
let ev = sign(build_profile(None, None, None, None, None).unwrap());
let v: serde_json::Value = serde_json::from_str(&ev.content).unwrap();
assert!(v.as_object().unwrap().is_empty());
}
#[test]
fn add_member_with_role() {
let cid = uuid();
let pubkey = "abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234";
let ev = sign(build_add_member(cid, pubkey, Some(MemberRole::Admin)).unwrap());
assert_eq!(ev.kind.as_u16(), 9000);
assert!(has_tag(&ev, "p", pubkey));
assert!(has_tag(&ev, "role", "admin"));
}
#[test]
fn add_member_without_role() {
let cid = uuid();
let pubkey = "abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234";
let ev = sign(build_add_member(cid, pubkey, None::<MemberRole>).unwrap());
assert_eq!(ev.kind.as_u16(), 9000);
assert!(tag_values(&ev, "role").is_empty());
}
#[test]
fn remove_member_happy_path() {
let cid = uuid();
let pubkey = "abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234";
let ev = sign(build_remove_member(cid, pubkey).unwrap());
assert_eq!(ev.kind.as_u16(), 9001);
assert!(has_tag(&ev, "p", pubkey));
}
#[test]
fn leave_happy_path() {
let cid = uuid();
let ev = sign(build_leave(cid).unwrap());
assert_eq!(ev.kind.as_u16(), 9022);
assert!(has_tag(&ev, "h", &cid.to_string()));
}
#[test]
fn update_channel_name_and_about() {
let cid = uuid();
let ev = sign(
build_update_channel(cid, Some("new-name"), Some("new about"), None, None).unwrap(),
);
assert_eq!(ev.kind.as_u16(), 9002);
assert!(has_tag(&ev, "name", "new-name"));
assert!(has_tag(&ev, "about", "new about"));
}
#[test]
fn update_channel_strips_all_leading_hashes_from_name() {
let ev =
sign(build_update_channel(uuid(), Some(" ###new-name "), None, None, None).unwrap());
assert!(has_tag(&ev, "name", "new-name"));
}
#[test]
fn update_channel_rejects_hash_only_name() {
assert!(matches!(
build_update_channel(uuid(), Some(" ### "), None, None, None),
Err(SdkError::InvalidTag(_))
));
}
#[test]
fn update_channel_visibility_and_ttl() {
let cid = uuid();
let ev =
sign(build_update_channel(cid, None, None, Some("private"), Some(Some(3600))).unwrap());
assert_eq!(ev.kind.as_u16(), 9002);
assert!(has_tag(&ev, "visibility", "private"));
assert!(has_tag(&ev, "ttl", "3600"));
}
#[test]
fn update_channel_clears_ttl() {
let cid = uuid();
let ev = sign(build_update_channel(cid, None, None, None, Some(None)).unwrap());
assert!(has_tag(&ev, "ttl", ""));
}
#[test]
fn update_channel_invalid_visibility_rejected() {
let cid = uuid();
assert!(matches!(
build_update_channel(cid, None, None, Some("secret"), None),
Err(SdkError::InvalidTag(_))
));
}
#[test]
fn update_channel_no_fields_rejected() {
let cid = uuid();
assert!(matches!(
build_update_channel(cid, None, None, None, None),
Err(SdkError::InvalidTag(_))
));
}
#[test]
fn set_topic_happy_path() {
let cid = uuid();
let ev = sign(build_set_topic(cid, "Rust async patterns").unwrap());
assert_eq!(ev.kind.as_u16(), 9002);
assert!(has_tag(&ev, "topic", "Rust async patterns"));
}
#[test]
fn set_purpose_happy_path() {
let cid = uuid();
let ev = sign(build_set_purpose(cid, "Team coordination").unwrap());
assert_eq!(ev.kind.as_u16(), 9002);
assert!(has_tag(&ev, "purpose", "Team coordination"));
}
#[test]
fn create_channel_all_fields() {
let cid = uuid();
let ev = sign(
build_create_channel(
cid,
"general",
Some(Visibility::Open),
Some(ChannelKind::Stream),
Some("General chat"),
None,
)
.unwrap(),
);
assert_eq!(ev.kind.as_u16(), 9007);
assert!(has_tag(&ev, "name", "general"));
assert!(has_tag(&ev, "visibility", "open"));
assert!(has_tag(&ev, "channel_type", "stream"));
assert!(has_tag(&ev, "about", "General chat"));
}
#[test]
fn create_channel_minimal() {
let cid = uuid();
let ev = sign(
build_create_channel(
cid,
"dev",
None::<Visibility>,
None::<ChannelKind>,
None,
None,
)
.unwrap(),
);
assert_eq!(ev.kind.as_u16(), 9007);
assert!(has_tag(&ev, "name", "dev"));
}
#[test]
fn create_channel_strips_all_leading_hashes_from_name() {
let ev = sign(
build_create_channel(
uuid(),
" ###dev ",
None::<Visibility>,
None::<ChannelKind>,
None,
None,
)
.unwrap(),
);
assert!(has_tag(&ev, "name", "dev"));
}
#[test]
fn create_channel_rejects_hash_only_name() {
assert!(matches!(
build_create_channel(
uuid(),
" ### ",
None::<Visibility>,
None::<ChannelKind>,
None,
None,
),
Err(SdkError::InvalidTag(_))
));
}
#[test]
fn create_channel_ephemeral_emits_ttl() {
let cid = uuid();
let ev = sign(
build_create_channel(
cid,
"standup",
Some(Visibility::Open),
Some(ChannelKind::Stream),
None,
Some(3600),
)
.unwrap(),
);
assert_eq!(ev.kind.as_u16(), 9007);
assert!(has_tag(&ev, "ttl", "3600"));
}
#[test]
fn join_happy_path() {
let cid = uuid();
let ev = sign(build_join(cid).unwrap());
assert_eq!(ev.kind.as_u16(), 9021);
assert!(has_tag(&ev, "h", &cid.to_string()));
}
#[test]
fn archive_happy_path() {
let cid = uuid();
let ev = sign(build_archive(cid).unwrap());
assert_eq!(ev.kind.as_u16(), 9002);
assert!(has_tag(&ev, "archived", "true"));
}
#[test]
fn unarchive_happy_path() {
let cid = uuid();
let ev = sign(build_unarchive(cid).unwrap());
assert_eq!(ev.kind.as_u16(), 9002);
assert!(has_tag(&ev, "archived", "false"));
}
#[test]
fn delete_channel_happy_path() {
let cid = uuid();
let ev = sign(build_delete_channel(cid).unwrap());
assert_eq!(ev.kind.as_u16(), 9008);
assert!(has_tag(&ev, "h", &cid.to_string()));
}
#[test]
fn extract_channel_id_present() {
let cid = uuid();
let ev = sign(build_join(cid).unwrap());
assert_eq!(extract_channel_id(&ev), Some(cid));
}
#[test]
fn extract_channel_id_absent() {
// build_note (kind 1) is a global text note — no h tag.
let ev = sign(build_note("hello", None).unwrap());
assert_eq!(extract_channel_id(&ev), None);
}
#[test]
fn extract_channel_id_invalid_uuid() {
// Build an event with a malformed h-tag value
let tags = vec![Tag::parse(["h", "not-a-uuid"]).unwrap()];
let ev = EventBuilder::new(Kind::Custom(9), "x")
.tags(tags)
.sign_with_keys(&keys())
.unwrap();
assert_eq!(extract_channel_id(&ev), None);
}
#[test]
fn build_note_happy_path() {
let builder = build_note("hello world", None).unwrap();
let keys = nostr::Keys::generate();
let event = builder.sign_with_keys(&keys).unwrap();
assert_eq!(event.kind, Kind::Custom(1));
assert_eq!(event.content, "hello world");
assert!(event.tags.is_empty());
}
#[test]
fn build_note_with_reply() {
let keys = nostr::Keys::generate();
// Create a dummy event to get a valid EventId
let dummy = EventBuilder::new(Kind::Custom(1), "dummy")
.tags(vec![])
.sign_with_keys(&keys)
.unwrap();
let builder = build_note("reply text", Some(dummy.id)).unwrap();
let event = builder.sign_with_keys(&keys).unwrap();
assert_eq!(event.kind, Kind::Custom(1));
assert_eq!(event.content, "reply text");
assert_eq!(event.tags.len(), 1);
let tag = event.tags.iter().next().unwrap();
assert_eq!(tag.as_slice()[0], "e");
assert_eq!(tag.as_slice()[1], dummy.id.to_hex());
assert_eq!(tag.as_slice()[3], "reply");
}
#[test]
fn build_note_content_too_large() {
let big = "x".repeat(64 * 1024 + 1);
let err = build_note(&big, None).unwrap_err();
assert!(matches!(err, SdkError::ContentTooLarge { .. }));
}
#[test]
fn build_note_empty_content() {
// Empty content is valid per NIP-01.
let builder = build_note("", None).unwrap();
let keys = nostr::Keys::generate();
let event = builder.sign_with_keys(&keys).unwrap();
assert_eq!(event.kind, Kind::Custom(1));
assert_eq!(event.content, "");
assert!(event.tags.is_empty());
}
#[test]
fn build_contact_list_happy_path() {
let pubkey = "a".repeat(64);
let contacts = vec![(pubkey.as_str(), None, None)];
let builder = build_contact_list(&contacts).unwrap();
let keys = nostr::Keys::generate();
let event = builder.sign_with_keys(&keys).unwrap();
assert_eq!(event.kind, Kind::Custom(3));
assert_eq!(event.content, "");
assert_eq!(event.tags.len(), 1);
let tag = event.tags.iter().next().unwrap();
assert_eq!(tag.as_slice()[0], "p");
assert_eq!(tag.as_slice()[1], pubkey);
}
#[test]
fn build_contact_list_normalizes_uppercase() {
let upper = "A".repeat(64);
let contacts = vec![(upper.as_str(), None, None)];
let builder = build_contact_list(&contacts).unwrap();
let keys = nostr::Keys::generate();
let event = builder.sign_with_keys(&keys).unwrap();
let tag = event.tags.iter().next().unwrap();
assert_eq!(tag.as_slice()[1], "a".repeat(64));
}
#[test]
fn build_contact_list_with_relay_and_petname() {
let pubkey = "b".repeat(64);
let contacts = vec![(
pubkey.as_str(),
Some("wss://relay.example.com"),
Some("alice"),
)];
let builder = build_contact_list(&contacts).unwrap();
let keys = nostr::Keys::generate();
let event = builder.sign_with_keys(&keys).unwrap();
let tag = event.tags.iter().next().unwrap();
assert_eq!(tag.as_slice()[0], "p");
assert_eq!(tag.as_slice()[2], "wss://relay.example.com");
assert_eq!(tag.as_slice()[3], "alice");
}
#[test]
fn build_contact_list_empty() {
let builder = build_contact_list(&[]).unwrap();
let keys = nostr::Keys::generate();
let event = builder.sign_with_keys(&keys).unwrap();
assert_eq!(event.kind, Kind::Custom(3));
assert!(event.tags.is_empty());
}
#[test]
fn build_contact_list_rejects_short_pubkey() {
let short = "a".repeat(63);
let contacts = vec![(short.as_str(), None, None)];
let err = build_contact_list(&contacts).unwrap_err();
assert!(matches!(err, SdkError::InvalidInput(_)));
}
#[test]
fn build_contact_list_rejects_long_pubkey() {
let long = "a".repeat(65);
let contacts = vec![(long.as_str(), None, None)];
let err = build_contact_list(&contacts).unwrap_err();
assert!(matches!(err, SdkError::InvalidInput(_)));
}
#[test]
fn build_contact_list_rejects_non_hex() {
let non_hex = "g".repeat(64);
let contacts = vec![(non_hex.as_str(), None, None)];
let err = build_contact_list(&contacts).unwrap_err();
assert!(matches!(err, SdkError::InvalidInput(_)));
}
#[test]
fn build_contact_list_rejects_long_relay_url() {
let pubkey = "a".repeat(64);
let long_url = "x".repeat(2049);
let contacts = vec![(pubkey.as_str(), Some(long_url.as_str()), None)];
let err = build_contact_list(&contacts).unwrap_err();
assert!(matches!(err, SdkError::InvalidInput(_)));
}
#[test]
fn build_contact_list_rejects_long_petname() {
let pubkey = "a".repeat(64);
let long_name = "x".repeat(257);
let contacts = vec![(pubkey.as_str(), None, Some(long_name.as_str()))];
let err = build_contact_list(&contacts).unwrap_err();
assert!(matches!(err, SdkError::InvalidInput(_)));
}
#[test]
fn build_contact_list_duplicate_pubkeys() {
let pubkey = "c".repeat(64);
// Same pubkey twice — only one p-tag should be emitted.
let contacts = vec![
(pubkey.as_str(), None, None),
(
pubkey.as_str(),
Some("wss://relay.example.com"),
Some("bob"),
),
];
let builder = build_contact_list(&contacts).unwrap();
let keys = nostr::Keys::generate();
let event = builder.sign_with_keys(&keys).unwrap();
assert_eq!(event.tags.len(), 1);
let tag = event.tags.iter().next().unwrap();
assert_eq!(tag.as_slice()[0], "p");
assert_eq!(tag.as_slice()[1], pubkey);
}
#[test]
fn build_contact_list_too_many() {
let pubkey = "d".repeat(64);
// MAX_CONTACTS + 1 entries (all same pubkey — uniqueness doesn't matter,
// the cap is checked before deduplication).
let entry = (pubkey.as_str(), None, None);
let contacts = vec![entry; MAX_CONTACTS + 1];
let err = build_contact_list(&contacts).unwrap_err();
assert!(matches!(err, SdkError::InvalidInput(_)));
}
#[test]
fn repo_announcement_happy_path_all_fields() {
let ev = sign(
build_repo_announcement(
"my-repo",
Some("My Repo"),
Some("A test repository"),
&["https://github.com/example/my-repo.git"],
Some("https://github.com/example/my-repo"),
&["wss://relay.example.com"],
)
.unwrap(),
);
assert_eq!(ev.kind.as_u16(), 30617);
assert_eq!(ev.content, "");
assert!(has_tag(&ev, "d", "my-repo"));
assert!(has_tag(&ev, "name", "My Repo"));
assert!(has_tag(&ev, "description", "A test repository"));
assert!(has_tag(
&ev,
"clone",
"https://github.com/example/my-repo.git"
));
assert!(has_tag(&ev, "web", "https://github.com/example/my-repo"));
// relays is a multi-value tag — check the tag key exists
assert!(ev.tags.iter().any(|t| {
let s = t.as_slice();
s.first().map(|v| v.as_str()) == Some("relays")
&& s.get(1).map(|v| v.as_str()) == Some("wss://relay.example.com")
}));
}
#[test]
fn repo_announcement_happy_path_minimal() {
let ev = sign(build_repo_announcement("bare-repo", None, None, &[], None, &[]).unwrap());
assert_eq!(ev.kind.as_u16(), 30617);
assert_eq!(ev.content, "");
assert!(has_tag(&ev, "d", "bare-repo"));
// No optional tags present
assert!(!ev
.tags
.iter()
.any(|t| t.as_slice().first().map(|v| v.as_str()) == Some("name")));
assert!(!ev
.tags
.iter()
.any(|t| t.as_slice().first().map(|v| v.as_str()) == Some("clone")));
}
#[test]
fn repo_announcement_with_tags_preserves_metadata_and_canonicalizes_d() {
let tags = vec![
Tag::parse(["d", "wrong-repo"]).unwrap(),
Tag::parse(["name", "Protected Repo"]).unwrap(),
Tag::parse(["buzz-channel", "channel-id"]).unwrap(),
Tag::parse(["future-metadata", "preserve-me"]).unwrap(),
];
let ev = sign(
build_repo_announcement_with_tags("protected-repo", "repository content", tags)
.unwrap(),
);
assert_eq!(ev.kind.as_u16(), 30617);
assert_eq!(ev.content, "repository content");
assert_eq!(
ev.tags
.iter()
.filter(|tag| tag.as_slice().first().map(String::as_str) == Some("d"))
.count(),
1
);
assert!(has_tag(&ev, "d", "protected-repo"));
assert!(has_tag(&ev, "buzz-channel", "channel-id"));
assert!(has_tag(&ev, "future-metadata", "preserve-me"));
}
#[test]
fn repo_announcement_rejects_empty_repo_id() {
let err = build_repo_announcement("", None, None, &[], None, &[]).unwrap_err();
assert!(matches!(err, SdkError::InvalidInput(_)));
}
#[test]
fn repo_announcement_rejects_leading_dot() {
let err = build_repo_announcement(".hidden", None, None, &[], None, &[]).unwrap_err();
assert!(matches!(err, SdkError::InvalidInput(_)));
}
#[test]
fn repo_announcement_rejects_double_dot() {
let err = build_repo_announcement("some..repo", None, None, &[], None, &[]).unwrap_err();
assert!(matches!(err, SdkError::InvalidInput(_)));
}
#[test]
fn repo_announcement_rejects_repo_id_over_64_chars() {
let long_id = "a".repeat(65);
let err = build_repo_announcement(&long_id, None, None, &[], None, &[]).unwrap_err();
assert!(matches!(err, SdkError::InvalidInput(_)));
}
#[test]
fn repo_announcement_rejects_invalid_chars_in_repo_id() {
let err = build_repo_announcement("bad repo!", None, None, &[], None, &[]).unwrap_err();
assert!(matches!(err, SdkError::InvalidInput(_)));
}
#[test]
fn repo_announcement_multiple_clone_urls_multi_value_tag() {
let ev = sign(
build_repo_announcement(
"multi-clone",
None,
None,
&[
"https://relay.example.com/git/abc/multi-clone",
"ssh://git@github.com/org/multi-clone.git",
],
None,
&[],
)
.unwrap(),
);
// clone is a multi-value tag per NIP-34: ["clone", url1, url2, ...]
let clone_tag = ev
.tags
.iter()
.find(|t| t.as_slice().first().map(|v| v.as_str()) == Some("clone"))
.expect("clone tag missing");
let vals: Vec<&str> = clone_tag
.as_slice()
.iter()
.skip(1)
.map(|v| v.as_str())
.collect();
assert_eq!(vals.len(), 2);
assert_eq!(vals[0], "https://relay.example.com/git/abc/multi-clone");
assert_eq!(vals[1], "ssh://git@github.com/org/multi-clone.git");
}
#[test]
fn git_patch_happy_path_minimal() {
let owner = "a".repeat(64);
let repo = GitRepoCoord {
owner: owner.clone(),
id: "my-repo".to_string(),
};
let ev =
sign(build_git_patch(&repo, "diff --git a/x b/x", &GitPatchMeta::default()).unwrap());
assert_eq!(ev.kind.as_u16(), 1617);
assert_eq!(ev.content, "diff --git a/x b/x");
assert!(has_tag(&ev, "a", &format!("30617:{owner}:my-repo")));
assert!(has_tag(&ev, "p", &owner));
}
#[test]
fn git_patch_root_and_metadata_tags() {
let owner = "a".repeat(64);
let commit = "c".repeat(40);
let parent = "d".repeat(40);
let repo = GitRepoCoord {
owner,
id: "repo".to_string(),
};
let meta = GitPatchMeta {
root: true,
commit: Some(commit.clone()),
parent_commit: Some(parent.clone()),
..Default::default()
};
let ev = sign(build_git_patch(&repo, "patch body", &meta).unwrap());
assert!(has_tag(&ev, "t", "root"));
assert!(has_tag(&ev, "commit", &commit));
assert!(has_tag(&ev, "parent-commit", &parent));
assert!(has_tag(&ev, "r", &commit));
}
#[test]
fn git_patch_rejects_root_and_root_revision_together() {
let repo = GitRepoCoord {
owner: "a".repeat(64),
id: "repo".to_string(),
};
let meta = GitPatchMeta {
root: true,
root_revision: true,
..Default::default()
};
let err = build_git_patch(&repo, "x", &meta).unwrap_err();
assert!(matches!(err, SdkError::InvalidInput(_)));
}
#[test]
fn git_patch_rejects_oversized_content() {
let repo = GitRepoCoord {
owner: "a".repeat(64),
id: "repo".to_string(),
};
let big = "x".repeat(60 * 1024 + 1);
let err = build_git_patch(&repo, &big, &GitPatchMeta::default()).unwrap_err();
assert!(matches!(err, SdkError::ContentTooLarge { .. }));
}
#[test]
fn git_patch_rejects_bad_repo_owner() {
let repo = GitRepoCoord {
owner: "not-hex".to_string(),
id: "repo".to_string(),
};
let err = build_git_patch(&repo, "x", &GitPatchMeta::default()).unwrap_err();
assert!(matches!(err, SdkError::InvalidInput(_)));
}
#[test]
fn git_issue_happy_path() {
let owner = "a".repeat(64);
let repo = GitRepoCoord {
owner: owner.clone(),
id: "repo".to_string(),
};
let meta = GitIssueMeta {
labels: vec!["bug".to_string(), "p1".to_string()],
recipients: vec![],
};
let ev =
sign(build_git_issue(&repo, "Crashes on startup", "steps to repro", &meta).unwrap());
assert_eq!(ev.kind.as_u16(), 1621);
assert_eq!(ev.content, "steps to repro");
assert!(has_tag(&ev, "a", &format!("30617:{owner}:repo")));
assert!(has_tag(&ev, "subject", "Crashes on startup"));
assert!(has_tag(&ev, "t", "bug"));
assert!(has_tag(&ev, "t", "p1"));
}
#[test]
fn git_issue_rejects_empty_subject() {
let repo = GitRepoCoord {
owner: "a".repeat(64),
id: "repo".to_string(),
};
let err = build_git_issue(&repo, "", "body", &GitIssueMeta::default()).unwrap_err();
assert!(matches!(err, SdkError::InvalidInput(_)));
}
#[test]
fn git_issue_assignment_happy_path() {
let owner = "a".repeat(64);
let repo = GitRepoCoord {
owner: owner.clone(),
id: "repo".to_string(),
};
let issue = "b".repeat(64);
// Duplicates (case-insensitive) collapse to a single p tag.
let assignees = vec!["C".repeat(64), "c".repeat(64), "d".repeat(64)];
let ev = sign(
build_git_issue_assignment(&repo, &issue, &assignees, "Assigned this issue to Thomas")
.unwrap(),
);
assert_eq!(ev.kind.as_u16(), 1);
assert_eq!(ev.content, "Assigned this issue to Thomas");
assert!(has_tag(&ev, "e", &issue));
assert!(has_tag(&ev, "a", &format!("30617:{owner}:repo")));
assert!(has_tag(&ev, "p", &"c".repeat(64)));
assert!(has_tag(&ev, "p", &"d".repeat(64)));
assert!(has_tag(&ev, "t", "assignment"));
let p_count = ev
.tags
.iter()
.filter(|tag| tag.as_slice().first().map(String::as_str) == Some("p"))
.count();
assert_eq!(p_count, 2);
}
#[test]
fn git_issue_assignment_rejects_bad_input() {
let repo = GitRepoCoord {
owner: "a".repeat(64),
id: "repo".to_string(),
};
let issue = "b".repeat(64);
// No assignees.
let err = build_git_issue_assignment(&repo, &issue, &[], "x").unwrap_err();
assert!(matches!(err, SdkError::InvalidInput(_)));
// Malformed assignee pubkey.
let err =
build_git_issue_assignment(&repo, &issue, &["nope".to_string()], "x").unwrap_err();
assert!(matches!(err, SdkError::InvalidInput(_)));
// Malformed issue id.
let err = build_git_issue_assignment(&repo, "short", &["c".repeat(64)], "x").unwrap_err();
assert!(matches!(err, SdkError::InvalidInput(_)));
}
#[test]
fn git_issue_assignment_with_prior_emits_valid_causal_tag() {
let repo = GitRepoCoord {
owner: "a".repeat(64),
id: "repo".to_string(),
};
let issue = "b".repeat(64);
let assignee = "c".repeat(64);
let prior = "d".repeat(64);
let ev = sign(
build_git_issue_assignment_with_prior(
&repo,
&issue,
&[assignee],
"Assigned this issue",
Some(&prior),
)
.unwrap(),
);
assert!(has_tag(&ev, "prior", &prior));
let unassignment = sign(
build_git_issue_unassignment_with_prior(
&repo,
&issue,
&["c".repeat(64)],
"Unassigned this issue",
Some(&prior),
)
.unwrap(),
);
assert!(has_tag(&unassignment, "prior", &prior));
assert!(build_git_issue_assignment_with_prior(
&repo,
&issue,
&["c".repeat(64)],
"Assigned this issue",
Some("invalid"),
)
.is_err());
}
#[test]
fn git_issue_unassignment_happy_path() {
let owner = "a".repeat(64);
let repo = GitRepoCoord {
owner: owner.clone(),
id: "repo".to_string(),
};
let issue = "b".repeat(64);
let assignee = "c".repeat(64);
let ev = sign(
build_git_issue_unassignment(
&repo,
&issue,
std::slice::from_ref(&assignee),
"Unassigned Thomas from this issue",
)
.unwrap(),
);
assert_eq!(ev.kind.as_u16(), 1);
assert_eq!(ev.content, "Unassigned Thomas from this issue");
assert!(has_tag(&ev, "e", &issue));
assert!(has_tag(&ev, "a", &format!("30617:{owner}:repo")));
assert!(has_tag(&ev, "p", &assignee));
assert!(has_tag(&ev, "t", "unassignment"));
assert!(!has_tag(&ev, "t", "assignment"));
}
#[test]
fn legacy_issue_assignment_builders_omit_prior() {
let repo = GitRepoCoord {
owner: "a".repeat(64),
id: "repo".to_string(),
};
let issue = "b".repeat(64);
let assignees = vec!["c".repeat(64)];
let assignment = sign(
build_git_issue_assignment(&repo, &issue, &assignees, "Assigned this issue").unwrap(),
);
let unassignment = sign(
build_git_issue_unassignment(&repo, &issue, &assignees, "Unassigned this issue")
.unwrap(),
);
assert!(!assignment
.tags
.iter()
.any(|tag| { tag.as_slice().first().map(String::as_str) == Some("prior") }));
assert!(!unassignment
.tags
.iter()
.any(|tag| { tag.as_slice().first().map(String::as_str) == Some("prior") }));
}
#[test]
fn git_status_open_happy_path() {
let root = event_id().to_hex();
let meta = GitStatusMeta {
root_event: root.clone(),
..Default::default()
};
let ev = sign(build_git_status(GitStatus::Open, "", &meta).unwrap());
assert_eq!(ev.kind.as_u16(), 1630);
assert!(has_tag(&ev, "e", &root));
}
#[test]
fn git_status_merged_with_applied_patches() {
let root = event_id().to_hex();
let patch_id = event_id().to_hex();
let merge_commit = "f".repeat(40);
let meta = GitStatusMeta {
root_event: root,
applied_patches: vec![GitAppliedPatchRef {
id: patch_id.clone(),
relay: None,
pubkey: None,
}],
merge_commit: Some(merge_commit.clone()),
..Default::default()
};
let ev =
sign(build_git_status(GitStatus::AppliedOrResolved, "merged, thanks!", &meta).unwrap());
assert_eq!(ev.kind.as_u16(), 1631);
assert!(has_tag(&ev, "q", &patch_id));
assert!(has_tag(&ev, "merge-commit", &merge_commit));
assert!(has_tag(&ev, "r", &merge_commit));
}
#[test]
fn git_status_rejects_merge_fields_on_non_merged_status() {
let root = event_id().to_hex();
let meta = GitStatusMeta {
root_event: root,
merge_commit: Some("f".repeat(40)),
..Default::default()
};
let err = build_git_status(GitStatus::Closed, "", &meta).unwrap_err();
assert!(matches!(err, SdkError::InvalidInput(_)));
}
#[test]
fn git_status_rejects_bad_root_event() {
let meta = GitStatusMeta {
root_event: "not-an-event-id".to_string(),
..Default::default()
};
let err = build_git_status(GitStatus::Open, "", &meta).unwrap_err();
assert!(matches!(err, SdkError::InvalidInput(_)));
}
#[test]
fn git_patch_rejects_empty_content() {
let repo = GitRepoCoord {
owner: "a".repeat(64),
id: "repo".to_string(),
};
let err = build_git_patch(&repo, "", &GitPatchMeta::default()).unwrap_err();
assert!(matches!(err, SdkError::InvalidInput(_)));
}
#[test]
fn git_patch_rejects_whitespace_only_content() {
// Regression: a failed `git format-patch | buzz patches send
// --patch-file -` must not silently publish a whitespace-only
// (i.e. unappliable) patch.
let repo = GitRepoCoord {
owner: "a".repeat(64),
id: "repo".to_string(),
};
let err = build_git_patch(&repo, " \n\t\n", &GitPatchMeta::default()).unwrap_err();
assert!(matches!(err, SdkError::InvalidInput(_)));
}
#[test]
fn git_repo_coord_rejects_invalid_repo_id_chars() {
let repo = GitRepoCoord {
owner: "a".repeat(64),
id: "../etc/passwd".to_string(),
};
let err = build_git_patch(&repo, "diff", &GitPatchMeta::default()).unwrap_err();
assert!(matches!(err, SdkError::InvalidInput(_)));
}
#[test]
fn git_patch_rejects_short_commit_hex() {
let repo = GitRepoCoord {
owner: "a".repeat(64),
id: "repo".to_string(),
};
let meta = GitPatchMeta {
commit: Some("a".to_string()),
..Default::default()
};
let err = build_git_patch(&repo, "diff", &meta).unwrap_err();
assert!(matches!(err, SdkError::InvalidInput(_)));
}
#[test]
fn git_patch_accepts_full_sha1_and_sha256_commit_hex() {
let repo = GitRepoCoord {
owner: "a".repeat(64),
id: "repo".to_string(),
};
let meta_sha1 = GitPatchMeta {
commit: Some("c".repeat(40)),
..Default::default()
};
assert!(build_git_patch(&repo, "diff", &meta_sha1).is_ok());
let meta_sha256 = GitPatchMeta {
commit: Some("c".repeat(64)),
..Default::default()
};
assert!(build_git_patch(&repo, "diff", &meta_sha256).is_ok());
}
#[test]
fn git_status_defaults_p_tag_to_repo_owner_when_repo_given() {
// SDK-level: GitStatusMeta.recipients is the caller's responsibility
// (the CLI defaults it), but verify the repo owner ends up p-tagged
// when the CLI-style recipients list includes it.
let owner = "a".repeat(64);
let root = event_id().to_hex();
let meta = GitStatusMeta {
root_event: root,
repo: Some(GitRepoCoord {
owner: owner.clone(),
id: "repo".to_string(),
}),
recipients: vec![owner.clone()],
..Default::default()
};
let ev = sign(build_git_status(GitStatus::Open, "", &meta).unwrap());
assert!(has_tag(&ev, "p", &owner));
}
#[test]
fn git_applied_patch_ref_parse_id_only() {
let id = "a".repeat(64);
let parsed = GitAppliedPatchRef::parse(&id).unwrap();
assert_eq!(parsed.id, id);
assert_eq!(parsed.relay, None);
assert_eq!(parsed.pubkey, None);
}
#[test]
fn git_applied_patch_ref_parse_id_and_relay() {
let id = "a".repeat(64);
let spec = format!("{id}:wss://relay.example.com");
let parsed = GitAppliedPatchRef::parse(&spec).unwrap();
assert_eq!(parsed.id, id);
assert_eq!(parsed.relay, Some("wss://relay.example.com".to_string()));
assert_eq!(parsed.pubkey, None);
}
#[test]
fn git_applied_patch_ref_parse_id_relay_and_pubkey() {
let id = "a".repeat(64);
let pubkey = "b".repeat(64);
let spec = format!("{id}:wss://relay.example.com:{pubkey}");
let parsed = GitAppliedPatchRef::parse(&spec).unwrap();
assert_eq!(parsed.id, id);
assert_eq!(parsed.relay, Some("wss://relay.example.com".to_string()));
assert_eq!(parsed.pubkey, Some(pubkey));
}
#[test]
fn git_status_q_tag_includes_relay_and_pubkey_hints() {
let root = event_id().to_hex();
let patch_id = event_id().to_hex();
let pubkey = "b".repeat(64);
let meta = GitStatusMeta {
root_event: root,
applied_patches: vec![GitAppliedPatchRef {
id: patch_id.clone(),
relay: Some("wss://relay.example.com".to_string()),
pubkey: Some(pubkey.clone()),
}],
..Default::default()
};
let ev = sign(build_git_status(GitStatus::AppliedOrResolved, "", &meta).unwrap());
let q_tag = ev
.tags
.iter()
.find(|t| t.as_slice().first().map(|v| v.as_str()) == Some("q"))
.expect("q tag present");
let parts = q_tag.as_slice();
assert_eq!(parts.get(1).map(|v| v.as_str()), Some(patch_id.as_str()));
assert_eq!(
parts.get(2).map(|v| v.as_str()),
Some("wss://relay.example.com")
);
assert_eq!(parts.get(3).map(|v| v.as_str()), Some(pubkey.as_str()));
}
#[test]
fn workflow_def_happy_path() {
let cid = uuid();
let wid = uuid();
let ev = sign(build_workflow_def(cid, wid, "name: test\ntrigger:\n on: webhook").unwrap());
assert_eq!(ev.kind.as_u16(), 30620);
assert!(has_tag(&ev, "d", &wid.to_string()));
assert!(has_tag(&ev, "h", &cid.to_string()));
assert!(ev.content.contains("name: test"));
}
#[test]
fn workflow_def_rejects_oversized_yaml() {
let big = "x".repeat(65 * 1024);
let err = build_workflow_def(uuid(), uuid(), &big).unwrap_err();
assert!(matches!(err, SdkError::ContentTooLarge { .. }));
}
#[test]
fn workflow_update_includes_h_tag() {
let cid = uuid();
let wid = uuid();
let revision = "a".repeat(64);
let ev = sign(build_workflow_update(cid, wid, "name: updated", &revision).unwrap());
assert_eq!(ev.kind.as_u16(), 30620);
assert!(has_tag(&ev, "d", &wid.to_string()));
assert!(has_tag(&ev, "h", &cid.to_string()));
assert!(has_tag(&ev, "expected-revision", &revision));
}
#[test]
fn workflow_update_rejects_oversized_yaml() {
let big = "x".repeat(65 * 1024);
let err = build_workflow_update(uuid(), uuid(), &big, &"a".repeat(64)).unwrap_err();
assert!(matches!(err, SdkError::ContentTooLarge { .. }));
}
#[test]
fn workflow_delete_happy_path() {
let pk = "a".repeat(64);
let wid = uuid();
let ev = sign(build_workflow_delete(&pk, wid).unwrap());
assert_eq!(ev.kind.as_u16(), 5);
let a_vals = tag_values(&ev, "a");
assert_eq!(a_vals.len(), 1);
assert!(a_vals[0].starts_with("30620:"));
assert!(a_vals[0].contains(&wid.to_string()));
}
#[test]
fn workflow_delete_rejects_bad_pubkey() {
let err = build_workflow_delete("bad", uuid()).unwrap_err();
assert!(matches!(err, SdkError::InvalidInput(_)));
}
#[test]
fn workflow_trigger_happy_path() {
let wid = uuid();
let ev = sign(build_workflow_trigger(wid).unwrap());
assert_eq!(ev.kind.as_u16(), 46020);
assert!(has_tag(&ev, "d", &wid.to_string()));
}
#[test]
fn workflow_approval_grant() {
let hash = "a".repeat(64);
let ev = sign(build_workflow_approval(&hash, true, "lgtm").unwrap());
assert_eq!(ev.kind.as_u16(), 46030);
assert!(has_tag(&ev, "d", &hash));
assert_eq!(ev.content, "lgtm");
}
#[test]
fn workflow_approval_deny() {
let hash = "b".repeat(64);
let ev = sign(build_workflow_approval(&hash, false, "").unwrap());
assert_eq!(ev.kind.as_u16(), 46031);
}
#[test]
fn workflow_approval_rejects_bad_token_hash() {
let err = build_workflow_approval("not-hex", true, "").unwrap_err();
assert!(matches!(err, SdkError::InvalidInput(_)));
}
#[test]
fn workflow_approval_rejects_short_hash() {
let short = "a".repeat(32);
let err = build_workflow_approval(&short, true, "").unwrap_err();
assert!(matches!(err, SdkError::InvalidInput(_)));
}
#[test]
fn dm_open_happy_path() {
let pk = "a".repeat(64);
let ev = sign(build_dm_open(&[&pk]).unwrap());
assert_eq!(ev.kind.as_u16(), 41010);
assert!(has_tag(&ev, "p", &pk));
}
#[test]
fn dm_open_rejects_empty_pubkeys() {
let err = build_dm_open(&[]).unwrap_err();
assert!(matches!(err, SdkError::InvalidInput(_)));
}
#[test]
fn dm_open_rejects_over_8_pubkeys() {
let pk = "a".repeat(64);
let pks: Vec<&str> = vec![pk.as_str(); 9];
let err = build_dm_open(&pks).unwrap_err();
assert!(matches!(err, SdkError::InvalidInput(_)));
}
#[test]
fn dm_open_rejects_bad_pubkey() {
let err = build_dm_open(&["bad-hex"]).unwrap_err();
assert!(matches!(err, SdkError::InvalidInput(_)));
}
#[test]
fn dm_add_member_happy_path() {
let cid = uuid();
let pk = "b".repeat(64);
let ev = sign(build_dm_add_member(cid, &pk).unwrap());
assert_eq!(ev.kind.as_u16(), 41011);
assert!(has_tag(&ev, "h", &cid.to_string()));
assert!(has_tag(&ev, "p", &pk));
}
#[test]
fn dm_add_member_rejects_bad_pubkey() {
let err = build_dm_add_member(uuid(), "short").unwrap_err();
assert!(matches!(err, SdkError::InvalidInput(_)));
}
#[test]
fn presence_update_content_is_status() {
let ev = sign(build_presence_update("online").unwrap());
assert_eq!(ev.kind.as_u16(), 20001);
assert_eq!(ev.content, "online");
assert!(has_tag(&ev, "status", "online"));
}
#[test]
fn presence_update_away() {
let ev = sign(build_presence_update("away").unwrap());
assert_eq!(ev.content, "away");
}
#[test]
fn presence_update_offline() {
let ev = sign(build_presence_update("offline").unwrap());
assert_eq!(ev.content, "offline");
}
#[test]
fn presence_update_rejects_invalid_status() {
let err = build_presence_update("dnd").unwrap_err();
assert!(matches!(err, SdkError::InvalidInput(_)));
}
// ── build_user_status ─────────────────────────────────────────────────────
#[test]
fn user_status_carries_text_and_emoji_on_d_general() {
let ev = sign(build_user_status("shipping the CLI", Some("🚀")).unwrap());
assert_eq!(ev.kind.as_u16(), 30315);
assert_eq!(ev.content, "shipping the CLI");
assert_eq!(tag_values(&ev, "d"), vec!["general"]);
assert_eq!(tag_values(&ev, "emoji"), vec!["🚀"]);
}
#[test]
fn user_status_trims_text_and_emoji() {
let ev = sign(build_user_status(" heads down ", Some(" 🎧 ")).unwrap());
assert_eq!(ev.content, "heads down");
assert_eq!(tag_values(&ev, "emoji"), vec!["🎧"]);
}
#[test]
fn user_status_omits_blank_emoji_tag() {
let ev = sign(build_user_status("on call", Some(" ")).unwrap());
assert_eq!(ev.content, "on call");
assert!(tag_values(&ev, "emoji").is_empty());
}
#[test]
fn user_status_keeps_emoji_when_text_is_blank() {
let ev = sign(build_user_status("", Some("🎶")).unwrap());
assert_eq!(ev.content, "");
assert_eq!(tag_values(&ev, "emoji"), vec!["🎶"]);
}
#[test]
fn user_status_clear_shape_is_empty_content_and_d_tag_only() {
let ev = sign(build_user_status("", None).unwrap());
assert_eq!(ev.kind.as_u16(), 30315);
assert_eq!(ev.content, "");
assert_eq!(tag_values(&ev, "d"), vec!["general"]);
assert_eq!(ev.tags.len(), 1);
}
#[test]
fn user_status_rejects_oversize_text() {
let err = build_user_status(&"x".repeat(64 * 1024 + 1), None).unwrap_err();
assert!(matches!(err, SdkError::ContentTooLarge { .. }));
}
// ── build_git_pull_request / build_git_pr_update ──────────────────────────
fn pr_repo() -> GitRepoCoord {
GitRepoCoord {
owner: "a".repeat(64),
id: "repo".to_string(),
}
}
fn full_clone_tag(event: &nostr::Event) -> Vec<String> {
event
.tags
.iter()
.find(|t| t.as_slice().first().map(|v| v.as_str()) == Some("clone"))
.map(|t| t.as_slice()[1..].iter().map(|v| v.to_string()).collect())
.unwrap_or_default()
}
#[test]
fn git_pr_happy_path() {
let meta = GitPullRequestMeta {
subject: "Add feature X".to_string(),
commit: "c".repeat(40),
clone_urls: vec!["https://example.com/repo.git".to_string()],
branch_name: Some("feat/x".to_string()),
labels: vec!["enhancement".to_string()],
channel_id: Some("11111111-1111-4111-8111-111111111111".to_string()),
..Default::default()
};
let ev = sign(build_git_pull_request(&pr_repo(), "PR body", &meta).unwrap());
assert_eq!(ev.kind.as_u16(), 1618);
assert_eq!(ev.content, "PR body");
assert!(has_tag(&ev, "a", &format!("30617:{}:repo", "a".repeat(64))));
assert!(has_tag(&ev, "p", &"a".repeat(64)));
assert!(has_tag(&ev, "subject", "Add feature X"));
assert!(has_tag(&ev, "c", &"c".repeat(40)));
assert!(has_tag(&ev, "t", "enhancement"));
assert!(has_tag(&ev, "h", "11111111-1111-4111-8111-111111111111"));
assert!(has_tag(&ev, "branch-name", "feat/x"));
assert_eq!(
full_clone_tag(&ev),
vec!["https://example.com/repo.git".to_string()]
);
}
#[test]
fn git_pr_emits_multi_url_clone_tag() {
let meta = GitPullRequestMeta {
subject: "s".to_string(),
commit: "c".repeat(40),
clone_urls: vec![
"https://a.example/repo.git".to_string(),
"https://b.example/repo.git".to_string(),
],
..Default::default()
};
let ev = sign(build_git_pull_request(&pr_repo(), "", &meta).unwrap());
assert_eq!(full_clone_tag(&ev).len(), 2);
}
#[test]
fn git_pr_rejects_empty_subject() {
let meta = GitPullRequestMeta {
subject: String::new(),
commit: "c".repeat(40),
clone_urls: vec!["https://example.com/repo.git".to_string()],
..Default::default()
};
let err = build_git_pull_request(&pr_repo(), "body", &meta).unwrap_err();
assert!(matches!(err, SdkError::InvalidInput(_)));
}
#[test]
fn git_pr_rejects_missing_clone_url() {
let meta = GitPullRequestMeta {
subject: "s".to_string(),
commit: "c".repeat(40),
clone_urls: vec![],
..Default::default()
};
let err = build_git_pull_request(&pr_repo(), "body", &meta).unwrap_err();
assert!(matches!(err, SdkError::InvalidInput(_)));
}
#[test]
fn git_pr_rejects_short_commit() {
let meta = GitPullRequestMeta {
subject: "s".to_string(),
commit: "abc".to_string(),
clone_urls: vec!["https://example.com/repo.git".to_string()],
..Default::default()
};
let err = build_git_pull_request(&pr_repo(), "body", &meta).unwrap_err();
assert!(matches!(err, SdkError::InvalidInput(_)));
}
#[test]
fn git_pr_rejects_invalid_channel_id() {
for channel_id in ["not-a-uuid", " 11111111-1111-4111-8111-111111111111 "] {
let meta = GitPullRequestMeta {
subject: "s".to_string(),
commit: "c".repeat(40),
clone_urls: vec!["https://example.com/repo.git".to_string()],
channel_id: Some(channel_id.to_string()),
..Default::default()
};
let err = build_git_pull_request(&pr_repo(), "body", &meta).unwrap_err();
assert!(matches!(err, SdkError::InvalidInput(_)));
}
}
#[test]
fn git_pr_canonicalizes_channel_id() {
let meta = GitPullRequestMeta {
subject: "s".to_string(),
commit: "c".repeat(40),
clone_urls: vec!["https://example.com/repo.git".to_string()],
channel_id: Some("AAAAAAAA-BBBB-4CCC-8DDD-EEEEEEEEEEEE".to_string()),
..Default::default()
};
let ev = sign(build_git_pull_request(&pr_repo(), "body", &meta).unwrap());
assert!(has_tag(&ev, "h", "aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee"));
}
#[test]
fn git_pr_revision_of_emits_e_tag() {
let patch = event_id().to_hex();
let meta = GitPullRequestMeta {
subject: "s".to_string(),
commit: "c".repeat(40),
clone_urls: vec!["https://example.com/repo.git".to_string()],
revision_of: Some(patch.clone()),
..Default::default()
};
let ev = sign(build_git_pull_request(&pr_repo(), "", &meta).unwrap());
assert!(has_tag(&ev, "e", &patch));
}
#[test]
fn git_pr_update_happy_path() {
let pr = event_id().to_hex();
let meta = GitPrUpdateMeta {
pr_event: pr.clone(),
pr_author: "b".repeat(64),
commit: "d".repeat(40),
clone_urls: vec!["https://example.com/repo.git".to_string()],
merge_base: Some("e".repeat(40)),
..Default::default()
};
let ev = sign(build_git_pr_update(&pr_repo(), "rebased", &meta).unwrap());
assert_eq!(ev.kind.as_u16(), 1619);
assert!(has_tag(&ev, "E", &pr));
assert!(has_tag(&ev, "P", &"b".repeat(64)));
assert!(has_tag(&ev, "c", &"d".repeat(40)));
assert!(has_tag(&ev, "merge-base", &"e".repeat(40)));
assert_eq!(full_clone_tag(&ev).len(), 1);
}
#[test]
fn git_pr_update_rejects_bad_pr_event() {
let meta = GitPrUpdateMeta {
pr_event: "not-hex".to_string(),
pr_author: "b".repeat(64),
commit: "d".repeat(40),
clone_urls: vec!["https://example.com/repo.git".to_string()],
..Default::default()
};
let err = build_git_pr_update(&pr_repo(), "", &meta).unwrap_err();
assert!(matches!(err, SdkError::InvalidInput(_)));
}
#[test]
fn git_pr_update_rejects_missing_clone_url() {
let meta = GitPrUpdateMeta {
pr_event: event_id().to_hex(),
pr_author: "b".repeat(64),
commit: "d".repeat(40),
clone_urls: vec![],
..Default::default()
};
let err = build_git_pr_update(&pr_repo(), "", &meta).unwrap_err();
assert!(matches!(err, SdkError::InvalidInput(_)));
}
// --- community moderation commands (9040–9044) ------------------------
#[test]
fn moderation_ban_permanent() {
let pk = "a".repeat(64);
let ev = sign(build_moderation_ban(&pk, None, None).unwrap());
assert_eq!(ev.kind.as_u16(), KIND_MODERATION_BAN as u16);
assert!(has_tag(&ev, "p", &pk));
assert!(tag_values(&ev, "expiration").is_empty());
assert!(tag_values(&ev, "reason").is_empty());
}
#[test]
fn moderation_ban_temporary_with_reason() {
let pk = "b".repeat(64);
let ev = sign(build_moderation_ban(&pk, Some(1783500000), Some("spam")).unwrap());
assert_eq!(ev.kind.as_u16(), KIND_MODERATION_BAN as u16);
assert!(has_tag(&ev, "p", &pk));
assert!(has_tag(&ev, "expiration", "1783500000"));
assert!(has_tag(&ev, "reason", "spam"));
}
#[test]
fn moderation_ban_lowercases_pubkey() {
let ev = sign(build_moderation_ban(&"A".repeat(64), None, None).unwrap());
assert!(has_tag(&ev, "p", &"a".repeat(64)));
}
#[test]
fn moderation_ban_rejects_short_pubkey() {
let err = build_moderation_ban("abc", None, None).unwrap_err();
assert!(matches!(err, SdkError::InvalidInput(_)));
}
#[test]
fn moderation_ban_rejects_overlong_pubkey() {
// Relay `extract_p_tag_bytes` requires exactly 64 hex; the SDK must
// reject 65+ hex here rather than sign a `p` tag the relay drops.
let err = build_moderation_ban(&"a".repeat(65), None, None).unwrap_err();
assert!(matches!(err, SdkError::InvalidInput(_)));
}
#[test]
fn moderation_unban_shape() {
let pk = "c".repeat(64);
let ev = sign(build_moderation_unban(&pk).unwrap());
assert_eq!(ev.kind.as_u16(), KIND_MODERATION_UNBAN as u16);
assert!(has_tag(&ev, "p", &pk));
}
#[test]
fn moderation_timeout_shape() {
let pk = "d".repeat(64);
let ev = sign(build_moderation_timeout(&pk, 1783500000, Some("cool off")).unwrap());
assert_eq!(ev.kind.as_u16(), KIND_MODERATION_TIMEOUT as u16);
assert!(has_tag(&ev, "p", &pk));
assert!(has_tag(&ev, "expiration", "1783500000"));
assert!(has_tag(&ev, "reason", "cool off"));
}
#[test]
fn moderation_untimeout_shape() {
let pk = "e".repeat(64);
let ev = sign(build_moderation_untimeout(&pk).unwrap());
assert_eq!(ev.kind.as_u16(), KIND_MODERATION_UNTIMEOUT as u16);
assert!(has_tag(&ev, "p", &pk));
}
#[test]
fn moderation_resolve_shape() {
let rid = event_id().to_hex();
let ev =
sign(build_moderation_resolve_report(&rid, "resolved", "ban", Some("rule 3")).unwrap());
assert_eq!(ev.kind.as_u16(), KIND_MODERATION_RESOLVE_REPORT as u16);
assert!(has_tag(&ev, "report", &rid));
assert!(has_tag(&ev, "status", "resolved"));
assert!(has_tag(&ev, "action", "ban"));
assert!(has_tag(&ev, "reason", "rule 3"));
}
#[test]
fn moderation_resolve_dismiss_no_reason() {
let rid = event_id().to_hex();
let ev = sign(build_moderation_resolve_report(&rid, "dismissed", "dismiss", None).unwrap());
assert!(has_tag(&ev, "status", "dismissed"));
assert!(has_tag(&ev, "action", "dismiss"));
assert!(tag_values(&ev, "reason").is_empty());
}
#[test]
fn moderation_resolve_rejects_bad_status() {
let rid = event_id().to_hex();
let err = build_moderation_resolve_report(&rid, "escalated", "ban", None).unwrap_err();
assert!(matches!(err, SdkError::InvalidInput(_)));
}
#[test]
fn moderation_resolve_rejects_bad_action() {
let rid = event_id().to_hex();
let err = build_moderation_resolve_report(&rid, "resolved", "nuke", None).unwrap_err();
assert!(matches!(err, SdkError::InvalidInput(_)));
}
#[test]
fn moderation_resolve_rejects_short_report_id() {
let err = build_moderation_resolve_report("abc", "resolved", "ban", None).unwrap_err();
assert!(matches!(err, SdkError::InvalidInput(_)));
}
#[test]
fn moderation_resolve_rejects_overlong_report_id() {
// Relay `extract_report_tag` requires exactly 64 hex; the SDK must
// reject 65+ hex here rather than sign a `report` tag the relay drops.
let err =
build_moderation_resolve_report(&"a".repeat(65), "resolved", "ban", None).unwrap_err();
assert!(matches!(err, SdkError::InvalidInput(_)));
}
// ── NIP-IA identity archival (kinds 9035/9036) ────────────────────────
//
// Mirrors `desktop/src-tauri/src/events.rs`'s own tests so both clients
// are pinned to the same wire form. See NIP-IA.md §Vector 1.
const IA_OWNER_HEX: &str = "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798";
const IA_TARGET_HEX: &str = "c6047f9441ed7d6d3045406e95c07cd85c778e4b8cef3ca7abac09b95c709ee5";
const IA_CONDITIONS: &str = "kind=1&created_at<1713957000";
const IA_SIG: &str = "8b7df2575caf0a108374f8471722b233c53f9ff827a8b0f91861966c3b9dd5cb2e189eae9f49d72187674c2f5bd244145e10ff86c9f257ffe65a1ee5f108b369";
#[test]
fn archive_identity_request_matches_spec_vector_1_layout() {
let auth: [String; 4] = [
"auth".into(),
IA_OWNER_HEX.into(),
IA_CONDITIONS.into(),
IA_SIG.into(),
];
let ev = sign(
build_archive_identity_request(
IA_TARGET_HEX,
"Archiving zombie agent after rebuild.",
Some("bot-rebuilt"),
None,
Some(&auth),
)
.unwrap(),
);
let tags: Vec<Vec<String>> = ev.tags.iter().map(|t| t.as_slice().to_vec()).collect();
assert_eq!(ev.kind, Kind::Custom(KIND_IA_ARCHIVE_REQUEST as u16));
// Spec layout: ["-"], ["p", target], ["reason", code], ["auth", ...]
assert_eq!(tags[0], vec!["-"]);
assert_eq!(tags[1], vec!["p", IA_TARGET_HEX]);
assert_eq!(tags[2], vec!["reason", "bot-rebuilt"]);
assert_eq!(tags[3], vec!["auth", IA_OWNER_HEX, IA_CONDITIONS, IA_SIG]);
assert_eq!(tags.len(), 4);
}
#[test]
fn archive_request_rejects_replaced_by_equal_target() {
let err =
build_archive_identity_request(IA_TARGET_HEX, "", None, Some(IA_TARGET_HEX), None)
.unwrap_err();
assert!(matches!(err, SdkError::InvalidInput(_)));
}
#[test]
fn unarchive_request_layout_self_path() {
// Self-unarchive: actor == target, so the `p` tag points at the
// signer. Pins that `.allow_self_tagging()` survives nostr 0.44's
// default same-pubkey `p`-tag scrub, and that no `auth` tag rides
// along on the self path.
let builder = build_unarchive_identity_request(
IA_TARGET_HEX,
"I am active again.",
Some("returned"),
None,
)
.unwrap();
let target_secret = nostr::SecretKey::from_hex(
"0000000000000000000000000000000000000000000000000000000000000002",
)
.unwrap();
let ev = builder
.sign_with_keys(&Keys::new(target_secret))
.expect("sign");
let tags: Vec<Vec<String>> = ev.tags.iter().map(|t| t.as_slice().to_vec()).collect();
assert_eq!(ev.kind, Kind::Custom(KIND_IA_UNARCHIVE_REQUEST as u16));
assert_eq!(tags[0], vec!["-"]);
assert_eq!(tags[1], vec!["p", IA_TARGET_HEX]);
assert_eq!(tags[2], vec!["reason", "returned"]);
assert_eq!(tags.len(), 3, "self unarchive must not carry an auth tag");
assert_eq!(ev.pubkey.to_hex(), IA_TARGET_HEX);
}
#[test]
fn identity_archive_reason_accepts_64_bytes() {
// check_reason compares `.len()` (bytes), not chars — use a
// multi-byte char so a chars-based off-by-one would slip through.
let reason: String = "é".repeat(32); // 32 * 2 bytes = 64 bytes exactly
assert_eq!(reason.len(), 64);
build_archive_identity_request(IA_TARGET_HEX, "", Some(&reason), None, None)
.expect("64-byte reason must be accepted");
}
#[test]
fn identity_archive_reason_rejects_65_bytes() {
let reason: String = "a".repeat(65);
let err = build_archive_identity_request(IA_TARGET_HEX, "", Some(&reason), None, None)
.unwrap_err();
assert!(matches!(err, SdkError::InvalidInput(_)));
}
#[test]
fn identity_archive_reason_rejects_control_chars() {
let err = build_unarchive_identity_request(IA_TARGET_HEX, "", Some("bad\nreason"), None)
.unwrap_err();
assert!(matches!(err, SdkError::InvalidInput(_)));
}
#[test]
fn identity_archive_rejects_malformed_auth_tag() {
let bad_auth: [String; 4] = [
"auth".into(),
IA_OWNER_HEX.into(),
IA_CONDITIONS.into(),
"not-hex".into(),
];
let err = build_archive_identity_request(IA_TARGET_HEX, "", None, None, Some(&bad_auth))
.unwrap_err();
assert!(matches!(err, SdkError::InvalidInput(_)));
}
#[test]
fn unarchive_request_has_no_replaced_by_param() {
// 9036 has no `replaced_by` parameter at all (unlike 9035) — this is
// a compile-time guarantee, but pin the tag count so a future
// signature change that reintroduces it doesn't silently widen the
// wire form without a test failing.
let ev = sign(
build_unarchive_identity_request(IA_TARGET_HEX, "", Some("returned"), None).unwrap(),
);
assert!(!ev
.tags
.iter()
.any(|t| t.as_slice().first().map(String::as_str) == Some("replaced-by")));
}
// ── NIP-MP cap-before-arity ordering ─────────────────────────────────────
/// When an envelope exceeds the member cap AND contains a malformed `a` tag,
/// the validator must fire `member-cap` (rule 3) — not `member-tag-arity`
/// (rule 4). This matches the relay's ingest ordering and means a client
/// sending an oversized list never receives a per-tag parse error.
#[test]
fn validate_project_envelope_cap_wins_over_arity_when_both_fail() {
let owner = "a".repeat(64);
// Build 65 well-formed `a` tags — enough to trigger the cap.
let mut tags = vec![Tag::parse(["d", "platform"]).unwrap()];
for i in 0..65usize {
let coord = format!("30617:{owner}:repo-{i}");
tags.push(Tag::parse(["a", &coord]).unwrap());
}
// Also add one malformed tag (four elements) that would fire
// member-tag-arity if evaluated before the cap check.
let coord_extra = format!("30617:{owner}:repo-extra");
tags.push(
Tag::parse([
"a",
&coord_extra,
"wss://relay.example.com",
"extra-element",
])
.unwrap(),
);
let err = validate_project_envelope(&tags, "").unwrap_err();
let msg = err.to_string();
assert!(
msg.contains("member-cap"),
"expected member-cap to win, got: {msg}"
);
assert!(
!msg.contains("member-tag-arity"),
"arity rule must not fire before cap rule, got: {msg}"
);
}
// ── Layer B writer-policy builder ───────────────────────────────────────
const OWNER64: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
const VALID_UUID: &str = "3580ca9b-47b4-4af9-b22a-1068778f26c6";
fn member_coord(repo: &str) -> ProjectMemberCoord {
ProjectMemberCoord::parse_full(&format!("30617:{OWNER64}:{repo}")).unwrap()
}
#[test]
fn build_project_emitted_envelope_has_correct_shape() {
// slug, name, description, channel, visibility, and one member.
let m = member_coord("buzz");
let ev = sign(
build_project(
"my-proj",
Some("My Project"),
Some("A description"),
&[m],
Some(VALID_UUID),
Some("listed"),
)
.expect("Layer B must accept valid inputs"),
);
// Kind must be 30621.
assert_eq!(ev.kind.as_u16(), KIND_PROJECT as u16);
// Content must be empty (Layer B policy).
assert!(ev.content.is_empty(), "content must be empty");
let all_tags: Vec<Vec<String>> = ev.tags.iter().map(|t| t.as_slice().to_vec()).collect();
// d tag must be present exactly once.
let d_tags: Vec<_> = all_tags.iter().filter(|t| t[0] == "d").collect();
assert_eq!(d_tags.len(), 1);
assert_eq!(d_tags[0][1], "my-proj");
// name, description, buzz-channel, buzz-visibility present.
let name_tags: Vec<_> = all_tags.iter().filter(|t| t[0] == "name").collect();
assert_eq!(name_tags.len(), 1);
assert_eq!(name_tags[0][1], "My Project");
let desc_tags: Vec<_> = all_tags.iter().filter(|t| t[0] == "description").collect();
assert_eq!(desc_tags.len(), 1);
assert_eq!(desc_tags[0][1], "A description");
let ch_tags: Vec<_> = all_tags.iter().filter(|t| t[0] == "buzz-channel").collect();
assert_eq!(ch_tags.len(), 1);
assert_eq!(ch_tags[0][1], VALID_UUID);
let vis_tags: Vec<_> = all_tags
.iter()
.filter(|t| t[0] == "buzz-visibility")
.collect();
assert_eq!(vis_tags.len(), 1);
assert_eq!(vis_tags[0][1], "listed");
// member a tag.
let a_tags: Vec<_> = all_tags.iter().filter(|t| t[0] == "a").collect();
assert_eq!(a_tags.len(), 1);
assert_eq!(a_tags[0][1], format!("30617:{OWNER64}:buzz"));
}
#[test]
fn build_project_optional_fields_absent_when_not_supplied() {
let m = member_coord("core");
let ev = sign(
build_project("my-proj", None, None, &[m], None, None)
.expect("minimal build must succeed"),
);
let names: Vec<_> = ev
.tags
.iter()
.filter(|t| t.as_slice().first().map(|s| s.as_str()) == Some("name"))
.collect();
assert!(names.is_empty(), "name tag must not be emitted when absent");
}
#[test]
fn build_project_rejects_empty_slug() {
let m = member_coord("r");
let err = build_project("", None, None, &[m], None, None).unwrap_err();
assert!(
matches!(err, SdkError::InvalidInput(_)),
"empty slug must be InvalidInput, got: {err:?}"
);
assert!(err.to_string().contains("empty"));
}
#[test]
fn build_project_rejects_overlong_slug() {
let long_slug = "a".repeat(PROJECT_D_MAX_LEN + 1);
let m = member_coord("r");
let err = build_project(&long_slug, None, None, &[m], None, None).unwrap_err();
assert!(matches!(err, SdkError::InvalidInput(_)));
}
#[test]
fn build_project_rejects_invalid_channel_uuid() {
let m = member_coord("r");
let err = build_project("slug", None, None, &[m], Some("not-a-uuid"), None).unwrap_err();
assert!(matches!(err, SdkError::InvalidInput(_)));
assert!(err.to_string().contains("UUID") || err.to_string().contains("uuid"));
}
#[test]
fn build_project_rejects_invalid_visibility_token() {
let m = member_coord("r");
let err = build_project("slug", None, None, &[m], None, Some("chartreuse")).unwrap_err();
assert!(matches!(err, SdkError::InvalidInput(_)));
assert!(err.to_string().contains("listed") || err.to_string().contains("unlisted"));
}
#[test]
fn build_project_rejects_over_cap_members() {
let members: Vec<_> = (0..=PROJECT_MEMBER_CAP)
.map(|i| member_coord(&format!("repo-{i}")))
.collect();
let err = build_project("slug", None, None, &members, None, None).unwrap_err();
assert!(matches!(err, SdkError::InvalidInput(_)));
assert!(
err.to_string().contains("member-cap"),
"over-cap must report member-cap, got: {err}"
);
}
#[test]
fn build_project_rejects_duplicate_members() {
let m = member_coord("same");
let err = build_project("slug", None, None, &[m.clone(), m], None, None).unwrap_err();
assert!(matches!(err, SdkError::InvalidInput(_)));
assert!(
err.to_string().contains("dedup") || err.to_string().contains("duplicate"),
"duplicate member must report dedup, got: {err}"
);
}
#[test]
fn build_project_content_is_always_empty() {
// build_project forces content="" regardless; Layer A also enforces
// that the envelope is valid. Any non-empty content would be dropped.
// This test pins the Layer B content-forced-empty policy.
let m = member_coord("r");
let ev = sign(build_project("slug", None, None, &[m], None, None).unwrap());
assert!(
ev.content.is_empty(),
"Layer B must always emit empty content"
);
}
// ── NIP-MP conformance fixtures ──────────────────────────────────────────
// `build_project_with_tags` directly. Accept cases must build; reject
// cases must fail with an error message containing the expected rule name.
// A count assertion guards against silent omissions.
//
// `include_str!` path is relative to this source file.
fn nip_mp_fixture_tags(json_tags: &serde_json::Value) -> Vec<Tag> {
json_tags
.as_array()
.unwrap()
.iter()
.map(|t| {
let parts: Vec<String> = t
.as_array()
.unwrap()
.iter()
.map(|v| v.as_str().unwrap().to_string())
.collect();
let parts_ref: Vec<&str> = parts.iter().map(String::as_str).collect();
Tag::parse(parts_ref.iter().copied())
.unwrap_or_else(|e| panic!("fixture tag parse error: {e}\n raw: {t}"))
})
.collect()
}
#[test]
fn nip_mp_fixtures_all_31_cases_exercised() {
const FIXTURE_JSON: &str = include_str!("../../../docs/nips/NIP-MP.fixtures.json");
let data: serde_json::Value =
serde_json::from_str(FIXTURE_JSON).expect("fixture JSON must parse");
let cases = data["cases"].as_array().expect("cases must be array");
// Count gate: the spec says "required to test against this one file"
// with the exact count as-shipped.
assert_eq!(
cases.len(),
31,
"expected 31 fixture cases, got {} — was NIP-MP.fixtures.json edited?",
cases.len()
);
let mut accept_count = 0usize;
let mut reject_count = 0usize;
for case in cases {
let name = case["name"].as_str().unwrap();
let expect = case["expect"].as_str().unwrap();
let template = &case["template"];
let content = template["content"].as_str().unwrap_or("");
let tags = nip_mp_fixture_tags(&template["tags"]);
match expect {
"accept" => {
build_project_with_tags(content, tags).unwrap_or_else(|e| {
panic!("fixture '{name}' (accept) must build successfully, got: {e}")
});
accept_count += 1;
}
"reject" => {
let reject_rules = case["reject_rules"]
.as_array()
.expect("reject case must have reject_rules")
.iter()
.map(|r| r.as_str().unwrap().to_string())
.collect::<Vec<_>>();
let err = build_project_with_tags(content, tags).unwrap_err();
let err_msg = err.to_string();
// The error must mention at least one of the expected rules.
let rule_matched = reject_rules.iter().any(|r| err_msg.contains(r.as_str()));
assert!(
rule_matched,
"fixture '{name}' rejected with wrong rule.\n expected one of: {reject_rules:?}\n got error: {err_msg}"
);
reject_count += 1;
}
other => panic!("fixture '{name}' has unknown expect value: {other:?}"),
}
}
assert_eq!(accept_count, 11, "expected 11 accept cases");
assert_eq!(reject_count, 20, "expected 20 reject cases");
}
}