From 58f1909570f03013817d1d8d03f34763879c6b18 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Mon, 22 Jun 2026 09:18:31 -0400 Subject: [PATCH] =?UTF-8?q?feat(nip71):=20Phase=201b=20=E2=80=94=20mirror?= =?UTF-8?q?=20teardown=20(NIP-09=20delete=20+=20empty-replace=20tombstone)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds remove(video, options) to the mirror service for delete / toggle-off: - NIP-09 kind-5 delete referencing BOTH addressable kinds (34235:pub:root + 34236:pub:root, with k tags) so it's robust to a since-changed orientation. - empty-replace tombstone (same d-tag, title, NO imeta) so clients that cache addressable events but ignore NIP-09 still see the entry cleared; uses the short kind for portrait videos. Published to the write/outbox relay set; unavailable without a signer. tests/nip71-mirror-service.test.mjs: remove publishes delete (both kinds) + tombstone; portrait => 34236 tombstone; unavailable without signer. Mutation-verified (tombstone emission + both-kind a-tags load-bearing). Note: full unit suite green apart from the documented order-dependent flakes (uploadModal-reset; a transient login-modal clipboard global-state flake) — both pass in isolation and are unrelated to this change. Co-Authored-By: Claude Opus 4.8 --- js/services/nip71MirrorService.js | 74 ++++++++++++++++++++++++++++- tests/nip71-mirror-service.test.mjs | 40 ++++++++++++++++ 2 files changed, 113 insertions(+), 1 deletion(-) diff --git a/js/services/nip71MirrorService.js b/js/services/nip71MirrorService.js index dcb92e5d..25bbec5c 100644 --- a/js/services/nip71MirrorService.js +++ b/js/services/nip71MirrorService.js @@ -114,7 +114,79 @@ export function createNip71MirrorService({ }; } - return { isAvailable, canMirror, publish }; + // Tear down the mirror (on delete, or when the user toggles sync off). Belt-and- + // suspenders per the plan: a NIP-09 delete referencing BOTH addressable kinds + // (robust to a since-changed orientation), AND an empty-replace tombstone so + // clients that cache addressable events but ignore NIP-09 still see it cleared. + async function remove(video, options = {}) { + if (!isAvailable()) { + return { ok: false, error: "unavailable" }; + } + const pubkey = str(video?.pubkey) || getActivePubkey(); + const root = str(video?.videoRootId); + if (!pubkey || !root) { + return { ok: false, error: "invalid" }; + } + + const createdAt = Math.floor(now() / 1000); + const reason = str(options.reason) || "Removed NIP-71 mirror"; + + const deleteEvent = { + kind: 5, + pubkey, + created_at: createdAt, + content: reason, + tags: [ + ["a", `34235:${pubkey}:${root}`], + ["a", `34236:${pubkey}:${root}`], + ["k", "34235"], + ["k", "34236"], + ], + }; + + const width = Number(video?.width); + const height = Number(video?.height); + const portrait = + Number.isFinite(width) && Number.isFinite(height) && width > 0 && height > 0 && height > width; + const tombstoneKind = + options.short === true || (options.short !== false && portrait) ? 34236 : 34235; + const tombstone = { + kind: tombstoneKind, + pubkey, + created_at: createdAt, + // No imeta → nothing plays; title kept so the entry reads as removed. + tags: [["d", root], ["title", str(video?.title) || "[removed]"], ["client", "bitvid"]], + content: "", + }; + + const relays = getWriteRelays() || []; + const pool = getPool(); + if (!pool || !relays.length) { + return { ok: false, error: "no-relays" }; + } + + let publishedOk = 0; + let total = 0; + for (const template of [deleteEvent, tombstone]) { + total += 1; + let signed; + try { + signed = await signEvent(template); + } catch (error) { + userLogger.warn("[nip71Mirror] Failed to sign teardown event:", error); + continue; + } + const results = await publishEventToRelays(pool, relays, signed); + const { accepted } = summarizePublishResults(results); + if (accepted.length > 0) { + publishedOk += 1; + } + } + + return { ok: publishedOk > 0, published: publishedOk, total }; + } + + return { isAvailable, canMirror, publish, remove }; } export const nip71MirrorService = createNip71MirrorService(); diff --git a/tests/nip71-mirror-service.test.mjs b/tests/nip71-mirror-service.test.mjs index 010cc242..ab9718cd 100644 --- a/tests/nip71-mirror-service.test.mjs +++ b/tests/nip71-mirror-service.test.mjs @@ -105,3 +105,43 @@ test("publish is unavailable without a signer / pubkey", async () => { assert.equal(result.ok, false); assert.equal(result.error, "unavailable"); }); + +test("remove() publishes a NIP-09 delete (both addressable kinds) AND a tombstone", async () => { + const { service, relay } = makeService(); + const result = await service.remove(baseVideo()); + assert.equal(result.ok, true); + assert.equal(relay.events.length, 2, "delete + empty-replace tombstone"); + + const del = relay.events.find((e) => e.kind === 5); + assert.ok(del, "must publish a NIP-09 kind-5 delete"); + const aTags = del.tags.filter((t) => t[0] === "a").map((t) => t[1]).sort(); + assert.deepEqual(aTags, [ + `34235:${PUBKEY}:root-1`, + `34236:${PUBKEY}:root-1`, + ], "delete references both addressable kinds (orientation-robust)"); + const kTags = del.tags.filter((t) => t[0] === "k").map((t) => t[1]).sort(); + assert.deepEqual(kTags, ["34235", "34236"]); + + const tomb = relay.events.find((e) => e.kind === 34235 || e.kind === 34236); + assert.ok(tomb, "must publish an empty-replace tombstone"); + assert.equal(tomb.tags.find((t) => t[0] === "d")[1], "root-1", "same d-tag"); + assert.equal( + tomb.tags.some((t) => t[0] === "imeta"), + false, + "tombstone has no playable imeta", + ); +}); + +test("remove() tombstone uses the short kind for portrait videos", async () => { + const { service, relay } = makeService(); + await service.remove(baseVideo({ width: 1080, height: 1920 })); + const tomb = relay.events.find((e) => e.kind === 34235 || e.kind === 34236); + assert.equal(tomb.kind, 34236, "portrait => 34236 tombstone"); +}); + +test("remove() is unavailable without a signer", async () => { + const { service } = makeService({ available: false }); + const result = await service.remove(baseVideo()); + assert.equal(result.ok, false); + assert.equal(result.error, "unavailable"); +});