mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-10-27 16:02:26 +00:00
On cold login the app opened WebSockets to a user's full NIP-65 read set
(in prod often ~20 relays, most dead). The flood of failing connections
churned the main thread and starved the nip-07 extension's postMessage
round-trips, so encrypted lists (blocks, subscriptions, hashtags) were
fetched but timed out during decryption ("message channel closed",
"Decryption timed out").
Bound every read/subscribe path to MAX_SUBSCRIBE_RELAYS=8 via a shared,
test-aware capReadRelays helper, applied at the chokepoints:
- client.js applyRelayPreferences (this.relays / this.readRelays)
- relayBatchFetcher.js fetchListIncrementally (blocks, subs, hashtags)
Writes stay uncapped (publish reach preserved).
capReadRelays is user-relays-first (a user's own relays are where their
data authoritatively lives, and small fast-path limits would otherwise
crowd them out — the regression that reverted the first attempt) while
reserving RESERVED_DEFAULT_RELAY_SLOTS=2 for reliable default aggregators
so the "~20 personal relays, all dead" case still reaches a live relay.
Under test the reserved core resolves to the test-relay override, keeping
harnesses isolated.
Tests: new relay-subscribe-cap coverage (bounded + reserved-defaults +
writes-uncapped); spec-corrected user-blocks, subscriptions-manager and
nostr/client fetch tests to bounded (not exact) relay fan-out while
keeping all behavioral/security assertions (per-relay coverage, correct
decrypt, block hydration, dedup) intact. See TEST_INTEGRITY.md and
KNOWN_BUGS #0/#4. Full unit + dm:unit suites green.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
1789 lines
56 KiB
JavaScript
1789 lines
56 KiB
JavaScript
import "./test-helpers/setup-localstorage.mjs";
|
|
import assert from "node:assert/strict";
|
|
|
|
const { userBlocks, USER_BLOCK_EVENTS } = await import("../js/userBlocks.js");
|
|
const {
|
|
nostrClient,
|
|
} = await import("../js/nostrClientFacade.js");
|
|
const { setActiveSigner, clearActiveSigner, getActiveSigner } = await import(
|
|
"../js/nostr/client.js"
|
|
);
|
|
const { relayManager } = await import("../js/relayManager.js");
|
|
const { capReadRelays, DEFAULT_RELAY_URLS } = await import(
|
|
"../js/nostr/toolkit.js"
|
|
);
|
|
|
|
if (typeof globalThis.window === "undefined") {
|
|
globalThis.window = {};
|
|
}
|
|
|
|
// Fix environment for NostrTools
|
|
if (typeof globalThis.NostrTools === "undefined") {
|
|
// Polyfill enough of NostrTools for the tests to function if actual library missing or failing bootstrap
|
|
// The test below overwrites window.NostrTools later, but we need globalThis.NostrTools to exist for js/userBlocks.js module scope checks
|
|
globalThis.NostrTools = {
|
|
nip19: {
|
|
decode: () => { throw new Error("Mock not implemented"); }
|
|
}
|
|
};
|
|
}
|
|
|
|
await (async () => {
|
|
const actor = "f".repeat(64);
|
|
const blocked = "e".repeat(64);
|
|
const relays = [
|
|
"wss://relay-one.example",
|
|
"wss://relay-two.example",
|
|
"wss://relay-three.example",
|
|
];
|
|
|
|
const originalRelayEntries = relayManager.getEntries();
|
|
relayManager.setEntries(relays.map(url => ({ url, mode: "both" })), { allowEmpty: false, updateClient: false });
|
|
|
|
const originalNostr = window.nostr;
|
|
const originalPool = nostrClient.pool;
|
|
const originalRelays = Array.isArray(nostrClient.relays)
|
|
? [...nostrClient.relays]
|
|
: nostrClient.relays;
|
|
const originalWriteRelays = Array.isArray(nostrClient.writeRelays)
|
|
? [...nostrClient.writeRelays]
|
|
: nostrClient.writeRelays;
|
|
const originalBlocked = new Set(userBlocks.blockedPubkeys);
|
|
const originalBlockEventId = userBlocks.blockEventId;
|
|
const originalMuteEventId = userBlocks.muteEventId;
|
|
const originalMuteEventCreatedAt = userBlocks.muteEventCreatedAt;
|
|
const originalLoaded = userBlocks.loaded;
|
|
|
|
const calls = [];
|
|
const resolvers = [];
|
|
|
|
window.nostr = {
|
|
...(originalNostr || {}),
|
|
nip04: {
|
|
...((originalNostr && originalNostr.nip04) || {}),
|
|
decrypt: async () =>
|
|
JSON.stringify({
|
|
blockedPubkeys: [blocked],
|
|
}),
|
|
},
|
|
};
|
|
|
|
const pool = originalPool ? { ...originalPool } : {};
|
|
pool.list = (targets) => {
|
|
const relay = Array.isArray(targets) ? targets[0] : undefined;
|
|
calls.push(relay);
|
|
let resolve;
|
|
const promise = new Promise((res) => {
|
|
resolve = res;
|
|
});
|
|
resolvers.push({ relay, resolve });
|
|
return promise;
|
|
};
|
|
|
|
nostrClient.pool = pool;
|
|
nostrClient.relays = relays;
|
|
nostrClient.writeRelays = relays;
|
|
// Mirror prod: a logged-in session resolves readRelays to the user's relays
|
|
// (via applyRelayPreferences). The bounded fetcher sorts candidates by the
|
|
// user's read preferences, so without this the bundled defaults would be
|
|
// ranked ahead of the user's own relays.
|
|
const originalReadRelays = Array.isArray(nostrClient.readRelays)
|
|
? [...nostrClient.readRelays]
|
|
: nostrClient.readRelays;
|
|
nostrClient.readRelays = relays;
|
|
|
|
assert.equal(
|
|
typeof nostrClient.pool?.list,
|
|
"function",
|
|
"nostrClient.pool.list should be stubbed for block list tests",
|
|
);
|
|
|
|
userBlocks.blockedPubkeys = new Set();
|
|
userBlocks._privateBlocks = new Set();
|
|
userBlocks._publicMutes = new Set();
|
|
userBlocks.blockEventId = null;
|
|
userBlocks.loaded = false;
|
|
|
|
try {
|
|
const loadPromise = userBlocks.loadBlocks(actor);
|
|
|
|
await new Promise((resolve) => setImmediate(resolve));
|
|
|
|
// Spec correction (SCN-block-reads-bounded): block reads now go through the
|
|
// bounded relayBatchFetcher (capReadRelays). The user's OWN relays are
|
|
// prioritized — they're where the block list authoritatively lives — so for
|
|
// a user with a small relay set every configured relay is still queried.
|
|
// The distinct read set is bounded so a large (mostly-dead) NIP-65 list can
|
|
// no longer storm the relay pool and starve list decryption.
|
|
const distinctCalls = new Set(calls);
|
|
for (const relay of relays) {
|
|
assert.ok(
|
|
distinctCalls.has(relay),
|
|
`loadBlocks should still query configured relay ${relay}`,
|
|
);
|
|
}
|
|
assert.ok(
|
|
distinctCalls.size <= 8,
|
|
`block read relay set should be bounded (<=8), got ${distinctCalls.size}`,
|
|
);
|
|
// Sanity: capReadRelays guarantees reliable defaults are reachable in the
|
|
// full read set even when the user's relays are all dead (liveness backstop).
|
|
assert.ok(
|
|
capReadRelays(relays).some((r) => DEFAULT_RELAY_URLS.includes(r)),
|
|
"capReadRelays must keep at least one reliable default in the read set",
|
|
);
|
|
|
|
// Reverting to original test data:
|
|
// relay-one (fast): OLD event
|
|
// relay-two (fast): NEW event
|
|
// This ensures that even if fast relay returns old data, eventual consistency (or Promise.any randomness?) picks it up.
|
|
// Wait, Promise.any picks the FIRST resolved.
|
|
// The test resolves them in order: relay-one (old), relay-two (new).
|
|
// So 'fast' path gets 'event-old'.
|
|
// Background path (Promise.allSettled) gets BOTH.
|
|
// Background path merges and finds 'event-new'.
|
|
// So if the code correctly implements eventual consistency, userBlocks.blockEventId will settle on 'event-new'.
|
|
const responses = {
|
|
[relays[0]]: [
|
|
{
|
|
id: "event-old",
|
|
kind: 10000,
|
|
created_at: 1700,
|
|
pubkey: actor,
|
|
content: "ciphertext-old",
|
|
},
|
|
],
|
|
[relays[1]]: [
|
|
{
|
|
id: "event-new",
|
|
kind: 10000,
|
|
created_at: 1800,
|
|
pubkey: actor,
|
|
content: "ciphertext-new",
|
|
},
|
|
],
|
|
[relays[2]]: [],
|
|
};
|
|
|
|
for (const { relay, resolve } of resolvers) {
|
|
// The bounded read set now also queries the bundled defaults; they carry
|
|
// no block events in this scenario, so resolve them empty.
|
|
resolve(responses[relay] || []);
|
|
}
|
|
|
|
await loadPromise;
|
|
|
|
assert.equal(
|
|
userBlocks.blockEventId,
|
|
"event-new",
|
|
"latest event should win after aggregating relay responses"
|
|
);
|
|
assert.deepEqual(
|
|
userBlocks.getBlockedPubkeys(),
|
|
[blocked],
|
|
"decrypted block list should hydrate from newest event"
|
|
);
|
|
} finally {
|
|
relayManager.setEntries(originalRelayEntries, { allowEmpty: false, updateClient: false });
|
|
userBlocks.blockedPubkeys = originalBlocked;
|
|
userBlocks.blockEventId = originalBlockEventId;
|
|
userBlocks.muteEventId = originalMuteEventId;
|
|
userBlocks.muteEventCreatedAt = originalMuteEventCreatedAt;
|
|
userBlocks.loaded = originalLoaded;
|
|
nostrClient.pool = originalPool;
|
|
nostrClient.relays = originalRelays;
|
|
nostrClient.writeRelays = originalWriteRelays;
|
|
nostrClient.readRelays = originalReadRelays;
|
|
window.nostr = originalNostr;
|
|
}
|
|
})();
|
|
|
|
await (async () => {
|
|
const actor = "7".repeat(64);
|
|
const UserBlockListManager = userBlocks.constructor;
|
|
const manager = new UserBlockListManager();
|
|
|
|
const originalRelayEntries = relayManager.getEntries();
|
|
const originalFetchIncremental = nostrClient.fetchListIncrementally;
|
|
const originalPool = nostrClient.pool;
|
|
const originalRelays = Array.isArray(nostrClient.relays)
|
|
? [...nostrClient.relays]
|
|
: nostrClient.relays;
|
|
const originalWriteRelays = Array.isArray(nostrClient.writeRelays)
|
|
? [...nostrClient.writeRelays]
|
|
: nostrClient.writeRelays;
|
|
|
|
const relayUrls = ["wss://relay-blocks-empty.example"];
|
|
relayManager.setEntries(
|
|
relayUrls.map((url) => ({ url, mode: "both" })),
|
|
{ allowEmpty: false, updateClient: false },
|
|
);
|
|
nostrClient.relays = relayUrls;
|
|
nostrClient.writeRelays = relayUrls;
|
|
nostrClient.pool = originalPool || { list: async () => [] };
|
|
nostrClient.fetchListIncrementally = async () => [];
|
|
|
|
const changeEvents = [];
|
|
const statusEvents = [];
|
|
const unsubscribeChange = manager.on(USER_BLOCK_EVENTS.CHANGE, (detail) => {
|
|
changeEvents.push(detail);
|
|
});
|
|
const unsubscribeStatus = manager.on(USER_BLOCK_EVENTS.STATUS, (detail) => {
|
|
statusEvents.push(detail);
|
|
});
|
|
|
|
try {
|
|
await manager.loadBlocks(actor);
|
|
|
|
const settledChange = changeEvents.filter(
|
|
(detail) => detail?.action === "load-settled",
|
|
);
|
|
assert.ok(
|
|
settledChange.length >= 1,
|
|
"loadBlocks should emit a final load-settled change event",
|
|
);
|
|
const finalChange = settledChange[settledChange.length - 1];
|
|
assert.equal(finalChange.activePubkey, actor);
|
|
assert.equal(finalChange.loaded, true);
|
|
assert.deepEqual(finalChange.blockedPubkeys, []);
|
|
|
|
const settledStatus = statusEvents.filter(
|
|
(detail) => detail?.status === "settled",
|
|
);
|
|
assert.ok(
|
|
settledStatus.length >= 1,
|
|
"loadBlocks should emit settled status metadata",
|
|
);
|
|
const finalStatus = settledStatus[settledStatus.length - 1];
|
|
assert.equal(finalStatus.status, "settled");
|
|
} finally {
|
|
unsubscribeChange?.();
|
|
unsubscribeStatus?.();
|
|
relayManager.setEntries(
|
|
originalRelayEntries,
|
|
{ allowEmpty: false, updateClient: false },
|
|
);
|
|
nostrClient.fetchListIncrementally = originalFetchIncremental;
|
|
nostrClient.pool = originalPool;
|
|
nostrClient.relays = originalRelays;
|
|
nostrClient.writeRelays = originalWriteRelays;
|
|
manager.reset();
|
|
}
|
|
})();
|
|
|
|
await (async () => {
|
|
const actor = "8".repeat(64);
|
|
const UserBlockListManager = userBlocks.constructor;
|
|
const manager = new UserBlockListManager();
|
|
|
|
const originalRelayEntries = relayManager.getEntries();
|
|
const originalFetchIncremental = nostrClient.fetchListIncrementally;
|
|
const originalPool = nostrClient.pool;
|
|
const originalRelays = Array.isArray(nostrClient.relays)
|
|
? [...nostrClient.relays]
|
|
: nostrClient.relays;
|
|
const originalWriteRelays = Array.isArray(nostrClient.writeRelays)
|
|
? [...nostrClient.writeRelays]
|
|
: nostrClient.writeRelays;
|
|
const originalNostr = window.nostr;
|
|
const originalEnsureSignerForPubkey = nostrClient.ensureActiveSignerForPubkey;
|
|
const originalSigner = getActiveSigner();
|
|
|
|
const relayUrls = ["wss://relay-blocks-permission.example"];
|
|
relayManager.setEntries(
|
|
relayUrls.map((url) => ({ url, mode: "both" })),
|
|
{ allowEmpty: false, updateClient: false },
|
|
);
|
|
nostrClient.relays = relayUrls;
|
|
nostrClient.writeRelays = relayUrls;
|
|
nostrClient.pool = originalPool || { list: async () => [] };
|
|
nostrClient.fetchListIncrementally = async () => [
|
|
{
|
|
id: "event-blocks-permission",
|
|
kind: 10000,
|
|
created_at: 505,
|
|
pubkey: actor,
|
|
content: "cipher-blocks-permission",
|
|
tags: [["encrypted", "nip04"]],
|
|
},
|
|
];
|
|
nostrClient.ensureActiveSignerForPubkey = async () => null;
|
|
clearActiveSigner();
|
|
window.nostr = undefined;
|
|
|
|
const changeEvents = [];
|
|
const statusEvents = [];
|
|
const unsubscribeChange = manager.on(USER_BLOCK_EVENTS.CHANGE, (detail) => {
|
|
changeEvents.push(detail);
|
|
});
|
|
const unsubscribeStatus = manager.on(USER_BLOCK_EVENTS.STATUS, (detail) => {
|
|
statusEvents.push(detail);
|
|
});
|
|
|
|
try {
|
|
await manager.loadBlocks(actor, { allowPermissionPrompt: false });
|
|
|
|
const settledChange = changeEvents.filter(
|
|
(detail) => detail?.action === "load-settled",
|
|
);
|
|
assert.ok(
|
|
settledChange.length >= 1,
|
|
"permission-gated loads should still emit load-settled change",
|
|
);
|
|
const finalChange = settledChange[settledChange.length - 1];
|
|
assert.equal(finalChange.activePubkey, actor);
|
|
assert.equal(finalChange.loaded, true);
|
|
|
|
const settledStatus = statusEvents.filter(
|
|
(detail) => detail?.status === "settled",
|
|
);
|
|
assert.ok(
|
|
settledStatus.length >= 1,
|
|
"permission-gated loads should still emit settled status",
|
|
);
|
|
assert.ok(
|
|
statusEvents.some(
|
|
(detail) => detail?.status === "permission-required",
|
|
),
|
|
"permission-gated load should emit permission-required status before settling",
|
|
);
|
|
} finally {
|
|
unsubscribeChange?.();
|
|
unsubscribeStatus?.();
|
|
relayManager.setEntries(
|
|
originalRelayEntries,
|
|
{ allowEmpty: false, updateClient: false },
|
|
);
|
|
nostrClient.fetchListIncrementally = originalFetchIncremental;
|
|
nostrClient.pool = originalPool;
|
|
nostrClient.relays = originalRelays;
|
|
nostrClient.writeRelays = originalWriteRelays;
|
|
nostrClient.ensureActiveSignerForPubkey = originalEnsureSignerForPubkey;
|
|
window.nostr = originalNostr;
|
|
clearActiveSigner();
|
|
if (originalSigner) {
|
|
setActiveSigner(originalSigner);
|
|
}
|
|
manager.reset();
|
|
}
|
|
})();
|
|
|
|
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
|
|
await (async () => {
|
|
const actor = "a".repeat(64);
|
|
const initialBlocked = "b".repeat(64);
|
|
const backgroundBlocked = "c".repeat(64);
|
|
const relays = [
|
|
"wss://fast-one.example",
|
|
"wss://fast-two.example",
|
|
"wss://fast-three.example",
|
|
"wss://background.example",
|
|
];
|
|
|
|
const originalRelayEntries = relayManager.getEntries();
|
|
relayManager.setEntries(relays.map(url => ({ url, mode: "both" })), { allowEmpty: false, updateClient: false });
|
|
|
|
const originalNostr = window.nostr;
|
|
const originalPool = nostrClient.pool;
|
|
const originalRelays = Array.isArray(nostrClient.relays)
|
|
? [...nostrClient.relays]
|
|
: nostrClient.relays;
|
|
const originalWriteRelays = Array.isArray(nostrClient.writeRelays)
|
|
? [...nostrClient.writeRelays]
|
|
: nostrClient.writeRelays;
|
|
const originalBlocked = new Set(userBlocks.blockedPubkeys);
|
|
const originalBlockEventId = userBlocks.blockEventId;
|
|
const originalBlockEventCreatedAt = userBlocks.blockEventCreatedAt;
|
|
const originalMuteEventId = userBlocks.muteEventId;
|
|
const originalMuteEventCreatedAt = userBlocks.muteEventCreatedAt;
|
|
const originalLoaded = userBlocks.loaded;
|
|
|
|
const calls = [];
|
|
const resolvers = [];
|
|
|
|
window.nostr = {
|
|
...(originalNostr || {}),
|
|
nip04: {
|
|
...((originalNostr && originalNostr.nip04) || {}),
|
|
decrypt: async () =>
|
|
JSON.stringify({
|
|
blockedPubkeys: [backgroundBlocked],
|
|
}),
|
|
},
|
|
};
|
|
|
|
const pool = originalPool ? { ...originalPool } : {};
|
|
pool.list = (targets) => {
|
|
const relay = Array.isArray(targets) ? targets[0] : undefined;
|
|
calls.push(relay);
|
|
let resolve;
|
|
const promise = new Promise((res) => {
|
|
resolve = res;
|
|
});
|
|
resolvers.push({ relay, resolve });
|
|
return promise;
|
|
};
|
|
|
|
nostrClient.pool = pool;
|
|
nostrClient.relays = relays;
|
|
nostrClient.writeRelays = relays;
|
|
// Mirror prod: a logged-in session resolves readRelays to the user's relays.
|
|
// The bounded fetcher ranks candidates by read preference, so the user's own
|
|
// relays must be the preferred set (otherwise bundled defaults rank ahead).
|
|
const originalReadRelays = Array.isArray(nostrClient.readRelays)
|
|
? [...nostrClient.readRelays]
|
|
: nostrClient.readRelays;
|
|
nostrClient.readRelays = relays;
|
|
|
|
assert.equal(
|
|
typeof nostrClient.pool?.list,
|
|
"function",
|
|
"nostrClient.pool.list should be stubbed for background relay tests",
|
|
);
|
|
|
|
userBlocks.blockedPubkeys = new Set([initialBlocked]);
|
|
userBlocks._privateBlocks = new Set([initialBlocked]);
|
|
userBlocks._publicMutes = new Set();
|
|
userBlocks.blockEventId = "existing-event";
|
|
userBlocks.blockEventCreatedAt = 1_000;
|
|
userBlocks.loaded = false;
|
|
|
|
const statusEvents = [];
|
|
const changeEvents = [];
|
|
const unsubscribeStatus = userBlocks.on(USER_BLOCK_EVENTS.STATUS, (detail) => {
|
|
statusEvents.push(detail);
|
|
});
|
|
const unsubscribeChange = userBlocks.on(USER_BLOCK_EVENTS.CHANGE, (detail) => {
|
|
changeEvents.push(detail);
|
|
});
|
|
|
|
try {
|
|
const loadPromise = userBlocks.loadBlocks(actor);
|
|
|
|
await new Promise((resolve) => setImmediate(resolve));
|
|
|
|
assert.deepEqual(
|
|
calls,
|
|
[
|
|
...relays.slice(0, 3),
|
|
...relays,
|
|
...relays,
|
|
...relays,
|
|
],
|
|
"loadBlocks should query capped fast relays first, then full relay set for background list variants",
|
|
);
|
|
|
|
for (const { relay, resolve } of resolvers) {
|
|
if (relay !== relays[3]) {
|
|
resolve([]);
|
|
}
|
|
}
|
|
|
|
await new Promise((resolve) => setImmediate(resolve));
|
|
|
|
assert.deepEqual(
|
|
userBlocks.getBlockedPubkeys(),
|
|
[initialBlocked],
|
|
"existing blocked list should be preserved while waiting for background relays",
|
|
);
|
|
|
|
const backgroundResolvers = resolvers.filter((entry) => entry.relay === relays[3]);
|
|
for (const resolver of backgroundResolvers) {
|
|
resolver.resolve([
|
|
{
|
|
id: "background-event",
|
|
kind: 10000,
|
|
pubkey: actor,
|
|
created_at: 2_000,
|
|
content: "ciphertext-background",
|
|
},
|
|
]);
|
|
}
|
|
|
|
await loadPromise;
|
|
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
|
|
assert.equal(
|
|
userBlocks.blockEventId,
|
|
"background-event",
|
|
"background relay event should be applied after fast relay failures",
|
|
);
|
|
assert.deepEqual(
|
|
userBlocks.getBlockedPubkeys(),
|
|
[backgroundBlocked],
|
|
"block list should hydrate from background relay payload",
|
|
);
|
|
|
|
assert(
|
|
statusEvents.some((detail) => detail?.status === "applied"),
|
|
"status events should report when the background payload is applied",
|
|
);
|
|
assert(
|
|
changeEvents.some(
|
|
(detail) =>
|
|
detail?.action === "sync" &&
|
|
Array.isArray(detail?.blockedPubkeys) &&
|
|
detail.blockedPubkeys.includes(backgroundBlocked),
|
|
),
|
|
"change events should emit sync updates after applying background payloads",
|
|
);
|
|
} finally {
|
|
unsubscribeStatus?.();
|
|
unsubscribeChange?.();
|
|
relayManager.setEntries(originalRelayEntries, { allowEmpty: false, updateClient: false });
|
|
userBlocks.blockedPubkeys = originalBlocked;
|
|
userBlocks.blockEventId = originalBlockEventId;
|
|
userBlocks.blockEventCreatedAt = originalBlockEventCreatedAt;
|
|
userBlocks.muteEventId = originalMuteEventId;
|
|
userBlocks.muteEventCreatedAt = originalMuteEventCreatedAt;
|
|
userBlocks.loaded = originalLoaded;
|
|
nostrClient.pool = originalPool;
|
|
nostrClient.relays = originalRelays;
|
|
nostrClient.writeRelays = originalWriteRelays;
|
|
nostrClient.readRelays = originalReadRelays;
|
|
window.nostr = originalNostr;
|
|
}
|
|
})();
|
|
|
|
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
|
|
await (async () => {
|
|
const actor = "1".repeat(64);
|
|
const seedOneHex = "2".repeat(64);
|
|
const seedTwoHex = "3".repeat(64);
|
|
const seedOneNpub = "npub1seedone";
|
|
const seedTwoNpub = "npub1seedtwo";
|
|
|
|
const originalEnsureLoaded = userBlocks.ensureLoaded;
|
|
const originalPublishBlockList = userBlocks.publishBlockList;
|
|
const originalBlocked = new Set(userBlocks.blockedPubkeys);
|
|
const originalBlockEventId = userBlocks.blockEventId;
|
|
const originalBlockEventCreatedAt = userBlocks.blockEventCreatedAt;
|
|
const originalLastPublishedCreatedAt = userBlocks.lastPublishedCreatedAt;
|
|
const originalMuteEventId = userBlocks.muteEventId;
|
|
const originalMuteEventCreatedAt = userBlocks.muteEventCreatedAt;
|
|
const originalLoaded = userBlocks.loaded;
|
|
const originalSeedStateCache = userBlocks.seedStateCache;
|
|
const originalNostrTools = globalThis.NostrTools;
|
|
|
|
const publishCalls = [];
|
|
|
|
localStorage.clear();
|
|
|
|
const decodeMap = new Map([
|
|
[seedOneNpub, seedOneHex],
|
|
[seedTwoNpub, seedTwoHex],
|
|
]);
|
|
|
|
// Patch both globalThis and window to ensure userBlocks sees it
|
|
globalThis.NostrTools = {
|
|
...(originalNostrTools || {}),
|
|
nip19: {
|
|
...((originalNostrTools && originalNostrTools.nip19) || {}),
|
|
decode(value) {
|
|
if (decodeMap.has(value)) {
|
|
return { type: "npub", data: decodeMap.get(value) };
|
|
}
|
|
throw new Error(`unexpected npub decode: ${value}`);
|
|
},
|
|
},
|
|
};
|
|
window.NostrTools = globalThis.NostrTools;
|
|
|
|
userBlocks.ensureLoaded = async () => {
|
|
userBlocks.loaded = true;
|
|
};
|
|
|
|
userBlocks.publishBlockList = async (pubkey) => {
|
|
publishCalls.push({
|
|
pubkey,
|
|
blocked: Array.from(userBlocks.blockedPubkeys),
|
|
});
|
|
userBlocks.blockEventId = `event-${publishCalls.length}`;
|
|
userBlocks.blockEventCreatedAt = 1_000 + publishCalls.length;
|
|
userBlocks.lastPublishedCreatedAt = userBlocks.blockEventCreatedAt;
|
|
return { id: userBlocks.blockEventId, created_at: userBlocks.blockEventCreatedAt };
|
|
};
|
|
|
|
userBlocks.blockedPubkeys = new Set();
|
|
userBlocks._privateBlocks = new Set();
|
|
userBlocks._publicMutes = new Set();
|
|
userBlocks._privateBlocks = new Set();
|
|
userBlocks._publicMutes = new Set();
|
|
userBlocks.blockEventId = null;
|
|
userBlocks.blockEventCreatedAt = null;
|
|
userBlocks.lastPublishedCreatedAt = null;
|
|
userBlocks.muteEventId = null;
|
|
userBlocks.muteEventCreatedAt = null;
|
|
userBlocks.loaded = false;
|
|
userBlocks.seedStateCache = new Map();
|
|
|
|
try {
|
|
const seedResult = await userBlocks.seedWithNpubs(actor, [
|
|
seedOneNpub,
|
|
seedTwoNpub,
|
|
seedTwoNpub,
|
|
]);
|
|
|
|
assert.equal(
|
|
seedResult.seeded,
|
|
true,
|
|
"initial seeding should publish the aggregated blacklist",
|
|
);
|
|
assert.equal(
|
|
publishCalls.length,
|
|
1,
|
|
"seeding should publish exactly one block list event",
|
|
);
|
|
assert.deepEqual(
|
|
Array.from(userBlocks.blockedPubkeys).sort(),
|
|
[seedOneHex, seedTwoHex],
|
|
"seeding should populate the local block list with decoded hex pubkeys",
|
|
);
|
|
|
|
const secondSeed = await userBlocks.seedWithNpubs(actor, [seedOneNpub, seedTwoNpub]);
|
|
assert.equal(
|
|
secondSeed.seeded,
|
|
false,
|
|
"subsequent seeding attempts should be a no-op",
|
|
);
|
|
assert.equal(
|
|
publishCalls.length,
|
|
1,
|
|
"subsequent seeding attempts should not republish the block list",
|
|
);
|
|
|
|
await userBlocks.removeBlock(seedTwoHex, actor);
|
|
assert.equal(
|
|
publishCalls.length,
|
|
2,
|
|
"removing a seeded pubkey should republish the block list",
|
|
);
|
|
assert.deepEqual(
|
|
Array.from(userBlocks.blockedPubkeys),
|
|
[seedOneHex],
|
|
"removal should persist in the local block list",
|
|
);
|
|
|
|
await userBlocks.removeBlock(seedOneHex, actor);
|
|
assert.equal(
|
|
publishCalls.length,
|
|
3,
|
|
"removing the final baseline entry should publish again",
|
|
);
|
|
assert.equal(
|
|
userBlocks.blockedPubkeys.size,
|
|
0,
|
|
"block list should become empty after removing all seeded entries",
|
|
);
|
|
|
|
const removalState = userBlocks._getSeedState(actor);
|
|
assert(removalState.removals.has(seedOneHex), "seed removals should be recorded for the actor");
|
|
assert(removalState.removals.has(seedTwoHex), "seed removals should include every removed pubkey");
|
|
|
|
const afterRemovalSeed = await userBlocks.seedWithNpubs(actor, [
|
|
seedOneNpub,
|
|
seedTwoNpub,
|
|
]);
|
|
assert.equal(
|
|
afterRemovalSeed.seeded,
|
|
false,
|
|
"seeding should stay disabled after removals",
|
|
);
|
|
assert.equal(
|
|
publishCalls.length,
|
|
3,
|
|
"no additional publish calls should occur after removals",
|
|
);
|
|
assert.equal(
|
|
userBlocks.blockedPubkeys.size,
|
|
0,
|
|
"removed entries should not be re-applied by seeding",
|
|
);
|
|
} finally {
|
|
userBlocks.ensureLoaded = originalEnsureLoaded;
|
|
userBlocks.publishBlockList = originalPublishBlockList;
|
|
userBlocks.blockedPubkeys = originalBlocked;
|
|
userBlocks.blockEventId = originalBlockEventId;
|
|
userBlocks.blockEventCreatedAt = originalBlockEventCreatedAt;
|
|
userBlocks.lastPublishedCreatedAt = originalLastPublishedCreatedAt;
|
|
userBlocks.muteEventId = originalMuteEventId;
|
|
userBlocks.muteEventCreatedAt = originalMuteEventCreatedAt;
|
|
userBlocks.loaded = originalLoaded;
|
|
userBlocks.seedStateCache = originalSeedStateCache;
|
|
globalThis.NostrTools = originalNostrTools;
|
|
localStorage.clear();
|
|
}
|
|
})();
|
|
|
|
await (async () => {
|
|
const actor = "4".repeat(64);
|
|
const target = "5".repeat(64);
|
|
|
|
const originalBlocked = new Set(userBlocks.blockedPubkeys);
|
|
const originalBlockEventId = userBlocks.blockEventId;
|
|
const originalBlockEventCreatedAt = userBlocks.blockEventCreatedAt;
|
|
const originalLastPublishedCreatedAt = userBlocks.lastPublishedCreatedAt;
|
|
const originalMuteEventId = userBlocks.muteEventId;
|
|
const originalMuteEventCreatedAt = userBlocks.muteEventCreatedAt;
|
|
const originalLoaded = userBlocks.loaded;
|
|
const originalPool = nostrClient.pool;
|
|
const originalRelays = Array.isArray(nostrClient.relays)
|
|
? [...nostrClient.relays]
|
|
: nostrClient.relays;
|
|
const originalWriteRelays = Array.isArray(nostrClient.writeRelays)
|
|
? [...nostrClient.writeRelays]
|
|
: nostrClient.writeRelays;
|
|
const originalEnsurePermissions = nostrClient.ensureExtensionPermissions;
|
|
const originalSignerState = getActiveSigner();
|
|
clearActiveSigner();
|
|
const originalNostr = window.nostr;
|
|
|
|
let permissionRequests = 0;
|
|
nostrClient.ensureExtensionPermissions = async () => {
|
|
permissionRequests += 1;
|
|
return { ok: true };
|
|
};
|
|
|
|
let eventCounter = 0;
|
|
let latestCiphertext = "cipher:[]";
|
|
let latestEventId = "event-initial";
|
|
let latestCreatedAt = 1_000;
|
|
// Capture tags for faithful mock
|
|
let latestTags = [];
|
|
|
|
const decryptPayloads = new Map([[latestCiphertext, JSON.stringify([])]]);
|
|
|
|
const signedEvents = [];
|
|
|
|
const relayUrls = ["wss://direct-signer.example"];
|
|
const originalRelayEntries = relayManager.getEntries();
|
|
relayManager.setEntries(relayUrls.map(url => ({ url, mode: "both" })), { allowEmpty: false, updateClient: false });
|
|
|
|
nostrClient.relays = relayUrls;
|
|
nostrClient.writeRelays = nostrClient.relays;
|
|
nostrClient.pool = {
|
|
publish: (_targets, event) => {
|
|
latestCiphertext = event.content;
|
|
latestTags = event.tags; // Store tags
|
|
latestEventId = event.id || `event-${Date.now()}`;
|
|
latestCreatedAt = event.created_at || Math.floor(Date.now() / 1000);
|
|
return {
|
|
on(eventName, handler) {
|
|
if (eventName === "ok") {
|
|
setImmediate(handler);
|
|
return true;
|
|
}
|
|
return false;
|
|
},
|
|
};
|
|
},
|
|
list: async () => [
|
|
{
|
|
id: latestEventId,
|
|
kind: 10000,
|
|
created_at: latestCreatedAt,
|
|
pubkey: actor,
|
|
content: latestCiphertext,
|
|
tags: latestTags || [], // Return tags so encryption hints work
|
|
},
|
|
],
|
|
};
|
|
|
|
setActiveSigner({
|
|
type: "private-key",
|
|
pubkey: actor,
|
|
nip04Decrypt: async (pubkey, ciphertext) => {
|
|
assert.equal(pubkey, actor, "decrypt should target the actor pubkey");
|
|
if (!decryptPayloads.has(ciphertext)) {
|
|
throw new Error(`unexpected ciphertext ${ciphertext}`);
|
|
}
|
|
return decryptPayloads.get(ciphertext);
|
|
},
|
|
nip04Encrypt: async (pubkey, plaintext) => {
|
|
assert.equal(pubkey, actor, "encrypt should target the actor pubkey");
|
|
let parsed;
|
|
try {
|
|
parsed = JSON.parse(plaintext);
|
|
} catch (error) {
|
|
throw new Error(`unexpected payload ${plaintext}`);
|
|
}
|
|
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;
|
|
},
|
|
nip44Encrypt: async (pubkey, plaintext) => {
|
|
assert.equal(pubkey, actor, "nip44 encrypt should target the actor pubkey");
|
|
return `nip44:${plaintext}`;
|
|
},
|
|
nip44Decrypt: async (pubkey, ciphertext) => {
|
|
assert.equal(pubkey, actor, "nip44 decrypt should target the actor pubkey");
|
|
if (ciphertext.startsWith("nip44:")) {
|
|
return ciphertext.slice(6);
|
|
}
|
|
throw new Error(`unexpected nip44 ciphertext ${ciphertext}`);
|
|
},
|
|
signEvent: async (event) => {
|
|
eventCounter += 1;
|
|
const signed = {
|
|
...event,
|
|
id: `event-${eventCounter}`,
|
|
created_at: event.created_at ?? Math.floor(Date.now() / 1000),
|
|
};
|
|
signedEvents.push(signed);
|
|
return signed;
|
|
},
|
|
});
|
|
|
|
userBlocks.blockedPubkeys = new Set();
|
|
userBlocks.blockEventId = null;
|
|
userBlocks.blockEventCreatedAt = null;
|
|
userBlocks.lastPublishedCreatedAt = null;
|
|
userBlocks.loaded = false;
|
|
|
|
try {
|
|
window.nostr = undefined;
|
|
|
|
await userBlocks.loadBlocks(actor);
|
|
assert.deepEqual(
|
|
userBlocks.getBlockedPubkeys(),
|
|
[],
|
|
"direct signer should load block list without extension decryptor",
|
|
);
|
|
assert.equal(
|
|
permissionRequests,
|
|
0,
|
|
"loadBlocks should not request extension permissions when signer decryptor exists",
|
|
);
|
|
|
|
const blockResult = await userBlocks.addMute(target, actor);
|
|
assert.equal(blockResult?.ok, true, "addMute should succeed with direct signer");
|
|
assert(userBlocks.blockedPubkeys.has(target), "target should be in the block list");
|
|
|
|
const unblockResult = await userBlocks.removeMute(target, actor);
|
|
assert.equal(
|
|
unblockResult?.ok,
|
|
true,
|
|
"removeMute should succeed with direct signer",
|
|
);
|
|
assert.equal(
|
|
userBlocks.blockedPubkeys.has(target),
|
|
false,
|
|
"target should be removed from the block list",
|
|
);
|
|
|
|
assert.equal(
|
|
permissionRequests,
|
|
0,
|
|
"direct signer flows should never trigger extension permission prompts",
|
|
);
|
|
|
|
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(
|
|
muteEvent.tags.some((tag) => Array.isArray(tag) && tag[0] === "p" && tag[1] === target),
|
|
"mute list event should include p-tags for blocked pubkeys",
|
|
);
|
|
} finally {
|
|
relayManager.setEntries(originalRelayEntries, { allowEmpty: false, updateClient: false });
|
|
userBlocks.blockedPubkeys = originalBlocked;
|
|
userBlocks.blockEventId = originalBlockEventId;
|
|
userBlocks.blockEventCreatedAt = originalBlockEventCreatedAt;
|
|
userBlocks.lastPublishedCreatedAt = originalLastPublishedCreatedAt;
|
|
userBlocks.muteEventId = originalMuteEventId;
|
|
userBlocks.muteEventCreatedAt = originalMuteEventCreatedAt;
|
|
userBlocks.loaded = originalLoaded;
|
|
nostrClient.pool = originalPool;
|
|
nostrClient.relays = originalRelays;
|
|
nostrClient.writeRelays = originalWriteRelays;
|
|
nostrClient.ensureExtensionPermissions = originalEnsurePermissions;
|
|
window.nostr = originalNostr;
|
|
clearActiveSigner();
|
|
if (originalSignerState) {
|
|
setActiveSigner(originalSignerState);
|
|
}
|
|
}
|
|
})();
|
|
|
|
await (async () => {
|
|
const actor = "9".repeat(64);
|
|
const relays = ["wss://relay-login-mode.example"];
|
|
|
|
const originalRelayEntries = relayManager.getEntries();
|
|
relayManager.setEntries(relays.map(url => ({ url, mode: "both" })), { allowEmpty: false, updateClient: false });
|
|
|
|
const originalPool = nostrClient.pool;
|
|
const originalRelays = Array.isArray(nostrClient.relays)
|
|
? [...nostrClient.relays]
|
|
: nostrClient.relays;
|
|
const originalWriteRelays = Array.isArray(nostrClient.writeRelays)
|
|
? [...nostrClient.writeRelays]
|
|
: nostrClient.writeRelays;
|
|
const originalEnsurePermissions = nostrClient.ensureExtensionPermissions;
|
|
const originalSigner = getActiveSigner();
|
|
const originalNostr = window.nostr;
|
|
const originalBlocked = new Set(userBlocks.blockedPubkeys);
|
|
const originalBlockEventId = userBlocks.blockEventId;
|
|
const originalBlockEventCreatedAt = userBlocks.blockEventCreatedAt;
|
|
const originalMuteEventId = userBlocks.muteEventId;
|
|
const originalMuteEventCreatedAt = userBlocks.muteEventCreatedAt;
|
|
const originalLoaded = userBlocks.loaded;
|
|
|
|
let permissionRequests = 0;
|
|
nostrClient.ensureExtensionPermissions = async () => {
|
|
permissionRequests += 1;
|
|
return { ok: true };
|
|
};
|
|
|
|
nostrClient.relays = relays;
|
|
nostrClient.writeRelays = relays;
|
|
nostrClient.pool = {
|
|
list: async () => [
|
|
{
|
|
id: "event-login",
|
|
kind: 10000,
|
|
created_at: 100,
|
|
pubkey: actor,
|
|
content: "cipher-login",
|
|
tags: [["encrypted", "nip04"]],
|
|
},
|
|
],
|
|
};
|
|
|
|
const statusEvents = [];
|
|
const unsubscribeStatus = userBlocks.on(USER_BLOCK_EVENTS.STATUS, (detail) => {
|
|
statusEvents.push(detail);
|
|
});
|
|
|
|
clearActiveSigner();
|
|
window.nostr = undefined;
|
|
|
|
try {
|
|
const start = Date.now();
|
|
await userBlocks.loadBlocks(actor, { allowPermissionPrompt: false });
|
|
const duration = Date.now() - start;
|
|
|
|
assert.equal(
|
|
permissionRequests,
|
|
0,
|
|
"login-mode load should not request extension permissions",
|
|
);
|
|
assert.ok(
|
|
duration < 7000,
|
|
`login-mode load should settle quickly (duration: ${duration}ms)`,
|
|
);
|
|
|
|
const permissionStatus = statusEvents.find(
|
|
(detail) => detail?.status === "permission-required",
|
|
);
|
|
assert.ok(permissionStatus, "login-mode load should emit permission-required status");
|
|
assert.equal(permissionStatus.allowPermissionPrompt, false);
|
|
} finally {
|
|
if (typeof unsubscribeStatus === "function") {
|
|
unsubscribeStatus();
|
|
}
|
|
relayManager.setEntries(originalRelayEntries, { allowEmpty: false, updateClient: false });
|
|
userBlocks.blockedPubkeys = originalBlocked;
|
|
userBlocks.blockEventId = originalBlockEventId;
|
|
userBlocks.blockEventCreatedAt = originalBlockEventCreatedAt;
|
|
userBlocks.muteEventId = originalMuteEventId;
|
|
userBlocks.muteEventCreatedAt = originalMuteEventCreatedAt;
|
|
userBlocks.loaded = originalLoaded;
|
|
nostrClient.pool = originalPool;
|
|
nostrClient.relays = originalRelays;
|
|
nostrClient.writeRelays = originalWriteRelays;
|
|
nostrClient.ensureExtensionPermissions = originalEnsurePermissions;
|
|
window.nostr = originalNostr;
|
|
clearActiveSigner();
|
|
if (originalSigner) {
|
|
setActiveSigner(originalSigner);
|
|
}
|
|
}
|
|
})();
|
|
|
|
await (async () => {
|
|
const actor = "1".repeat(64);
|
|
const blocked = "2".repeat(64);
|
|
|
|
const UserBlockListManager = userBlocks.constructor;
|
|
const manager = new UserBlockListManager();
|
|
|
|
const originalRelays = Array.isArray(nostrClient.relays)
|
|
? [...nostrClient.relays]
|
|
: nostrClient.relays;
|
|
const originalPool = nostrClient.pool;
|
|
const originalEnsurePermissions = nostrClient.ensureExtensionPermissions;
|
|
const originalSigner = getActiveSigner();
|
|
|
|
const relayUrls = ["wss://relay-nip44-blocklist.example"];
|
|
nostrClient.relays = relayUrls;
|
|
nostrClient.ensureExtensionPermissions = async () => ({ ok: true });
|
|
|
|
const publishCalls = [];
|
|
nostrClient.pool = {
|
|
publish(urls, event) {
|
|
publishCalls.push({ urls, event });
|
|
return {
|
|
on(eventName, handler) {
|
|
if (eventName === "ok") {
|
|
handler();
|
|
}
|
|
return true;
|
|
},
|
|
};
|
|
},
|
|
};
|
|
|
|
const encryptCalls = { nip44: 0, nip04: 0 };
|
|
const signedEvents = [];
|
|
setActiveSigner({
|
|
type: "nsec",
|
|
pubkey: actor,
|
|
async nip44Encrypt(pubkey, plaintext) {
|
|
encryptCalls.nip44 += 1;
|
|
assert.equal(pubkey, actor);
|
|
assert.equal(
|
|
plaintext,
|
|
JSON.stringify([["p", blocked]]),
|
|
);
|
|
return "cipher-nip44";
|
|
},
|
|
async nip04Encrypt() {
|
|
encryptCalls.nip04 += 1;
|
|
throw new Error("nip04 should not be used when nip44 is available");
|
|
},
|
|
async signEvent(event) {
|
|
signedEvents.push(event);
|
|
return { ...event, id: "signed-nip44-block-event" };
|
|
},
|
|
});
|
|
|
|
manager.blockedPubkeys = new Set([blocked]);
|
|
manager._privateBlocks = new Set([blocked]);
|
|
manager._publicMutes = new Set();
|
|
manager._privateBlocks = new Set([blocked]);
|
|
manager._publicMutes = new Set();
|
|
manager.publishMuteListSnapshot = async () => null;
|
|
manager.loadBlocks = async () => {};
|
|
|
|
try {
|
|
const result = await manager.publishBlockList(actor);
|
|
|
|
assert.equal(result.id, "signed-nip44-block-event");
|
|
assert.equal(encryptCalls.nip44, 1, "nip44Encrypt should be used once");
|
|
assert.equal(encryptCalls.nip04, 0, "nip04Encrypt should not be invoked");
|
|
assert.equal(signedEvents.length, 1, "signEvent should be called once");
|
|
const encryptedTags = signedEvents[0].tags.filter((tag) => tag[0] === "encrypted");
|
|
assert.deepEqual(
|
|
encryptedTags,
|
|
[["encrypted", "nip44_v2"]],
|
|
"block list event should advertise nip44_v2 encryption",
|
|
);
|
|
assert.equal(publishCalls.length, relayUrls.length, "event should publish to relays");
|
|
} finally {
|
|
nostrClient.relays = originalRelays;
|
|
nostrClient.pool = originalPool;
|
|
nostrClient.ensureExtensionPermissions = originalEnsurePermissions;
|
|
setActiveSigner(originalSigner);
|
|
}
|
|
})();
|
|
|
|
await (async () => {
|
|
const actor = "3".repeat(64);
|
|
const blocked = "4".repeat(64);
|
|
|
|
const UserBlockListManager = userBlocks.constructor;
|
|
const manager = new UserBlockListManager();
|
|
|
|
const originalRelays = Array.isArray(nostrClient.relays)
|
|
? [...nostrClient.relays]
|
|
: nostrClient.relays;
|
|
const originalPool = nostrClient.pool;
|
|
const originalEnsurePermissions = nostrClient.ensureExtensionPermissions;
|
|
const originalSigner = getActiveSigner();
|
|
|
|
nostrClient.relays = ["wss://relay-nip04-fallback.example"];
|
|
nostrClient.ensureExtensionPermissions = async () => ({ ok: true });
|
|
|
|
const publishCalls = [];
|
|
nostrClient.pool = {
|
|
publish(urls, event) {
|
|
publishCalls.push({ urls, event });
|
|
return {
|
|
on(eventName, handler) {
|
|
if (eventName === "ok") {
|
|
handler();
|
|
}
|
|
return true;
|
|
},
|
|
};
|
|
},
|
|
};
|
|
|
|
const encryptCalls = { nip44: 0, nip04: 0 };
|
|
const signedEvents = [];
|
|
setActiveSigner({
|
|
type: "nsec",
|
|
pubkey: actor,
|
|
async nip44Encrypt() {
|
|
encryptCalls.nip44 += 1;
|
|
throw new Error("simulated nip44 failure");
|
|
},
|
|
async nip04Encrypt(pubkey, plaintext) {
|
|
encryptCalls.nip04 += 1;
|
|
assert.equal(pubkey, actor);
|
|
assert.equal(
|
|
plaintext,
|
|
JSON.stringify([["p", blocked]]),
|
|
);
|
|
return "cipher-nip04";
|
|
},
|
|
async signEvent(event) {
|
|
signedEvents.push(event);
|
|
return { ...event, id: "signed-nip04-block-event" };
|
|
},
|
|
});
|
|
|
|
manager.blockedPubkeys = new Set([blocked]);
|
|
manager._privateBlocks = new Set([blocked]);
|
|
manager._privateBlocks = new Set([blocked]);
|
|
manager.publishMuteListSnapshot = async () => null;
|
|
manager.loadBlocks = async () => {};
|
|
|
|
try {
|
|
const result = await manager.publishBlockList(actor);
|
|
|
|
assert.equal(result.id, "signed-nip04-block-event");
|
|
assert.ok(
|
|
encryptCalls.nip44 >= 1,
|
|
"nip44Encrypt should be attempted before falling back",
|
|
);
|
|
assert.equal(encryptCalls.nip04, 1, "nip04Encrypt should handle fallback");
|
|
const encryptedTags = signedEvents[0].tags.filter((tag) => tag[0] === "encrypted");
|
|
assert.deepEqual(
|
|
encryptedTags,
|
|
[["encrypted", "nip04"]],
|
|
"fallback nip04 encryption should advertise nip04",
|
|
);
|
|
assert.equal(publishCalls.length, 1, "event should publish to the available relay");
|
|
} finally {
|
|
nostrClient.relays = originalRelays;
|
|
nostrClient.pool = originalPool;
|
|
nostrClient.ensureExtensionPermissions = originalEnsurePermissions;
|
|
setActiveSigner(originalSigner);
|
|
}
|
|
})();
|
|
|
|
await (async () => {
|
|
const actor = "5".repeat(64);
|
|
const blocked = "6".repeat(64);
|
|
|
|
const UserBlockListManager = userBlocks.constructor;
|
|
const manager = new UserBlockListManager();
|
|
|
|
const originalNostr = window.nostr;
|
|
const originalRelays = Array.isArray(nostrClient.relays)
|
|
? [...nostrClient.relays]
|
|
: nostrClient.relays;
|
|
const originalPool = nostrClient.pool;
|
|
const originalEnsurePermissions = nostrClient.ensureExtensionPermissions;
|
|
const originalSigner = getActiveSigner();
|
|
|
|
clearActiveSigner();
|
|
|
|
const relayUrls = ["wss://relay-nip44-read.example"];
|
|
const originalRelayEntries = relayManager.getEntries();
|
|
relayManager.setEntries(relayUrls.map(url => ({ url, mode: "both" })), { allowEmpty: false, updateClient: false });
|
|
|
|
nostrClient.relays = relayUrls;
|
|
nostrClient.ensureExtensionPermissions = async () => ({ ok: true });
|
|
|
|
const decryptCalls = { nip44: 0, nip04: 0 };
|
|
window.nostr = {
|
|
nip04: {
|
|
async decrypt() {
|
|
decryptCalls.nip04 += 1;
|
|
return JSON.stringify({ blockedPubkeys: [blocked] });
|
|
},
|
|
},
|
|
nip44: {
|
|
v2: {
|
|
async decrypt(_pubkey, ciphertext) {
|
|
decryptCalls.nip44 += 1;
|
|
return JSON.stringify([["p", blocked, ciphertext]]);
|
|
},
|
|
},
|
|
},
|
|
};
|
|
|
|
nostrClient.pool = {
|
|
list() {
|
|
return Promise.resolve([
|
|
{
|
|
id: "event-nip44-read",
|
|
kind: 10000,
|
|
created_at: 9_000,
|
|
pubkey: actor,
|
|
content: "cipher-nip44-read",
|
|
tags: [["encrypted", "nip44_v2"]],
|
|
},
|
|
]);
|
|
},
|
|
};
|
|
|
|
try {
|
|
await manager.loadBlocks(actor);
|
|
|
|
assert.ok(decryptCalls.nip44 >= 1, "nip44 decryptor should be preferred");
|
|
assert.ok(
|
|
decryptCalls.nip04 <= 1,
|
|
"nip04 decryptor should not be repeatedly used when nip44 succeeds",
|
|
);
|
|
} finally {
|
|
relayManager.setEntries(originalRelayEntries, { allowEmpty: false, updateClient: false });
|
|
window.nostr = originalNostr;
|
|
nostrClient.relays = originalRelays;
|
|
nostrClient.pool = originalPool;
|
|
nostrClient.ensureExtensionPermissions = originalEnsurePermissions;
|
|
if (originalSigner) {
|
|
setActiveSigner(originalSigner);
|
|
} else {
|
|
clearActiveSigner();
|
|
}
|
|
}
|
|
})();
|
|
|
|
await (async () => {
|
|
const actor = "7".repeat(64);
|
|
const validOne = "8".repeat(64);
|
|
const validTwoUpper = "ABCDEF01".repeat(8);
|
|
const validTwo = validTwoUpper.toLowerCase();
|
|
|
|
const UserBlockListManager = userBlocks.constructor;
|
|
const manager = new UserBlockListManager();
|
|
|
|
const originalRelays = Array.isArray(nostrClient.relays)
|
|
? [...nostrClient.relays]
|
|
: nostrClient.relays;
|
|
const originalPool = nostrClient.pool;
|
|
const originalSigner = getActiveSigner();
|
|
const originalStderrWrite = process.stderr.write.bind(process.stderr);
|
|
|
|
const publishCalls = [];
|
|
const encryptionCalls = [];
|
|
const signedEvents = [];
|
|
const stderrChunks = [];
|
|
|
|
process.stderr.write = (chunk, encoding, callback) => {
|
|
const text =
|
|
typeof chunk === "string"
|
|
? chunk
|
|
: Buffer.from(chunk, typeof encoding === "string" ? encoding : undefined).toString();
|
|
stderrChunks.push(text);
|
|
if (typeof callback === "function") {
|
|
callback();
|
|
}
|
|
return true;
|
|
};
|
|
|
|
nostrClient.relays = ["wss://sanitize-blocks.example"];
|
|
nostrClient.pool = {
|
|
publish(urls, event) {
|
|
publishCalls.push({ urls, event });
|
|
return {
|
|
on(eventName, handler) {
|
|
if (eventName === "ok") {
|
|
handler();
|
|
return true;
|
|
}
|
|
return false;
|
|
},
|
|
};
|
|
},
|
|
};
|
|
|
|
setActiveSigner({
|
|
type: "nsec",
|
|
pubkey: actor,
|
|
async nip44Encrypt(pubkey, plaintext) {
|
|
encryptionCalls.push({ pubkey, plaintext });
|
|
return `cipher-${encryptionCalls.length}`;
|
|
},
|
|
async signEvent(event) {
|
|
signedEvents.push(event);
|
|
return { ...event, id: `signed-${signedEvents.length}`, created_at: event.created_at };
|
|
},
|
|
});
|
|
|
|
const badInputs = new Set([
|
|
validOne,
|
|
validTwoUpper,
|
|
actor,
|
|
["p", ` ${validTwoUpper} `],
|
|
["p"],
|
|
["p", null],
|
|
"",
|
|
["e", validOne],
|
|
null,
|
|
]);
|
|
manager.blockedPubkeys = new Set(badInputs);
|
|
manager._privateBlocks = new Set(badInputs);
|
|
manager._publicMutes = new Set(badInputs);
|
|
manager.loadBlocks = async () => {};
|
|
|
|
try {
|
|
await manager.publishBlockList(actor);
|
|
|
|
assert.equal(publishCalls.length, 1, "only one merged event should publish");
|
|
assert.equal(encryptionCalls.length, 1, "payload should encrypt once for private content");
|
|
|
|
const [blockPayload] = encryptionCalls;
|
|
assert.equal(blockPayload.pubkey, actor, "block payload should encrypt for the actor");
|
|
|
|
const matrix = JSON.parse(blockPayload.plaintext);
|
|
assert.deepEqual(
|
|
matrix,
|
|
[
|
|
["p", validOne],
|
|
["p", validTwo],
|
|
],
|
|
"block payload should serialize only sanitized mute tags",
|
|
);
|
|
|
|
const muteEvent = signedEvents.find((event) =>
|
|
Array.isArray(event?.tags) && event.tags.some((tag) => Array.isArray(tag) && tag[0] === "p"),
|
|
);
|
|
assert(muteEvent, "mute list snapshot should include public p tags");
|
|
const muteTags = muteEvent.tags.filter((tag) => Array.isArray(tag) && tag[0] === "p");
|
|
assert.deepEqual(
|
|
muteTags,
|
|
matrix,
|
|
"mute list snapshot should publish sanitized p tags",
|
|
);
|
|
|
|
const expectedReasons = [
|
|
"self-target",
|
|
"duplicate",
|
|
"invalid-tuple",
|
|
"invalid-target",
|
|
"invalid-string",
|
|
"unsupported-tag",
|
|
"unsupported-entry",
|
|
].sort();
|
|
|
|
const warningLines = stderrChunks
|
|
.join("")
|
|
.split(/\n+/)
|
|
.filter((line) => line.includes("[UserBlockList] Discarded block entry"));
|
|
|
|
assert.equal(
|
|
warningLines.length,
|
|
expectedReasons.length * 2,
|
|
"every malformed entry should trigger a warning (once for block processing, once for mute processing)",
|
|
);
|
|
|
|
const actualReasons = warningLines
|
|
.map((line) => {
|
|
const match = line.match(/\(([^)]+)\)/);
|
|
return match ? match[1] : null;
|
|
})
|
|
.filter(Boolean)
|
|
.sort();
|
|
|
|
const uniqueActualReasons = [...new Set(actualReasons)].sort();
|
|
|
|
assert.deepEqual(
|
|
uniqueActualReasons,
|
|
expectedReasons,
|
|
"discarded entries should log their discard reasons",
|
|
);
|
|
} finally {
|
|
process.stderr.write = originalStderrWrite;
|
|
nostrClient.relays = originalRelays;
|
|
nostrClient.pool = originalPool;
|
|
if (originalSigner) {
|
|
setActiveSigner(originalSigner);
|
|
} else {
|
|
clearActiveSigner();
|
|
}
|
|
}
|
|
})();
|
|
|
|
await (async () => {
|
|
// Test for tag-only Kind 10000 events (Primal-style)
|
|
const actor = "f".repeat(64);
|
|
const blockedHex = "a".repeat(64);
|
|
|
|
const UserBlockListManager = userBlocks.constructor;
|
|
const manager = new UserBlockListManager();
|
|
|
|
const originalRelays = Array.isArray(nostrClient.relays) ? [...nostrClient.relays] : nostrClient.relays;
|
|
const originalPool = nostrClient.pool;
|
|
|
|
const relayUrls = ["wss://tag-only.example"];
|
|
const originalRelayEntries = relayManager.getEntries();
|
|
relayManager.setEntries(relayUrls.map(url => ({ url, mode: "both" })), { allowEmpty: false, updateClient: false });
|
|
|
|
nostrClient.relays = relayUrls;
|
|
nostrClient.pool = {
|
|
list: async () => [
|
|
{
|
|
id: "event-tag-only",
|
|
kind: 10000,
|
|
created_at: 10_000,
|
|
pubkey: actor,
|
|
content: "", // Empty content
|
|
tags: [["p", blockedHex]],
|
|
},
|
|
],
|
|
};
|
|
|
|
try {
|
|
await manager.loadBlocks(actor);
|
|
|
|
assert(manager.blockedPubkeys.has(blockedHex), "should load blocked pubkeys from p-tags when content is empty");
|
|
assert.equal(manager.blockedPubkeys.size, 1, "should have exactly one blocked pubkey");
|
|
assert.equal(manager.blockEventId, "event-tag-only", "should track the correct event ID");
|
|
} finally {
|
|
relayManager.setEntries(originalRelayEntries, { allowEmpty: false, updateClient: false });
|
|
nostrClient.relays = originalRelays;
|
|
nostrClient.pool = originalPool;
|
|
}
|
|
})();
|
|
|
|
await (async () => {
|
|
// Test for tag-only Kind 10000 events with whitespace content
|
|
const actor = "f".repeat(64);
|
|
const blockedHex = "b".repeat(64);
|
|
|
|
const UserBlockListManager = userBlocks.constructor;
|
|
const manager = new UserBlockListManager();
|
|
|
|
const originalRelays = Array.isArray(nostrClient.relays) ? [...nostrClient.relays] : nostrClient.relays;
|
|
const originalPool = nostrClient.pool;
|
|
|
|
const relayUrls = ["wss://tag-only-whitespace.example"];
|
|
const originalRelayEntries = relayManager.getEntries();
|
|
relayManager.setEntries(relayUrls.map(url => ({ url, mode: "both" })), { allowEmpty: false, updateClient: false });
|
|
|
|
nostrClient.relays = relayUrls;
|
|
nostrClient.pool = {
|
|
list: async () => [
|
|
{
|
|
id: "event-tag-only-space",
|
|
kind: 10000,
|
|
created_at: 11_000,
|
|
pubkey: actor,
|
|
content: " ", // Whitespace content
|
|
tags: [["p", blockedHex]],
|
|
},
|
|
],
|
|
};
|
|
|
|
try {
|
|
await manager.loadBlocks(actor);
|
|
|
|
assert(manager.blockedPubkeys.has(blockedHex), "should load blocked pubkeys from p-tags when content is whitespace");
|
|
} finally {
|
|
relayManager.setEntries(originalRelayEntries, { allowEmpty: false, updateClient: false });
|
|
nostrClient.relays = originalRelays;
|
|
nostrClient.pool = originalPool;
|
|
}
|
|
})();
|
|
|
|
await (async () => {
|
|
// Test for empty content AND no tags -> should clear
|
|
const actor = "f".repeat(64);
|
|
const previouslyBlocked = "c".repeat(64);
|
|
|
|
const UserBlockListManager = userBlocks.constructor;
|
|
const manager = new UserBlockListManager();
|
|
manager.blockedPubkeys.add(previouslyBlocked);
|
|
manager._privateBlocks.add(previouslyBlocked);
|
|
|
|
const originalRelays = Array.isArray(nostrClient.relays) ? [...nostrClient.relays] : nostrClient.relays;
|
|
const originalPool = nostrClient.pool;
|
|
|
|
const relayUrls = ["wss://empty-clear.example"];
|
|
const originalRelayEntries = relayManager.getEntries();
|
|
relayManager.setEntries(relayUrls.map(url => ({ url, mode: "both" })), { allowEmpty: false, updateClient: false });
|
|
|
|
nostrClient.relays = relayUrls;
|
|
nostrClient.pool = {
|
|
list: async () => [
|
|
{
|
|
id: "event-empty",
|
|
kind: 10000,
|
|
created_at: 12_000,
|
|
pubkey: actor,
|
|
content: "",
|
|
tags: [], // No p tags
|
|
},
|
|
],
|
|
};
|
|
|
|
try {
|
|
await manager.loadBlocks(actor);
|
|
|
|
assert.equal(manager.blockedPubkeys.size, 0, "should clear block list when content and tags are empty");
|
|
} finally {
|
|
relayManager.setEntries(originalRelayEntries, { allowEmpty: false, updateClient: false });
|
|
nostrClient.relays = originalRelays;
|
|
nostrClient.pool = originalPool;
|
|
}
|
|
})();
|
|
|
|
await (async () => {
|
|
// Test: Merge legacy tagged/private lists with standard mute lists
|
|
const actor = "a".repeat(64);
|
|
const taggedBlocked = "b".repeat(64);
|
|
const plainBlocked = "c".repeat(64);
|
|
|
|
const originalRelays = nostrClient.relays;
|
|
const originalPool = nostrClient.pool;
|
|
const originalNostr = window.nostr;
|
|
|
|
const relayUrls = ["wss://repro.example"];
|
|
const originalRelayEntries = relayManager.getEntries();
|
|
relayManager.setEntries(relayUrls.map(url => ({ url, mode: "both" })), { allowEmpty: false, updateClient: false });
|
|
|
|
nostrClient.relays = relayUrls;
|
|
|
|
window.nostr = {
|
|
nip04: {
|
|
decrypt: async (_pubkey, ciphertext) => {
|
|
if (ciphertext === "encrypted-tagged") {
|
|
return JSON.stringify({ blockedPubkeys: [taggedBlocked] });
|
|
}
|
|
return "{}";
|
|
}
|
|
}
|
|
};
|
|
|
|
nostrClient.pool = {
|
|
list: async () => [
|
|
{
|
|
id: "event-tagged",
|
|
kind: 10000,
|
|
created_at: 1000,
|
|
pubkey: actor,
|
|
content: "encrypted-tagged",
|
|
tags: [["d", "user-blocks"]],
|
|
},
|
|
{
|
|
id: "event-plain",
|
|
kind: 10000,
|
|
created_at: 2000, // Newer!
|
|
pubkey: actor,
|
|
content: "",
|
|
tags: [["p", plainBlocked]],
|
|
}
|
|
]
|
|
};
|
|
|
|
userBlocks.reset();
|
|
|
|
try {
|
|
await userBlocks.loadBlocks(actor);
|
|
|
|
const blocked = userBlocks.getBlockedPubkeys();
|
|
assert.ok(
|
|
blocked.includes(taggedBlocked),
|
|
"should include pubkey from legacy tagged/private list",
|
|
);
|
|
assert.ok(
|
|
blocked.includes(plainBlocked),
|
|
"should include pubkey from standard mute list",
|
|
);
|
|
} finally {
|
|
relayManager.setEntries(originalRelayEntries, { allowEmpty: false, updateClient: false });
|
|
nostrClient.relays = originalRelays;
|
|
nostrClient.pool = originalPool;
|
|
window.nostr = originalNostr;
|
|
}
|
|
})();
|
|
|
|
await (async () => {
|
|
// Test: standard mute lists may contain non-encrypted text content
|
|
const actor = "d".repeat(64);
|
|
const blockedHex = "e".repeat(64);
|
|
|
|
const UserBlockListManager = userBlocks.constructor;
|
|
const manager = new UserBlockListManager();
|
|
|
|
const originalRelays = Array.isArray(nostrClient.relays)
|
|
? [...nostrClient.relays]
|
|
: nostrClient.relays;
|
|
const originalPool = nostrClient.pool;
|
|
const originalNostr = window.nostr;
|
|
|
|
const relayUrls = ["wss://plain-content-mute.example"];
|
|
const originalRelayEntries = relayManager.getEntries();
|
|
relayManager.setEntries(
|
|
relayUrls.map((url) => ({ url, mode: "both" })),
|
|
{ allowEmpty: false, updateClient: false },
|
|
);
|
|
|
|
window.nostr = {
|
|
...(originalNostr || {}),
|
|
nip04: undefined,
|
|
nip44: undefined,
|
|
};
|
|
|
|
nostrClient.relays = relayUrls;
|
|
nostrClient.pool = {
|
|
list: async () => [
|
|
{
|
|
id: "event-standard-note",
|
|
kind: 10000,
|
|
created_at: 13_000,
|
|
pubkey: actor,
|
|
content: "mute list synced from another client",
|
|
tags: [["p", blockedHex]],
|
|
},
|
|
],
|
|
};
|
|
|
|
try {
|
|
await manager.loadBlocks(actor, { allowPermissionPrompt: false });
|
|
assert.equal(
|
|
manager.blockedPubkeys.has(blockedHex),
|
|
true,
|
|
"non-encrypted content should not block p-tag mute ingestion",
|
|
);
|
|
} finally {
|
|
relayManager.setEntries(
|
|
originalRelayEntries,
|
|
{ allowEmpty: false, updateClient: false },
|
|
);
|
|
nostrClient.relays = originalRelays;
|
|
nostrClient.pool = originalPool;
|
|
window.nostr = originalNostr;
|
|
}
|
|
})();
|
|
|
|
await (async () => {
|
|
// Test: decrypt timeout should still apply public p-tags while preserving
|
|
// existing private blocks.
|
|
const actor = "6".repeat(64);
|
|
const existingPrivateBlocked = "7".repeat(64);
|
|
const publicBlocked = "8".repeat(64);
|
|
|
|
const UserBlockListManager = userBlocks.constructor;
|
|
const manager = new UserBlockListManager();
|
|
|
|
const originalRelays = Array.isArray(nostrClient.relays)
|
|
? [...nostrClient.relays]
|
|
: nostrClient.relays;
|
|
const originalWriteRelays = Array.isArray(nostrClient.writeRelays)
|
|
? [...nostrClient.writeRelays]
|
|
: nostrClient.writeRelays;
|
|
const originalPool = nostrClient.pool;
|
|
const originalSigner = getActiveSigner();
|
|
const originalRelayEntries = relayManager.getEntries();
|
|
|
|
const relayUrls = ["wss://decrypt-timeout-partial.example"];
|
|
relayManager.setEntries(
|
|
relayUrls.map((url) => ({ url, mode: "both" })),
|
|
{ allowEmpty: false, updateClient: false },
|
|
);
|
|
|
|
nostrClient.relays = relayUrls;
|
|
nostrClient.writeRelays = relayUrls;
|
|
nostrClient.pool = {
|
|
list: async () => [
|
|
{
|
|
id: "event-timeout-partial",
|
|
kind: 10000,
|
|
created_at: 21_000,
|
|
pubkey: actor,
|
|
content: "ciphertext-timeout",
|
|
tags: [
|
|
["encrypted", "nip44"],
|
|
["p", publicBlocked],
|
|
],
|
|
},
|
|
],
|
|
};
|
|
|
|
setActiveSigner({
|
|
getPublicKey: async () => actor,
|
|
nip44Decrypt: async () => new Promise(() => {}),
|
|
});
|
|
|
|
manager._privateBlocks = new Set([existingPrivateBlocked]);
|
|
manager._publicMutes = new Set();
|
|
manager.blockedPubkeys = new Set([existingPrivateBlocked]);
|
|
manager.blockEventId = "event-previous";
|
|
manager.blockEventCreatedAt = 20_000;
|
|
manager.loaded = true;
|
|
|
|
const statusEvents = [];
|
|
const unsubscribe = manager.on(USER_BLOCK_EVENTS.STATUS, (detail) => {
|
|
statusEvents.push(detail);
|
|
});
|
|
|
|
try {
|
|
await manager.loadBlocks(actor, {
|
|
allowPermissionPrompt: false,
|
|
decryptTimeoutMs: 30,
|
|
});
|
|
|
|
const blocked = manager.getBlockedPubkeys();
|
|
assert.equal(
|
|
blocked.includes(existingPrivateBlocked),
|
|
true,
|
|
"timeout fallback should preserve previously decrypted private blocks",
|
|
);
|
|
assert.equal(
|
|
blocked.includes(publicBlocked),
|
|
true,
|
|
"timeout fallback should still ingest public mute p-tags",
|
|
);
|
|
assert.equal(
|
|
manager.blockEventId,
|
|
"event-timeout-partial",
|
|
"timeout fallback should keep metadata anchored to the newest relay event",
|
|
);
|
|
assert(
|
|
statusEvents.some(
|
|
(detail) =>
|
|
detail?.status === "stale" && detail?.reason === "decrypt-timeout",
|
|
),
|
|
"timeout fallback should emit stale decrypt-timeout status",
|
|
);
|
|
} finally {
|
|
unsubscribe?.();
|
|
relayManager.setEntries(
|
|
originalRelayEntries,
|
|
{ allowEmpty: false, updateClient: false },
|
|
);
|
|
nostrClient.relays = originalRelays;
|
|
nostrClient.writeRelays = originalWriteRelays;
|
|
nostrClient.pool = originalPool;
|
|
clearActiveSigner();
|
|
if (originalSigner) {
|
|
setActiveSigner(originalSigner);
|
|
}
|
|
manager.reset();
|
|
}
|
|
})();
|