mirror of
https://github.com/block/buzz.git
synced 2026-10-30 05:51:46 +00:00
## Summary - inject bounded project-home identity and repository context into managed agent sessions - add project-aware CLI flows for creating projects, repositories, issues, and related channels - match project homes through the existing relay query surface, then filter channel metadata client-side This is Part 1 of the channel-first Projects stack. Part 2 contains project creation and model foundations. ## Testing - `cargo fmt --all -- --check` - `cargo clippy -p buzz-cli -p buzz-acp --all-targets -- -D warnings` - `cargo test -p buzz-cli -p buzz-acp` — 1,184 tests passed, 1 doc test ignored - full pre-push gate passed ## Post-Deploy Monitoring & Validation - validate project-home agent context and project-aware CLI commands against a staging relay - healthy signals: project context matches the active channel, explicit repo coordinates remain stable, and normal channels receive no project block - failure signals: cross-channel project context, duplicate project creation, or commands targeting an unrelated repository; mitigate by reverting this PR --------- Signed-off-by: Thomas Petersen <thomasp@squareup.com> Signed-off-by: Wrench <0eabe6ea5758c1e4c5b68cea4ac42b32c479072883cb28da8110b4a47c32b9a1@buzz.block.builderlab.xyz> Co-authored-by: Wrench <0eabe6ea5758c1e4c5b68cea4ac42b32c479072883cb28da8110b4a47c32b9a1@buzz.block.builderlab.xyz>
1716 lines
64 KiB
Rust
1716 lines
64 KiB
Rust
//! `buzz projects` commands — NIP-MP kind:30621 write path.
|
|
//!
|
|
//! All mutations follow a read-modify-write pattern:
|
|
//! 1. Fetch the caller's own live head via `kinds:[30621] + authors:[self] + #d:[slug]`.
|
|
//! 2. Mutate the tag set (strip `auth`, apply change).
|
|
//! 3. Re-validate the full envelope through Layer A before submitting.
|
|
//! 4. Set `created_at = max(client_now, head.created_at + 1)` so the
|
|
//! replacement dominates the observed head and uses wall clock for
|
|
//! ordinary stale heads. Unusually future heads may still hit the relay's
|
|
//! timestamp-drift guard until time advances.
|
|
//!
|
|
//! Limitations recorded in this phase:
|
|
//! - Relay hints are read-preserved but not authored (`--repo` carries
|
|
//! a coordinate only; existing hinted tags survive RMW unchanged).
|
|
//! - `delete` targets signer-self only (NIP-OA owner-delete path deferred).
|
|
//! - Deletion durability against later arrival (watermark follow-up) is
|
|
//! not in scope.
|
|
|
|
use buzz_core::kind::KIND_PROJECT;
|
|
use buzz_sdk::{
|
|
build_delete_addressable, build_project, build_project_with_tags, ProjectMemberCoord,
|
|
PROJECT_D_MAX_LEN,
|
|
};
|
|
use nostr::{Event, EventBuilder, PublicKey, Tag, Timestamp};
|
|
|
|
use crate::agent_management::{build_project_channel, CreateProjectChannelDraft};
|
|
use crate::client::BuzzClient;
|
|
use crate::commands::parse_write_response;
|
|
use crate::commands::project_channel::{
|
|
repo_id_from_project_slug, require_repo_channel_binding, truncate_repo_name,
|
|
};
|
|
use crate::commands::repos::{build_create_announcement, fetch_own_repo_announcement};
|
|
use crate::error::CliError;
|
|
|
|
async fn cmd_add_channel_draft(
|
|
client: &BuzzClient,
|
|
home_channel: String,
|
|
name: String,
|
|
description: Option<String>,
|
|
visibility: String,
|
|
ttl_seconds: Option<u64>,
|
|
template_name: Option<String>,
|
|
) -> Result<(), CliError> {
|
|
let owner_hex = client
|
|
.auth_tag_owner_hex()
|
|
.ok_or_else(|| CliError::Auth("project channel requests require BUZZ_AUTH_TAG".into()))?;
|
|
let owner = PublicKey::parse(&owner_hex)
|
|
.map_err(|error| CliError::Auth(format!("invalid owner attestation: {error}")))?;
|
|
let built = build_project_channel(
|
|
client.keys(),
|
|
&owner,
|
|
CreateProjectChannelDraft {
|
|
home_channel_id: home_channel,
|
|
name,
|
|
description,
|
|
visibility,
|
|
ttl_seconds,
|
|
template_name,
|
|
},
|
|
)?;
|
|
let response = client.publish_ephemeral_event(built.event).await?;
|
|
let mut output: serde_json::Value = serde_json::from_str(&response)
|
|
.map_err(|error| CliError::Other(format!("invalid relay response: {error}")))?;
|
|
if let Some(object) = output.as_object_mut() {
|
|
object.insert("request_id".into(), built.request_id.into());
|
|
object.insert("action".into(), "add-channel".into());
|
|
object.insert("saved".into(), false.into());
|
|
object.insert(
|
|
"message".into(),
|
|
"Project channel draft sent to Buzz Desktop for owner review. The channel is not created until the owner approves it."
|
|
.into(),
|
|
);
|
|
}
|
|
println!("{output}");
|
|
Ok(())
|
|
}
|
|
|
|
// ── Buzz repo-ID grammar (bare --repo shorthand) ─────────────────────────────
|
|
|
|
/// Pattern for a Buzz-hosted repo identifier (bare `--repo` shorthand).
|
|
/// `[a-zA-Z0-9._-]{1,64}` — no colons, so guaranteed collision-free with
|
|
/// `30617:<owner>:<d>` full coordinates.
|
|
fn is_bare_repo_id(s: &str) -> bool {
|
|
!s.is_empty()
|
|
&& s.len() <= 64
|
|
&& s.chars()
|
|
.all(|c| c.is_ascii_alphanumeric() || c == '.' || c == '_' || c == '-')
|
|
}
|
|
|
|
/// Expand a CLI `--repo` argument into a full `30617:<owner>:<d>` coordinate.
|
|
///
|
|
/// Bare form (`[a-zA-Z0-9._-]{1,64}`): owner defaults to the caller's pubkey.
|
|
/// Full form (`30617:<owner-hex>:<d>`): used verbatim.
|
|
fn expand_repo_coord(s: &str, caller_pubkey: &str) -> Result<ProjectMemberCoord, CliError> {
|
|
if is_bare_repo_id(s) {
|
|
// Bare form: expand to full coordinate with caller as owner.
|
|
let full = format!("30617:{caller_pubkey}:{s}");
|
|
ProjectMemberCoord::parse_full(&full)
|
|
.map_err(|e| CliError::Usage(format!("invalid repo coordinate: {e}")))
|
|
} else {
|
|
// Full form: must be parseable as a complete coordinate.
|
|
ProjectMemberCoord::parse_full(s)
|
|
.map_err(|e| CliError::Usage(format!("invalid repo coordinate: {e}")))
|
|
}
|
|
}
|
|
|
|
// ── Head-fetch helper ─────────────────────────────────────────────────────────
|
|
|
|
fn parse_events(json: &str) -> Result<Vec<Event>, CliError> {
|
|
serde_json::from_str(json)
|
|
.map_err(|e| CliError::Other(format!("failed to parse relay response: {e}")))
|
|
}
|
|
|
|
/// Fetch listed kind:30621 heads whose `buzz-channel` is `channel`.
|
|
fn project_tags_match_channel<'a>(tags: impl IntoIterator<Item = &'a Tag>, channel: &str) -> bool {
|
|
tags.into_iter()
|
|
.any(|tag| tag_name(tag) == Some("buzz-channel") && tag_value(tag) == Some(channel))
|
|
}
|
|
|
|
pub(crate) const PROJECT_QUERY_EVENT_BOUND: u32 = 10_000;
|
|
|
|
pub(crate) async fn fetch_projects_for_channel(
|
|
client: &BuzzClient,
|
|
channel: &str,
|
|
) -> Result<Vec<Event>, CliError> {
|
|
fetch_projects_for_channel_bounded(client, channel, PROJECT_QUERY_EVENT_BOUND).await
|
|
}
|
|
|
|
async fn fetch_projects_for_channel_bounded(
|
|
client: &BuzzClient,
|
|
channel: &str,
|
|
max_events: u32,
|
|
) -> Result<Vec<Event>, CliError> {
|
|
let filter = serde_json::json!({
|
|
"kinds": [KIND_PROJECT],
|
|
"#buzz-channel": [channel],
|
|
});
|
|
let events: Vec<Event> = client
|
|
.query_all_bounded(filter, max_events)
|
|
.await?
|
|
.into_iter()
|
|
.map(|event| {
|
|
serde_json::from_value(event)
|
|
.map_err(|e| CliError::Other(format!("failed to parse relay response: {e}")))
|
|
})
|
|
.collect::<Result<_, _>>()?;
|
|
Ok(events
|
|
.into_iter()
|
|
.filter(|event| project_tags_match_channel(event.tags.iter(), channel))
|
|
.collect())
|
|
}
|
|
|
|
fn project_is_unlisted(event: &Event) -> bool {
|
|
event.tags.iter().any(|tag| {
|
|
matches!(
|
|
tag.as_slice(),
|
|
[name, value, ..] if name == "buzz-visibility" && value == "unlisted"
|
|
)
|
|
})
|
|
}
|
|
|
|
fn project_slug(event: &Event) -> Option<String> {
|
|
event.tags.iter().find_map(|tag| match tag.as_slice() {
|
|
[name, value, ..] if name == "d" && !value.is_empty() => Some(value.clone()),
|
|
_ => None,
|
|
})
|
|
}
|
|
|
|
/// Add repos to a project the caller owns. Returns the relay write JSON.
|
|
pub async fn add_repos_to_own_project(
|
|
client: &BuzzClient,
|
|
slug: &str,
|
|
repos: &[String],
|
|
) -> Result<String, CliError> {
|
|
validate_project_slug(slug)?;
|
|
let caller_pubkey = client.keys().public_key().to_hex();
|
|
|
|
let new_members: Vec<ProjectMemberCoord> = repos
|
|
.iter()
|
|
.map(|r| expand_repo_coord(r, &caller_pubkey))
|
|
.collect::<Result<Vec<_>, _>>()?;
|
|
|
|
let mut seen = std::collections::HashSet::new();
|
|
for m in &new_members {
|
|
if !seen.insert(m.coord.clone()) {
|
|
return Err(CliError::Usage(format!(
|
|
"duplicate --repo coordinate in this invocation: {:?}",
|
|
m.coord
|
|
)));
|
|
}
|
|
}
|
|
|
|
let head = fetch_own_project(client, slug)
|
|
.await?
|
|
.ok_or_else(|| CliError::NotFound(format!("project {slug:?} not found")))?;
|
|
let next_ts = next_timestamp(&head, Timestamp::now())?;
|
|
|
|
let mut tags: Vec<Tag> = head.tags.iter().cloned().collect();
|
|
let existing_coords: std::collections::HashSet<String> = head
|
|
.tags
|
|
.iter()
|
|
.filter(|t| tag_name(t) == Some("a"))
|
|
.filter_map(|t| tag_value(t).map(String::from))
|
|
.collect();
|
|
let mut added = 0usize;
|
|
for m in &new_members {
|
|
if !existing_coords.contains(m.coord.as_str()) {
|
|
let parts = m.to_tag_parts();
|
|
let parts_ref: Vec<&str> = parts.iter().map(String::as_str).collect();
|
|
tags.push(
|
|
Tag::parse(parts_ref.iter().copied())
|
|
.map_err(|e| CliError::Other(format!("member tag construction failed: {e}")))?,
|
|
);
|
|
added += 1;
|
|
}
|
|
}
|
|
|
|
if added == 0 {
|
|
return Err(CliError::Conflict(format!(
|
|
"all requested repositories are already members of project {slug:?}"
|
|
)));
|
|
}
|
|
|
|
let builder = rebuild_project(&head.content, tags, next_ts)?;
|
|
let event = client.sign_event(builder)?;
|
|
client.submit_event(event).await
|
|
}
|
|
|
|
/// If this channel is already a project the caller owns, attach `repo_id`.
|
|
pub async fn try_add_own_repo_to_channel_project(
|
|
client: &BuzzClient,
|
|
channel: &str,
|
|
repo_id: &str,
|
|
) -> Result<(), CliError> {
|
|
let projects = fetch_projects_for_channel(client, channel).await?;
|
|
let caller = client.keys().public_key().to_hex();
|
|
let Some(event) = projects.iter().find(|candidate| {
|
|
candidate.pubkey.to_hex().eq_ignore_ascii_case(&caller) && !project_is_unlisted(candidate)
|
|
}) else {
|
|
return Ok(());
|
|
};
|
|
let Some(slug) = project_slug(event) else {
|
|
return Ok(());
|
|
};
|
|
match add_repos_to_own_project(client, &slug, &[repo_id.to_string()]).await {
|
|
Ok(_) | Err(CliError::Conflict(_)) => Ok(()),
|
|
Err(error) => Err(error),
|
|
}
|
|
}
|
|
|
|
/// Fetch a project head by slug and optional owner pubkey.
|
|
async fn fetch_project(
|
|
client: &BuzzClient,
|
|
slug: &str,
|
|
owner: Option<&str>,
|
|
) -> Result<Option<Event>, CliError> {
|
|
let pubkey = match owner {
|
|
Some(pk) => {
|
|
crate::validate::validate_hex64(pk)?;
|
|
pk.to_string()
|
|
}
|
|
None => client.keys().public_key().to_hex(),
|
|
};
|
|
let filter = serde_json::json!({
|
|
"kinds": [KIND_PROJECT],
|
|
"authors": [pubkey],
|
|
"#d": [slug],
|
|
"limit": 1,
|
|
});
|
|
let raw = client.query(&filter).await?;
|
|
let mut events = parse_events(&raw)?;
|
|
events.sort_by_key(|e| std::cmp::Reverse(e.created_at));
|
|
Ok(events.into_iter().next())
|
|
}
|
|
|
|
/// Fetch the caller's own live kind:30621 head for `slug`.
|
|
async fn fetch_own_project(client: &BuzzClient, slug: &str) -> Result<Option<Event>, CliError> {
|
|
fetch_project(client, slug, None).await
|
|
}
|
|
|
|
// ── Tag helpers ───────────────────────────────────────────────────────────────
|
|
|
|
fn tag_name(tag: &Tag) -> Option<&str> {
|
|
tag.as_slice().first().map(String::as_str)
|
|
}
|
|
|
|
fn tag_value(tag: &Tag) -> Option<&str> {
|
|
tag.as_slice().get(1).map(String::as_str)
|
|
}
|
|
|
|
fn make_tag(parts: &[&str]) -> Result<Tag, CliError> {
|
|
Tag::parse(parts.iter().copied())
|
|
.map_err(|e| CliError::Other(format!("tag construction failed: {e}")))
|
|
}
|
|
|
|
// ── Submit helper ─────────────────────────────────────────────────────────────
|
|
|
|
/// Submit a project event and print the relay's write response.
|
|
///
|
|
/// `link_slug` carries the project's d-tag on creates whose slug fits the
|
|
/// `buzz://` link charset; the response then also carries a `link` field,
|
|
/// which renders as a rich preview card in Buzz Desktop when included in a
|
|
/// chat message — agents announce projects with it (see base_prompt.md).
|
|
async fn submit_project(
|
|
client: &BuzzClient,
|
|
builder: EventBuilder,
|
|
link_slug: Option<&str>,
|
|
) -> Result<(), CliError> {
|
|
let event = client.sign_event(builder)?;
|
|
let owner = event.pubkey.to_hex();
|
|
let raw = client.submit_event(event).await?;
|
|
let response = parse_write_response(&raw, "project changed concurrently; retry")?;
|
|
match link_slug {
|
|
Some(slug) => crate::client::print_create_response(
|
|
&response,
|
|
"link",
|
|
&crate::links::project_link(&owner, slug),
|
|
),
|
|
None => println!("{response}"),
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
// ── Build helpers ─────────────────────────────────────────────────────────────
|
|
|
|
/// Choose the later of client wall clock and the instant after the observed head.
|
|
///
|
|
/// The relay remains authoritative for timestamp drift: a sufficiently future
|
|
/// head can require a timestamp that the relay will temporarily reject.
|
|
fn next_timestamp(head: &Event, now: Timestamp) -> Result<Timestamp, CliError> {
|
|
let after_head = head
|
|
.created_at
|
|
.as_secs()
|
|
.checked_add(1)
|
|
.ok_or_else(|| CliError::Other("project timestamp cannot be advanced".into()))?;
|
|
Ok(Timestamp::from(after_head.max(now.as_secs())))
|
|
}
|
|
|
|
/// Strip `auth` from a tag list and pass the resulting envelope through
|
|
/// Layer A validation. Returns a validated `EventBuilder` at `next_ts`.
|
|
fn rebuild_project(
|
|
content: &str,
|
|
tags: Vec<Tag>,
|
|
next_ts: Timestamp,
|
|
) -> Result<EventBuilder, CliError> {
|
|
// Strip auth tags.
|
|
let clean_tags: Vec<Tag> = tags
|
|
.into_iter()
|
|
.filter(|t| tag_name(t) != Some("auth"))
|
|
.collect();
|
|
|
|
build_project_with_tags(content, clean_tags)
|
|
.map_err(|e| CliError::Other(format!("envelope validation failed: {e}")))
|
|
.map(|b| b.custom_created_at(next_ts))
|
|
}
|
|
|
|
// ── Command implementations ───────────────────────────────────────────────────
|
|
|
|
/// `buzz projects create`
|
|
pub async fn cmd_create(
|
|
client: &BuzzClient,
|
|
slug: &str,
|
|
repos: &[String],
|
|
name: Option<&str>,
|
|
description: Option<&str>,
|
|
channel: Option<&str>,
|
|
visibility: Option<&str>,
|
|
) -> Result<(), CliError> {
|
|
// ── Local validation (all checks before any .await) ───────────────────
|
|
validate_project_slug(slug)?;
|
|
|
|
let caller_pubkey = client.keys().public_key().to_hex();
|
|
|
|
// Expand and validate repo coordinates.
|
|
let mut members: Vec<ProjectMemberCoord> = repos
|
|
.iter()
|
|
.map(|r| expand_repo_coord(r, &caller_pubkey))
|
|
.collect::<Result<Vec<_>, _>>()?;
|
|
|
|
if members.is_empty() && channel.is_none() {
|
|
return Err(CliError::Usage(
|
|
"pass --channel to create a default repository, or --repo to attach an existing one"
|
|
.into(),
|
|
));
|
|
}
|
|
|
|
// Dedupe: preserve first occurrence, reject duplicates with Usage.
|
|
let mut seen = std::collections::HashSet::new();
|
|
for m in &members {
|
|
if !seen.insert(m.coord.clone()) {
|
|
return Err(CliError::Usage(format!(
|
|
"duplicate --repo coordinate in this invocation: {:?}",
|
|
m.coord
|
|
)));
|
|
}
|
|
}
|
|
|
|
// Validate optional metadata (early, before any network call).
|
|
if let Some(ch) = channel {
|
|
crate::validate::validate_uuid(ch)?;
|
|
}
|
|
if let Some(vis) = visibility {
|
|
validate_visibility(vis)?;
|
|
}
|
|
if let Some(n) = name {
|
|
if n.len() > 256 {
|
|
return Err(CliError::Usage(format!(
|
|
"project name must not exceed 256 bytes (got {})",
|
|
n.len()
|
|
)));
|
|
}
|
|
}
|
|
|
|
// ── Network: collision preflight ──────────────────────────────────────
|
|
if fetch_own_project(client, slug).await?.is_some() {
|
|
return Err(CliError::Conflict(format!(
|
|
"project {slug:?} already exists; use 'buzz projects update' to modify it"
|
|
)));
|
|
}
|
|
if let Some(channel) = channel {
|
|
if let Some(existing) = fetch_projects_for_channel(client, channel)
|
|
.await?
|
|
.into_iter()
|
|
.find(|event| {
|
|
event.pubkey.to_hex().eq_ignore_ascii_case(&caller_pubkey)
|
|
&& !project_is_unlisted(event)
|
|
})
|
|
{
|
|
let existing_slug = project_slug(&existing).unwrap_or_else(|| slug.to_string());
|
|
return Err(CliError::Conflict(format!(
|
|
"you already own project {existing_slug:?} for channel {channel}; update that project instead"
|
|
)));
|
|
}
|
|
}
|
|
|
|
if members.is_empty() {
|
|
let home = channel.ok_or_else(|| {
|
|
CliError::Usage(
|
|
"pass --channel to create a default repository, or --repo to attach an existing one"
|
|
.into(),
|
|
)
|
|
})?;
|
|
let repo_id = ensure_default_create_repo(client, slug, name, description, home).await?;
|
|
members.push(expand_repo_coord(&repo_id, &caller_pubkey)?);
|
|
}
|
|
|
|
// ── Build via Layer B (enforces all writer policy) ────────────────────
|
|
let builder = build_project(slug, name, description, &members, channel, visibility)
|
|
.map_err(|e| CliError::Usage(e.to_string()))?;
|
|
|
|
// Slugs wider than the link charset stay linkless rather than emitting a
|
|
// `link` no client can parse.
|
|
submit_project(
|
|
client,
|
|
builder,
|
|
crate::links::is_linkable_dtag(slug).then_some(slug),
|
|
)
|
|
.await
|
|
}
|
|
|
|
/// `buzz projects get`
|
|
pub async fn cmd_get(client: &BuzzClient, slug: &str, owner: Option<&str>) -> Result<(), CliError> {
|
|
validate_project_slug(slug)?;
|
|
let resp = match fetch_project(client, slug, owner).await? {
|
|
Some(event) => serde_json::json!({
|
|
"event_id": event.id.to_hex(),
|
|
"pubkey": event.pubkey.to_hex(),
|
|
"created_at": event.created_at.as_secs(),
|
|
"kind": event.kind.as_u16(),
|
|
"tags": event.tags.iter().map(|t| t.as_slice().to_vec()).collect::<Vec<_>>(),
|
|
"content": event.content,
|
|
}),
|
|
None => {
|
|
let owner_desc = owner.unwrap_or("current identity");
|
|
return Err(CliError::NotFound(format!(
|
|
"project {slug:?} not found for {owner_desc}"
|
|
)));
|
|
}
|
|
};
|
|
println!("{resp}");
|
|
Ok(())
|
|
}
|
|
|
|
/// `buzz projects list`
|
|
pub async fn cmd_list(
|
|
client: &BuzzClient,
|
|
owner: Option<&str>,
|
|
limit: Option<u32>,
|
|
) -> Result<(), CliError> {
|
|
let pubkey = match owner {
|
|
Some(pk) => {
|
|
crate::validate::validate_hex64(pk)?;
|
|
pk.to_string()
|
|
}
|
|
None => client.keys().public_key().to_hex(),
|
|
};
|
|
let mut filter = serde_json::json!({
|
|
"kinds": [KIND_PROJECT],
|
|
"authors": [pubkey],
|
|
});
|
|
if let Some(n) = limit {
|
|
filter["limit"] = serde_json::json!(n);
|
|
}
|
|
let resp = client.query(&filter).await?;
|
|
println!("{resp}");
|
|
Ok(())
|
|
}
|
|
|
|
/// `buzz projects add-repo`
|
|
pub async fn cmd_add_repo(
|
|
client: &BuzzClient,
|
|
slug: &str,
|
|
repos: &[String],
|
|
) -> Result<(), CliError> {
|
|
let raw = add_repos_to_own_project(client, slug, repos).await?;
|
|
let response = parse_write_response(&raw, "project changed concurrently; retry")?;
|
|
println!("{response}");
|
|
Ok(())
|
|
}
|
|
|
|
/// `buzz projects remove-repo`
|
|
pub async fn cmd_remove_repo(
|
|
client: &BuzzClient,
|
|
slug: &str,
|
|
repos: &[String],
|
|
) -> Result<(), CliError> {
|
|
validate_project_slug(slug)?;
|
|
let caller_pubkey = client.keys().public_key().to_hex();
|
|
|
|
// ── Local validation before any .await ────────────────────────────────
|
|
let to_remove: Vec<ProjectMemberCoord> = repos
|
|
.iter()
|
|
.map(|r| expand_repo_coord(r, &caller_pubkey))
|
|
.collect::<Result<Vec<_>, _>>()?;
|
|
|
|
// ── Network: fetch head ───────────────────────────────────────────────
|
|
let head = fetch_own_project(client, slug)
|
|
.await?
|
|
.ok_or_else(|| CliError::NotFound(format!("project {slug:?} not found")))?;
|
|
let next_ts = next_timestamp(&head, Timestamp::now())?;
|
|
|
|
// Verify all requested repos exist in the project.
|
|
let existing_coords: std::collections::HashSet<String> = head
|
|
.tags
|
|
.iter()
|
|
.filter(|t| tag_name(t) == Some("a"))
|
|
.filter_map(|t| tag_value(t).map(String::from))
|
|
.collect();
|
|
for m in &to_remove {
|
|
if !existing_coords.contains(m.coord.as_str()) {
|
|
return Err(CliError::NotFound(format!(
|
|
"project {slug:?} does not contain member {:?}",
|
|
m.coord
|
|
)));
|
|
}
|
|
}
|
|
|
|
let remove_coords: std::collections::HashSet<&str> =
|
|
to_remove.iter().map(|m| m.coord.as_str()).collect();
|
|
|
|
// Keep all tags except auth and the removed members.
|
|
let tags: Vec<Tag> = head
|
|
.tags
|
|
.iter()
|
|
.filter(|t| {
|
|
if tag_name(t) == Some("auth") {
|
|
return false;
|
|
}
|
|
if tag_name(t) == Some("a") {
|
|
if let Some(coord) = tag_value(t) {
|
|
return !remove_coords.contains(coord);
|
|
}
|
|
}
|
|
true
|
|
})
|
|
.cloned()
|
|
.collect();
|
|
|
|
// Single rebuild validates the full envelope and strips any remaining auth.
|
|
let builder = rebuild_project(&head.content, tags, next_ts)?;
|
|
submit_project(client, builder, None).await
|
|
}
|
|
|
|
/// `buzz projects update`
|
|
///
|
|
/// Requires at least one setter or clearer; a no-op call is a usage error.
|
|
#[allow(clippy::too_many_arguments)]
|
|
pub async fn cmd_update(
|
|
client: &BuzzClient,
|
|
slug: &str,
|
|
name: Option<&str>,
|
|
clear_name: bool,
|
|
description: Option<&str>,
|
|
clear_description: bool,
|
|
channel: Option<&str>,
|
|
clear_channel: bool,
|
|
visibility: Option<&str>,
|
|
clear_visibility: bool,
|
|
) -> Result<(), CliError> {
|
|
// Guard: at least one mutation required. The clap `ArgGroup` with
|
|
// `required(true).multiple(true)` enforces this at parse time; this
|
|
// runtime check is a defense-in-depth safety net for callers that invoke
|
|
// `cmd_update` directly (e.g. tests and future programmatic callers).
|
|
let has_mutation = name.is_some()
|
|
|| clear_name
|
|
|| description.is_some()
|
|
|| clear_description
|
|
|| channel.is_some()
|
|
|| clear_channel
|
|
|| visibility.is_some()
|
|
|| clear_visibility;
|
|
if !has_mutation {
|
|
return Err(CliError::Usage(
|
|
"buzz projects update requires at least one of: \
|
|
--name, --clear-name, --description, --clear-description, \
|
|
--channel, --clear-channel, --visibility, --clear-visibility"
|
|
.into(),
|
|
));
|
|
}
|
|
|
|
validate_project_slug(slug)?;
|
|
if let Some(ch) = channel {
|
|
crate::validate::validate_uuid(ch)?;
|
|
}
|
|
if let Some(vis) = visibility {
|
|
validate_visibility(vis)?;
|
|
}
|
|
|
|
let head = fetch_own_project(client, slug)
|
|
.await?
|
|
.ok_or_else(|| CliError::NotFound(format!("project {slug:?} not found")))?;
|
|
let next_ts = next_timestamp(&head, Timestamp::now())?;
|
|
|
|
// Build the new tag set. For each singleton metadata field:
|
|
// - setter present: replace value (strip old, append new)
|
|
// - clear flag set: drop the tag
|
|
// - neither: keep existing
|
|
// Non-singleton / non-metadata tags (d, a, unknown) are preserved as-is.
|
|
let singleton_fields = ["name", "description", "buzz-channel", "buzz-visibility"];
|
|
let mut tags: Vec<Tag> = head
|
|
.tags
|
|
.iter()
|
|
.filter(|t| {
|
|
if tag_name(t) == Some("auth") {
|
|
return false;
|
|
}
|
|
// Drop singletons we're replacing or clearing.
|
|
if let Some(field) = tag_name(t) {
|
|
if singleton_fields.contains(&field) {
|
|
let clear = match field {
|
|
"name" => clear_name || name.is_some(),
|
|
"description" => clear_description || description.is_some(),
|
|
"buzz-channel" => clear_channel || channel.is_some(),
|
|
"buzz-visibility" => clear_visibility || visibility.is_some(),
|
|
_ => false,
|
|
};
|
|
return !clear;
|
|
}
|
|
}
|
|
true
|
|
})
|
|
.cloned()
|
|
.collect();
|
|
|
|
// Append new singleton values.
|
|
if let Some(n) = name {
|
|
tags.push(make_tag(&["name", n])?);
|
|
}
|
|
if let Some(d) = description {
|
|
tags.push(make_tag(&["description", d])?);
|
|
}
|
|
if let Some(ch) = channel {
|
|
tags.push(make_tag(&["buzz-channel", ch])?);
|
|
}
|
|
if let Some(vis) = visibility {
|
|
tags.push(make_tag(&["buzz-visibility", vis])?);
|
|
}
|
|
|
|
let builder = build_project_with_tags(&head.content, tags)
|
|
.map_err(|e| CliError::Other(format!("envelope validation failed: {e}")))?
|
|
.custom_created_at(next_ts);
|
|
submit_project(client, builder, None).await
|
|
}
|
|
|
|
/// `buzz projects delete`
|
|
///
|
|
/// Head-based and verified:
|
|
/// 1. Fetch own live head — `NotFound` if absent.
|
|
/// 2. Build tombstone at `max(client_now, head.created_at + 1)`.
|
|
/// 3. Submit.
|
|
/// 4. Re-query the coordinate; if a newer head survived → `Conflict`.
|
|
pub async fn cmd_delete(client: &BuzzClient, slug: &str) -> Result<(), CliError> {
|
|
validate_project_slug(slug)?;
|
|
|
|
let head = fetch_own_project(client, slug)
|
|
.await?
|
|
.ok_or_else(|| CliError::NotFound(format!("project {slug:?} not found")))?;
|
|
let next_ts = next_timestamp(&head, Timestamp::now())?;
|
|
|
|
let pubkey_hex = client.keys().public_key().to_hex();
|
|
let tombstone = build_delete_addressable(KIND_PROJECT, &pubkey_hex, slug)
|
|
.map_err(|e| CliError::Other(format!("failed to build delete event: {e}")))?
|
|
.custom_created_at(next_ts);
|
|
|
|
let event = client.sign_event(tombstone)?;
|
|
let raw = client.submit_event(event).await?;
|
|
parse_write_response(&raw, "delete event was dominated; a newer head exists")?;
|
|
|
|
// Post-submit verification: re-query to confirm the head is gone.
|
|
if let Some(survivor) = fetch_own_project(client, slug).await? {
|
|
// A newer head survived the tombstone.
|
|
return Err(CliError::Conflict(format!(
|
|
"project {slug:?} still exists (head at {}); a concurrent write raced the delete",
|
|
survivor.created_at.as_secs()
|
|
)));
|
|
}
|
|
|
|
println!("{}", serde_json::json!({ "deleted": slug, "status": "ok" }));
|
|
Ok(())
|
|
}
|
|
|
|
async fn ensure_default_create_repo(
|
|
client: &BuzzClient,
|
|
slug: &str,
|
|
name: Option<&str>,
|
|
description: Option<&str>,
|
|
channel: &str,
|
|
) -> Result<String, CliError> {
|
|
let repo_id = repo_id_from_project_slug(slug)?;
|
|
if let Some(existing) = fetch_own_repo_announcement(client, &repo_id).await? {
|
|
require_repo_channel_binding(&existing, channel)?;
|
|
return Ok(repo_id);
|
|
}
|
|
let raw_name = name.unwrap_or(slug);
|
|
let display_name = truncate_repo_name(raw_name);
|
|
let builder = build_create_announcement(
|
|
&repo_id,
|
|
Some(&display_name),
|
|
description,
|
|
&[],
|
|
None,
|
|
&[],
|
|
Some(channel),
|
|
)?;
|
|
let event = client.sign_event(builder)?;
|
|
let raw = client.submit_event(event).await?;
|
|
let winner = fetch_own_repo_announcement(client, &repo_id).await?;
|
|
verify_default_repo_write(&raw, winner.as_ref(), channel)?;
|
|
Ok(repo_id)
|
|
}
|
|
|
|
pub(crate) fn verify_default_repo_write(
|
|
raw: &str,
|
|
winner: Option<&Event>,
|
|
channel: &str,
|
|
) -> Result<(), CliError> {
|
|
match parse_write_response(
|
|
raw,
|
|
"default repository changed concurrently; checking the winning head",
|
|
) {
|
|
Ok(_) | Err(CliError::Conflict(_)) => {}
|
|
Err(error) => return Err(error),
|
|
}
|
|
let winner = winner.ok_or_else(|| {
|
|
CliError::Conflict(
|
|
"default repository write was not authoritative; retry project creation".into(),
|
|
)
|
|
})?;
|
|
require_repo_channel_binding(winner, channel)
|
|
}
|
|
|
|
// ── Validation helpers ────────────────────────────────────────────────────────
|
|
|
|
/// Validate a project slug: non-empty, ≤1024 bytes, verbatim.
|
|
/// Does NOT impose the Buzz repo-ID grammar — project slugs are more permissive.
|
|
fn validate_project_slug(slug: &str) -> Result<(), CliError> {
|
|
if slug.is_empty() {
|
|
return Err(CliError::Usage("project slug must not be empty".into()));
|
|
}
|
|
if slug.len() > PROJECT_D_MAX_LEN {
|
|
return Err(CliError::Usage(format!(
|
|
"project slug must not exceed {PROJECT_D_MAX_LEN} bytes (got {})",
|
|
slug.len()
|
|
)));
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
/// Validate a `buzz-visibility` value at the writer level.
|
|
fn validate_visibility(vis: &str) -> Result<(), CliError> {
|
|
if vis != "listed" && vis != "unlisted" {
|
|
return Err(CliError::Usage(format!(
|
|
"visibility must be 'listed' or 'unlisted' (got {vis:?})"
|
|
)));
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
// ── Dispatch ──────────────────────────────────────────────────────────────────
|
|
|
|
pub async fn dispatch(cmd: crate::ProjectsCmd, client: &BuzzClient) -> Result<(), CliError> {
|
|
use crate::ProjectsCmd;
|
|
match cmd {
|
|
ProjectsCmd::Create {
|
|
slug,
|
|
repo,
|
|
name,
|
|
description,
|
|
channel,
|
|
visibility,
|
|
} => {
|
|
cmd_create(
|
|
client,
|
|
&slug,
|
|
&repo,
|
|
name.as_deref(),
|
|
description.as_deref(),
|
|
channel.as_deref(),
|
|
visibility.map(|v| v.as_str()),
|
|
)
|
|
.await
|
|
}
|
|
ProjectsCmd::Get { slug, owner } => cmd_get(client, &slug, owner.as_deref()).await,
|
|
ProjectsCmd::List { owner, limit } => cmd_list(client, owner.as_deref(), limit).await,
|
|
ProjectsCmd::AddRepo { slug, repo } => cmd_add_repo(client, &slug, &repo).await,
|
|
ProjectsCmd::AddChannel {
|
|
home_channel,
|
|
name,
|
|
description,
|
|
visibility,
|
|
ttl,
|
|
template,
|
|
} => {
|
|
cmd_add_channel_draft(
|
|
client,
|
|
home_channel,
|
|
name,
|
|
description,
|
|
visibility.to_string(),
|
|
ttl,
|
|
template,
|
|
)
|
|
.await
|
|
}
|
|
ProjectsCmd::RemoveRepo { slug, repo } => cmd_remove_repo(client, &slug, &repo).await,
|
|
ProjectsCmd::Update {
|
|
slug,
|
|
name,
|
|
clear_name,
|
|
description,
|
|
clear_description,
|
|
channel,
|
|
clear_channel,
|
|
visibility,
|
|
clear_visibility,
|
|
} => {
|
|
cmd_update(
|
|
client,
|
|
&slug,
|
|
name.as_deref(),
|
|
clear_name,
|
|
description.as_deref(),
|
|
clear_description,
|
|
channel.as_deref(),
|
|
clear_channel,
|
|
visibility.map(|v| v.as_str()),
|
|
clear_visibility,
|
|
)
|
|
.await
|
|
}
|
|
ProjectsCmd::Delete { slug } => cmd_delete(client, &slug).await,
|
|
}
|
|
}
|
|
|
|
// ── Tests ─────────────────────────────────────────────────────────────────────
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use buzz_sdk::{validate_project_envelope, PROJECT_MEMBER_CAP};
|
|
use nostr::Tag;
|
|
|
|
use super::*;
|
|
|
|
async fn run_default_repo_create_race(
|
|
winning_channel: &str,
|
|
) -> (Result<(), CliError>, Vec<u16>) {
|
|
use std::sync::{Arc, Mutex};
|
|
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
|
|
|
let requested_channel = "11111111-1111-4111-8111-111111111111";
|
|
let keys = nostr::Keys::generate();
|
|
let winner = build_create_announcement(
|
|
"app",
|
|
Some("App"),
|
|
None,
|
|
&[],
|
|
None,
|
|
&[],
|
|
Some(winning_channel),
|
|
)
|
|
.unwrap()
|
|
.sign_with_keys(&keys)
|
|
.unwrap();
|
|
let winner_json = serde_json::to_value(winner).unwrap();
|
|
let posted_kinds = Arc::new(Mutex::new(Vec::new()));
|
|
let server_kinds = posted_kinds.clone();
|
|
let repo_queries = Arc::new(std::sync::atomic::AtomicUsize::new(0));
|
|
let server_repo_queries = repo_queries.clone();
|
|
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
|
let base_url = format!("http://{}", listener.local_addr().unwrap());
|
|
let server = tokio::spawn(async move {
|
|
while let Ok((mut socket, _)) = listener.accept().await {
|
|
let mut buf = vec![0; 65_536];
|
|
let read = socket.read(&mut buf).await.unwrap_or(0);
|
|
let request = String::from_utf8_lossy(&buf[..read]);
|
|
let (status, body) = if request.starts_with("POST /query ") {
|
|
let is_repo_query = request.contains("30617");
|
|
let repo_query_index = is_repo_query.then(|| {
|
|
server_repo_queries.fetch_add(1, std::sync::atomic::Ordering::SeqCst)
|
|
});
|
|
if repo_query_index == Some(1) {
|
|
("200 OK", serde_json::json!([winner_json]).to_string())
|
|
} else {
|
|
("200 OK", "[]".to_string())
|
|
}
|
|
} else if request.starts_with("POST /events ") {
|
|
let json_start = request.find("\r\n\r\n").unwrap() + 4;
|
|
let event: serde_json::Value =
|
|
serde_json::from_str(&request[json_start..]).unwrap();
|
|
let kind = event["kind"].as_u64().unwrap() as u16;
|
|
server_kinds.lock().unwrap().push(kind);
|
|
if kind == buzz_core::kind::KIND_GIT_REPO_ANNOUNCEMENT as u16 {
|
|
(
|
|
"200 OK",
|
|
serde_json::json!({
|
|
"event_id": event["id"], "accepted": true, "message": "duplicate"
|
|
})
|
|
.to_string(),
|
|
)
|
|
} else {
|
|
(
|
|
"200 OK",
|
|
serde_json::json!({
|
|
"event_id": event["id"], "accepted": true, "message": ""
|
|
})
|
|
.to_string(),
|
|
)
|
|
}
|
|
} else {
|
|
("404 Not Found", "{}".to_string())
|
|
};
|
|
let response = format!(
|
|
"HTTP/1.1 {status}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
|
|
body.len()
|
|
);
|
|
socket.write_all(response.as_bytes()).await.unwrap();
|
|
}
|
|
});
|
|
let client = crate::client::BuzzClient::new(base_url, keys, None, None).unwrap();
|
|
let result = cmd_create(
|
|
&client,
|
|
"app",
|
|
&[],
|
|
Some("App"),
|
|
None,
|
|
Some(requested_channel),
|
|
None,
|
|
)
|
|
.await;
|
|
server.abort();
|
|
let kinds = posted_kinds.lock().unwrap().clone();
|
|
(result, kinds)
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn create_does_not_publish_project_after_default_repo_loses_to_foreign_home() {
|
|
let (result, posted_kinds) =
|
|
run_default_repo_create_race("22222222-2222-4222-8222-222222222222").await;
|
|
|
|
assert!(matches!(result, Err(CliError::Conflict(_))));
|
|
assert_eq!(
|
|
posted_kinds,
|
|
vec![buzz_core::kind::KIND_GIT_REPO_ANNOUNCEMENT as u16],
|
|
"the command must stop before publishing kind:30621"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn create_is_idempotent_when_dominated_default_repo_winner_matches_home() {
|
|
let (result, posted_kinds) =
|
|
run_default_repo_create_race("11111111-1111-4111-8111-111111111111").await;
|
|
|
|
result.expect("matching winning repo head permits project publication");
|
|
assert_eq!(
|
|
posted_kinds,
|
|
vec![
|
|
buzz_core::kind::KIND_GIT_REPO_ANNOUNCEMENT as u16,
|
|
buzz_core::kind::KIND_PROJECT as u16,
|
|
]
|
|
);
|
|
}
|
|
|
|
// ── Coordinate expansion ──────────────────────────────────────────────────
|
|
|
|
const OWNER_HEX: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
|
|
const OWNER_B_HEX: &str = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb";
|
|
|
|
#[tokio::test]
|
|
async fn project_lookup_scopes_the_production_query_before_the_global_bound() {
|
|
use std::sync::{Arc, Mutex};
|
|
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
|
|
|
let channel = "11111111-1111-4111-8111-111111111111";
|
|
let request_body = Arc::new(Mutex::new(None));
|
|
let captured_body = request_body.clone();
|
|
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
|
let base_url = format!("http://{}", listener.local_addr().unwrap());
|
|
let server = tokio::spawn(async move {
|
|
let (mut socket, _) = listener.accept().await.unwrap();
|
|
let mut buf = vec![0; 65_536];
|
|
let read = socket.read(&mut buf).await.unwrap();
|
|
let request = String::from_utf8_lossy(&buf[..read]);
|
|
let body = request.split("\r\n\r\n").nth(1).unwrap().to_owned();
|
|
*captured_body.lock().unwrap() = Some(body);
|
|
socket
|
|
.write_all(
|
|
b"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: 2\r\nConnection: close\r\n\r\n[]",
|
|
)
|
|
.await
|
|
.unwrap();
|
|
});
|
|
let client =
|
|
crate::client::BuzzClient::new(base_url, nostr::Keys::generate(), None, None).unwrap();
|
|
|
|
let projects = fetch_projects_for_channel(&client, channel).await.unwrap();
|
|
assert!(projects.is_empty());
|
|
server.await.unwrap();
|
|
let body: serde_json::Value =
|
|
serde_json::from_str(request_body.lock().unwrap().as_deref().unwrap()).unwrap();
|
|
assert_eq!(body[0]["#buzz-channel"], serde_json::json!([channel]));
|
|
assert_eq!(body[0]["kinds"], serde_json::json!([KIND_PROJECT]));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn channel_scoping_prevents_unrelated_heads_from_consuming_the_bound() {
|
|
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
|
|
|
let channel = "11111111-1111-4111-8111-111111111111";
|
|
let target = build_project("target", None, None, &[], Some(channel), None)
|
|
.unwrap()
|
|
.sign_with_keys(&nostr::Keys::generate())
|
|
.unwrap();
|
|
let decoy_channel = "22222222-2222-4222-8222-222222222222";
|
|
let decoys = ["decoy-a", "decoy-b"].map(|slug| {
|
|
build_project(slug, None, None, &[], Some(decoy_channel), None)
|
|
.unwrap()
|
|
.sign_with_keys(&nostr::Keys::generate())
|
|
.unwrap()
|
|
});
|
|
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
|
let base_url = format!("http://{}", listener.local_addr().unwrap());
|
|
let server = tokio::spawn(async move {
|
|
let (mut socket, _) = listener.accept().await.unwrap();
|
|
let mut buf = vec![0; 65_536];
|
|
let read = socket.read(&mut buf).await.unwrap();
|
|
let request = String::from_utf8_lossy(&buf[..read]);
|
|
let body = request.split("\r\n\r\n").nth(1).unwrap();
|
|
let filter: serde_json::Value = serde_json::from_str(body).unwrap();
|
|
let response_body = if filter[0]["#buzz-channel"] == serde_json::json!([channel]) {
|
|
serde_json::to_string(&[target]).unwrap()
|
|
} else {
|
|
serde_json::to_string(&decoys).unwrap()
|
|
};
|
|
let response = format!(
|
|
"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{response_body}",
|
|
response_body.len()
|
|
);
|
|
socket.write_all(response.as_bytes()).await.unwrap();
|
|
});
|
|
let client =
|
|
crate::client::BuzzClient::new(base_url, nostr::Keys::generate(), None, None).unwrap();
|
|
|
|
let projects = fetch_projects_for_channel_bounded(&client, channel, 1)
|
|
.await
|
|
.unwrap();
|
|
server.await.unwrap();
|
|
assert_eq!(projects.len(), 1);
|
|
assert_eq!(project_slug(&projects[0]).as_deref(), Some("target"));
|
|
}
|
|
|
|
#[test]
|
|
fn project_channel_matching_ignores_unrelated_claims() {
|
|
let expected = "11111111-1111-4111-8111-111111111111";
|
|
let tags = make_head_tags(&[
|
|
make_test_tag(&["buzz-channel", "22222222-2222-4222-8222-222222222222"]),
|
|
make_test_tag(&["name", "Unrelated"]),
|
|
]);
|
|
assert!(!project_tags_match_channel(tags.iter(), expected));
|
|
|
|
let tags = make_head_tags(&[make_test_tag(&["buzz-channel", expected])]);
|
|
assert!(project_tags_match_channel(tags.iter(), expected));
|
|
}
|
|
|
|
#[test]
|
|
fn expand_repo_coord_bare_expands_with_caller_pubkey() {
|
|
let coord = expand_repo_coord("my-repo", OWNER_HEX).unwrap();
|
|
assert_eq!(coord.coord, format!("30617:{OWNER_HEX}:my-repo"));
|
|
}
|
|
|
|
#[test]
|
|
fn expand_repo_coord_full_passes_through() {
|
|
let full = format!("30617:{OWNER_HEX}:some-repo");
|
|
let coord = expand_repo_coord(&full, OWNER_B_HEX).unwrap();
|
|
// Owner from the full coord, not the caller.
|
|
assert_eq!(coord.coord, full);
|
|
}
|
|
|
|
#[test]
|
|
fn expand_repo_coord_full_cross_owner() {
|
|
let full = format!("30617:{OWNER_B_HEX}:infra");
|
|
let coord = expand_repo_coord(&full, OWNER_HEX).unwrap();
|
|
assert_eq!(coord.coord, full);
|
|
}
|
|
|
|
#[test]
|
|
fn expand_repo_coord_rejects_uppercase_owner() {
|
|
let upper = "30617:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA:buzz";
|
|
assert!(expand_repo_coord(upper, OWNER_HEX).is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn expand_repo_coord_rejects_coordinate_shaped_bare_value() {
|
|
// A value with a colon is never a bare id.
|
|
let not_bare = "30617:something";
|
|
// parse_full will fail because it's not a valid full coordinate either.
|
|
assert!(expand_repo_coord(not_bare, OWNER_HEX).is_err());
|
|
}
|
|
|
|
// ── validate_project_slug ─────────────────────────────────────────────────
|
|
|
|
#[test]
|
|
fn validate_project_slug_accepts_normal() {
|
|
assert!(validate_project_slug("my-project").is_ok());
|
|
assert!(validate_project_slug("platform:v2").is_ok()); // colons allowed — more permissive than repo-id
|
|
}
|
|
|
|
#[test]
|
|
fn validate_project_slug_rejects_empty() {
|
|
assert!(validate_project_slug("").is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn validate_project_slug_rejects_over_1024() {
|
|
let long = "a".repeat(1025);
|
|
assert!(validate_project_slug(&long).is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn validate_project_slug_accepts_1024() {
|
|
let at_limit = "a".repeat(1024);
|
|
assert!(validate_project_slug(&at_limit).is_ok());
|
|
}
|
|
|
|
// ── validate_visibility ───────────────────────────────────────────────────
|
|
|
|
#[test]
|
|
fn validate_visibility_accepts_listed_and_unlisted() {
|
|
assert!(validate_visibility("listed").is_ok());
|
|
assert!(validate_visibility("unlisted").is_ok());
|
|
}
|
|
|
|
#[test]
|
|
fn validate_visibility_rejects_unknown_token() {
|
|
assert!(validate_visibility("chartreuse").is_err());
|
|
assert!(validate_visibility("").is_err());
|
|
}
|
|
|
|
// ── is_bare_repo_id ───────────────────────────────────────────────────────
|
|
|
|
#[test]
|
|
fn bare_repo_id_accepts_valid() {
|
|
assert!(is_bare_repo_id("buzz"));
|
|
assert!(is_bare_repo_id("my-repo_1.0"));
|
|
}
|
|
|
|
#[test]
|
|
fn bare_repo_id_rejects_colon() {
|
|
assert!(!is_bare_repo_id("30617:something"));
|
|
assert!(!is_bare_repo_id("has:colon"));
|
|
}
|
|
|
|
#[test]
|
|
fn bare_repo_id_rejects_empty() {
|
|
assert!(!is_bare_repo_id(""));
|
|
}
|
|
|
|
#[test]
|
|
fn bare_repo_id_rejects_over_64() {
|
|
let long = "a".repeat(65);
|
|
assert!(!is_bare_repo_id(&long));
|
|
}
|
|
|
|
// ── tag helpers ───────────────────────────────────────────────────────────
|
|
|
|
fn make_test_tag(parts: &[&str]) -> Tag {
|
|
Tag::parse(parts.iter().copied()).unwrap()
|
|
}
|
|
|
|
// ── rebuild_project: hinted / unknown tag preservation ───────────────────
|
|
|
|
#[test]
|
|
fn rebuild_project_preserves_hinted_member_tags() {
|
|
// A member 'a' tag with a relay hint must survive RMW untouched.
|
|
let coord = format!("30617:{OWNER_HEX}:buzz");
|
|
let hint = "wss://relay.example.com";
|
|
let tags = vec![
|
|
make_test_tag(&["d", "platform"]),
|
|
Tag::parse(["a", &coord, hint]).unwrap(),
|
|
];
|
|
let ts = Timestamp::from(1_700_000_001u64);
|
|
let b = rebuild_project("", tags, ts).unwrap();
|
|
let ev = b.sign_with_keys(&nostr::Keys::generate()).expect("sign");
|
|
let a_tag = ev
|
|
.tags
|
|
.iter()
|
|
.find(|t| tag_name(t) == Some("a"))
|
|
.expect("a tag present");
|
|
assert_eq!(
|
|
a_tag.as_slice(),
|
|
&["a".to_string(), coord, hint.to_string()],
|
|
"relay hint must survive rebuild"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn rebuild_project_preserves_unknown_tags() {
|
|
let tags = vec![
|
|
make_test_tag(&["d", "platform"]),
|
|
make_test_tag(&["future-metadata", "value"]),
|
|
];
|
|
let ts = Timestamp::from(1_700_000_001u64);
|
|
let b = rebuild_project("", tags, ts).unwrap();
|
|
let ev = b.sign_with_keys(&nostr::Keys::generate()).expect("sign");
|
|
assert!(ev
|
|
.tags
|
|
.iter()
|
|
.any(|t| tag_name(t) == Some("future-metadata")));
|
|
}
|
|
|
|
#[test]
|
|
fn rebuild_project_strips_auth_tag() {
|
|
let tags = vec![
|
|
make_test_tag(&["d", "platform"]),
|
|
make_test_tag(&["auth", &"a".repeat(64), "kind=30617", &"b".repeat(128)]),
|
|
];
|
|
let ts = Timestamp::from(1_700_000_001u64);
|
|
let b = rebuild_project("", tags, ts).unwrap();
|
|
let ev = b.sign_with_keys(&nostr::Keys::generate()).expect("sign");
|
|
assert!(
|
|
!ev.tags.iter().any(|t| tag_name(t) == Some("auth")),
|
|
"auth tag must be stripped"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn rebuild_project_rejects_over_cap_foreign_head() {
|
|
// A foreign head with 65 members must fail Layer A on republish.
|
|
let mut tags = vec![make_test_tag(&["d", "wide"])];
|
|
for i in 0..=64u32 {
|
|
let coord = format!("30617:{OWNER_HEX}:repo-{i:02}");
|
|
tags.push(make_test_tag(&["a", &coord]));
|
|
}
|
|
assert_eq!(
|
|
tags.iter().filter(|t| tag_name(t) == Some("a")).count(),
|
|
65,
|
|
"65 a-tags"
|
|
);
|
|
let ts = Timestamp::from(1_700_000_001u64);
|
|
// rebuild_project strips auth, but 65 a-tags still exceeds cap.
|
|
assert!(
|
|
rebuild_project("", tags, ts).is_err(),
|
|
"over-cap foreign head must fail rebuild"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn rebuild_project_at_exact_cap_succeeds() {
|
|
let mut tags = vec![make_test_tag(&["d", "wide"])];
|
|
for i in 0..PROJECT_MEMBER_CAP {
|
|
let coord = format!("30617:{OWNER_HEX}:repo-{i:02}");
|
|
tags.push(make_test_tag(&["a", &coord]));
|
|
}
|
|
let ts = Timestamp::from(1_700_000_001u64);
|
|
assert!(rebuild_project("", tags, ts).is_ok());
|
|
}
|
|
|
|
// ── clear-flag semantics ──────────────────────────────────────────────────
|
|
|
|
/// Build a minimal head Event for testing update semantics without the relay.
|
|
fn make_head_tags(extra: &[Tag]) -> Vec<Tag> {
|
|
let mut tags = vec![make_test_tag(&["d", "platform"])];
|
|
tags.extend_from_slice(extra);
|
|
tags
|
|
}
|
|
|
|
#[allow(clippy::too_many_arguments)]
|
|
fn apply_update_tags(
|
|
head_tags: Vec<Tag>,
|
|
name: Option<&str>,
|
|
clear_name: bool,
|
|
description: Option<&str>,
|
|
clear_description: bool,
|
|
channel: Option<&str>,
|
|
clear_channel: bool,
|
|
visibility: Option<&str>,
|
|
clear_visibility: bool,
|
|
) -> Vec<Tag> {
|
|
// Replicate the tag-mutation logic from cmd_update (sans relay I/O).
|
|
let singleton_fields = ["name", "description", "buzz-channel", "buzz-visibility"];
|
|
let mut tags: Vec<Tag> = head_tags
|
|
.iter()
|
|
.filter(|t| {
|
|
if tag_name(t) == Some("auth") {
|
|
return false;
|
|
}
|
|
if let Some(field) = tag_name(t) {
|
|
if singleton_fields.contains(&field) {
|
|
let clear = match field {
|
|
"name" => clear_name || name.is_some(),
|
|
"description" => clear_description || description.is_some(),
|
|
"buzz-channel" => clear_channel || channel.is_some(),
|
|
"buzz-visibility" => clear_visibility || visibility.is_some(),
|
|
_ => false,
|
|
};
|
|
return !clear;
|
|
}
|
|
}
|
|
true
|
|
})
|
|
.cloned()
|
|
.collect();
|
|
if let Some(n) = name {
|
|
tags.push(make_test_tag(&["name", n]));
|
|
}
|
|
if let Some(d) = description {
|
|
tags.push(make_test_tag(&["description", d]));
|
|
}
|
|
if let Some(ch) = channel {
|
|
tags.push(make_test_tag(&["buzz-channel", ch]));
|
|
}
|
|
if let Some(vis) = visibility {
|
|
tags.push(make_test_tag(&["buzz-visibility", vis]));
|
|
}
|
|
tags
|
|
}
|
|
|
|
#[test]
|
|
fn update_omission_preserves_existing_field() {
|
|
let head = make_head_tags(&[make_test_tag(&["name", "Old Name"])]);
|
|
let result = apply_update_tags(head, None, false, None, false, None, false, None, false);
|
|
assert!(result.iter().any(|t| tag_value(t) == Some("Old Name")));
|
|
}
|
|
|
|
#[test]
|
|
fn update_setter_replaces_existing_field() {
|
|
let head = make_head_tags(&[make_test_tag(&["name", "Old Name"])]);
|
|
let result = apply_update_tags(
|
|
head,
|
|
Some("New Name"),
|
|
false,
|
|
None,
|
|
false,
|
|
None,
|
|
false,
|
|
None,
|
|
false,
|
|
);
|
|
assert!(result.iter().any(|t| tag_value(t) == Some("New Name")));
|
|
assert!(!result.iter().any(|t| tag_value(t) == Some("Old Name")));
|
|
}
|
|
|
|
#[test]
|
|
fn update_clear_drops_existing_field() {
|
|
let head = make_head_tags(&[make_test_tag(&["name", "Old Name"])]);
|
|
let result = apply_update_tags(head, None, true, None, false, None, false, None, false);
|
|
assert!(!result.iter().any(|t| tag_name(t) == Some("name")));
|
|
}
|
|
|
|
#[test]
|
|
fn update_clear_visibility_drops_tag() {
|
|
let head = make_head_tags(&[make_test_tag(&["buzz-visibility", "unlisted"])]);
|
|
let result = apply_update_tags(head, None, false, None, false, None, false, None, true);
|
|
assert!(!result
|
|
.iter()
|
|
.any(|t| tag_name(t) == Some("buzz-visibility")));
|
|
}
|
|
|
|
#[test]
|
|
fn update_exactly_one_singleton_after_replace() {
|
|
// Start with a buzz-channel; replace with a new one; must have exactly one.
|
|
let uuid1 = "3580ca9b-47b4-4af9-b22a-1068778f26c6";
|
|
let uuid2 = "00000000-0000-0000-0000-000000000000";
|
|
let head = make_head_tags(&[make_test_tag(&["buzz-channel", uuid1])]);
|
|
let result = apply_update_tags(
|
|
head,
|
|
None,
|
|
false,
|
|
None,
|
|
false,
|
|
Some(uuid2),
|
|
false,
|
|
None,
|
|
false,
|
|
);
|
|
let channels: Vec<_> = result
|
|
.iter()
|
|
.filter(|t| tag_name(t) == Some("buzz-channel"))
|
|
.collect();
|
|
assert_eq!(channels.len(), 1);
|
|
assert_eq!(tag_value(channels[0]), Some(uuid2));
|
|
}
|
|
|
|
// ── duplicate-member rejection on republish ───────────────────────────────
|
|
|
|
#[test]
|
|
fn duplicate_member_in_foreign_head_fails_rebuild() {
|
|
let coord = format!("30617:{OWNER_HEX}:buzz");
|
|
let tags = vec![
|
|
make_test_tag(&["d", "platform"]),
|
|
make_test_tag(&["a", &coord]),
|
|
make_test_tag(&["a", &coord]), // duplicate
|
|
];
|
|
let ts = Timestamp::from(1_700_000_001u64);
|
|
assert!(rebuild_project("", tags, ts).is_err());
|
|
}
|
|
|
|
// ── validate_project_envelope integration ────────────────────────────────
|
|
|
|
#[test]
|
|
fn validate_project_envelope_accepts_hinted_member() {
|
|
let coord = format!("30617:{OWNER_HEX}:buzz");
|
|
let tags = vec![
|
|
make_test_tag(&["d", "platform"]),
|
|
Tag::parse(["a", &coord, "wss://relay.example.com"]).unwrap(),
|
|
];
|
|
assert!(validate_project_envelope(&tags, "").is_ok());
|
|
}
|
|
|
|
#[test]
|
|
fn validate_project_envelope_rejects_four_element_member() {
|
|
let coord = format!("30617:{OWNER_HEX}:buzz");
|
|
let tags = vec![
|
|
make_test_tag(&["d", "platform"]),
|
|
Tag::parse(["a", &coord, "wss://relay.example.com", "extra"]).unwrap(),
|
|
];
|
|
assert!(validate_project_envelope(&tags, "").is_err());
|
|
}
|
|
|
|
// ── next_timestamp ordering ───────────────────────────────────────────────
|
|
|
|
fn project_head_at(created_at: u64) -> Event {
|
|
let keys = nostr::Keys::generate();
|
|
let tags = vec![
|
|
make_test_tag(&["d", "platform"]),
|
|
make_test_tag(&["a", &format!("30617:{OWNER_HEX}:buzz")]),
|
|
];
|
|
rebuild_project("", tags, Timestamp::from(created_at))
|
|
.expect("valid head envelope")
|
|
.sign_with_keys(&keys)
|
|
.expect("sign")
|
|
}
|
|
|
|
#[test]
|
|
fn next_timestamp_uses_later_of_wall_clock_and_after_head() {
|
|
let cases = [
|
|
("stale head", 100, 1_000, 1_000),
|
|
("head equal to now", 1_000, 1_000, 1_001),
|
|
("future head", 1_500, 1_000, 1_501),
|
|
("last timestamp inside future boundary", 1_899, 1_000, 1_900),
|
|
(
|
|
"future boundary cannot be dominated inside the window",
|
|
1_900,
|
|
1_000,
|
|
1_901,
|
|
),
|
|
];
|
|
|
|
for (name, head_ts, now, expected) in cases {
|
|
let head = project_head_at(head_ts);
|
|
let next = next_timestamp(&head, Timestamp::from(now)).expect("no overflow");
|
|
|
|
assert_eq!(next.as_secs(), expected, "case: {name}");
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn next_timestamp_rejects_overflowing_head() {
|
|
let head = project_head_at(u64::MAX);
|
|
|
|
let err = next_timestamp(&head, Timestamp::from(1_000u64))
|
|
.expect_err("maximum timestamp cannot be advanced");
|
|
|
|
assert!(
|
|
matches!(err, CliError::Other(ref message) if message == "project timestamp cannot be advanced"),
|
|
"unexpected error: {err}"
|
|
);
|
|
}
|
|
|
|
// ── empty update guard ────────────────────────────────────────────────────
|
|
|
|
/// `cmd_update` with no setters or clearers must return `CliError::Usage`
|
|
/// before making any network call. The guard is synchronous (before the
|
|
/// first `.await`) so we can drive it with a dummy client whose address
|
|
/// would reject any real connection attempt.
|
|
#[tokio::test]
|
|
async fn empty_update_returns_usage_error_before_any_network_call() {
|
|
let keys = nostr::Keys::generate();
|
|
// Port 9 is the discard protocol — any real connect will be refused
|
|
// immediately, but the guard fires before the first await so this
|
|
// never reaches the network.
|
|
let client = crate::client::BuzzClient::new("http://127.0.0.1:9".into(), keys, None, None)
|
|
.expect("client construction");
|
|
|
|
let err = cmd_update(
|
|
&client, "my-slug", None, false, // name / clear_name
|
|
None, false, // description / clear_description
|
|
None, false, // channel / clear_channel
|
|
None, false, // visibility / clear_visibility
|
|
)
|
|
.await
|
|
.expect_err("empty update must fail");
|
|
|
|
assert!(
|
|
matches!(err, CliError::Usage(_)),
|
|
"expected CliError::Usage, got {err:?}"
|
|
);
|
|
}
|
|
|
|
// ── no-network malformed-input tests ─────────────────────────────────────
|
|
//
|
|
// All three cases use port 9 (discard protocol): any real connection is
|
|
// refused immediately, but local validation fires before the first .await
|
|
// so the network is never touched.
|
|
|
|
fn discard_client() -> crate::client::BuzzClient {
|
|
let keys = nostr::Keys::generate();
|
|
crate::client::BuzzClient::new("http://127.0.0.1:9".into(), keys, None, None)
|
|
.expect("client construction")
|
|
}
|
|
|
|
/// Creating without --repo or --channel must fail locally; the default
|
|
/// repository needs a home channel to bind as git ACL.
|
|
#[tokio::test]
|
|
async fn create_without_repo_or_channel_returns_usage_before_any_network_call() {
|
|
let client = discard_client();
|
|
let err = cmd_create(&client, "my-slug", &[], None, None, None, None)
|
|
.await
|
|
.expect_err("missing repo and channel must fail");
|
|
assert!(
|
|
matches!(err, CliError::Usage(_)),
|
|
"expected CliError::Usage, got {err:?}"
|
|
);
|
|
assert!(
|
|
format!("{err}").contains("--channel"),
|
|
"Usage message must mention --channel, got {err:?}"
|
|
);
|
|
}
|
|
|
|
/// Invalid visibility token must return Usage before touching the relay.
|
|
#[tokio::test]
|
|
async fn create_invalid_visibility_returns_usage_before_any_network_call() {
|
|
let client = discard_client();
|
|
let err = cmd_create(
|
|
&client,
|
|
"my-slug",
|
|
&["buzz".to_string()],
|
|
None,
|
|
None,
|
|
None,
|
|
Some("chartreuse"),
|
|
)
|
|
.await
|
|
.expect_err("invalid visibility must fail");
|
|
assert!(
|
|
matches!(err, CliError::Usage(_)),
|
|
"expected CliError::Usage for invalid visibility, got {err:?}"
|
|
);
|
|
}
|
|
|
|
/// A name longer than 256 bytes must return Usage before touching the relay.
|
|
#[tokio::test]
|
|
async fn create_overlong_name_returns_usage_before_any_network_call() {
|
|
let client = discard_client();
|
|
let long_name = "a".repeat(257);
|
|
let err = cmd_create(
|
|
&client,
|
|
"my-slug",
|
|
&["buzz".to_string()],
|
|
Some(&long_name),
|
|
None,
|
|
None,
|
|
None,
|
|
)
|
|
.await
|
|
.expect_err("overlong name must fail");
|
|
assert!(
|
|
matches!(err, CliError::Usage(_)),
|
|
"expected CliError::Usage for overlong name, got {err:?}"
|
|
);
|
|
}
|
|
|
|
/// A malformed --repo coordinate must return Usage before touching the relay.
|
|
#[tokio::test]
|
|
async fn create_malformed_repo_returns_usage_before_any_network_call() {
|
|
let client = discard_client();
|
|
let err = cmd_create(
|
|
&client,
|
|
"my-slug",
|
|
&["nope:bad".to_string()],
|
|
None,
|
|
None,
|
|
None,
|
|
None,
|
|
)
|
|
.await
|
|
.expect_err("malformed repo must fail");
|
|
assert!(
|
|
matches!(err, CliError::Usage(_)),
|
|
"expected CliError::Usage for malformed repo, got {err:?}"
|
|
);
|
|
}
|
|
|
|
/// A malformed --repo coordinate on add-repo must return Usage before touching the relay.
|
|
#[tokio::test]
|
|
async fn add_repo_malformed_coord_returns_usage_before_any_network_call() {
|
|
let client = discard_client();
|
|
let err = cmd_add_repo(&client, "my-slug", &["nope:bad".to_string()])
|
|
.await
|
|
.expect_err("malformed repo must fail");
|
|
assert!(
|
|
matches!(err, CliError::Usage(_)),
|
|
"expected CliError::Usage for malformed repo on add-repo, got {err:?}"
|
|
);
|
|
}
|
|
|
|
/// A malformed --repo coordinate on remove-repo must return Usage before touching the relay.
|
|
#[tokio::test]
|
|
async fn remove_repo_malformed_coord_returns_usage_before_any_network_call() {
|
|
let client = discard_client();
|
|
let err = cmd_remove_repo(&client, "my-slug", &["nope:bad".to_string()])
|
|
.await
|
|
.expect_err("malformed repo must fail");
|
|
assert!(
|
|
matches!(err, CliError::Usage(_)),
|
|
"expected CliError::Usage for malformed repo on remove-repo, got {err:?}"
|
|
);
|
|
}
|
|
|
|
// ── duplicate --repo within one invocation ────────────────────────────────
|
|
|
|
/// Supplying the same coordinate twice in one create call must return Usage
|
|
/// (names the duplicate) before any network call.
|
|
#[tokio::test]
|
|
async fn create_duplicate_repo_returns_usage_before_any_network_call() {
|
|
let client = discard_client();
|
|
let coord = format!("30617:{OWNER_HEX}:buzz");
|
|
let err = cmd_create(
|
|
&client,
|
|
"my-slug",
|
|
&[coord.clone(), coord.clone()],
|
|
None,
|
|
None,
|
|
None,
|
|
None,
|
|
)
|
|
.await
|
|
.expect_err("duplicate repo must fail");
|
|
assert!(
|
|
matches!(err, CliError::Usage(_)),
|
|
"expected CliError::Usage for duplicate repo, got {err:?}"
|
|
);
|
|
// Error message must name the duplicate coordinate.
|
|
assert!(
|
|
format!("{err}").contains("buzz"),
|
|
"Usage message must name the duplicate coordinate, got {err:?}"
|
|
);
|
|
}
|
|
|
|
/// Supplying the same coordinate twice in one add-repo call must return Usage
|
|
/// (names the duplicate) before any network call.
|
|
#[tokio::test]
|
|
async fn add_repo_duplicate_coord_returns_usage_before_any_network_call() {
|
|
let client = discard_client();
|
|
let coord = format!("30617:{OWNER_HEX}:buzz");
|
|
let err = cmd_add_repo(&client, "my-slug", &[coord.clone(), coord.clone()])
|
|
.await
|
|
.expect_err("duplicate repo must fail");
|
|
assert!(
|
|
matches!(err, CliError::Usage(_)),
|
|
"expected CliError::Usage for duplicate repo on add-repo, got {err:?}"
|
|
);
|
|
}
|
|
|
|
// ── create collision guard ────────────────────────────────────────────────
|
|
|
|
// The create-collision Conflict path is pinned by the live transcript
|
|
// (step: duplicate create → Conflict, exit=5). No relay mock is available
|
|
// for a unit test; the no-network tests above cover all pre-await paths.
|
|
|
|
// ── add-repo no-op guard ──────────────────────────────────────────────────
|
|
|
|
// The add-repo no-op Conflict path is pinned by the live transcript
|
|
// (step 7: buzz already present → exit=5). No relay mock is available
|
|
// for a unit test; the async no-network tests above cover all pre-await paths.
|
|
}
|