mirror of
https://github.com/block/buzz.git
synced 2026-10-28 13:01:10 +00:00
🤖 ## Summary Sharing a Solo chat with a large channel today means adding every person as a member, one at a time, and the app blocks the share past 100 people. The plan is to share it the way Google Docs does with "anyone at the company with the link". Buzz's `open` visibility already gives that kind of access: anyone in the community can read and join an open channel. The problem is that an open channel also shows up in everyone's channel list and search, and a private chat shouldn't appear there just because its link was posted somewhere. This PR adds a channel setting, `unlisted`. An **unlisted open channel** can be read and joined by anyone who names it by id (from a shared link). For non-members, it is left out of every read that does not name it. Members see it as before. This is the relay side only; the buzz-app change comes later. #8575 (batch adds) remains the path for small audiences. Unlisted is not an access control. Anyone who has the id can read the channel, and that is intended. ## Solo flows this supports Each user action is one event. The buzz-app side comes later; the relay supports all of these now. | Flow | Event | Result | | --- | --- | --- | | New Solo chat | kind:9007 `visibility private` + `["unlisted", "true"]` | The chat starts unlisted, so it stays unlisted when any client later opens it. | | Share | One kind:9002: `visibility open` + `unlisted true` + `join_role guest\|member`. Then post the link. | Anyone with the link can read the chat by id, both history and live. Nobody is added as a member, so the chat isn't in anyone's channel list. A non-member can't post; `join_role` decides whether someone who joins can. | | View | A REQ that names the chat by `#h`, or by `#d` for its metadata | A non-member can read it. It is never listed for them. | | Reply | kind:9021 join (the app sends it before a viewer's first reply) | Joining makes them a member with the role from `join_role`, so with `member` they can post. A kind:9022 leave makes them a viewer again; the chat stays readable by id. | | Unshare | kind:9002 `visibility private` | The existing open→private eviction closes non-members' live subscriptions. New reads by non-members are refused. | | Convert to a channel | One kind:9002: `["t", ""]` + `unlisted false`, plus `visibility` | Clears the Solo label. Non-members can now find it in discovery. | **Sidebar pins are not relay state.** Pin and unpin live in the app's existing kind:30078 sidebar preferences, which sync across devices and don't touch membership. Don't map them to join/leave: someone a chat was shared with by member add is already a member, so a leave would remove their access. Your own Solo chats keep showing up, because you're their owner member, and members see unlisted channels everywhere. Existing Solo chats aren't backfilled. They get the flag when they're shared, because the share event sets it. **Open question, not built here:** someone who only views a shared chat isn't a member. A mention of them doesn't reach their inbox (`#p:[me]` without `#h`) and sends them no push. ## Behavior | Reader | Reads that don't name the channel (kind:39000 without `#d`, `#P` creator queries, messages, COUNT, NIP-50 search, artifact queries, channel list) | Reads that name it (`#h`, or `#d` on 39000–39003) | kind:9021 join | | --- | --- | --- | --- | | Member | Listed | Readable | n/a | | Non-member, unlisted open channel | **Not listed** | Readable | Works; `join_role` still decides the role | | Non-member, private channel (unlisted or not) | Not listed | Not readable | Refused | - **Setting it:** kind:9002 `["unlisted", "true"]` / `["unlisted", "false"]`. Owner or admin only, like `visibility`. A missing tag means no change. It can be sent in the same event as `["visibility", "open"]`, so a private chat becomes open and unlisted in one step. Any other value is refused before the event is stored. The relay posts an `unlisted_changed` system message and clears the accessible-channels cache across nodes. Nobody loses access when a channel is unlisted, so no subscriptions are evicted. - **At creation:** kind:9007 also takes `["unlisted", "true"|"false"]`. A missing tag means listed. Any other value is refused before the event is stored, as on kind:9002. It is stored with the channel row in the create's transaction, on both the client-UUID path and the relay-chosen-UUID path. - **kind:39000** carries a bare `["unlisted"]` flag while the setting is on. It does not add NIP-29 `hidden`, so the metadata stays readable by id. - **NIP-11** advertises `buzz-unlisted-channels` in `supported_extensions`. A relay without this PR would apply `["visibility", "open"]` and ignore `unlisted`, which would list the chat publicly. So buzz-app must check for the extension before it sends that pair. - **CLI:** `buzz channels update --channel <id> --unlisted true|false` and `buzz channels create ... --unlisted`. `buzz_sdk::build_update_channel` takes a new `unlisted: Option<bool>` argument, and `buzz_sdk::build_create_channel` takes `unlisted: bool`, which emits the tag only when true. ### Open question: does `unlisted` only mean something when the channel is open? **Decision: store it on its own; it only has an effect while the channel is open.** Private channels are already invisible to non-members, so on a private channel the flag does nothing. It is still stored and published, for three reasons: - a client can mark a Solo chat unlisted before opening it, or in the same event that opens it, so the chat is never listed even for a moment; - switching private → open → private → open keeps the setting; - there is no cross-field rule in kind:9002 to validate or keep in sync. kind:39000 publishes the flag as stored, whatever the visibility, so clients can show the setting. ## How The relay already has a "readable only when named" path for system channels (NIP-CL). Unlisted open channels reuse it: - **`get_accessible_channel_ids`**: the open half (the implicit scope of a REQ that doesn't name a channel) adds `AND NOT unlisted`. The member half is unchanged, so members still have the channel. - **`get_readable_named_only_channels`**: also returns unlisted open channels of the legacy types. Every reader of that set (REQ, COUNT, NIP-50 search, HTTP `/query` and `/count`, thread windows, workflow run reads) adds a named-only channel only to a filter that names it, through `filter_channel_scope`. An unlisted channel's type is `stream`/`forum`, so the `#t:["system"]`/`#P` system opt-in never adds it. - **`get_accessible_channels`** (the channel list): open channels are listed to non-members only when not unlisted. - **`query_artifacts`**: an artifact in an unlisted open channel reaches a non-member only when the query's `#h` names that channel. - **Live fan-out:** no change was needed. A global subscription never receives channel-scoped events, kind:39000 included (`fan_out_scoped`). A channel-scoped subscription has to name the channel, and that goes through the same named-only authorization. **Left alone on purpose:** access checks that read a named channel or event keep treating an unlisted channel as open. These are `handle_join_request` (kind:9021), the ingest write gate, operator-listener mention delivery (`load_deliverable_event`), personal read-state writes, and push matching. Push matching still requires membership, so non-members get no pushes from an unlisted channel. **Named reads checked:** these callers use the implicit channel set (`get_accessible_channel_ids`, its cache, `get_accessible_channels`). Every one that reads a channel the request names also adds the named-only set, so a non-member can still read an unlisted open chat by id. That covers WS REQ (history, live registration, NIP-50 search), WS COUNT, HTTP `/query` and `/count`, thread windows and workflow run reads. Other named reads don't use the implicit set: they check visibility or membership, so an unlisted open chat counts as open. Those are live fan-out, the ingest write gate, typing, kind:9021 join, huddle audio join, operator-listener delivery, the workflow sink and read-state writes. Feed queries (mentions, needs-action, activity) and the `/buzz/v1/me/sidebar` accessory API are lists, so leaving out chats you only view is intended. Media reads don't check channels. `buzz-auth`'s `ChannelAccessChecker` has no production implementation. ## Database (docs/database.md checklist) - **Phase:** expand only. The migration adds a column with a constant default, and the same release ships the code that reads and writes it. No backfill or contract step follows. - No trigger, function, procedure, rule or foreign key is added. - No migration changes existing rows. `ADD COLUMN ... BOOLEAN NOT NULL DEFAULT FALSE` stores the constant default in the catalog, so the table is not rewritten. - Every location in Rule 4 is updated: `migrations/0069_channel_unlisted.sql`, `schema/tables/public/channels.sql`, and the migration count. No seeds, types or reconcile changes are needed. `admin_schema_parity_between_desired_state_and_migrations` now compares `channels` columns between the pgschema desired state and the migrated database. I failed it once by removing the column from `channels.sql`. Only columns are compared for `channels`: on current `main`, pgschema already re-emits `idx_channels_id_live` without its `INCLUDE (community_id)`, an index drift that predates this PR and is noted in the test. - **Currently deployed relay against the new schema:** it never selects or writes `unlisted`, and every insert gets the default. All channel-record queries name their columns. Nobody can set the flag until this relay ships, because the old relay refuses a kind:9002 whose only tag is `unlisted`. - The migration starts with `SET LOCAL lock_timeout = '5s'`. No index is added: the new predicates ride the existing `(community_id, visibility)` index. ## Rollout 1. Deploy. Every channel stays listed, so nothing changes. 2. **Don't unlist any channel while an old relay is still running.** An old relay ignores the column and lists the channel to everyone. The NIP-11 extension lets clients gate on this. Unlisted isn't an access boundary, so a mixed fleet only exposes the channel in listings for a while. 3. **Rollback:** an older relay ignores the column, so unlisted channels show up as ordinary open channels again. Nothing breaks. ## Tests Postgres (`buzz-db`, discovered by the Postgres lane): - `unlisted_open_channel_is_named_only_for_non_members`: sets the flag through `update_channel`. Then checks that a non-member doesn't get it from `get_accessible_channel_ids` or `get_accessible_channels`, and does get it from `get_readable_named_only_channels`; that a member still lists it; that `put_member` (the kind:9021 path) lets the non-member join, after which they list it; and that listing it again shows it to a new reader. - `unlisted_private_channel_stays_private_and_opens_unlisted`: a private unlisted channel is not readable by name, and opening it keeps the flag. - `group_metadata_publishes_the_stored_unlisted_flag`, plus the unit test `publishes_the_unlisted_flag_as_stored`. - `unlisted_channel_artifacts_are_read_by_name_for_non_members`. - `channel_created_unlisted_is_never_listed`: creating through `create_channel_with_event` (the kind:9007 path) and `create_labeled_channel` stores the flag, and the channel is never listed to a non-member. - Mutation-checked: each of `NOT unlisted` in the implicit scope, `NOT c.unlisted` in the channel list, the unlisted branch of the named-only query, its `visibility = 'open'` guard, and the artifact condition was removed one at a time, and each removal failed at least one of these tests. Relay E2E (`test_nip29_unlisted_channel_wire`, added to the CI relay E2E step), against a local relay built from this branch: - A non-member or an unknown value is refused. - kind:39000 by `#d` carries `unlisted` and `public`, and not `hidden`. - kind:39000 discovery without `#d`: non-members don't see the channel, members do. - History and NIP-50 search: unnamed, a non-member doesn't see the channel's messages; with `#h`, they do; a member sees them unnamed. - A kind:9021 join works, and the joiner then lists the channel. - Listing it again shows it to everyone. - A reader whose channel set was cached before each change sees the change at once. I ran this against a relay with the cache invalidation removed, and it failed. Relay E2E (`test_nip29_solo_share_and_convert_wire`, added to the CI relay E2E step), each flow as a single event: - kind:9007 with an unknown `unlisted` value is refused. A chat created with `unlisted true` publishes the flag, and stays unlisted after a plain `visibility open`. - Share (open + unlisted + `join_role guest` in one kind:9002): a non-member doesn't list the chat. They read it by `#h`, both history and a live message, and can't post. A kind:9021 join lists it for them as a read-only guest. A kind:9022 leave unlists it again, and it stays readable by `#h`. - Unshare (`visibility private`): the viewer's live subscription gets CLOSED, and a new `#h` read is refused. - Convert (`["t", ""]` + `unlisted false`): the Solo label is cleared, and a non-member finds the chat in discovery. - I ran this against a relay that dropped the kind:9007 flag, and it failed. Also run: `cargo test` for buzz-db, buzz-sdk, buzz-cli, buzz-core, buzz-relay and buzz-test-client; the Postgres lane for buzz-db and buzz-relay (1045 tests); and the existing unlisted, announce, departure, guest, role-change and NIP-43 snapshot wire tests. In the lane, two tests failed on pool timeouts (`resend_validated_after_the_first_commit_is_a_duplicate`, `edit_replaces_and_clears_labels`) and passed when rerun alone. An earlier run had the same kind of flake: `routed_fallback_spends_one_acquire_budget_when_aurora_cache_is_cold` (a timing budget) and `resent_edit_is_a_duplicate` (a pool timeout). In a full local `e2e_relay` run, three tests outside CI's list also failed (`test_invite_*` twice, `test_unarchive_emits_member_added_notification`). They fail the same way on a relay built from `main`. ## Not in this PR - The buzz-app follow-up: support open Solo chats, the join-from-card view, the large-audience prompt, and gating on `buzz-unlisted-channels`. - A NIP-CL update. The behavior is documented in NOSTR.md ("Unlisted channels"). buzz-review-completed --------- Signed-off-by: Bradley Axen <baxen@squareup.com> Signed-off-by: Luke Tornquist <tornquist@squareup.com> Co-authored-by: ART <ea39c1c8acf57180161ef4ba4ed68abf8589f5291956b909a5aa991fc66664ca@buzz.block.builderlab.xyz> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: mbot <3754f8729004d95654c46dbab3129e4ab9ef05cc2534e2a3fbfc155983bd637b@buzz.block.builderlab.xyz> Co-authored-by: Luke Tornquist <tornquist@squareup.com> Co-authored-by: Elrond <28d6302a099e5225b02c4155ac4236e4912603df2ab08dbfc2f4fef08ce598c8@buzz.block.builderlab.xyz>
5673 lines
204 KiB
Rust
5673 lines
204 KiB
Rust
//! Typed event builder functions (39 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(())
|
||
}
|
||
|
||
/// Validate, deduplicate, and cap mentions, emitting p-tags.
|
||
///
|
||
/// Validation lives here rather than in [`crate::mentions::normalize_mention_pubkeys`]
|
||
/// because this helper is the single choke point every mention reaches: it is
|
||
/// shared by `build_message`, `build_forum_post`, and `build_forum_comment`, and
|
||
/// a caller may hand those builders an explicit list without going through the
|
||
/// normalizer at all. Without a check here, `"not-a-pubkey"`, `""`, and
|
||
/// `"../../etc/passwd"` were lowercased into `p` tags and signed, so the
|
||
/// composer reported a successful mention that could notify nobody.
|
||
///
|
||
/// [`check_pubkey_hex`] is reused deliberately: it is the SDK's existing
|
||
/// structural pubkey contract (64 ASCII hex, returned lowercased) and is already
|
||
/// applied before other builders emit `p` tags, so mentions now agree with the
|
||
/// rest of this module and yield the same [`SdkError::InvalidInput`]. Adopting
|
||
/// `nostr::PublicKey::from_hex`'s stricter on-curve rule would be a wider
|
||
/// consistency change across every `check_pubkey_hex` call site, not a
|
||
/// mentions-only fix.
|
||
///
|
||
/// The cap is checked before validation so an over-cap list still reports
|
||
/// [`SdkError::TooManyMentions`] rather than being masked by whichever entry
|
||
/// happens to be malformed.
|
||
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 = check_pubkey_hex(hex, "mention pubkey")?;
|
||
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(())
|
||
}
|
||
|
||
/// Attach NIP-30 `["emoji", shortcode, url]` tags.
|
||
///
|
||
/// Each element of `emoji_tags` must be a three-element vector whose first
|
||
/// entry is `"emoji"`. Entries that don't match this shape are silently
|
||
/// skipped so an unknown future shape never blocks a message send.
|
||
fn nip30_emoji_tags(emoji_tags: &[Vec<String>], tags: &mut Vec<Tag>) -> Result<(), SdkError> {
|
||
for et in emoji_tags {
|
||
if et.len() == 3 && et[0] == "emoji" {
|
||
let parts: Vec<&str> = et.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
|
||
/// - `emoji_tags`: NIP-30 `["emoji", shortcode, url]` tag vectors
|
||
pub fn build_message(
|
||
channel_id: Uuid,
|
||
content: &str,
|
||
thread_ref: Option<&ThreadRef>,
|
||
mentions: &[&str],
|
||
broadcast: bool,
|
||
media_tags: &[Vec<String>],
|
||
emoji_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)?;
|
||
nip30_emoji_tags(emoji_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).
|
||
///
|
||
/// When `expected_revision` is set, an `["expected-revision", …]` tag is
|
||
/// attached. A 64-hex event ID names the head the write was composed against;
|
||
/// the literal `none` asserts no head exists yet. The relay enforces this tag
|
||
/// as a compare-and-swap (CAS): it reads the canonical live head under an
|
||
/// advisory lock, checks the precondition, and rejects mismatched writes before
|
||
/// insertion. Omit the tag for an unconditional append (backward compatible).
|
||
pub fn build_set_canvas(
|
||
channel_id: Uuid,
|
||
content: &str,
|
||
expected_revision: Option<&str>,
|
||
) -> Result<EventBuilder, SdkError> {
|
||
let mut tags = vec![tag(&["h", &channel_id.to_string()])?];
|
||
if let Some(expected_revision) = expected_revision {
|
||
if expected_revision != "none"
|
||
&& (expected_revision.len() != 64
|
||
|| !expected_revision.chars().all(|c| c.is_ascii_hexdigit()))
|
||
{
|
||
return Err(SdkError::InvalidInput(format!(
|
||
"expected_revision must be the literal \"none\" or a 64-character hex event id (got {expected_revision:?})"
|
||
)));
|
||
}
|
||
tags.push(tag(&["expected-revision", expected_revision])?);
|
||
}
|
||
Ok(EventBuilder::new(Kind::Custom(40100), content).tags(tags))
|
||
}
|
||
|
||
/// Build a canvas write (kind 40100) that edits or restores against a known
|
||
/// head, applying writer discipline in one place.
|
||
///
|
||
/// Sets the `expected-revision` precondition to `head_id` and stamps
|
||
/// `created_at = max(now, head_created_at + 1)` so the event sorts strictly
|
||
/// ahead of the head it asserts under `created_at DESC, id ASC`. This keeps a
|
||
/// legitimate first-party restore/edit whose local clock lags the head from
|
||
/// landing behind that head in read order (which would "succeed" without
|
||
/// changing the visible canvas). First-party signers (CLI `set`/restore,
|
||
/// Desktop save/restore) MUST route disciplined canvas writes through this
|
||
/// helper rather than re-deriving the timestamp.
|
||
///
|
||
/// Ordering note: the `+ 1` bump guarantees a strictly greater `created_at`, so
|
||
/// the write never ties the head. Writes that *do* share a second resolve by
|
||
/// `id ASC` under `created_at DESC, id ASC` — the smallest event id wins the
|
||
/// visible head, not the last write. This helper sidesteps that tie by stamping
|
||
/// ahead; unconditional appends that omit the bump remain subject to it.
|
||
pub fn build_set_canvas_after_head(
|
||
channel_id: Uuid,
|
||
content: &str,
|
||
head_id: &str,
|
||
head_created_at: u64,
|
||
) -> Result<EventBuilder, SdkError> {
|
||
let created_at = canvas_write_created_at(head_created_at)?;
|
||
Ok(build_set_canvas(channel_id, content, Some(head_id))?
|
||
.custom_created_at(nostr::Timestamp::from(created_at)))
|
||
}
|
||
|
||
/// Build an unconditional canvas write (kind 40100) that still applies writer
|
||
/// discipline: stamps `created_at = max(now, head_created_at + 1)` so the
|
||
/// event sorts strictly ahead of the current head under `created_at DESC, id ASC`.
|
||
///
|
||
/// Unlike [`build_set_canvas_after_head`], no `expected-revision` tag is added
|
||
/// — the write is an unconditional append that can never conflict. Use this for
|
||
/// `buzz canvas set`, which documents unconditional replace semantics.
|
||
///
|
||
/// Returns an error if `head_created_at` is more than
|
||
/// [`CANVAS_MAX_FUTURE_SKEW_SECS`] beyond `now` (poisoned timeline guard).
|
||
pub fn build_set_canvas_unconditional_after_head(
|
||
channel_id: Uuid,
|
||
content: &str,
|
||
head_created_at: u64,
|
||
) -> Result<EventBuilder, SdkError> {
|
||
let created_at = canvas_write_created_at(head_created_at)?;
|
||
Ok(build_set_canvas(channel_id, content, None)?
|
||
.custom_created_at(nostr::Timestamp::from(created_at)))
|
||
}
|
||
|
||
/// Maximum future skew a canvas head may carry before a first-party client
|
||
/// refuses to ratchet past it (seconds). A head timestamped beyond `now +
|
||
/// CANVAS_MAX_FUTURE_SKEW_SECS` is treated as poisoned: stamping
|
||
/// `max(now, head + 1)` against it would silently extend a bogus timeline
|
||
/// arbitrarily far ahead, and every later legitimate write would inherit that
|
||
/// floor.
|
||
///
|
||
/// This is kept well below the relay's general ±900 s drift window. A ceiling
|
||
/// head at `now + 60` produces a write at `now + 61`, which is within the relay's
|
||
/// kind-40100-specific future bound (`CANVAS_MAX_INGEST_FUTURE_SECS = 300` in
|
||
/// ingest.rs) and far inside the general ±900 s window — first-party clients
|
||
/// can never construct an ingest-rejected event through ordinary use.
|
||
pub const CANVAS_MAX_FUTURE_SKEW_SECS: u64 = 60;
|
||
|
||
/// Contract-v3 writer-discipline timestamp for a canvas write asserting a head
|
||
/// at `head_created_at`: `max(now, head_created_at + 1)` (Unix seconds).
|
||
///
|
||
/// The single home for canvas timestamp discipline. `build_set_canvas_after_head`
|
||
/// stamps CLI restore/`set` writes with this, and Desktop's `set_canvas` calls
|
||
/// it directly for the same reason, so the `max(now, head + 1)` rule is never
|
||
/// re-derived per surface.
|
||
///
|
||
/// Rejects a head timestamped more than [`CANVAS_MAX_FUTURE_SKEW_SECS`] beyond
|
||
/// `now` rather than ratcheting past it: extending a poisoned future timeline
|
||
/// would strand every later write behind a floor arbitrarily far ahead.
|
||
/// `u64::MAX` is covered by the same ceiling.
|
||
pub fn canvas_write_created_at(head_created_at: u64) -> Result<u64, SdkError> {
|
||
let now = std::time::SystemTime::now()
|
||
.duration_since(std::time::UNIX_EPOCH)
|
||
.map(|d| d.as_secs())
|
||
.unwrap_or(0);
|
||
canvas_write_created_at_at(head_created_at, now)
|
||
}
|
||
|
||
/// Pure core of [`canvas_write_created_at`] with `now` injected — the clock
|
||
/// seam. The public wrapper reads the real clock; tests drive the exact
|
||
/// `now + 60` / `now + 61` boundaries against a fixed `now`.
|
||
fn canvas_write_created_at_at(head_created_at: u64, now: u64) -> Result<u64, SdkError> {
|
||
if head_created_at > now.saturating_add(CANVAS_MAX_FUTURE_SKEW_SECS) {
|
||
return Err(SdkError::InvalidInput(
|
||
"canvas head is timestamped too far in the future; refusing to extend it".into(),
|
||
));
|
||
}
|
||
Ok(now.max(head_created_at.saturating_add(1)))
|
||
}
|
||
|
||
/// Maximum ancestry links the post-write supersession walk follows before
|
||
/// failing safe (reporting superseded). A legitimate descendant chain visible
|
||
/// in one history page is far shorter; the bound only caps a pathological or
|
||
/// adversarial revision stream so the walk can never loop or run unbounded.
|
||
pub const CANVAS_ANCESTRY_WALK_MAX: usize = 256;
|
||
|
||
/// Whether a just-published canvas write still survives as — or anywhere in the
|
||
/// accepted ancestry of — the live head read back after submit. The post-write
|
||
/// supersession check.
|
||
///
|
||
/// `revisions` is a recent slice of the channel's canvas stream ordered newest
|
||
/// first, each entry `(event_id, the expected-revision it built on)`;
|
||
/// `revisions[0]` is the live head. A conflict-checked write (Desktop
|
||
/// save/restore, CLI `restore`) re-reads this stream after publishing and the
|
||
/// walk starts at the head, following `expected-revision` links backward:
|
||
/// - the head **is** our event → survived;
|
||
/// - our event is reached anywhere in the head's ancestry chain (e.g. A→B→C
|
||
/// with C the head and A ours, each linked by `expected-revision`) → a later
|
||
/// write legitimately layered on top of ours → survived;
|
||
/// - the chain ends, reaches a link outside `revisions`, cycles, exceeds
|
||
/// [`CANVAS_ANCESTRY_WALK_MAX`], or there is no head at all → not survived: a
|
||
/// concurrent write won the visible head and ours is superseded (preserved in
|
||
/// history, not lost). Every non-survival outcome, including a truncated or
|
||
/// adversarial stream, fails safe as superseded and never hangs.
|
||
///
|
||
/// All id comparisons are case-insensitive, matching the precondition check's
|
||
/// `eq_ignore_ascii_case` convention. This only detects a competitor already
|
||
/// visible at verification time; a competitor that lands after this read is
|
||
/// still missed — the relay's advisory-lock CAS prevents concurrent conflicting
|
||
/// writes from both being accepted, but this client check is a secondary
|
||
/// confirmation for the caller's own visibility.
|
||
pub fn canvas_write_survived(our_id: &str, revisions: &[(String, Option<String>)]) -> bool {
|
||
let Some((head_id, _)) = revisions.first() else {
|
||
return false; // no head after an accepted write → conservatively superseded
|
||
};
|
||
if head_id.eq_ignore_ascii_case(our_id) {
|
||
return true;
|
||
}
|
||
// Lowercased id → its expected-revision, for walking the chain backward.
|
||
let by_id: std::collections::HashMap<String, Option<&str>> = revisions
|
||
.iter()
|
||
.map(|(id, expected)| (id.to_ascii_lowercase(), expected.as_deref()))
|
||
.collect();
|
||
let mut seen = std::collections::HashSet::new();
|
||
let mut cursor = head_id.to_ascii_lowercase();
|
||
for _ in 0..CANVAS_ANCESTRY_WALK_MAX {
|
||
if !seen.insert(cursor.clone()) {
|
||
return false; // cycle guard
|
||
}
|
||
// The link out of `cursor`; absent id or missing tag ends the walk.
|
||
let Some(expected) = by_id.get(&cursor).copied().flatten() else {
|
||
return false;
|
||
};
|
||
if expected.eq_ignore_ascii_case(our_id) {
|
||
return true;
|
||
}
|
||
cursor = expected.to_ascii_lowercase();
|
||
}
|
||
false // depth exhausted → fail safe (superseded)
|
||
}
|
||
|
||
/// 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/join_role/unlisted (kind 9002).
|
||
///
|
||
/// `ttl`: outer `None` leaves it unchanged; `Some(Some(secs))` sets the
|
||
/// ephemeral timeout; `Some(None)` clears it (emits `["ttl", ""]`).
|
||
/// `unlisted`: `None` leaves it unchanged; `Some(b)` emits
|
||
/// `["unlisted", "true"|"false"]`.
|
||
pub fn build_update_channel(
|
||
channel_id: Uuid,
|
||
name: Option<&str>,
|
||
about: Option<&str>,
|
||
visibility: Option<&str>,
|
||
ttl: Option<Option<i32>>,
|
||
join_role: Option<buzz_core::channel::ChannelJoinRole>,
|
||
unlisted: Option<bool>,
|
||
) -> Result<EventBuilder, SdkError> {
|
||
if name.is_none()
|
||
&& about.is_none()
|
||
&& visibility.is_none()
|
||
&& ttl.is_none()
|
||
&& join_role.is_none()
|
||
&& unlisted.is_none()
|
||
{
|
||
return Err(SdkError::InvalidTag(
|
||
"at least one of name, about, visibility, ttl, join_role, or unlisted 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", ""])?),
|
||
}
|
||
}
|
||
// Always explicit: kind:9002 is a partial update, so an absent tag
|
||
// leaves the setting unchanged and going back needs `member`.
|
||
if let Some(join_role) = join_role {
|
||
tags.push(tag(&["join_role", join_role.as_str()])?);
|
||
}
|
||
if let Some(unlisted) = unlisted {
|
||
tags.push(tag(&["unlisted", if unlisted { "true" } else { "false" }])?);
|
||
}
|
||
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.
|
||
///
|
||
/// `unlisted`: `true` emits `["unlisted", "true"]`, so the channel starts out
|
||
/// of channel listings for non-members; `false` emits nothing, and the
|
||
/// channel is listed. Check the relay's NIP-11 `buzz-unlisted-channels`
|
||
/// extension first: a relay without it ignores the tag.
|
||
pub fn build_create_channel(
|
||
channel_id: Uuid,
|
||
name: &str,
|
||
visibility: Option<Visibility>,
|
||
channel_type: Option<ChannelKind>,
|
||
about: Option<&str>,
|
||
ttl: Option<i32>,
|
||
unlisted: bool,
|
||
) -> 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()])?);
|
||
}
|
||
if unlisted {
|
||
tags.push(tag(&["unlisted", "true"])?);
|
||
}
|
||
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()
|
||
}
|
||
|
||
/// A distinct well-formed 64-character hex pubkey per index, for tests that
|
||
/// need many valid mentions.
|
||
fn distinct_pubkey_hex(i: u8) -> String {
|
||
format!("{i:02x}{}", "ab".repeat(31))
|
||
}
|
||
|
||
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);
|
||
}
|
||
|
||
/// Every structurally invalid mention must be refused by all three builders
|
||
/// that share `mention_tags`, rather than lowercased into a `p` tag and
|
||
/// signed. See #6291.
|
||
#[test]
|
||
fn malformed_mention_pubkeys_are_refused_by_every_builder() {
|
||
let cid = uuid();
|
||
let root = event_id();
|
||
let tr = ThreadRef {
|
||
root_event_id: root,
|
||
parent_event_id: root,
|
||
};
|
||
let short = "a".repeat(63);
|
||
let long = "a".repeat(65);
|
||
// Empty, non-hex, and both off-by-one lengths. The wrong-alphabet cases
|
||
// are pinned separately from the wrong-length cases because a
|
||
// length-only check would otherwise pass: "zzzz" is caught by length,
|
||
// but a 64-character non-hex string is not.
|
||
let non_hex_64 = "z".repeat(64);
|
||
let path_like_64 = format!("../../etc/passwd{}", "0".repeat(48));
|
||
for bad in [
|
||
"",
|
||
"not-a-pubkey",
|
||
"zzzz",
|
||
"../../etc/passwd",
|
||
short.as_str(),
|
||
long.as_str(),
|
||
non_hex_64.as_str(),
|
||
path_like_64.as_str(),
|
||
] {
|
||
assert!(
|
||
matches!(
|
||
build_message(cid, "hi", None, &[bad], false, &[], &[]),
|
||
Err(SdkError::InvalidInput(_))
|
||
),
|
||
"build_message must refuse mention {bad:?}"
|
||
);
|
||
assert!(
|
||
matches!(
|
||
build_forum_post(cid, "hi", &[bad], &[]),
|
||
Err(SdkError::InvalidInput(_))
|
||
),
|
||
"build_forum_post must refuse mention {bad:?}"
|
||
);
|
||
assert!(
|
||
matches!(
|
||
build_forum_comment(cid, "hi", &tr, &[bad], &[]),
|
||
Err(SdkError::InvalidInput(_))
|
||
),
|
||
"build_forum_comment must refuse mention {bad:?}"
|
||
);
|
||
}
|
||
}
|
||
|
||
/// One malformed entry must refuse the whole list rather than being silently
|
||
/// dropped: a filtered mention looks successful to the composer while
|
||
/// notifying nobody.
|
||
#[test]
|
||
fn one_malformed_mention_refuses_the_whole_list() {
|
||
let cid = uuid();
|
||
let valid = "abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234";
|
||
let result = build_message(cid, "hi", None, &[valid, "nope", valid], false, &[], &[]);
|
||
assert!(matches!(result, Err(SdkError::InvalidInput(_))));
|
||
}
|
||
|
||
/// Validation must not break the two behaviours callers already rely on:
|
||
/// uppercase hex is legitimate input and is canonicalized to lowercase, and
|
||
/// duplicates across cases collapse to a single `p` tag.
|
||
#[test]
|
||
fn valid_mentions_are_canonicalized_and_deduped_across_case() {
|
||
let cid = uuid();
|
||
let upper = "ABCD1234ABCD1234ABCD1234ABCD1234ABCD1234ABCD1234ABCD1234ABCD1234";
|
||
let lower = upper.to_ascii_lowercase();
|
||
let root = event_id();
|
||
let tr = ThreadRef {
|
||
root_event_id: root,
|
||
parent_event_id: root,
|
||
};
|
||
|
||
let ev = sign(build_message(cid, "hi", None, &[upper], false, &[], &[]).unwrap());
|
||
assert_eq!(
|
||
tag_values(&ev, "p"),
|
||
vec![lower.clone()],
|
||
"uppercase hex must be accepted and lowercased"
|
||
);
|
||
|
||
// Same pubkey in both spellings is one person, so one p tag.
|
||
let ev = sign(build_message(cid, "hi", None, &[upper, &lower], false, &[], &[]).unwrap());
|
||
assert_eq!(tag_values(&ev, "p"), vec![lower.clone()]);
|
||
|
||
let ev = sign(build_forum_post(cid, "hi", &[upper], &[]).unwrap());
|
||
assert_eq!(tag_values(&ev, "p"), vec![lower.clone()]);
|
||
|
||
let ev = sign(build_forum_comment(cid, "hi", &tr, &[upper], &[]).unwrap());
|
||
assert_eq!(tag_values(&ev, "p"), vec![lower]);
|
||
}
|
||
|
||
/// The cap is checked before per-entry validation, so an over-cap list of
|
||
/// well-formed pubkeys keeps reporting `TooManyMentions`, and a list that is
|
||
/// both over-cap *and* malformed reports `TooManyMentions` too rather than
|
||
/// changing which error callers see.
|
||
#[test]
|
||
fn too_many_mentions_takes_precedence_over_validation() {
|
||
let cid = uuid();
|
||
let hexes: Vec<String> = (0..51u8).map(distinct_pubkey_hex).collect();
|
||
let mut refs: Vec<&str> = hexes.iter().map(String::as_str).collect();
|
||
refs.push("not-a-pubkey");
|
||
assert!(matches!(
|
||
build_message(cid, "hi", None, &refs, false, &[], &[]),
|
||
Err(SdkError::TooManyMentions)
|
||
));
|
||
}
|
||
|
||
/// A list exactly at the cap still signs, so the guard cannot have moved the
|
||
/// boundary.
|
||
#[test]
|
||
fn mentions_at_the_cap_still_sign() {
|
||
let cid = uuid();
|
||
let hexes: Vec<String> = (0..crate::mentions::MENTION_CAP as u8)
|
||
.map(distinct_pubkey_hex)
|
||
.collect();
|
||
let refs: Vec<&str> = hexes.iter().map(String::as_str).collect();
|
||
let ev = sign(build_message(cid, "hi", None, &refs, false, &[], &[]).unwrap());
|
||
assert_eq!(tag_values(&ev, "p").len(), crate::mentions::MENTION_CAP);
|
||
}
|
||
|
||
#[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 message_emoji_tags_attached() {
|
||
let cid = uuid();
|
||
let emoji_tags = vec![
|
||
vec![
|
||
"emoji".to_string(),
|
||
"wave".to_string(),
|
||
"https://example.com/wave.gif".to_string(),
|
||
],
|
||
vec![
|
||
"emoji".to_string(),
|
||
"party".to_string(),
|
||
"https://example.com/party.gif".to_string(),
|
||
],
|
||
];
|
||
let ev = sign(
|
||
build_message(
|
||
cid,
|
||
":wave: hey :party:",
|
||
None,
|
||
&[],
|
||
false,
|
||
&[],
|
||
&emoji_tags,
|
||
)
|
||
.unwrap(),
|
||
);
|
||
// Both emoji tags present
|
||
assert!(ev
|
||
.tags
|
||
.iter()
|
||
.any(|t| t.as_slice() == ["emoji", "wave", "https://example.com/wave.gif"]));
|
||
assert!(ev
|
||
.tags
|
||
.iter()
|
||
.any(|t| t.as_slice() == ["emoji", "party", "https://example.com/party.gif"]));
|
||
// kind 9
|
||
assert_eq!(ev.kind.as_u16(), 9);
|
||
}
|
||
|
||
#[test]
|
||
fn message_malformed_emoji_tag_silently_skipped() {
|
||
let cid = uuid();
|
||
let emoji_tags = vec![
|
||
// only 2 elements — invalid, must be skipped
|
||
vec!["emoji".to_string(), "wave".to_string()],
|
||
// wrong kind — must be skipped
|
||
vec![
|
||
"imeta".to_string(),
|
||
"wave".to_string(),
|
||
"https://example.com/wave.gif".to_string(),
|
||
],
|
||
// valid
|
||
vec![
|
||
"emoji".to_string(),
|
||
"ok".to_string(),
|
||
"https://example.com/ok.gif".to_string(),
|
||
],
|
||
];
|
||
let ev = sign(build_message(cid, "hi", None, &[], false, &[], &emoji_tags).unwrap());
|
||
let emoji_count = ev
|
||
.tags
|
||
.iter()
|
||
.filter(|t| t.as_slice().first().map(String::as_str) == Some("emoji"))
|
||
.count();
|
||
assert_eq!(emoji_count, 1);
|
||
assert!(ev
|
||
.tags
|
||
.iter()
|
||
.any(|t| t.as_slice() == ["emoji", "ok", "https://example.com/ok.gif"]));
|
||
}
|
||
|
||
#[test]
|
||
fn message_empty_emoji_tags_slice_ok() {
|
||
let cid = uuid();
|
||
let ev = sign(build_message(cid, "hello", None, &[], false, &[], &[]).unwrap());
|
||
let emoji_count = ev
|
||
.tags
|
||
.iter()
|
||
.filter(|t| t.as_slice().first().map(String::as_str) == Some("emoji"))
|
||
.count();
|
||
assert_eq!(emoji_count, 0);
|
||
}
|
||
|
||
#[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", None).unwrap());
|
||
assert_eq!(ev.kind.as_u16(), 40100);
|
||
assert!(has_tag(&ev, "h", &cid.to_string()));
|
||
assert_eq!(ev.content, "# Canvas\nHello");
|
||
assert!(!ev.tags.iter().any(|t| t
|
||
.as_slice()
|
||
.first()
|
||
.is_some_and(|k| k == "expected-revision")));
|
||
}
|
||
|
||
#[test]
|
||
fn set_canvas_pins_expected_revision() {
|
||
let cid = uuid();
|
||
let head = event_id().to_hex();
|
||
let ev = sign(build_set_canvas(cid, "# Canvas\nHi", Some(&head)).unwrap());
|
||
assert!(has_tag(&ev, "expected-revision", &head));
|
||
|
||
let create = sign(build_set_canvas(cid, "# New", Some("none")).unwrap());
|
||
assert!(has_tag(&create, "expected-revision", "none"));
|
||
}
|
||
|
||
#[test]
|
||
fn set_canvas_after_head_pins_revision_and_bumps_timestamp() {
|
||
let cid = uuid();
|
||
let head = event_id().to_hex();
|
||
let now = std::time::SystemTime::now()
|
||
.duration_since(std::time::UNIX_EPOCH)
|
||
.unwrap()
|
||
.as_secs();
|
||
|
||
// Head created ahead of the signer's clock but within the future-skew
|
||
// ceiling: the discipline must still stamp strictly ahead of it.
|
||
let future_head = now + CANVAS_MAX_FUTURE_SKEW_SECS;
|
||
let ev = sign(build_set_canvas_after_head(cid, "# Restored", &head, future_head).unwrap());
|
||
assert!(has_tag(&ev, "expected-revision", &head));
|
||
assert!(
|
||
ev.created_at.as_secs() > future_head,
|
||
"created_at {} must be strictly ahead of future head {future_head}",
|
||
ev.created_at.as_secs()
|
||
);
|
||
|
||
// Head in the past: the signer's `now` wins and is still ahead.
|
||
let past_head = 1_000_u64;
|
||
let ev = sign(build_set_canvas_after_head(cid, "# Restored", &head, past_head).unwrap());
|
||
assert!(ev.created_at.as_secs() > past_head);
|
||
}
|
||
|
||
#[test]
|
||
fn set_canvas_rejects_malformed_expected_revision() {
|
||
let cid = uuid();
|
||
// Wrong length (63 hex chars).
|
||
assert!(matches!(
|
||
build_set_canvas(cid, "x", Some(&"a".repeat(63))),
|
||
Err(SdkError::InvalidInput(_))
|
||
));
|
||
// Correct length but non-hex.
|
||
assert!(matches!(
|
||
build_set_canvas(cid, "x", Some(&"z".repeat(64))),
|
||
Err(SdkError::InvalidInput(_))
|
||
));
|
||
// Literal "none" and a valid 64-hex id are accepted.
|
||
assert!(build_set_canvas(cid, "x", Some("none")).is_ok());
|
||
assert!(build_set_canvas(cid, "x", Some(&"a".repeat(64))).is_ok());
|
||
}
|
||
|
||
#[test]
|
||
fn set_canvas_after_head_rejects_max_head_created_at() {
|
||
let cid = uuid();
|
||
let head = event_id().to_hex();
|
||
// u64::MAX is far beyond the future-skew ceiling: reject instead of
|
||
// silently saturating and breaking the head-advancement guarantee.
|
||
assert!(matches!(
|
||
build_set_canvas_after_head(cid, "# Restored", &head, u64::MAX),
|
||
Err(SdkError::InvalidInput(_))
|
||
));
|
||
}
|
||
|
||
#[test]
|
||
fn canvas_write_created_at_skew_boundary() {
|
||
// Fixed clock: the injected-`now` core removes the second-rollover seam
|
||
// that a real-clock read introduces. A head exactly at the ceiling
|
||
// (`now + 60`) is accepted and stamped strictly ahead; one second past
|
||
// it is rejected as poisoned.
|
||
//
|
||
// Invariant contract (pinned with literals, not constants, so a constant
|
||
// mutation also fails this test): the client ceiling is 60 s; the relay
|
||
// canvas ingest bound is 300 s; the relay general drift bound is 900 s.
|
||
// A ceiling head at now+60 produces now+61, well below both relay bounds.
|
||
// These numeric assertions must stay consistent with CANVAS_MAX_FUTURE_SKEW_SECS
|
||
// and the relay's CANVAS_MAX_INGEST_FUTURE_SECS (300) / MAX_TIMESTAMP_DRIFT_SECS (900).
|
||
let now = 1_700_000_000u64;
|
||
let at_ceiling = now + CANVAS_MAX_FUTURE_SKEW_SECS;
|
||
assert_eq!(
|
||
canvas_write_created_at_at(at_ceiling, now).unwrap(),
|
||
at_ceiling + 1,
|
||
"a head at now+60 is accepted and stamped strictly ahead"
|
||
);
|
||
assert!(
|
||
matches!(
|
||
canvas_write_created_at_at(at_ceiling + 1, now),
|
||
Err(SdkError::InvalidInput(_))
|
||
),
|
||
"a head at now+61 is poisoned and rejected"
|
||
);
|
||
|
||
// Fixed-literal contract: client ceiling IS 60 s, NOT the old 900 s.
|
||
// Mutating CANVAS_MAX_FUTURE_SKEW_SECS back to 900 makes these fail.
|
||
assert!(
|
||
canvas_write_created_at_at(now + 60, now).is_ok(),
|
||
"now+60 is within the 60 s client ceiling"
|
||
);
|
||
assert!(
|
||
matches!(
|
||
canvas_write_created_at_at(now + 61, now),
|
||
Err(SdkError::InvalidInput(_))
|
||
),
|
||
"now+61 is one second past the 60 s client ceiling"
|
||
);
|
||
// Confirm the old 900 s ceiling is now rejected (prevents silent
|
||
// reversion): a head at now+900 must not be ratcheted past.
|
||
assert!(
|
||
matches!(
|
||
canvas_write_created_at_at(now + 900, now),
|
||
Err(SdkError::InvalidInput(_))
|
||
),
|
||
"now+900 must be rejected by the 60 s client ceiling"
|
||
);
|
||
}
|
||
|
||
/// Standalone numeric contract for the client-side canvas future-skew ceiling.
|
||
///
|
||
/// No constants used — if CANVAS_MAX_FUTURE_SKEW_SECS changes, this test
|
||
/// catches it regardless of whether the constant-based assertions remain
|
||
/// self-consistent. The client ceiling IS 60 s: now+60 is the last accepted
|
||
/// head; now+61 is the first rejected head.
|
||
///
|
||
/// Cross-crate invariant: the relay canvas ingest bound is 300 s and the
|
||
/// relay general drift bound is 900 s. A ceiling head at now+60 is stamped
|
||
/// now+61, comfortably below both relay bounds.
|
||
#[test]
|
||
fn canvas_write_created_at_numeric_contract() {
|
||
let now = 1_700_000_000u64;
|
||
// The client ceiling is exactly 60 s — not 900 s (the old value).
|
||
// These assertions use only fixed numeric literals; they cannot be
|
||
// self-referential regardless of what CANVAS_MAX_FUTURE_SKEW_SECS holds.
|
||
assert!(
|
||
canvas_write_created_at_at(now + 60, now).is_ok(),
|
||
"now+60 must be accepted: client ceiling is 60 s",
|
||
);
|
||
assert!(
|
||
canvas_write_created_at_at(now + 61, now).is_err(),
|
||
"now+61 must be rejected: one second past the 60 s client ceiling",
|
||
);
|
||
// Old 900 s value must also be rejected (prevents silent reversion).
|
||
assert!(
|
||
canvas_write_created_at_at(now + 900, now).is_err(),
|
||
"now+900 must be rejected: the old 900 s ceiling is no longer valid",
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn canvas_write_survived_classifies_head_ancestry() {
|
||
let ours = "a".repeat(64);
|
||
let other = "b".repeat(64);
|
||
let third = "c".repeat(64);
|
||
// Head is our own event → survived.
|
||
assert!(canvas_write_survived(&ours, &[(ours.clone(), None)]));
|
||
// Head is case-insensitively our event → survived.
|
||
assert!(canvas_write_survived(&ours, &[(ours.to_uppercase(), None)]));
|
||
// Head is a stranger that builds directly on us (case-insensitively) →
|
||
// survived.
|
||
assert!(canvas_write_survived(
|
||
&ours,
|
||
&[(other.clone(), Some(ours.to_uppercase()))]
|
||
));
|
||
// Transitive descendant: A(ours) → B(exp=A) → C(exp=B, the head). Ours
|
||
// is reached by walking the chain, so the write survived.
|
||
assert!(canvas_write_survived(
|
||
&ours,
|
||
&[
|
||
(third.clone(), Some(other.clone())),
|
||
(other.clone(), Some(ours.clone())),
|
||
(ours.clone(), None),
|
||
]
|
||
));
|
||
// Head is a stranger that builds on someone else, and that ancestor is
|
||
// not ours and not in the stream → superseded.
|
||
assert!(!canvas_write_survived(
|
||
&ours,
|
||
&[(other.clone(), Some(third.clone()))]
|
||
));
|
||
// Head is a stranger with no ancestry tag → superseded.
|
||
assert!(!canvas_write_survived(&ours, &[(other.clone(), None)]));
|
||
// No head at all after an accepted write → superseded, never silent
|
||
// success (shouldn't happen, classified conservatively).
|
||
assert!(!canvas_write_survived(&ours, &[]));
|
||
// A descendant chain that never reaches ours before the stream ends →
|
||
// superseded (fails safe rather than assuming survival).
|
||
assert!(!canvas_write_survived(
|
||
&ours,
|
||
&[
|
||
(third.clone(), Some(other.clone())),
|
||
(other.clone(), Some("d".repeat(64))),
|
||
]
|
||
));
|
||
}
|
||
|
||
#[test]
|
||
fn canvas_write_survived_fails_safe_on_cycles_and_depth() {
|
||
let ours = "a".repeat(64);
|
||
let x = "b".repeat(64);
|
||
let y = "c".repeat(64);
|
||
// A cycle in the stream (x→y→x) that never touches ours must terminate
|
||
// as superseded, not loop forever.
|
||
assert!(!canvas_write_survived(
|
||
&ours,
|
||
&[(x.clone(), Some(y.clone())), (y.clone(), Some(x.clone()))]
|
||
));
|
||
// A chain longer than the walk bound that never reaches ours fails safe
|
||
// as superseded. Each link i points to link i+1; ours is never in it.
|
||
let mut revisions: Vec<(String, Option<String>)> = (0..(CANVAS_ANCESTRY_WALK_MAX + 10))
|
||
.map(|i| {
|
||
let id = format!("{i:064x}");
|
||
let next = format!("{:064x}", i + 1);
|
||
(id, Some(next))
|
||
})
|
||
.collect();
|
||
// Terminate the last link at ours so only the depth bound (not a missing
|
||
// link) can stop the walk — proving the bound itself is the guard.
|
||
let last = revisions.len() - 1;
|
||
revisions[last].1 = Some(ours.clone());
|
||
assert!(!canvas_write_survived(&ours, &revisions));
|
||
}
|
||
|
||
#[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,
|
||
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,
|
||
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, 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)),
|
||
None,
|
||
None,
|
||
)
|
||
.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), None, 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, None, None),
|
||
Err(SdkError::InvalidTag(_))
|
||
));
|
||
}
|
||
|
||
#[test]
|
||
fn update_channel_join_role_is_always_explicit() {
|
||
use buzz_core::channel::ChannelJoinRole;
|
||
let cid = uuid();
|
||
let on = sign(
|
||
build_update_channel(
|
||
cid,
|
||
None,
|
||
None,
|
||
None,
|
||
None,
|
||
Some(ChannelJoinRole::Guest),
|
||
None,
|
||
)
|
||
.unwrap(),
|
||
);
|
||
assert!(has_tag(&on, "join_role", "guest"));
|
||
let off = sign(
|
||
build_update_channel(
|
||
cid,
|
||
None,
|
||
None,
|
||
None,
|
||
None,
|
||
Some(ChannelJoinRole::Member),
|
||
None,
|
||
)
|
||
.unwrap(),
|
||
);
|
||
assert!(has_tag(&off, "join_role", "member"));
|
||
let other =
|
||
sign(build_update_channel(cid, Some("n"), None, None, None, None, None).unwrap());
|
||
assert!(!other
|
||
.tags
|
||
.iter()
|
||
.any(|t| t.kind().to_string() == "join_role"));
|
||
}
|
||
|
||
#[test]
|
||
fn update_channel_unlisted_is_always_explicit() {
|
||
let cid = uuid();
|
||
let on = sign(
|
||
build_update_channel(cid, None, None, Some("open"), None, None, Some(true)).unwrap(),
|
||
);
|
||
assert!(has_tag(&on, "visibility", "open"));
|
||
assert!(has_tag(&on, "unlisted", "true"));
|
||
let off =
|
||
sign(build_update_channel(cid, None, None, None, None, None, Some(false)).unwrap());
|
||
assert!(has_tag(&off, "unlisted", "false"));
|
||
let other =
|
||
sign(build_update_channel(cid, Some("n"), None, None, None, None, None).unwrap());
|
||
assert!(!other
|
||
.tags
|
||
.iter()
|
||
.any(|t| t.kind().to_string() == "unlisted"));
|
||
}
|
||
|
||
#[test]
|
||
fn update_channel_no_fields_rejected() {
|
||
let cid = uuid();
|
||
assert!(matches!(
|
||
build_update_channel(cid, None, None, 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,
|
||
false,
|
||
)
|
||
.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,
|
||
false,
|
||
)
|
||
.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,
|
||
false,
|
||
)
|
||
.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,
|
||
false,
|
||
),
|
||
Err(SdkError::InvalidTag(_))
|
||
));
|
||
}
|
||
|
||
#[test]
|
||
fn create_channel_unlisted_emits_tag_only_when_set() {
|
||
let build = |unlisted| {
|
||
sign(
|
||
build_create_channel(
|
||
uuid(),
|
||
"solo",
|
||
Some(Visibility::Private),
|
||
Some(ChannelKind::Stream),
|
||
None,
|
||
None,
|
||
unlisted,
|
||
)
|
||
.unwrap(),
|
||
)
|
||
};
|
||
assert!(has_tag(&build(true), "unlisted", "true"));
|
||
assert!(!build(false)
|
||
.tags
|
||
.iter()
|
||
.any(|t| t.kind().to_string() == "unlisted"));
|
||
}
|
||
|
||
#[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),
|
||
false,
|
||
)
|
||
.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");
|
||
}
|
||
}
|