From a36f7a4cfca6c66c54a80e4586c130f490031738 Mon Sep 17 00:00:00 2001 From: Wes Date: Fri, 21 Aug 2026 18:36:30 -0600 Subject: [PATCH 1/2] fix(projects): allow owners to delete agent projects Co-authored-by: Wes Co-authored-by: Carl <32a2e2c9d428ee08902cab75d956da2c1d235a22d4766b0dd4138bf6e2e5db1d@buzz.block.builderlab.xyz> Signed-off-by: Wes --- crates/buzz-test-client/tests/e2e_project.rs | 118 ++++++++++++++++- .../features/projects/deleteProject.test.mjs | 122 ++++++++++++++++++ desktop/src/features/projects/hooks.ts | 23 +--- .../projects/projectDeletion.test.mjs | 58 +++++++++ .../src/features/projects/projectDeletion.ts | 114 ++++++++++++++++ .../features/projects/projectModels.test.mjs | 14 +- .../src/features/projects/projectModels.ts | 15 +-- .../projects/ui/ProjectsOverviewItems.tsx | 20 ++- .../src/features/projects/ui/ProjectsView.tsx | 5 +- .../projects/useProjectDeletionAccess.ts | 32 +++++ .../sidebar/ui/SidebarProjectsSection.tsx | 17 ++- 11 files changed, 485 insertions(+), 53 deletions(-) create mode 100644 desktop/src/features/projects/deleteProject.test.mjs create mode 100644 desktop/src/features/projects/projectDeletion.test.mjs create mode 100644 desktop/src/features/projects/projectDeletion.ts create mode 100644 desktop/src/features/projects/useProjectDeletionAccess.ts diff --git a/crates/buzz-test-client/tests/e2e_project.rs b/crates/buzz-test-client/tests/e2e_project.rs index c0a05e46740..4f0d34e61e8 100644 --- a/crates/buzz-test-client/tests/e2e_project.rs +++ b/crates/buzz-test-client/tests/e2e_project.rs @@ -25,6 +25,7 @@ use std::time::Duration; +use buzz_sdk::nip_oa; use buzz_test_client::BuzzTestClient; use nostr::{Alphabet, EventBuilder, Filter, Keys, Kind, SingleLetterTag, Tag, Timestamp}; @@ -92,8 +93,14 @@ fn repo_announcement(keys: &Keys, repo_d: &str) -> nostr::Event { /// A NIP-09 `a`-tag-only deletion at a NIP-33 coordinate. No `e` tag, so the /// relay takes the coordinate-delete path rather than the event-id path. /// `created_at` defaults to now when `None`. -fn coordinate_delete(keys: &Keys, kind: u16, d_tag: &str, created_at: Option) -> nostr::Event { - let coord = format!("{kind}:{}:{d_tag}", keys.public_key().to_hex()); +fn coordinate_delete_for_author( + signer: &Keys, + author: &Keys, + kind: u16, + d_tag: &str, + created_at: Option, +) -> nostr::Event { + let coord = format!("{kind}:{}:{d_tag}", author.public_key().to_hex()); let builder = EventBuilder::new(Kind::Custom(5), "") .tags(vec![Tag::parse(["a", coord.as_str()]).unwrap()]); @@ -101,10 +108,28 @@ fn coordinate_delete(keys: &Keys, kind: u16, d_tag: &str, created_at: Option builder.custom_created_at(Timestamp::from(ts)), None => builder, } - .sign_with_keys(keys) + .sign_with_keys(signer) .unwrap() } +fn coordinate_delete(keys: &Keys, kind: u16, d_tag: &str, created_at: Option) -> nostr::Event { + coordinate_delete_for_author(keys, keys, kind, d_tag, created_at) +} + +async fn connect_agent_with_owner(agent: &Keys, owner: &Keys) -> BuzzTestClient { + let tag_json = nip_oa::compute_auth_tag(owner, &agent.public_key(), "kind=9") + .expect("compute NIP-OA auth tag"); + let auth_tag = nip_oa::parse_auth_tag(&tag_json).expect("parse NIP-OA auth tag"); + let mut client = BuzzTestClient::connect_unauthenticated(&relay_url()) + .await + .expect("connect agent unauthenticated"); + client + .authenticate_with_nip_oa(agent, &auth_tag) + .await + .expect("authenticate agent with NIP-OA owner"); + client +} + fn addressable_filter(kind: u16, author: &Keys, d_tag: &str) -> Filter { Filter::new() .kind(Kind::Custom(kind)) @@ -345,6 +370,93 @@ async fn test_project_tombstone_deletes_coordinate_and_spares_members() { client.disconnect().await.expect("disconnect"); } +/// NIP-OA extends NIP-09 coordinate ownership: a human owner may delete an +/// agent-authored project, while an unrelated signer must be rejected without +/// changing the live project head. +#[tokio::test] +#[ignore] +async fn test_agent_owner_can_delete_agent_project_but_third_party_cannot() { + let agent = Keys::generate(); + let owner = Keys::generate(); + let third_party = Keys::generate(); + let project_d = unique("agent-owned-project"); + + let mut agent_client = connect_agent_with_owner(&agent, &owner).await; + let ok = agent_client + .send_event(project_event( + &agent, + &project_d, + "Agent project", + &[], + None, + )) + .await + .expect("send agent project"); + assert!(ok.accepted, "relay rejected agent project: {}", ok.message); + + let mut third_party_client = BuzzTestClient::connect(&relay_url(), &third_party) + .await + .expect("connect third party"); + let ok = third_party_client + .send_event(coordinate_delete_for_author( + &third_party, + &agent, + PROJECT_KIND, + &project_d, + None, + )) + .await + .expect("send third-party tombstone"); + assert!( + !ok.accepted, + "unrelated signer deleted an agent-owned project" + ); + let still_live = query( + &mut third_party_client, + "agent-owner-third-party-rejected", + addressable_filter(PROJECT_KIND, &agent, &project_d), + ) + .await; + assert_eq!( + still_live.len(), + 1, + "rejected tombstone changed project state" + ); + + let mut owner_client = BuzzTestClient::connect(&relay_url(), &owner) + .await + .expect("connect owner"); + let ok = owner_client + .send_event(coordinate_delete_for_author( + &owner, + &agent, + PROJECT_KIND, + &project_d, + None, + )) + .await + .expect("send owner tombstone"); + assert!( + ok.accepted, + "relay rejected owner deletion of agent project: {}", + ok.message + ); + let deleted = query( + &mut owner_client, + "agent-owner-deleted", + addressable_filter(PROJECT_KIND, &agent, &project_d), + ) + .await; + assert!(deleted.is_empty(), "owner tombstone left project live"); + + agent_client.disconnect().await.expect("disconnect agent"); + third_party_client + .disconnect() + .await + .expect("disconnect third party"); + owner_client.disconnect().await.expect("disconnect owner"); +} + /// NIP-09 scopes an `a`-tag deletion to versions at or before the deletion's own /// `created_at`. A tombstone signed between V1 and V2 — delayed in transit or /// replayed by a third party — must therefore retire V1 only and leave the newer diff --git a/desktop/src/features/projects/deleteProject.test.mjs b/desktop/src/features/projects/deleteProject.test.mjs new file mode 100644 index 00000000000..e2d018cfeae --- /dev/null +++ b/desktop/src/features/projects/deleteProject.test.mjs @@ -0,0 +1,122 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { deleteProject } from "./projectDeletion.ts"; + +const OWNER = "a".repeat(64); +const VIEWER = "b".repeat(64); +const PROJECT_ADDRESS = `30621:${OWNER}:platform`; +const project = { + createdAt: 50, + description: "", + dtag: "platform", + id: PROJECT_ADDRESS, + legacy: false, + name: "Platform", + owner: OWNER, + primaryRepositoryAddress: `30617:${OWNER}:repo`, + projectAddress: PROJECT_ADDRESS, + projectChannelId: null, + repositories: [], + repositoryAddresses: [`30617:${OWNER}:repo`], + status: "active", +}; + +function event(overrides = {}) { + return { + id: "1".repeat(64), + kind: 30621, + pubkey: OWNER, + created_at: 75, + content: "", + tags: [["d", "platform"]], + ...overrides, + }; +} + +test("deleteProject lets the relay authorize an agent owner and tombstones only the project", async () => { + const calls = []; + await deleteProject(project, { + fetchEvents: async (filter) => { + calls.push(["fetch", filter]); + return calls.filter(([type]) => type === "fetch").length === 1 + ? [event()] + : []; + }, + nowSeconds: () => 74, + signEvent: async (template) => { + calls.push(["sign", template]); + return event({ + pubkey: VIEWER, + kind: template.kind, + content: template.content, + created_at: template.createdAt, + tags: template.tags, + }); + }, + publishEvent: async (signed) => { + calls.push(["publish", signed]); + }, + }); + + assert.deepEqual(calls[0][1], { + kinds: [30621], + authors: [OWNER], + "#d": ["platform"], + limit: 1, + }); + assert.deepEqual(calls[1][1].tags, [["a", PROJECT_ADDRESS]]); + assert.equal(calls[1][1].createdAt, 76); + assert.equal(calls[2][1].pubkey, VIEWER); + assert.deepEqual(calls[3][1], calls[0][1]); + assert.equal(calls[1][1].content, "Delete project Platform"); +}); + +test("deleteProject builds a one-coordinate tombstone", async () => { + const calls = []; + await deleteProject(project, { + fetchEvents: async () => (calls.length === 0 ? [event()] : []), + signEvent: async (template) => { + calls.push(template); + return event({ + kind: template.kind, + content: template.content, + created_at: template.createdAt, + tags: template.tags, + }); + }, + publishEvent: async () => {}, + }); + + assert.equal(calls[0].kind, 5); + assert.deepEqual(calls[0].tags, [["a", PROJECT_ADDRESS]]); +}); + +test("deleteProject fails closed when the live project head is missing", async () => { + await assert.rejects( + deleteProject(project, { fetchEvents: async () => [] }), + /Could not find this project on the relay/, + ); +}); + +test("deleteProject reports a concurrent replacement that survives", async () => { + let fetchCount = 0; + await assert.rejects( + deleteProject(project, { + fetchEvents: async () => { + fetchCount += 1; + return [event({ created_at: fetchCount === 1 ? 75 : 77 })]; + }, + nowSeconds: () => 74, + signEvent: async (template) => + event({ + pubkey: VIEWER, + kind: template.kind, + created_at: template.createdAt, + tags: template.tags, + }), + publishEvent: async () => {}, + }), + /updated while it was being deleted/, + ); +}); diff --git a/desktop/src/features/projects/hooks.ts b/desktop/src/features/projects/hooks.ts index 432fa85e02b..74e4244d1a7 100644 --- a/desktop/src/features/projects/hooks.ts +++ b/desktop/src/features/projects/hooks.ts @@ -14,7 +14,6 @@ import { listProjectLocalRepositories, } from "@/shared/api/projectGit"; import { - KIND_DELETION, KIND_GIT_ISSUE, KIND_GIT_PATCH, KIND_GIT_PR_UPDATE, @@ -59,6 +58,7 @@ import { projectPullRequestEventsToPullRequests, } from "./projectPullRequests.mjs"; import { fetchProjectsWorkItems } from "./projectWorkItems"; +import { deleteProject } from "./projectDeletion"; import { eventToRepository, type Project, @@ -621,25 +621,6 @@ async function fetchProjectActivitySummaries( ); } -async function deleteProject(project: Project): Promise { - const identity = await getIdentity(); - if (identity.pubkey.toLowerCase() !== project.owner.toLowerCase()) { - throw new Error("Only the project owner can delete this project."); - } - - const event = await signRelayEvent({ - kind: KIND_DELETION, - content: `Delete project ${project.name}`, - tags: [["a", project.projectAddress]], - }); - - await relayClient.publishEvent( - event, - "Timed out deleting project.", - "Failed to delete project.", - ); -} - export const projectsQueryKey = ["projects"] as const; /** @@ -984,7 +965,7 @@ export function useDeleteProjectMutation() { const queryClient = useQueryClient(); return useMutation({ - mutationFn: deleteProject, + mutationFn: (project: Project) => deleteProject(project), onSuccess: (_data, project) => { queryClient.setQueryData(projectsQueryKey, (current = []) => current.filter((item) => item.id !== project.id), diff --git a/desktop/src/features/projects/projectDeletion.test.mjs b/desktop/src/features/projects/projectDeletion.test.mjs new file mode 100644 index 00000000000..58d8ead63a0 --- /dev/null +++ b/desktop/src/features/projects/projectDeletion.test.mjs @@ -0,0 +1,58 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + buildProjectDeletionTemplate, + canDeleteProject, +} from "./projectDeletion.ts"; + +const AGENT = "a".repeat(64); +const OWNER = "b".repeat(64); +const OTHER = "c".repeat(64); +const PROJECT_ADDRESS = `30621:${AGENT}:platform`; +const project = { + name: "Platform", + owner: AGENT, + projectAddress: PROJECT_ADDRESS, +}; + +test("project deletion capability includes direct and agent owners", () => { + assert.equal(canDeleteProject(project, AGENT, undefined, new Set()), true); + assert.equal( + canDeleteProject(project, OWNER, undefined, new Set([AGENT])), + true, + ); + assert.equal( + canDeleteProject( + project, + OWNER, + { [AGENT]: { ownerPubkey: OWNER } }, + new Set(), + ), + true, + ); +}); + +test("project deletion capability rejects unrelated viewers", () => { + assert.equal( + canDeleteProject( + project, + OTHER, + { [AGENT]: { ownerPubkey: OWNER } }, + new Set(), + ), + false, + ); +}); + +test("project deletion tombstone targets only the container and dominates its head", () => { + assert.deepEqual( + buildProjectDeletionTemplate(project, { created_at: 101 }, 100), + { + kind: 5, + content: "Delete project Platform", + createdAt: 102, + tags: [["a", PROJECT_ADDRESS]], + }, + ); +}); diff --git a/desktop/src/features/projects/projectDeletion.ts b/desktop/src/features/projects/projectDeletion.ts new file mode 100644 index 00000000000..1f51e61b6ca --- /dev/null +++ b/desktop/src/features/projects/projectDeletion.ts @@ -0,0 +1,114 @@ +import type { UserProfileLookup } from "@/features/profile/lib/identity"; +import { ownsAuthorAgent } from "@/features/profile/lib/identity"; +import { relayClient } from "@/shared/api/relayClient"; +import type { RelayEvent } from "@/shared/api/types"; +import { signRelayEvent } from "@/shared/api/tauri"; +import { + KIND_DELETION, + KIND_PROJECT_ANNOUNCEMENT, + KIND_REPO_ANNOUNCEMENT, +} from "@/shared/constants/kinds"; +import { normalizePubkey } from "@/shared/lib/pubkey"; +import type { Project } from "./projectModels"; + +export type DeleteProjectEventTemplate = { + kind: number; + content: string; + createdAt: number; + tags: string[][]; +}; + +/** + * Buzz lets a human owner manage content authored by their NIP-OA agent, just + * as it does for agent-owned channels. Local managed-agent records are also + * sufficient capability evidence; the relay remains the final authority. + */ +export function canDeleteProject( + project: Pick, + currentPubkey: string | undefined, + profiles: UserProfileLookup | undefined, + managedAgentPubkeys: ReadonlySet, +): boolean { + if (!currentPubkey) return false; + + const owner = normalizePubkey(project.owner); + return ( + owner === normalizePubkey(currentPubkey) || + managedAgentPubkeys.has(owner) || + ownsAuthorAgent(profiles?.[owner], currentPubkey) + ); +} + +/** Build a tombstone that dominates the exact live coordinate head. */ +export function buildProjectDeletionTemplate( + project: Pick, + liveHead: Pick, + nowSeconds = Math.floor(Date.now() / 1_000), +): DeleteProjectEventTemplate { + return { + kind: KIND_DELETION, + content: `Delete project ${project.name}`, + createdAt: Math.max(nowSeconds, liveHead.created_at + 1), + tags: [["a", project.projectAddress]], + }; +} + +type ProjectDeletionFetchEvents = (filter: { + kinds: number[]; + authors: string[]; + "#d": string[]; + limit: number; +}) => Promise; + +type ProjectDeletionDeps = { + fetchEvents: ProjectDeletionFetchEvents; + nowSeconds: () => number; + publishEvent: ( + event: RelayEvent, + timeoutMessage: string, + failureMessage: string, + ) => Promise; + signEvent: (input: DeleteProjectEventTemplate) => Promise; +}; + +/** Delete the exact live project coordinate and detect a concurrent replacement. */ +export async function deleteProject( + project: Project, + deps?: Partial, +): Promise { + const { + fetchEvents = relayClient.fetchEvents.bind(relayClient), + nowSeconds = () => Math.floor(Date.now() / 1_000), + publishEvent = relayClient.publishEvent.bind(relayClient), + signEvent = signRelayEvent, + } = deps ?? {}; + const filter = { + kinds: [ + project.legacy ? KIND_REPO_ANNOUNCEMENT : KIND_PROJECT_ANNOUNCEMENT, + ], + authors: [project.owner.toLowerCase()], + "#d": [project.dtag], + limit: 1, + }; + const [liveHead] = await fetchEvents(filter); + if (!liveHead) { + throw new Error( + "Could not find this project on the relay. Refresh and try again.", + ); + } + + const event = await signEvent( + buildProjectDeletionTemplate(project, liveHead, nowSeconds()), + ); + await publishEvent( + event, + "Timed out deleting project.", + "Failed to delete project.", + ); + + if ((await fetchEvents(filter)).length > 0) { + throw new Error( + "This project was updated while it was being deleted. Refresh and try again.", + ); + } +} diff --git a/desktop/src/features/projects/projectModels.test.mjs b/desktop/src/features/projects/projectModels.test.mjs index 7be105584a9..b7c82fb293b 100644 --- a/desktop/src/features/projects/projectModels.test.mjs +++ b/desktop/src/features/projects/projectModels.test.mjs @@ -483,9 +483,9 @@ test("buildProjectReadModels ignores a tombstone that predates the live head", ( ); }); -test("buildProjectReadModels rejects a tombstone signed by a different pubkey", () => { +test("buildProjectReadModels applies a relay-authorized owner tombstone", () => { const owner = "a".repeat(64); - const impostor = "b".repeat(64); + const agentOwner = "b".repeat(64); const projectAddress = `30621:${owner}:platform`; const projectEvent = { id: "p".repeat(64), @@ -495,10 +495,10 @@ test("buildProjectReadModels rejects a tombstone signed by a different pubkey", content: "", tags: [["d", "platform"]], }; - const foreignDeletion = { + const ownerDeletion = { id: "d".repeat(64), kind: 5, - pubkey: impostor, + pubkey: agentOwner, created_at: 200, content: "", tags: [["a", projectAddress]], @@ -507,12 +507,12 @@ test("buildProjectReadModels rejects a tombstone signed by a different pubkey", const projects = buildProjectReadModels({ projectEvents: [projectEvent], repositoryEvents: [], - deletionEvents: [foreignDeletion], + deletionEvents: [ownerDeletion], }); assert.equal( projects.filter((p) => !p.legacy).length, - 1, - "a stranger's tombstone must not delete someone else's project", + 0, + "relay-authorized owner tombstone should delete the agent project", ); }); diff --git a/desktop/src/features/projects/projectModels.ts b/desktop/src/features/projects/projectModels.ts index 6d539b54d9e..f081e7c8e47 100644 --- a/desktop/src/features/projects/projectModels.ts +++ b/desktop/src/features/projects/projectModels.ts @@ -387,27 +387,18 @@ function repositoryToLegacyProject(repository: Repository): Project { } /** - * Builds the set of addressable coordinates that have been authoritatively - * deleted per NIP-09 semantics: the deletion signer must equal the coordinate - * owner, and the deletion's `created_at` must be ≥ the live head's timestamp. - * Returns a `Map` for threshold comparison. + * Builds deletion thresholds from relay-accepted tombstones. The relay has + * already enforced that each signer controls the addressed coordinate, + * including Buzz's NIP-OA owner delegation for agent-authored events. */ function buildDeletionThresholds( deletionEvents: RelayEvent[], ): Map { const thresholds = new Map(); for (const event of deletionEvents) { - const signer = event.pubkey.toLowerCase(); for (const tag of event.tags) { if (tag[0] !== "a" || !tag[1]) continue; const coordinate = tag[1]; - // The signer must be the owner of the coordinate. - const firstColon = coordinate.indexOf(":"); - const secondColon = coordinate.indexOf(":", firstColon + 1); - if (firstColon < 0 || secondColon < 0) continue; - const owner = coordinate.slice(firstColon + 1, secondColon).toLowerCase(); - if (owner !== signer) continue; - // Keep the latest (most permissive) deletion threshold. const existing = thresholds.get(coordinate); if (existing === undefined || event.created_at > existing) { thresholds.set(coordinate, event.created_at); diff --git a/desktop/src/features/projects/ui/ProjectsOverviewItems.tsx b/desktop/src/features/projects/ui/ProjectsOverviewItems.tsx index df1ceb25595..3da9805961b 100644 --- a/desktop/src/features/projects/ui/ProjectsOverviewItems.tsx +++ b/desktop/src/features/projects/ui/ProjectsOverviewItems.tsx @@ -1,4 +1,5 @@ import type { UserProfileLookup } from "@/features/profile/lib/identity"; +import { canDeleteProject } from "@/features/projects/projectDeletion"; import type { Project, ProjectActivitySummary, @@ -14,7 +15,6 @@ import { repositoryShareLink, } from "@/features/projects/lib/projectShareLinks"; import { - isProjectOwnedByCurrentUser, projectPeople, type ProjectsFilter, type ProjectsViewMode, @@ -39,6 +39,7 @@ export function ProjectsOverviewProjectItems({ deleteDisabled, filter, localRepoNames, + managedAgentPubkeys, onDelete, onOpen, onOpenTerminal, @@ -52,6 +53,7 @@ export function ProjectsOverviewProjectItems({ deleteDisabled: boolean; filter: ProjectsFilter; localRepoNames: Set; + managedAgentPubkeys: ReadonlySet; onDelete: (project: Project) => void; onOpen: (project: Project) => void; onOpenTerminal: (project: Project) => void; @@ -76,9 +78,15 @@ export function ProjectsOverviewProjectItems({ > {visibleProjects.map((project) => { const summary = summaries?.[project.id]; + const canDelete = canDeleteProject( + project, + currentPubkey, + profiles, + managedAgentPubkeys, + ); return ( {visibleProjects.map((project) => { const summary = summaries?.[project.id]; + const canDelete = canDeleteProject( + project, + currentPubkey, + profiles, + managedAgentPubkeys, + ); const selectionRangeItems = visibleProjects.map((item) => selectionItemFromProject({ channelId: item.projectChannelId, @@ -113,7 +127,7 @@ export function ProjectsOverviewProjectItems({ ); return ( 0, }); const profiles = profilesQuery.data?.profiles; + const { managedAgentPubkeys } = useProjectDeletionAccess(projects); const deleteProjectMutation = useDeleteProjectMutation(); const currentPubkey = identityQuery.data?.pubkey; - const handleViewModeChange = React.useCallback( (nextViewMode: ProjectsViewMode) => { setStoredViewMode(nextViewMode); @@ -612,6 +612,7 @@ export function ProjectsView() { deleteDisabled={deleteProjectMutation.isPending} filter={filter} localRepoNames={localRepoNames} + managedAgentPubkeys={managedAgentPubkeys} onDelete={handleDeleteProject} onOpen={handleOpenProject} onOpenTerminal={handleOpenTerminal} diff --git a/desktop/src/features/projects/useProjectDeletionAccess.ts b/desktop/src/features/projects/useProjectDeletionAccess.ts new file mode 100644 index 00000000000..197ea69b6dd --- /dev/null +++ b/desktop/src/features/projects/useProjectDeletionAccess.ts @@ -0,0 +1,32 @@ +import * as React from "react"; + +import { useManagedAgentsQuery } from "@/features/agents/hooks"; +import { useUsersBatchQuery } from "@/features/profile/hooks"; +import { normalizePubkey } from "@/shared/lib/pubkey"; +import type { Project } from "./projectModels"; + +/** Load owner profiles and local managed-agent capability for project deletion. */ +export function useProjectDeletionAccess(projects: Project[]) { + const ownerPubkeys = React.useMemo( + () => [...new Set(projects.map((project) => project.owner))], + [projects], + ); + const ownerProfilesQuery = useUsersBatchQuery(ownerPubkeys, { + enabled: ownerPubkeys.length > 0, + }); + const managedAgentsQuery = useManagedAgentsQuery(); + const managedAgentPubkeys = React.useMemo( + () => + new Set( + (managedAgentsQuery.data ?? []).map((agent) => + normalizePubkey(agent.pubkey), + ), + ), + [managedAgentsQuery.data], + ); + + return { + managedAgentPubkeys, + ownerProfiles: ownerProfilesQuery.data?.profiles, + }; +} diff --git a/desktop/src/features/sidebar/ui/SidebarProjectsSection.tsx b/desktop/src/features/sidebar/ui/SidebarProjectsSection.tsx index 65b6cb2582e..0814c777242 100644 --- a/desktop/src/features/sidebar/ui/SidebarProjectsSection.tsx +++ b/desktop/src/features/sidebar/ui/SidebarProjectsSection.tsx @@ -21,7 +21,8 @@ import { useDeleteProjectMutation, useProjectsQuery, } from "@/features/projects/hooks"; -import { isProjectOwnedByCurrentUser } from "@/features/projects/lib/projectsViewHelpers"; +import { canDeleteProject } from "@/features/projects/projectDeletion"; +import { useProjectDeletionAccess } from "@/features/projects/useProjectDeletionAccess"; import { projectShareLink } from "@/features/projects/lib/projectShareLinks"; import { addProjectToSidebar, @@ -123,6 +124,9 @@ export function SidebarProjectsSection() { function SidebarProjectsSectionContent() { const projectsQuery = useProjectsQuery(); const identityQuery = useIdentityQuery(); + const { managedAgentPubkeys, ownerProfiles } = useProjectDeletionAccess( + projectsQuery.data ?? [], + ); const currentPubkey = identityQuery.data?.pubkey; const { goProject, goProjects } = useAppNavigation(); const pathname = useLocation({ select: (location) => location.pathname }); @@ -306,6 +310,12 @@ function SidebarProjectsSectionContent() { {projects.length > 0 ? ( {projects.map((project) => { + const canDelete = canDeleteProject( + project, + currentPubkey, + ownerProfiles, + managedAgentPubkeys, + ); const isActive = routeProjectId != null && projectMatchesRouteId(project, routeProjectId); @@ -318,10 +328,7 @@ function SidebarProjectsSectionContent() { return ( Date: Sat, 22 Aug 2026 08:30:40 -0600 Subject: [PATCH 2/2] fix(projects): align deletion authority and recovery Co-authored-by: Wes Co-authored-by: Carl <32a2e2c9d428ee08902cab75d956da2c1d235a22d4766b0dd4138bf6e2e5db1d@buzz.block.builderlab.xyz> Signed-off-by: Wes --- .../features/projects/deleteProject.test.mjs | 18 ++++++ desktop/src/features/projects/hooks.ts | 19 +++--- .../projects/projectDeletion.test.mjs | 21 ++----- .../src/features/projects/projectDeletion.ts | 8 +-- .../projects/projectDeletionMutation.test.mjs | 63 +++++++++++++++++++ .../projects/projectDeletionMutation.ts | 24 +++++++ .../projects/ui/ProjectsOverviewItems.tsx | 16 +---- .../src/features/projects/ui/ProjectsView.tsx | 3 - .../projects/useProjectDeletionAccess.ts | 32 ---------- .../projects/useProjectOwnerProfiles.ts | 14 +++++ .../sidebar/ui/SidebarProjectsSection.tsx | 7 +-- 11 files changed, 137 insertions(+), 88 deletions(-) create mode 100644 desktop/src/features/projects/projectDeletionMutation.test.mjs create mode 100644 desktop/src/features/projects/projectDeletionMutation.ts delete mode 100644 desktop/src/features/projects/useProjectDeletionAccess.ts create mode 100644 desktop/src/features/projects/useProjectOwnerProfiles.ts diff --git a/desktop/src/features/projects/deleteProject.test.mjs b/desktop/src/features/projects/deleteProject.test.mjs index e2d018cfeae..2b1467532fc 100644 --- a/desktop/src/features/projects/deleteProject.test.mjs +++ b/desktop/src/features/projects/deleteProject.test.mjs @@ -120,3 +120,21 @@ test("deleteProject reports a concurrent replacement that survives", async () => /updated while it was being deleted/, ); }); + +test("deleteProject reports uncertain outcome when publish acknowledgement is lost", async () => { + await assert.rejects( + deleteProject(project, { + fetchEvents: async () => [event()], + signEvent: async (template) => + event({ + kind: template.kind, + created_at: template.createdAt, + tags: template.tags, + }), + publishEvent: async (_event, timeoutMessage) => { + throw new Error(timeoutMessage); + }, + }), + /Could not confirm whether the project was deleted\. Projects were refreshed\./, + ); +}); diff --git a/desktop/src/features/projects/hooks.ts b/desktop/src/features/projects/hooks.ts index 74e4244d1a7..be2fd3ab55d 100644 --- a/desktop/src/features/projects/hooks.ts +++ b/desktop/src/features/projects/hooks.ts @@ -58,7 +58,10 @@ import { projectPullRequestEventsToPullRequests, } from "./projectPullRequests.mjs"; import { fetchProjectsWorkItems } from "./projectWorkItems"; -import { deleteProject } from "./projectDeletion"; +import { + projectDeletionMutationOptions, + projectsQueryKey, +} from "./projectDeletionMutation"; import { eventToRepository, type Project, @@ -71,6 +74,8 @@ import { } from "./projectEnumeration"; import { projectMatchesRouteId } from "./projectRoutes"; +export { projectsQueryKey }; + export type { Project, ProjectIssue, @@ -621,8 +626,6 @@ async function fetchProjectActivitySummaries( ); } -export const projectsQueryKey = ["projects"] as const; - /** * Freshness windows for the Projects surface. Every local write path * invalidates its keys explicitly (issue/PR mutations, project creation, @@ -964,13 +967,5 @@ export function useProjectActivitySummariesQuery(projects: Project[]) { export function useDeleteProjectMutation() { const queryClient = useQueryClient(); - return useMutation({ - mutationFn: (project: Project) => deleteProject(project), - onSuccess: (_data, project) => { - queryClient.setQueryData(projectsQueryKey, (current = []) => - current.filter((item) => item.id !== project.id), - ); - void queryClient.invalidateQueries({ queryKey: projectsQueryKey }); - }, - }); + return useMutation(projectDeletionMutationOptions(queryClient)); } diff --git a/desktop/src/features/projects/projectDeletion.test.mjs b/desktop/src/features/projects/projectDeletion.test.mjs index 58d8ead63a0..6508b929b3d 100644 --- a/desktop/src/features/projects/projectDeletion.test.mjs +++ b/desktop/src/features/projects/projectDeletion.test.mjs @@ -17,30 +17,17 @@ const project = { }; test("project deletion capability includes direct and agent owners", () => { - assert.equal(canDeleteProject(project, AGENT, undefined, new Set()), true); + assert.equal(canDeleteProject(project, AGENT, undefined), true); assert.equal( - canDeleteProject(project, OWNER, undefined, new Set([AGENT])), - true, - ); - assert.equal( - canDeleteProject( - project, - OWNER, - { [AGENT]: { ownerPubkey: OWNER } }, - new Set(), - ), + canDeleteProject(project, OWNER, { [AGENT]: { ownerPubkey: OWNER } }), true, ); }); test("project deletion capability rejects unrelated viewers", () => { + assert.equal(canDeleteProject(project, OWNER, undefined), false); assert.equal( - canDeleteProject( - project, - OTHER, - { [AGENT]: { ownerPubkey: OWNER } }, - new Set(), - ), + canDeleteProject(project, OTHER, { [AGENT]: { ownerPubkey: OWNER } }), false, ); }); diff --git a/desktop/src/features/projects/projectDeletion.ts b/desktop/src/features/projects/projectDeletion.ts index 1f51e61b6ca..3bc35d302d3 100644 --- a/desktop/src/features/projects/projectDeletion.ts +++ b/desktop/src/features/projects/projectDeletion.ts @@ -20,21 +20,19 @@ export type DeleteProjectEventTemplate = { /** * Buzz lets a human owner manage content authored by their NIP-OA agent, just - * as it does for agent-owned channels. Local managed-agent records are also - * sufficient capability evidence; the relay remains the final authority. + * as it does for agent-owned channels. The profile ownership evidence mirrors + * the relay authority used for the owner-signed tombstone. */ export function canDeleteProject( project: Pick, currentPubkey: string | undefined, profiles: UserProfileLookup | undefined, - managedAgentPubkeys: ReadonlySet, ): boolean { if (!currentPubkey) return false; const owner = normalizePubkey(project.owner); return ( owner === normalizePubkey(currentPubkey) || - managedAgentPubkeys.has(owner) || ownsAuthorAgent(profiles?.[owner], currentPubkey) ); } @@ -102,7 +100,7 @@ export async function deleteProject( ); await publishEvent( event, - "Timed out deleting project.", + "Could not confirm whether the project was deleted. Projects were refreshed.", "Failed to delete project.", ); diff --git a/desktop/src/features/projects/projectDeletionMutation.test.mjs b/desktop/src/features/projects/projectDeletionMutation.test.mjs new file mode 100644 index 00000000000..5ee2d4b00d5 --- /dev/null +++ b/desktop/src/features/projects/projectDeletionMutation.test.mjs @@ -0,0 +1,63 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + MutationObserver, + QueryClient, + QueryObserver, +} from "@tanstack/react-query"; +import { + projectDeletionMutationOptions, + projectsQueryKey, +} from "./projectDeletionMutation.ts"; + +const project = { + id: "30621:owner:platform", + name: "Platform", +}; + +test("lost deletion acknowledgement refetches and removes the stale project", async () => { + let fetchCount = 0; + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false }, mutations: { retry: false } }, + }); + const queryObserver = new QueryObserver(queryClient, { + queryKey: projectsQueryKey, + queryFn: async () => { + fetchCount += 1; + return fetchCount === 1 ? [project] : []; + }, + }); + const unsubscribeQuery = queryObserver.subscribe(() => {}); + await queryObserver.refetch(); + assert.deepEqual(queryClient.getQueryData(projectsQueryKey), [project]); + + const mutationObserver = new MutationObserver( + queryClient, + projectDeletionMutationOptions(queryClient, async () => { + throw new Error( + "Could not confirm whether the project was deleted. Projects were refreshed.", + ); + }), + ); + await assert.rejects(mutationObserver.mutate(project), /Could not confirm/); + + assert.equal(fetchCount, 2); + assert.deepEqual(queryClient.getQueryData(projectsQueryKey), []); + unsubscribeQuery(); +}); + +test("successful deletion removes the project before refetch", async () => { + const queryClient = new QueryClient({ + defaultOptions: { mutations: { retry: false } }, + }); + queryClient.setQueryData(projectsQueryKey, [project]); + const mutationObserver = new MutationObserver( + queryClient, + projectDeletionMutationOptions(queryClient, async () => {}), + ); + + await mutationObserver.mutate(project); + + assert.deepEqual(queryClient.getQueryData(projectsQueryKey), []); +}); diff --git a/desktop/src/features/projects/projectDeletionMutation.ts b/desktop/src/features/projects/projectDeletionMutation.ts new file mode 100644 index 00000000000..f284a0cf22f --- /dev/null +++ b/desktop/src/features/projects/projectDeletionMutation.ts @@ -0,0 +1,24 @@ +import type { QueryClient } from "@tanstack/react-query"; + +import type { Project } from "./projectModels"; +import { deleteProject } from "./projectDeletion"; + +export const projectsQueryKey = ["projects"] as const; + +/** Keep the shared project cache authoritative even when publish ACK is lost. */ +export function projectDeletionMutationOptions( + queryClient: QueryClient, + deleteProjectFn: (project: Project) => Promise = deleteProject, +) { + type DeletionResult = Awaited>; + return { + mutationFn: deleteProjectFn, + onSuccess: (_data: DeletionResult, project: Project) => { + queryClient.setQueryData(projectsQueryKey, (current = []) => + current.filter((item) => item.id !== project.id), + ); + }, + onSettled: () => + queryClient.invalidateQueries({ queryKey: projectsQueryKey }), + }; +} diff --git a/desktop/src/features/projects/ui/ProjectsOverviewItems.tsx b/desktop/src/features/projects/ui/ProjectsOverviewItems.tsx index 3da9805961b..dff2c697e06 100644 --- a/desktop/src/features/projects/ui/ProjectsOverviewItems.tsx +++ b/desktop/src/features/projects/ui/ProjectsOverviewItems.tsx @@ -39,7 +39,6 @@ export function ProjectsOverviewProjectItems({ deleteDisabled, filter, localRepoNames, - managedAgentPubkeys, onDelete, onOpen, onOpenTerminal, @@ -53,7 +52,6 @@ export function ProjectsOverviewProjectItems({ deleteDisabled: boolean; filter: ProjectsFilter; localRepoNames: Set; - managedAgentPubkeys: ReadonlySet; onDelete: (project: Project) => void; onOpen: (project: Project) => void; onOpenTerminal: (project: Project) => void; @@ -78,12 +76,7 @@ export function ProjectsOverviewProjectItems({ > {visibleProjects.map((project) => { const summary = summaries?.[project.id]; - const canDelete = canDeleteProject( - project, - currentPubkey, - profiles, - managedAgentPubkeys, - ); + const canDelete = canDeleteProject(project, currentPubkey, profiles); return ( {visibleProjects.map((project) => { const summary = summaries?.[project.id]; - const canDelete = canDeleteProject( - project, - currentPubkey, - profiles, - managedAgentPubkeys, - ); + const canDelete = canDeleteProject(project, currentPubkey, profiles); const selectionRangeItems = visibleProjects.map((item) => selectionItemFromProject({ channelId: item.projectChannelId, diff --git a/desktop/src/features/projects/ui/ProjectsView.tsx b/desktop/src/features/projects/ui/ProjectsView.tsx index 70a1176d53e..0e319881254 100644 --- a/desktop/src/features/projects/ui/ProjectsView.tsx +++ b/desktop/src/features/projects/ui/ProjectsView.tsx @@ -19,7 +19,6 @@ import { useRepositoryActivitySummariesQuery } from "@/features/projects/reposit import { useCreateProjectMutation } from "@/features/projects/useCreateProject"; import { selectProjectRepository } from "@/features/projects/projectModels"; import { useProjectsRepoSnapshotsQuery } from "@/features/projects/useProjectsRepoSnapshots"; -import { useProjectDeletionAccess } from "@/features/projects/useProjectDeletionAccess"; import { buildProjectSelectionAgentContext } from "@/features/projects/lib/projectDetailAgentContext"; import type { ProjectSelectionItem } from "@/features/projects/lib/projectSelection"; import { @@ -226,7 +225,6 @@ export function ProjectsView() { enabled: projectPubkeys.length > 0, }); const profiles = profilesQuery.data?.profiles; - const { managedAgentPubkeys } = useProjectDeletionAccess(projects); const deleteProjectMutation = useDeleteProjectMutation(); const currentPubkey = identityQuery.data?.pubkey; const handleViewModeChange = React.useCallback( @@ -612,7 +610,6 @@ export function ProjectsView() { deleteDisabled={deleteProjectMutation.isPending} filter={filter} localRepoNames={localRepoNames} - managedAgentPubkeys={managedAgentPubkeys} onDelete={handleDeleteProject} onOpen={handleOpenProject} onOpenTerminal={handleOpenTerminal} diff --git a/desktop/src/features/projects/useProjectDeletionAccess.ts b/desktop/src/features/projects/useProjectDeletionAccess.ts deleted file mode 100644 index 197ea69b6dd..00000000000 --- a/desktop/src/features/projects/useProjectDeletionAccess.ts +++ /dev/null @@ -1,32 +0,0 @@ -import * as React from "react"; - -import { useManagedAgentsQuery } from "@/features/agents/hooks"; -import { useUsersBatchQuery } from "@/features/profile/hooks"; -import { normalizePubkey } from "@/shared/lib/pubkey"; -import type { Project } from "./projectModels"; - -/** Load owner profiles and local managed-agent capability for project deletion. */ -export function useProjectDeletionAccess(projects: Project[]) { - const ownerPubkeys = React.useMemo( - () => [...new Set(projects.map((project) => project.owner))], - [projects], - ); - const ownerProfilesQuery = useUsersBatchQuery(ownerPubkeys, { - enabled: ownerPubkeys.length > 0, - }); - const managedAgentsQuery = useManagedAgentsQuery(); - const managedAgentPubkeys = React.useMemo( - () => - new Set( - (managedAgentsQuery.data ?? []).map((agent) => - normalizePubkey(agent.pubkey), - ), - ), - [managedAgentsQuery.data], - ); - - return { - managedAgentPubkeys, - ownerProfiles: ownerProfilesQuery.data?.profiles, - }; -} diff --git a/desktop/src/features/projects/useProjectOwnerProfiles.ts b/desktop/src/features/projects/useProjectOwnerProfiles.ts new file mode 100644 index 00000000000..5c6161d2f28 --- /dev/null +++ b/desktop/src/features/projects/useProjectOwnerProfiles.ts @@ -0,0 +1,14 @@ +import * as React from "react"; + +import { useUsersBatchQuery } from "@/features/profile/hooks"; +import type { Project } from "./projectModels"; + +/** Load relay-backed ownership profiles for project deletion capability. */ +export function useProjectOwnerProfiles(projects: Project[]) { + const ownerPubkeys = React.useMemo( + () => [...new Set(projects.map((project) => project.owner))], + [projects], + ); + return useUsersBatchQuery(ownerPubkeys, { enabled: ownerPubkeys.length > 0 }) + .data?.profiles; +} diff --git a/desktop/src/features/sidebar/ui/SidebarProjectsSection.tsx b/desktop/src/features/sidebar/ui/SidebarProjectsSection.tsx index 0814c777242..5db7a5d4b2f 100644 --- a/desktop/src/features/sidebar/ui/SidebarProjectsSection.tsx +++ b/desktop/src/features/sidebar/ui/SidebarProjectsSection.tsx @@ -22,7 +22,7 @@ import { useProjectsQuery, } from "@/features/projects/hooks"; import { canDeleteProject } from "@/features/projects/projectDeletion"; -import { useProjectDeletionAccess } from "@/features/projects/useProjectDeletionAccess"; +import { useProjectOwnerProfiles } from "@/features/projects/useProjectOwnerProfiles"; import { projectShareLink } from "@/features/projects/lib/projectShareLinks"; import { addProjectToSidebar, @@ -124,9 +124,7 @@ export function SidebarProjectsSection() { function SidebarProjectsSectionContent() { const projectsQuery = useProjectsQuery(); const identityQuery = useIdentityQuery(); - const { managedAgentPubkeys, ownerProfiles } = useProjectDeletionAccess( - projectsQuery.data ?? [], - ); + const ownerProfiles = useProjectOwnerProfiles(projectsQuery.data ?? []); const currentPubkey = identityQuery.data?.pubkey; const { goProject, goProjects } = useAppNavigation(); const pathname = useLocation({ select: (location) => location.pathname }); @@ -314,7 +312,6 @@ function SidebarProjectsSectionContent() { project, currentPubkey, ownerProfiles, - managedAgentPubkeys, ); const isActive = routeProjectId != null &&