diff --git a/js/nostrEventSchemas.js b/js/nostrEventSchemas.js index 8f7e5372..c64ddd4b 100644 --- a/js/nostrEventSchemas.js +++ b/js/nostrEventSchemas.js @@ -302,22 +302,24 @@ const BASE_SCHEMAS = { }, appendTags: DEFAULT_APPEND_TAGS, content: { - format: "nip04-json", - description: "Encrypted JSON: { subPubkeys: string[] }.", + format: "encrypted-tag-list", + description: + "NIP-04/NIP-44 encrypted JSON array of NIP-51 tag tuples (e.g., [['p', ], …]).", }, }, [NOTE_TYPES.USER_BLOCK_LIST]: { type: NOTE_TYPES.USER_BLOCK_LIST, label: "User block list", - kind: 30002, + kind: 10000, identifierTag: { name: "d", value: BLOCK_LIST_IDENTIFIER, }, appendTags: DEFAULT_APPEND_TAGS, content: { - format: "nip04-json", - description: "Encrypted JSON: { blockedPubkeys: string[] }.", + format: "encrypted-tag-list", + description: + "NIP-04/NIP-44 encrypted JSON array of mute tags per NIP-51 (e.g., [['p', ], …]).", }, }, [NOTE_TYPES.HASHTAG_PREFERENCES]: { @@ -1390,8 +1392,10 @@ export function buildSubscriptionListEvent({ if (identifierName && identifierValue) { tags.push([identifierName, identifierValue]); } - if (typeof encryption === "string" && encryption) { - tags.push(["encrypted", encryption]); + const normalizedEncryption = + typeof encryption === "string" ? encryption.trim() : ""; + if (normalizedEncryption) { + tags.push(["encrypted", normalizedEncryption]); } appendSchemaTags(tags, schema); return { @@ -1416,12 +1420,14 @@ export function buildBlockListEvent({ if (identifierName && identifierValue) { tags.push([identifierName, identifierValue]); } - if (typeof encryption === "string" && encryption) { - tags.push(["encrypted", encryption]); + const normalizedEncryption = + typeof encryption === "string" ? encryption.trim() : ""; + if (normalizedEncryption) { + tags.push(["encrypted", normalizedEncryption]); } appendSchemaTags(tags, schema); return { - kind: schema?.kind ?? 30002, + kind: schema?.kind ?? 10000, pubkey, created_at, tags, diff --git a/js/subscriptions.js b/js/subscriptions.js index 1352c4a0..24263dc0 100644 --- a/js/subscriptions.js +++ b/js/subscriptions.js @@ -126,6 +126,82 @@ function determineDecryptionOrder(event, availableSchemes) { return prioritized.length ? prioritized : available; } +function serializeSubscriptionTagMatrix(values) { + const tags = []; + const seen = new Set(); + if (!values) { + return JSON.stringify(tags); + } + const iterable = Array.isArray(values) + ? values + : values instanceof Set + ? Array.from(values) + : []; + for (const candidate of iterable) { + const normalized = normalizeHexPubkey(candidate); + if (!normalized || seen.has(normalized)) { + continue; + } + seen.add(normalized); + tags.push(["p", normalized]); + } + return JSON.stringify(tags); +} + +function parseSubscriptionPlaintext(plaintext) { + if (typeof plaintext !== "string" || !plaintext) { + return []; + } + + let parsed; + try { + parsed = JSON.parse(plaintext); + } catch (error) { + devLogger.warn( + "[SubscriptionsManager] Failed to parse subscription ciphertext as JSON; treating as empty.", + error, + ); + return []; + } + + if (Array.isArray(parsed)) { + const collected = []; + const seen = new Set(); + for (const entry of parsed) { + if (!Array.isArray(entry) || entry.length < 2) { + continue; + } + const marker = typeof entry[0] === "string" ? entry[0].trim().toLowerCase() : ""; + if (marker !== "p") { + continue; + } + const normalized = normalizeHexPubkey(entry[1]); + if (!normalized || seen.has(normalized)) { + continue; + } + seen.add(normalized); + collected.push(normalized); + } + return collected; + } + + if (parsed && typeof parsed === "object") { + const tagArray = Array.isArray(parsed.tags) ? parsed.tags : []; + if (tagArray.length) { + try { + return parseSubscriptionPlaintext(JSON.stringify(tagArray)); + } catch { + // fall through to legacy handling + } + } + + const legacy = Array.isArray(parsed.subPubkeys) ? parsed.subPubkeys : []; + return legacy.map((value) => normalizeHexPubkey(value)).filter(Boolean); + } + + return []; +} + const getApp = () => getApplication(); const listVideoViewEventsApi = (pointer, options) => @@ -135,7 +211,7 @@ const subscribeVideoViewEventsApi = (pointer, options) => /** * Manages the user's subscription list (kind=30002) *privately*, - * using NIP-04 encryption for the content field. + * using encrypted NIP-51 tag arrays (NIP-04/NIP-44) for the content field. * Also handles fetching and rendering subscribed channels' videos * in the same card style as your home page. */ @@ -295,13 +371,7 @@ class SubscriptionsManager { const decryptedStr = decryptResult.plaintext; - const parsed = JSON.parse(decryptedStr); - const subArray = Array.isArray(parsed.subPubkeys) - ? parsed.subPubkeys - : []; - const normalized = subArray - .map((value) => normalizeHexPubkey(value)) - .filter((value) => Boolean(value)); + const normalized = parseSubscriptionPlaintext(decryptedStr); this.subscribedPubkeys = new Set(normalized); this.loaded = true; @@ -534,8 +604,7 @@ class SubscriptionsManager { throw error; } - const plainObj = { subPubkeys: Array.from(this.subscribedPubkeys) }; - const plainStr = JSON.stringify(plainObj); + const plainStr = serializeSubscriptionTagMatrix(this.subscribedPubkeys); const encryptors = []; const registerEncryptor = (scheme, handler) => { @@ -604,7 +673,9 @@ class SubscriptionsManager { ? "nip44_v2" : encryptionScheme === "nip44" ? "nip44" - : undefined; + : encryptionScheme === "nip04" + ? "nip04" + : undefined; const evt = buildSubscriptionListEvent({ pubkey: userPubkey, diff --git a/js/userBlocks.js b/js/userBlocks.js index fb95fc14..029dbb5e 100644 --- a/js/userBlocks.js +++ b/js/userBlocks.js @@ -159,6 +159,111 @@ function determineDecryptionOrder(event, availableSchemes) { return prioritized.length ? prioritized : available; } +function serializeBlockListTagMatrix(values, ownerPubkey) { + const tags = []; + const seen = new Set(); + const owner = normalizeHex(ownerPubkey); + const iterable = Array.isArray(values) + ? values + : values instanceof Set + ? Array.from(values) + : values && typeof values[Symbol.iterator] === "function" + ? Array.from(values) + : []; + + for (const candidate of iterable) { + const normalized = normalizeHex(candidate); + if (!normalized || normalized === owner || seen.has(normalized)) { + continue; + } + seen.add(normalized); + tags.push(["p", normalized]); + } + + return JSON.stringify(tags); +} + +function parseBlockListPlaintext(plaintext, ownerPubkey) { + if (typeof plaintext !== "string" || !plaintext) { + return []; + } + + let parsed; + try { + parsed = JSON.parse(plaintext); + } catch (error) { + userLogger.warn( + "[UserBlockList] Failed to parse block list ciphertext as JSON; treating as empty.", + error, + ); + return []; + } + + const owner = normalizeHex(ownerPubkey); + + const extractFromTags = (tags) => { + const collected = []; + const seen = new Set(); + for (const entry of tags) { + if (!Array.isArray(entry) || entry.length < 2) { + continue; + } + const label = typeof entry[0] === "string" ? entry[0].trim().toLowerCase() : ""; + if (label !== "p") { + continue; + } + const normalized = normalizeHex(entry[1]); + if (!normalized || normalized === owner || seen.has(normalized)) { + continue; + } + seen.add(normalized); + collected.push(normalized); + } + return collected; + }; + + if (Array.isArray(parsed)) { + return extractFromTags(parsed); + } + + if (parsed && typeof parsed === "object") { + if (Array.isArray(parsed.tags) && parsed.tags.length) { + return extractFromTags(parsed.tags); + } + + const legacy = Array.isArray(parsed.blockedPubkeys) ? parsed.blockedPubkeys : []; + return legacy + .map((value) => normalizeHex(value)) + .filter((value) => value && value !== owner); + } + + return []; +} + +function isUserBlockListEvent(event) { + if (!event || typeof event.kind !== "number") { + return false; + } + + if (event.kind === 10000) { + return true; + } + + if (event.kind === 30002) { + const tags = Array.isArray(event.tags) ? event.tags : []; + return tags.some( + (tag) => + Array.isArray(tag) && + typeof tag[0] === "string" && + tag[0].trim().toLowerCase() === "d" && + typeof tag[1] === "string" && + tag[1].trim() === BLOCK_LIST_IDENTIFIER, + ); + } + + return false; +} + function decodeNpubToHex(npub) { if (typeof npub !== "string") { return null; @@ -540,10 +645,9 @@ class UserBlockListManager { try { const filter = { - kinds: [30002], + kinds: [10000, 30002], authors: [normalized], - "#d": [BLOCK_LIST_IDENTIFIER], - limit: 1, + limit: 2, }; if (since !== null) { @@ -603,7 +707,10 @@ class UserBlockListManager { settled = true; clearTimeout(timer); const events = Array.isArray(result) - ? result.filter((event) => event && event.pubkey === normalized) + ? result.filter( + (event) => + event && event.pubkey === normalized && isUserBlockListEvent(event), + ) : []; if (requireEvent && !events.length) { const emptyError = new Error( @@ -656,7 +763,10 @@ class UserBlockListManager { } const sorted = events - .filter((event) => event && event.pubkey === normalized) + .filter( + (event) => + event && event.pubkey === normalized && isUserBlockListEvent(event), + ) .sort((a, b) => (b?.created_at || 0) - (a?.created_at || 0)); if (!sorted.length) { @@ -810,21 +920,7 @@ class UserBlockListManager { } try { - const parsed = JSON.parse(decrypted); - const list = Array.isArray(parsed?.blockedPubkeys) - ? parsed.blockedPubkeys - : []; - const sanitized = list - .map((entry) => normalizeHex(entry)) - .filter((candidate) => { - if (!candidate) { - return false; - } - if (candidate === normalized) { - return false; - } - return true; - }); + const sanitized = parseBlockListPlaintext(decrypted, normalized); applyBlockedPubkeys(sanitized, { source, reason: "applied", @@ -1336,7 +1432,7 @@ class UserBlockListManager { (candidate) => candidate && candidate !== normalized ), }; - const plaintext = JSON.stringify(payload); + const plaintext = serializeBlockListTagMatrix(payload.blockedPubkeys, normalized); const encryptors = []; const registerEncryptor = (scheme, handler) => { @@ -1397,7 +1493,9 @@ class UserBlockListManager { ? "nip44_v2" : encryptionScheme === "nip44" ? "nip44" - : undefined; + : encryptionScheme === "nip04" + ? "nip04" + : undefined; const event = buildBlockListEvent({ pubkey: normalized, diff --git a/tests/moderation/trusted-mute-lists.test.mjs b/tests/moderation/trusted-mute-lists.test.mjs index 108d70c3..fd4ea988 100644 --- a/tests/moderation/trusted-mute-lists.test.mjs +++ b/tests/moderation/trusted-mute-lists.test.mjs @@ -103,18 +103,24 @@ test("trusted mute lists from seeds hide authors for anonymous viewers", async ( await userBlocks.addBlock(mutedHex, seedHex); - const muteEvent = signedEvents.find((event) => event.kind === 10000); + const muteEvent = signedEvents.find( + (event) => + event.kind === 10000 && + Array.isArray(event.tags) && + event.tags.some((tag) => Array.isArray(tag) && tag[0] === "p"), + ); assert(muteEvent, "block list updates should publish a kind 10000 mute list event"); assert( - Array.isArray(muteEvent.tags) && - muteEvent.tags.some((tag) => Array.isArray(tag) && tag[0] === "p" && tag[1] === mutedHex), + muteEvent.tags.some((tag) => Array.isArray(tag) && tag[0] === "p" && tag[1] === mutedHex), "mute list event should include p-tags for blocked pubkeys", ); - assert.equal( - publishedEvents.filter((event) => event?.kind === 10000).length, - 1, - "mute list event should be published to relays", + const publishedMuteEvents = publishedEvents.filter( + (event) => + event?.kind === 10000 && + Array.isArray(event.tags) && + event.tags.some((tag) => Array.isArray(tag) && tag[0] === "p"), ); + assert(publishedMuteEvents.length >= 1, "mute list event should be published to relays"); const { service } = createModerationServiceHarness(t, { userBlocks }); service.refreshViewerFromClient = async () => {}; diff --git a/tests/subscriptions-manager.test.mjs b/tests/subscriptions-manager.test.mjs index 441178c5..1656b15e 100644 --- a/tests/subscriptions-manager.test.mjs +++ b/tests/subscriptions-manager.test.mjs @@ -73,7 +73,7 @@ test("loadSubscriptions aggregates relay results when one rejects", async () => async decrypt(_pubkey, ciphertext) { decryptCalls.push(ciphertext); if (ciphertext === "cipher-new") { - return JSON.stringify({ subPubkeys: ["pub-new"] }); + return JSON.stringify([["p", "pub-new"]]); } return JSON.stringify({ subPubkeys: ["pub-old"] }); }, @@ -179,7 +179,7 @@ test("loadSubscriptions falls back to nip44 when hinted", async () => { nip44: { async decrypt(_pubkey, ciphertext) { decryptCalls.nip44 += 1; - return JSON.stringify({ subPubkeys: ["pub-nip44"], hint: ciphertext }); + return JSON.stringify([["p", "pub-nip44", ciphertext]]); }, }, }; @@ -264,7 +264,7 @@ test("loadSubscriptions handles nip44.v2 decryptors", async () => { v2: { async decrypt(_pubkey, ciphertext) { decryptCalls.nip44_v2 += 1; - return JSON.stringify({ subPubkeys: ["pub-nip44-v2"], hint: ciphertext }); + return JSON.stringify([["p", "pub-nip44-v2"], ["t", ciphertext]]); }, }, }, @@ -354,14 +354,14 @@ test("loadSubscriptions prefers nip44 decryptors when both are available", async nip04: { async decrypt() { decryptCalls.nip04 += 1; - return JSON.stringify({ subPubkeys: ["pub-nip04"] }); + return JSON.stringify([["p", "pub-nip04"]]); }, }, nip44: { v2: { async decrypt(_pubkey, ciphertext) { decryptCalls.nip44 += 1; - return JSON.stringify({ subPubkeys: ["pub-nip44-pref"], hint: ciphertext }); + return JSON.stringify([["p", "pub-nip44-pref", ciphertext]]); }, }, }, @@ -443,7 +443,7 @@ test( pubkey: "user-pubkey-123", async nip04Decrypt(pubkey, ciphertext) { decryptCalls.push({ pubkey, ciphertext }); - return JSON.stringify({ subPubkeys: ["pub-direct"] }); + return JSON.stringify([["p", "pub-direct"]]); }, }); @@ -712,7 +712,7 @@ test( encryptCalls[0], { pubkey: "user-pubkey-123", - plaintext: JSON.stringify({ subPubkeys: ["pub-direct"] }), + plaintext: JSON.stringify([["p", "pub-direct"]]), }, "nip04Encrypt should receive the serialized subscription list", ); @@ -782,7 +782,7 @@ test("publishSubscriptionList prefers nip44 encryption when available", async () assert.equal(pubkey, "user-pubkey-123"); assert.equal( plaintext, - JSON.stringify({ subPubkeys: ["pub-one", "pub-two"] }), + JSON.stringify([["p", "pub-one"], ["p", "pub-two"]]), ); return "cipher-nip44"; }, @@ -871,8 +871,8 @@ test("publishSubscriptionList falls back to nip04 when nip44 fails", async () => encryptCalls.nip04 += 1; assert.equal(pubkey, "user-pubkey-123"); assert.equal( - plaintext, - JSON.stringify({ subPubkeys: ["pub-three"] }), + plaintext, + JSON.stringify([["p", "pub-three"]]), ); return "cipher-nip04"; }, @@ -894,10 +894,10 @@ test("publishSubscriptionList falls back to nip04 when nip44 fails", async () => assert.equal(encryptCalls.nip04, 1, "nip04Encrypt should handle fallback"); const signedEvent = signedEvents[0]; const encryptedTags = signedEvent.tags.filter((tag) => tag[0] === "encrypted"); - assert.equal( - encryptedTags.length, - 0, - "fallback nip04 encryption should not advertise a nip44 tag", + assert.deepEqual( + encryptedTags, + [["encrypted", "nip04"]], + "fallback nip04 encryption should advertise nip04", ); assert.equal(publishCalls.length, relayUrls.length, "event should publish to relays"); assert.equal(manager.subsEventId, "signed-nip04-event"); diff --git a/tests/user-blocks.test.mjs b/tests/user-blocks.test.mjs index baeef990..66c07d75 100644 --- a/tests/user-blocks.test.mjs +++ b/tests/user-blocks.test.mjs @@ -490,9 +490,7 @@ await (async () => { let latestEventId = "event-initial"; let latestCreatedAt = 1_000; - const decryptPayloads = new Map([ - [latestCiphertext, JSON.stringify({ blockedPubkeys: [] })], - ]); + const decryptPayloads = new Map([[latestCiphertext, JSON.stringify([])]]); const signedEvents = []; @@ -541,9 +539,18 @@ await (async () => { } catch (error) { throw new Error(`unexpected payload ${plaintext}`); } - const blocked = Array.isArray(parsed?.blockedPubkeys) - ? [...parsed.blockedPubkeys].sort() - : []; + let blocked; + if (Array.isArray(parsed)) { + blocked = parsed + .filter((tag) => Array.isArray(tag) && tag.length >= 2 && tag[0] === "p") + .map((tag) => tag[1]) + .filter((value) => typeof value === "string") + .sort(); + } else if (Array.isArray(parsed?.blockedPubkeys)) { + blocked = [...parsed.blockedPubkeys].sort(); + } else { + blocked = []; + } const cipher = `cipher:${JSON.stringify(blocked)}`; decryptPayloads.set(cipher, plaintext); return cipher; @@ -607,11 +614,15 @@ await (async () => { "direct signer flows should never trigger extension permission prompts", ); - const muteEvent = signedEvents.find((event) => event.kind === 10000); + const muteEvent = signedEvents.find( + (event) => + event.kind === 10000 && + Array.isArray(event.tags) && + event.tags.some((tag) => Array.isArray(tag) && tag[0] === "p"), + ); assert(muteEvent, "block list updates should publish a kind 10000 mute list event"); assert( - Array.isArray(muteEvent.tags) && - muteEvent.tags.some((tag) => Array.isArray(tag) && tag[0] === "p" && tag[1] === target), + muteEvent.tags.some((tag) => Array.isArray(tag) && tag[0] === "p" && tag[1] === target), "mute list event should include p-tags for blocked pubkeys", ); } finally { @@ -677,7 +688,7 @@ await (async () => { assert.equal(pubkey, actor); assert.equal( plaintext, - JSON.stringify({ blockedPubkeys: [blocked] }), + JSON.stringify([["p", blocked]]), ); return "cipher-nip44"; }, @@ -763,7 +774,7 @@ await (async () => { assert.equal(pubkey, actor); assert.equal( plaintext, - JSON.stringify({ blockedPubkeys: [blocked] }), + JSON.stringify([["p", blocked]]), ); return "cipher-nip04"; }, @@ -787,10 +798,10 @@ await (async () => { ); assert.equal(encryptCalls.nip04, 1, "nip04Encrypt should handle fallback"); const encryptedTags = signedEvents[0].tags.filter((tag) => tag[0] === "encrypted"); - assert.equal( - encryptedTags.length, - 0, - "fallback nip04 encryption should not advertise a nip44 tag", + assert.deepEqual( + encryptedTags, + [["encrypted", "nip04"]], + "fallback nip04 encryption should advertise nip04", ); assert.equal(publishCalls.length, 1, "event should publish to the available relay"); } finally { @@ -834,7 +845,7 @@ await (async () => { v2: { async decrypt(_pubkey, ciphertext) { decryptCalls.nip44 += 1; - return JSON.stringify({ blockedPubkeys: [blocked], hint: ciphertext }); + return JSON.stringify([["p", blocked, ciphertext]]); }, }, },