feat(projects): add buzz projects CLI commands (NIP-MP kind:30621) (#4020)

Implements the `buzz projects` command group — the NIP-MP Phase 2 write
path for kind:30621 multi-repo projects. The relay accepted kind:30621
in #3171; this adds the two-layer Rust builder in `buzz-sdk` and the
seven CLI commands.

## What this adds

### `crates/buzz-sdk/src/builders.rs` — two-layer builder

**Layer A (protocol):**
- `validate_project_envelope(tags, content)` — 8 NIP-MP rules in relay
order: `d`-cardinality, `d`-empty/length, member-cap (≤64 `a` tags,
checked before per-tag parse), member-tag-arity (2–3 elements),
member-coordinate grammar (first-two-colons split, literal `30617`,
lowercase 64-hex owner, non-empty remainder), member-duplicate
(coordinate only, hint ignored), singleton metadata cardinality, byte
bounds (`name` ≤256 / `description` ≤2048 / `buzz-channel` ≤256 /
`buzz-visibility` ≤256).
- `build_project_with_tags(content, tags)` — raw Layer A builder; RMW
mutations path.
- `ProjectMemberCoord` — `30617:<owner-hex>:<repo-d>` + optional opaque
relay hint; equality/Hash by coordinate only.

**Layer B (writer policy):**
- `build_project(slug, name, description, members, channel, visibility)`
— constructs `d` tag, enforces UUID channel and `listed|unlisted`
visibility, forces empty content; composes onto Layer A. This is the
`create` path.

**Shared:**
- `build_delete_addressable(kind, pubkey, d)` — generic NIP-09 kind:5
coordinate delete; `build_workflow_delete` now delegates to this.
- All 31 `NIP-MP.fixtures.json` cases exercised through
`build_project_with_tags`; count assertion guards against omissions.

### `crates/buzz-cli/` — seven commands

```
buzz projects create <slug> --repo <coord> [--name] [--description] [--channel <uuid>] [--visibility listed|unlisted]
buzz projects get <slug> [--owner <pubkey>]
buzz projects list [--owner <pubkey>] [--limit <n>]
buzz projects add-repo <slug> --repo <coord> [--repo <coord>]...
buzz projects remove-repo <slug> --repo <coord> [--repo <coord>]...
buzz projects update <slug> [--name|--clear-name] [--description|--clear-description] [--channel <uuid>|--clear-channel] [--visibility listed|unlisted|--clear-visibility]
buzz projects delete <slug>
```

Command semantics:
- **`create`**: all local validation (slug, repos, channel, visibility,
name length) fires before the collision preflight — invalid input
returns `Usage` without a network call. Routes through Layer B
(`build_project`).
- **`update`**: at least one setter/clearer required — enforced by a
clap `ArgGroup` with `required(true).multiple(true)`, with a runtime
backstop for programmatic callers; setter + own clearer are mutually
exclusive per clap conflicts.
- **`add-repo`/`remove-repo`**: coordinate expansion and dedup fire
before head fetch — malformed or duplicate `--repo` values return
`Usage` without touching the relay.
- **`delete`**: head-based tombstone at `created_at = head + 1`;
post-submit re-query verifies tombstone landed.
- All mutations: strip `auth`, re-validate full envelope through Layer
A; `created_at` advances from observed head, never wall-clock.
- Relay hints on existing member tags preserved verbatim through RMW.

## Limitations (recorded, not in scope)

- **No relay-hint authoring**: `--repo` carries a coordinate only;
existing hinted `a` tags survive RMW unchanged.
- **Signer-self delete only**: NIP-OA owner-delete extension not
exposed; `delete` targets the signer's own coordinate.
- **Deletion durability**: watermark carry-over applies; `delete` is
best-effort against a later-arriving replacement.

## Live round-trip

21-step transcript executed against a relay built from `origin/main`
`b1b283cd4`, covering create, get, multi-field update (name +
description + channel in one call), channel set/clear, add-repo,
remove-repo, delete (tombstone verified at `head+1`, repeated delete →
`NotFound`). Delta transcript confirmed multi-field update, channel
set/clear, no-op add-repo → `Conflict` exit 5, empty update and
setter+own-clearer both rejected at parse time. Duplicate create →
`Conflict`. Cross-owner `add-repo` with full coordinate exercised.

---------

Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Co-authored-by: npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7 <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@buzz.block.builderlab.xyz>
This commit is contained in:
Will Pfleger
2026-08-02 12:48:49 -04:00
committed by GitHub
co-authored by npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7
parent 28ae6cd217
commit b7bb15122e
5 changed files with 2110 additions and 28 deletions
+28
View File
@@ -12,9 +12,37 @@ pub mod notes;
pub mod pack;
pub mod patches;
pub mod pr;
pub mod projects;
pub mod reactions;
pub mod repos;
pub mod social;
pub mod upload;
pub mod users;
pub mod workflows;
use crate::{client::normalize_write_response, error::CliError};
/// Parse a relay write-response JSON blob, mapping a duplicate (dominated)
/// write to [`CliError::Conflict`] with the caller-supplied message.
///
/// Used by every command that publishes an NIP-33 addressable event and
/// needs to tell accepted from duplicate/dominated.
pub fn parse_write_response(raw: &str, conflict_msg: &str) -> Result<String, CliError> {
let response: serde_json::Value = serde_json::from_str(raw)
.map_err(|e| CliError::Other(format!("relay response is not JSON: {e} ({raw})")))?;
let accepted = response
.get("accepted")
.and_then(serde_json::Value::as_bool)
.unwrap_or(false);
let message = response
.get("message")
.and_then(serde_json::Value::as_str)
.unwrap_or("");
if !accepted {
return Err(CliError::Other(format!("relay rejected event: {message}")));
}
if message == "duplicate" || message.starts_with("duplicate:") {
return Err(CliError::Conflict(conflict_msg.to_string()));
}
Ok(normalize_write_response(raw))
}
File diff suppressed because it is too large Load Diff
+6 -20
View File
@@ -4,7 +4,8 @@ use buzz_core::{
};
use nostr::{Event, EventBuilder, Tag, Timestamp};
use crate::client::{normalize_write_response, BuzzClient};
use crate::client::BuzzClient;
use crate::commands::parse_write_response;
use crate::error::CliError;
use crate::validate::validate_repo_id;
@@ -186,25 +187,10 @@ fn protection_rules_json(event: &Event) -> Result<serde_json::Value, CliError> {
}
fn validate_write_response(raw: &str) -> Result<String, CliError> {
let response: serde_json::Value = serde_json::from_str(raw)
.map_err(|error| CliError::Other(format!("relay response is not JSON: {error} ({raw})")))?;
let accepted = response
.get("accepted")
.and_then(serde_json::Value::as_bool)
.unwrap_or(false);
let message = response
.get("message")
.and_then(serde_json::Value::as_str)
.unwrap_or("");
if !accepted {
return Err(CliError::Other(format!("relay rejected event: {message}")));
}
if message == "duplicate" || message.starts_with("duplicate:") {
return Err(CliError::Conflict(
"repository changed concurrently; fetch the latest rules and retry".into(),
));
}
Ok(normalize_write_response(raw))
parse_write_response(
raw,
"repository changed concurrently; fetch the latest rules and retry",
)
}
async fn submit_repo_update(client: &BuzzClient, builder: EventBuilder) -> Result<(), CliError> {
+241
View File
@@ -212,6 +212,9 @@ enum Cmd {
/// Announce and discover git repositories (NIP-34)
#[command(subcommand)]
Repos(ReposCmd),
/// Create and manage multi-repo projects (NIP-MP)
#[command(subcommand)]
Projects(ProjectsCmd),
/// Send, get, list, and set status on git patches (NIP-34)
#[command(subcommand)]
Patches(PatchesCmd),
@@ -1227,6 +1230,122 @@ pub enum RepoPushRole {
Member,
}
/// Visibility of a multi-repo project listing.
#[derive(Clone, Copy, Debug, clap::ValueEnum)]
pub enum ProjectVisibility {
/// Project appears in public listings (default).
Listed,
/// Project is hidden from public listings.
Unlisted,
}
impl ProjectVisibility {
pub fn as_str(self) -> &'static str {
match self {
ProjectVisibility::Listed => "listed",
ProjectVisibility::Unlisted => "unlisted",
}
}
}
#[derive(Subcommand)]
pub enum ProjectsCmd {
/// Create a new multi-repo project (NIP-MP kind:30621)
///
/// Requires at least one --repo. Fails with Conflict if the project already exists.
Create {
/// Project identifier (slug), up to 1024 bytes
slug: String,
/// Member repository coordinate: bare Buzz repo id (e.g. `buzz`) or full
/// `30617:<owner-hex>:<repo-d>` for cross-owner or colon-bearing repo ids.
/// At least one --repo is required.
#[arg(long = "repo", required = true)]
repo: Vec<String>,
/// Display name (≤256 bytes)
#[arg(long)]
name: Option<String>,
/// Description (≤2048 bytes)
#[arg(long)]
description: Option<String>,
/// Associated Buzz channel UUID
#[arg(long)]
channel: Option<String>,
/// Visibility: `listed` (default) or `unlisted`
#[arg(long)]
visibility: Option<ProjectVisibility>,
},
/// Get a project by slug
Get {
/// Project slug
slug: String,
/// Owner pubkey (64-char hex). Defaults to the current identity.
#[arg(long)]
owner: Option<String>,
},
/// List projects
List {
/// Owner pubkey (64-char hex). Defaults to the current identity.
#[arg(long)]
owner: Option<String>,
/// Maximum number of results
#[arg(long)]
limit: Option<u32>,
},
/// Add one or more member repositories to a project
#[command(name = "add-repo")]
AddRepo {
/// Project slug
slug: String,
/// Member repository coordinate (bare id or full `30617:<owner-hex>:<repo-d>`)
#[arg(long = "repo", required = true)]
repo: Vec<String>,
},
/// Remove one or more member repositories from a project
#[command(name = "remove-repo")]
RemoveRepo {
/// Project slug
slug: String,
/// Member repository coordinate to remove (bare id or full `30617:<owner-hex>:<repo-d>`)
#[arg(long = "repo", required = true)]
repo: Vec<String>,
},
/// Update project metadata (at least one setter or clearer required)
#[command(group = clap::ArgGroup::new("mutation").required(true).multiple(true))]
Update {
/// Project slug
slug: String,
/// Set the display name
#[arg(long, group = "mutation")]
name: Option<String>,
/// Remove the display name
#[arg(long, group = "mutation", conflicts_with = "name")]
clear_name: bool,
/// Set the description
#[arg(long, group = "mutation")]
description: Option<String>,
/// Remove the description
#[arg(long, group = "mutation", conflicts_with = "description")]
clear_description: bool,
/// Set the associated Buzz channel UUID
#[arg(long, group = "mutation")]
channel: Option<String>,
/// Remove the associated channel
#[arg(long, group = "mutation", conflicts_with = "channel")]
clear_channel: bool,
/// Set visibility: `listed` or `unlisted`
#[arg(long, group = "mutation")]
visibility: Option<ProjectVisibility>,
/// Remove the visibility tag (absence defaults to `listed`)
#[arg(long, group = "mutation", conflicts_with = "visibility")]
clear_visibility: bool,
},
/// Delete a project (head-based tombstone; verified after submit)
Delete {
/// Project slug
slug: String,
},
}
#[derive(Subcommand)]
pub enum PatchesCmd {
/// Send a git patch (NIP-34 kind:1617)
@@ -1865,6 +1984,7 @@ async fn run(cli: Cli) -> Result<(), CliError> {
Cmd::Social(sub) => commands::social::dispatch(sub, &client).await,
Cmd::Notes(sub) => commands::notes::dispatch(sub, &client).await,
Cmd::Repos(sub) => commands::repos::dispatch(sub, &client).await,
Cmd::Projects(sub) => commands::projects::dispatch(sub, &client).await,
Cmd::Patches(sub) => commands::patches::dispatch(sub, &client).await,
Cmd::Issues(sub) => commands::issues::dispatch(sub, &client).await,
Cmd::Pr(sub) => commands::pr::dispatch(sub, &client).await,
@@ -1974,6 +2094,7 @@ mod tests {
"pack",
"patches",
"pr",
"projects",
"reactions",
"repos",
"social",
@@ -2129,6 +2250,18 @@ mod tests {
names(&cmd, "patches"),
vec!["get", "list", "send", "status"]
);
assert_eq!(
names(&cmd, "projects"),
vec![
"add-repo",
"create",
"delete",
"get",
"list",
"remove-repo",
"update"
]
);
assert_eq!(
names(&cmd, "issues"),
vec!["create", "get", "list", "status"]
@@ -2166,6 +2299,7 @@ mod tests {
("pack", 2),
("patches", 4),
("pr", 5),
("projects", 7),
("reactions", 3),
("repos", 5),
("social", 7),
@@ -2233,4 +2367,111 @@ mod tests {
.join("\n")
);
}
// ── projects update mutation group ────────────────────────────────────────
/// Multiple independent fields must be accepted in the same invocation.
#[test]
fn projects_update_multi_field_is_accepted() {
assert!(
Cli::try_parse_from([
"buzz",
"projects",
"update",
"my-slug",
"--name",
"X",
"--description",
"Y",
])
.is_ok(),
"--name and --description together must be accepted"
);
}
/// A setter for one field and a clearer for a different field must be accepted.
#[test]
fn projects_update_setter_with_other_clearer_is_accepted() {
assert!(
Cli::try_parse_from([
"buzz",
"projects",
"update",
"my-slug",
"--name",
"X",
"--clear-description",
])
.is_ok(),
"--name with --clear-description must be accepted"
);
}
/// A setter and its own clearer are mutually exclusive — clap must reject this.
#[test]
fn projects_update_setter_with_own_clearer_is_rejected() {
assert!(
Cli::try_parse_from([
"buzz",
"projects",
"update",
"my-slug",
"--name",
"X",
"--clear-name",
])
.is_err(),
"--name and --clear-name together must be rejected by clap"
);
}
/// Providing no mutation options at all must be rejected by clap (required group).
#[test]
fn projects_update_no_mutation_is_rejected_by_clap() {
// Without credentials, a valid parse would reach authentication and fail
// with auth_error — but a clap-level rejection happens before any I/O.
// We verify it's a clap error (not just any error) by checking the error
// kind is not a runtime/auth failure — Cli::try_parse_from returns Err
// immediately for argument violations.
assert!(
Cli::try_parse_from(["buzz", "projects", "update", "my-slug"]).is_err(),
"update with no setters or clearers must be rejected at parse time"
);
}
/// An unrecognised visibility token must be rejected by clap before any I/O.
#[test]
fn projects_create_invalid_visibility_is_rejected_by_clap() {
assert!(
Cli::try_parse_from([
"buzz",
"projects",
"create",
"my-slug",
"--repo",
"buzz",
"--visibility",
"chartreuse",
])
.is_err(),
"--visibility chartreuse must be rejected at parse time"
);
}
/// An unrecognised visibility token on update must be rejected by clap before any I/O.
#[test]
fn projects_update_invalid_visibility_is_rejected_by_clap() {
assert!(
Cli::try_parse_from([
"buzz",
"projects",
"update",
"my-slug",
"--visibility",
"chartreuse",
])
.is_err(),
"--visibility chartreuse on update must be rejected at parse time"
);
}
}
+637 -8
View File
@@ -11,8 +11,8 @@ use buzz_core::{
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_USER_STATUS,
KIND_WORKFLOW_DEF, KIND_WORKFLOW_TRIGGER,
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,
@@ -1499,12 +1499,7 @@ pub fn build_workflow_delete(
author_pubkey: &str,
workflow_id: Uuid,
) -> Result<EventBuilder, SdkError> {
let pk = check_pubkey_hex(author_pubkey, "author_pubkey")?;
let tags = vec![tag(&[
"a",
&format!("{}:{pk}:{workflow_id}", KIND_WORKFLOW_DEF),
])?];
Ok(EventBuilder::new(Kind::Custom(KIND_DELETION as u16), "").tags(tags))
build_delete_addressable(KIND_WORKFLOW_DEF, author_pubkey, &workflow_id.to_string())
}
/// Build a workflow trigger event (kind 46020).
@@ -1838,6 +1833,364 @@ pub fn build_unarchive_identity_request(
)
}
// ─── 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 (1000019999 or 3000039999).
/// - `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 1000019999 or 3000039999)"
)));
}
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::*;
@@ -3884,4 +4237,280 @@ mod tests {
.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");
}
}