Improve Nostr watch history snapshot caching

This commit is contained in:
thePR0M3TH3AN committed 2025-10-22 20:13:10 -04:00
1 parent 9f79983d5d
commit 1ff3523bd0
7 files changed
+777 -39

No files matched your search

+14 -4
View File
@@ -2452,6 +2452,11 @@ export class NostrClient {
!!normalizedLogged &&
activeSigner &&
typeof activeSigner.signEvent === "function";
const canSignWithExtension =
!!normalizedLogged &&
activeSigner &&
typeof activeSigner.signEvent === "function" &&
shouldRequestExtensionPermissions(activeSigner);
if (!forceRenew && canSignWithActiveSigner) {
return normalizedLogged;
@@ -3237,6 +3242,10 @@ export class NostrClient {
if (!pubkey) throw new Error("Not logged in to publish video.");
const { videoData, nip71Metadata } = extractVideoPublishPayload(videoPayload);
const nip71EditedFlag =
videoPayload && typeof videoPayload === "object"
? videoPayload.nip71Edited
: null;
// NOTE: Keep the Upload, Edit, and Revert flows synchronized when
// updating shared fields. Changes here must be reflected in the modal
@@ -3274,6 +3283,7 @@ export class NostrClient {
const finalIsNsfw = videoData.isNsfw === true;
const finalIsForKids =
videoData.isForKids === true && !finalIsNsfw;
const wantPrivate = videoData.isPrivate === true;
const finalWs =
typeof videoData.ws === "string" ? videoData.ws.trim() : "";
const finalXs =
@@ -3389,10 +3399,10 @@ export class NostrClient {
description: contentObject.description,
url: contentObject.url,
magnet: contentObject.magnet,
thumbnail: contentObject.thumbnail,
mode: contentObject.mode,
isPrivate: contentObject.isPrivate,
isNsfw: contentObject.isNsfw,
thumbnail: contentObject.thumbnail,
mode: contentObject.mode,
isPrivate: wantPrivate,
isNsfw: contentObject.isNsfw,
isForKids: contentObject.isForKids,
};
+87 -6
View File
@@ -9,20 +9,61 @@ import {
import { buildRepostEvent, buildVideoMirrorEvent } from "../nostrEventSchemas.js";
import { normalizePointerInput } from "./watchHistory.js";
import { sanitizeRelayList } from "./nip46Client.js";
import { RELAY_URLS, getCachedNostrTools } from "./toolkit.js";
import {
RELAY_URLS,
getCachedNostrTools,
readToolkitFromScope,
} from "./toolkit.js";
import { DEFAULT_NIP07_PERMISSION_METHODS } from "./nip07Permissions.js";
import { devLogger, userLogger } from "../utils/logger.js";
const REBROADCAST_GUARD_PREFIX = "bitvid:rebroadcast:v1";
const rebroadcastAttemptMemory = new Map();
const HEX_PRIVATE_KEY_REGEX = /^[0-9a-f]{64}$/i;
export function signEventWithPrivateKey(event, privateKey) {
const tools = getCachedNostrTools();
const scopedTools = readToolkitFromScope();
const canonicalTools =
typeof globalThis !== "undefined" &&
globalThis.__BITVID_CANONICAL_NOSTR_TOOLS__
? globalThis.__BITVID_CANONICAL_NOSTR_TOOLS__
: null;
const signFn =
typeof canonicalTools?.signEvent === "function"
? canonicalTools.signEvent
: typeof tools?.signEvent === "function"
? tools.signEvent
: typeof scopedTools?.signEvent === "function"
? scopedTools.signEvent
: null;
// console.debug('signEventWithPrivateKey', {
// signFn: typeof signFn,
// scopedSign: typeof scopedTools?.signEvent,
// cachedSign: typeof tools?.signEvent,
// });
const finalizeFn =
typeof canonicalTools?.finalizeEvent === "function"
? canonicalTools.finalizeEvent
: typeof tools?.finalizeEvent === "function"
? tools.finalizeEvent
: typeof scopedTools?.finalizeEvent === "function"
? scopedTools.finalizeEvent
: null;
const hashFn =
typeof canonicalTools?.getEventHash === "function"
? canonicalTools.getEventHash
: typeof tools?.getEventHash === "function"
? tools.getEventHash
: typeof scopedTools?.getEventHash === "function"
? scopedTools.getEventHash
: null;
if (
!privateKey ||
typeof privateKey !== "string" ||
!tools?.getEventHash ||
typeof tools.signEvent !== "function"
(!signFn && !finalizeFn)
) {
throw new Error("Missing signing primitives");
}
@@ -38,10 +79,50 @@ export function signEventWithPrivateKey(event, privateKey) {
tags,
content: typeof event.content === "string" ? event.content : "",
};
const id = tools.getEventHash(prepared);
const sig = tools.signEvent(prepared, privateKey);
return { ...prepared, id, sig };
if (signFn) {
const signature = signFn(prepared, privateKey);
if (signature && typeof signature === "object") {
const idValue =
typeof signature.id === "string"
? signature.id
: hashFn
? hashFn(prepared)
: null;
const sigValue =
typeof signature.sig === "string"
? signature.sig
: typeof signature.signature === "string"
? signature.signature
: null;
if (!idValue || !sigValue) {
throw new Error("Missing signing primitives");
}
return { ...prepared, id: idValue, sig: sigValue };
}
const sigValue = typeof signature === "string" ? signature : null;
if (!sigValue || !hashFn) {
throw new Error("Missing signing primitives");
}
const idValue = hashFn(prepared);
return { ...prepared, id: idValue, sig: sigValue };
}
const normalizedKey = privateKey.trim();
if (!HEX_PRIVATE_KEY_REGEX.test(normalizedKey)) {
throw new Error("Missing signing primitives");
}
const finalized = finalizeFn(prepared, normalizedKey);
if (
!finalized ||
typeof finalized.id !== "string" ||
typeof finalized.sig !== "string"
) {
throw new Error("Missing signing primitives");
}
return finalized;
}
export function deriveRebroadcastBucketIndex(referenceSeconds = null) {
+91 -12
View File
@@ -88,7 +88,9 @@ function mergePointerDetails(target, source) {
target.watchedAt = Math.max(0, Math.floor(source.watchedAt));
}
if (typeof source.relay === "string" && source.relay.trim()) {
target.relay = source.relay.trim();
if (!target.relay || !target.relay.trim()) {
target.relay = source.relay.trim();
}
}
if (!target.metadata && source.metadata && typeof source.metadata === "object") {
target.metadata = clonePointerMetadata(source.metadata);
@@ -175,11 +177,7 @@ export function pointerKey(pointer) {
if (!type || !value) {
return "";
}
const relay =
typeof pointer.relay === "string" && pointer.relay.trim()
? pointer.relay.trim().toLowerCase()
: "";
return relay ? `${type}:${value}:${relay}` : `${type}:${value}`;
return `${type}:${value}`;
}
function normalizePointerTag(tag) {
@@ -466,7 +464,10 @@ export function normalizeActorKey(actor) {
if (/^[0-9a-f]{64}$/i.test(trimmed)) {
return trimmed.toLowerCase();
}
const decoder = getCachedNostrTools()?.nip19?.decode;
const toolkit = getCachedNostrTools();
const decoder =
(typeof window !== "undefined" ? window?.NostrTools?.nip19?.decode : null) ||
toolkit?.nip19?.decode;
if (typeof decoder === "function") {
try {
const decoded = decoder(trimmed);
@@ -729,7 +730,11 @@ export function isNip04EncryptedWatchHistoryEvent(pointerEvent, ciphertext) {
class WatchHistoryManager {
constructor(deps) {
this.deps = deps || {};
this.deps = {
ensureNostrTools,
getCachedNostrTools,
...(deps || {}),
};
this.cache = new Map();
this.storage = null;
this.republishTimers = new Map();
@@ -1225,11 +1230,51 @@ class WatchHistoryManager {
if (cachedNip04Tools) {
return cachedNip04Tools;
}
const tools = await ensureNostrTools();
if (tools?.nip04 && typeof tools.nip04.encrypt === "function") {
cachedNip04Tools = tools;
return cachedNip04Tools;
const ensureToolkit =
typeof this.deps.ensureNostrTools === "function"
? this.deps.ensureNostrTools
: ensureNostrTools;
if (ensureToolkit) {
try {
const ensured = await ensureToolkit();
if (
ensured?.nip04 &&
typeof ensured.nip04.encrypt === "function"
) {
cachedNip04Tools = ensured;
return cachedNip04Tools;
}
} catch (error) {
devLogger.warn(
"[nostr] Failed to resolve nostr-tools for watch history:",
error,
);
}
}
const readCachedToolkit =
typeof this.deps.getCachedNostrTools === "function"
? this.deps.getCachedNostrTools
: getCachedNostrTools;
if (readCachedToolkit) {
try {
const cached = await readCachedToolkit();
if (
cached?.nip04 &&
typeof cached.nip04.encrypt === "function"
) {
cachedNip04Tools = cached;
return cachedNip04Tools;
}
} catch (error) {
devLogger.warn(
"[nostr] Failed to read cached nostr-tools for watch history:",
error,
);
}
}
return null;
};
const encryptChunk = async (plaintext) => {
@@ -1483,6 +1528,40 @@ class WatchHistoryManager {
if (!success && errorCode) {
result.error = errorCode;
}
const shouldPersistLocally =
pointerAcceptedCount > 0 && !chunkRejectedEverywhere;
if (shouldPersistLocally) {
const storage = this.getStorage();
const previousEntry =
this.cache.get(actorKey) || storage.actors?.[actorKey] || {};
const metadata = sanitizeWatchHistoryMetadata(previousEntry.metadata);
metadata.updatedAt = Date.now();
metadata.status = success ? "ok" : "partial";
metadata.lastPublishResults = result.publishResults;
metadata.skippedCount = result.skippedCount || 0;
if (success) {
delete metadata.lastError;
} else {
metadata.lastError = result.error || "publish-partial";
}
const entry = {
actor: actorPubkey,
items: canonicalItems,
snapshotId,
pointerEvent: result.pointerEvent,
chunkEvents: result.chunkEvents,
savedAt: Date.now(),
fingerprint: await this.getFingerprint(actorPubkey, canonicalItems),
metadata,
};
this.cache.set(actorKey, entry);
this.persistEntry(actorKey, entry);
}
devLogger.info("[nostr] Watch history snapshot publish result.", {
actor: actorKey,
snapshotId,
+51 -1
View File
@@ -5,7 +5,14 @@ if (typeof globalThis.window === "undefined") {
globalThis.window = {};
}
if (!window.crypto || !window.crypto.getRandomValues) {
const { webcrypto } = await import("node:crypto");
window.crypto = webcrypto;
}
const originalWindowNostr = window.nostr;
const originalWindowNostrTools = window.NostrTools;
const nostrToolsModule = await import("nostr-tools");
window.nostr = {
...(originalWindowNostr || {}),
@@ -20,12 +27,43 @@ window.nostr = {
},
};
window.NostrTools = {
...(originalWindowNostrTools || {}),
...nostrToolsModule,
generatePrivateKey: () => {
const raw = nostrToolsModule.generateSecretKey();
const bytes = raw instanceof Uint8Array ? raw : Uint8Array.from(raw || []);
return Array.from(bytes)
.map((byte) => byte.toString(16).padStart(2, "0"))
.join("");
},
getEventHash: nostrToolsModule.getEventHash,
signEvent: (event, priv) => {
const signed = nostrToolsModule.finalizeEvent(event, priv);
return signed.sig;
},
};
let activeSignerCleanup = null;
try {
const { RelayPublishError } = await import("../js/nostrPublish.js");
const { nostrClient } = await import("../js/nostr.js");
const { nostrClient, setActiveSigner, clearActiveSigner } = await import("../js/nostr.js");
const { subscriptions } = await import("../js/subscriptions.js");
const { userBlocks } = await import("../js/userBlocks.js");
setActiveSigner({
type: "session",
pubkey: "f".repeat(64),
signEvent: async (event) => ({
...event,
id: event?.id || `signed-${event.kind}`,
sig: `sig-${event.kind}`,
}),
nip04Encrypt: async (_pubkey, plaintext) => `cipher:${plaintext}`,
});
activeSignerCleanup = clearActiveSigner;
function createRejectingPool(reason = "relay rejected") {
const calls = [];
return {
@@ -245,9 +283,21 @@ try {
console.log("nostr publish rejection tests passed");
} finally {
if (typeof activeSignerCleanup === "function") {
try {
activeSignerCleanup();
} catch (_) {
// ignore cleanup errors
}
}
if (typeof originalWindowNostr === "undefined") {
delete window.nostr;
} else {
window.nostr = originalWindowNostr;
}
if (typeof originalWindowNostrTools === "undefined") {
delete window.NostrTools;
} else {
window.NostrTools = originalWindowNostrTools;
}
}
@@ -0,0 +1,310 @@
import "../test-helpers/setup-localstorage.mjs";
import assert from "node:assert/strict";
import test from "node:test";
import { webcrypto } from "node:crypto";
import {
finalizeEvent,
generateSecretKey,
getEventHash,
getPublicKey,
nip04,
} from "nostr-tools";
const {
nostrClient,
clearActiveSigner,
} = await import("../../js/nostr.js");
const { accessControl } = await import("../../js/accessControl.js");
const { WATCH_HISTORY_KIND } = await import("../../js/config.js");
const { normalizeActorKey } = await import("../../js/nostr/watchHistory.js");
const { VIEW_EVENT_KIND } = await import("../../js/nostr/viewEvents.js");
const { watchHistoryService } = await import("../../js/watchHistoryService.js");
function finalizeWithKey(eventTemplate, secretKey, pubkeyOverride = null) {
const tags = Array.isArray(eventTemplate?.tags)
? eventTemplate.tags.map((tag) => Array.isArray(tag) ? [...tag] : [])
: [];
const base = {
kind: Number.isFinite(eventTemplate?.kind)
? eventTemplate.kind
: 1,
content: typeof eventTemplate?.content === "string"
? eventTemplate.content
: "",
created_at: Number.isFinite(eventTemplate?.created_at)
? Math.floor(eventTemplate.created_at)
: Math.floor(Date.now() / 1000),
tags,
pubkey:
typeof pubkeyOverride === "string" && pubkeyOverride
? pubkeyOverride
: typeof eventTemplate?.pubkey === "string" && eventTemplate.pubkey
? eventTemplate.pubkey
: getPublicKey(secretKey),
};
return finalizeEvent(base, secretKey);
}
test("nostr login + remote signing + publish + watch history integration", async (t) => {
const originalWindow = globalThis.window;
const windowRef = originalWindow || {};
globalThis.window = windowRef;
if (!windowRef.crypto || !windowRef.crypto.subtle) {
windowRef.crypto = webcrypto;
}
const originalNostr = windowRef.nostr;
const originalNostrTools = windowRef.NostrTools;
const originalGlobalNostrTools = globalThis.NostrTools;
const originalCanonicalTools = globalThis.__BITVID_CANONICAL_NOSTR_TOOLS__;
const originalToolsReady = globalThis.nostrToolsReady;
const originalPool = nostrClient.pool;
const originalRelays = Array.isArray(nostrClient.relays)
? [...nostrClient.relays]
: nostrClient.relays;
const originalReadRelays = Array.isArray(nostrClient.readRelays)
? [...nostrClient.readRelays]
: nostrClient.readRelays;
const originalWriteRelays = Array.isArray(nostrClient.writeRelays)
? [...nostrClient.writeRelays]
: nostrClient.writeRelays;
const originalPubkey = nostrClient.pubkey;
const originalSessionActor = nostrClient.sessionActor;
const originalWatchHistoryCacheEntries = Array.from(
nostrClient.watchHistory?.cache?.entries?.() || [],
);
const originalWatchHistoryStorage = nostrClient.watchHistory?.storage;
const userSecret = generateSecretKey();
const userPubkey = getPublicKey(userSecret);
let extensionSignCount = 0;
let remoteSignCount = 0;
let remoteEncryptCount = 0;
const publishedEvents = [];
const poolStub = {
publish(urls, event) {
publishedEvents.push({
urls: Array.isArray(urls) ? [...urls] : [],
event,
});
return {
on(eventName, handler) {
if (eventName === "ok") {
setTimeout(() => handler(), 0);
}
return this;
},
};
},
sub() {
return { on() {}, unsub() {} };
},
close() {},
};
nostrClient.pool = poolStub;
nostrClient.relays = ["wss://relay.integration"];
nostrClient.readRelays = [...nostrClient.relays];
nostrClient.writeRelays = [...nostrClient.relays];
nostrClient.watchHistory.cache?.clear?.();
nostrClient.watchHistory.fingerprints?.clear?.();
nostrClient.watchHistory.republishTimers?.forEach?.((entry) => {
if (entry?.timer) {
clearTimeout(entry.timer);
}
});
nostrClient.watchHistory.republishTimers?.clear?.();
nostrClient.watchHistory.storage = null;
watchHistoryService.resetProgress?.();
if (typeof localStorage?.clear === "function") {
localStorage.clear();
}
const toolkit = {
finalizeEvent,
getEventHash,
getPublicKey,
nip04,
nip19: {
npubEncode: (hex) => `npub${hex}`,
},
};
windowRef.NostrTools = toolkit;
globalThis.NostrTools = toolkit;
globalThis.__BITVID_CANONICAL_NOSTR_TOOLS__ = toolkit;
globalThis.nostrToolsReady = Promise.resolve(toolkit);
const userNpub = toolkit.nip19.npubEncode(userPubkey);
const originalAccessState = {
whitelistEnabled: accessControl.whitelistEnabled,
whitelist: new Set(accessControl.whitelist),
blacklist: new Set(accessControl.blacklist),
editors: new Set(accessControl.editors),
};
accessControl.whitelistEnabled = false;
accessControl.whitelist = new Set([userNpub]);
accessControl.blacklist = new Set();
accessControl.editors = new Set([userNpub]);
windowRef.nostr = {
enable: () => Promise.resolve(),
getPublicKey: () => Promise.resolve(userPubkey),
signEvent: async (event) => {
extensionSignCount += 1;
return finalizeWithKey(event, userSecret, userPubkey);
},
nip04: {
encrypt: (pubkey, plaintext) => nip04.encrypt(userSecret, pubkey, plaintext),
decrypt: (pubkey, ciphertext) => nip04.decrypt(userSecret, pubkey, ciphertext),
},
};
t.after(() => {
clearActiveSigner();
accessControl.whitelistEnabled = originalAccessState.whitelistEnabled;
accessControl.whitelist = new Set(originalAccessState.whitelist);
accessControl.blacklist = new Set(originalAccessState.blacklist);
accessControl.editors = new Set(originalAccessState.editors);
nostrClient.pool = originalPool;
nostrClient.relays = originalRelays;
nostrClient.readRelays = originalReadRelays;
nostrClient.writeRelays = originalWriteRelays;
nostrClient.pubkey = originalPubkey;
nostrClient.sessionActor = originalSessionActor;
nostrClient.watchHistory.cache = new Map(originalWatchHistoryCacheEntries);
nostrClient.watchHistory.storage = originalWatchHistoryStorage || null;
nostrClient.watchHistory.fingerprints?.clear?.();
nostrClient.watchHistory.republishTimers?.forEach?.((entry) => {
if (entry?.timer) {
clearTimeout(entry.timer);
}
});
nostrClient.watchHistory.republishTimers?.clear?.();
watchHistoryService.resetProgress?.();
if (typeof localStorage?.clear === "function") {
localStorage.clear();
}
if (originalNostr === undefined) {
delete windowRef.nostr;
} else {
windowRef.nostr = originalNostr;
}
if (originalNostrTools === undefined) {
delete windowRef.NostrTools;
} else {
windowRef.NostrTools = originalNostrTools;
}
if (originalGlobalNostrTools === undefined) {
delete globalThis.NostrTools;
} else {
globalThis.NostrTools = originalGlobalNostrTools;
}
if (originalCanonicalTools === undefined) {
delete globalThis.__BITVID_CANONICAL_NOSTR_TOOLS__;
} else {
globalThis.__BITVID_CANONICAL_NOSTR_TOOLS__ = originalCanonicalTools;
}
if (originalToolsReady === undefined) {
delete globalThis.nostrToolsReady;
} else {
globalThis.nostrToolsReady = originalToolsReady;
}
if (!originalWindow) {
delete globalThis.window;
}
});
const loginResult = await nostrClient.login();
assert.equal(loginResult, userPubkey);
extensionSignCount = 0;
const remoteSigner = {
type: "nip46",
pubkey: userPubkey,
async signEvent(event) {
remoteSignCount += 1;
return finalizeWithKey(event, userSecret, userPubkey);
},
async nip04Encrypt(pubkey, plaintext) {
remoteEncryptCount += 1;
return nip04.encrypt(userSecret, pubkey, plaintext);
},
nip04Decrypt(pubkey, ciphertext) {
return nip04.decrypt(userSecret, pubkey, ciphertext);
},
};
nostrClient.installNip46Client(
{
getActiveSigner: () => remoteSigner,
destroy() {},
},
{ userPubkey },
);
const viewPointer = {
type: "e",
value: "view-event-integration",
watchedAt: 1_700_000_123,
};
const viewResult = await nostrClient.recordVideoView(viewPointer, {
created_at: 1_700_000_125,
});
assert.equal(viewResult.ok, true);
const publishResult = await nostrClient.publishVideo(
{
legacyFormData: {
title: "Integration Remote Video",
description: "remote signer flow",
magnet: "magnet:?xt=urn:btih:REMOTEINTEGRATION123456789",
mode: "live",
},
},
userPubkey,
);
assert.equal(typeof publishResult?.id, "string");
const snapshotItems = [
{ type: "e", value: viewPointer.value, watchedAt: viewPointer.watchedAt },
];
const snapshotResult = await nostrClient.publishWatchHistorySnapshot(
snapshotItems,
{ actorPubkey: userPubkey, snapshotId: "integration-snapshot" },
);
assert.equal(snapshotResult.ok, true);
assert.equal(extensionSignCount, 0, "extension signer should not handle publish operations");
assert(remoteSignCount > 0, "remote signer should sign nostr events");
assert(remoteEncryptCount > 0, "remote signer should encrypt watch history payloads");
const publishedKinds = publishedEvents.map((entry) => entry.event?.kind);
assert(
publishedKinds.some((kind) => kind === VIEW_EVENT_KIND),
"view events should be published",
);
assert(
publishedKinds.some((kind) => kind === WATCH_HISTORY_KIND),
"watch history events should be published",
);
const actorKey = normalizeActorKey(userPubkey);
const cachedEntry = nostrClient.watchHistory.cache.get(actorKey);
assert.ok(cachedEntry, "watch history cache should contain the published snapshot");
assert.equal(cachedEntry.items.length, 1);
assert.equal(cachedEntry.items[0]?.value, viewPointer.value);
const latest = await watchHistoryService.loadLatest(userPubkey);
assert(Array.isArray(latest), "watch history service should resolve an array");
});
+134
View File
@@ -1,3 +1,4 @@
import "../test-helpers/setup-localstorage.mjs";
import assert from "node:assert/strict";
import test from "node:test";
@@ -304,3 +305,136 @@ test("fetchWatchHistory falls back to pointer payload when nip04 decrypt fails",
manager.clear();
}
});
test("publishWatchHistorySnapshot uses injected nostr-tools helpers when signer cannot encrypt", async () => {
const actorPubkey = "f".repeat(64);
const sessionPrivateKey = "a".repeat(64);
let encryptCalls = 0;
const toolkit = {
nip04: {
encrypt: async (_priv, _pub, plaintext) => {
encryptCalls += 1;
return `encrypted:${plaintext}`;
},
},
};
const manager = createWatchHistoryManager({
getActivePubkey: () => actorPubkey,
getSessionActor: () => ({ pubkey: actorPubkey, privateKey: sessionPrivateKey }),
ensureSessionActor: async () => actorPubkey,
resolveActiveSigner: () => null,
shouldRequestExtensionPermissions: () => false,
ensureExtensionPermissions: async () => ({ ok: true }),
ensureNostrTools: async () => toolkit,
getCachedNostrTools: () => toolkit,
signEventWithPrivateKey: (event) => ({
...event,
id: `signed-${event.kind}`,
sig: `sig-${event.kind}`,
}),
getReadRelays: () => ["wss://relay.integration"],
getWriteRelays: () => ["wss://relay.integration"],
getRelayFallback: () => ["wss://relay.integration"],
getPool: () => ({
publish: () => ({
on(eventName, handler) {
if (eventName === "ok") {
handler();
}
return this;
},
}),
}),
});
try {
const result = await manager.publishSnapshot(
[{ type: "e", value: "pointer", watchedAt: 123 }],
{ actorPubkey, snapshotId: "snapshot-injected" },
);
assert.equal(
result.ok,
true,
"snapshot publish should succeed with injected toolkit helpers",
);
assert(
encryptCalls > 0,
"fallback nostr-tools encrypt helper should be invoked",
);
} finally {
manager.clear();
}
});
test("publishWatchHistorySnapshot caches successful snapshot results", async () => {
const actorPubkey = "c".repeat(64);
const sessionPrivateKey = "s".repeat(64);
const relayUrls = ["wss://relay.cache"];
const publishCalls = [];
const manager = createWatchHistoryManager({
getActivePubkey: () => actorPubkey,
getSessionActor: () => ({ pubkey: actorPubkey, privateKey: sessionPrivateKey }),
ensureSessionActor: async () => actorPubkey,
resolveActiveSigner: () => null,
shouldRequestExtensionPermissions: () => false,
signEventWithPrivateKey: async (event, key) => ({
...event,
id: `${event.kind}:${event.created_at}:${event.content?.length || 0}`,
sig: `sig-${key}`,
}),
ensureNostrTools: async () => ({
nip04: {
encrypt: async (priv, pub, plaintext) => `enc:${priv}:${pub}:${plaintext}`,
},
}),
getWriteRelays: () => relayUrls,
getRelayFallback: () => relayUrls,
getPool: () => ({
publish(urls, event) {
publishCalls.push({ urls, event });
return {
on(eventName, handler) {
if (eventName === "ok") {
setTimeout(handler, 0);
}
return this;
},
};
},
}),
});
try {
const items = [{ type: "e", value: "cached-pointer", watchedAt: 234 }];
const result = await manager.publishSnapshot(items, {
actorPubkey,
relays: relayUrls,
snapshotId: "snapshot-cache",
});
assert.equal(result.ok, true, "snapshot publish should succeed");
assert(publishCalls.length > 0, "publish helper should be invoked");
const actorKey = actorPubkey.toLowerCase();
const cacheEntry = manager.cache.get(actorKey);
assert.ok(cacheEntry, "cache entry should be stored for the actor");
assert.equal(cacheEntry.items.length, 1, "cache should retain canonical items");
assert.equal(cacheEntry.items[0]?.value, "cached-pointer");
assert.equal(cacheEntry.snapshotId, result.snapshotId);
assert.deepStrictEqual(cacheEntry.items, result.items);
const storage = manager.getStorage();
assert.ok(storage.actors[actorKey], "storage should persist the cache entry");
assert.equal(storage.actors[actorKey].snapshotId, result.snapshotId);
assert.equal(storage.actors[actorKey].items.length, 1);
const fingerprint = manager.fingerprints.get(actorKey);
assert.ok(fingerprint, "fingerprint cache should be populated");
assert.equal(fingerprint, cacheEntry.fingerprint);
} finally {
manager.clear();
}
});
+90 -16
View File
@@ -17,7 +17,10 @@ const {
getWatchHistoryV2Enabled,
setWatchHistoryV2Enabled,
} = await import("../js/constants.js");
const { nostrClient } = await import("../js/nostr.js");
const { nostrClient, setActiveSigner, getActiveSigner, clearActiveSigner } = await import(
"../js/nostr.js",
);
const { rememberNostrTools } = await import("../js/nostr/toolkit.js");
const { normalizeActorKey } = await import("../js/nostr/watchHistory.js");
const { watchHistoryService } = await import("../js/watchHistoryService.js");
const { buildHistoryCard } = await import("../js/historyView.js");
@@ -39,6 +42,10 @@ setWatchHistoryV2Enabled(true);
const originalWindowNostr = window.nostr;
const originalNostrTools = window.NostrTools || {};
const originalWatchHistoryEnsureTools =
nostrClient.watchHistory?.deps?.ensureNostrTools || null;
const originalWatchHistoryGetCachedTools =
nostrClient.watchHistory?.deps?.getCachedNostrTools || null;
const originalPool = nostrClient.pool;
const originalRelays = Array.isArray(nostrClient.relays)
? [...nostrClient.relays]
@@ -302,19 +309,30 @@ nostrClient.relays = ["wss://relay.test"];
nostrClient.readRelays = ["wss://relay.test"];
nostrClient.writeRelays = ["wss://relay.test"];
function installSessionCrypto({ privateKey }) {
async function installSessionCrypto({ privateKey }) {
const original = window.NostrTools || {};
let encryptCalls = 0;
let decryptCalls = 0;
const { createHash, randomBytes } = await import("node:crypto");
const deriveHex = (input) =>
createHash("sha256").update(String(input ?? ""), "utf8").digest("hex");
const previousEnsure =
nostrClient.watchHistory?.deps?.ensureNostrTools || null;
const previousGetCached =
nostrClient.watchHistory?.deps?.getCachedNostrTools || null;
window.NostrTools = {
...original,
generatePrivateKey: () => randomBytes(32).toString("hex"),
getPublicKey: (secret) => {
const normalized = typeof secret === "string" ? secret.trim() : "";
if (!normalized) {
throw new Error("missing-secret");
}
return deriveHex(`pub:${normalized}`);
},
getEventHash: (event) =>
`hash-${event.kind}-${event.created_at}-${event.tags?.length || 0}`,
signEvent: (event, key) => ({
...event,
id: `signed-${event.kind}-${event.created_at}-${event.tags?.length || 0}`,
sig: `sig-${key}`,
}),
signEvent: (_event, key) => `sig-${key}`,
nip04: {
...(original.nip04 || {}),
encrypt: async (secret, pub, plaintext) => {
@@ -333,9 +351,28 @@ function installSessionCrypto({ privateKey }) {
},
},
};
rememberNostrTools(window.NostrTools);
globalThis.__BITVID_CANONICAL_NOSTR_TOOLS__ = window.NostrTools;
globalThis.nostrToolsReady = Promise.resolve(window.NostrTools);
if (nostrClient.watchHistory?.deps) {
nostrClient.watchHistory.deps.ensureNostrTools = async () => window.NostrTools;
nostrClient.watchHistory.deps.getCachedNostrTools = () => window.NostrTools;
}
return {
restore() {
window.NostrTools = original;
if (nostrClient.watchHistory?.deps) {
if (previousEnsure) {
nostrClient.watchHistory.deps.ensureNostrTools = previousEnsure;
} else {
delete nostrClient.watchHistory.deps.ensureNostrTools;
}
if (previousGetCached) {
nostrClient.watchHistory.deps.getCachedNostrTools = previousGetCached;
} else {
delete nostrClient.watchHistory.deps.getCachedNostrTools;
}
}
},
getEncryptCalls: () => encryptCalls,
getDecryptCalls: () => decryptCalls,
@@ -396,8 +433,24 @@ function installExtensionCrypto({ actor }) {
},
},
};
rememberNostrTools(window.NostrTools);
globalThis.__BITVID_CANONICAL_NOSTR_TOOLS__ = window.NostrTools;
globalThis.nostrToolsReady = Promise.resolve(window.NostrTools);
const previousSigner = getActiveSigner();
setActiveSigner({
type: "extension",
pubkey: actor,
signEvent: window.nostr.signEvent,
nip04Encrypt: window.nostr.nip04.encrypt,
nip04Decrypt: window.nostr.nip04.decrypt,
});
return {
restore() {
if (previousSigner) {
setActiveSigner(previousSigner);
} else {
clearActiveSigner();
}
window.nostr = originalNostr;
window.NostrTools = originalTools;
},
@@ -644,7 +697,7 @@ async function testFetchWatchHistoryExtensionDecryptsHexAndNpub() {
const runVariant = async (label, actorInput, pubkeyInput) => {
await publishEvents();
const sessionCrypto = installSessionCrypto({ privateKey: `session-${label}` });
const sessionCrypto = await installSessionCrypto({ privateKey: `session-${label}` });
const extensionCrypto = installExtensionCrypto({ actor: actorHex });
const previousEnsure = nostrClient.ensureSessionActor;
@@ -727,7 +780,7 @@ async function testPublishSnapshotCanonicalizationAndChunking() {
poolHarness.setResolver(() => ({ ok: true }));
const actor = "session-pubkey";
const restoreCrypto = installSessionCrypto({ privateKey: "session-priv" });
const restoreCrypto = await installSessionCrypto({ privateKey: "session-priv" });
const originalEnsure = nostrClient.ensureSessionActor;
const originalSession = nostrClient.sessionActor;
@@ -756,6 +809,9 @@ async function testPublishSnapshotCanonicalizationAndChunking() {
{ type: "e", value: "pointer-small", watchedAt: 80 },
];
const { getCachedNostrTools } = await import("../js/nostr/toolkit.js");
getCachedNostrTools();
const firstResult = await nostrClient.publishWatchHistorySnapshot(rawItems, {
actorPubkey: actor,
snapshotId: "session-snapshot",
@@ -890,7 +946,7 @@ async function testPublishSnapshotUsesExtensionCrypto() {
poolHarness.setResolver(() => ({ ok: true }));
const actor = "ext-pubkey";
const sessionRestore = installSessionCrypto({ privateKey: "session-priv" });
const sessionRestore = await installSessionCrypto({ privateKey: "session-priv" });
const extension = installExtensionCrypto({ actor });
const originalEnsure = nostrClient.ensureSessionActor;
const originalSession = nostrClient.sessionActor;
@@ -1013,7 +1069,7 @@ async function testPublishSnapshotFailureRetry() {
nostrClient.watchHistoryLastCreatedAt = 0;
const actor = "retry-actor";
const restoreCrypto = installSessionCrypto({ privateKey: "retry-priv" });
const restoreCrypto = await installSessionCrypto({ privateKey: "retry-priv" });
const originalEnsure = nostrClient.ensureSessionActor;
const originalSession = nostrClient.sessionActor;
const originalPub = nostrClient.pubkey;
@@ -1070,7 +1126,7 @@ async function testWatchHistoryPartialRelayRetry() {
nostrClient.watchHistoryLastCreatedAt = 0;
const actor = "partial-actor";
const restoreCrypto = installSessionCrypto({ privateKey: "partial-priv" });
const restoreCrypto = await installSessionCrypto({ privateKey: "partial-priv" });
const originalEnsure = nostrClient.ensureSessionActor;
const originalSession = nostrClient.sessionActor;
const originalPub = nostrClient.pubkey;
@@ -1270,10 +1326,11 @@ async function testWatchHistoryPartialRelayRetry() {
}
async function testResolveWatchHistoryBatchingWindow() {
const actor = "batch-window-actor";
const actor = "b".repeat(64);
const originalEnsure = nostrClient.ensureSessionActor;
const originalPub = nostrClient.pubkey;
const originalFetch = nostrClient.fetchWatchHistory;
const originalManagerFetch = nostrClient.watchHistory.fetch;
const originalCache = nostrClient.watchHistoryCache;
const originalStorage = nostrClient.watchHistoryStorage;
@@ -1289,11 +1346,13 @@ async function testResolveWatchHistoryBatchingWindow() {
watchedAt: 1_700_100_000 + index,
}));
nostrClient.fetchWatchHistory = async () => ({
const fetchResult = {
items: syntheticItems.map((item) => ({ ...item })),
snapshotId: "batch-snapshot",
pointerEvent: null,
});
};
nostrClient.fetchWatchHistory = async () => fetchResult;
nostrClient.watchHistory.fetch = async () => fetchResult;
const resolved = await nostrClient.resolveWatchHistory(actor, {
forceRefresh: true,
@@ -1332,6 +1391,7 @@ async function testResolveWatchHistoryBatchingWindow() {
nostrClient.ensureSessionActor = originalEnsure;
nostrClient.pubkey = originalPub;
nostrClient.fetchWatchHistory = originalFetch;
nostrClient.watchHistory.fetch = originalManagerFetch;
nostrClient.watchHistoryCache = originalCache;
nostrClient.watchHistoryStorage = originalStorage;
}
@@ -1351,7 +1411,7 @@ async function testWatchHistoryServiceIntegration() {
infoHash: "89abcdef0123456789abcdef0123456789abcdef",
legacyInfoHash: "89abcdef0123456789abcdef0123456789abcdef",
};
const restoreCrypto = installSessionCrypto({ privateKey: "service-priv" });
const restoreCrypto = await installSessionCrypto({ privateKey: "service-priv" });
const originalEnsure = nostrClient.ensureSessionActor;
const originalSession = nostrClient.sessionActor;
const originalPub = nostrClient.pubkey;
@@ -2143,6 +2203,20 @@ console.log("watch-history.test.mjs completed successfully");
window.nostr = originalWindowNostr;
window.NostrTools = originalNostrTools;
if (nostrClient.watchHistory?.deps) {
if (originalWatchHistoryEnsureTools) {
nostrClient.watchHistory.deps.ensureNostrTools =
originalWatchHistoryEnsureTools;
} else {
delete nostrClient.watchHistory.deps.ensureNostrTools;
}
if (originalWatchHistoryGetCachedTools) {
nostrClient.watchHistory.deps.getCachedNostrTools =
originalWatchHistoryGetCachedTools;
} else {
delete nostrClient.watchHistory.deps.getCachedNostrTools;
}
}
nostrClient.pool = originalPool;
nostrClient.relays = originalRelays;
nostrClient.readRelays = originalReadRelays;