feat(services): add window.nostr fallback for hashtag preference decryption

This commit updates `HashtagPreferencesService.decryptEvent` to register and utilize decryption methods from `window.nostr` (NIP-04 and NIP-44) as a fallback mechanism. This ensures that users without an active internal signer (e.g., read-only or extension-only users) can still decrypt their preferences if they have granted permission to the extension.

- Mirrors the decryption logic from `UserBlockListManager` (`js/userBlocks.js`).
- Adds logging for fallback attempts and failures.
- Preserves existing scheme selection logic (`determineDecryptionOrder`).
- Adds new test cases to `tests/hashtag-preferences.test.mjs` verifying fallback behavior when the active signer is missing or incapable.

Co-authored-by: PR0M3TH3AN <[email protected]>
This commit is contained in:
google-labs-jules[bot]andPR0M3TH3AN committed 2026-01-27 23:46:23 +00:00
1 parent c4edfd0d36
commit 39d1eda058
2 files changed
+165 -5

No files matched your search

+73 -5
View File
@@ -726,22 +726,76 @@ class HashtagPreferencesService {
}
const decryptors = new Map();
const registerDecryptor = (scheme, handler) => {
const sources = new Map();
const registerDecryptor = (scheme, handler, source = "unknown") => {
if (!scheme || typeof handler !== "function" || decryptors.has(scheme)) {
return;
}
decryptors.set(scheme, handler);
sources.set(scheme, source);
};
if (signerHasNip44) {
registerDecryptor("nip44", (payload) => signer.nip44Decrypt(userPubkey, payload));
registerDecryptor("nip44_v2", (payload) =>
signer.nip44Decrypt(userPubkey, payload),
registerDecryptor(
"nip44",
(payload) => signer.nip44Decrypt(userPubkey, payload),
"active-signer",
);
registerDecryptor(
"nip44_v2",
(payload) => signer.nip44Decrypt(userPubkey, payload),
"active-signer",
);
}
if (signerHasNip04) {
registerDecryptor("nip04", (payload) => signer.nip04Decrypt(userPubkey, payload));
registerDecryptor(
"nip04",
(payload) => signer.nip04Decrypt(userPubkey, payload),
"active-signer",
);
}
const nostrApi = typeof window !== "undefined" ? window?.nostr : null;
if (nostrApi) {
if (typeof nostrApi.nip04?.decrypt === "function") {
registerDecryptor(
"nip04",
(payload) => nostrApi.nip04.decrypt(userPubkey, payload),
"extension",
);
}
const nip44 =
nostrApi.nip44 && typeof nostrApi.nip44 === "object"
? nostrApi.nip44
: null;
if (nip44) {
if (typeof nip44.decrypt === "function") {
registerDecryptor(
"nip44",
(payload) => nip44.decrypt(userPubkey, payload),
"extension",
);
}
const nip44v2 =
nip44.v2 && typeof nip44.v2 === "object" ? nip44.v2 : null;
if (nip44v2 && typeof nip44v2.decrypt === "function") {
registerDecryptor(
"nip44_v2",
(payload) => nip44v2.decrypt(userPubkey, payload),
"extension",
);
if (!decryptors.has("nip44")) {
registerDecryptor(
"nip44",
(payload) => nip44v2.decrypt(userPubkey, payload),
"extension",
);
}
}
}
}
if (!decryptors.size) {
@@ -760,6 +814,14 @@ class HashtagPreferencesService {
if (!decryptFn) {
continue;
}
const source = sources.get(scheme);
if (source === "extension") {
userLogger.info(
`${LOG_PREFIX} Attempting decryption via window.nostr fallback (${scheme})`,
);
}
try {
const plaintext = await decryptFn(ciphertext);
if (typeof plaintext !== "string") {
@@ -771,6 +833,12 @@ class HashtagPreferencesService {
}
return { ok: true, plaintext, scheme };
} catch (error) {
if (source === "extension") {
userLogger.warn(
`${LOG_PREFIX} window.nostr fallback decryption failed (${scheme})`,
error,
);
}
attemptErrors.push({ scheme, error });
}
}
+92
View File
@@ -463,3 +463,95 @@ test(
},
);
test(
"load falls back to window.nostr when active signer is missing",
{ concurrency: false },
async (t) => {
const pubkey = "abcdef".repeat(10) + "1234";
// Ensure no active signer (override beforeEach)
clearActiveSigner();
// Mock window.nostr
window.nostr = {
nip04: {
decrypt: async (pk, ciphertext) => {
if (pk !== pubkey) throw new Error("Wrong pubkey");
if (ciphertext === "window-encrypted") {
return JSON.stringify({
version: 1,
interests: ["WindowSuccess"],
disinterests: []
});
}
throw new Error("Decrypt failed");
}
}
};
// Mock pool list to return an event
nostrClient.pool = {
list: async () => [{
id: "evt1",
created_at: 1000,
pubkey,
content: "window-encrypted",
tags: [["encrypted", "nip04"]]
}]
};
relayManager.setEntries([{ url: "wss://relay.fallback", mode: "both" }], { allowEmpty: false, updateClient: false });
await hashtagPreferences.load(pubkey);
assert.deepEqual(hashtagPreferences.getInterests(), ["windowsuccess"]);
}
);
test(
"load falls back to window.nostr when active signer lacks decrypt capabilities",
{ concurrency: false },
async (t) => {
const pubkey = "abcdef".repeat(10) + "5678";
// Set a signer that can sign but NOT decrypt (override beforeEach)
setActiveSigner({
signEvent: async (e) => ({ ...e, id: "fake" }),
// No nip04Decrypt or nip44Decrypt
});
// Mock window.nostr
window.nostr = {
nip04: {
decrypt: async (pk, ciphertext) => {
if (pk !== pubkey) throw new Error("Wrong pubkey");
if (ciphertext === "window-encrypted-2") {
return JSON.stringify({
version: 1,
interests: ["SignerFallback"],
disinterests: []
});
}
throw new Error("Decrypt failed");
}
}
};
// Mock pool list to return an event
nostrClient.pool = {
list: async () => [{
id: "evt2",
created_at: 1000,
pubkey,
content: "window-encrypted-2",
tags: [["encrypted", "nip04"]]
}]
};
relayManager.setEntries([{ url: "wss://relay.fallback", mode: "both" }], { allowEmpty: false, updateClient: false });
await hashtagPreferences.load(pubkey);
assert.deepEqual(hashtagPreferences.getInterests(), ["signerfallback"]);
}
);