diff --git a/src/features/messages/MessageManagement.test.tsx b/src/features/messages/MessageManagement.test.tsx index c4bc8cbe6..ea022919d 100644 --- a/src/features/messages/MessageManagement.test.tsx +++ b/src/features/messages/MessageManagement.test.tsx @@ -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"; @@ -65,6 +67,7 @@ async function fixture( withAttachments = false, originalAttachment = false, originalMention = false, + agentOwner: "viewer" | "other" | undefined = undefined, ) { vi.stubGlobal( "ResizeObserver", @@ -76,9 +79,16 @@ 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)" @@ -86,18 +96,46 @@ async function fixture( ? "@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>; @@ -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, @@ -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)/ }); diff --git a/src/features/messages/MessageManagement.tsx b/src/features/messages/MessageManagement.tsx index 2422a7703..b84361504 100644 --- a/src/features/messages/MessageManagement.tsx +++ b/src/features/messages/MessageManagement.tsx @@ -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"; @@ -59,6 +61,7 @@ const empty = () => emptyOperations; const noop = () => () => {}; type Deletion = { row: ChannelMessage; + authorization?: AgentOwnerAuthorization | undefined; done?: (() => void) | undefined; focus?: (() => HTMLElement | null) | undefined; }; @@ -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[]; @@ -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 }); }, }} > @@ -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) => @@ -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 ( @@ -338,6 +355,7 @@ export function MessageManagementItems({ row, undefined, () => editor?.focusTarget() ?? null, + authorization, ), ) } @@ -382,7 +400,7 @@ function DeleteMessageDialog({ operations: readonly OutgoingEvent[]; close(): void; }) { - const { row } = selection; + const { row, authorization } = selection; const [operationId, setOperationId] = useState( () => operations.find( @@ -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 @@ -436,7 +454,7 @@ function DeleteMessageDialog({ return ( (operationId ? (selection.focus?.() ?? true) : true)} onClose={close} diff --git a/src/features/messages/MessageRow.test.tsx b/src/features/messages/MessageRow.test.tsx index 23d006515..5986b8e37 100644 --- a/src/features/messages/MessageRow.test.tsx +++ b/src/features/messages/MessageRow.test.tsx @@ -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: () => () => {}, diff --git a/src/features/profiles/useAgentOwnerEvidence.ts b/src/features/profiles/useAgentOwnerEvidence.ts index aee4efa27..9e0cc378f 100644 --- a/src/features/profiles/useAgentOwnerEvidence.ts +++ b/src/features/profiles/useAgentOwnerEvidence.ts @@ -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), ); diff --git a/src/features/relay/fold.test.ts b/src/features/relay/fold.test.ts index 4869c536c..802008699 100644 --- a/src/features/relay/fold.test.ts +++ b/src/features/relay/fold.test.ts @@ -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), diff --git a/src/features/relay/fold.ts b/src/features/relay/fold.ts index f3f0ef787..a06f20a50 100644 --- a/src/features/relay/fold.ts +++ b/src/features/relay/fold.ts @@ -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. */ @@ -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; diff --git a/src/features/relay/messages.ts b/src/features/relay/messages.ts index 9b6f72230..c414c33e1 100644 --- a/src/features/relay/messages.ts +++ b/src/features/relay/messages.ts @@ -7,6 +7,12 @@ import { threadReference } from "./thread-reference"; import type { EventData } from "./events"; import type { Outbox, OutboxRecovery } from "./outbox"; +/** Owner authorization is supplied only after the agent's signed profile is verified. */ +export type AgentOwnerAuthorization = Readonly<{ + agentId: string; + ownerId: string; +}>; + /** NIP-56 types accepted by the Buzz relay for message reports. */ export const REPORT_TYPES = [ "spam", @@ -256,7 +262,10 @@ export function createMessages( ], }); }, - remove(eventIds: readonly string[]) { + remove( + eventIds: readonly string[], + authorization?: AgentOwnerAuthorization, + ) { const ids = [...new Set(eventIds)]; if (!ids.length || ids.length > 100) throw new Error("Choose between 1 and 100 events to remove"); @@ -264,7 +273,16 @@ export function createMessages( const event = find(id); if (!event || ![7, 9, 40002].includes(event.kind)) throw new Error("Load the message or reaction before removing it"); - if (event.kind === 7 ? event.pubkey !== viewer : !ownedMessage(event)) + const agentOwnerRemoval = + !!viewer && + event.kind !== 7 && + messageAuthor(event, signingAuthority) === authorization?.agentId && + authorization.ownerId === viewer; + if ( + event.kind === 7 + ? event.pubkey !== viewer + : !ownedMessage(event) && !agentOwnerRemoval + ) throw new Error( "Only your own messages and reactions can be removed", ); diff --git a/src/features/relay/reactions.test.ts b/src/features/relay/reactions.test.ts index 0620b7f76..54a7ad3fe 100644 --- a/src/features/relay/reactions.test.ts +++ b/src/features/relay/reactions.test.ts @@ -119,9 +119,10 @@ it("keeps pill order from the first reaction when counts change", () => { ).toEqual(["❤️", "😂"]); }); -it("incremental channel projection applies delete-of-reaction and rolls back a failed deletion", () => { +it("incremental channel projection applies delete-of-reaction and rejects an owner deletion of a reaction", () => { const mine = reaction(), - deletion = remove(mine); + deletion = remove(mine), + foreignDelete = remove(mine, other); const projection = new MessageProjection( "c", relay.pubkey, @@ -131,6 +132,9 @@ it("incremental channel projection applies delete-of-reaction and rolls back a f expect( projection.reconcile([root, mine, remove(mine, other)], [])[0]?.reactions, ).toHaveLength(1); + expect( + projection.reconcile([root, mine, foreignDelete], [])[0]?.reactions, + ).toHaveLength(1); expect( projection.reconcile([root, mine, deletion], [])[0]?.reactions, ).toEqual([]); @@ -184,6 +188,125 @@ it("removal validates every loaded author and conversation before queuing one de }); }); +it("requires matching viewer and agent identities to remove agent messages", () => { + const mine = reaction(), + agentReaction = reaction(other), + agentMessage = message(other, "c", "Agent message", 4); + const events = [root, mine, agentReaction, agentMessage]; + const send = vi.fn(() => "operation"); + const messages = createMessages( + { + ready: async () => {}, + recover: async () => {}, + acknowledge: async () => {}, + supports: () => true, + send, + retry() {}, + dismiss: async () => {}, + snapshot: () => [], + subscribe: () => () => {}, + observeSend: () => () => {}, + }, + viewer.pubkey, + (id) => events.find((item) => item.id === id), + () => [], + () => {}, + ); + const authorization = { agentId: other.pubkey, ownerId: viewer.pubkey }; + + expect(() => messages.remove([agentMessage.id])).toThrow(/Only your own/); + expect(() => + messages.remove([agentMessage.id], { + ...authorization, + ownerId: other.pubkey, + }), + ).toThrow(/Only your own/); + expect(() => + messages.remove([agentMessage.id], { + ...authorization, + agentId: viewer.pubkey, + }), + ).toThrow(/Only your own/); + expect(() => messages.remove([agentReaction.id], authorization)).toThrow( + /Only your own/, + ); + expect(send).not.toHaveBeenCalled(); + + messages.remove([agentMessage.id], authorization); + expect(send).toHaveBeenCalledExactlyOnceWith({ + kind: 5, + content: "", + tags: [ + ["h", "c"], + ["e", agentMessage.id], + ["k", "9"], + ], + }); +}); + +it("authorizes removal against the relay-attributed agent author, not the relay signer", () => { + const agent = keypair(); + const attributed = signed(relay, { + kind: 9, + content: "Agent output", + created_at: 5, + tags: [ + ["actor", agent.pubkey], + ["h", "c"], + ], + }); + const forged = signed(other, { + kind: 9, + content: "Forged actor", + created_at: 6, + tags: [ + ["actor", agent.pubkey], + ["h", "c"], + ], + }); + const events = [attributed, forged]; + const send = vi.fn(() => "operation"); + const messages = createMessages( + { + ready: async () => {}, + recover: async () => {}, + acknowledge: async () => {}, + supports: () => true, + send, + retry() {}, + dismiss: async () => {}, + snapshot: () => [], + subscribe: () => () => {}, + observeSend: () => () => {}, + }, + viewer.pubkey, + (id) => events.find((item) => item.id === id), + () => [], + () => {}, + undefined, + undefined, + undefined, + relay.pubkey, + ); + const authorization = { agentId: agent.pubkey, ownerId: viewer.pubkey }; + + expect(() => messages.remove([forged.id], authorization)).toThrow( + /Only your own/, + ); + expect(send).not.toHaveBeenCalled(); + + messages.remove([attributed.id], authorization); + expect(send).toHaveBeenCalledExactlyOnceWith({ + kind: 5, + content: "", + tags: [ + ["h", "c"], + ["e", attributed.id], + ["k", "9"], + ], + }); +}); + it("real session retries the same signed deletion and keeps the reaction removed after confirmation", async () => { const wire = scriptedTransport(viewer.pubkey, relay.pubkey); let live!: LiveCallbacks; diff --git a/src/features/relay/session.ts b/src/features/relay/session.ts index 303f298aa..a6fa4cb8b 100644 --- a/src/features/relay/session.ts +++ b/src/features/relay/session.ts @@ -839,6 +839,9 @@ export function createRelaySession( viewer: transport?.viewer ?? "", relayAuthor: transport?.relayAuthor ?? "", workflowAuthority: transport?.archiveAuthority, + ...(transport?.archiveAuthority + ? { signingAuthority: transport.archiveAuthority } + : {}), notify, follows: browserThreadFollows(readScope), }); diff --git a/src/features/relay/unread.test.ts b/src/features/relay/unread.test.ts index cdb754159..e475fc0e9 100644 --- a/src/features/relay/unread.test.ts +++ b/src/features/relay/unread.test.ts @@ -1,6 +1,6 @@ import { afterEach, assert, expect, it, vi } from "vitest"; import { createRelaySession } from "./session"; -import { foldMessages } from "./fold"; +import { deletionApplies, foldMessages } from "./fold"; import { readJournal, newReadJournal, @@ -280,6 +280,86 @@ it("unread repair observes history without seeding the channel window or consumi }); }); +it("owner deletion reconciles agent message unread evidence but keeps agent reactions", async () => { + const h = setup({}, true, undefined, true); + h.grant("room"); + const agent = keypair(); + const attributed = signed(h.relay, { + kind: 9, + content: "Agent unread message", + created_at: 11, + tags: [ + ["actor", agent.pubkey], + ["h", "room"], + ], + }); + const olderMessage = message(h.alice, "room", "Older message", 10); + const agentReaction = signed(h.relay, { + kind: 7, + content: "+", + created_at: 12, + tags: [ + ["actor", agent.pubkey], + ["h", "room"], + ["e", olderMessage.id], + ], + }); + h.emit([olderMessage, attributed]); + expect(h.snapshot()).toMatchObject({ + observedCount: 2, + latestMessage: { id: attributed.id, createdAt: 11 }, + }); + + // The confirmed owner request carries a standard message-kind hint. Its + // verified target no longer contributes to unread count or latest evidence. + const ownerDeletion = signed(h.viewer, { + kind: 5, + content: "", + created_at: 13, + tags: [ + ["h", "room"], + ["e", attributed.id], + ["k", "9"], + ], + }); + h.emit([ownerDeletion]); + expect(h.snapshot()).toMatchObject({ + observedCount: 1, + latestMessage: { id: olderMessage.id, createdAt: 10 }, + }); + + const laterMessage = message(h.alice, "room", "Later message", 14); + h.emit([agentReaction, laterMessage]); + expect(h.snapshot()).toMatchObject({ + observedCount: 2, + latestMessage: { id: laterMessage.id, createdAt: 14 }, + }); + + const reactionOwnerDelete = signed(h.viewer, { + kind: 5, + content: "", + created_at: 15, + tags: [ + ["h", "room"], + ["e", agentReaction.id], + ["k", "7"], + ], + }); + expect( + deletionApplies(reactionOwnerDelete, agentReaction, h.relay.pubkey), + ).toBe(false); + const rows = foldMessages( + "room", + h.relay.pubkey, + [olderMessage, agentReaction, reactionOwnerDelete], + { signingAuthority: h.relay.pubkey }, + ); + expect(rows).toHaveLength(1); + expect(rows[0]?.reactions[0]?.events.map(({ id }) => id)).toEqual([ + agentReaction.id, + ]); +}); + it.each([5, 9005])( "later kind-%s deletions resolve repair-only evidence without seeding a window", async (kind) => { diff --git a/src/features/relay/unread.ts b/src/features/relay/unread.ts index aa08de240..30323b58d 100644 --- a/src/features/relay/unread.ts +++ b/src/features/relay/unread.ts @@ -16,7 +16,7 @@ import type { } from "./read-state"; import type { ReadJournal } from "./read-state-storage"; import type { Priority, RelayReader } from "./reader"; -import { foldMessages } from "./fold"; +import { foldMessages, deletionApplies } from "./fold"; import { threadReference } from "./thread-reference"; import { memoryThreadFollows, @@ -190,6 +190,7 @@ export function createUnread({ reader, viewer, relayAuthor, + signingAuthority, workflowAuthority, notify = (listener) => listener(), follows = memoryThreadFollows(), @@ -199,6 +200,7 @@ export function createUnread({ reader: RelayReader; viewer: string; relayAuthor?: string; + signingAuthority?: string; workflowAuthority?: string | undefined; notify?: (listener: () => void) => void; follows?: ThreadFollowStorage; @@ -395,9 +397,15 @@ export function createUnread({ ownThreads.clear(); for (const event of events.values()) { if (event.kind !== 5 && event.kind !== 9005) continue; - for (const [name, id] of event.tags) - if (name === "e" && id && events.get(id)?.pubkey === event.pubkey) - tombstones.add(id); + for (const [name, id] of event.tags) { + const target = id ? events.get(id) : undefined; + if ( + name === "e" && + target && + deletionApplies(event, target, signingAuthority) + ) + tombstones.add(target.id); + } } for (const event of events.values()) { if (!contentKind(event) || tombstones.has(event.id)) continue; diff --git a/tests/browser/fixture.mjs b/tests/browser/fixture.mjs index e13672e54..196a07c4e 100644 --- a/tests/browser/fixture.mjs +++ b/tests/browser/fixture.mjs @@ -11,7 +11,7 @@ import { import { writeFile } from "node:fs/promises"; import { createHash, randomBytes } from "node:crypto"; import { schnorr } from "@noble/curves/secp256k1.js"; -import { bytesToHex } from "nostr-tools/utils"; +import { bytesToHex, hexToBytes } from "nostr-tools/utils"; import { platform, arch } from "node:os"; import { fileURLToPath } from "node:url"; import { execFileSync } from "node:child_process"; @@ -35,6 +35,7 @@ export const test = base.extend({ productionBroker: [false, { option: true }], archiveOnDisk: [false, { option: true }], actionProfile: [false, { option: true }], + agentMessageDeletion: [false, { option: true }], profilePicture: ["", { option: true }], readState: [false, { option: true }], threadUnread: [false, { option: true }], @@ -89,6 +90,7 @@ export const test = base.extend({ productionBroker, archiveOnDisk, actionProfile, + agentMessageDeletion, profilePicture, readState, threadUnread, @@ -200,8 +202,19 @@ export const test = base.extend({ ); // Kind 0 by author for keys a test creates; served on later profile reads. const servedProfiles = new Map(); - const ownerAgentKey = lifecycleOwnerAgent ? generateSecretKey() : undefined; + const ownerAgentKey = + lifecycleOwnerAgent || agentMessageDeletion + ? generateSecretKey() + : undefined; const ownerAgent = ownerAgentKey ? getPublicKey(ownerAgentKey) : undefined; + const ownerAuthDigest = ownerAgent + ? new Uint8Array( + await crypto.subtle.digest( + "SHA-256", + new TextEncoder().encode(`nostr:agent-auth:${ownerAgent}:`), + ), + ) + : undefined; const ownerAgentProfile = ownerAgentKey ? sign( 0, @@ -210,20 +223,22 @@ export const test = base.extend({ "auth", viewer, "", - bytesToHex( - schnorr.sign( - createHash("sha256") - .update(`nostr:agent-auth:${ownerAgent}:`) - .digest(), - userKey, - ), - ), + bytesToHex(schnorr.sign(ownerAuthDigest, userKey)), ], ], JSON.stringify({ name: "Owner Agent", is_agent: true }), ownerAgentKey, ) : undefined; + const ownerAttestationValid = + !!ownerAgent && + !!ownerAgentProfile && + !!ownerAuthDigest && + schnorr.verify( + hexToBytes(ownerAgentProfile.tags[0][3]), + ownerAuthDigest, + hexToBytes(viewer), + ); const participants = largeSidebar ? Array.from({ length: 1001 }, (_, i) => (i + 1).toString(16).padStart(64, "0"), @@ -391,6 +406,19 @@ export const test = base.extend({ ), ), ); + const ownerAgentMessage = agentMessageDeletion + ? sign( + 9, + [["h", "alpha"]], + "Owned agent deletion fixture", + ownerAgentKey, + Math.max( + ...histories.get("primary/alpha").map((event) => event.created_at), + ) + 1, + ) + : undefined; + if (ownerAgentMessage) + histories.get("primary/alpha").push(ownerAgentMessage); const historyDurationMs = performance.now() - historyStarted; for (const community of ["primary", "secondary"]) for (const id of [...dmIds, ...lifecycleRows.map((row) => row.id)]) @@ -749,6 +777,7 @@ export const test = base.extend({ developmentReact, pluginFixtures, agentManagement, + agentMessageDeletion, compiledBuild: { worker: testInfo.workerIndex, durationMs: compiledApp.durationMs, @@ -1102,6 +1131,12 @@ export const test = base.extend({ (filter.until === undefined || event.created_at <= filter.until), ) .slice(0, filter.limit); + if ( + agentMessageDeletion && + filter.kinds?.includes(0) && + filter.authors?.includes(ownerAgent) + ) + return ownerAgentProfile ? [ownerAgentProfile] : []; if (filter.kinds?.includes(0)) return [ ...[...servedProfiles.values()].filter((event) => @@ -1517,7 +1552,20 @@ export const test = base.extend({ expect(target.tags).toContainEqual(["h", channel]); if (event.kind === 7) expect(target.kind).toBe(9); else { - expect(target.pubkey).toBe(viewer); + const ownerDelete = + event.kind === 5 && + agentMessageDeletion && + target.pubkey === ownerAgent && + target.kind === 9; + if (ownerDelete) { + expect(ownerAgentProfile).toBeDefined(); + expect(ownerAttestationValid).toBe(true); + const auth = ownerAgentProfile.tags.find( + ([name]) => name === "auth", + ); + expect(auth).toHaveLength(4); + expect(auth[1]).toBe(viewer); + } else expect(target.pubkey).toBe(viewer); expect([7, 9]).toContain(target.kind); if (event.kind === 5) expect(event.tags).toContainEqual(["k", String(target.kind)]); @@ -2055,6 +2103,7 @@ export const test = base.extend({ relay.publish("primary", deletion); }, exact, + ownerAgentMessage, searchTarget, openChannelId: OPEN_CHANNEL, membership( diff --git a/tests/browser/mentions.spec.mjs b/tests/browser/mentions.spec.mjs index 5f2f64e42..35708c74a 100644 --- a/tests/browser/mentions.spec.mjs +++ b/tests/browser/mentions.spec.mjs @@ -820,7 +820,11 @@ test("saved team selection previews avatars and inserts exact recipients in one await expect(row.locator("[data-team-avatars] .buzz-avatar")).toHaveCount( 2, ); - await row.click(); + // Exercise keyboard selection; pointer selection has a separate known + // layout race when the directory loading status disappears mid-click. + await picker.getByRole("searchbox").press("ArrowDown"); + await expect(row).toBeFocused(); + await row.press("Enter"); await expect(input).toBeFocused(); await expect(input.locator(".inline-chip")).toHaveText([ "@Honey", diff --git a/tests/browser/owned-agent-message-deletion.spec.mjs b/tests/browser/owned-agent-message-deletion.spec.mjs new file mode 100644 index 000000000..11e94dc40 --- /dev/null +++ b/tests/browser/owned-agent-message-deletion.spec.mjs @@ -0,0 +1,104 @@ +import { test, expect } from "./fixture.mjs"; +import { open } from "./timeline.mjs"; + +const opened = async (menu) => { + await expect(menu).toBeVisible(); + await menu.evaluate((node) => + Promise.all( + node + .getAnimations({ subtree: true }) + .filter( + (animation) => + animation.effect?.getComputedTiming().endTime !== Infinity, + ) + .map((animation) => animation.finished), + ), + ); +}; +test.use({ + productionBroker: true, + readState: true, + historyCounts: { alpha: 2, beta: 1 }, + agentMessageDeletion: true, + viewport: { width: 1280, height: 720 }, + video: "on", +}); + +test("the verified owner confirms a NIP-09 delete for an agent message", async ({ + page, + app, +}, testInfo) => { + await open(page, app); + const event = app.ownerAgentMessage; + expect(event).toBeTruthy(); + const row = page.locator( + `[data-channel-timeline] [data-message-id="${event.id}"]`, + ); + await expect(row).toBeVisible(); + await row.scrollIntoViewIfNeeded(); + await row.hover(); + await row.getByRole("button", { name: "More message actions" }).click(); + const action = page.getByRole("menuitem", { + name: "Delete message", + exact: true, + }); + await expect(action).toBeVisible(); + const actionMenu = page.getByRole("menu"); + await opened(actionMenu); + await page.screenshot({ + path: testInfo.outputPath("owned-agent-delete-menu.png"), + }); + await action.click(); + + const confirmation = page.getByRole("alertdialog", { + name: "Delete message?", + }); + await expect(confirmation).toBeVisible(); + await expect( + confirmation.getByRole("heading", { name: "Delete message?" }), + ).toBeVisible(); + await expect(confirmation).toContainText( + "This requests removal of this message from Buzz’s relay.", + ); + await expect(confirmation).toContainText("People may still have copies."); + await expect( + confirmation.getByRole("button", { name: "Cancel" }), + ).toBeFocused(); + await confirmation.evaluate((dialog) => + Promise.all( + dialog + .getAnimations({ subtree: true }) + .filter( + (animation) => + animation.effect?.getComputedTiming().endTime !== Infinity, + ) + .map((animation) => animation.finished), + ), + ); + await expect + .poll(() => + app.report.publications.filter(({ event: item }) => item.kind === 5), + ) + .toHaveLength(0); + await page.screenshot({ + path: testInfo.outputPath("owned-agent-delete-confirmation.png"), + }); + + await confirmation + .getByRole("button", { name: "Delete", exact: true }) + .click(); + await expect(row).toHaveCount(0); + await expect(confirmation).toHaveCount(0); + const deletion = app.report.publications.find( + ({ event: item }) => + item.kind === 5 && + item.tags.some(([name, id]) => name === "e" && id === event.id), + ).event; + expect(deletion.pubkey).toBe(app.viewer); + expect(deletion.tags.filter(([name]) => name !== "client-id")).toEqual([ + ["h", "alpha"], + ["e", event.id], + ["k", "9"], + ]); + expect(app.report.unexpected).toEqual([]); +});