feat(nip71): Phase 0 — addressable 34235/36 mirror builder + magnet/i/ox imeta

First build step of the NIP-71 interop plan (no UX; FEATURE_PUBLISH_NIP71 stays
off). Pure mapping from a bitvid video (kind-30078 shape) to an opt-in addressable
NIP-71 mirror event.

- js/nostr/nip71.js (foundational):
  - normalizeNip71Kind now recognizes the addressable kinds 34235/34236 (so the
    parser preserves them), alongside 21/22.
  - imeta builder + parser gained standard NIP-94 fields magnet, i (infohash), ox
    — bitvid's WebTorrent now rides the standard, round-trippable.
- js/nostr/nip71Mirror.js (new): buildNip71MirrorEvent(video, options) →
  { ok:true, event } | { ok:false, reason }. Builds kind 34235 (normal) / 34236
  (short, by dims or explicit flag), d=videoRootId, title/published_at(stable)/alt,
  imeta (url/m/image/x/ox/magnet/i/dim/duration), content-warning (NSFW), t
  hashtags, origin + a back-pointer to the canonical 30078, client=bitvid.
  Hard rules enforced: private videos refused; non-HTTPS-url refused.

tests/nip71-mirror.test.mjs: spec-shape mapping, round-trip via
extractNip71MetadataFromTags (magnet/i/ox survive), private + url-less refusals,
portrait→34236 + content-warning, explicit short override, MIME derivation.
Mutation-verified (private refusal, magnet imeta, d-tag all load-bearing).

Co-Authored-By: Claude Opus 4.8 <[email protected]>
This commit is contained in:
thePR0M3TH3ANandClaude Opus 4.8 committed 2026-06-21 23:05:12 -04:00
1 parent 80dce87195
commit 0e2f683b62
3 files changed
+349

No files matched your search

