// Phase 1 of the NIP-71 interop plan (docs/nip71-migration-plan.md): publish the // opt-in addressable NIP-71 mirror (kind 34235/34236) for a bitvid video to the // user's write/outbox relays. // // Builds on the Phase 0 pure builder (buildNip71MirrorEvent) which already enforces // the "private never mirrored" and "HTTPS url required" rules. This service adds: // - the site-wide NSFW gate: when ALLOW_NSFW_CONTENT is false, an instance that // won't surface NSFW must not publish it outward either (moderation audit item). // - signing with the active signer + publishing to the write (outbox) relay set. // // Dependency-injected so it unit-tests without a live signer/relay pool. import { buildNip71MirrorEvent, NIP71_NORMAL_VIDEO_KIND, NIP71_SHORT_VIDEO_KIND, } from "../nostr/nip71Mirror.js"; import { nostrClient } from "../nostrClientFacade.js"; import { getActiveSigner } from "../nostr/index.js"; import { publishEventToRelays as defaultPublishEventToRelays, summarizePublishResults as defaultSummarize, } from "../nostrPublish.js"; import { ALLOW_NSFW_CONTENT } from "../config.js"; import { userLogger } from "../utils/logger.js"; function str(value) { return typeof value === "string" ? value.trim() : ""; } export function createNip71MirrorService({ buildMirrorEvent = buildNip71MirrorEvent, getActivePubkey = () => str(nostrClient?.pubkey), getSigner = () => getActiveSigner(), // Outbox/write set (mirrors getDeletePublishRelays from the delete work). getWriteRelays = () => typeof nostrClient?.getDeletePublishRelays === "function" ? nostrClient.getDeletePublishRelays() : [], getPool = () => nostrClient?.pool || null, // L1 chokepoint (no direct pool.list — see scripts/check-direct-pool-access.mjs). getSubscriptionManager = () => typeof nostrClient?.getSubscriptionManager === "function" ? nostrClient.getSubscriptionManager() : null, // Look up the author's existing mirror events (both kinds) for a video root, on // the write relays where mirrors are published. Returns [{ kind, created_at }]. // Injected so the idempotency logic unit-tests without a live relay manager. fetchExistingMirrors = async ({ pubkey, root }) => { const sm = getSubscriptionManager(); const relays = getWriteRelays() || []; if (!sm || typeof sm.list !== "function" || !relays.length) { return []; } try { const events = await sm.list({ filters: [ { kinds: [NIP71_NORMAL_VIDEO_KIND, NIP71_SHORT_VIDEO_KIND], authors: [pubkey], "#d": [root], }, ], relays, }); return (Array.isArray(events) ? events : []).map((e) => ({ kind: e?.kind, created_at: Number(e?.created_at) || 0, })); } catch (error) { userLogger.warn("[nip71Mirror] existing-mirror lookup failed:", error); return []; } }, // One relay query for all roots in the My Videos view. Kept separate from the // single-root lookup so publish/remove can remain narrowly scoped. fetchMirrorsByRoots = async ({ pubkey, roots }) => { const sm = getSubscriptionManager(); const relays = getWriteRelays() || []; const uniqueRoots = [...new Set((Array.isArray(roots) ? roots : []).map(str).filter(Boolean))]; if (!sm || typeof sm.list !== "function" || !relays.length || !uniqueRoots.length) { return []; } try { const events = await sm.list({ filters: [ { kinds: [NIP71_NORMAL_VIDEO_KIND, NIP71_SHORT_VIDEO_KIND], authors: [pubkey], "#d": uniqueRoots, }, ], relays, }); return (Array.isArray(events) ? events : []).map((event) => ({ root: str(event?.tags?.find((tag) => Array.isArray(tag) && tag[0] === "d")?.[1]), kind: event?.kind, created_at: Number(event?.created_at) || 0, })); } catch (error) { userLogger.warn("[nip71Mirror] mirror batch lookup failed:", error); return []; } }, publishEventToRelays = defaultPublishEventToRelays, summarizePublishResults = defaultSummarize, signEvent = async (template) => { const signer = getActiveSigner(); if (!signer || typeof signer.signEvent !== "function") { const err = new Error("Active signer missing signEvent support."); err.code = "sign-event-missing"; throw err; } return signer.signEvent(template); }, // Instance policy: only mirror NSFW outward when the site permits NSFW. allowNsfw = () => ALLOW_NSFW_CONTENT === true, now = () => Date.now(), } = {}) { function isAvailable() { const pubkey = getActivePubkey(); const signer = getSigner(); return Boolean(pubkey) && Boolean(signer) && typeof signer.signEvent === "function"; } // Whether a video is even eligible for the mirror (drives UI gating too). function canMirror(video) { if (!video || typeof video !== "object") { return { ok: false, reason: "invalid" }; } if (video.isPrivate === true) { return { ok: false, reason: "private" }; } if (video.isNsfw === true && !allowNsfw()) { return { ok: false, reason: "nsfw-blocked" }; } if (!/^https:\/\//i.test(str(video.url))) { return { ok: false, reason: "no-url" }; } return { ok: true }; } async function publish(video, options = {}) { if (!isAvailable()) { return { ok: false, error: "unavailable" }; } // Site NSFW policy gate (defense in depth alongside the UI not offering it). if (video?.isNsfw === true && !allowNsfw()) { return { ok: false, reason: "nsfw-blocked" }; } const pubkey = str(video?.pubkey) || getActivePubkey(); const root = str(video?.videoRootId); // Idempotency (#34): the mirror kind (34235 normal / 34236 short) is inferred // from the video's dimensions, which aren't always present — so re-mirroring the // same video could publish the OTHER kind, and since (kind,pubkey,d) is the // addressable identity that produced a DUPLICATE in NIP-71 clients. Reuse the kind // of any existing mirror so a re-publish REPLACES the same coordinate, and remember // a stray of the other kind to clean up (self-heals duplicates created earlier). let forcedShort = options.short; // explicit caller override always wins let staleKind = null; let reusedExistingKind = false; let newestExisting = null; if (pubkey && root) { const existing = await fetchExistingMirrors({ pubkey, root }); if (Array.isArray(existing) && existing.length) { const newest = existing.reduce((a, b) => (b.created_at || 0) > (a.created_at || 0) ? b : a, ); newestExisting = newest; if (forcedShort === undefined) { forcedShort = newest.kind === NIP71_SHORT_VIDEO_KIND; reusedExistingKind = true; const otherKind = forcedShort ? NIP71_NORMAL_VIDEO_KIND : NIP71_SHORT_VIDEO_KIND; if (existing.some((e) => e.kind === otherKind)) { staleKind = otherKind; } } } } // Addressable events only replace an older coordinate. Match the canonical // edit path: a fast second edit or a clock behind an existing relay event // must still advance the mirror. const createdAt = Math.max( Math.floor(now() / 1000), (Number(newestExisting?.created_at) || 0) + 1, ); const built = buildMirrorEvent( { ...video, pubkey }, { ...options, short: forcedShort, createdAt }, ); if (!built.ok) { return { ok: false, reason: built.reason }; } let signed; try { signed = await signEvent(built.event); } catch (error) { userLogger.warn("[nip71Mirror] Failed to sign mirror event:", error); return { ok: false, error: "sign-failed", cause: error }; } const relays = getWriteRelays() || []; const pool = getPool(); if (!pool || !relays.length) { return { ok: false, error: "no-relays" }; } const results = await publishEventToRelays(pool, relays, signed); const { accepted, failed } = summarizePublishResults(results); // Self-heal: if a duplicate of the OTHER kind already existed, tear it down via a // NIP-09 delete so the video stops showing twice in NIP-71 clients. let healedStaleKind = false; if (staleKind != null && accepted.length > 0) { try { const del = await signEvent({ kind: 5, pubkey, created_at: createdAt, content: "Removed duplicate NIP-71 mirror", tags: [ ["a", `${staleKind}:${pubkey}:${root}`], ["k", String(staleKind)], ], }); const delResults = await publishEventToRelays(pool, relays, del); healedStaleKind = summarizePublishResults(delResults).accepted.length > 0; } catch (error) { userLogger.warn("[nip71Mirror] Failed to clean up duplicate mirror:", error); } } return { ok: accepted.length > 0, accepted: accepted.length, total: accepted.length + failed.length, event: signed, kind: built.event.kind, reusedExistingKind, healedStaleKind, }; } // 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 existing = await fetchExistingMirrors({ pubkey, root }); const newestCreatedAt = Array.isArray(existing) ? existing.reduce((max, event) => Math.max(max, Number(event?.created_at) || 0), 0) : 0; // NIP-09 only deletes addressable versions at or before the delete event, // and empty replacements must likewise win their coordinate. const createdAt = Math.max(Math.floor(now() / 1000), newestCreatedAt + 1); 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"], ], }; // Empty-replace both coordinates. The NIP-09 delete is the canonical // teardown, but clients that ignore it must not retain an old mirror when // a source replacement changed portrait/landscape orientation. const tombstones = [NIP71_NORMAL_VIDEO_KIND, NIP71_SHORT_VIDEO_KIND].map((kind) => ({ kind, 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, ...tombstones]) { 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 }; } // Relay-truth detection (#34): derive "is this mirrored?" from the actual // published events instead of the device-local flag. `kinds.length > 1` flags a // pre-existing cross-kind duplicate. (UI wiring is a follow-up — batch these.) async function findMirror(video) { const pubkey = str(video?.pubkey) || getActivePubkey(); const root = str(video?.videoRootId); if (!pubkey || !root) { return { mirrored: false, kinds: [], duplicate: false }; } const existing = await fetchExistingMirrors({ pubkey, root }); const kinds = [ ...new Set((Array.isArray(existing) ? existing : []).map((e) => e.kind).filter(Boolean)), ]; return { mirrored: kinds.length > 0, kinds, duplicate: kinds.length > 1 }; } // Batched relay-truth detection for a list of videos (e.g. the My Videos grid): // ONE query for all video roots instead of N. Returns Map. Roots with no mirror are simply absent. async function findMirrors(videos) { const list = Array.isArray(videos) ? videos : []; const pubkey = getActivePubkey(); const roots = [ ...new Set(list.map((v) => str(v?.videoRootId)).filter(Boolean)), ]; const out = new Map(); if (!pubkey || !roots.length) { return out; } const events = await fetchMirrorsByRoots({ pubkey, roots }); const kindsByRoot = new Map(roots.map((root) => [root, new Set()])); for (const event of Array.isArray(events) ? events : []) { if (kindsByRoot.has(event?.root) && event?.kind) { kindsByRoot.get(event.root).add(event.kind); } } for (const [root, kindsSet] of kindsByRoot) { const kinds = [...kindsSet]; out.set(root, { mirrored: kinds.length > 0, kinds, duplicate: kinds.length > 1, }); } return out; } return { isAvailable, canMirror, publish, remove, findMirror, findMirrors }; } export const nip71MirrorService = createNip71MirrorService(); export default nip71MirrorService;