feat(projects): authorize home channel admins

Signed-off-by: John Tennant <jtennant@squareup.com>
This commit is contained in:
John Tennant
2026-08-31 15:49:10 -04:00
parent bc006f6708
commit eb18afaacd
28 changed files with 1628 additions and 116 deletions

View File

@@ -89,6 +89,9 @@ pub fn parse_write_response(raw: &str, conflict_msg: &str) -> Result<String, Cli
.and_then(serde_json::Value::as_str)
.unwrap_or("");
if !accepted {
if message.starts_with("conflict:") {
return Err(CliError::Conflict(conflict_msg.to_string()));
}
return Err(CliError::Other(format!("relay rejected event: {message}")));
}
if message == "duplicate" || message.starts_with("duplicate:") {

View File

@@ -17,9 +17,10 @@
//! not in scope.
use buzz_core::kind::KIND_PROJECT;
use buzz_core::project_revision::{ProjectCoordinate, ProjectRevision};
use buzz_sdk::{
build_delete_addressable, build_project, build_project_with_tags, ProjectMemberCoord,
PROJECT_D_MAX_LEN,
build_delete_addressable, build_project, build_project_revision, build_project_with_tags,
ProjectMemberCoord, PROJECT_D_MAX_LEN,
};
use nostr::{Event, EventBuilder, PublicKey, Tag, Timestamp};
@@ -278,6 +279,70 @@ async fn fetch_own_project(client: &BuzzClient, slug: &str) -> Result<Option<Eve
fetch_project(client, slug, None).await
}
async fn current_project_revision(
client: &BuzzClient,
project: &Event,
slug: &str,
) -> Result<String, CliError> {
let coordinate = ProjectCoordinate {
owner: project.pubkey.to_hex(),
slug: slug.to_owned(),
}
.as_string();
let filter = serde_json::json!({
"kinds": [buzz_core::kind::KIND_PROJECT_REVISION],
"#a": [coordinate],
});
let events: Vec<Event> = client
.query_all_bounded(filter, PROJECT_QUERY_EVENT_BOUND)
.await?
.into_iter()
.map(|event| {
serde_json::from_value(event).map_err(|error| {
CliError::Other(format!("failed to parse Project revision: {error}"))
})
})
.collect::<Result<_, _>>()?;
let mut current = project.id.to_hex();
loop {
let next = events.iter().find(|candidate| {
ProjectRevision::parse(candidate)
.is_ok_and(|revision| revision.expected_revision == current)
});
let Some(next) = next else { break };
current = next.id.to_hex();
}
Ok(current)
}
async fn cmd_related_channel_revision(
client: &BuzzClient,
slug: &str,
owner: Option<&str>,
channel: &str,
operation: buzz_core::project_revision::ProjectRevisionOperation,
) -> Result<(), CliError> {
validate_project_slug(slug)?;
let channel_id = crate::validate::parse_uuid(channel)?;
let project = fetch_project(client, slug, owner)
.await?
.ok_or_else(|| CliError::NotFound(format!("project {slug:?} not found")))?;
let expected_revision = current_project_revision(client, &project, slug).await?;
let coordinate = ProjectCoordinate {
owner: project.pubkey.to_hex(),
slug: slug.to_owned(),
}
.as_string();
let event = client.sign_event(
build_project_revision(&coordinate, &expected_revision, operation, channel_id)
.map_err(crate::validate::sdk_err)?,
)?;
let raw = client.submit_event(event).await?;
let response = parse_write_response(&raw, "Project changed concurrently; refresh and retry")?;
println!("{response}");
Ok(())
}
// ── Tag helpers ───────────────────────────────────────────────────────────────
fn tag_name(tag: &Tag) -> Option<&str> {
@@ -842,6 +907,34 @@ pub async fn dispatch(cmd: crate::ProjectsCmd, client: &BuzzClient) -> Result<()
)
.await
}
ProjectsCmd::LinkChannel {
slug,
owner,
channel,
} => {
cmd_related_channel_revision(
client,
&slug,
owner.as_deref(),
&channel,
buzz_core::project_revision::ProjectRevisionOperation::AddRelatedChannel,
)
.await
}
ProjectsCmd::UnlinkChannel {
slug,
owner,
channel,
} => {
cmd_related_channel_revision(
client,
&slug,
owner.as_deref(),
&channel,
buzz_core::project_revision::ProjectRevisionOperation::RemoveRelatedChannel,
)
.await
}
ProjectsCmd::RemoveRepo { slug, repo } => cmd_remove_repo(client, &slug, &repo).await,
ProjectsCmd::Update {
slug,

View File

@@ -1356,6 +1356,30 @@ pub enum ProjectsCmd {
#[arg(long)]
template: Option<String>,
},
/// Link an existing channel to a Project using collaborative permissions
#[command(name = "link-channel")]
LinkChannel {
/// Project slug
slug: String,
/// Project owner pubkey. Defaults to the current identity.
#[arg(long)]
owner: Option<String>,
/// Existing channel UUID to link
#[arg(long)]
channel: String,
},
/// Remove an existing related channel from a Project
#[command(name = "unlink-channel")]
UnlinkChannel {
/// Project slug
slug: String,
/// Project owner pubkey. Defaults to the current identity.
#[arg(long)]
owner: Option<String>,
/// Related channel UUID to unlink
#[arg(long)]
channel: String,
},
/// Remove one or more member repositories from a project
#[command(name = "remove-repo")]
RemoveRepo {