+34
View File
@@ -176,6 +176,12 @@ function normalizeNip71Kind(value) {
if (numeric === 22) { if (numeric === 22) {
return 22; return 22;
} }
if (numeric === 34235) {
return 34235;
}
if (numeric === 34236) {
return 34236;
}
return 21; return 21;
} }
@@ -199,6 +205,9 @@ function buildImetaTags(variants) {
const dim = stringFromInput(variant.dim); const dim = stringFromInput(variant.dim);
const url = stringFromInput(variant.url); const url = stringFromInput(variant.url);
const x = stringFromInput(variant.x); const x = stringFromInput(variant.x);
const ox = stringFromInput(variant.ox);
const magnet = stringFromInput(variant.magnet);
const infohash = stringFromInput(variant.i);
const rawMime = stringFromInput(variant.m); const rawMime = stringFromInput(variant.m);
const mime = rawMime ? rawMime.toLowerCase() : ""; const mime = rawMime ? rawMime.toLowerCase() : "";
const duration = formatNonNegativeNumber(variant.duration, { const duration = formatNonNegativeNumber(variant.duration, {
@@ -221,6 +230,9 @@ function buildImetaTags(variants) {
!dim && !dim &&
!url && !url &&
!x && !x &&
!ox &&
!magnet &&
!infohash &&
!mime && !mime &&
!duration && !duration &&
!bitrate && !bitrate &&
@@ -241,9 +253,19 @@ function buildImetaTags(variants) {
if (x) { if (x) {
entries.push(`x ${x}`); entries.push(`x ${x}`);
} }
if (ox) {
entries.push(`ox ${ox}`);
}
if (mime) { if (mime) {
entries.push(`m ${mime}`); entries.push(`m ${mime}`);
} }
// WebTorrent rides standard NIP-94 imeta fields so torrent-aware clients can use it.
if (magnet) {
entries.push(`magnet ${magnet}`);
}
if (infohash) {
entries.push(`i ${infohash}`);
}
if (duration) { if (duration) {
entries.push(`duration ${duration}`); entries.push(`duration ${duration}`);
} }
@@ -456,6 +478,15 @@ function parseImetaTag(tag) {
case "x": case "x":
variant.x = parsed.value; variant.x = parsed.value;
break; break;
case "ox":
variant.ox = parsed.value;
break;
case "magnet":
variant.magnet = parsed.value;
break;
case "i":
variant.i = parsed.value;
break;
case "m": case "m":
variant.m = parsed.value; variant.m = parsed.value;
break; break;
@@ -489,6 +520,9 @@ function parseImetaTag(tag) {
Boolean(variant.dim) || Boolean(variant.dim) ||
Boolean(variant.url) || Boolean(variant.url) ||
Boolean(variant.x) || Boolean(variant.x) ||
Boolean(variant.ox) ||
Boolean(variant.magnet) ||
Boolean(variant.i) ||
Boolean(variant.m) || Boolean(variant.m) ||
(variant.duration !== undefined && variant.duration !== null && variant.duration !== "") || (variant.duration !== undefined && variant.duration !== null && variant.duration !== "") ||
(variant.bitrate !== undefined && variant.bitrate !== null && variant.bitrate !== "") || (variant.bitrate !== undefined && variant.bitrate !== null && variant.bitrate !== "") ||
+169
View File
@@ -0,0 +1,169 @@
// Phase 0 of the NIP-71 interop plan (docs/nip71-migration-plan.md).
//
// Maps a bitvid video (the kind-30078 content shape) to an opt-in, addressable
// NIP-71 *mirror* event (kind 34235 normal / 34236 short). The 30078 stays the
// canonical record; this mirror is additive and lets the video show up on other
// Nostr video clients (Nostube, Amethyst, …).
//
// Pure / no UX wiring (gated by FEATURE_PUBLISH_NIP71 at the call sites later).
// Hard rules baked in: private videos are NEVER mirrored, and a publicly-playable
// HTTPS url is required (foreign clients can't play magnet-only). bitvid's
// WebTorrent rides standard NIP-94 imeta fields (magnet, i).
import { buildNip71MetadataTags, buildVideoPointerValue } from "./nip71.js";
import { extractBtihFromMagnet } from "../magnetShared.js";
export const NIP71_NORMAL_VIDEO_KIND = 34235;
export const NIP71_SHORT_VIDEO_KIND = 34236;
const VIDEO_MIME_BY_EXT = {
mp4: "video/mp4",
m4v: "video/mp4",
webm: "video/webm",
ogv: "video/ogg",
ogg: "video/ogg",
mov: "video/quicktime",
mkv: "video/x-matroska",
m3u8: "application/x-mpegURL",
};
function str(value) {
return typeof value === "string" ? value.trim() : "";
}
function isHttpsUrl(value) {
return /^https:\/\//i.test(str(value));
}
// Best-effort MIME from the url extension; bitvid doesn't persist a MIME today.
export function deriveVideoMime(url) {
const value = str(url).toLowerCase();
if (!value) {
return "video/mp4";
}
const path = value.split(/[?#]/)[0];
const ext = path.includes(".") ? path.slice(path.lastIndexOf(".") + 1) : "";
return VIDEO_MIME_BY_EXT[ext] || "video/mp4";
}
function normalizePublishedAt(value) {
const numeric = Number(value);
if (!Number.isFinite(numeric) || numeric <= 0) {
return Math.floor(Date.now() / 1000);
}
// Tolerate millisecond timestamps.
return numeric > 10_000_000_000 ? Math.floor(numeric / 1000) : Math.floor(numeric);
}
/**
* Build an addressable NIP-71 mirror event from a bitvid video object.
*
* @param {object} video - bitvid video (30078 content shape) + `pubkey`.
* @param {object} [options]
* @param {boolean} [options.short] - Force 34236 (short) / 34235 (normal). When
* omitted, inferred from dimensions (portrait => short).
* @param {number} [options.createdAt] - Signing time (unix seconds). Default now.
* @param {number|string} [options.publishedAt] - First-publish time for the
* `published_at` tag; kept stable across edits. Defaults to the video's
* published_at/created_at, else now.
* @param {string} [options.watchUrl] - Canonical bitvid watch URL for `origin`.
* @returns {{ok:true, event:object} | {ok:false, reason:string}}
*/
export function buildNip71MirrorEvent(video, options = {}) {
if (!video || typeof video !== "object") {
return { ok: false, reason: "invalid" };
}
const videoRootId = str(video.videoRootId);
const title = str(video.title);
const pubkey = str(video.pubkey).toLowerCase();
if (!videoRootId || !title || !pubkey) {
return { ok: false, reason: "invalid" };
}
// Hard rule 1: private videos are never mirrored (no NIP-71 encryption).
if (video.isPrivate === true) {
return { ok: false, reason: "private" };
}
// Hard rule 2: foreign clients need a hosted, publicly-playable HTTPS url.
const url = str(video.url);
if (!isHttpsUrl(url)) {
return { ok: false, reason: "no-url" };
}
const magnet = str(video.magnet);
const infohash =
str(video.infoHash) || (magnet ? str(extractBtihFromMagnet(magnet)) : "");
const width = Number(video.width);
const height = Number(video.height);
const hasDims =
Number.isFinite(width) && Number.isFinite(height) && width > 0 && height > 0;
const duration = Number(video.duration);
const variant = {
url,
m: deriveVideoMime(url),
image: str(video.thumbnail) ? [str(video.thumbnail)] : [],
x: str(video.fileSha256) || undefined,
ox: str(video.originalFileSha256) || undefined,
magnet: magnet || undefined,
i: infohash || undefined,
dim: hasDims ? `${width}x${height}` : undefined,
duration: Number.isFinite(duration) && duration > 0 ? duration : undefined,
};
const hashtags = Array.isArray(video.hashtags)
? video.hashtags.map(str).filter(Boolean)
: [];
const metadata = {
title,
// Short accessibility text — NOT a dump of the full description.
alt: title,
publishedAt: normalizePublishedAt(
options.publishedAt ?? video.published_at ?? video.created_at,
),
imeta: [variant],
hashtags,
contentWarning:
video.isNsfw === true ? str(video.nsfwReason) || "nsfw" : undefined,
};
const tags = buildNip71MetadataTags(metadata);
// Addressable identity + bitvid linkage (ignored by foreign clients).
tags.push(["d", videoRootId]);
const aValue = buildVideoPointerValue(pubkey, videoRootId);
if (aValue) {
tags.push(["a", aValue]); // back-pointer to the canonical kind 30078
}
const origin = ["origin", "bitvid", videoRootId];
const watchUrl = str(options.watchUrl);
if (watchUrl) {
origin.push(watchUrl);
}
tags.push(origin);
tags.push(["client", "bitvid"]);
const short =
options.short === true ||
(options.short !== false && hasDims && height > width);
const createdAt = Number.isFinite(options.createdAt)
? Math.floor(options.createdAt)
: Math.floor(Date.now() / 1000);
const event = {
kind: short ? NIP71_SHORT_VIDEO_KIND : NIP71_NORMAL_VIDEO_KIND,
pubkey,
created_at: createdAt,
tags,
content: str(video.description),
};
return { ok: true, event };
}
export default buildNip71MirrorEvent;
+146
View File
@@ -0,0 +1,146 @@
// Phase 0 of the NIP-71 interop plan: bitvid video (kind 30078 shape) -> addressable
// NIP-71 mirror event (kind 34235/34236). Scenarios assert the observable event
// shape AND that bitvid's own NIP-71 parser reads it back (round-trip), plus the
// two hard rules (private never mirrored, HTTPS url required) and short selection.
import "./test-helpers/setup-localstorage.mjs";
import assert from "node:assert/strict";
import test from "node:test";
import {
buildNip71MirrorEvent,
deriveVideoMime,
NIP71_NORMAL_VIDEO_KIND,
NIP71_SHORT_VIDEO_KIND,
} from "../js/nostr/nip71Mirror.js";
import { extractNip71MetadataFromTags } from "../js/nostr/nip71.js";
const PUBKEY = "a".repeat(64);
const INFOHASH = "b".repeat(40);
function baseVideo(overrides = {}) {
return {
videoRootId: "root-123",
pubkey: PUBKEY,
title: "Crystalline Bliss",
description: "A lo-fi voxel puzzle journey.",
url: "https://cdn.example.com/u/abc/video.mp4",
thumbnail: "https://cdn.example.com/u/abc/thumb.jpg",
magnet: `magnet:?xt=urn:btih:${INFOHASH}&ws=https://cdn.example.com/u/abc/video.mp4`,
infoHash: INFOHASH,
fileSha256: "c".repeat(64),
originalFileSha256: "d".repeat(64),
isPrivate: false,
isNsfw: false,
hashtags: ["gamestr", "voxel"],
duration: 123,
...overrides,
};
}
// Find the imeta tag and parse "key value" entries into an object (last wins,
// images/magnet collected).
function imetaFields(event) {
const tag = event.tags.find((t) => t[0] === "imeta");
assert.ok(tag, "event must have an imeta tag");
const fields = {};
for (let i = 1; i < tag.length; i += 1) {
const sp = tag[i].indexOf(" ");
const k = tag[i].slice(0, sp);
const v = tag[i].slice(sp + 1);
fields[k] = v;
}
return fields;
}
function tagValue(event, name) {
const tag = event.tags.find((t) => t[0] === name);
return tag ? tag[1] : undefined;
}
test("maps a public bitvid video to a spec-shaped addressable 34235 event", async () => {
const res = buildNip71MirrorEvent(baseVideo(), { createdAt: 1000, publishedAt: 900 });
assert.equal(res.ok, true);
const ev = res.event;
assert.equal(ev.kind, NIP71_NORMAL_VIDEO_KIND, "landscape/default => 34235");
assert.equal(ev.pubkey, PUBKEY);
assert.equal(ev.content, "A lo-fi voxel puzzle journey.");
// Required + addressable identity
assert.equal(tagValue(ev, "d"), "root-123", "addressable d-tag = videoRootId");
assert.equal(tagValue(ev, "title"), "Crystalline Bliss");
assert.equal(tagValue(ev, "published_at"), "900", "stable first-publish time");
// Back-pointer + attribution + hashtags
assert.equal(tagValue(ev, "a"), `30078:${PUBKEY}:root-123`, "links to canonical 30078");
const origin = ev.tags.find((t) => t[0] === "origin");
assert.deepEqual(origin.slice(0, 3), ["origin", "bitvid", "root-123"]);
const tTags = ev.tags.filter((t) => t[0] === "t").map((t) => t[1]).sort();
assert.deepEqual(tTags, ["gamestr", "voxel"]);
// imeta: HTTPS url + standard NIP-94 magnet/infohash/hashes
const f = imetaFields(ev);
assert.equal(f.url, "https://cdn.example.com/u/abc/video.mp4");
assert.equal(f.m, "video/mp4");
assert.equal(f.image, "https://cdn.example.com/u/abc/thumb.jpg");
assert.equal(f.x, "c".repeat(64));
assert.equal(f.ox, "d".repeat(64));
assert.equal(f.i, INFOHASH, "infohash rides standard NIP-94 'i'");
assert.ok(f.magnet.startsWith("magnet:?xt=urn:btih:"), "magnet rides standard NIP-94 'magnet'");
assert.equal(f.duration, "123");
});
test("round-trips: the produced event parses back via bitvid's NIP-71 parser", async () => {
const res = buildNip71MirrorEvent(baseVideo());
const parsed = extractNip71MetadataFromTags(res.event);
assert.ok(parsed, "parser must read the mirror event");
const meta = parsed.metadata;
assert.equal(meta.kind, NIP71_NORMAL_VIDEO_KIND, "addressable kind preserved");
assert.equal(parsed.source.kind, NIP71_NORMAL_VIDEO_KIND, "source kind preserved");
assert.equal(meta.title, "Crystalline Bliss");
assert.deepEqual(meta.hashtags.sort(), ["gamestr", "voxel"]);
const v = meta.imeta[0];
assert.equal(v.url, "https://cdn.example.com/u/abc/video.mp4");
assert.equal(v.magnet, baseVideo().magnet, "magnet survives the round-trip");
assert.equal(v.i, INFOHASH, "infohash survives the round-trip");
assert.equal(v.ox, "d".repeat(64), "original-file hash survives the round-trip");
});
test("private videos are NEVER mirrored", async () => {
const res = buildNip71MirrorEvent(baseVideo({ isPrivate: true }));
assert.equal(res.ok, false);
assert.equal(res.reason, "private");
});
test("a video without an HTTPS url cannot be mirrored", async () => {
// magnet-only: bitvid plays it, foreign clients can't.
const res = buildNip71MirrorEvent(baseVideo({ url: "" }));
assert.equal(res.ok, false);
assert.equal(res.reason, "no-url");
const httpRes = buildNip71MirrorEvent(baseVideo({ url: "http://insecure.example/v.mp4" }));
assert.equal(httpRes.ok, false, "non-HTTPS url is refused");
});
test("portrait dimensions select the short kind 34236; content-warning for NSFW", async () => {
const res = buildNip71MirrorEvent(
baseVideo({ width: 1080, height: 1920, isNsfw: true, nsfwReason: "mature" }),
);
assert.equal(res.event.kind, NIP71_SHORT_VIDEO_KIND, "portrait => 34236 short");
assert.equal(tagValue(res.event, "content-warning"), "mature");
assert.equal(imetaFields(res.event).dim, "1080x1920");
});
test("explicit short flag overrides dimension inference", async () => {
const landscapeButShort = buildNip71MirrorEvent(
baseVideo({ width: 1920, height: 1080 }),
{ short: true },
);
assert.equal(landscapeButShort.event.kind, NIP71_SHORT_VIDEO_KIND);
});
test("deriveVideoMime maps common extensions and defaults to mp4", async () => {
assert.equal(deriveVideoMime("https://x/y.webm"), "video/webm");
assert.equal(deriveVideoMime("https://x/y.m3u8?token=1"), "application/x-mpegURL");
assert.equal(deriveVideoMime("https://x/y"), "video/mp4");
});