Skip to content
Merged
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
100 changes: 86 additions & 14 deletions src/features/messages/MessageManagement.test.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
// @vitest-environment jsdom
import { schnorr } from "@noble/curves/secp256k1.js";
import { bytesToHex } from "nostr-tools/utils";
import { afterEach, assert, expect, it, vi } from "vitest";
import "@testing-library/jest-dom/vitest";
import { composerDOMFixture } from "./composer-testing";
Expand Down Expand Up @@ -65,6 +67,7 @@ async function fixture(
withAttachments = false,
originalAttachment = false,
originalMention = false,
agentOwner: "viewer" | "other" | undefined = undefined,
) {
vi.stubGlobal(
"ResizeObserver",
Expand All @@ -76,28 +79,63 @@ async function fixture(
HTMLElement.prototype.scrollIntoView = vi.fn();
const viewer = keypair(),
relay = keypair(),
peer = keypair();
peer = keypair(),
agent = agentOwner ? keypair() : undefined,
agentOwnerKey =
agentOwner === "viewer"
? viewer
: agentOwner === "other"
? keypair()
: undefined;
const original = message(
own ? viewer : peer,
own ? viewer : (agent ?? peer),
"room",
originalAttachment
? "Original message\n\n[original.pdf](https://fixture.test/media/original.pdf)"
: originalMention
? "@Honey Original message"
: "Original message",
1700000000,
originalAttachment
? [
[
"imeta",
"url https://fixture.test/media/original.pdf",
"m application/pdf",
[
...(originalAttachment
? [
[
"imeta",
"url https://fixture.test/media/original.pdf",
"m application/pdf",
],
]
: []),
...(originalMention ? [["p", peer.pubkey]] : []),
],
);
const agentProfile =
agent && agentOwnerKey
? signed(agent, {
kind: 0,
content: JSON.stringify({ name: "Test agent" }),
tags: [
[
"auth",
agentOwnerKey.pubkey,
"kind=0&created_at<1700000001",
bytesToHex(
schnorr.sign(
new Uint8Array(
await crypto.subtle.digest(
"SHA-256",
new TextEncoder().encode(
`nostr:agent-auth:${agent.pubkey}:kind=0&created_at<1700000001`,
),
),
),
agentOwnerKey.secret,
),
),
],
],
]
: originalMention
? [["p", peer.pubkey]]
: [],
);
})
: undefined;
const publications: {
event: RelayEvent;
result: ReturnType<typeof deferred<void>>;
Expand Down Expand Up @@ -139,7 +177,14 @@ async function fixture(
roster(relay, "room", [viewer.pubkey, peer.pubkey]),
];
if (filter.kinds?.includes(0))
return originalMention ? [profile(peer, { name: "Honey" })] : [];
return [
...(originalMention && filter.authors?.includes(peer.pubkey)
? [profile(peer, { name: "Honey" })]
: []),
...(agent && agentProfile && filter.authors?.includes(agent.pubkey)
? [agentProfile]
: []),
];
if (filter.kinds?.includes(9))
return [
original,
Expand Down Expand Up @@ -284,6 +329,33 @@ it("edits in the composer, retains a rejected change and retries the same operat
);
});

it("offers a confirmed NIP-09 deletion for an agent owned by the viewer", async () => {
const h = await fixture(false, false, false, false, false, "viewer");
const item = await screen.findByRole("menuitem", { name: "Delete message" });
expect(item).toBeVisible();
fireEvent.click(item);
const confirmation = await screen.findByRole("alertdialog", {
name: "Delete message?",
});
expect(confirmation).toHaveTextContent(
"This requests removal of this message from Buzz’s relay.",
);
expect(confirmation).toHaveTextContent("People may still have copies.");
expect(h.publications).toHaveLength(0);
fireEvent.click(screen.getByRole("button", { name: "Delete" }));
await waitFor(() => expect(h.publications).toHaveLength(1));
expect(h.publication(0).event.kind).toBe(5);
expect(h.publication(0).event.pubkey).toBe(h.owner.session.viewer);
expect(h.publication(0).event.tags).toContainEqual(["e", h.original.id]);
await act(async () => h.publication(0).result.resolve());
});

it("does not offer agent-message deletion to a non-owner", async () => {
await fixture(false, false, false, false, false, "other");
await screen.findByRole("menuitem", { name: /Mark (unread|read)/ });
expect(screen.queryByRole("menuitem", { name: "Delete message" })).toBeNull();
});

it("does not expose edit or delete for another person's message", async () => {
await fixture(false);
await screen.findByRole("menuitem", { name: /Mark (unread|read)/ });
Expand Down
30 changes: 24 additions & 6 deletions src/features/messages/MessageManagement.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,11 @@ import {
import { Button } from "../../shared/design-system/ui/Button";
import { AlertDialog } from "../../shared/design-system/ui/AlertDialog";
import { MenuItem, MenuIcon } from "../../shared/design-system/ui/Menu";
import { useAgentOwnerEvidence } from "../profiles/useAgentOwnerEvidence";
import type { ChannelMessage } from "../relay/contracts";
import { useListedChannel } from "../relay/listed-channel";
import type { RelaySession } from "../relay/session";
import type { AgentOwnerAuthorization } from "../relay/messages";
import type { OutgoingEvent } from "../relay/outbox";
import { MessageEditScope, useMessageEditScope } from "./MessageEditScope";
import { useAfterMessageMenuClose } from "./MessageActionBar";
Expand Down Expand Up @@ -59,6 +61,7 @@ const empty = () => emptyOperations;
const noop = () => () => {};
type Deletion = {
row: ChannelMessage;
authorization?: AgentOwnerAuthorization | undefined;
done?: (() => void) | undefined;
focus?: (() => HTMLElement | null) | undefined;
};
Expand All @@ -68,6 +71,7 @@ const Management = createContext<
row: ChannelMessage,
done?: () => void,
focus?: () => HTMLElement | null,
authorization?: AgentOwnerAuthorization,
): void;
report(error: string | undefined): void;
operations: readonly OutgoingEvent[];
Expand Down Expand Up @@ -181,8 +185,8 @@ export function MessageManagement({
channelId,
operations,
report,
remove(row, done, focus) {
setSelection({ row, done, focus });
remove(row, done, focus, authorization) {
setSelection({ row, done, focus, authorization });
},
}}
>
Expand Down Expand Up @@ -286,6 +290,12 @@ export function MessageManagementItems({
const following = useSyncExternalStore(session.unread.subscribeSync, () =>
session.unread.following(row.channelId, threadRootId),
);
const ownership = useAgentOwnerEvidence(
session,
management && !archived && row.authorId !== session.viewer
? row.authorId
: undefined,
);
if (!management) return null;
const busy = management.operations.some(
(item) =>
Expand All @@ -295,7 +305,14 @@ export function MessageManagementItems({
);
const own = row.authorId === session.viewer && !archived;
const canEdit = own && editor && lastEditableMessage(session, [row]);
const canDelete = own && session.outbox?.supports(5);
const authorization =
row.authorId !== session.viewer &&
session.viewer &&
ownership.status === "ready" &&
ownership.owner === session.viewer
? { agentId: row.authorId, ownerId: session.viewer }
: undefined;
const canDelete = (own || !!authorization) && session.outbox?.supports(5);
const act = (action: () => void) =>
afterClose ? afterClose(action) : action();
return (
Expand Down Expand Up @@ -338,6 +355,7 @@ export function MessageManagementItems({
row,
undefined,
() => editor?.focusTarget() ?? null,
authorization,
),
)
}
Expand Down Expand Up @@ -382,7 +400,7 @@ function DeleteMessageDialog({
operations: readonly OutgoingEvent[];
close(): void;
}) {
const { row } = selection;
const { row, authorization } = selection;
const [operationId, setOperationId] = useState(
() =>
operations.find(
Expand Down Expand Up @@ -424,7 +442,7 @@ function DeleteMessageDialog({
"This conversation is no longer available for changes.",
);
if (operationId) session.outbox?.retry(operationId);
else setOperationId(session.messages.remove([row.id]));
else setOperationId(session.messages.remove([row.id], authorization));
} catch (cause) {
setError(
cause instanceof Error
Expand All @@ -436,7 +454,7 @@ function DeleteMessageDialog({
return (
<AlertDialog
title="Delete message?"
description="Request deletion of this message from the conversation. People may still have copies. This cannot be undone."
description="This requests removal of this message from Buzz’s relay. People may still have copies. This cannot be undone."
pending={pending}
finalFocus={() => (operationId ? (selection.focus?.() ?? true) : true)}
onClose={close}
Expand Down
1 change: 1 addition & 0 deletions src/features/messages/MessageRow.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1093,6 +1093,7 @@ it.each(["peer", "own"] as const)(
viewer: author === "own" ? row.authorId : "viewer",
channels: { list: () => snapshot, subscribeList: () => () => {} },
messages: { report: vi.fn(async () => {}) },
profiles: { subscribe: () => () => {}, event: () => undefined },
outbox: {
supports: () => true,
subscribe: () => () => {},
Expand Down
2 changes: 1 addition & 1 deletion src/features/profiles/useAgentOwnerEvidence.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ export function useAgentOwnerEvidence(
// Subscribed, so any head change (live, read or disk restore) re-renders.
// Without a live view nothing can signal an auth-only change, so show nothing.
const directoryHead = useSyncExternalStore(
session.profiles.subscribe,
pubkey ? session.profiles.subscribe : noSubscribe,
() => (pubkey ? session.profiles.event?.(pubkey) : undefined),
() => (pubkey ? session.profiles.event?.(pubkey) : undefined),
);
Expand Down
42 changes: 42 additions & 0 deletions src/features/relay/fold.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -255,6 +255,48 @@ describe("message fold", () => {
[bob.pubkey, "spoof"],
]);
});
it("applies relay-authorized agent message deletes but not owner deletes of agent reactions", () => {
const agentMessage = message(alice, channel, "agent message", 10);
const agentReaction = signed(alice, {
kind: 7,
content: "👍",
created_at: 11,
tags: [["e", agentMessage.id]],
});
const ownerDeleteReaction = signed(bob, {
kind: 5,
content: "",
created_at: 12,
tags: [
["e", agentReaction.id],
["k", "7"],
],
});
const ownerDeleteMessage = signed(bob, {
kind: 5,
content: "",
created_at: 13,
tags: [
["e", agentMessage.id],
["k", "9"],
],
});
expect(
foldMessages(channel, relay.pubkey, [
agentMessage,
agentReaction,
ownerDeleteReaction,
])[0]?.reactions,
).toHaveLength(1);
expect(
foldMessages(channel, relay.pubkey, [
agentMessage,
agentReaction,
ownerDeleteMessage,
]),
).toHaveLength(0);
});

it("reads relay-signed thread summaries only and tolerates malformed ones", () => {
const a = message(alice, channel, "a", 10),
b = message(alice, channel, "b", 11),
Expand Down
32 changes: 28 additions & 4 deletions src/features/relay/fold.ts
Original file line number Diff line number Diff line change
Expand Up @@ -206,6 +206,30 @@ export function messageAuthor(
: event.pubkey;
}

/** A relay-verified NIP-09 that passed the relay's author/agent-owner check
* removes its target. Keep this predicate shared by timeline and unread evidence.
* Attributed owner deletion applies only to message kinds; reactions retain
* ordinary raw-author deletion semantics. */
export function deletionApplies(
deletion: EventData,
event: EventData,
signingAuthority: string | undefined,
) {
const messageKind = [9, 40002].includes(event.kind);
const nip09 = deletion.kind === 5 || deletion.kind === 9005;
return (
(nip09 && deletion.pubkey === event.pubkey) ||
(nip09 &&
messageKind &&
deletion.pubkey === messageAuthor(event, signingAuthority)) ||
(deletion.kind === 5 &&
messageKind &&
deletion.tags.some(
([name, value]) => name === "k" && value === String(event.kind),
))
);
}

/** Folds one window's top-level messages with their aux overlays: author deletes (5/9005),
* author edits (40003, latest wins), reactions (7) and relay-signed thread summaries (39005).
* Replies stay out of the top level. Output is ascending by time; ties break on id so windows merge deterministically. */
Expand Down Expand Up @@ -235,16 +259,16 @@ export function foldMessages(
overlays.set(entry[1], list);
}
}
// The relay lets an attributed author edit and delete like the signer.
// Relay-signed author attribution and NIP-09 owner deletion are both
// independently verified before these events enter the fold. Only the
// message target kinds gain the owner path; reactions remain author-only.
const byAuthor = (item: EventData, event: EventData) =>
item.pubkey === event.pubkey ||
item.pubkey === messageAuthor(event, signingAuthority);
const deleted = (event: EventData) =>
overlays
.get(event.id)
?.some(
(item) => [5, 9005].includes(item.kind) && byAuthor(item, event),
) ?? false;
?.some((item) => deletionApplies(item, event, signingAuthority)) ?? false;
const rows: ChannelMessage[] = [];
for (const event of events) {
if (!channelRowKind(event.kind)) continue;
Expand Down
Loading
Loading