Update subscription and block lists to encrypted tag arrays

This commit is contained in:
thePR0M3TH3AN committed 2025-10-30 15:51:21 -04:00
1 parent 6fce4ecc14
commit 2fae0a0e7c
6 files changed
+272 -80

No files matched your search

+16 -10
View File
@@ -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', <hex>], …]).",
},
},
[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', <hex>], …]).",
},
},
[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,
+82 -11
View File
@@ -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,
+120 -22
View File
@@ -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,
+13 -7
View File
@@ -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 () => {};
+14 -14
View File
@@ -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");
+27 -16
View File
@@ -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]]);
},
},
},