From f590b125f58b831ee42104234c0f3fa99ec96971 Mon Sep 17 00:00:00 2001 From: DaX Date: Thu, 30 Jul 2026 20:06:13 +0200 Subject: [PATCH] fix(desktop): stop hiding repos re-announced after a deletion `isDeletedByA` matched any same-author kind:5 tombstone carrying the project's `30617::` coordinate, with no timestamp comparison. Repo announcements are addressable, so the coordinate outlives any single event at it: deleting a repo and announcing the same dtag again is a legitimate recovery path. NIP-09 scopes an `a`-tag deletion to versions "up to the created_at timestamp of the deletion request event", so a tombstone must not hide an announcement published after it. The relay already gets this right, which made the failure confusing: the newer announcement was stored, served over REQ, and clone/push worked, while Desktop's Repositories list never rendered the card again for anyone. Bound the match to `event.created_at >= project.createdAt` and move the predicate (with `projectCoordinate`) into `lib/projectDeletions.ts` so it is unit-testable, which also keeps hooks.ts under the 1000-line guard. Fixes #3760 Signed-off-by: DaX --- desktop/src/features/projects/hooks.ts | 19 +--- .../projects/lib/projectDeletions.test.mjs | 102 ++++++++++++++++++ .../features/projects/lib/projectDeletions.ts | 44 ++++++++ 3 files changed, 150 insertions(+), 15 deletions(-) create mode 100644 desktop/src/features/projects/lib/projectDeletions.test.mjs create mode 100644 desktop/src/features/projects/lib/projectDeletions.ts diff --git a/desktop/src/features/projects/hooks.ts b/desktop/src/features/projects/hooks.ts index a51191d479..daac260958 100644 --- a/desktop/src/features/projects/hooks.ts +++ b/desktop/src/features/projects/hooks.ts @@ -3,6 +3,10 @@ import * as React from "react"; import { relayClient } from "@/shared/api/relayClient"; import { getRelaySelf } from "@/features/moderation/lib/relaySelf"; +import { + isDeletedByA, + projectCoordinate, +} from "@/features/projects/lib/projectDeletions"; import { getCachedRelayOrigin } from "@/shared/lib/mediaUrl"; import { signRelayEvent } from "@/shared/api/tauri"; import { getIdentity } from "@/shared/api/tauriIdentity"; @@ -144,10 +148,6 @@ function getCloneUrls(event: RelayEvent): string[] { return tag ? tag.slice(1) : []; } -function projectCoordinate(project: Pick): string { - return `${KIND_REPO_ANNOUNCEMENT}:${project.owner}:${project.dtag}`; -} - function readHiddenProjectCards(): string[] { if (typeof window === "undefined") { return []; @@ -169,17 +169,6 @@ function isHiddenLocally(project: Project): boolean { return readHiddenProjectCards().includes(projectCoordinate(project)); } -function isDeletedByA(project: Project, deletionEvents: RelayEvent[]): boolean { - const coordinate = projectCoordinate(project); - // NIP-09: a deletion is only valid when signed by the author of the - // referenced event — otherwise anyone could hide someone else's project. - return deletionEvents.some( - (event) => - event.pubkey.toLowerCase() === project.owner.toLowerCase() && - event.tags.some((tag) => tag[0] === "a" && tag[1] === coordinate), - ); -} - /** * Converts a kind:30617 repo announcement into a `Project`. * diff --git a/desktop/src/features/projects/lib/projectDeletions.test.mjs b/desktop/src/features/projects/lib/projectDeletions.test.mjs new file mode 100644 index 0000000000..adbe9119a9 --- /dev/null +++ b/desktop/src/features/projects/lib/projectDeletions.test.mjs @@ -0,0 +1,102 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; + +import { isDeletedByA, projectCoordinate } from "./projectDeletions.ts"; + +const OWNER = "a".repeat(64); +const OTHER = "b".repeat(64); + +const ANNOUNCED_AT = 1_700_000_000; + +function project(overrides = {}) { + return { + owner: OWNER, + dtag: "bitchat", + createdAt: ANNOUNCED_AT, + ...overrides, + }; +} + +function deletion({ + pubkey = OWNER, + createdAt = ANNOUNCED_AT, + coordinate, +} = {}) { + return { + id: "deadbeef", + pubkey, + kind: 5, + created_at: createdAt, + content: "", + tags: [["a", coordinate ?? projectCoordinate(project())]], + sig: "", + }; +} + +test("a tombstone older than the announcement does not hide it", () => { + // The #3760 regression: delete a repo, then announce the same dtag again. + // The re-announcement is newer than the tombstone, so it is live. + assert.equal( + isDeletedByA(project(), [deletion({ createdAt: ANNOUNCED_AT - 1 })]), + false, + ); +}); + +test("a tombstone newer than the announcement hides it", () => { + assert.equal( + isDeletedByA(project(), [deletion({ createdAt: ANNOUNCED_AT + 1 })]), + true, + ); +}); + +test("a tombstone at the announcement's own timestamp hides it", () => { + // NIP-09 deletes versions "up to the created_at timestamp" — inclusive. + assert.equal(isDeletedByA(project(), [deletion()]), true); +}); + +test("only the announcement author can delete it", () => { + assert.equal( + isDeletedByA(project(), [ + deletion({ pubkey: OTHER, createdAt: ANNOUNCED_AT + 1 }), + ]), + false, + ); +}); + +test("author matching stays case-insensitive", () => { + assert.equal( + isDeletedByA(project(), [ + deletion({ pubkey: OWNER.toUpperCase(), createdAt: ANNOUNCED_AT + 1 }), + ]), + true, + ); +}); + +test("a tombstone for a different coordinate is ignored", () => { + assert.equal( + isDeletedByA(project(), [ + deletion({ + createdAt: ANNOUNCED_AT + 1, + coordinate: projectCoordinate({ owner: OWNER, dtag: "other-repo" }), + }), + ]), + false, + ); +}); + +test("a newer tombstone still hides the announcement when an older one exists", () => { + assert.equal( + isDeletedByA(project(), [ + deletion({ createdAt: ANNOUNCED_AT - 100 }), + deletion({ createdAt: ANNOUNCED_AT + 100 }), + ]), + true, + ); +}); + +test("projectCoordinate builds the NIP-34 repo address", () => { + assert.equal( + projectCoordinate({ owner: OWNER, dtag: "bitchat" }), + `30617:${OWNER}:bitchat`, + ); +}); diff --git a/desktop/src/features/projects/lib/projectDeletions.ts b/desktop/src/features/projects/lib/projectDeletions.ts new file mode 100644 index 0000000000..87957dfaed --- /dev/null +++ b/desktop/src/features/projects/lib/projectDeletions.ts @@ -0,0 +1,44 @@ +import type { Project } from "@/features/projects/hooks"; +import type { RelayEvent } from "@/shared/api/types"; +import { KIND_REPO_ANNOUNCEMENT } from "@/shared/constants/kinds"; + +/** + * The NIP-34 repo address (`30617::`) — the coordinate a kind:5 + * deletion targets with an `a` tag, and the identity two forks of the same + * dtag are distinguished by. + */ +export function projectCoordinate( + project: Pick, +): string { + return `${KIND_REPO_ANNOUNCEMENT}:${project.owner}:${project.dtag}`; +} + +/** + * Whether a kind:5 tombstone in `deletionEvents` hides this repo announcement. + * + * A repo announcement is addressable (kind:30617), so its coordinate outlives + * any single event at it: deleting a repo and announcing the same dtag again + * is a legitimate recovery path, and the re-announcement is live. NIP-09 + * scopes an `a`-tag deletion to versions "up to the `created_at` timestamp of + * the deletion request event" — so a tombstone only hides announcements at or + * older than itself, never a newer one published after it. + * + * Without that timestamp bound a single deletion hid the coordinate forever: + * the relay kept serving the newer announcement and git clone/push worked, + * but the card never rendered again for anyone (#3760). + */ +export function isDeletedByA( + project: Pick, + deletionEvents: RelayEvent[], +): boolean { + const coordinate = projectCoordinate(project); + + return deletionEvents.some( + (event) => + // NIP-09: a deletion is only valid when signed by the author of the + // referenced event — otherwise anyone could hide someone else's project. + event.pubkey.toLowerCase() === project.owner.toLowerCase() && + event.created_at >= project.createdAt && + event.tags.some((tag) => tag[0] === "a" && tag[1] === coordinate), + ); +}