diff --git a/docs/nostr-event-schemas.md b/docs/nostr-event-schemas.md index bd29f337..4931d099 100644 --- a/docs/nostr-event-schemas.md +++ b/docs/nostr-event-schemas.md @@ -42,6 +42,31 @@ When managing relay metadata, use `buildRelayListEvent` so the resulting replaceable event follows NIP-65 (`kind:10002`) with `"r"` tags describing the read/write split. +### Active signer registry + +Authentication providers should register their capabilities with the +`nostrClient` after login so every publish helper can reuse them: + +```js +import { setActiveSigner } from "./nostr.js"; + +setActiveSigner({ + type: "extension", // optional label, used to request NIP-07 permissions + pubkey, // hex or npub of the active account + signEvent: (event) => extension.signEvent(event), + nip04Encrypt: (targetHex, plaintext) => extension.nip04.encrypt(targetHex, plaintext), + nip04Decrypt: (actorHex, ciphertext) => extension.nip04.decrypt(actorHex, ciphertext), + nip44Encrypt: (targetHex, plaintext) => extension.nip44.encrypt(targetHex, plaintext), + nip44Decrypt: (actorHex, ciphertext) => extension.nip44.decrypt(actorHex, ciphertext), +}); +``` + +`setActiveSigner` accepts any object that implements the subset of capabilities +you support. `nostrClient` will prefer the registered signer for signing and +encryption before falling back to session actors. Call `clearActiveSigner()` on +logout if your integration manages session state manually; the built-in logout +handler already does this for the default extension flow. + ### Accessing raw events `NostrClient` now keeps a lightweight cache of parsed videos alongside a diff --git a/js/nostr.js b/js/nostr.js index ac41990f..05e83c02 100644 --- a/js/nostr.js +++ b/js/nostr.js @@ -57,6 +57,8 @@ export const DEFAULT_RELAY_URLS = Object.freeze([ const RELAY_URLS = Array.from(DEFAULT_RELAY_URLS); +let activeSigner = null; + const EVENTS_CACHE_STORAGE_KEY = "bitvid:eventsCache:v1"; const LEGACY_EVENTS_STORAGE_KEY = "bitvidEvents"; const EVENTS_CACHE_TTL_MS = 10 * 60 * 1000; // 10 minutes @@ -2432,6 +2434,119 @@ function normalizeActorKey(actor) { return trimmed.toLowerCase(); } +export function getActiveSigner() { + return activeSigner; +} + +export function clearActiveSigner() { + activeSigner = null; + return activeSigner; +} + +export function setActiveSigner(candidate = null) { + if (!candidate || typeof candidate !== "object") { + activeSigner = null; + return activeSigner; + } + + const pubkeyInput = + typeof candidate.pubkey === "string" + ? candidate.pubkey + : typeof candidate.pubKey === "string" + ? candidate.pubKey + : ""; + const normalizedPubkey = normalizeActorKey(pubkeyInput); + + let signEvent = null; + if (typeof candidate.signEvent === "function") { + signEvent = candidate.signEvent; + } + + let nip04Encrypt = null; + if (typeof candidate.nip04Encrypt === "function") { + nip04Encrypt = candidate.nip04Encrypt; + } else if (candidate.nip04 && typeof candidate.nip04.encrypt === "function") { + nip04Encrypt = candidate.nip04.encrypt.bind(candidate.nip04); + } + + let nip04Decrypt = null; + if (typeof candidate.nip04Decrypt === "function") { + nip04Decrypt = candidate.nip04Decrypt; + } else if (candidate.nip04 && typeof candidate.nip04.decrypt === "function") { + nip04Decrypt = candidate.nip04.decrypt.bind(candidate.nip04); + } + + let nip44Encrypt = null; + if (typeof candidate.nip44Encrypt === "function") { + nip44Encrypt = candidate.nip44Encrypt; + } else if (candidate.nip44 && typeof candidate.nip44.encrypt === "function") { + nip44Encrypt = candidate.nip44.encrypt.bind(candidate.nip44); + } else if ( + candidate.nip44?.v2 && + typeof candidate.nip44.v2.encrypt === "function" + ) { + nip44Encrypt = candidate.nip44.v2.encrypt.bind(candidate.nip44.v2); + } + + let nip44Decrypt = null; + if (typeof candidate.nip44Decrypt === "function") { + nip44Decrypt = candidate.nip44Decrypt; + } else if (candidate.nip44 && typeof candidate.nip44.decrypt === "function") { + nip44Decrypt = candidate.nip44.decrypt.bind(candidate.nip44); + } else if ( + candidate.nip44?.v2 && + typeof candidate.nip44.v2.decrypt === "function" + ) { + nip44Decrypt = candidate.nip44.v2.decrypt.bind(candidate.nip44.v2); + } + + const signerType = + typeof candidate.type === "string" && candidate.type.trim() + ? candidate.type.trim() + : null; + + activeSigner = { + type: signerType, + pubkey: normalizedPubkey, + signEvent, + nip04Encrypt, + nip04Decrypt, + nip44Encrypt, + nip44Decrypt, + }; + + return activeSigner; +} + +function resolveActiveSigner(targetActor) { + const signer = getActiveSigner(); + if (!signer) { + return null; + } + + const normalizedTarget = normalizeActorKey(targetActor); + const normalizedSigner = normalizeActorKey(signer.pubkey); + if (normalizedTarget && normalizedSigner && normalizedTarget !== normalizedSigner) { + return null; + } + + if ( + typeof signer.signEvent !== "function" && + typeof signer.nip04Encrypt !== "function" && + typeof signer.nip04Decrypt !== "function" && + typeof signer.nip44Encrypt !== "function" && + typeof signer.nip44Decrypt !== "function" + ) { + return null; + } + + return signer; +} + +function shouldRequestExtensionPermissions(signer) { + return signer?.type === "extension"; +} + function canonicalizeWatchHistoryItems(rawItems, maxItems = WATCH_HISTORY_MAX_ITEMS) { const seen = new Map(); if (Array.isArray(rawItems)) { @@ -3642,13 +3757,13 @@ export class NostrClient { typeof this.pubkey === "string" && this.pubkey ? this.pubkey.toLowerCase() : ""; - const extension = window?.nostr; - const canSignWithExtension = + const activeSigner = resolveActiveSigner(this.pubkey); + const canSignWithActiveSigner = !!normalizedLogged && - extension && - typeof extension.signEvent === "function"; + activeSigner && + typeof activeSigner.signEvent === "function"; - if (!forceRenew && canSignWithExtension) { + if (!forceRenew && canSignWithActiveSigner) { return normalizedLogged; } @@ -4283,21 +4398,29 @@ export class NostrClient { } const actorPubkey = resolvedActor || actorKey; - const extension = window?.nostr; - const extensionActorKey = normalizeActorKey(this.pubkey); - const canUseExtensionSign = - extension && - typeof extension.signEvent === "function" && - actorKey === extensionActorKey; + const normalizedLogged = normalizeActorKey(this.pubkey); + const signer = resolveActiveSigner(actorKey); + const canUseActiveSignerSign = + normalizedLogged && + normalizedLogged === actorKey && + signer && + typeof signer.signEvent === "function"; + const useActiveSignerEncrypt = + canUseActiveSignerSign && + signer && + typeof signer.nip04Encrypt === "function"; + const activeSigner = canUseActiveSignerSign ? signer : null; + const encryptionSigner = useActiveSignerEncrypt ? signer : null; - const useExtensionEncrypt = - canUseExtensionSign && - extension && - extension.nip04 && - typeof extension.nip04.encrypt === "function"; + if ( + (canUseActiveSignerSign || useActiveSignerEncrypt) && + shouldRequestExtensionPermissions(signer) + ) { + await this.ensureExtensionPermissions(DEFAULT_NIP07_PERMISSION_METHODS); + } let privateKey = ""; - if (!canUseExtensionSign) { + if (!canUseActiveSignerSign) { if (!this.sessionActor || this.sessionActor.pubkey !== actorKey) { const ensured = await this.ensureSessionActor(); if (normalizeActorKey(ensured) !== actorKey) { @@ -4313,10 +4436,6 @@ export class NostrClient { } } - if (useExtensionEncrypt) { - await this.ensureExtensionPermissions(DEFAULT_NIP07_PERMISSION_METHODS); - } - const canonicalItems = canonicalizeWatchHistoryItems( Array.isArray(rawItems) ? rawItems : [], WATCH_HISTORY_MAX_ITEMS, @@ -4387,8 +4506,8 @@ export class NostrClient { }; const encryptChunk = async (plaintext) => { - if (useExtensionEncrypt) { - return extension.nip04.encrypt(actorPubkey, plaintext); + if (encryptionSigner) { + return encryptionSigner.nip04Encrypt(actorPubkey, plaintext); } const tools = await ensureNip04Tools(); if (!tools?.nip04 || typeof tools.nip04.encrypt !== "function") { @@ -4398,8 +4517,8 @@ export class NostrClient { }; const signEvent = async (event) => { - if (canUseExtensionSign) { - return extension.signEvent(event); + if (activeSigner) { + return activeSigner.signEvent(event); } return signEventWithPrivateKey(event, privateKey); }; @@ -4843,13 +4962,24 @@ export class NostrClient { const actorKeyIsHex = /^[0-9a-f]{64}$/.test(actorKey); + const normalizedLogged = normalizeActorKey(this.pubkey); + const signer = resolveActiveSigner(actorKey); + const canUseActiveSignerDecrypt = + normalizedLogged && + normalizedLogged === actorKey && + signer && + typeof signer.nip04Decrypt === "function"; + const decryptSigner = canUseActiveSignerDecrypt ? signer : null; + + if (decryptSigner && shouldRequestExtensionPermissions(decryptSigner)) { + await this.ensureExtensionPermissions(DEFAULT_NIP07_PERMISSION_METHODS); + } + devLogger.info("[nostr] Fetching watch history from relays.", { actor: resolvedActor, forceRefresh: options.forceRefresh === true, }); - const extension = window?.nostr; - const existingEntry = this.watchHistoryCache.get(actorKey); const now = Date.now(); const ttl = this.getWatchHistoryCacheTtlMs(); @@ -5122,26 +5252,24 @@ export class NostrClient { expectedPlaintextFormat: "JSON string with { version, items, snapshot, chunkIndex, totalChunks }", }); - const extensionDecrypt = - extension && - typeof extension?.nip04?.decrypt === "function" && - normalizeActorKey(this.pubkey) === actorKey; - if (extensionDecrypt) { - await this.ensureExtensionPermissions(DEFAULT_NIP07_PERMISSION_METHODS); + if (decryptSigner) { devLogger.info( - "[nostr] Using logged in user's extension key to decrypt watch history chunk.", + "[nostr] Using active signer to decrypt watch history chunk.", { - actorKey, - chunkIdentifier: context.chunkIdentifier ?? null, - eventId: context.eventId ?? null, + actorKey, + chunkIdentifier: context.chunkIdentifier ?? null, + eventId: context.eventId ?? null, + }, + ); + const plaintext = await decryptSigner.nip04Decrypt(actorKey, ciphertext); + devLogger.info( + "[nostr] Successfully decrypted watch history chunk via active signer.", + { + actorKey, + chunkIdentifier: context.chunkIdentifier ?? null, + eventId: context.eventId ?? null, }, ); - const plaintext = await extension.nip04.decrypt(actorKey, ciphertext); - devLogger.info("[nostr] Successfully decrypted watch history chunk via extension key.", { - actorKey, - chunkIdentifier: context.chunkIdentifier ?? null, - eventId: context.eventId ?? null, - }); return plaintext; } if (!this.sessionActor || this.sessionActor.pubkey !== actorKey) { @@ -5841,28 +5969,33 @@ export class NostrClient { let signedEvent = null; - if ( + const signer = resolveActiveSigner(actorPubkey); + const canUseActiveSigner = normalizedActor && normalizedActor === normalizedLogged && - window?.nostr && - typeof window.nostr.signEvent === "function" - ) { - const permissionResult = await this.ensureExtensionPermissions( - DEFAULT_NIP07_PERMISSION_METHODS, - ); + signer && + typeof signer.signEvent === "function"; + + if (canUseActiveSigner) { + let permissionResult = { ok: true }; + if (shouldRequestExtensionPermissions(signer)) { + permissionResult = await this.ensureExtensionPermissions( + DEFAULT_NIP07_PERMISSION_METHODS, + ); + } if (permissionResult.ok) { try { - signedEvent = await window.nostr.signEvent(event); + signedEvent = await signer.signEvent(event); } catch (error) { userLogger.warn( - "[nostr] Failed to sign view event with extension:", + "[nostr] Failed to sign view event with active signer:", error, ); return { ok: false, error: "signing-failed", details: error }; } } else { userLogger.warn( - "[nostr] Extension permissions missing; signing view event with session key.", + "[nostr] Active signer permissions missing; signing view event with session key.", permissionResult.error, ); } @@ -6163,6 +6296,17 @@ export class NostrClient { this.pubkey = pubkey; devLogger.log("Logged in with extension. Pubkey:", this.pubkey); + setActiveSigner({ + type: "extension", + pubkey, + signEvent: + typeof extension.signEvent === "function" + ? extension.signEvent.bind(extension) + : null, + nip04: extension.nip04, + nip44: extension.nip44, + }); + const postLoginPermissions = await this.ensureExtensionPermissions( DEFAULT_NIP07_PERMISSION_METHODS, ); @@ -6181,6 +6325,7 @@ export class NostrClient { logout() { this.pubkey = null; + clearActiveSigner(); for (const timer of this.watchHistoryRepublishTimers.values()) { if (timer && typeof timer.timer === "number") { clearTimeout(timer.timer); @@ -6222,33 +6367,41 @@ export class NostrClient { return { ok: false, error: "nostr-uninitialized" }; } - const extension = window?.nostr; - if (!extension) { - return { ok: false, error: "nostr-extension-missing" }; + const activeSignerCandidate = + typeof actorPubkeyOverride === "string" && actorPubkeyOverride.trim() + ? resolveActiveSigner(actorPubkeyOverride) + : resolveActiveSigner(this.pubkey); + const baseActiveSigner = activeSignerCandidate || getActiveSigner(); + const signer = + typeof actorPubkeyOverride === "string" && actorPubkeyOverride.trim() + ? activeSignerCandidate + : baseActiveSigner + ? resolveActiveSigner(baseActiveSigner.pubkey || this.pubkey) + : null; + + if (!signer || typeof signer.signEvent !== "function") { + return { ok: false, error: "sign-event-unavailable" }; } - const permissionResult = await this.ensureExtensionPermissions( - DEFAULT_NIP07_PERMISSION_METHODS, - ); - if (!permissionResult.ok) { - userLogger.warn( - "[nostr] Cannot send direct message without extension permissions.", - permissionResult.error, - ); - return { - ok: false, - error: "extension-permission-denied", - details: permissionResult.error, - }; - } - - const nip04 = extension.nip04; - if (!nip04 || typeof nip04.encrypt !== "function") { + if (typeof signer.nip04Encrypt !== "function") { return { ok: false, error: "nip04-unavailable" }; } - if (typeof extension.signEvent !== "function") { - return { ok: false, error: "sign-event-unavailable" }; + if (shouldRequestExtensionPermissions(signer)) { + const permissionResult = await this.ensureExtensionPermissions( + DEFAULT_NIP07_PERMISSION_METHODS, + ); + if (!permissionResult.ok) { + userLogger.warn( + "[nostr] Cannot send direct message without extension permissions.", + permissionResult.error, + ); + return { + ok: false, + error: "extension-permission-denied", + details: permissionResult.error, + }; + } } let actorHex = @@ -6260,15 +6413,8 @@ export class NostrClient { actorHex = this.pubkey.trim(); } - if (!actorHex && typeof extension.getPublicKey === "function") { - try { - actorHex = await extension.getPublicKey(); - } catch (error) { - devLogger.warn( - "[nostr] Failed to fetch actor pubkey from extension:", - error - ); - } + if (!actorHex && typeof signer?.pubkey === "string") { + actorHex = signer.pubkey; } if (!actorHex) { @@ -6282,7 +6428,7 @@ export class NostrClient { let ciphertext = ""; try { - ciphertext = await nip04.encrypt(targetHex, trimmedMessage); + ciphertext = await signer.nip04Encrypt(targetHex, trimmedMessage); } catch (error) { return { ok: false, error: "encryption-failed", details: error }; } @@ -6297,7 +6443,7 @@ export class NostrClient { let signedEvent; try { - signedEvent = await extension.signEvent(event); + signedEvent = await signer.signEvent(event); } catch (error) { return { ok: false, error: "signature-failed", details: error }; } @@ -6334,12 +6480,6 @@ export class NostrClient { relaysOverride = null, } = {} ) { - const extension = window?.nostr; - const extensionSigner = - extension && typeof extension.signEvent === "function" - ? extension.signEvent.bind(extension) - : null; - const normalizedEventPubkey = event && typeof event.pubkey === "string" ? event.pubkey.toLowerCase() @@ -6351,30 +6491,35 @@ export class NostrClient { normalizedLogged && normalizedEventPubkey !== normalizedLogged; + const signer = resolveActiveSigner(normalizedEventPubkey || this.pubkey); + const canUseActiveSigner = + !usingSessionActor && + signer && + typeof signer.signEvent === "function"; + let eventToSign = event; let signedEvent = null; let signerPubkey = null; - if ( - extensionSigner && - normalizedEventPubkey && - normalizedEventPubkey === normalizedLogged - ) { - const permissionResult = await this.ensureExtensionPermissions( - DEFAULT_NIP07_PERMISSION_METHODS, - ); + if (canUseActiveSigner) { + let permissionResult = { ok: true }; + if (shouldRequestExtensionPermissions(signer)) { + permissionResult = await this.ensureExtensionPermissions( + DEFAULT_NIP07_PERMISSION_METHODS, + ); + } if (permissionResult.ok) { try { - signedEvent = await extensionSigner(event); + signedEvent = await signer.signEvent(event); } catch (error) { userLogger.warn( - "[nostr] Failed to sign event with extension:", + "[nostr] Failed to sign event with active signer:", error, ); } } else { userLogger.warn( - "[nostr] Extension permissions missing; falling back to session signer.", + "[nostr] Active signer permissions missing; falling back to session signer.", permissionResult.error, ); } @@ -6918,31 +7063,35 @@ export class NostrClient { devLogger.log("Creating edited event with root ID:", oldRootId); devLogger.log("Event content:", event.content); - const extensionSigner = window?.nostr?.signEvent; - if (typeof extensionSigner !== "function") { - const error = new Error("A NIP-07 extension with signEvent support is required to edit videos."); + const signer = resolveActiveSigner(userPubkeyLower); + if (!signer || typeof signer.signEvent !== "function") { + const error = new Error( + "An active signer with signEvent support is required to edit videos.", + ); error.code = "nostr-extension-missing"; throw error; } - const permissionResult = await this.ensureExtensionPermissions( - DEFAULT_NIP07_PERMISSION_METHODS, - ); - if (!permissionResult.ok) { - userLogger.warn( - "[nostr] Extension permissions denied while editing a video.", - permissionResult.error, + if (shouldRequestExtensionPermissions(signer)) { + const permissionResult = await this.ensureExtensionPermissions( + DEFAULT_NIP07_PERMISSION_METHODS, ); - const error = new Error( - "The NIP-07 extension must grant decrypt and sign permissions before editing a video.", - ); - error.code = "extension-permission-denied"; - error.cause = permissionResult.error; - throw error; + if (!permissionResult.ok) { + userLogger.warn( + "[nostr] Signer permissions denied while editing a video.", + permissionResult.error, + ); + const error = new Error( + "The active signer must grant decrypt and sign permissions before editing a video.", + ); + error.code = "extension-permission-denied"; + error.cause = permissionResult.error; + throw error; + } } try { - const signedEvent = await extensionSigner(event); + const signedEvent = await signer.signEvent(event); devLogger.log("Signed edited event:", signedEvent); const publishResults = await publishEventToRelays( @@ -7083,36 +7232,34 @@ export class NostrClient { content: JSON.stringify(contentObject), }; - const extension = window?.nostr; - const extensionSigner = - extension && typeof extension.signEvent === "function" - ? extension.signEvent.bind(extension) - : null; - if (typeof extensionSigner !== "function") { + const signer = resolveActiveSigner(pubkey); + if (!signer || typeof signer.signEvent !== "function") { const error = new Error( - "A NIP-07 extension with signEvent support is required to revert videos.", + "An active signer with signEvent support is required to revert videos.", ); error.code = "nostr-extension-missing"; throw error; } - const permissionResult = await this.ensureExtensionPermissions( - DEFAULT_NIP07_PERMISSION_METHODS, - ); - if (!permissionResult.ok) { - userLogger.warn( - "[nostr] Extension permissions denied while reverting a video.", - permissionResult.error, + if (shouldRequestExtensionPermissions(signer)) { + const permissionResult = await this.ensureExtensionPermissions( + DEFAULT_NIP07_PERMISSION_METHODS, ); - const error = new Error( - "The NIP-07 extension must grant decrypt and sign permissions before reverting a video.", - ); - error.code = "extension-permission-denied"; - error.cause = permissionResult.error; - throw error; + if (!permissionResult.ok) { + userLogger.warn( + "[nostr] Signer permissions denied while reverting a video.", + permissionResult.error, + ); + const error = new Error( + "The active signer must grant decrypt and sign permissions before reverting a video.", + ); + error.code = "extension-permission-denied"; + error.cause = permissionResult.error; + throw error; + } } - const signedEvent = await extensionSigner(event); + const signedEvent = await signer.signEvent(event); const publishResults = await publishEventToRelays( this.pool, this.relays, @@ -7381,34 +7528,31 @@ export class NostrClient { const deleteSummaries = []; if (identifierRecords.length) { - const extension = window?.nostr; - const extensionSigner = - extension && typeof extension.signEvent === "function" - ? extension.signEvent.bind(extension) - : null; - - if (typeof extensionSigner !== "function") { + const signer = resolveActiveSigner(pubkey); + if (!signer || typeof signer.signEvent !== "function") { const error = new Error( - "A NIP-07 extension with signEvent support is required to delete videos.", + "An active signer with signEvent support is required to delete videos.", ); error.code = "nostr-extension-missing"; throw error; } - const permissionResult = await this.ensureExtensionPermissions( - DEFAULT_NIP07_PERMISSION_METHODS, - ); - if (!permissionResult.ok) { - userLogger.warn( - "[nostr] Extension permissions denied while deleting videos.", - permissionResult.error, + if (shouldRequestExtensionPermissions(signer)) { + const permissionResult = await this.ensureExtensionPermissions( + DEFAULT_NIP07_PERMISSION_METHODS, ); - const error = new Error( - "The NIP-07 extension must grant decrypt and sign permissions before deleting a video.", - ); - error.code = "extension-permission-denied"; - error.cause = permissionResult.error; - throw error; + if (!permissionResult.ok) { + userLogger.warn( + "[nostr] Signer permissions denied while deleting videos.", + permissionResult.error, + ); + const error = new Error( + "The active signer must grant decrypt and sign permissions before deleting a video.", + ); + error.code = "extension-permission-denied"; + error.cause = permissionResult.error; + throw error; + } } const chunkSize = 100; @@ -7426,7 +7570,7 @@ export class NostrClient { : "Delete published video events", }; - const signedDelete = await extensionSigner(deleteEvent); + const signedDelete = await signer.signEvent(deleteEvent); const publishResults = await publishEventToRelays( this.pool, this.relays, diff --git a/js/relayManager.js b/js/relayManager.js index c1a88756..114afa1b 100644 --- a/js/relayManager.js +++ b/js/relayManager.js @@ -1,6 +1,7 @@ import { isDevMode } from "./config.js"; import { DEFAULT_RELAY_URLS, + getActiveSigner, nostrClient, requestDefaultExtensionPermissions, } from "./nostr.js"; @@ -606,28 +607,31 @@ class RelayPreferencesManager { throw error; } - const permissionResult = await requestDefaultExtensionPermissions(); - if (!permissionResult.ok) { - userLogger.warn( - "[RelayPreferencesManager] Extension permissions denied while publishing relay preferences.", - permissionResult.error, - ); + const signer = getActiveSigner(); + if (!signer || typeof signer.signEvent !== "function") { const error = new Error( - "The NIP-07 extension must allow signing before publishing relay preferences.", - ); - error.code = "extension-permission-denied"; - error.cause = permissionResult.error; - throw error; - } - - if (!window?.nostr?.signEvent) { - const error = new Error( - "A NIP-07 extension is required to publish relay preferences." + "An active signer with signEvent support is required to publish relay preferences." ); error.code = "nostr-extension-missing"; throw error; } + if (signer.type === "extension") { + const permissionResult = await requestDefaultExtensionPermissions(); + if (!permissionResult.ok) { + userLogger.warn( + "[RelayPreferencesManager] Signer permissions denied while publishing relay preferences.", + permissionResult.error, + ); + const error = new Error( + "The active signer must allow signing before publishing relay preferences.", + ); + error.code = "extension-permission-denied"; + error.cause = permissionResult.error; + throw error; + } + } + if (!nostrClient?.pool) { const error = new Error( "Nostr is not connected yet. Please try again once relays are ready." @@ -649,7 +653,7 @@ class RelayPreferencesManager { relays: entries, }); - const signedEvent = await window.nostr.signEvent(event); + const signedEvent = await signer.signEvent(event); const targets = this.getPublishTargets(options?.relayUrls); if (!targets.length) { diff --git a/js/subscriptions.js b/js/subscriptions.js index a7c0e011..471a6afd 100644 --- a/js/subscriptions.js +++ b/js/subscriptions.js @@ -1,5 +1,6 @@ // js/subscriptions.js import { + getActiveSigner, nostrClient, convertEventToVideo as sharedConvertEventToVideo, requestDefaultExtensionPermissions, @@ -286,14 +287,6 @@ class SubscriptionsManager { return { ok: false, error }; } - const nostrApi = - typeof window !== "undefined" && window && window.nostr ? window.nostr : null; - if (!nostrApi) { - const error = new Error("Nostr extension is unavailable for decrypting subscriptions."); - error.code = "nostr-extension-missing"; - return { ok: false, error }; - } - const decryptors = new Map(); const registerDecryptor = (scheme, handler) => { if (!scheme || typeof handler !== "function" || decryptors.has(scheme)) { @@ -302,28 +295,48 @@ class SubscriptionsManager { decryptors.set(scheme, handler); }; - if (nostrApi.nip04 && typeof nostrApi.nip04.decrypt === "function") { - registerDecryptor("nip04", (payload) => nostrApi.nip04.decrypt(userPubkey, payload)); + const signer = getActiveSigner(); + if (signer?.nip04Decrypt) { + registerDecryptor("nip04", (payload) => signer.nip04Decrypt(userPubkey, payload)); } - 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)); + if (signer?.nip44Decrypt) { + registerDecryptor("nip44", (payload) => signer.nip44Decrypt(userPubkey, payload)); + } + + const nostrApi = + typeof window !== "undefined" && window && window.nostr ? window.nostr : null; + if (nostrApi) { + if (nostrApi.nip04 && typeof nostrApi.nip04.decrypt === "function") { + registerDecryptor("nip04", (payload) => + nostrApi.nip04.decrypt(userPubkey, payload) + ); } - 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)); - if (!decryptors.has("nip44")) { - registerDecryptor("nip44", (payload) => nip44v2.decrypt(userPubkey, payload)); + 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)); + } + + 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) + ); + if (!decryptors.has("nip44")) { + registerDecryptor("nip44", (payload) => nip44v2.decrypt(userPubkey, payload)); + } } } } if (!decryptors.size) { - const error = new Error("No compatible decryption helpers are available."); - error.code = "subscriptions-no-decryptors"; + const error = new Error( + "No active signer or extension decryptors are available for subscriptions." + ); + error.code = "nostr-extension-missing"; return { ok: false, error }; } @@ -413,17 +426,40 @@ class SubscriptionsManager { throw new Error("No pubkey => cannot publish subscription list."); } - const permissionResult = await requestDefaultExtensionPermissions(); - if (!permissionResult.ok) { - userLogger.warn( - "[SubscriptionsManager] Extension permissions denied while updating subscriptions.", - permissionResult.error, - ); + const signer = getActiveSigner(); + if (!signer) { const error = new Error( - "The NIP-07 extension must allow encryption and signing before updating subscriptions.", + "An active signer is required to update subscriptions." ); - error.code = "extension-permission-denied"; - error.cause = permissionResult.error; + error.code = "signer-missing"; + throw error; + } + + if (signer.type === "extension") { + const permissionResult = await requestDefaultExtensionPermissions(); + if (!permissionResult.ok) { + userLogger.warn( + "[SubscriptionsManager] Signer permissions denied while updating subscriptions.", + permissionResult.error, + ); + const error = new Error( + "The active signer must allow encryption and signing before updating subscriptions.", + ); + error.code = "extension-permission-denied"; + error.cause = permissionResult.error; + throw error; + } + } + + if (typeof signer.nip04Encrypt !== "function") { + const error = new Error("NIP-04 encryption is required to update subscriptions."); + error.code = "nip04-missing"; + throw error; + } + + if (typeof signer.signEvent !== "function") { + const error = new Error("Active signer missing signEvent support."); + error.code = "sign-event-missing"; throw error; } @@ -440,7 +476,7 @@ class SubscriptionsManager { */ let cipherText = ""; try { - cipherText = await window.nostr.nip04.encrypt(userPubkey, plainStr); + cipherText = await signer.nip04Encrypt(userPubkey, plainStr); } catch (err) { userLogger.error("Encryption failed:", err); throw err; @@ -454,7 +490,7 @@ class SubscriptionsManager { let signedEvent; try { - signedEvent = await window.nostr.signEvent(evt); + signedEvent = await signer.signEvent(evt); } catch (signErr) { userLogger.error("Failed to sign subscription list:", signErr); throw signErr; diff --git a/js/userBlocks.js b/js/userBlocks.js index e87788bd..80bf0aaa 100644 --- a/js/userBlocks.js +++ b/js/userBlocks.js @@ -1,5 +1,6 @@ // js/userBlocks.js import { + getActiveSigner, nostrClient, requestDefaultExtensionPermissions, } from "./nostr.js"; @@ -548,9 +549,19 @@ class UserBlockListManager { return; } + const activeSigner = getActiveSigner(); + const decryptWithActiveSigner = + activeSigner && typeof activeSigner.nip04Decrypt === "function"; + let decrypted = ""; try { - decrypted = await window.nostr.nip04.decrypt(normalized, newest.content); + if (decryptWithActiveSigner) { + decrypted = await activeSigner.nip04Decrypt(normalized, newest.content); + } else if (window?.nostr?.nip04?.decrypt) { + decrypted = await window.nostr.nip04.decrypt(normalized, newest.content); + } else { + throw new Error("nip04-unavailable"); + } } catch (err) { userLogger.error("[UserBlockList] Failed to decrypt block list:", err); applyBlockedPubkeys([], { @@ -905,21 +916,32 @@ class UserBlockListManager { onStatus?.({ status: "publishing" }); - const permissionResult = await requestDefaultExtensionPermissions(); - if (!permissionResult.ok) { - userLogger.warn( - "[UserBlockList] Extension permissions denied while updating the block list.", - permissionResult.error, - ); + const signer = getActiveSigner(); + if (!signer) { const err = new Error( - "The NIP-07 extension must allow encryption and signing before updating the block list.", + "An active signer is required to update the block list." ); - err.code = "extension-permission-denied"; - err.cause = permissionResult.error; + err.code = "signer-missing"; throw err; } - if (!window?.nostr?.nip04?.encrypt) { + if (signer.type === "extension") { + const permissionResult = await requestDefaultExtensionPermissions(); + if (!permissionResult.ok) { + userLogger.warn( + "[UserBlockList] Signer permissions denied while updating the block list.", + permissionResult.error, + ); + const err = new Error( + "The active signer must allow encryption and signing before updating the block list.", + ); + err.code = "extension-permission-denied"; + err.cause = permissionResult.error; + throw err; + } + } + + if (typeof signer.nip04Encrypt !== "function") { const err = new Error( "NIP-04 encryption is required to update the block list." ); @@ -927,9 +949,9 @@ class UserBlockListManager { throw err; } - if (typeof window.nostr.signEvent !== "function") { - const err = new Error("Nostr extension missing signEvent support."); - err.code = "nip04-missing"; + if (typeof signer.signEvent !== "function") { + const err = new Error("Active signer missing signEvent support."); + err.code = "sign-event-missing"; throw err; } @@ -947,7 +969,7 @@ class UserBlockListManager { let cipherText = ""; try { - cipherText = await window.nostr.nip04.encrypt(normalized, plaintext); + cipherText = await signer.nip04Encrypt(normalized, plaintext); } catch (error) { const err = new Error("Failed to encrypt block list."); err.code = "nip04-missing"; @@ -960,7 +982,7 @@ class UserBlockListManager { content: cipherText, }); - const signedEvent = await window.nostr.signEvent(event); + const signedEvent = await signer.signEvent(event); const publishResults = await publishEventToRelays( nostrClient.pool, diff --git a/tests/admin-list-store.test.mjs b/tests/admin-list-store.test.mjs index 3fd9996a..5c85ae1f 100644 --- a/tests/admin-list-store.test.mjs +++ b/tests/admin-list-store.test.mjs @@ -52,7 +52,7 @@ const { } = await import( "../js/adminListStore.js" ); -const { nostrClient } = await import("../js/nostr.js"); +const { nostrClient, setActiveSigner } = await import("../js/nostr.js"); const { ADMIN_SUPER_NPUB, ADMIN_LIST_NAMESPACE, @@ -290,6 +290,12 @@ globalThis.window.nostr.signEvent = async (event) => ({ id: "signed-event", }); +setActiveSigner({ + pubkey: superHex, + signEvent: globalThis.window.nostr.signEvent, + type: "test", +}); + resetListRegistry(); registerListEvent({