diff --git a/js/nostr/nip71.js b/js/nostr/nip71.js index 41f1ad0c..376164de 100644 --- a/js/nostr/nip71.js +++ b/js/nostr/nip71.js @@ -2,6 +2,7 @@ import { ACCEPT_LEGACY_V1 } from "../constants.js"; import { deriveTitleFromEvent, magnetFromText } from "../videoEventUtils.js"; import { extractMagnetHints } from "../magnet.js"; import { devLogger } from "../utils/logger.js"; +import { getCachedNostrTools } from "./toolkit.js"; function stringFromInput(value) { if (value === null || value === undefined) { @@ -16,6 +17,50 @@ function stringFromInput(value) { return String(value).trim(); } +const HEX_32_BYTE_REGEX = /^[0-9a-f]{64}$/i; + +function decodeNpubToHex(candidate) { + const globalDecoder = + typeof window !== "undefined" ? window?.NostrTools?.nip19?.decode : null; + const toolkit = getCachedNostrTools(); + const toolkitDecoder = toolkit?.nip19?.decode; + const decoder = + typeof globalDecoder === "function" ? globalDecoder : toolkitDecoder; + if (typeof decoder !== "function") { + return null; + } + try { + const decoded = decoder(candidate); + if (decoded?.type === "npub" && typeof decoded.data === "string") { + return decoded.data.toLowerCase(); + } + if (decoded?.type === "npub" && typeof decoded.data?.pubkey === "string") { + return decoded.data.pubkey.toLowerCase(); + } + } catch (error) { + devLogger.warn("[nostr] Failed to decode npub participant pubkey", error); + } + return null; +} + +function normalizeNostrPubkey(candidate) { + const value = stringFromInput(candidate); + if (!value) { + return null; + } + if (HEX_32_BYTE_REGEX.test(value)) { + return value.toLowerCase(); + } + if (value.toLowerCase().startsWith("npub1")) { + const decoded = decodeNpubToHex(value); + if (decoded && HEX_32_BYTE_REGEX.test(decoded)) { + return decoded; + } + return null; + } + return null; +} + function normalizeUnixSeconds(value) { if (value === null || value === undefined || value === "") { return ""; @@ -224,11 +269,20 @@ function buildParticipantTag(participant) { if (!participant || typeof participant !== "object") { return null; } - const pubkey = stringFromInput(participant.pubkey); + const rawPubkey = participant.pubkey; + const pubkey = normalizeNostrPubkey(rawPubkey); if (!pubkey) { + const candidate = stringFromInput(rawPubkey); + if (candidate) { + devLogger.warn( + "[nostr] Dropping NIP-71 participant tag with invalid pubkey", + { pubkey: candidate } + ); + } return null; } const relay = stringFromInput(participant.relay); + // NIP-71 "Other tags" mandates 32-byte hex participant pubkeys. const values = ["p", pubkey]; if (relay) { values.push(relay); diff --git a/tests/nip71-builder.test.mjs b/tests/nip71-builder.test.mjs index 2b305fdc..3da345c8 100644 --- a/tests/nip71-builder.test.mjs +++ b/tests/nip71-builder.test.mjs @@ -6,6 +6,47 @@ import { extractNip71MetadataFromTags, } from "../js/nostr.js"; +const participantNpub = "npub1bitvidparticipantfixture"; +const participantHexFromNpub = "c".repeat(64); + +const hadWindow = typeof globalThis.window !== "undefined"; +const windowRef = hadWindow ? globalThis.window : {}; +if (!hadWindow) { + globalThis.window = windowRef; +} +const hadNostrTools = typeof windowRef.NostrTools !== "undefined"; +const nostrToolsRef = hadNostrTools ? windowRef.NostrTools : {}; +if (!hadNostrTools) { + windowRef.NostrTools = nostrToolsRef; +} +const previousNip19 = nostrToolsRef.nip19; +nostrToolsRef.nip19 = { + ...previousNip19, + decode: (value) => { + if (value === participantNpub) { + return { type: "npub", data: participantHexFromNpub }; + } + if (typeof previousNip19?.decode === "function") { + return previousNip19.decode(value); + } + throw new Error("Unsupported npub fixture"); + }, +}; + +test.after(() => { + if (previousNip19) { + nostrToolsRef.nip19 = previousNip19; + } else { + delete nostrToolsRef.nip19; + } + if (!hadNostrTools) { + delete windowRef.NostrTools; + } + if (!hadWindow) { + delete globalThis.window; + } +}); + test("buildNip71VideoEvent assembles rich metadata", () => { const metadata = { kind: 21, @@ -53,8 +94,8 @@ test("buildNip71VideoEvent assembles rich metadata", () => { ], hashtags: ["bitvid", "nostr"], participants: [ - { pubkey: "a".repeat(64), relay: "wss://relay.example" }, - { pubkey: "b".repeat(64) }, + { pubkey: "A".repeat(64), relay: "wss://relay.example" }, + { pubkey: ` ${participantNpub} ` }, ], references: [ "https://example.com/info", @@ -135,7 +176,7 @@ test("buildNip71VideoEvent assembles rich metadata", () => { const participantTags = event.tags.filter((tag) => tag[0] === "p"); assert.deepEqual(participantTags, [ ["p", "a".repeat(64), "wss://relay.example"], - ["p", "b".repeat(64)], + ["p", participantHexFromNpub], ]); const referenceTags = event.tags.filter((tag) => tag[0] === "r");