diff --git a/docs/agents.md b/docs/agents.md index 9531b32812..feb100b596 100644 --- a/docs/agents.md +++ b/docs/agents.md @@ -76,6 +76,32 @@ same local bound and lifetime. Keys stay in native custody. Hosted-provider provisioning and standalone tool servers do not support these operations. +## Message edits + +The shared MCP `edit` tool retains the latest live attachment set, including an +empty set left by a previous edit. Both app-hosted and standalone agents resolve +that state as the agent through the version-1 `effective_edit` primary lookup on +`/query`; they verify the signed original/replacement and their target/channel. +The exact filter uses kinds `[9,40002,40008,45001,45003]`. +Deploy the relay support from +[buzz#9226](https://github.com/block/buzz/pull/9226) before releasing this client. +An old relay's array response, inaccessible target, invalid response or failed +lookup aborts without publishing. All MCP agent edits stop working on a community +whose relay lacks this lookup; upgrade each community before releasing the client. +There is no original-attachment fallback. Observe MCP edit errors and the relay's +`buzz_media_publication_access_total` denials during rollout. Rolling the client +back restores the original-attachment resurrection bug. + +The tool waits until the second after the observed head before requesting a +signature, reusing the bounded wait for notes/canvas. If that second is over five +seconds ahead, it asks the caller to retry when the clock catches up. +The MCP publisher stamps the current time, so its five-second wait bounds tool +latency. The CLI supplies an explicit timestamp and tolerates a 60-second lead. +Concurrent writers still use relay ordering; this is not compare-and-swap. The relay must +authorize every carried file for the signing agent; an owner's access does not +grant the agent private-media access, and denial never causes an automatic retry +with files stripped. + ## External app authorization The build flag `BUZZ_EXTERNAL_MCP_COMMUNITIES` lists the allowed community diff --git a/docs/buzz-help.md b/docs/buzz-help.md index ab03580752..2063eadcb4 100644 --- a/docs/buzz-help.md +++ b/docs/buzz-help.md @@ -1203,6 +1203,10 @@ confirmation. **Delete team** asks first; it doesn't delete the team's agents. standalone Buzz MCP server checks that channel's members and privacy against the key its relay advertises; where the relay does not advertise one, the tools find none. +- Agent text edits retain the message's current attachments, including removals + made by an earlier edit. Editing requires a community that supports effective + edit lookup; if that read fails, the tool leaves the message alone. The agent + must still have permission to publish every retained file. - The community authorizes reads and writes. An agent's name, profile, picture, or appearance in a list does not prove access, ownership, or running status. When helping someone explore a community, use only your available access and diff --git a/src/bundled/bestie/testing.ts b/src/bundled/bestie/testing.ts index 779ff2769c..1ed99a4a16 100644 --- a/src/bundled/bestie/testing.ts +++ b/src/bundled/bestie/testing.ts @@ -380,6 +380,7 @@ export async function bestieFixture( publish: vi.fn(), publishProfile: vi.fn(), query: vi.fn(), + effectiveEdit: vi.fn(), searchProfiles: vi.fn(), upload: vi.fn(), media: vi.fn(), diff --git a/src/bundled/builderlab/index.test.tsx b/src/bundled/builderlab/index.test.tsx index a7eaa8160b..ef46a03851 100644 --- a/src/bundled/builderlab/index.test.tsx +++ b/src/bundled/builderlab/index.test.tsx @@ -118,6 +118,7 @@ function provideAgents( publish: vi.fn(), publishProfile: vi.fn(), query: vi.fn(), + effectiveEdit: vi.fn(), searchProfiles: vi.fn(), upload: vi.fn(), media: vi.fn(), diff --git a/src/buzz-mcp/app-client.test.ts b/src/buzz-mcp/app-client.test.ts index 43bdc6bf39..06056244c7 100644 --- a/src/buzz-mcp/app-client.test.ts +++ b/src/buzz-mcp/app-client.test.ts @@ -158,6 +158,30 @@ it("carries connection authorities independently of query event authors", async expect(query).toHaveBeenCalledWith([{ kinds: [39000] }]); }); +it("reads effective edit state as the agent and withholds it after the turn retires", async () => { + const result = gate>>(); + const effectiveEdit = vi.fn(() => result.promise); + let live = true; + const client = appClient({ effectiveEdit } as unknown as AgentHandle, { + read: vi.fn(), + memories: async () => ({ entries: [], partial: false }), + check: () => { + if (!live) throw new Error("retired"); + }, + }); + const pending = client.effectiveEdit?.("target", "private"); + expect(effectiveEdit).toHaveBeenCalledWith("target", "private"); + live = false; + result.resolve({ id: "head" } as Awaited< + ReturnType + >); + await expect(pending).rejects.toThrow("retired"); + await expect(client.effectiveEdit?.("target", "private")).rejects.toThrow( + "retired", + ); + expect(effectiveEdit).toHaveBeenCalledOnce(); +}); + it("does not invent authority when a host has none", () => { const client = appClient({ pubkey: "agent" } as AgentHandle, { read: vi.fn(), diff --git a/src/buzz-mcp/app-client.ts b/src/buzz-mcp/app-client.ts index d76aeb935e..e131b3689a 100644 --- a/src/buzz-mcp/app-client.ts +++ b/src/buzz-mcp/app-client.ts @@ -138,6 +138,12 @@ export function appClient( check(); return events; }, + effectiveEdit: async (id, channel) => { + check(); + const event = await agent.effectiveEdit(id, channel); + check(); + return event; + }, searchProfiles: (query, limit) => { check(); return agent.searchProfiles(query, limit); diff --git a/src/buzz-mcp/client.ts b/src/buzz-mcp/client.ts index 834cd5d879..31302ce2fb 100644 --- a/src/buzz-mcp/client.ts +++ b/src/buzz-mcp/client.ts @@ -137,6 +137,8 @@ export interface BuzzClient { readonly signingAuthority?: string | undefined; /** Reads the community as the agent. Every filter names its kinds. */ query(filters: readonly Filter[]): Promise; + /** Latest live attachment state, read as this agent from a primary snapshot. */ + effectiveEdit?(id: string, channel: string): Promise; /** Finds profiles whose `name` or `display_name` matches `query`, best * match first. `undefined` when the community has no profile name search; * NIP-50 search, which matches the whole profile, is the fallback. */ diff --git a/src/buzz-mcp/edit.test.ts b/src/buzz-mcp/edit.test.ts new file mode 100644 index 0000000000..83ff71d42b --- /dev/null +++ b/src/buzz-mcp/edit.test.ts @@ -0,0 +1,226 @@ +import { afterEach, expect, it, vi } from "vitest"; +import { bytesToHex } from "nostr-tools/utils"; +import { eventDto } from "../features/relay/events"; +import { keypair, signed } from "../features/relay/testing"; +import { nodeClient } from "./node"; +import { callTool } from "./tools"; +import type { BuzzEvent } from "./client"; + +const agent = keypair(); +const owner = keypair(); +const channel = "00000000-0000-4000-8000-000000000001"; +const file = [ + "imeta", + "url https://relay.example.org/media/private.png", + `x ${"a".repeat(64)}`, +]; + +function fixture(kind = 9) { + vi.useFakeTimers(); + vi.setSystemTime(1_800_000_000_000); + const original = signed(agent, { + kind, + content: "original", + tags: [["h", channel], file], + created_at: 1_799_999_999, + }); + let latest: BuzzEvent | null = null; + let answer: unknown; + let lookupStatus = 200; + let accepted = true; + const published: BuzzEvent[] = []; + const fetcher = vi.fn(async (url: string, init: RequestInit) => { + const body = JSON.parse(String(init.body)); + if (url.endsWith("/events")) { + const event = eventDto(body); + published.push(event); + if (accepted) latest = event; + return Response.json({ + accepted, + message: accepted ? "" : "file not publishable by signer", + }); + } + const auth = String( + (init.headers as Record).authorization, + ).slice(6); + expect( + eventDto(JSON.parse(Buffer.from(auth, "base64").toString())).pubkey, + ).toBe(agent.pubkey); + if (body[0].effective_edit) { + expect(body).toEqual([ + { + kinds: [9, 40002, 40008, 45001, 45003], + ids: [original.id], + "#h": [channel], + effective_edit: 1, + consistency: "strong", + }, + ]); + return Response.json( + answer === undefined + ? { effective_edit: 1, original, edit: latest } + : answer, + { status: lookupStatus }, + ); + } + return Response.json([original]); + }); + vi.stubGlobal("fetch", fetcher); + const client = nodeClient({ + relay: "wss://relay.example.org", + key: bytesToHex(agent.secret), + auth: JSON.stringify(["auth", owner.pubkey, "", "0".repeat(128)]), + root: "/", + }); + return { + original, + client, + published, + fetcher, + edit: () => + callTool(client, { channel }, "edit", { + id: original.id, + text: "edited", + }), + setLatest: (tags: string[][], created_at = 1_800_000_000) => { + latest = signed(agent, { + kind: 40003, + content: "prior", + tags: [ + ["h", channel], + ["e", "invalid"], + ["e", original.id.toUpperCase()], + ...tags, + ], + created_at, + }); + return latest; + }, + answer: (value: unknown, status = 200) => { + answer = value; + lookupStatus = status; + }, + refuse: () => { + accepted = false; + }, + }; +} + +afterEach(() => { + vi.useRealTimers(); + vi.unstubAllGlobals(); +}); + +it.each([9, 40002, 40008, 45001, 45003])( + "retains current private attachments across successive MCP edits of kind %s", + async (kind) => { + const f = fixture(kind); + await f.edit(); + expect(f.published[0]?.tags).toContainEqual(file); + const replacement = [ + "imeta", + "url https://relay.example.org/media/replacement.png", + `x ${"b".repeat(64)}`, + ]; + const head = f.setLatest([replacement]); + const second = f.edit(); + await vi.advanceTimersByTimeAsync(1000); + await second; + expect(f.published[1]?.created_at).toBeGreaterThan(head.created_at); + expect(f.published[1]?.tags.filter((t) => t[0] === "imeta")).toEqual([ + replacement, + ]); + f.setLatest([], 1_800_000_001); + const third = f.edit(); + await vi.advanceTimersByTimeAsync(1000); + await third; + const fourth = f.edit(); + await vi.advanceTimersByTimeAsync(1000); + await fourth; + expect( + f.published.slice(2).map((e) => e.tags.filter((t) => t[0] === "imeta")), + ).toEqual([[], []]); + expect( + f.published.every( + (e) => + e.pubkey === agent.pubkey && + e.tags.some((t) => t[0] === "auth" && t[1] === owner.pubkey), + ), + ).toBe(true); + }, +); + +it.each([ + ["old relay array", [], 200], + ["missing edit", { effective_edit: 1 }, 200], + ["wrong version", { effective_edit: 2, edit: null }, 200], + ["404", { error: "message not found" }, 404], + ["lookup failure", { error: "database unavailable" }, 503], +])( + "aborts before message signing/publication on %s", + async (_, response, status) => { + const f = fixture(); + f.answer(response, status as number | undefined); + await expect(f.edit()).rejects.toThrow(); + expect(f.published).toEqual([]); + expect( + f.fetcher.mock.calls.every(([url]) => !url.endsWith("/events")), + ).toBe(true); + }, +); + +it.each([ + "target", + "channel", + "kind", + "signature", + "replacement target", + "replacement channel", + "replacement kind", +])("rejects a signed snapshot with invalid %s", async (fault) => { + const f = fixture(); + let original = f.original; + let edit: BuzzEvent | null = null; + if (fault === "target") + original = signed(agent, { ...original, content: "another" }); + if (fault === "channel") + original = signed(agent, { ...original, tags: [["h", "elsewhere"]] }); + if (fault === "kind") original = signed(agent, { ...original, kind: 7 }); + if (fault === "signature") original = { ...original, sig: "0".repeat(128) }; + if (fault.startsWith("replacement")) + edit = signed(agent, { + kind: fault === "replacement kind" ? 7 : 40003, + content: "bad", + tags: [ + ["h", fault.endsWith("channel") ? "elsewhere" : channel], + ["e", fault.endsWith("target") ? "f".repeat(64) : original.id], + ], + }); + f.answer({ effective_edit: 1, original, edit }); + await expect(f.edit()).rejects.toThrow(); + expect(f.published).toEqual([]); +}); + +it("refuses an excessively future head instead of extending its timeline", async () => { + const f = fixture(); + f.setLatest([file], 1_800_000_010); + await expect(f.edit()).rejects.toThrow(/clock catches up/); + expect(f.published).toEqual([]); +}); + +it.each([40003, 7, 45002])( + "rejects unsupported original kind %s without publication", + async (kind) => { + const f = fixture(kind); + await expect(f.edit()).rejects.toThrow("wrong original"); + expect(f.published).toEqual([]); + }, +); + +it("reports signer media denial without retrying or stripping the attachments", async () => { + const f = fixture(); + f.refuse(); + await expect(f.edit()).rejects.toThrow("file not publishable by signer"); + expect(f.published).toHaveLength(1); + expect(f.published[0]?.tags).toContainEqual(file); +}); diff --git a/src/buzz-mcp/node.ts b/src/buzz-mcp/node.ts index 7cf2bad803..35b1b582f7 100644 --- a/src/buzz-mcp/node.ts +++ b/src/buzz-mcp/node.ts @@ -14,6 +14,10 @@ import { finalizeEvent, getPublicKey } from "nostr-tools/pure"; import { hexToBytes } from "nostr-tools/utils"; import { CANVAS_MAX_EVENT_BYTES } from "../features/channel-templates/canvas-limits.ts"; import { eventsDto } from "../features/relay/events"; +import { + effectiveEditDto, + effectiveEditFilter, +} from "../features/relay/effective-edit"; import type { BuzzClient, BuzzEvent, @@ -166,6 +170,16 @@ export function nodeClient( key: pubkey, query: async (filters) => eventsDto(await request("/query", JSON.stringify(filters))), + async effectiveEdit(id, channel) { + return effectiveEditDto( + await request( + "/query", + JSON.stringify(effectiveEditFilter(id, channel)), + ), + id, + channel, + ); + }, async searchProfiles(query, limit) { const response = await send( "/profiles/search", diff --git a/src/buzz-mcp/tools.test.ts b/src/buzz-mcp/tools.test.ts index 9c467f563d..c9fb837cdf 100644 --- a/src/buzz-mcp/tools.test.ts +++ b/src/buzz-mcp/tools.test.ts @@ -89,6 +89,11 @@ function fake( const client: BuzzClient = { key: me, pubkey: me, + effectiveEdit: vi.fn(async (id: string) => { + const found = events.find((event) => event.id === id); + if (!found) throw new Error("No effective edit fixture"); + return found; + }), query: vi.fn(async (filters: readonly Filter[]) => { const newest = [...events, ...kept].sort( (a, b) => b.created_at - a.created_at || a.id.localeCompare(b.id), @@ -623,6 +628,18 @@ it("posts at the channel's top level when given a channel", async () => { expect(published[0]?.tags).toEqual([["h", "other"]]); }); +it("refuses to edit when the host cannot obtain effective attachment state", async () => { + const { client, published } = fake(community); + delete client.effectiveEdit; + await expect( + callTool(client, { channel: "chan" }, "edit", { + id: root, + text: "must not copy the original's attachments", + }), + ).rejects.toThrow("cannot obtain effective edit state"); + expect(published).toEqual([]); +}); + it("tags the community emoji a message or edit uses, as the composer does", async () => { const stamp = "https://relay/media/stamp.png"; const emojiSet = (pubkey: string, created_at: number, entries: string[][]) => diff --git a/src/buzz-mcp/tools.ts b/src/buzz-mcp/tools.ts index 1221564755..f01fe7abf4 100644 --- a/src/buzz-mcp/tools.ts +++ b/src/buzz-mcp/tools.ts @@ -639,14 +639,23 @@ const HANDLERS: Readonly> = { const channel = tag(target, "h"); if (!channel) throw new Error(`${id} is not a channel message.`); const content = required(args, "text"); + if (!tools.client.effectiveEdit) + throw new Error("This host cannot obtain effective edit state."); + const effective = await tools.client.effectiveEdit(id, channel); + const after = (effective.created_at + 1) * 1000; + if (after - Date.now() > 5000) + throw new Error( + "The message was just changed; try again when the clock catches up.", + ); + await waitUntil(after); await tools.client.publish({ kind: 40003, content, - // An edit replaces the attachments too, so it keeps the message's. + // An edit replaces attachments too, including an explicitly empty set. tags: [ ["h", channel], ["e", id], - ...target.tags.filter((t) => t[0] === "imeta"), + ...effective.tags.filter((t) => t[0] === "imeta"), ...(await tools.emoji(content)), ], }); diff --git a/src/features/agents/local-activity.integration.test.ts b/src/features/agents/local-activity.integration.test.ts index 0e38aef948..11f8532a5a 100644 --- a/src/features/agents/local-activity.integration.test.ts +++ b/src/features/agents/local-activity.integration.test.ts @@ -89,6 +89,7 @@ it("carries a verified mention through host handover into all three scopes witho forget: vi.fn(), publish: vi.fn(), query: vi.fn(async () => []), + effectiveEdit: vi.fn(), searchProfiles: vi.fn(async () => undefined), upload: vi.fn(), remember: vi.fn(), diff --git a/src/features/agents/native.test.ts b/src/features/agents/native.test.ts index e8b90aa279..94730a8b59 100644 --- a/src/features/agents/native.test.ts +++ b/src/features/agents/native.test.ts @@ -113,6 +113,60 @@ it("admits the community's events through the relay verifier", async () => { }); }); +it("reads effective edit state through the agent-authenticated native query", async () => { + const edit = signed(agent, { + kind: 40003, + content: "no files", + tags: [ + ["h", "c"], + ["e", message.id.toUpperCase()], + ], + }); + vi.mocked(invoke).mockResolvedValueOnce({ + effective_edit: 1, + original: message, + edit, + }); + const current = await native.effectiveEdit(member, message.id, "c"); + expect(current.id).toBe(edit.id); + expect(hasEventProof(current)).toBe(true); + expect(current.tags.filter((t) => t[0] === "imeta")).toEqual([]); + expect(invoke).toHaveBeenLastCalledWith("app_agent_query", { + ...member, + filters: [ + { + kinds: [9, 40002, 40008, 45001, 45003], + ids: [message.id], + "#h": ["c"], + effective_edit: 1, + consistency: "strong", + }, + ], + }); +}); + +it.each([ + ["old relay", [message]], + [ + "unsigned original", + { + effective_edit: 1, + original: { ...message, sig: "0".repeat(128) }, + edit: null, + }, + ], + ["wrong channel", { effective_edit: 1, original: message, edit: null }], +])("rejects effective state from %s", async (fault, response) => { + vi.mocked(invoke).mockResolvedValueOnce(response); + await expect( + native.effectiveEdit( + member, + message.id, + fault === "wrong channel" ? "other" : "c", + ), + ).rejects.toThrow(); +}); + it.each([ ["a forged signature", [{ ...message, sig: "0".repeat(128) }]], ["edited content", [{ ...message, content: "bye" }]], diff --git a/src/features/agents/native.ts b/src/features/agents/native.ts index 567d09e2f0..08ff4b0c8e 100644 --- a/src/features/agents/native.ts +++ b/src/features/agents/native.ts @@ -4,6 +4,7 @@ import type { } from "../agent-protection/service"; import { invoke, isTauri } from "@tauri-apps/api/core"; import { eventsDto, type RelayEvent } from "../relay/events"; +import { effectiveEditDto, effectiveEditFilter } from "../relay/effective-edit"; import { importedEmojiPicture } from "./import-picture"; /** A plugin agent's identity in one community as native custody reports it; @@ -189,6 +190,11 @@ export type AgentsNative = { publish(member: Membership, event: AgentEventTemplate): Promise; /** Reads the membership's community as the agent: one to eight filters. */ query(member: Membership, filters: readonly object[]): Promise; + effectiveEdit( + member: Membership, + id: string, + channel: string, + ): Promise; /** Finds profiles by `name` or `display_name` in the agent's community, as * the agent, best match first. `undefined` when the community has no * profile name search. */ @@ -309,6 +315,16 @@ export const nativeAgents = (): AgentsNative | undefined => eventsDto( await invoke("app_agent_query", { pubkey, relay, filters }), ), + effectiveEdit: async ({ pubkey, relay }, id, channel) => + effectiveEditDto( + await invoke("app_agent_query", { + pubkey, + relay, + filters: effectiveEditFilter(id, channel), + }), + id, + channel, + ), searchProfiles: async ({ pubkey, relay }, query, limit) => { const found = await invoke("app_agent_search_profiles", { pubkey, diff --git a/src/features/agents/runner.ts b/src/features/agents/runner.ts index 92440a42b2..c59a340d88 100644 --- a/src/features/agents/runner.ts +++ b/src/features/agents/runner.ts @@ -538,6 +538,7 @@ export type RunnerHost = { | "release" | "publish" | "query" + | "effectiveEdit" | "searchProfiles" | "upload" | "media" @@ -2889,6 +2890,10 @@ export class AgentRunners { lifetime.throwIfAborted(); return this.require().query(agent, filters); }, + effectiveEdit: async (id: string, channel: string) => { + lifetime.throwIfAborted(); + return this.require().effectiveEdit(agent, id, channel); + }, searchProfiles: async (query: string, limit: number) => { lifetime.throwIfAborted(); return this.require().searchProfiles(agent, query, limit); diff --git a/src/features/agents/service-testing.ts b/src/features/agents/service-testing.ts index c3dcb77acd..b3f52606d4 100644 --- a/src/features/agents/service-testing.ts +++ b/src/features/agents/service-testing.ts @@ -540,6 +540,9 @@ export function fakeNative(identities: AgentIdentity[] = []) { ), publishProfile: vi.fn(async (_: Membership) => {}), query: vi.fn(async () => []), + effectiveEdit: vi.fn(async () => { + throw new Error("No effective edit fixture"); + }), searchProfiles: vi.fn(async () => undefined), upload: vi.fn(async () => ({ url: "", sha256: "", size: 0, type: "" })), media: vi.fn(async () => ({ path: "", type: "", size: 0 })), diff --git a/src/features/agents/service.test.ts b/src/features/agents/service.test.ts index ce50c12167..9768b9b692 100644 --- a/src/features/agents/service.test.ts +++ b/src/features/agents/service.test.ts @@ -11488,6 +11488,20 @@ async function handleOf( return run.mock.calls.at(-1)?.[0].agent as Delivery["agent"]; } +it("routes effective edit lookups through the running agent's custody", async () => { + const { service, native, run, emit } = await setup(); + await service.create({ type: "example/echo", name: "Echo" }); + const handle = await handleOf(run, emit); + const head = event("head", { kind: 40003, tags: [["h", channel]] }); + native.effectiveEdit.mockResolvedValueOnce(head); + expect(await handle.effectiveEdit("target", channel)).toBe(head); + expect(native.effectiveEdit).toHaveBeenCalledWith( + expect.objectContaining({ pubkey: bot, relay: expect.any(String) }), + "target", + channel, + ); +}); + it("lets a run read and write its attention with expected-state tokens", async () => { const { service, run, emit } = await setup(); await service.create({ type: "example/echo", name: "Echo" }); diff --git a/src/features/agents/service.ts b/src/features/agents/service.ts index 37fd27176a..394e56234d 100644 --- a/src/features/agents/service.ts +++ b/src/features/agents/service.ts @@ -495,6 +495,7 @@ export type AgentHandle = Readonly<{ publish(event: AgentEventTemplate, check?: () => void): Promise; /** Reads the community as the agent, seeing only what it may see. */ query(filters: readonly object[]): Promise; + effectiveEdit(id: string, channel: string): Promise; /** Finds profiles by `name` or `display_name`, as the agent, best match * first. `undefined` when its community has no profile name search. */ searchProfiles( diff --git a/src/features/relay/effective-edit.ts b/src/features/relay/effective-edit.ts new file mode 100644 index 0000000000..731fe2bcb5 --- /dev/null +++ b/src/features/relay/effective-edit.ts @@ -0,0 +1,45 @@ +import { eventDto } from "./events"; + +/** Versioned primary snapshot; a generic #e read cannot resolve mixed-case targets. */ +export function effectiveEditFilter(id: string, channel: string) { + return [ + { + kinds: [9, 40002, 40008, 45001, 45003], + ids: [id], + "#h": [channel], + effective_edit: 1, + consistency: "strong", + }, + ]; +} + +/** Empty replacement tags are authoritative, never a reason to use the original. */ +export function effectiveEditDto(value: unknown, id: string, channel: string) { + if ( + !value || + typeof value !== "object" || + Array.isArray(value) || + !("effective_edit" in value) || + value.effective_edit !== 1 || + !("original" in value) || + !("edit" in value) + ) + throw new Error("Relay must support effective_edit v1 before editing."); + const original = eventDto(value.original); + const inChannel = (event: typeof original) => + event.tags.find((t) => t[0] === "h")?.[1] === channel; + if ( + original.id !== id || + ![9, 40002, 40008, 45001, 45003].includes(original.kind) || + !inChannel(original) + ) + throw new Error("Effective edit response has the wrong original."); + if (value.edit === null) return original; + const edit = eventDto(value.edit); + const target = edit.tags.find( + (t) => t[0] === "e" && /^[0-9a-f]{64}$/i.test(t[1] ?? ""), + )?.[1]; + if (edit.kind !== 40003 || !inChannel(edit) || target?.toLowerCase() !== id) + throw new Error("Effective edit response has the wrong replacement."); + return edit; +}