Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 4 additions & 15 deletions desktop/src/features/projects/hooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -144,10 +148,6 @@ function getCloneUrls(event: RelayEvent): string[] {
return tag ? tag.slice(1) : [];
}

function projectCoordinate(project: Pick<Project, "owner" | "dtag">): string {
return `${KIND_REPO_ANNOUNCEMENT}:${project.owner}:${project.dtag}`;
}

function readHiddenProjectCards(): string[] {
if (typeof window === "undefined") {
return [];
Expand All @@ -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`.
*
Expand Down
102 changes: 102 additions & 0 deletions desktop/src/features/projects/lib/projectDeletions.test.mjs
Original file line number Diff line number Diff line change
@@ -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`,
);
});
44 changes: 44 additions & 0 deletions desktop/src/features/projects/lib/projectDeletions.ts
Original file line number Diff line number Diff line change
@@ -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:<owner>:<dtag>`) — 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<Project, "owner" | "dtag">,
): 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<Project, "owner" | "dtag" | "createdAt">,
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),
);
}