diff --git a/js/services/nip71MirrorService.js b/js/services/nip71MirrorService.js new file mode 100644 index 00000000..dcb92e5d --- /dev/null +++ b/js/services/nip71MirrorService.js @@ -0,0 +1,122 @@ +// 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 } 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, + 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 built = buildMirrorEvent( + { ...video, pubkey }, + { ...options, createdAt: Math.floor(now() / 1000) }, + ); + 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); + return { + ok: accepted.length > 0, + accepted: accepted.length, + total: accepted.length + failed.length, + event: signed, + }; + } + + return { isAvailable, canMirror, publish }; +} + +export const nip71MirrorService = createNip71MirrorService(); + +export default nip71MirrorService; diff --git a/tests/nip71-mirror-service.test.mjs b/tests/nip71-mirror-service.test.mjs new file mode 100644 index 00000000..010cc242 --- /dev/null +++ b/tests/nip71-mirror-service.test.mjs @@ -0,0 +1,107 @@ +// Phase 1: the NIP-71 mirror publish service. Scenarios assert the observable +// outcomes at the publish boundary, especially the site-wide NSFW moderation gate +// (ALLOW_NSFW_CONTENT) — an instance that won't surface NSFW must not publish it +// outward — plus the private / url / availability gates and a real publish. + +import "./test-helpers/setup-localstorage.mjs"; +import assert from "node:assert/strict"; +import test from "node:test"; +import { createNip71MirrorService } from "../js/services/nip71MirrorService.js"; +import { + publishEventToRelays, + summarizePublishResults, +} from "../js/nostrPublish.js"; + +const PUBKEY = "a".repeat(64); + +function baseVideo(overrides = {}) { + return { + videoRootId: "root-1", + pubkey: PUBKEY, + title: "Hello", + description: "desc", + url: "https://cdn.example.com/v.mp4", + thumbnail: "https://cdn.example.com/t.jpg", + isPrivate: false, + isNsfw: false, + ...overrides, + }; +} + +// Faithful relay twin: stores published events; thenable publish => success path. +function makeRelay() { + const events = []; + return { + events, + publish(_urls, event) { + events.push(event); + return Promise.resolve(); + }, + }; +} + +function makeService({ allowNsfw = false, available = true } = {}) { + const relay = makeRelay(); + const signer = available + ? { signEvent: async (tpl) => ({ ...tpl, id: "id", sig: "sig" }) } + : null; + const service = createNip71MirrorService({ + getActivePubkey: () => (available ? PUBKEY : ""), + getSigner: () => signer, + getWriteRelays: () => ["wss://write.relay"], + getPool: () => relay, + publishEventToRelays, + summarizePublishResults, + signEvent: signer ? signer.signEvent : async () => { throw new Error("no signer"); }, + allowNsfw: () => allowNsfw === true, + }); + return { service, relay }; +} + +test("publishes a public video as an addressable 34235 mirror to the write relays", async () => { + const { service, relay } = makeService(); + const result = await service.publish(baseVideo()); + assert.equal(result.ok, true); + assert.equal(result.accepted, 1); + assert.equal(relay.events.length, 1); + assert.equal(relay.events[0].kind, 34235); + assert.equal(relay.events[0].tags.find((t) => t[0] === "d")[1], "root-1"); +}); + +test("NSFW video is NOT mirrored when the instance forbids NSFW (ALLOW_NSFW_CONTENT=false)", async () => { + const { service, relay } = makeService({ allowNsfw: false }); + const result = await service.publish(baseVideo({ isNsfw: true })); + assert.equal(result.ok, false); + assert.equal(result.reason, "nsfw-blocked"); + assert.equal(relay.events.length, 0, "must not publish NSFW outward"); + assert.deepEqual(service.canMirror(baseVideo({ isNsfw: true })), { + ok: false, + reason: "nsfw-blocked", + }); +}); + +test("NSFW video IS mirrored when the instance allows NSFW", async () => { + const { service, relay } = makeService({ allowNsfw: true }); + const result = await service.publish(baseVideo({ isNsfw: true, nsfwReason: "mature" })); + assert.equal(result.ok, true); + assert.equal(relay.events.length, 1); + assert.equal( + relay.events[0].tags.find((t) => t[0] === "content-warning")[1], + "mature", + ); +}); + +test("private and url-less videos are refused (builder rules)", async () => { + const { service, relay } = makeService(); + assert.equal((await service.publish(baseVideo({ isPrivate: true }))).reason, "private"); + assert.equal((await service.publish(baseVideo({ url: "" }))).reason, "no-url"); + assert.equal(relay.events.length, 0); +}); + +test("publish is unavailable without a signer / pubkey", async () => { + const { service } = makeService({ available: false }); + assert.equal(service.isAvailable(), false); + const result = await service.publish(baseVideo()); + assert.equal(result.ok, false); + assert.equal(result.error, "unavailable"); +});