diff --git a/crates/buzz-cli/src/commands/mod.rs b/crates/buzz-cli/src/commands/mod.rs index 8bb24218eb..515cea7554 100644 --- a/crates/buzz-cli/src/commands/mod.rs +++ b/crates/buzz-cli/src/commands/mod.rs @@ -89,6 +89,9 @@ pub fn parse_write_response(raw: &str, conflict_msg: &str) -> Result Result Result { + 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 = 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::>()?; + 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, diff --git a/crates/buzz-cli/src/lib.rs b/crates/buzz-cli/src/lib.rs index d0155970fa..ef635ac440 100644 --- a/crates/buzz-cli/src/lib.rs +++ b/crates/buzz-cli/src/lib.rs @@ -1356,6 +1356,30 @@ pub enum ProjectsCmd { #[arg(long)] template: Option, }, + /// 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, + /// 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, + /// Related channel UUID to unlink + #[arg(long)] + channel: String, + }, /// Remove one or more member repositories from a project #[command(name = "remove-repo")] RemoveRepo { diff --git a/crates/buzz-core/src/kind.rs b/crates/buzz-core/src/kind.rs index 3c6f1d5913..3cc21ed878 100644 --- a/crates/buzz-core/src/kind.rs +++ b/crates/buzz-core/src/kind.rs @@ -613,6 +613,13 @@ pub const KIND_GIT_PULL_REQUEST: u32 = 1618; pub const KIND_GIT_PR_UPDATE: u32 = 1619; /// NIP-34: Issue. pub const KIND_GIT_ISSUE: u32 = 1621; +/// NIP-MP: actor-signed, relay-authorized mutation of a Project container. +/// +/// Project revisions are regular events. They reference the stable +/// `kind:30621` Project coordinate and the exact effective revision they were +/// based on, allowing the relay to authorize collaborative writes without +/// forging a replacement under the Project owner's key. +pub const KIND_PROJECT_REVISION: u32 = 1622; /// NIP-34: Status — Open. pub const KIND_GIT_STATUS_OPEN: u32 = 1630; /// NIP-34: Status — Applied / Merged. @@ -758,6 +765,7 @@ pub const ALL_KINDS: &[u32] = &[ KIND_GIT_PULL_REQUEST, KIND_GIT_PR_UPDATE, KIND_GIT_ISSUE, + KIND_PROJECT_REVISION, KIND_GIT_STATUS_OPEN, KIND_GIT_STATUS_MERGED, KIND_GIT_STATUS_CLOSED, @@ -822,6 +830,7 @@ pub const fn is_command_kind(kind: u32) -> bool { | KIND_WORKFLOW_TRIGGER | KIND_APPROVAL_GRANT | KIND_APPROVAL_DENY + | KIND_PROJECT_REVISION ) } diff --git a/crates/buzz-core/src/lib.rs b/crates/buzz-core/src/lib.rs index 36dc772da3..af96455ee4 100644 --- a/crates/buzz-core/src/lib.rs +++ b/crates/buzz-core/src/lib.rs @@ -36,6 +36,7 @@ pub mod pairing; pub mod presence; /// NIP-PMA owner-encrypted private managed-agent wire codec. pub mod private_managed_agent; +pub mod project_revision; /// Canonical relay runtime identities. pub mod relay; /// Tenant identity — the server-resolved community key carried on scoped paths. diff --git a/crates/buzz-core/src/project_revision.rs b/crates/buzz-core/src/project_revision.rs new file mode 100644 index 0000000000..2ef4c7d1ab --- /dev/null +++ b/crates/buzz-core/src/project_revision.rs @@ -0,0 +1,300 @@ +//! NIP-MP collaborative Project revision primitives. + +use nostr::Event; +use thiserror::Error; +use uuid::Uuid; + +use crate::kind::{KIND_PROJECT, KIND_PROJECT_REVISION}; + +/// Mutation encoded by a [`KIND_PROJECT_REVISION`] event. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum ProjectRevisionOperation { + /// Add one extra channel to the Project. + AddRelatedChannel, + /// Remove one extra channel from the Project. + RemoveRelatedChannel, +} + +impl ProjectRevisionOperation { + /// Canonical wire token. + #[must_use] + pub const fn as_str(self) -> &'static str { + match self { + Self::AddRelatedChannel => "add-related-channel", + Self::RemoveRelatedChannel => "remove-related-channel", + } + } + + fn parse(value: &str) -> Result { + match value { + "add-related-channel" => Ok(Self::AddRelatedChannel), + "remove-related-channel" => Ok(Self::RemoveRelatedChannel), + _ => Err(ProjectRevisionError::InvalidOperation), + } + } +} + +/// A parsed Project coordinate. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ProjectCoordinate { + /// Project owner/signer as lowercase hex. + pub owner: String, + /// Project `d` tag, preserved verbatim. + pub slug: String, +} + +impl ProjectCoordinate { + /// Parse the exact `30621::` form. + pub fn parse(value: &str) -> Result { + let mut parts = value.splitn(3, ':'); + if parts.next() != Some("30621") { + return Err(ProjectRevisionError::InvalidProjectCoordinate); + } + let owner = parts + .next() + .filter(|value| { + value.len() == 64 + && value + .bytes() + .all(|byte| byte.is_ascii_hexdigit() && !byte.is_ascii_uppercase()) + }) + .ok_or(ProjectRevisionError::InvalidProjectCoordinate)?; + let slug = parts + .next() + .filter(|value| !value.is_empty()) + .ok_or(ProjectRevisionError::InvalidProjectCoordinate)?; + Ok(Self { + owner: owner.to_owned(), + slug: slug.to_owned(), + }) + } + + /// Return the canonical coordinate. + #[must_use] + pub fn as_string(&self) -> String { + format!("{KIND_PROJECT}:{}:{}", self.owner, self.slug) + } +} + +/// Validated contents of a Project revision event. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ProjectRevision { + /// Stable Project coordinate. + pub project: ProjectCoordinate, + /// Exact base or prior revision event id. + pub expected_revision: String, + /// Requested mutation. + pub operation: ProjectRevisionOperation, + /// Related channel being changed. + pub channel_id: Uuid, +} + +/// Project revision envelope error. +#[derive(Clone, Debug, Error, Eq, PartialEq)] +pub enum ProjectRevisionError { + /// Wrong event kind. + #[error("event is not a Project revision")] + WrongKind, + /// A required singleton tag was missing or repeated. + #[error("Project revision requires exactly one {0} tag")] + TagCardinality(&'static str), + /// Project coordinate is malformed. + #[error("invalid Project coordinate")] + InvalidProjectCoordinate, + /// Revision event id is malformed. + #[error("invalid expected Project revision")] + InvalidExpectedRevision, + /// Operation is unsupported. + #[error("invalid Project revision operation")] + InvalidOperation, + /// Channel id is malformed. + #[error("invalid related channel id")] + InvalidChannel, + /// Content must be empty. + #[error("Project revision content must be empty")] + NonEmptyContent, +} + +fn singleton_tag<'a>( + event: &'a Event, + name: &'static str, +) -> Result<&'a str, ProjectRevisionError> { + let values: Vec<&str> = event + .tags + .iter() + .filter_map(|tag| match tag.as_slice() { + [tag_name, value] if tag_name == name => Some(value.as_str()), + _ => None, + }) + .collect(); + match values.as_slice() { + [value] => Ok(value), + _ => Err(ProjectRevisionError::TagCardinality(name)), + } +} + +impl ProjectRevision { + /// Parse and validate a signed revision envelope. + pub fn parse(event: &Event) -> Result { + if u32::from(event.kind.as_u16()) != KIND_PROJECT_REVISION { + return Err(ProjectRevisionError::WrongKind); + } + if !event.content.is_empty() { + return Err(ProjectRevisionError::NonEmptyContent); + } + let project = ProjectCoordinate::parse(singleton_tag(event, "a")?)?; + let expected_revision = singleton_tag(event, "e")?; + if expected_revision.len() != 64 + || !expected_revision + .bytes() + .all(|byte| byte.is_ascii_hexdigit()) + { + return Err(ProjectRevisionError::InvalidExpectedRevision); + } + let operation = ProjectRevisionOperation::parse(singleton_tag(event, "op")?)?; + let channel_id = singleton_tag(event, "channel")? + .parse() + .map_err(|_| ProjectRevisionError::InvalidChannel)?; + Ok(Self { + project, + expected_revision: expected_revision.to_ascii_lowercase(), + operation, + channel_id, + }) + } +} + +/// Apply a revision to a set of related channels. +pub fn apply_project_revision( + channels: &mut Vec, + home_channel: Option, + operation: ProjectRevisionOperation, + channel_id: Uuid, +) -> Result<(), &'static str> { + if home_channel == Some(channel_id) { + return Err("the Project home channel cannot also be related"); + } + match operation { + ProjectRevisionOperation::AddRelatedChannel => { + if channels.contains(&channel_id) { + return Err("channel is already related to the Project"); + } + if channels.len() >= 64 { + return Err("Project already has 64 related channels"); + } + channels.push(channel_id); + } + ProjectRevisionOperation::RemoveRelatedChannel => { + let Some(index) = channels + .iter() + .position(|candidate| *candidate == channel_id) + else { + return Err("channel is not related to the Project"); + }; + channels.remove(index); + } + } + Ok(()) +} + +/// Whether an actor may manage a Project under the home-channel rule. +/// +/// The Project signer is always authorized. Other actors require an active +/// `owner` or `admin` role in the current, resolvable home channel. +#[must_use] +pub fn can_manage_project(actor: &[u8], project_owner: &[u8], channel_role: Option<&str>) -> bool { + actor == project_owner || matches!(channel_role, Some("owner" | "admin")) +} + +#[cfg(test)] +mod tests { + use nostr::{EventBuilder, Keys, Kind, Tag}; + + use super::*; + + fn revision_event(operation: &str) -> Event { + let keys = Keys::generate(); + let owner = "a".repeat(64); + EventBuilder::new(Kind::Custom(KIND_PROJECT_REVISION as u16), "") + .tags([ + Tag::parse(vec!["a".to_owned(), format!("30621:{owner}:buzz")]).unwrap(), + Tag::parse(vec!["e".to_owned(), "b".repeat(64)]).unwrap(), + Tag::parse(vec!["op".to_owned(), operation.to_owned()]).unwrap(), + Tag::parse(vec![ + "channel".to_owned(), + "11111111-1111-4111-8111-111111111111".to_owned(), + ]) + .unwrap(), + ]) + .sign_with_keys(&keys) + .unwrap() + } + + #[test] + fn parses_canonical_revision() { + let parsed = ProjectRevision::parse(&revision_event("add-related-channel")).unwrap(); + assert_eq!(parsed.project.slug, "buzz"); + assert_eq!( + parsed.operation, + ProjectRevisionOperation::AddRelatedChannel + ); + } + + #[test] + fn rejects_ambiguous_or_unknown_envelopes() { + let mut duplicate = revision_event("add-related-channel"); + duplicate + .tags + .push(Tag::parse(["op", "remove-related-channel"]).unwrap()); + assert_eq!( + ProjectRevision::parse(&duplicate), + Err(ProjectRevisionError::TagCardinality("op")) + ); + assert_eq!( + ProjectRevision::parse(&revision_event("replace-all")), + Err(ProjectRevisionError::InvalidOperation) + ); + } + + #[test] + fn add_remove_and_home_channel_guards() { + let home = Uuid::parse_str("11111111-1111-4111-8111-111111111111").unwrap(); + let related = Uuid::parse_str("22222222-2222-4222-8222-222222222222").unwrap(); + let mut channels = Vec::new(); + apply_project_revision( + &mut channels, + Some(home), + ProjectRevisionOperation::AddRelatedChannel, + related, + ) + .unwrap(); + assert_eq!(channels, vec![related]); + assert!(apply_project_revision( + &mut channels, + Some(home), + ProjectRevisionOperation::AddRelatedChannel, + home, + ) + .is_err()); + apply_project_revision( + &mut channels, + Some(home), + ProjectRevisionOperation::RemoveRelatedChannel, + related, + ) + .unwrap(); + assert!(channels.is_empty()); + } + + #[test] + fn only_owner_and_home_channel_admin_roles_manage_projects() { + let owner = [1_u8; 32]; + let actor = [2_u8; 32]; + assert!(can_manage_project(&owner, &owner, None)); + assert!(can_manage_project(&actor, &owner, Some("owner"))); + assert!(can_manage_project(&actor, &owner, Some("admin"))); + for role in [Some("member"), Some("guest"), Some("bot"), None] { + assert!(!can_manage_project(&actor, &owner, role)); + } + } +} diff --git a/crates/buzz-db/src/lib.rs b/crates/buzz-db/src/lib.rs index ea81bc354b..ec0f25103d 100644 --- a/crates/buzz-db/src/lib.rs +++ b/crates/buzz-db/src/lib.rs @@ -36,9 +36,9 @@ pub(crate) use runtime::{ }; pub use store::{ admin_moderation, allowlist, api_token, archived_identities, channel, channel_members, - community, deletion, dm, event, feed, git_repo, moderation, partition, product_feedback, push, - reaction, relay_admin_actions, relay_invite, relay_members, relay_operators, reminder, - replaceable, thread, usage, user, workflow, + community, deletion, dm, event, feed, git_repo, moderation, partition, product_feedback, + project_revision, push, reaction, relay_admin_actions, relay_invite, relay_members, + relay_operators, reminder, replaceable, thread, usage, user, workflow, }; pub use allowlist::AllowlistEntry; diff --git a/crates/buzz-db/src/runtime/migration.rs b/crates/buzz-db/src/runtime/migration.rs index 00cd81c694..1b4ea27449 100644 --- a/crates/buzz-db/src/runtime/migration.rs +++ b/crates/buzz-db/src/runtime/migration.rs @@ -1241,6 +1241,13 @@ mod tests { operator_audit.contains("_operator_global_tables"), "migration 39 must register relay_operator_audit in _operator_global_tables" ); + + assert_eq!(migrations[39].version, 40); + let project_revisions = migrations[39].sql.as_str(); + assert!( + project_revisions.contains("CREATE TABLE project_revision_heads"), + "migration 40 must create project_revision_heads" + ); } #[test] @@ -2207,14 +2214,15 @@ mod tests { .await .expect("connect migrated probe database"); MIGRATOR - .run_to(39, &migrated) + .run_to(40, &migrated) .await - .expect("apply migrations 1-39"); + .expect("apply migrations 1-40"); for table in [ "relay_admin_actions", "relay_admin_outbox", "relay_operator_audit", + "project_revision_heads", ] { assert_eq!( columns(&desired, table).await, diff --git a/crates/buzz-db/src/store/mod.rs b/crates/buzz-db/src/store/mod.rs index 1fa1273eb0..0c567c2592 100644 --- a/crates/buzz-db/src/store/mod.rs +++ b/crates/buzz-db/src/store/mod.rs @@ -30,6 +30,8 @@ pub mod moderation; pub mod partition; /// Buzz product-feedback sidecar persistence. pub mod product_feedback; +/// Collaborative Project revision compare-and-swap state. +pub mod project_revision; /// Community-scoped push lease and durable wake-outbox persistence. pub mod push; /// Reaction persistence. diff --git a/crates/buzz-db/src/store/project_revision.rs b/crates/buzz-db/src/store/project_revision.rs new file mode 100644 index 0000000000..702e8f375a --- /dev/null +++ b/crates/buzz-db/src/store/project_revision.rs @@ -0,0 +1,487 @@ +//! Transactional persistence for collaborative Project revisions. + +use buzz_core::kind::KIND_PROJECT; +use buzz_core::project_revision::{apply_project_revision, can_manage_project, ProjectRevision}; +use buzz_core::CommunityId; +use chrono::{DateTime, Utc}; +use nostr::Event; +use sqlx::Row; +use uuid::Uuid; + +use crate::event::insert_event_in_transaction; +use crate::replaceable::event_replacement_lock_key; +use crate::{Db, DbError, Result}; + +/// Outcome of applying a Project revision command. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum ProjectRevisionApplyStatus { + /// The operation was authorized, current, and persisted. + Applied, + /// This exact signed command was already applied. + Duplicate, + /// The Project coordinate has no live base event. + ProjectNotFound, + /// The expected revision is not the current effective revision. + Conflict, + /// The signer is neither the Project owner nor a home-channel owner/admin. + Forbidden, + /// The requested add/remove is not valid for current effective state. + InvalidMutation, +} + +/// Result of a Project revision attempt. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ProjectRevisionApplyResult { + /// Outcome category. + pub status: ProjectRevisionApplyStatus, + /// Human-readable detail suitable for a relay rejection. + pub message: Option, +} + +impl ProjectRevisionApplyResult { + fn status(status: ProjectRevisionApplyStatus) -> Self { + Self { + status, + message: None, + } + } + + fn invalid(message: impl Into) -> Self { + Self { + status: ProjectRevisionApplyStatus::InvalidMutation, + message: Some(message.into()), + } + } +} + +fn tag_values(tags: &serde_json::Value, name: &str) -> Vec { + tags.as_array() + .into_iter() + .flatten() + .filter_map(|tag| { + let parts = tag.as_array()?; + (parts.first()?.as_str()? == name) + .then(|| parts.get(1)?.as_str().map(ToOwned::to_owned))? + }) + .collect() +} + +fn home_channel(tags: &serde_json::Value) -> Option { + let values = tag_values(tags, "buzz-channel"); + match values.as_slice() { + [value] => value.parse().ok(), + _ => None, + } +} + +fn base_related_channels(tags: &serde_json::Value, home: Option) -> Vec { + let mut channels = Vec::new(); + for value in tag_values(tags, "buzz-related-channel") { + if let Ok(channel) = value.parse() { + if Some(channel) != home && !channels.contains(&channel) && channels.len() < 64 { + channels.push(channel); + } + } + } + channels +} + +impl Db { + /// Authorize and atomically apply one actor-signed Project revision. + pub async fn apply_project_revision( + &self, + community_id: CommunityId, + event: &Event, + revision: &ProjectRevision, + ) -> Result { + let owner = hex::decode(&revision.project.owner) + .map_err(|error| DbError::InvalidData(format!("invalid Project owner: {error}")))?; + let actor = event.pubkey.to_bytes(); + let expected = hex::decode(&revision.expected_revision) + .map_err(|error| DbError::InvalidData(format!("invalid Project revision: {error}")))?; + let mut tx = self.begin_transaction().await?; + + // Serialize with direct owner replacements of the kind:30621 base. + let lock_key = event_replacement_lock_key( + community_id, + KIND_PROJECT as i32, + &owner, + Some(revision.project.slug.as_bytes()), + ); + sqlx::query("SELECT pg_advisory_xact_lock($1)") + .bind(lock_key) + .execute(&mut *tx) + .await?; + + // Command retries are idempotent even after the effective head has + // advanced beyond this event's expected revision. + let already_applied = sqlx::query_scalar::<_, bool>( + "SELECT EXISTS(SELECT 1 FROM events WHERE community_id=$1 AND id=$2)", + ) + .bind(community_id.as_uuid()) + .bind(event.id.as_bytes().as_slice()) + .fetch_one(&mut *tx) + .await?; + if already_applied { + tx.rollback().await?; + return Ok(ProjectRevisionApplyResult::status( + ProjectRevisionApplyStatus::Duplicate, + )); + } + + let base = sqlx::query( + "SELECT id, tags FROM events \ + WHERE community_id=$1 AND kind=$2 AND pubkey=$3 AND d_tag=$4 \ + AND deleted_at IS NULL \ + ORDER BY created_at DESC, id ASC LIMIT 1", + ) + .bind(community_id.as_uuid()) + .bind(KIND_PROJECT as i32) + .bind(&owner) + .bind(&revision.project.slug) + .fetch_optional(&mut *tx) + .await?; + let Some(base) = base else { + tx.rollback().await?; + return Ok(ProjectRevisionApplyResult::status( + ProjectRevisionApplyStatus::ProjectNotFound, + )); + }; + let base_id: Vec = base.try_get("id")?; + let tags: serde_json::Value = base.try_get("tags")?; + let home = home_channel(&tags); + + let role = if actor.as_slice() == owner.as_slice() { + None + } else if let Some(home_channel_id) = home { + sqlx::query_scalar::<_, String>( + "SELECT cm.role::text FROM channel_members cm \ + JOIN channels c ON c.community_id=cm.community_id AND c.id=cm.channel_id \ + WHERE cm.community_id=$1 AND cm.channel_id=$2 AND cm.pubkey=$3 \ + AND cm.removed_at IS NULL AND c.deleted_at IS NULL", + ) + .bind(community_id.as_uuid()) + .bind(home_channel_id) + .bind(actor.as_slice()) + .fetch_optional(&mut *tx) + .await? + } else { + None + }; + if !can_manage_project(actor.as_slice(), &owner, role.as_deref()) { + tx.rollback().await?; + return Ok(ProjectRevisionApplyResult::status( + ProjectRevisionApplyStatus::Forbidden, + )); + } + + let materialized = sqlx::query( + "SELECT base_event_id, revision_event_id, related_channel_ids \ + FROM project_revision_heads \ + WHERE community_id=$1 AND project_owner=$2 AND project_d_tag=$3", + ) + .bind(community_id.as_uuid()) + .bind(&owner) + .bind(&revision.project.slug) + .fetch_optional(&mut *tx) + .await?; + + let (current_revision, mut channels) = match materialized { + Some(row) if row.try_get::, _>("base_event_id")? == base_id => ( + row.try_get::, _>("revision_event_id")?, + row.try_get::, _>("related_channel_ids")?, + ), + _ => (base_id.clone(), base_related_channels(&tags, home)), + }; + if expected != current_revision { + tx.rollback().await?; + return Ok(ProjectRevisionApplyResult::status( + ProjectRevisionApplyStatus::Conflict, + )); + } + + if let Err(message) = + apply_project_revision(&mut channels, home, revision.operation, revision.channel_id) + { + tx.rollback().await?; + return Ok(ProjectRevisionApplyResult::invalid(message)); + } + + let (_, inserted) = insert_event_in_transaction(&mut tx, community_id, event, None).await?; + if !inserted { + tx.rollback().await?; + return Ok(ProjectRevisionApplyResult::status( + ProjectRevisionApplyStatus::Duplicate, + )); + } + + sqlx::query( + "INSERT INTO project_revision_heads \ + (community_id, project_owner, project_d_tag, base_event_id, revision_event_id, related_channel_ids, updated_at) \ + VALUES ($1,$2,$3,$4,$5,$6,$7) \ + ON CONFLICT (community_id, project_owner, project_d_tag) DO UPDATE SET \ + base_event_id=EXCLUDED.base_event_id, revision_event_id=EXCLUDED.revision_event_id, \ + related_channel_ids=EXCLUDED.related_channel_ids, updated_at=EXCLUDED.updated_at", + ) + .bind(community_id.as_uuid()) + .bind(&owner) + .bind(&revision.project.slug) + .bind(&base_id) + .bind(event.id.as_bytes().as_slice()) + .bind(&channels) + .bind(DateTime::::from(Utc::now())) + .execute(&mut *tx) + .await?; + tx.commit().await?; + Ok(ProjectRevisionApplyResult::status( + ProjectRevisionApplyStatus::Applied, + )) + } +} + +#[cfg(test)] +mod tests { + use buzz_core::channel::{ChannelType, ChannelVisibility}; + use buzz_core::kind::KIND_PROJECT_REVISION; + use buzz_core::project_revision::{ProjectCoordinate, ProjectRevisionOperation}; + use nostr::{EventBuilder, Keys, Kind, Tag}; + use sqlx::postgres::PgPoolOptions; + + use super::*; + + const TEST_DB_URL: &str = "postgres://buzz:buzz_dev@localhost:5432/buzz"; // sadscan:disable np.postgres.1 -- local test-only credentials + + fn project_event(owner: &Keys, slug: &str, home: Option<&str>) -> Event { + let mut tags = vec![Tag::parse(["d", slug]).unwrap()]; + if let Some(home) = home { + tags.push(Tag::parse(["buzz-channel", home]).unwrap()); + } + EventBuilder::new(Kind::Custom(KIND_PROJECT as u16), "") + .tags(tags) + .sign_with_keys(owner) + .unwrap() + } + + fn revision_event( + actor: &Keys, + project: &ProjectCoordinate, + expected: &str, + operation: ProjectRevisionOperation, + channel: Uuid, + ) -> Event { + EventBuilder::new(Kind::Custom(KIND_PROJECT_REVISION as u16), "") + .tags([ + Tag::parse(vec!["a".to_owned(), project.as_string()]).unwrap(), + Tag::parse(vec!["e".to_owned(), expected.to_owned()]).unwrap(), + Tag::parse(vec!["op".to_owned(), operation.as_str().to_owned()]).unwrap(), + Tag::parse(vec!["channel".to_owned(), channel.to_string()]).unwrap(), + ]) + .sign_with_keys(actor) + .unwrap() + } + + #[test] + fn malformed_or_deleted_home_channel_is_unresolvable() { + assert_eq!(home_channel(&serde_json::json!([])), None); + assert_eq!( + home_channel(&serde_json::json!([["buzz-channel", "not-a-uuid"]])), + None + ); + assert_eq!( + home_channel(&serde_json::json!([ + ["buzz-channel", "11111111-1111-4111-8111-111111111111"], + ["buzz-channel", "22222222-2222-4222-8222-222222222222"] + ])), + None + ); + } + + #[test] + fn base_channels_are_bounded_deduplicated_and_exclude_home() { + let home = Uuid::parse_str("11111111-1111-4111-8111-111111111111").unwrap(); + let related = Uuid::parse_str("22222222-2222-4222-8222-222222222222").unwrap(); + let tags = serde_json::json!([ + ["buzz-related-channel", home.to_string()], + ["buzz-related-channel", related.to_string()], + ["buzz-related-channel", related.to_string()], + ["buzz-related-channel", "bad"] + ]); + assert_eq!(base_related_channels(&tags, Some(home)), vec![related]); + } + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn project_revision_authorization_cas_and_attribution() { + let database_url = + std::env::var("TEST_DATABASE_URL").unwrap_or_else(|_| TEST_DB_URL.into()); + let pool = PgPoolOptions::new() + .max_connections(5) + .connect(&database_url) + .await + .expect("connect to test DB"); + crate::migration::run_migrations(&pool) + .await + .expect("run migrations"); + let db = Db::from_pool(pool.clone()); + let community = CommunityId::from_uuid(Uuid::new_v4()); + sqlx::query("INSERT INTO communities (id, host) VALUES ($1,$2)") + .bind(community.as_uuid()) + .bind(format!("project-revision-{}.example", community.as_uuid())) + .execute(&pool) + .await + .unwrap(); + + let owner = Keys::generate(); + let home_owner = Keys::generate(); + let admin = Keys::generate(); + let member = Keys::generate(); + let guest = Keys::generate(); + let bot = Keys::generate(); + let unrelated = Keys::generate(); + let home = Uuid::new_v4(); + let related_a = Uuid::new_v4(); + let related_b = Uuid::new_v4(); + crate::channel::create_channel_with_id( + &pool, + community, + home, + "project-home", + ChannelType::Stream, + ChannelVisibility::Open, + None, + home_owner.public_key().to_bytes().as_slice(), + None, + ) + .await + .unwrap(); + for (keys, role) in [ + (&admin, "admin"), + (&member, "member"), + (&guest, "guest"), + (&bot, "bot"), + ] { + sqlx::query( + "INSERT INTO channel_members (community_id,channel_id,pubkey,role,invited_by) \ + VALUES ($1,$2,$3,$4::member_role,$5)", + ) + .bind(community.as_uuid()) + .bind(home) + .bind(keys.public_key().to_bytes().as_slice()) + .bind(role) + .bind(home_owner.public_key().to_bytes().as_slice()) + .execute(&pool) + .await + .unwrap(); + } + + let base = project_event(&owner, "shared", Some(&home.to_string())); + db.insert_event(community, &base, None).await.unwrap(); + let coordinate = ProjectCoordinate { + owner: owner.public_key().to_hex(), + slug: "shared".into(), + }; + + let add = revision_event( + &admin, + &coordinate, + &base.id.to_hex(), + ProjectRevisionOperation::AddRelatedChannel, + related_a, + ); + let parsed = ProjectRevision::parse(&add).unwrap(); + assert_eq!( + db.apply_project_revision(community, &add, &parsed) + .await + .unwrap() + .status, + ProjectRevisionApplyStatus::Applied + ); + assert_eq!( + db.apply_project_revision(community, &add, &parsed) + .await + .unwrap() + .status, + ProjectRevisionApplyStatus::Duplicate + ); + + for actor in [&member, &guest, &bot, &unrelated] { + let denied = revision_event( + actor, + &coordinate, + &add.id.to_hex(), + ProjectRevisionOperation::AddRelatedChannel, + related_b, + ); + let parsed = ProjectRevision::parse(&denied).unwrap(); + assert_eq!( + db.apply_project_revision(community, &denied, &parsed) + .await + .unwrap() + .status, + ProjectRevisionApplyStatus::Forbidden + ); + } + + let stale = revision_event( + &home_owner, + &coordinate, + &base.id.to_hex(), + ProjectRevisionOperation::AddRelatedChannel, + related_b, + ); + let parsed = ProjectRevision::parse(&stale).unwrap(); + assert_eq!( + db.apply_project_revision(community, &stale, &parsed) + .await + .unwrap() + .status, + ProjectRevisionApplyStatus::Conflict + ); + + let remove = revision_event( + &owner, + &coordinate, + &add.id.to_hex(), + ProjectRevisionOperation::RemoveRelatedChannel, + related_a, + ); + let parsed = ProjectRevision::parse(&remove).unwrap(); + assert_eq!( + db.apply_project_revision(community, &remove, &parsed) + .await + .unwrap() + .status, + ProjectRevisionApplyStatus::Applied + ); + let stored_actor: Vec = + sqlx::query_scalar("SELECT pubkey FROM events WHERE community_id=$1 AND id=$2") + .bind(community.as_uuid()) + .bind(add.id.as_bytes().as_slice()) + .fetch_one(&pool) + .await + .unwrap(); + assert_eq!(stored_actor, admin.public_key().to_bytes()); + + sqlx::query("UPDATE channels SET deleted_at=NOW() WHERE community_id=$1 AND id=$2") + .bind(community.as_uuid()) + .bind(home) + .execute(&pool) + .await + .unwrap(); + let after_delete = revision_event( + &admin, + &coordinate, + &remove.id.to_hex(), + ProjectRevisionOperation::AddRelatedChannel, + related_b, + ); + let parsed = ProjectRevision::parse(&after_delete).unwrap(); + assert_eq!( + db.apply_project_revision(community, &after_delete, &parsed) + .await + .unwrap() + .status, + ProjectRevisionApplyStatus::Forbidden + ); + } +} diff --git a/crates/buzz-relay/src/handlers/command_executor.rs b/crates/buzz-relay/src/handlers/command_executor.rs index ae7adc9814..9bd1a16cd7 100644 --- a/crates/buzz-relay/src/handlers/command_executor.rs +++ b/crates/buzz-relay/src/handlers/command_executor.rs @@ -70,12 +70,56 @@ pub async fn handle_command( KIND_WORKFLOW_TRIGGER => handle_workflow_trigger(tenant, state, &event, &auth).await, KIND_APPROVAL_GRANT => handle_approval_grant(tenant, state, &event, &auth).await, KIND_APPROVAL_DENY => handle_approval_deny(tenant, state, &event, &auth).await, + KIND_PROJECT_REVISION => handle_project_revision(tenant, state, &event).await, _ => Err(IngestError::Rejected(format!( "unknown command kind: {kind}" ))), } } +async fn handle_project_revision( + tenant: &TenantContext, + state: &Arc, + event: &Event, +) -> Result { + use buzz_core::project_revision::ProjectRevision; + use buzz_db::project_revision::ProjectRevisionApplyStatus; + + let revision = ProjectRevision::parse(event) + .map_err(|error| IngestError::Rejected(format!("invalid: {error}")))?; + let result = state + .db + .apply_project_revision(tenant.community(), event, &revision) + .await + .map_err(|error| { + IngestError::Internal(format!("error: applying Project revision: {error}")) + })?; + match result.status { + ProjectRevisionApplyStatus::Applied | ProjectRevisionApplyStatus::Duplicate => { + Ok(IngestResult { + event_id: event.id.to_hex(), + accepted: true, + message: String::new(), + }) + } + ProjectRevisionApplyStatus::ProjectNotFound => Err(IngestError::Rejected( + "invalid: Project does not exist".into(), + )), + ProjectRevisionApplyStatus::Conflict => Err(IngestError::Rejected( + "conflict: Project changed since it was loaded".into(), + )), + ProjectRevisionApplyStatus::Forbidden => Err(IngestError::Rejected( + "restricted: Project owner or home-channel admin required".into(), + )), + ProjectRevisionApplyStatus::InvalidMutation => Err(IngestError::Rejected(format!( + "invalid: {}", + result + .message + .unwrap_or_else(|| "invalid Project mutation".into()) + ))), + } +} + /// Result of persisting a command event: either a duplicate (already processed) /// or an open transaction that the handler must commit after executing mutations. enum PersistResult { diff --git a/crates/buzz-relay/src/handlers/ingest.rs b/crates/buzz-relay/src/handlers/ingest.rs index dd2fa6e93e..dd3852c382 100644 --- a/crates/buzz-relay/src/handlers/ingest.rs +++ b/crates/buzz-relay/src/handlers/ingest.rs @@ -28,13 +28,13 @@ use buzz_core::kind::{ KIND_NIP29_EDIT_METADATA, KIND_NIP29_JOIN_REQUEST, KIND_NIP29_LEAVE_REQUEST, KIND_NIP29_PUT_USER, KIND_NIP29_REMOVE_USER, KIND_NIP43_LEAVE_REQUEST, KIND_NIP65_RELAY_LIST_METADATA, KIND_PERSONA, KIND_PIN_LIST, KIND_PRESENCE_UPDATE, - KIND_PRIVATE_MANAGED_AGENT, KIND_PRODUCT_FEEDBACK, KIND_PROFILE, KIND_PROJECT, KIND_REACTION, - KIND_READ_STATE, KIND_REPORT, KIND_STREAM_MESSAGE, KIND_STREAM_MESSAGE_BOOKMARKED, - KIND_STREAM_MESSAGE_DIFF, KIND_STREAM_MESSAGE_EDIT, KIND_STREAM_MESSAGE_PINNED, - KIND_STREAM_MESSAGE_SCHEDULED, KIND_STREAM_MESSAGE_V2, KIND_STREAM_REMINDER, KIND_TEAM, - KIND_TEAM_CATALOG, KIND_TEXT_NOTE, KIND_USER_STATUS, KIND_WORKFLOW_DEF, KIND_WORKFLOW_TRIGGER, - RELAY_ADMIN_ADD_MEMBER, RELAY_ADMIN_CHANGE_ROLE, RELAY_ADMIN_REMOVE_MEMBER, - RELAY_ADMIN_SET_WORKSPACE_PROFILE, + KIND_PRIVATE_MANAGED_AGENT, KIND_PRODUCT_FEEDBACK, KIND_PROFILE, KIND_PROJECT, + KIND_PROJECT_REVISION, KIND_REACTION, KIND_READ_STATE, KIND_REPORT, KIND_STREAM_MESSAGE, + KIND_STREAM_MESSAGE_BOOKMARKED, KIND_STREAM_MESSAGE_DIFF, KIND_STREAM_MESSAGE_EDIT, + KIND_STREAM_MESSAGE_PINNED, KIND_STREAM_MESSAGE_SCHEDULED, KIND_STREAM_MESSAGE_V2, + KIND_STREAM_REMINDER, KIND_TEAM, KIND_TEAM_CATALOG, KIND_TEXT_NOTE, KIND_USER_STATUS, + KIND_WORKFLOW_DEF, KIND_WORKFLOW_TRIGGER, RELAY_ADMIN_ADD_MEMBER, RELAY_ADMIN_CHANGE_ROLE, + RELAY_ADMIN_REMOVE_MEMBER, RELAY_ADMIN_SET_WORKSPACE_PROFILE, }; use buzz_core::tenant::TenantContext; use buzz_core::verification::verify_event; @@ -529,7 +529,7 @@ fn required_scope_for_kind(kind: u32, event: &Event) -> Result Ok(Scope::ReposWrite), // NIP-MP: a project is repository metadata — grouping repositories needs // the same scope as announcing them. - KIND_PROJECT => Ok(Scope::ReposWrite), + KIND_PROJECT | KIND_PROJECT_REVISION => Ok(Scope::ReposWrite), KIND_GIT_PATCH | KIND_GIT_PULL_REQUEST | KIND_GIT_PR_UPDATE @@ -671,6 +671,7 @@ pub(crate) fn is_global_only_kind(kind: u32) -> bool { // `buzz-channel` tag is a metadata reference, not a routing directive, // so a project's state is never channel-scoped. | KIND_PROJECT + | KIND_PROJECT_REVISION // Community moderation commands (9040–9044): community-global // direct commands, same model as the NIP-43 9030-series. A stray // `h` tag must never channel-scope them (pinned contract — @@ -5287,6 +5288,11 @@ mod tests { Scope::ReposWrite, "a project is repository metadata — same scope as announcing a repo" ); + assert_eq!( + required_scope_for_kind(KIND_PROJECT_REVISION, &dummy).unwrap(), + Scope::ReposWrite, + "a Project revision has the same API-token scope as its base" + ); } #[test] @@ -5294,6 +5300,8 @@ mod tests { // `buzz-channel` is a metadata reference, not a routing directive. assert!(is_global_only_kind(KIND_PROJECT)); assert!(!requires_h_channel_scope(KIND_PROJECT)); + assert!(is_global_only_kind(KIND_PROJECT_REVISION)); + assert!(!requires_h_channel_scope(KIND_PROJECT_REVISION)); } #[test] diff --git a/crates/buzz-sdk/src/builders.rs b/crates/buzz-sdk/src/builders.rs index 71c0f1e73d..528fc246e6 100644 --- a/crates/buzz-sdk/src/builders.rs +++ b/crates/buzz-sdk/src/builders.rs @@ -3,6 +3,7 @@ //! All functions return `Result`. //! The caller signs: `builder.sign_with_keys(&keys)?`. +use buzz_core::project_revision::{ProjectCoordinate, ProjectRevisionOperation}; use buzz_core::{ kind::{ KIND_AGENT_OBSERVER_FRAME, KIND_APPROVAL_DENY, KIND_APPROVAL_GRANT, KIND_DELETION, @@ -12,7 +13,7 @@ use buzz_core::{ KIND_GIT_STATUS_OPEN, KIND_IA_ARCHIVE_REQUEST, KIND_IA_UNARCHIVE_REQUEST, KIND_MODERATION_BAN, KIND_MODERATION_RESOLVE_REPORT, KIND_MODERATION_TIMEOUT, KIND_MODERATION_UNBAN, KIND_MODERATION_UNTIMEOUT, KIND_PRESENCE_UPDATE, KIND_PROJECT, - KIND_USER_STATUS, KIND_WORKFLOW_DEF, KIND_WORKFLOW_TRIGGER, + KIND_PROJECT_REVISION, KIND_USER_STATUS, KIND_WORKFLOW_DEF, KIND_WORKFLOW_TRIGGER, }, observer::{ content_looks_like_nip44, OBSERVER_AGENT_TAG, OBSERVER_FRAME_CONTROL, OBSERVER_FRAME_TAG, @@ -2225,6 +2226,30 @@ pub fn build_project_with_tags(content: &str, tags: Vec) -> Result Result { + let project = ProjectCoordinate::parse(project_coordinate) + .map_err(|error| SdkError::InvalidInput(error.to_string()))?; + let expected_revision = check_hex_exact(expected_revision, 64, "expected Project revision")?; + Ok( + EventBuilder::new(Kind::Custom(KIND_PROJECT_REVISION as u16), "").tags([ + tag(&["a", &project.as_string()])?, + tag(&["e", &expected_revision])?, + tag(&["op", operation.as_str()])?, + tag(&["channel", &channel_id.to_string()])?, + ]), + ) +} + /// **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 @@ -4782,6 +4807,38 @@ mod tests { ); } + #[test] + fn build_project_revision_emits_canonical_cas_operation() { + let expected = "b".repeat(64); + let channel = Uuid::parse_str(VALID_UUID).unwrap(); + let event = sign( + build_project_revision( + &format!("30621:{OWNER64}:my-proj"), + &expected, + ProjectRevisionOperation::AddRelatedChannel, + channel, + ) + .unwrap(), + ); + + assert_eq!(event.kind.as_u16(), KIND_PROJECT_REVISION as u16); + assert!(event.content.is_empty()); + let tags = event + .tags + .iter() + .map(|tag| tag.as_slice().to_vec()) + .collect::>(); + assert_eq!( + tags, + vec![ + vec!["a".into(), format!("30621:{OWNER64}:my-proj")], + vec!["e".into(), expected], + vec!["op".into(), "add-related-channel".into()], + vec!["channel".into(), VALID_UUID.into()], + ] + ); + } + // ── 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. diff --git a/desktop/src/features/projects/projectEnumeration.test.mjs b/desktop/src/features/projects/projectEnumeration.test.mjs index 9f9fe26b25..4436bf3125 100644 --- a/desktop/src/features/projects/projectEnumeration.test.mjs +++ b/desktop/src/features/projects/projectEnumeration.test.mjs @@ -82,10 +82,14 @@ test("buildProjectHomeFromFetcher scopes startup lookup to the active channel", extraFilter: { "#buzz-channel": [channelId] }, }); assert.deepEqual(calls[1], { + kinds: [1622], + extraFilter: undefined, + }); + assert.deepEqual(calls[2], { kinds: [30617], extraFilter: { "#buzz-channel": [channelId] }, }); - assert.deepEqual(calls[2], { + assert.deepEqual(calls[3], { kinds: [5], extraFilter: { "#a": [`30621:${owner}:relay`, repositoryAddress] }, }); diff --git a/desktop/src/features/projects/projectEnumeration.ts b/desktop/src/features/projects/projectEnumeration.ts index 109ccc2d82..96235d0436 100644 --- a/desktop/src/features/projects/projectEnumeration.ts +++ b/desktop/src/features/projects/projectEnumeration.ts @@ -3,6 +3,7 @@ import type { RelayEvent } from "@/shared/api/types"; import { KIND_DELETION, KIND_PROJECT_ANNOUNCEMENT, + KIND_PROJECT_REVISION, KIND_REPO_ANNOUNCEMENT, } from "@/shared/constants/kinds"; import { absorbStandaloneProjectRepositories } from "./lib/projectCollection"; @@ -179,10 +180,12 @@ export async function buildProjectsFromFetcher( viewerPubkey?: string | null; } = {}, ): Promise { - const [projectEvents, repositoryEvents] = await Promise.all([ - fetchExhaustively([KIND_PROJECT_ANNOUNCEMENT]), - fetchExhaustively([KIND_REPO_ANNOUNCEMENT]), - ]); + const [projectEvents, projectRevisionEvents, repositoryEvents] = + await Promise.all([ + fetchExhaustively([KIND_PROJECT_ANNOUNCEMENT]), + fetchExhaustively([KIND_PROJECT_REVISION]), + fetchExhaustively([KIND_REPO_ANNOUNCEMENT]), + ]); // Tombstones are fetched second (not in parallel) because the `#a` scoping // needs the announcement coordinates; both announcement kinds are small, @@ -207,6 +210,7 @@ export async function buildProjectsFromFetcher( return absorbStandaloneProjectRepositories( buildProjectReadModels({ projectEvents, + projectRevisionEvents, repositoryEvents, deletionEvents: tombstoneResult.events, relayOrigin: options.relayOrigin ?? null, @@ -233,7 +237,7 @@ export async function buildProjectHomeFromFetcher( (kinds, extraFilter) => fetchExhaustively( kinds, - kinds.includes(KIND_DELETION) + kinds.includes(KIND_DELETION) || kinds.includes(KIND_PROJECT_REVISION) ? extraFilter : { ...extraFilter, "#buzz-channel": [channelId] }, ), diff --git a/desktop/src/features/projects/projectModels.test.mjs b/desktop/src/features/projects/projectModels.test.mjs index 7d09f04d5e..ec8e75301e 100644 --- a/desktop/src/features/projects/projectModels.test.mjs +++ b/desktop/src/features/projects/projectModels.test.mjs @@ -48,6 +48,23 @@ function projectEvent(repositoryTags, overrides = {}) { }; } +function projectRevision(id, expected, operation, channelId, overrides = {}) { + return { + id, + kind: 1622, + pubkey: "d".repeat(64), + created_at: 201, + content: "", + tags: [ + ["a", `30621:${PROJECT_OWNER}:sprout`], + ["e", expected], + ["op", operation], + ["channel", channelId], + ], + ...overrides, + }; +} + test("eventToRepository preserves repository-scoped identity and clone data", () => { const repository = eventToRepository( repositoryEvent(FRONTEND_OWNER, "frontend"), @@ -117,6 +134,65 @@ test("buildProjectReadModels keeps extra related channel ids", () => { assert.deepEqual(projects[0].relatedChannelIds, [relatedA, relatedB]); }); +test("buildProjectReadModels folds the relay-authorized related-channel revision chain", () => { + const relatedA = "22222222-2222-4222-8222-222222222222"; + const relatedB = "33333333-3333-4333-8333-333333333333"; + const base = projectEvent([["buzz-related-channel", relatedA]], { + id: "a".repeat(64), + }); + const addId = "b".repeat(64); + const removeId = "c".repeat(64); + const [project] = buildProjectReadModels({ + projectEvents: [base], + projectRevisionEvents: [ + projectRevision(removeId, addId, "remove-related-channel", relatedA), + projectRevision(addId, base.id, "add-related-channel", relatedB), + ], + repositoryEvents: [], + relayOrigin: RELAY_ORIGIN, + }); + + assert.deepEqual(project.relatedChannelIds, [relatedB]); + assert.equal(project.effectiveRevisionId, removeId); +}); + +test("buildProjectReadModels ignores stale branches and malformed revisions", () => { + const relatedA = "22222222-2222-4222-8222-222222222222"; + const relatedB = "33333333-3333-4333-8333-333333333333"; + const base = projectEvent([], { id: "a".repeat(64) }); + const [project] = buildProjectReadModels({ + projectEvents: [base], + projectRevisionEvents: [ + projectRevision( + "b".repeat(64), + "f".repeat(64), + "add-related-channel", + relatedA, + ), + projectRevision("c".repeat(64), base.id, "replace-all", relatedB), + projectRevision( + "d".repeat(64), + base.id, + "add-related-channel", + relatedA, + { + tags: [ + ["a", `30621:${PROJECT_OWNER}:other-project`], + ["e", base.id], + ["op", "add-related-channel"], + ["channel", relatedA], + ], + }, + ), + ], + repositoryEvents: [], + relayOrigin: RELAY_ORIGIN, + }); + + assert.deepEqual(project.relatedChannelIds, []); + assert.equal(project.effectiveRevisionId, base.id); +}); + test("buildProjectReadModels keeps unclaimed repositories as implicit projects", () => { const frontendAddress = `30617:${PROJECT_OWNER}:frontend`; const projects = buildProjectReadModels({ diff --git a/desktop/src/features/projects/projectModels.ts b/desktop/src/features/projects/projectModels.ts index e201713a3b..9f36f6308b 100644 --- a/desktop/src/features/projects/projectModels.ts +++ b/desktop/src/features/projects/projectModels.ts @@ -1,6 +1,7 @@ import type { RelayEvent } from "@/shared/api/types"; import { KIND_PROJECT_ANNOUNCEMENT, + KIND_PROJECT_REVISION, KIND_REPO_ANNOUNCEMENT, } from "@/shared/constants/kinds"; import { effectiveCloneUrls } from "./lib/projectCloneUrl"; @@ -38,6 +39,8 @@ export type Project = { * unrecognized metadata, so older readers ignore it. */ relatedChannelIds: string[]; + /** Current base or collaborative revision id used as the next CAS token. */ + effectiveRevisionId?: string; status: string; projectAddress: string; primaryRepositoryAddress: string | null; @@ -56,6 +59,7 @@ export function isExplicitProject(project: Project): boolean { type BuildProjectReadModelsInput = { projectEvents: RelayEvent[]; + projectRevisionEvents?: RelayEvent[]; repositoryEvents: RelayEvent[]; /** NIP-09 kind:5 deletion events relevant to projects and repositories. */ deletionEvents?: RelayEvent[]; @@ -381,6 +385,7 @@ export function eventToExplicitProject( createdAt: event.created_at, projectChannelId, relatedChannelIds, + effectiveRevisionId: event.id.toLowerCase(), status: visibility === "listed" ? "active" : "unlisted", projectAddress, primaryRepositoryAddress, @@ -408,6 +413,7 @@ function repositoryToLegacyProject(repository: Repository): Project { createdAt: repository.createdAt, projectChannelId: null, relatedChannelIds: [], + effectiveRevisionId: repository.id.toLowerCase(), status: repository.status, projectAddress: repository.repoAddress, primaryRepositoryAddress: repository.repoAddress, @@ -454,6 +460,7 @@ function projectIsListingEligible( export function buildProjectReadModels({ projectEvents, + projectRevisionEvents = [], repositoryEvents, deletionEvents = [], relayOrigin, @@ -501,7 +508,7 @@ export function buildProjectReadModels({ return project && projectIsListingEligible(project, viewerPubkey) && !hiddenAddresses.has(project.projectAddress) - ? [project] + ? [applyProjectRevisionEvents(project, event.id, projectRevisionEvents)] : []; }); const claimedRepositories = new Set( @@ -525,6 +532,64 @@ export function buildProjectReadModels({ ); } +function singletonRevisionTag(event: RelayEvent, name: string): string | null { + const matches = event.tags.filter( + (tag) => tag.length === 2 && tag[0] === name && Boolean(tag[1]), + ); + return matches.length === 1 ? matches[0][1] : null; +} + +/** Fold the relay-authorized linear revision chain rooted at a Project head. */ +export function applyProjectRevisionEvents( + project: Project, + baseEventId: string, + events: RelayEvent[], +): Project { + let effectiveRevisionId = baseEventId.toLowerCase(); + let relatedChannelIds = [...project.relatedChannelIds]; + const candidates = events.filter( + (event) => + event.kind === KIND_PROJECT_REVISION && + singletonRevisionTag(event, "a") === project.projectAddress, + ); + for (;;) { + const next = candidates.find( + (event) => + singletonRevisionTag(event, "e")?.toLowerCase() === effectiveRevisionId, + ); + if (!next) break; + const operation = singletonRevisionTag(next, "op"); + const channelId = singletonRevisionTag(next, "channel"); + if (!channelId || !isValidProjectChannelId(channelId)) break; + if (operation === "add-related-channel") { + if ( + channelId === project.projectChannelId || + relatedChannelIds.includes(channelId) || + relatedChannelIds.length >= MAX_PROJECT_RELATED_CHANNELS + ) { + break; + } + relatedChannelIds.push(channelId); + } else if (operation === "remove-related-channel") { + if (!relatedChannelIds.includes(channelId)) break; + relatedChannelIds = relatedChannelIds.filter( + (candidate) => candidate !== channelId, + ); + } else { + break; + } + effectiveRevisionId = next.id.toLowerCase(); + } + return { + ...project, + createdAt: + events.find((event) => event.id.toLowerCase() === effectiveRevisionId) + ?.created_at ?? project.createdAt, + effectiveRevisionId, + relatedChannelIds, + }; +} + export function selectProjectRepository( project: Project | null | undefined, requestedRepositoryId: string | null | undefined, diff --git a/desktop/src/features/projects/projectRelatedChannelRevision.test.mjs b/desktop/src/features/projects/projectRelatedChannelRevision.test.mjs new file mode 100644 index 0000000000..38fd71bf13 --- /dev/null +++ b/desktop/src/features/projects/projectRelatedChannelRevision.test.mjs @@ -0,0 +1,102 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + buildProjectRelatedChannelRevisionTemplate, + removeProjectRelatedChannel, +} from "./projectRelatedChannelRevision.ts"; + +const HOME = "11111111-1111-4111-8111-111111111111"; +const RELATED = "22222222-2222-4222-8222-222222222222"; +const OWNER = "a".repeat(64); + +function project(overrides = {}) { + return { + effectiveRevisionId: "b".repeat(64), + legacy: false, + projectAddress: `30621:${OWNER}:buzz`, + projectChannelId: HOME, + relatedChannelIds: [], + ...overrides, + }; +} + +test("buildProjectRelatedChannelRevisionTemplate builds add and remove CAS operations", () => { + assert.deepEqual( + buildProjectRelatedChannelRevisionTemplate( + project(), + RELATED, + "add-related-channel", + ), + { + kind: 1622, + content: "", + tags: [ + ["a", `30621:${OWNER}:buzz`], + ["e", "b".repeat(64)], + ["op", "add-related-channel"], + ["channel", RELATED], + ], + }, + ); + assert.equal( + buildProjectRelatedChannelRevisionTemplate( + project({ relatedChannelIds: [RELATED] }), + RELATED, + "remove-related-channel", + ).tags[2][1], + "remove-related-channel", + ); +}); + +test("buildProjectRelatedChannelRevisionTemplate rejects stale and invalid mutations", () => { + assert.throws( + () => + buildProjectRelatedChannelRevisionTemplate( + project({ effectiveRevisionId: undefined }), + RELATED, + "add-related-channel", + ), + /Refresh/, + ); + assert.throws( + () => + buildProjectRelatedChannelRevisionTemplate( + project(), + HOME, + "add-related-channel", + ), + /home channel/, + ); + assert.throws( + () => + buildProjectRelatedChannelRevisionTemplate( + project({ relatedChannelIds: [RELATED] }), + RELATED, + "add-related-channel", + ), + /already related/, + ); +}); + +test("removeProjectRelatedChannel publishes a remove revision and advances local state", async () => { + const calls = []; + const updated = await removeProjectRelatedChannel( + project({ relatedChannelIds: [RELATED] }), + RELATED, + { + signEvent: async (template) => ({ + ...template, + id: "c".repeat(64), + pubkey: OWNER, + created_at: 123, + }), + publishEvent: async (...args) => calls.push(args), + }, + ); + + assert.equal(calls.length, 1); + assert.equal(calls[0][0].tags[2][1], "remove-related-channel"); + assert.deepEqual(updated.relatedChannelIds, []); + assert.equal(updated.effectiveRevisionId, "c".repeat(64)); +}); diff --git a/desktop/src/features/projects/projectRelatedChannelRevision.ts b/desktop/src/features/projects/projectRelatedChannelRevision.ts new file mode 100644 index 0000000000..a4fc9a75b8 --- /dev/null +++ b/desktop/src/features/projects/projectRelatedChannelRevision.ts @@ -0,0 +1,98 @@ +import type { Project } from "@/features/projects/projectModels"; +import { isValidProjectChannelId } from "@/features/projects/projectModels"; +import { relayClient } from "@/shared/api/relayClient"; +import { signRelayEvent } from "@/shared/api/tauri"; +import type { RelayEvent } from "@/shared/api/types"; +import { KIND_PROJECT_REVISION } from "@/shared/constants/kinds"; + +export type ProjectRelatedChannelOperation = + | "add-related-channel" + | "remove-related-channel"; + +export function buildProjectRelatedChannelRevisionTemplate( + project: Pick< + Project, + | "effectiveRevisionId" + | "legacy" + | "projectAddress" + | "projectChannelId" + | "relatedChannelIds" + >, + channelId: string, + operation: ProjectRelatedChannelOperation, +) { + if (project.legacy || !project.projectAddress.startsWith("30621:")) { + throw new Error("Only explicit Projects can link related channels."); + } + const expectedRevision = project.effectiveRevisionId?.toLowerCase(); + if (!expectedRevision || !/^[0-9a-f]{64}$/.test(expectedRevision)) { + throw new Error("Refresh this Project before changing its channels."); + } + const normalizedChannelId = channelId.trim().toLowerCase(); + if (!isValidProjectChannelId(normalizedChannelId)) { + throw new Error("Project channel is invalid."); + } + if (normalizedChannelId === project.projectChannelId?.toLowerCase()) { + throw new Error("The Project home channel cannot also be related."); + } + const alreadyRelated = project.relatedChannelIds.some( + (candidate) => candidate.toLowerCase() === normalizedChannelId, + ); + if (operation === "add-related-channel" && alreadyRelated) { + throw new Error("That channel is already related to this Project."); + } + if (operation === "remove-related-channel" && !alreadyRelated) { + throw new Error("That channel is not related to this Project."); + } + return { + kind: KIND_PROJECT_REVISION, + content: "", + tags: [ + ["a", project.projectAddress], + ["e", expectedRevision], + ["op", operation], + ["channel", normalizedChannelId], + ], + }; +} + +export async function publishProjectRelatedChannelRevision( + project: Parameters[0], + channelId: string, + operation: ProjectRelatedChannelOperation, + deps?: { + publishEvent?: typeof relayClient.publishEvent; + signEvent?: typeof signRelayEvent; + }, +): Promise { + const event = await (deps?.signEvent ?? signRelayEvent)( + buildProjectRelatedChannelRevisionTemplate(project, channelId, operation), + ); + await (deps?.publishEvent ?? relayClient.publishEvent.bind(relayClient))( + event, + "Could not confirm the Project channel change. Refresh before retrying.", + "Could not change the Project's related channels.", + ); + return event; +} + +export async function removeProjectRelatedChannel( + project: Project, + channelId: string, + deps?: Parameters[3], +): Promise { + const revision = await publishProjectRelatedChannelRevision( + project, + channelId, + "remove-related-channel", + deps, + ); + return { + ...project, + createdAt: revision.created_at, + effectiveRevisionId: revision.id.toLowerCase(), + relatedChannelIds: project.relatedChannelIds.filter( + (candidate) => candidate.toLowerCase() !== channelId.toLowerCase(), + ), + }; +} diff --git a/desktop/src/features/projects/ui/ProjectChannelManagement.tsx b/desktop/src/features/projects/ui/ProjectChannelManagement.tsx index 9c774c9a3b..5e5ad39be8 100644 --- a/desktop/src/features/projects/ui/ProjectChannelManagement.tsx +++ b/desktop/src/features/projects/ui/ProjectChannelManagement.tsx @@ -2,10 +2,8 @@ import { Plus } from "lucide-react"; import * as React from "react"; import { toast } from "sonner"; -import { useIsManagedAgent } from "@/features/agent-memory/hooks"; import { useAppNavigation } from "@/app/navigation/useAppNavigation"; -import { useUsersBatchQuery } from "@/features/profile/hooks"; -import { ownsAuthorAgent } from "@/features/profile/lib/identity"; +import { useChannelMembersQuery } from "@/features/channels/hooks"; import type { Project } from "@/features/projects/hooks"; import { useAddProjectChannelMutation } from "@/features/projects/useAddProjectChannel"; import { CreateChannelDialog } from "@/features/sidebar/ui/CreateChannelDialog"; @@ -21,25 +19,7 @@ export function ProjectChannelManagement({ const { goChannel } = useAppNavigation(); const [createOpen, setCreateOpen] = React.useState(false); const createMutation = useAddProjectChannelMutation(); - const ownerProfileQuery = useUsersBatchQuery([project.owner], { - enabled: Boolean(identityPubkey), - }); - const projectOwnerProfile = - ownerProfileQuery.data?.profiles[project.owner.toLowerCase()]; - const projectOwnerIsManaged = useIsManagedAgent(project.owner) === true; - const viewerIsProjectOwner = - identityPubkey?.toLowerCase() === project.owner.toLowerCase(); - const viewerOwnsProjectAgent = ownsAuthorAgent( - projectOwnerProfile, - identityPubkey, - ); - const canEdit = - !project.legacy && - (viewerIsProjectOwner || projectOwnerIsManaged || viewerOwnsProjectAgent); - const ownerControlAgentPubkey = - viewerOwnsProjectAgent && !projectOwnerIsManaged && !viewerIsProjectOwner - ? project.owner - : undefined; + const canEdit = useCanManageProjectChannels(project, identityPubkey); return ( <> @@ -51,7 +31,6 @@ export function ProjectChannelManagement({ onCreate={async (input) => { const result = await createMutation.mutateAsync({ ...input, - ownerControlAgentPubkey, project, }); toast.success(`Channel "#${result.channel.name}" created.`); @@ -70,7 +49,9 @@ export function ProjectChannelManagement({ onClick={() => setCreateOpen(true)} size="icon" title={ - canEdit ? "Add channel" : "Only the project owner can add channels" + canEdit + ? "Add channel" + : "Only the Project owner or a home-channel admin can add channels" } type="button" variant="ghost" @@ -80,3 +61,19 @@ export function ProjectChannelManagement({ ); } + +export function useCanManageProjectChannels( + project: Project, + identityPubkey?: string, +): boolean { + const homeMembersQuery = useChannelMembersQuery(project.projectChannelId); + const viewerHomeRole = homeMembersQuery.data?.find( + (member) => member.pubkey.toLowerCase() === identityPubkey?.toLowerCase(), + )?.role; + return ( + !project.legacy && + (identityPubkey?.toLowerCase() === project.owner.toLowerCase() || + viewerHomeRole === "owner" || + viewerHomeRole === "admin") + ); +} diff --git a/desktop/src/features/projects/ui/ProjectHomeContextPanel.tsx b/desktop/src/features/projects/ui/ProjectHomeContextPanel.tsx index 080ec5aa32..2f62a04247 100644 --- a/desktop/src/features/projects/ui/ProjectHomeContextPanel.tsx +++ b/desktop/src/features/projects/ui/ProjectHomeContextPanel.tsx @@ -26,6 +26,14 @@ import { cn } from "@/shared/lib/cn"; import type { EntityLinkTab } from "@/shared/lib/entityLink"; import { Button } from "@/shared/ui/button"; import { ProjectChannelManagement } from "./ProjectChannelManagement"; +import { useCanManageProjectChannels } from "./ProjectChannelManagement"; +import { useRemoveProjectRelatedChannelMutation } from "@/features/projects/useRemoveProjectRelatedChannel"; +import { + ContextMenu, + ContextMenuContent, + ContextMenuItem, + ContextMenuTrigger, +} from "@/shared/ui/context-menu"; import { ProjectRepositoryManagement } from "./ProjectRepositoryManagement"; import { SECTION_ACTION_VISIBILITY_CLASS } from "@/features/sidebar/ui/sidebarSectionStyles"; @@ -152,23 +160,22 @@ function ContextNavButton({ function ChannelContextRow({ channel, onClick, + onRemove, projectHome, testId, }: { channel: Channel; onClick?: () => void; + onRemove?: () => void; projectHome?: boolean; testId: string; }) { const Icon = projectHome ? ProjectChannelIcon : Hash; - if (onClick) { - return ( - } onClick={onClick} testId={testId}> - {channel.name} - - ); - } - return ( + const row = onClick ? ( + } onClick={onClick} testId={testId}> + {channel.name} + + ) : (
}>{channel.name}
); + if (!onRemove) return row; + return ( + + {row} + + + Remove from Project + + + + ); } export function ProjectHomeContextPanel({ @@ -219,6 +237,11 @@ export function ProjectHomeContextPanel({ ...project.repositories.flatMap((repository) => repository.contributors), ]).size; const activityQuery = useProjectActivitySummariesQuery([project]); + const canManageProjectChannels = useCanManageProjectChannels( + project, + identityPubkey, + ); + const removeProjectChannelMutation = useRemoveProjectRelatedChannelMutation(); const activity = activityQuery.data?.[project.id]; const repoStateQuery = useRepoStateQuery(firstRepository); const defaultBranch = firstRepository @@ -344,6 +367,15 @@ export function ProjectHomeContextPanel({ ? undefined : () => onOpenChannel(binding.channel.id) } + onRemove={ + !isHome && canManageProjectChannels + ? () => + removeProjectChannelMutation.mutate({ + channelId: binding.channel.id, + project, + }) + : undefined + } projectHome={isHome} testId={ isHome diff --git a/desktop/src/features/projects/useAddProjectChannel.test.mjs b/desktop/src/features/projects/useAddProjectChannel.test.mjs index c6c7c71af0..25387d0fcc 100644 --- a/desktop/src/features/projects/useAddProjectChannel.test.mjs +++ b/desktop/src/features/projects/useAddProjectChannel.test.mjs @@ -16,6 +16,7 @@ function makeProject(overrides = {}) { createdAt: 100, projectChannelId: "11111111-1111-4111-8111-111111111111", relatedChannelIds: [], + effectiveRevisionId: "f".repeat(64), status: "active", projectAddress: `30621:${OWNER}:platform`, primaryRepositoryAddress: null, @@ -105,7 +106,7 @@ test("addProjectChannel removes its channel and does not publish when the projec createChannel: async () => ({ id: CREATED_CHANNEL }), deleteChannel: async (channelId) => deleted.push(channelId), fetchEvents: async () => fetchResults.shift() ?? [], - publishOwnerAnnouncement: async () => { + publishRevision: async () => { publishCalls += 1; throw new Error("must not publish a stale project replacement"); }, @@ -132,7 +133,7 @@ test("addProjectChannel removes its channel when project publication fails", asy fetchCalls += 1; return [liveHead]; }, - publishOwnerAnnouncement: async () => { + publishRevision: async () => { throw new Error("publication failed"); }, }), @@ -142,3 +143,32 @@ test("addProjectChannel removes its channel when project publication fails", asy assert.equal(fetchCalls, 2); assert.deepEqual(deleted, [CREATED_CHANNEL]); }); + +test("addProjectChannel publishes an actor-signed revision and advances the local CAS head", async () => { + const liveHead = makeLiveHead(100); + const revision = { + id: "e".repeat(64), + kind: 1622, + pubkey: "b".repeat(64), + created_at: 110, + content: "", + tags: [], + }; + const calls = []; + const result = await addProjectChannel(input(), { + applyAgents: async () => {}, + applyCanvas: async () => {}, + createChannel: async () => ({ id: CREATED_CHANNEL }), + fetchEvents: async () => [liveHead], + publishRevision: async (...args) => { + calls.push(args); + return revision; + }, + }); + + assert.deepEqual(calls, [ + [input().project, CREATED_CHANNEL, "add-related-channel"], + ]); + assert.deepEqual(result.project.relatedChannelIds, [CREATED_CHANNEL]); + assert.equal(result.project.effectiveRevisionId, revision.id); +}); diff --git a/desktop/src/features/projects/useAddProjectChannel.ts b/desktop/src/features/projects/useAddProjectChannel.ts index 1ef50bf934..f5792a7502 100644 --- a/desktop/src/features/projects/useAddProjectChannel.ts +++ b/desktop/src/features/projects/useAddProjectChannel.ts @@ -8,14 +8,16 @@ import { import { useApplyTemplate } from "@/features/channel-templates/useApplyTemplate"; import { type Project, projectsQueryKey } from "@/features/projects/hooks"; import { isUnsupportedProjectKindError } from "@/features/projects/projectCreation"; -import { buildProjectRelatedChannelPatchTemplate } from "@/features/projects/projectChannelCreation"; import { addRelatedChannelToProject } from "@/features/projects/projectModels"; -import { publishOwnedAgentProjectAnnouncements } from "@/features/projects/projectOwnerControl"; +import { publishProjectRelatedChannelRevision } from "@/features/projects/projectRelatedChannelRevision"; import { markProjectDataAuthoritative } from "@/features/projects/projectSnapshot"; -import { publishProjectOwnerAnnouncement } from "@/shared/api/projectGit"; import { relayClient } from "@/shared/api/relayClient"; import { deleteChannel as deleteChannelApi } from "@/shared/api/tauriChannels"; -import type { Channel, ChannelVisibility } from "@/shared/api/types"; +import type { + Channel, + ChannelVisibility, + RelayEvent, +} from "@/shared/api/types"; import { KIND_PROJECT_ANNOUNCEMENT } from "@/shared/constants/kinds"; export type AddProjectChannelInput = { @@ -43,8 +45,7 @@ export async function addProjectChannel( createChannel: ReturnType["mutateAsync"]; deleteChannel?: typeof deleteChannelApi; fetchEvents?: typeof relayClient.fetchEvents; - publishOwnedAgentAnnouncements?: typeof publishOwnedAgentProjectAnnouncements; - publishOwnerAnnouncement?: typeof publishProjectOwnerAnnouncement; + publishRevision?: typeof publishProjectRelatedChannelRevision; }, ): Promise<{ channel: Channel; project: Project }> { const { @@ -53,8 +54,7 @@ export async function addProjectChannel( createChannel, deleteChannel = deleteChannelApi, fetchEvents = relayClient.fetchEvents.bind(relayClient), - publishOwnedAgentAnnouncements = publishOwnedAgentProjectAnnouncements, - publishOwnerAnnouncement = publishProjectOwnerAnnouncement, + publishRevision = publishProjectRelatedChannelRevision, } = deps; const targetOwner = input.project.owner.toLowerCase(); @@ -114,60 +114,35 @@ export async function addProjectChannel( ); } - const templates = buildProjectRelatedChannelPatchTemplate({ - channelId: channel.id, - liveHead: confirmedLiveHead, - ownerPubkey: targetOwner, - }); - let projectCreatedAt = confirmedLiveHead.created_at; - if (!templates.alreadyBound) { - const createdAt = Math.max( - Math.floor(Date.now() / 1_000), - confirmedLiveHead.created_at + 1, - ); - try { - if (input.ownerControlAgentPubkey) { - const events = await publishOwnedAgentAnnouncements( - input.ownerControlAgentPubkey, - [{ ...templates.project, createdAt }], - ); - projectCreatedAt = - events.find((event) => event.kind === KIND_PROJECT_ANNOUNCEMENT) - ?.created_at ?? createdAt; - } else { - const publication = await publishOwnerAnnouncement({ - ...templates.project, - createdAt, - targetOwner, - }); - if (publication.publicationError) { - throw new Error(publication.publicationError); - } - projectCreatedAt = publication.event.created_at; - } - } catch (error) { - await removeCreatedChannelAndThrow( - isUnsupportedProjectKindError(error) - ? new Error( - "This relay does not support projects yet, so the channel could not be linked.", - ) - : error instanceof Error - ? error - : new Error("Could not link the channel to this project."), - ); - } - } + const revision: RelayEvent = await publishRevision( + input.project, + channel.id, + "add-related-channel", + ).catch((error: unknown) => + removeCreatedChannelAndThrow( + isUnsupportedProjectKindError(error) + ? new Error( + "This relay does not support collaborative Project channels yet, so the channel could not be linked.", + ) + : error instanceof Error + ? error + : new Error("Could not link the channel to this project."), + ), + ); await applyCanvas(input.templateId, channel.id, input.name); void applyAgents(input.templateId, channel.id); return { channel, - project: addRelatedChannelToProject( - input.project, - channel.id, - projectCreatedAt, - ), + project: { + ...addRelatedChannelToProject( + input.project, + channel.id, + revision.created_at, + ), + effectiveRevisionId: revision.id.toLowerCase(), + }, }; } diff --git a/desktop/src/features/projects/useRemoveProjectRelatedChannel.ts b/desktop/src/features/projects/useRemoveProjectRelatedChannel.ts new file mode 100644 index 0000000000..6fd0f66394 --- /dev/null +++ b/desktop/src/features/projects/useRemoveProjectRelatedChannel.ts @@ -0,0 +1,28 @@ +import { useMutation, useQueryClient } from "@tanstack/react-query"; + +import { projectsQueryKey } from "@/features/projects/hooks"; +import type { Project } from "@/features/projects/projectModels"; +import { removeProjectRelatedChannel } from "@/features/projects/projectRelatedChannelRevision"; +import { markProjectDataAuthoritative } from "@/features/projects/projectSnapshot"; + +export function useRemoveProjectRelatedChannelMutation() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: ({ + channelId, + project, + }: { + channelId: string; + project: Project; + }) => removeProjectRelatedChannel(project, channelId), + onSuccess: (project) => { + markProjectDataAuthoritative(project, "local-write"); + queryClient.setQueryData(projectsQueryKey, (current = []) => + current.map((candidate) => + candidate.id === project.id ? project : candidate, + ), + ); + void queryClient.invalidateQueries({ queryKey: projectsQueryKey }); + }, + }); +} diff --git a/desktop/src/shared/constants/kinds.ts b/desktop/src/shared/constants/kinds.ts index 5d4308c5c2..e3e5b5f961 100644 --- a/desktop/src/shared/constants/kinds.ts +++ b/desktop/src/shared/constants/kinds.ts @@ -74,6 +74,8 @@ export const KIND_GIT_PATCH = 1617; export const KIND_GIT_PULL_REQUEST = 1618; export const KIND_GIT_PR_UPDATE = 1619; export const KIND_GIT_ISSUE = 1621; +// NIP-MP: actor-signed, relay-authorized Project revision operation. +export const KIND_PROJECT_REVISION = 1622; export const KIND_GIT_STATUS_OPEN = 1630; export const KIND_GIT_STATUS_MERGED = 1631; export const KIND_GIT_STATUS_CLOSED = 1632; diff --git a/docs/nips/NIP-MP.md b/docs/nips/NIP-MP.md index 14936d2a7c..584899658e 100644 --- a/docs/nips/NIP-MP.md +++ b/docs/nips/NIP-MP.md @@ -28,7 +28,9 @@ One custom kind, held by one signer, with all group state in one replaceable eve ## Non-Goals -This NIP does not define shared or delegated project editing — a project is replaceable only by its own signer (see [Authority](#authority)). +This NIP defines collaborative editing only for related-channel membership. All +other project fields remain replaceable only by the project signer (see +[Authority](#authority)). This NIP does not define any authorization over member repositories. Membership is not a permission grant, and a project is never consulted by git push policy. This NIP does not define project-level branch protection, CI, or workflow configuration. This NIP does not define nested projects. A project's members are repositories, never other projects. @@ -50,6 +52,7 @@ This document uses MUST, MUST NOT, SHOULD, SHOULD NOT, MAY, and RECOMMENDED as d | Kind | Name | Signer | Class | Purpose | |------|------|--------|-------|---------| | `30621` | Project | user | addressable | A named grouping of `kind:30617` repository announcements | +| `1622` | Project revision | project owner or current home-channel owner/admin | regular | A CAS-protected related-channel add/remove operation | `kind:30621` is an addressable event per NIP-01 (`30000 <= n < 40000`), addressed by `(pubkey, 30621, d)`. Two signers may use the same `d` value; those are two distinct projects. Addressable events were formerly specified as "parameterized replaceable events" in NIP-33, which upstream has since folded into NIP-01; this document cites NIP-01 throughout. @@ -140,9 +143,33 @@ Clients MUST preserve each member repository's own owner provenance in the UI. A ### Editing model -Editing is **owner-only**: publish a replacement `kind:30621` with the same `d` and a newer `created_at`. Adding, removing, or reordering members and changing metadata are all one operation — replacing the container. This falls out of the addressable-event model with no relay-side permission machinery; NIP-01 replacement already refuses to let one pubkey overwrite another's coordinate. +The project signer may still edit by publishing a replacement `kind:30621` +with the same `d` and a newer `created_at`. Changing repositories, metadata, +visibility, or the home channel remains owner-only. -Delegated or maintainer editing is deliberately out of scope for this version. Adding it later needs no change to this event shape — only a new rule about who may replace a coordinate. +Related channels additionally support actor-signed `kind:1622` revisions. A +revision has empty content and exactly one of each of these tags: + +- `a`: the stable `30621::` Project coordinate +- `e`: the exact current base/revision event id (the compare-and-swap token) +- `op`: `add-related-channel` or `remove-related-channel` +- `channel`: the related channel UUID + +The relay serializes revisions with replacements of the referenced Project, +loads the current live `kind:30621`, and authorizes the revision signer against +current state. The Project signer is always authorized. Otherwise, the current +home channel must resolve to a live channel and the signer must be an active +`owner` or `admin` of it. Members, guests, bots, and unrelated users are not +authorized. A missing, malformed, or deleted home channel therefore falls back +to owner-only management. + +The relay applies the add/remove operation to its current materialized related +channels and rejects an `e` tag that does not name the current effective +revision. It stores the signed operation for attribution and audit. Readers +start from the current base Project id and follow the unique accepted `e` +chain, applying operations in order. A replacement base starts a new chain. +This mechanism does not permit a collaborator to replace the owner's +`kind:30621` coordinate. ### Zero-member projects diff --git a/migrations/0040_project_revision_heads.sql b/migrations/0040_project_revision_heads.sql new file mode 100644 index 0000000000..d046b7067b --- /dev/null +++ b/migrations/0040_project_revision_heads.sql @@ -0,0 +1,18 @@ +-- Effective head for actor-signed collaborative Project revisions. +-- +-- The signed operation events remain the portable audit trail in `events`. +-- This row is the relay's transactional CAS/materialized state, keyed by the +-- stable owner-authored kind:30621 coordinate. +CREATE TABLE project_revision_heads ( + community_id UUID NOT NULL, + project_owner BYTEA NOT NULL CHECK (octet_length(project_owner) = 32), + project_d_tag TEXT NOT NULL, + base_event_id BYTEA NOT NULL CHECK (octet_length(base_event_id) = 32), + revision_event_id BYTEA NOT NULL CHECK (octet_length(revision_event_id) = 32), + related_channel_ids UUID[] NOT NULL DEFAULT '{}', + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + PRIMARY KEY (community_id, project_owner, project_d_tag) +); + +CREATE INDEX idx_project_revision_heads_revision + ON project_revision_heads (community_id, revision_event_id); diff --git a/schema/schema.sql b/schema/schema.sql index 5456610333..fca599c90e 100644 --- a/schema/schema.sql +++ b/schema/schema.sql @@ -1895,3 +1895,21 @@ CREATE INDEX idx_relay_operator_audit_target INSERT INTO _operator_global_tables (table_name, reason) VALUES ('relay_operator_audit', 'deployment-global append-only roster mutation audit trail; no community_id intentionally'); + +-- ── Collaborative Project revision heads ──────────────────────────────────── +-- The signed kind:1622 events remain in `events`; this is the transactional +-- compare-and-swap head and materialized related-channel membership. + +CREATE TABLE project_revision_heads ( + community_id UUID NOT NULL, + project_owner BYTEA NOT NULL CHECK (octet_length(project_owner) = 32), + project_d_tag TEXT NOT NULL, + base_event_id BYTEA NOT NULL CHECK (octet_length(base_event_id) = 32), + revision_event_id BYTEA NOT NULL CHECK (octet_length(revision_event_id) = 32), + related_channel_ids UUID[] NOT NULL DEFAULT '{}', + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + PRIMARY KEY (community_id, project_owner, project_d_tag) +); + +CREATE INDEX idx_project_revision_heads_revision + ON project_revision_heads (community_id, revision_event_id);