mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-10-31 01:42:27 +00:00
feat(nip71): Phase 1a — mirror publish service with site NSFW gate
Publish service for the opt-in NIP-71 mirror (kind 34235/36) to the user's write/outbox relays, built on the Phase 0 builder. Flag still off; not yet wired into the publish path (next increment). - js/services/nip71MirrorService.js: createNip71MirrorService (DI) → isAvailable, canMirror(video), publish(video, options). Signs with the active signer and publishes to the write set (getDeletePublishRelays). Resolves the moderation audit item: the site ALLOW_NSFW_CONTENT gate — an instance that won't surface NSFW must not publish it outward (enforced in both publish() and canMirror(), defense-in-depth). Private/url rules come from the builder. tests/nip71-mirror-service.test.mjs: publishes a public video; NSFW blocked when ALLOW_NSFW_CONTENT=false (and allowed when true); private/url-less refused; unavailable without signer. Mutation-verified (NSFW gate load-bearing). Co-Authored-By: Claude Opus 4.8 <[email protected]>
This commit is contained in:
1 parent
5c93b7496e
commit
e20191d6bc
2 files changed
+229
No files matched your search
@@ -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;
|
||||||
@@ -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");
|
||||||
|
});
|
||||||
Reference in new issue
Block a user