feat(nip71): Phase 1b — mirror teardown (NIP-09 delete + empty-replace tombstone)

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 <[email protected]>
This commit is contained in:
thePR0M3TH3ANandClaude Opus 4.8 committed 2026-06-22 09:18:31 -04:00
1 parent e20191d6bc
commit 58f1909570
2 files changed
+113 -1

No files matched your search

+73 -1
View File
@@ -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();
+40
View File
@@ -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");
});