From 267244d5205307dfffa8d1a9b1cfc965dcf833e5 Mon Sep 17 00:00:00 2001 From: John Tennant Date: Mon, 31 Aug 2026 16:54:06 -0400 Subject: [PATCH] fix(projects): preserve revised channel membership Signed-off-by: John Tennant --- crates/buzz-cli/src/commands/projects.rs | 164 ++++++++++++++++-- .../src/features/projects/createProject.ts | 1 + .../projects/projectChannelCreation.test.mjs | 105 ----------- .../projects/projectChannelCreation.ts | 71 -------- .../features/projects/projectModels.test.mjs | 29 ++++ .../src/features/projects/projectModels.ts | 38 ++-- .../projectRepositoryCreation.test.mjs | 27 +++ .../projects/projectRepositoryCreation.ts | 45 ++++- .../ui/ProjectChannelManagement.test.mjs | 31 ++++ .../projects/ui/ProjectChannelManagement.tsx | 9 + .../ui/ProjectsCategoryCreateDialogs.tsx | 39 ++++- .../src/features/projects/ui/ProjectsView.tsx | 1 + .../features/projects/useAddProjectChannel.ts | 1 - .../projects/useAddProjectRepository.ts | 1 + .../projects/useAttachProjectRepository.ts | 1 + .../projects/useProjectChannelRequests.ts | 23 ++- 16 files changed, 367 insertions(+), 219 deletions(-) delete mode 100644 desktop/src/features/projects/projectChannelCreation.test.mjs delete mode 100644 desktop/src/features/projects/projectChannelCreation.ts create mode 100644 desktop/src/features/projects/ui/ProjectChannelManagement.test.mjs diff --git a/crates/buzz-cli/src/commands/projects.rs b/crates/buzz-cli/src/commands/projects.rs index 80b0afbdfc..3f7c7efeb8 100644 --- a/crates/buzz-cli/src/commands/projects.rs +++ b/crates/buzz-cli/src/commands/projects.rs @@ -17,12 +17,13 @@ //! not in scope. use buzz_core::kind::KIND_PROJECT; -use buzz_core::project_revision::{ProjectCoordinate, ProjectRevision}; +use buzz_core::project_revision::{apply_project_revision, ProjectCoordinate, ProjectRevision}; use buzz_sdk::{ build_delete_addressable, build_project, build_project_revision, build_project_with_tags, ProjectMemberCoord, PROJECT_D_MAX_LEN, }; use nostr::{Event, EventBuilder, PublicKey, Tag, Timestamp}; +use uuid::Uuid; use crate::agent_management::{build_project_channel, CreateProjectChannelDraft}; use crate::client::BuzzClient; @@ -194,9 +195,20 @@ pub async fn add_repos_to_own_project( let head = fetch_own_project(client, slug) .await? .ok_or_else(|| CliError::NotFound(format!("project {slug:?} not found")))?; + let effective_revision = current_project_revision(client, &head, slug).await?; let next_ts = next_timestamp(&head, Timestamp::now())?; let mut tags: Vec = head.tags.iter().cloned().collect(); + let home_channel = tags + .iter() + .find(|tag| tag_name(tag) == Some("buzz-channel")) + .and_then(tag_value) + .and_then(|value| value.parse::().ok()); + carry_effective_related_channels( + &mut tags, + &effective_revision.related_channels, + home_channel, + )?; let existing_coords: std::collections::HashSet = head .tags .iter() @@ -279,11 +291,33 @@ async fn fetch_own_project(client: &BuzzClient, slug: &str) -> Result, +} + +fn carry_effective_related_channels( + tags: &mut Vec, + related_channels: &[Uuid], + home_channel: Option, +) -> Result<(), CliError> { + tags.retain(|tag| tag_name(tag) != Some("buzz-related-channel")); + for channel_id in related_channels { + if Some(*channel_id) != home_channel { + tags.push(make_tag(&[ + "buzz-related-channel", + &channel_id.to_string(), + ])?); + } + } + Ok(()) +} + async fn current_project_revision( client: &BuzzClient, project: &Event, slug: &str, -) -> Result { +) -> Result { let coordinate = ProjectCoordinate { owner: project.pubkey.to_hex(), slug: slug.to_owned(), @@ -303,16 +337,56 @@ async fn current_project_revision( }) }) .collect::>()?; + let home_channel = project + .tags + .iter() + .find(|tag| tag_name(tag) == Some("buzz-channel")) + .and_then(tag_value) + .and_then(|value| value.parse::().ok()); + let mut related_channels = Vec::new(); + for channel in project + .tags + .iter() + .filter(|tag| tag_name(tag) == Some("buzz-related-channel")) + .filter_map(tag_value) + .filter_map(|value| value.parse::().ok()) + { + if Some(channel) != home_channel + && !related_channels.contains(&channel) + && related_channels.len() < 64 + { + related_channels.push(channel); + } + } + let parsed: Vec<(String, ProjectRevision)> = events + .iter() + .filter_map(|event| { + ProjectRevision::parse(event) + .ok() + .map(|revision| (event.id.to_hex(), revision)) + }) + .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(); + let Some((event_id, revision)) = parsed + .iter() + .find(|(_, revision)| revision.expected_revision == current) + else { + break; + }; + apply_project_revision( + &mut related_channels, + home_channel, + revision.operation, + revision.channel_id, + ) + .map_err(|message| CliError::Other(format!("invalid Project revision chain: {message}")))?; + current.clone_from(event_id); } - Ok(current) + Ok(CurrentProjectRevision { + id: current, + related_channels, + }) } async fn cmd_related_channel_revision( @@ -327,7 +401,7 @@ async fn cmd_related_channel_revision( 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 expected_revision = current_project_revision(client, &project, slug).await?.id; let coordinate = ProjectCoordinate { owner: project.pubkey.to_hex(), slug: slug.to_owned(), @@ -337,12 +411,24 @@ async fn cmd_related_channel_revision( build_project_revision(&coordinate, &expected_revision, operation, channel_id) .map_err(crate::validate::sdk_err)?, )?; - let raw = client.submit_event(event).await?; + let raw = client + .submit_event(event) + .await + .map_err(map_project_revision_submit_error)?; let response = parse_write_response(&raw, "Project changed concurrently; refresh and retry")?; println!("{response}"); Ok(()) } +fn map_project_revision_submit_error(error: CliError) -> CliError { + match error { + CliError::Relay { body, .. } if body.starts_with("conflict:") => { + CliError::Conflict("Project changed concurrently; refresh and retry".into()) + } + error => error, + } +} + // ── Tag helpers ─────────────────────────────────────────────────────────────── fn tag_name(tag: &Tag) -> Option<&str> { @@ -602,6 +688,7 @@ pub async fn cmd_remove_repo( let head = fetch_own_project(client, slug) .await? .ok_or_else(|| CliError::NotFound(format!("project {slug:?} not found")))?; + let effective_revision = current_project_revision(client, &head, slug).await?; let next_ts = next_timestamp(&head, Timestamp::now())?; // Verify all requested repos exist in the project. @@ -624,7 +711,7 @@ pub async fn cmd_remove_repo( to_remove.iter().map(|m| m.coord.as_str()).collect(); // Keep all tags except auth and the removed members. - let tags: Vec = head + let mut tags: Vec = head .tags .iter() .filter(|t| { @@ -640,6 +727,16 @@ pub async fn cmd_remove_repo( }) .cloned() .collect(); + let home_channel = tags + .iter() + .find(|tag| tag_name(tag) == Some("buzz-channel")) + .and_then(tag_value) + .and_then(|value| value.parse::().ok()); + carry_effective_related_channels( + &mut tags, + &effective_revision.related_channels, + home_channel, + )?; // Single rebuild validates the full envelope and strips any remaining auth. let builder = rebuild_project(&head.content, tags, next_ts)?; @@ -694,6 +791,7 @@ pub async fn cmd_update( let head = fetch_own_project(client, slug) .await? .ok_or_else(|| CliError::NotFound(format!("project {slug:?} not found")))?; + let effective_revision = current_project_revision(client, &head, slug).await?; let next_ts = next_timestamp(&head, Timestamp::now())?; // Build the new tag set. For each singleton metadata field: @@ -706,7 +804,7 @@ pub async fn cmd_update( .tags .iter() .filter(|t| { - if tag_name(t) == Some("auth") { + if tag_name(t) == Some("auth") || tag_name(t) == Some("buzz-related-channel") { return false; } // Drop singletons we're replacing or clearing. @@ -740,6 +838,16 @@ pub async fn cmd_update( if let Some(vis) = visibility { tags.push(make_tag(&["buzz-visibility", vis])?); } + let home_channel = tags + .iter() + .find(|tag| tag_name(tag) == Some("buzz-channel")) + .and_then(tag_value) + .and_then(|value| value.parse::().ok()); + carry_effective_related_channels( + &mut tags, + &effective_revision.related_channels, + home_channel, + )?; let builder = build_project_with_tags(&head.content, tags) .map_err(|e| CliError::Other(format!("envelope validation failed: {e}")))? @@ -974,6 +1082,16 @@ mod tests { use super::*; + #[test] + fn project_revision_http_conflict_uses_conflict_exit_category() { + let error = map_project_revision_submit_error(CliError::Relay { + status: 400, + body: "conflict: expected revision is stale".into(), + }); + assert!(matches!(error, CliError::Conflict(_))); + assert_eq!(crate::error::exit_code(&error), 5); + } + async fn run_default_repo_create_race( winning_channel: &str, ) -> (Result<(), CliError>, Vec) { @@ -1387,6 +1505,26 @@ mod tests { assert!(rebuild_project("", tags, ts).is_ok()); } + #[test] + fn carrying_revision_channels_replaces_stale_base_membership() { + let home = Uuid::parse_str("11111111-1111-4111-8111-111111111111").unwrap(); + let stale = Uuid::parse_str("22222222-2222-4222-8222-222222222222").unwrap(); + let effective = Uuid::parse_str("33333333-3333-4333-8333-333333333333").unwrap(); + let mut tags = vec![ + make_test_tag(&["d", "platform"]), + make_test_tag(&["buzz-related-channel", &stale.to_string()]), + ]; + + carry_effective_related_channels(&mut tags, &[home, effective], Some(home)).unwrap(); + + let related: Vec<_> = tags + .iter() + .filter(|tag| tag_name(tag) == Some("buzz-related-channel")) + .filter_map(tag_value) + .collect(); + assert_eq!(related, vec![effective.to_string()]); + } + // ── clear-flag semantics ────────────────────────────────────────────────── /// Build a minimal head Event for testing update semantics without the relay. diff --git a/desktop/src/features/projects/createProject.ts b/desktop/src/features/projects/createProject.ts index 8adfe777a9..7003054cd9 100644 --- a/desktop/src/features/projects/createProject.ts +++ b/desktop/src/features/projects/createProject.ts @@ -187,6 +187,7 @@ async function ensureDefaultRepository({ const patched = buildProjectPatchTemplate({ liveHead, ownerPubkey, + relatedChannelIds: project.relatedChannelIds, repositoryAddresses: [ ...new Set([ ...project.repositoryAddresses, diff --git a/desktop/src/features/projects/projectChannelCreation.test.mjs b/desktop/src/features/projects/projectChannelCreation.test.mjs deleted file mode 100644 index 3782a91905..0000000000 --- a/desktop/src/features/projects/projectChannelCreation.test.mjs +++ /dev/null @@ -1,105 +0,0 @@ -import assert from "node:assert/strict"; -import { test } from "node:test"; - -import { buildProjectRelatedChannelPatchTemplate } from "./projectChannelCreation.ts"; -import { - MAX_PROJECT_RELATED_CHANNELS, - PROJECT_RELATED_CHANNEL_TAG, -} from "./projectModels.ts"; - -const OWNER = "a".repeat(64); -const OTHER = "b".repeat(64); -const CHANNEL_A = "11111111-1111-4111-8111-111111111111"; -const CHANNEL_B = "22222222-2222-4222-8222-222222222222"; - -function liveHead(tags = []) { - return { - content: "", - created_at: 100, - id: "project-head", - kind: 30621, - pubkey: OWNER, - sig: "sig", - tags: [ - ["d", "sprout"], - ["name", "Sprout"], - ["buzz-channel", CHANNEL_A], - ...tags, - ], - }; -} - -test("appends a related channel tag and preserves the live head", () => { - const patched = buildProjectRelatedChannelPatchTemplate({ - channelId: CHANNEL_B, - liveHead: liveHead([["description", "A project"]]), - ownerPubkey: OWNER, - }); - - assert.equal(patched.alreadyBound, false); - assert.deepEqual(patched.project.tags, [ - ["d", "sprout"], - ["name", "Sprout"], - ["buzz-channel", CHANNEL_A], - ["description", "A project"], - [PROJECT_RELATED_CHANNEL_TAG, CHANNEL_B], - ]); -}); - -test("is idempotent when the related channel is already tagged", () => { - const patched = buildProjectRelatedChannelPatchTemplate({ - channelId: CHANNEL_B, - liveHead: liveHead([[PROJECT_RELATED_CHANNEL_TAG, CHANNEL_B]]), - ownerPubkey: OWNER, - }); - - assert.equal(patched.alreadyBound, true); - assert.equal( - patched.project.tags.filter((tag) => tag[0] === PROJECT_RELATED_CHANNEL_TAG) - .length, - 1, - ); -}); - -test("refuses to bind the home channel as a related channel", () => { - assert.throws( - () => - buildProjectRelatedChannelPatchTemplate({ - channelId: CHANNEL_A, - liveHead: liveHead(), - ownerPubkey: OWNER, - }), - /already this project's home/, - ); -}); - -test("only the project owner can add related channels", () => { - assert.throws( - () => - buildProjectRelatedChannelPatchTemplate({ - channelId: CHANNEL_B, - liveHead: liveHead(), - ownerPubkey: OTHER, - }), - /Only the project owner/, - ); -}); - -test("caps extra related channels", () => { - const tags = Array.from( - { length: MAX_PROJECT_RELATED_CHANNELS }, - (_, index) => { - const suffix = String(index + 1).padStart(12, "0"); - return [PROJECT_RELATED_CHANNEL_TAG, `33333333-3333-4333-8333-${suffix}`]; - }, - ); - assert.throws( - () => - buildProjectRelatedChannelPatchTemplate({ - channelId: CHANNEL_B, - liveHead: liveHead(tags), - ownerPubkey: OWNER, - }), - /extra channels/, - ); -}); diff --git a/desktop/src/features/projects/projectChannelCreation.ts b/desktop/src/features/projects/projectChannelCreation.ts deleted file mode 100644 index a75425c23b..0000000000 --- a/desktop/src/features/projects/projectChannelCreation.ts +++ /dev/null @@ -1,71 +0,0 @@ -import type { RelayEvent } from "@/shared/api/types"; -import { KIND_PROJECT_ANNOUNCEMENT } from "@/shared/constants/kinds"; -import { - isValidProjectChannelId, - MAX_PROJECT_RELATED_CHANNELS, - PROJECT_RELATED_CHANNEL_TAG, - validateProjectEventEnvelope, -} from "@/features/projects/projectModels"; -import type { ProjectEventTemplate } from "./projectCreation"; - -/** - * Appends a `buzz-related-channel` tag to a live project head. Every other - * tag is preserved so adding a stream cannot erase unknown metadata. - */ -export function buildProjectRelatedChannelPatchTemplate({ - channelId, - liveHead, - ownerPubkey, -}: { - channelId: string; - liveHead: RelayEvent; - ownerPubkey: string; -}): { alreadyBound: boolean; project: ProjectEventTemplate } { - const normalizedOwner = ownerPubkey.trim().toLowerCase(); - if (normalizedOwner !== liveHead.pubkey.toLowerCase()) { - throw new Error("Only the project owner can add channels."); - } - const normalizedChannelId = channelId.trim(); - if (!isValidProjectChannelId(normalizedChannelId)) { - throw new Error("Project channel is invalid."); - } - const homeChannelId = liveHead.tags.find( - (tag) => tag[0] === "buzz-channel", - )?.[1]; - if (homeChannelId === normalizedChannelId) { - throw new Error("That channel is already this project's home."); - } - const existingRelated = liveHead.tags - .filter((tag) => tag[0] === PROJECT_RELATED_CHANNEL_TAG) - .map((tag) => tag[1]) - .filter((value): value is string => Boolean(value)); - if (existingRelated.includes(normalizedChannelId)) { - validateProjectEventEnvelope(liveHead.tags, liveHead.content); - return { - alreadyBound: true, - project: { - kind: KIND_PROJECT_ANNOUNCEMENT, - content: liveHead.content, - tags: liveHead.tags.map((tag) => [...tag]), - }, - }; - } - if (existingRelated.length >= MAX_PROJECT_RELATED_CHANNELS) { - throw new Error( - `A project cannot contain more than ${MAX_PROJECT_RELATED_CHANNELS} extra channels.`, - ); - } - const tags = [ - ...liveHead.tags.map((tag) => [...tag]), - [PROJECT_RELATED_CHANNEL_TAG, normalizedChannelId], - ]; - validateProjectEventEnvelope(tags, liveHead.content); - return { - alreadyBound: false, - project: { - kind: KIND_PROJECT_ANNOUNCEMENT, - content: liveHead.content, - tags, - }, - }; -} diff --git a/desktop/src/features/projects/projectModels.test.mjs b/desktop/src/features/projects/projectModels.test.mjs index 5515404fb6..5744598a68 100644 --- a/desktop/src/features/projects/projectModels.test.mjs +++ b/desktop/src/features/projects/projectModels.test.mjs @@ -134,6 +134,35 @@ test("buildProjectReadModels keeps extra related channel ids", () => { assert.deepEqual(projects[0].relatedChannelIds, [relatedA, relatedB]); }); +test("buildProjectReadModels normalizes channel UUID tags before folding revisions", () => { + const home = "11111111-1111-4111-8111-111111111111"; + const related = "abcdefab-cdef-4abc-8def-abcdefabcdef"; + const base = projectEvent([["buzz-related-channel", related.toUpperCase()]], { + id: "a".repeat(64), + tags: [ + ["d", "sprout"], + ["buzz-channel", home.toUpperCase()], + ["buzz-related-channel", related.toUpperCase()], + ], + }); + const [project] = buildProjectReadModels({ + projectEvents: [base], + projectRevisionEvents: [ + projectRevision( + "b".repeat(64), + base.id, + "remove-related-channel", + related.toUpperCase(), + ), + ], + repositoryEvents: [], + relayOrigin: RELAY_ORIGIN, + }); + + assert.equal(project.projectChannelId, home); + assert.deepEqual(project.relatedChannelIds, []); +}); + test("buildProjectReadModels folds the relay-authorized related-channel revision chain", () => { const relatedA = "22222222-2222-4222-8222-222222222222"; const relatedB = "33333333-3333-4333-8333-333333333333"; diff --git a/desktop/src/features/projects/projectModels.ts b/desktop/src/features/projects/projectModels.ts index 9f36f6308b..771c90e454 100644 --- a/desktop/src/features/projects/projectModels.ts +++ b/desktop/src/features/projects/projectModels.ts @@ -130,6 +130,11 @@ export function isValidProjectChannelId(value: string): boolean { ); } +function normalizedProjectChannelId(value: string): string | null { + const normalized = value.trim().toLowerCase(); + return isValidProjectChannelId(normalized) ? normalized : null; +} + /** Repeatable project tag naming an extra stream besides `buzz-channel`. */ export const PROJECT_RELATED_CHANNEL_TAG = "buzz-related-channel"; @@ -366,14 +371,15 @@ export function eventToExplicitProject( const visibility = rawVisibility === "unlisted" ? ("unlisted" as const) : ("listed" as const); const channel = getTag(event, "buzz-channel"); - const projectChannelId = - channel && isValidProjectChannelId(channel) ? channel : null; + const projectChannelId = channel ? normalizedProjectChannelId(channel) : null; const relatedChannelIds = [ ...new Set( - getAllTags(event, PROJECT_RELATED_CHANNEL_TAG).filter( - (channelId) => - isValidProjectChannelId(channelId) && channelId !== projectChannelId, - ), + getAllTags(event, PROJECT_RELATED_CHANNEL_TAG).flatMap((channelId) => { + const normalized = normalizedProjectChannelId(channelId); + return normalized && normalized !== projectChannelId + ? [normalized] + : []; + }), ), ].slice(0, MAX_PROJECT_RELATED_CHANNELS); return { @@ -559,8 +565,11 @@ export function applyProjectRevisionEvents( ); if (!next) break; const operation = singletonRevisionTag(next, "op"); - const channelId = singletonRevisionTag(next, "channel"); - if (!channelId || !isValidProjectChannelId(channelId)) break; + const rawChannelId = singletonRevisionTag(next, "channel"); + const channelId = rawChannelId + ? normalizedProjectChannelId(rawChannelId) + : null; + if (!channelId) break; if (operation === "add-related-channel") { if ( channelId === project.projectChannelId || @@ -656,11 +665,16 @@ export function addRelatedChannelToProject( channelId: string, createdAt: number, ): Project { + const normalizedChannelId = normalizedProjectChannelId(channelId); const relatedChannelIds = [ - ...new Set([...(project.relatedChannelIds ?? []), channelId]), - ].filter( - (id) => id !== project.projectChannelId && isValidProjectChannelId(id), - ); + ...new Set([ + ...(project.relatedChannelIds ?? []).flatMap((id) => { + const normalized = normalizedProjectChannelId(id); + return normalized ? [normalized] : []; + }), + ...(normalizedChannelId ? [normalizedChannelId] : []), + ]), + ].filter((id) => id !== project.projectChannelId?.toLowerCase()); return { ...project, createdAt, diff --git a/desktop/src/features/projects/projectRepositoryCreation.test.mjs b/desktop/src/features/projects/projectRepositoryCreation.test.mjs index a7bcc5bccc..aab5740ee0 100644 --- a/desktop/src/features/projects/projectRepositoryCreation.test.mjs +++ b/desktop/src/features/projects/projectRepositoryCreation.test.mjs @@ -88,6 +88,33 @@ test("buildProjectPatchTemplate preserves unknown tags from the live head", () = assert.deepEqual(memberTags.sort(), [existingAddress, newAddress].sort()); }); +test("buildProjectPatchTemplate carries folded related channels into a new base", () => { + const related = "22222222-2222-4222-8222-222222222222"; + const liveHead = { + id: "e".repeat(64), + kind: 30621, + pubkey: OWNER, + created_at: 100, + content: "", + tags: [ + ["d", "platform"], + ["buzz-channel", "11111111-1111-4111-8111-111111111111"], + ], + }; + + const template = buildProjectPatchTemplate({ + liveHead, + ownerPubkey: OWNER, + relatedChannelIds: [related.toUpperCase()], + repositoryAddresses: [], + }); + + assert.deepEqual( + template.tags.filter((tag) => tag[0] === "buzz-related-channel"), + [["buzz-related-channel", related]], + ); +}); + test("buildProjectPatchTemplate preserves relay hints on existing members", () => { const OWNER = "a".repeat(64); const OTHER = "b".repeat(64); diff --git a/desktop/src/features/projects/projectRepositoryCreation.ts b/desktop/src/features/projects/projectRepositoryCreation.ts index 08da3a4118..d7b391a792 100644 --- a/desktop/src/features/projects/projectRepositoryCreation.ts +++ b/desktop/src/features/projects/projectRepositoryCreation.ts @@ -3,6 +3,8 @@ import type { Repository } from "@/features/projects/hooks"; import { isValidProjectChannelId, MAX_PROJECT_MEMBERS, + MAX_PROJECT_RELATED_CHANNELS, + PROJECT_RELATED_CHANNEL_TAG, validateProjectEventEnvelope, } from "@/features/projects/projectModels"; import { @@ -22,10 +24,11 @@ export { repositoryDtagFromName }; /** * Creates a project-replacement event template from a live, signed raw head - * (fetched immediately before the mutation). Only the `a` membership tags are - * patched; every other tag and the `content` field are preserved verbatim, - * satisfying NIP-MP's extension-tag preservation rule and preventing a cached - * UI projection from silently erasing unknown tags. + * (fetched immediately before the mutation). Repository membership is patched, + * and callers with a folded Project model carry its effective related channels + * into the new base. Every other tag and the `content` field are preserved + * verbatim, satisfying NIP-MP's extension-tag preservation rule and preventing + * a cached UI projection from silently erasing unknown tags. * * Performs full NIP-MP envelope validation on the patched output via the shared * `validateProjectEventEnvelope` validator — the same checks applied by the @@ -35,10 +38,12 @@ export { repositoryDtagFromName }; function buildProjectPatchTemplate({ liveHead, ownerPubkey, + relatedChannelIds, repositoryAddresses, }: { liveHead: RelayEvent; ownerPubkey: string; + relatedChannelIds?: string[]; repositoryAddresses: string[]; }): ProjectEventTemplate { const normalizedOwner = ownerPubkey.trim().toLowerCase(); @@ -64,7 +69,12 @@ function buildProjectPatchTemplate({ // Replace all existing `a` tags with the new set, preserving everything else // (d, name, description, buzz-channel, buzz-visibility, relay hints embedded // in `a` tags, and any future/unknown tags). - const nonMemberTags = liveHead.tags.filter((tag) => tag[0] !== "a"); + const nonMemberTags = liveHead.tags.filter( + (tag) => + tag[0] !== "a" && + (relatedChannelIds === undefined || + tag[0] !== PROJECT_RELATED_CHANNEL_TAG), + ); const existingHints = new Map(); for (const tag of liveHead.tags) { if (tag[0] === "a" && tag[1] && tag[2]) { @@ -76,7 +86,27 @@ function buildProjectPatchTemplate({ return hint ? ["a", address, hint] : ["a", address]; }); - const patchedTags = [...nonMemberTags, ...memberTags]; + const homeChannelId = liveHead.tags + .find((tag) => tag[0] === "buzz-channel")?.[1] + ?.toLowerCase(); + const relatedChannelTags = [ + ...new Set( + (relatedChannelIds ?? []).map((channelId) => + channelId.trim().toLowerCase(), + ), + ), + ] + .filter( + (channelId) => + isValidProjectChannelId(channelId) && channelId !== homeChannelId, + ) + .slice(0, MAX_PROJECT_RELATED_CHANNELS) + .map((channelId) => [PROJECT_RELATED_CHANNEL_TAG, channelId]); + const patchedTags = [ + ...nonMemberTags, + ...(relatedChannelIds === undefined ? [] : relatedChannelTags), + ...memberTags, + ]; const content = liveHead.content; // Validate the full patched envelope against NIP-MP rules. This catches @@ -161,6 +191,7 @@ export function buildAddedRepositoryEventTemplatesFromHead({ liveHead, name, ownerPubkey, + relatedChannelIds, repositoryHeadExists = true, webUrl, }: { @@ -171,6 +202,7 @@ export function buildAddedRepositoryEventTemplatesFromHead({ liveHead: RelayEvent; name: string; ownerPubkey: string; + relatedChannelIds?: string[]; /** * Whether a kind-30617 head already exists at the new coordinate. When the * live project head references the coordinate but no repository head exists @@ -248,6 +280,7 @@ export function buildAddedRepositoryEventTemplatesFromHead({ const projectTemplate = buildProjectPatchTemplate({ liveHead, ownerPubkey, + relatedChannelIds, repositoryAddresses: newAddresses, }); diff --git a/desktop/src/features/projects/ui/ProjectChannelManagement.test.mjs b/desktop/src/features/projects/ui/ProjectChannelManagement.test.mjs new file mode 100644 index 0000000000..b59c39d320 --- /dev/null +++ b/desktop/src/features/projects/ui/ProjectChannelManagement.test.mjs @@ -0,0 +1,31 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { canManageProjectChannels } from "./ProjectChannelManagement.tsx"; + +const OWNER = "a".repeat(64); +const VIEWER = "b".repeat(64); + +function project(overrides = {}) { + return { + legacy: false, + owner: OWNER, + ...overrides, + }; +} + +test("Project owner and home-channel owner/admin can manage related channels", () => { + assert.equal(canManageProjectChannels(project(), OWNER), true); + assert.equal(canManageProjectChannels(project(), VIEWER, "owner"), true); + assert.equal(canManageProjectChannels(project(), VIEWER, "admin"), true); +}); + +test("members, guests, bots, unrelated identities, and legacy Projects cannot manage channels", () => { + for (const role of ["member", "guest", "bot", undefined]) { + assert.equal(canManageProjectChannels(project(), VIEWER, role), false); + } + assert.equal( + canManageProjectChannels(project({ legacy: true }), OWNER, "owner"), + false, + ); +}); diff --git a/desktop/src/features/projects/ui/ProjectChannelManagement.tsx b/desktop/src/features/projects/ui/ProjectChannelManagement.tsx index 5e5ad39be8..048e63d2dd 100644 --- a/desktop/src/features/projects/ui/ProjectChannelManagement.tsx +++ b/desktop/src/features/projects/ui/ProjectChannelManagement.tsx @@ -7,6 +7,7 @@ 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"; +import type { ChannelMember } from "@/shared/api/types"; import { Button } from "@/shared/ui/button"; export function ProjectChannelManagement({ @@ -70,6 +71,14 @@ export function useCanManageProjectChannels( const viewerHomeRole = homeMembersQuery.data?.find( (member) => member.pubkey.toLowerCase() === identityPubkey?.toLowerCase(), )?.role; + return canManageProjectChannels(project, identityPubkey, viewerHomeRole); +} + +export function canManageProjectChannels( + project: Project, + identityPubkey?: string, + viewerHomeRole?: ChannelMember["role"], +): boolean { return ( !project.legacy && (identityPubkey?.toLowerCase() === project.owner.toLowerCase() || diff --git a/desktop/src/features/projects/ui/ProjectsCategoryCreateDialogs.tsx b/desktop/src/features/projects/ui/ProjectsCategoryCreateDialogs.tsx index e0746ae14d..819a33941d 100644 --- a/desktop/src/features/projects/ui/ProjectsCategoryCreateDialogs.tsx +++ b/desktop/src/features/projects/ui/ProjectsCategoryCreateDialogs.tsx @@ -1,17 +1,25 @@ +import { useQueries } from "@tanstack/react-query"; import * as React from "react"; import { toast } from "sonner"; import { useAppNavigation } from "@/app/navigation/useAppNavigation"; import { useChannelsQuery } from "@/features/channels/hooks"; +import { + CHANNEL_MEMBERS_STALE_TIME_MS, + channelMembersQueryKey, +} from "@/features/channels/rosterFreshness"; import type { Project } from "@/features/projects/hooks"; import { useAddProjectChannelMutation } from "@/features/projects/useAddProjectChannel"; import { useAddProjectRepositoryMutation } from "@/features/projects/useAddProjectRepository"; import { AddProjectRepositoryDialog } from "@/features/projects/ui/AddProjectRepositoryDialog"; import { CreateChannelDialog } from "@/features/sidebar/ui/CreateChannelDialog"; +import { getChannelMembers } from "@/shared/api/tauri"; +import { canManageProjectChannels } from "@/features/projects/ui/ProjectChannelManagement"; export function ProjectsCategoryCreateDialogs({ channelOpen, editableProjects, + identityPubkey, onChannelOpenChange, onRepositoryOpenChange, ownerControlAgentPubkeyFor, @@ -19,16 +27,40 @@ export function ProjectsCategoryCreateDialogs({ }: { channelOpen: boolean; editableProjects: Project[]; + identityPubkey?: string; onChannelOpenChange: (open: boolean) => void; onRepositoryOpenChange: (open: boolean) => void; ownerControlAgentPubkeyFor: (project: Project) => string | undefined; repositoryOpen: boolean; }) { const { goChannel, goProject } = useAppNavigation(); + const homeRosterQueries = useQueries({ + queries: editableProjects.map((project) => { + const homeChannelId = project.projectChannelId; + return { + enabled: + channelOpen && + Boolean(homeChannelId) && + project.owner.toLowerCase() !== identityPubkey?.toLowerCase(), + queryFn: () => { + if (!homeChannelId) throw new Error("Project has no home channel."); + return getChannelMembers(homeChannelId); + }, + queryKey: channelMembersQueryKey(homeChannelId ?? "none"), + staleTime: CHANNEL_MEMBERS_STALE_TIME_MS, + }; + }), + }); + const channelProjects = editableProjects.filter((project, index) => { + const role = homeRosterQueries[index]?.data?.find( + (member) => member.pubkey.toLowerCase() === identityPubkey?.toLowerCase(), + )?.role; + return canManageProjectChannels(project, identityPubkey, role); + }); const [channelProjectId, setChannelProjectId] = React.useState(""); const channelProject = - editableProjects.find((project) => project.id === channelProjectId) ?? - editableProjects[0]; + channelProjects.find((project) => project.id === channelProjectId) ?? + channelProjects[0]; const createChannelMutation = useAddProjectChannelMutation(); const createRepositoryMutation = useAddProjectRepositoryMutation(); const channelsQuery = useChannelsQuery({ enabled: repositoryOpen }); @@ -57,7 +89,6 @@ export function ProjectsCategoryCreateDialogs({ if (!channelProject) throw new Error("Choose a project."); const result = await createChannelMutation.mutateAsync({ ...input, - ownerControlAgentPubkey: ownerControlAgentPubkeyFor(channelProject), project: channelProject, }); toast.success(`Channel "#${result.channel.name}" created.`); @@ -76,7 +107,7 @@ export function ProjectsCategoryCreateDialogs({ onChange={(event) => setChannelProjectId(event.target.value)} value={channelProject?.id ?? ""} > - {editableProjects.map((project) => ( + {channelProjects.map((project) => ( diff --git a/desktop/src/features/projects/ui/ProjectsView.tsx b/desktop/src/features/projects/ui/ProjectsView.tsx index f79cf910cb..6c26c7a908 100644 --- a/desktop/src/features/projects/ui/ProjectsView.tsx +++ b/desktop/src/features/projects/ui/ProjectsView.tsx @@ -781,6 +781,7 @@ export function ProjectsView() { 0, }); diff --git a/desktop/src/features/projects/useAttachProjectRepository.ts b/desktop/src/features/projects/useAttachProjectRepository.ts index c43f215762..a96e7181c0 100644 --- a/desktop/src/features/projects/useAttachProjectRepository.ts +++ b/desktop/src/features/projects/useAttachProjectRepository.ts @@ -57,6 +57,7 @@ async function attachProjectRepository({ const template = buildProjectPatchTemplate({ liveHead, ownerPubkey: targetOwner, + relatedChannelIds: project.relatedChannelIds, repositoryAddresses: [...liveAddresses, repository.repoAddress], }); const createdAt = Math.max( diff --git a/desktop/src/features/projects/useProjectChannelRequests.ts b/desktop/src/features/projects/useProjectChannelRequests.ts index 8c95ff15c7..d5b252d897 100644 --- a/desktop/src/features/projects/useProjectChannelRequests.ts +++ b/desktop/src/features/projects/useProjectChannelRequests.ts @@ -4,7 +4,10 @@ import { toast } from "sonner"; import { classifyAgentManagementOrigin } from "@/features/agents/agentManagementBuffer"; import { subscribeProjectChannelRequests } from "@/features/agents/observerRelayStore"; import { useManagedAgentsQuery } from "@/features/agents/hooks"; -import { useChannelsQuery } from "@/features/channels/hooks"; +import { + useChannelMembersQuery, + useChannelsQuery, +} from "@/features/channels/hooks"; import { useChannelTemplatesQuery } from "@/features/channel-templates/hooks"; import { useProjectsQuery } from "@/features/projects/hooks"; import type { ProjectChannelRequest } from "@/features/projects/projectChannelRequest"; @@ -15,6 +18,7 @@ import { type AcceptedProjectChannelRequest, } from "@/features/projects/projectChannelRequestQueue"; import { useAddProjectChannelMutation } from "@/features/projects/useAddProjectChannel"; +import { canManageProjectChannels } from "@/features/projects/ui/ProjectChannelManagement"; import { useIdentityQuery } from "@/shared/api/hooks"; import { normalizePubkey } from "@/shared/lib/pubkey"; @@ -119,6 +123,9 @@ export function useProjectChannelRequests() { candidate.name.trim().toLocaleLowerCase() === request.request.templateName?.trim().toLocaleLowerCase(), ) ?? null); + const homeMembersQuery = useChannelMembersQuery( + project?.projectChannelId ?? null, + ); function dismiss() { if (mutation.isPending) return; @@ -133,10 +140,14 @@ export function useProjectChannelRequests() { return; } const identity = identityQuery.data?.pubkey; - const owner = normalizePubkey(project.owner); - const agent = normalizePubkey(sourceAgentPubkey); - if ((!identity || normalizePubkey(identity) !== owner) && agent !== owner) { - setError("Only the project owner can approve this channel."); + const viewerRole = homeMembersQuery.data?.find( + (member) => + normalizePubkey(member.pubkey) === normalizePubkey(identity ?? ""), + )?.role; + if (!canManageProjectChannels(project, identity, viewerRole)) { + setError( + "Only the project owner or a home-channel admin can approve this channel.", + ); return; } if (request.request.templateName && !template) { @@ -150,8 +161,6 @@ export function useProjectChannelRequests() { await mutation.mutateAsync({ description: request.request.description, name: request.request.name, - ownerControlAgentPubkey: - agent === owner ? sourceAgentPubkey : undefined, project, templateId: template?.id, ttlSeconds: request.request.ttlSeconds,