diff --git a/js/adminListStore.js b/js/adminListStore.js index 1abb14bc..f46ff07f 100644 --- a/js/adminListStore.js +++ b/js/adminListStore.js @@ -36,6 +36,19 @@ function normalizeNpub(value) { return typeof value === "string" ? value.trim() : ""; } +const HEX_PUBKEY_REGEX = /^[0-9a-f]{64}$/i; + +function isHexPubkey(value) { + if (typeof value !== "string") { + return false; + } + const trimmed = value.trim(); + if (!trimmed) { + return false; + } + return HEX_PUBKEY_REGEX.test(trimmed); +} + function dedupeNpubs(values) { const normalized = Array.isArray(values) ? values.map(normalizeNpub) : []; return Array.from( @@ -238,17 +251,67 @@ function encodeHexToNpub(hex) { } } +function normalizeParticipantTagValue(value) { + if (typeof value !== "string") { + return ""; + } + + const trimmed = value.trim(); + if (!trimmed) { + return ""; + } + + const nip19 = window?.NostrTools?.nip19; + const lower = trimmed.toLowerCase(); + if (lower.startsWith("npub")) { + if (nip19?.decode) { + try { + const decoded = nip19.decode(trimmed); + if (decoded?.type === "npub") { + return trimmed; + } + } catch (error) { + // Swallow decode errors so we can still fall back to the raw npub string. + } + } + return trimmed; + } + + if (isHexPubkey(trimmed)) { + const encoded = encodeHexToNpub(trimmed); + if (encoded) { + return encoded; + } + if (!canDecodeNpub()) { + return trimmed; + } + } + + if (nip19?.decode) { + try { + const decoded = nip19.decode(trimmed); + if (decoded?.type === "npub") { + return trimmed; + } + } catch (error) { + // Ignore decode failures; values that cannot be interpreted as npubs are dropped. + } + } + + return ""; +} + function extractNpubsFromEvent(event) { if (!event || !Array.isArray(event.tags)) { return []; } - const hexKeys = event.tags + const candidates = event.tags .filter((tag) => Array.isArray(tag) && tag[0] === "p" && tag[1]) .map((tag) => tag[1]); - const npubs = hexKeys - .map((hex) => encodeHexToNpub(hex)) + const npubs = candidates + .map((value) => normalizeParticipantTagValue(value)) .filter((npub) => typeof npub === "string" && npub); return dedupeNpubs(npubs); @@ -570,3 +633,8 @@ export async function persistAdminState(actorNpub, updates) { return persistNostrState(actorNpub, updates); } + +export const __adminListStoreTestHooks = Object.freeze({ + extractNpubsFromEvent, + normalizeParticipantTagValue, +}); diff --git a/tests/admin-list-store.test.mjs b/tests/admin-list-store.test.mjs new file mode 100644 index 00000000..33ebf951 --- /dev/null +++ b/tests/admin-list-store.test.mjs @@ -0,0 +1,89 @@ +// Run with: node tests/admin-list-store.test.mjs + +import "./test-helpers/setup-localstorage.mjs"; +import assert from "node:assert/strict"; + +if (typeof globalThis.window === "undefined") { + globalThis.window = {}; +} + +if (!globalThis.window.NostrTools) { + globalThis.window.NostrTools = {}; +} + +const mockNip19 = { + decode(value) { + if (typeof value !== "string") { + throw new Error("invalid input"); + } + if (value.trim().toLowerCase().startsWith("npub")) { + return { type: "npub", data: "mock" }; + } + throw new Error("unsupported value"); + }, + npubEncode(hex) { + if (typeof hex !== "string") { + throw new Error("invalid hex"); + } + const trimmed = hex.trim(); + if (!trimmed) { + throw new Error("empty hex"); + } + return `npub1${trimmed}`; + }, +}; + +globalThis.window.NostrTools.nip19 = mockNip19; + +const { __adminListStoreTestHooks } = await import("../js/adminListStore.js"); + +const { extractNpubsFromEvent, normalizeParticipantTagValue } = + __adminListStoreTestHooks; + +const sampleHex = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; +const sampleNpub = "npub1existingparticipant"; + +const event = { + tags: [ + ["p", sampleHex], + ["p", sampleNpub], + ["p", " npub1existingparticipant "], + ["p", ""], + ["e", "irrelevant"], + ], +}; + +const normalized = extractNpubsFromEvent(event); +assert.deepEqual( + normalized, + [ + mockNip19.npubEncode(sampleHex), + sampleNpub, + ], + "should return unique npubs from hex and npub participant tags" +); + +mockNip19.decode = () => { + throw new Error("decode unavailable"); +}; + +assert.equal( + normalizeParticipantTagValue(sampleNpub), + sampleNpub, + "should fall back to the raw npub when decode fails" +); + +mockNip19.decode = undefined; +mockNip19.npubEncode = undefined; + +globalThis.window.NostrTools.nip19 = mockNip19; + +const fallbackHex = "abcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcd"; + +assert.equal( + normalizeParticipantTagValue(fallbackHex), + fallbackHex, + "should preserve hex when nip19 helpers are unavailable" +); + +console.log("admin-list-store tests passed");