mirror of
https://github.com/block/buzz.git
synced 2026-10-27 12:40:30 +00:00
fix(projects): allow owners to delete agent projects (#6533)
## Summary - expose project deletion when the current user directly owns the project or owns/manages its author agent - publish a viewer-signed, project-only tombstone after fetching the exact live head, then detect concurrent replacement - honor relay-authorized owner-signed tombstones in desktop project read models ## Testing - pre-push hook: desktop Biome, TypeScript, 5,358 desktop tests, Rust tests, Tauri checks, and file-size ratchet passed at `a36f7a4cf` - `cargo check -p buzz-test-client --tests` - relay E2E coverage added as ignored; not run against a live relay --------- Signed-off-by: Wes <wesbillman@users.noreply.github.com> Signed-off-by: Carl <32a2e2c9d428ee08902cab75d956da2c1d235a22d4766b0dd4138bf6e2e5db1d@buzz.block.builderlab.xyz> Signed-off-by: Carl <9d00794d3df50972eb8b615511783cab12a77a8fd5dd5edd58073ec73b54bd8b@buzz.block.builderlab.xyz> Co-authored-by: Carl <32a2e2c9d428ee08902cab75d956da2c1d235a22d4766b0dd4138bf6e2e5db1d@buzz.block.builderlab.xyz> Co-authored-by: Carl <9d00794d3df50972eb8b615511783cab12a77a8fd5dd5edd58073ec73b54bd8b@buzz.block.builderlab.xyz>
This commit is contained in:
@@ -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<u64>) -> 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<u64>,
|
||||
) -> 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<u64
|
||||
Some(ts) => 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<u64>) -> 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
|
||||
|
||||
140
desktop/src/features/projects/deleteProject.test.mjs
Normal file
140
desktop/src/features/projects/deleteProject.test.mjs
Normal file
@@ -0,0 +1,140 @@
|
||||
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/,
|
||||
);
|
||||
});
|
||||
|
||||
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\./,
|
||||
);
|
||||
});
|
||||
@@ -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,10 @@ import {
|
||||
projectPullRequestEventsToPullRequests,
|
||||
} from "./projectPullRequests.mjs";
|
||||
import { fetchProjectsWorkItems } from "./projectWorkItems";
|
||||
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,
|
||||
@@ -624,27 +629,6 @@ async function fetchProjectActivitySummaries(
|
||||
);
|
||||
}
|
||||
|
||||
async function deleteProject(project: Project): Promise<void> {
|
||||
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;
|
||||
|
||||
/**
|
||||
* Freshness windows for the Projects surface. Every local write path
|
||||
* invalidates its keys explicitly (issue/PR mutations, project creation,
|
||||
@@ -987,13 +971,5 @@ export function useProjectActivitySummariesQuery(projects: Project[]) {
|
||||
export function useDeleteProjectMutation() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: deleteProject,
|
||||
onSuccess: (_data, project) => {
|
||||
queryClient.setQueryData<Project[]>(projectsQueryKey, (current = []) =>
|
||||
current.filter((item) => item.id !== project.id),
|
||||
);
|
||||
void queryClient.invalidateQueries({ queryKey: projectsQueryKey });
|
||||
},
|
||||
});
|
||||
return useMutation(projectDeletionMutationOptions(queryClient));
|
||||
}
|
||||
|
||||
45
desktop/src/features/projects/projectDeletion.test.mjs
Normal file
45
desktop/src/features/projects/projectDeletion.test.mjs
Normal file
@@ -0,0 +1,45 @@
|
||||
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), true);
|
||||
assert.equal(
|
||||
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 } }),
|
||||
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]],
|
||||
},
|
||||
);
|
||||
});
|
||||
112
desktop/src/features/projects/projectDeletion.ts
Normal file
112
desktop/src/features/projects/projectDeletion.ts
Normal file
@@ -0,0 +1,112 @@
|
||||
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. The profile ownership evidence mirrors
|
||||
* the relay authority used for the owner-signed tombstone.
|
||||
*/
|
||||
export function canDeleteProject(
|
||||
project: Pick<Project, "owner">,
|
||||
currentPubkey: string | undefined,
|
||||
profiles: UserProfileLookup | undefined,
|
||||
): boolean {
|
||||
if (!currentPubkey) return false;
|
||||
|
||||
const owner = normalizePubkey(project.owner);
|
||||
return (
|
||||
owner === normalizePubkey(currentPubkey) ||
|
||||
ownsAuthorAgent(profiles?.[owner], currentPubkey)
|
||||
);
|
||||
}
|
||||
|
||||
/** Build a tombstone that dominates the exact live coordinate head. */
|
||||
export function buildProjectDeletionTemplate(
|
||||
project: Pick<Project, "name" | "projectAddress">,
|
||||
liveHead: Pick<RelayEvent, "created_at">,
|
||||
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<RelayEvent[]>;
|
||||
|
||||
type ProjectDeletionDeps = {
|
||||
fetchEvents: ProjectDeletionFetchEvents;
|
||||
nowSeconds: () => number;
|
||||
publishEvent: (
|
||||
event: RelayEvent,
|
||||
timeoutMessage: string,
|
||||
failureMessage: string,
|
||||
) => Promise<void>;
|
||||
signEvent: (input: DeleteProjectEventTemplate) => Promise<RelayEvent>;
|
||||
};
|
||||
|
||||
/** Delete the exact live project coordinate and detect a concurrent replacement. */
|
||||
export async function deleteProject(
|
||||
project: Project,
|
||||
deps?: Partial<ProjectDeletionDeps>,
|
||||
): Promise<void> {
|
||||
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,
|
||||
"Could not confirm whether the project was deleted. Projects were refreshed.",
|
||||
"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.",
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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), []);
|
||||
});
|
||||
24
desktop/src/features/projects/projectDeletionMutation.ts
Normal file
24
desktop/src/features/projects/projectDeletionMutation.ts
Normal file
@@ -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<void> = deleteProject,
|
||||
) {
|
||||
type DeletionResult = Awaited<ReturnType<typeof deleteProjectFn>>;
|
||||
return {
|
||||
mutationFn: deleteProjectFn,
|
||||
onSuccess: (_data: DeletionResult, project: Project) => {
|
||||
queryClient.setQueryData<Project[]>(projectsQueryKey, (current = []) =>
|
||||
current.filter((item) => item.id !== project.id),
|
||||
);
|
||||
},
|
||||
onSettled: () =>
|
||||
queryClient.invalidateQueries({ queryKey: projectsQueryKey }),
|
||||
};
|
||||
}
|
||||
@@ -546,9 +546,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),
|
||||
@@ -558,10 +558,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]],
|
||||
@@ -570,12 +570,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",
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -421,27 +421,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<coordinate, deletedAt>` 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<string, number> {
|
||||
const thresholds = new Map<string, number>();
|
||||
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);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import * as React from "react";
|
||||
|
||||
import type { UserProfileLookup } from "@/features/profile/lib/identity";
|
||||
import { canDeleteProject } from "@/features/projects/projectDeletion";
|
||||
import { FolderGit2, Folders } from "lucide-react";
|
||||
import type {
|
||||
Project,
|
||||
@@ -17,7 +18,6 @@ import {
|
||||
repositoryShareLink,
|
||||
} from "@/features/projects/lib/projectShareLinks";
|
||||
import {
|
||||
isProjectOwnedByCurrentUser,
|
||||
isProjectMine,
|
||||
projectPeople,
|
||||
type ProjectsViewMode,
|
||||
@@ -228,9 +228,10 @@ export function ProjectsOverviewProjectItems({
|
||||
key={project.id}
|
||||
>
|
||||
<ProjectGridCard
|
||||
canDelete={isProjectOwnedByCurrentUser(
|
||||
canDelete={canDeleteProject(
|
||||
project,
|
||||
currentPubkey,
|
||||
profiles,
|
||||
)}
|
||||
deleteDisabled={deleteDisabled}
|
||||
hasLocal={hasLocalCheckout(project, localRepoNames)}
|
||||
@@ -272,9 +273,10 @@ export function ProjectsOverviewProjectItems({
|
||||
key={project.id}
|
||||
>
|
||||
<ProjectListRow
|
||||
canDelete={isProjectOwnedByCurrentUser(
|
||||
canDelete={canDeleteProject(
|
||||
project,
|
||||
currentPubkey,
|
||||
profiles,
|
||||
)}
|
||||
deleteDisabled={deleteDisabled}
|
||||
hasLocal={hasLocalCheckout(project, localRepoNames)}
|
||||
|
||||
14
desktop/src/features/projects/useProjectOwnerProfiles.ts
Normal file
14
desktop/src/features/projects/useProjectOwnerProfiles.ts
Normal file
@@ -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;
|
||||
}
|
||||
@@ -24,7 +24,8 @@ import {
|
||||
useProjectsQuery,
|
||||
} from "@/features/projects/hooks";
|
||||
import { listProjectChildChannels } from "@/features/projects/lib/projectRelatedChannels";
|
||||
import { isProjectOwnedByCurrentUser } from "@/features/projects/lib/projectsViewHelpers";
|
||||
import { canDeleteProject } from "@/features/projects/projectDeletion";
|
||||
import { useProjectOwnerProfiles } from "@/features/projects/useProjectOwnerProfiles";
|
||||
import { projectShareLink } from "@/features/projects/lib/projectShareLinks";
|
||||
import {
|
||||
addProjectToSidebar,
|
||||
@@ -128,6 +129,7 @@ function SidebarProjectsSectionContent() {
|
||||
const projectsQuery = useProjectsQuery();
|
||||
const channelsQuery = useChannelsQuery();
|
||||
const identityQuery = useIdentityQuery();
|
||||
const ownerProfiles = useProjectOwnerProfiles(projectsQuery.data ?? []);
|
||||
const currentPubkey = identityQuery.data?.pubkey;
|
||||
const { goChannel, goProject, goProjects } = useAppNavigation();
|
||||
const pathname = useLocation({ select: (location) => location.pathname });
|
||||
@@ -311,9 +313,10 @@ function SidebarProjectsSectionContent() {
|
||||
return (
|
||||
<React.Fragment key={project.id}>
|
||||
<SidebarProjectRow
|
||||
canDelete={isProjectOwnedByCurrentUser(
|
||||
canDelete={canDeleteProject(
|
||||
project,
|
||||
currentPubkey,
|
||||
ownerProfiles,
|
||||
)}
|
||||
childCount={childChannels.length}
|
||||
deleteDisabled={deleteProjectMutation.isPending}
|
||||
|
||||
Reference in New Issue
Block a user