Add active signer support and NIP-07 provider refactor

This commit is contained in:
thePR0M3TH3AN committed 2025-10-21 11:30:28 -04:00
1 parent edc47631bb
commit fbe6f4a649
6 files changed
+856 -487

No files matched your search

+132
View File
@@ -0,0 +1,132 @@
// js/nip07Support.js
import { isDevMode } from "./config.js";
export const NIP07_LOGIN_TIMEOUT_MS = 60_000; // 60 seconds
export const NIP07_LOGIN_TIMEOUT_ERROR_MESSAGE =
"Timed out waiting for the NIP-07 extension. Confirm the extension prompt in your browser toolbar and try again.";
const DEFAULT_ENABLE_VARIANT_TIMEOUT_MS = 7000;
export const DEFAULT_NIP07_PERMISSION_METHODS = Object.freeze([
"get_public_key",
"sign_event",
"nip04.encrypt",
"nip04.decrypt",
"read_relays",
"write_relays",
]);
export function getEnableVariantTimeoutMs() {
const overrideValue =
typeof globalThis !== "undefined" &&
globalThis !== null &&
Number.isFinite(globalThis.__BITVID_NIP07_ENABLE_VARIANT_TIMEOUT_MS__)
? Math.floor(globalThis.__BITVID_NIP07_ENABLE_VARIANT_TIMEOUT_MS__)
: null;
if (overrideValue !== null && overrideValue > 0) {
return Math.max(50, overrideValue);
}
return DEFAULT_ENABLE_VARIANT_TIMEOUT_MS;
}
function withNip07Timeout(
operation,
{
timeoutMs = NIP07_LOGIN_TIMEOUT_MS,
message = NIP07_LOGIN_TIMEOUT_ERROR_MESSAGE,
} = {},
) {
const numericTimeout = Number(timeoutMs);
const effectiveTimeout =
Number.isFinite(numericTimeout) && numericTimeout > 0
? numericTimeout
: NIP07_LOGIN_TIMEOUT_MS;
let timeoutId;
const timeoutPromise = new Promise((_, reject) => {
timeoutId = setTimeout(() => {
reject(new Error(message));
}, effectiveTimeout);
});
let operationResult;
try {
operationResult = operation();
} catch (err) {
if (timeoutId) {
clearTimeout(timeoutId);
}
throw err;
}
const operationPromise = Promise.resolve(operationResult);
return Promise.race([operationPromise, timeoutPromise]).finally(() => {
if (timeoutId) {
clearTimeout(timeoutId);
}
});
}
export async function runNip07WithRetry(
operation,
{
label = "NIP-07 operation",
timeoutMs = NIP07_LOGIN_TIMEOUT_MS,
retryMultiplier = 2,
} = {},
) {
let hasStarted = false;
let cachedPromise = null;
const getOrStartOperation = () => {
if (!hasStarted) {
hasStarted = true;
try {
cachedPromise = Promise.resolve(operation());
} catch (error) {
hasStarted = false;
cachedPromise = null;
throw error;
}
}
return cachedPromise;
};
try {
return await withNip07Timeout(getOrStartOperation, {
timeoutMs,
message: NIP07_LOGIN_TIMEOUT_ERROR_MESSAGE,
});
} catch (error) {
const isTimeoutError =
error instanceof Error &&
error.message === NIP07_LOGIN_TIMEOUT_ERROR_MESSAGE;
if (!isTimeoutError || retryMultiplier <= 1) {
throw error;
}
const extendedTimeout = Math.max(
timeoutMs,
Math.round(timeoutMs * retryMultiplier),
);
if (isDevMode) {
console.warn(
`[nostr] ${label} taking longer than ${timeoutMs}ms. Waiting up to ${extendedTimeout}ms for extension response.`,
);
}
return withNip07Timeout(getOrStartOperation, {
timeoutMs: extendedTimeout,
message: NIP07_LOGIN_TIMEOUT_ERROR_MESSAGE,
});
}
}
export const __testExports = { runNip07WithRetry };
+363 -442
View File
@@ -40,6 +40,14 @@ import {
assertAnyRelayAccepted,
} from "./nostrPublish.js";
import { nostrToolsReady } from "./nostrToolsBootstrap.js";
import {
DEFAULT_NIP07_PERMISSION_METHODS,
getEnableVariantTimeoutMs,
NIP07_LOGIN_TIMEOUT_ERROR_MESSAGE,
NIP07_LOGIN_TIMEOUT_MS,
runNip07WithRetry,
} from "./nip07Support.js";
import { getAuthProvider } from "./services/authProviders/index.js";
/**
* The default relay set BitVid bootstraps with before loading a user's
@@ -58,26 +66,6 @@ const RELAY_URLS = Array.from(DEFAULT_RELAY_URLS);
const EVENTS_CACHE_STORAGE_KEY = "bitvid:eventsCache:v1";
const LEGACY_EVENTS_STORAGE_KEY = "bitvidEvents";
const EVENTS_CACHE_TTL_MS = 10 * 60 * 1000; // 10 minutes
const NIP07_LOGIN_TIMEOUT_MS = 60_000; // 60 seconds
const NIP07_LOGIN_TIMEOUT_ERROR_MESSAGE =
"Timed out waiting for the NIP-07 extension. Confirm the extension prompt in your browser toolbar and try again.";
const DEFAULT_ENABLE_VARIANT_TIMEOUT_MS = 7000;
function getEnableVariantTimeoutMs() {
const overrideValue =
typeof globalThis !== "undefined" &&
globalThis !== null &&
Number.isFinite(globalThis.__BITVID_NIP07_ENABLE_VARIANT_TIMEOUT_MS__)
? Math.floor(globalThis.__BITVID_NIP07_ENABLE_VARIANT_TIMEOUT_MS__)
: null;
if (overrideValue !== null && overrideValue > 0) {
return Math.max(50, overrideValue);
}
return DEFAULT_ENABLE_VARIANT_TIMEOUT_MS;
}
const SESSION_ACTOR_STORAGE_KEY = "bitvid:sessionActor:v1";
const VIEW_EVENT_GUARD_PREFIX = "bitvid:viewed";
const REBROADCAST_GUARD_PREFIX = "bitvid:rebroadcast:v1";
@@ -89,15 +77,6 @@ const WATCH_HISTORY_REPUBLISH_MAX_DELAY_MS = 5 * 60 * 1000;
const WATCH_HISTORY_REPUBLISH_MAX_ATTEMPTS = 8;
const WATCH_HISTORY_REPUBLISH_JITTER = 0.25;
const DEFAULT_NIP07_PERMISSION_METHODS = Object.freeze([
"get_public_key",
"sign_event",
"nip04.encrypt",
"nip04.decrypt",
"read_relays",
"write_relays",
]);
const viewEventPublishMemory = new Map();
const rebroadcastAttemptMemory = new Map();
@@ -172,7 +151,7 @@ function readToolkitFromScope(scope = globalScope) {
return null;
}
const __nostrToolsBootstrapResult = await (async () => {
const __nostrToolsBootstrapPromise = (async () => {
try {
const result = await nostrToolsReadySource;
if (result && typeof result === "object" && result.ok === false) {
@@ -199,15 +178,20 @@ const __nostrToolsBootstrapResult = await (async () => {
}
})();
let cachedNostrTools = __nostrToolsBootstrapResult.toolkit || null;
const nostrToolsBootstrapFailure = __nostrToolsBootstrapResult.failure || null;
let cachedNostrTools = null;
let nostrToolsBootstrapFailure = null;
if (!cachedNostrTools && nostrToolsBootstrapFailure && isDevMode) {
console.warn(
"[nostr] nostr-tools helpers unavailable after bootstrap.",
nostrToolsBootstrapFailure
);
}
__nostrToolsBootstrapPromise.then((bootstrap) => {
cachedNostrTools = bootstrap.toolkit || null;
nostrToolsBootstrapFailure = bootstrap.failure || null;
if (!cachedNostrTools && nostrToolsBootstrapFailure && isDevMode) {
console.warn(
"[nostr] nostr-tools helpers unavailable after bootstrap.",
nostrToolsBootstrapFailure
);
}
});
function rememberNostrTools(candidate) {
const normalized = normalizeToolkitCandidate(candidate);
@@ -358,103 +342,6 @@ function logErrorOnce(message, eventContent = null) {
}
}
function withNip07Timeout(
operation,
{
timeoutMs = NIP07_LOGIN_TIMEOUT_MS,
message = NIP07_LOGIN_TIMEOUT_ERROR_MESSAGE,
} = {},
) {
const numericTimeout = Number(timeoutMs);
const effectiveTimeout =
Number.isFinite(numericTimeout) && numericTimeout > 0
? numericTimeout
: NIP07_LOGIN_TIMEOUT_MS;
let timeoutId;
const timeoutPromise = new Promise((_, reject) => {
timeoutId = setTimeout(() => {
reject(new Error(message));
}, effectiveTimeout);
});
let operationResult;
try {
operationResult = operation();
} catch (err) {
if (timeoutId) {
clearTimeout(timeoutId);
}
throw err;
}
const operationPromise = Promise.resolve(operationResult);
return Promise.race([operationPromise, timeoutPromise]).finally(() => {
if (timeoutId) {
clearTimeout(timeoutId);
}
});
}
async function runNip07WithRetry(
operation,
{
label = "NIP-07 operation",
timeoutMs = NIP07_LOGIN_TIMEOUT_MS,
retryMultiplier = 2,
} = {},
) {
let hasStarted = false;
let cachedPromise = null;
const getOrStartOperation = () => {
if (!hasStarted) {
hasStarted = true;
try {
cachedPromise = Promise.resolve(operation());
} catch (error) {
hasStarted = false;
cachedPromise = null;
throw error;
}
}
return cachedPromise;
};
try {
return await withNip07Timeout(getOrStartOperation, {
timeoutMs,
message: NIP07_LOGIN_TIMEOUT_ERROR_MESSAGE,
});
} catch (error) {
const isTimeoutError =
error instanceof Error &&
error.message === NIP07_LOGIN_TIMEOUT_ERROR_MESSAGE;
if (!isTimeoutError || retryMultiplier <= 1) {
throw error;
}
const extendedTimeout = Math.max(
timeoutMs,
Math.round(timeoutMs * retryMultiplier),
);
if (isDevMode) {
console.warn(
`[nostr] ${label} taking longer than ${timeoutMs}ms. Waiting up to ${extendedTimeout}ms for extension response.`,
);
}
return withNip07Timeout(getOrStartOperation, {
timeoutMs: extendedTimeout,
message: NIP07_LOGIN_TIMEOUT_ERROR_MESSAGE,
});
}
}
export const __testExports = { runNip07WithRetry };
function withRequestTimeout(promise, timeoutMs, onTimeout, message = "Request timed out") {
@@ -3088,6 +2975,8 @@ export class NostrClient {
this.countRequestCounter = 0;
this.countUnsupportedRelays = new Set();
this.extensionPermissionCache = new Set();
this.activeSigner = null;
this.providerSigners = new Map();
}
markExtensionPermissions(methods = []) {
@@ -3191,6 +3080,119 @@ export class NostrClient {
};
}
setActiveSigner(signer) {
if (!this.providerSigners || typeof this.providerSigners.set !== "function") {
this.providerSigners = new Map();
}
if (!signer) {
this.activeSigner = null;
this.providerSigners.clear();
return null;
}
const normalizedProviderId =
typeof signer.providerId === "string" && signer.providerId.trim()
? signer.providerId.trim().toLowerCase()
: "";
const normalizedPubkey =
typeof signer.pubkey === "string" && signer.pubkey.trim()
? signer.pubkey.trim().toLowerCase()
: "";
const signEventFn =
signer && typeof signer.signEvent === "function" ? signer.signEvent : null;
const encryptFn =
signer && typeof signer.encrypt === "function" ? signer.encrypt : null;
const decryptFn =
signer && typeof signer.decrypt === "function" ? signer.decrypt : null;
const hasCapability = !!(signEventFn || encryptFn || decryptFn);
if (!hasCapability) {
if (normalizedProviderId) {
this.providerSigners.delete(normalizedProviderId);
if (this.activeSigner?.providerId === normalizedProviderId) {
this.activeSigner = null;
}
} else {
this.activeSigner = null;
}
return null;
}
const metadata =
signer && typeof signer.metadata === "object" && signer.metadata !== null
? { ...signer.metadata }
: null;
const normalizedSigner = {
providerId: normalizedProviderId || null,
pubkey: normalizedPubkey,
signEvent: signEventFn,
encrypt: encryptFn,
decrypt: decryptFn,
metadata,
};
if (normalizedProviderId) {
this.providerSigners.set(normalizedProviderId, normalizedSigner);
}
this.activeSigner = normalizedSigner;
return normalizedSigner;
}
clearActiveSigner(providerId = null) {
if (!this.providerSigners || typeof this.providerSigners.delete !== "function") {
this.providerSigners = new Map();
}
if (typeof providerId === "string" && providerId.trim()) {
const normalized = providerId.trim().toLowerCase();
const removed = this.providerSigners.get(normalized);
this.providerSigners.delete(normalized);
if (removed && this.activeSigner?.providerId === normalized) {
this.activeSigner = null;
}
return;
}
this.providerSigners.clear();
this.activeSigner = null;
}
getActiveSigner() {
return this.activeSigner || null;
}
getActiveSignerForPubkey(pubkey) {
const active = this.getActiveSigner();
if (!active) {
return null;
}
const normalizedTarget =
typeof pubkey === "string" && pubkey.trim() ? pubkey.trim().toLowerCase() : "";
const normalizedActive =
typeof active.pubkey === "string" && active.pubkey.trim()
? active.pubkey.trim().toLowerCase()
: "";
if (!normalizedTarget) {
return active;
}
if (!normalizedActive || normalizedActive === normalizedTarget) {
return active;
}
return null;
}
async ensureNostrTools() {
return ensureNostrTools();
}
applyRootCreatedAt(video) {
if (!video || typeof video !== "object") {
return null;
@@ -5666,31 +5668,48 @@ export class NostrClient {
let signedEvent = null;
if (
normalizedActor &&
normalizedActor === normalizedLogged &&
window?.nostr &&
typeof window.nostr.signEvent === "function"
) {
const activeSigner = this.getActiveSignerForPubkey(normalizedActor);
if (activeSigner && typeof activeSigner.signEvent === "function") {
try {
signedEvent = await window.nostr.signEvent(event);
signedEvent = await activeSigner.signEvent(event);
} catch (error) {
console.warn("[nostr] Failed to sign view event with extension:", error);
return { ok: false, error: "signing-failed", details: error };
if (isDevMode) {
console.warn("[nostr] Active signer failed to sign view event:", error);
}
signedEvent = null;
}
} else {
try {
if (!this.sessionActor || this.sessionActor.pubkey !== actorPubkey) {
await this.ensureSessionActor(true);
}
if (!signedEvent) {
const extensionSigner =
normalizedActor &&
normalizedActor === normalizedLogged &&
window?.nostr &&
typeof window.nostr.signEvent === "function"
? window.nostr.signEvent.bind(window.nostr)
: null;
if (extensionSigner) {
try {
signedEvent = await extensionSigner(event);
} catch (error) {
console.warn("[nostr] Failed to sign view event with extension:", error);
return { ok: false, error: "signing-failed", details: error };
}
if (!this.sessionActor || this.sessionActor.pubkey !== actorPubkey) {
throw new Error("session-actor-mismatch");
} else {
try {
if (!this.sessionActor || this.sessionActor.pubkey !== actorPubkey) {
await this.ensureSessionActor(true);
}
if (!this.sessionActor || this.sessionActor.pubkey !== actorPubkey) {
throw new Error("session-actor-mismatch");
}
const privateKey = this.sessionActor.privateKey;
signedEvent = signEventWithPrivateKey(event, privateKey);
} catch (error) {
console.warn("[nostr] Failed to sign view event with session key:", error);
return { ok: false, error: "signing-failed", details: error };
}
const privateKey = this.sessionActor.privateKey;
signedEvent = signEventWithPrivateKey(event, privateKey);
} catch (error) {
console.warn("[nostr] Failed to sign view event with session key:", error);
return { ok: false, error: "signing-failed", details: error };
}
}
@@ -5748,7 +5767,7 @@ export class NostrClient {
if (isDevMode) {
console.warn(
"[nostr] Failed to ingest optimistic view event:",
error
error,
);
}
}
@@ -5879,173 +5898,53 @@ export class NostrClient {
* Attempt login with a Nostr extension
*/
async login(options = {}) {
const provider = getAuthProvider("nip07");
if (!provider || typeof provider.login !== "function") {
throw new Error("NIP-07 login is not available.");
}
const normalizedOptions =
options && typeof options === "object" ? { ...options } : {};
try {
const extension = window.nostr;
if (!extension) {
console.log("No Nostr extension found");
throw new Error(
"Please install a Nostr extension (Alby, nos2x, etc.)."
);
}
const { allowAccountSelection = false, expectPubkey } =
typeof options === "object" && options !== null ? options : {};
const normalizedExpectedPubkey =
typeof expectPubkey === "string" && expectPubkey.trim()
? expectPubkey.trim().toLowerCase()
: null;
if (typeof extension.getPublicKey !== "function") {
throw new Error(
"This NIP-07 extension is missing getPublicKey support. Please update the extension."
);
}
if (typeof extension.enable === "function") {
if (isDevMode) {
console.log("Requesting permissions from NIP-07 extension...");
}
const requestedPermissionMethods = Array.from(
DEFAULT_NIP07_PERMISSION_METHODS,
);
const permissionVariants = [null];
const objectPermissions = requestedPermissionMethods
.map((method) =>
typeof method === "string" && method.trim()
? { method: method.trim() }
: null,
)
.filter(Boolean);
if (objectPermissions.length) {
permissionVariants.push({ permissions: objectPermissions });
}
const stringPermissions = Array.from(
new Set(objectPermissions.map((entry) => entry.method)),
);
if (stringPermissions.length) {
permissionVariants.push({ permissions: stringPermissions });
}
let enableError = null;
for (const options of permissionVariants) {
try {
await runNip07WithRetry(
() =>
options
? extension.enable(options)
: extension.enable(),
{
label: "extension.enable",
...(options
? {
timeoutMs: Math.min(
NIP07_LOGIN_TIMEOUT_MS,
getEnableVariantTimeoutMs(),
),
}
: {}),
retryMultiplier: 1,
},
);
enableError = null;
break;
} catch (error) {
enableError = error;
if (options && isDevMode) {
console.warn(
"[nostr] extension.enable request with explicit permissions failed:",
error,
);
}
}
}
if (enableError) {
throw new Error(
enableError && enableError.message
? enableError.message
: "The NIP-07 extension denied the permission request.",
);
}
this.markExtensionPermissions(requestedPermissionMethods);
}
if (allowAccountSelection && typeof extension.selectAccounts === "function") {
try {
const selection = await runNip07WithRetry(
() => extension.selectAccounts(expectPubkey ? [expectPubkey] : undefined),
{ label: "extension.selectAccounts" }
);
const didCancelSelection =
selection === undefined ||
selection === null ||
selection === false ||
(Array.isArray(selection) && selection.length === 0);
if (didCancelSelection) {
throw new Error("Account selection was cancelled.");
}
} catch (selectionErr) {
const message =
selectionErr && typeof selectionErr.message === "string"
? selectionErr.message
: "Account selection was cancelled.";
throw new Error(message);
}
}
const pubkey = await runNip07WithRetry(() => extension.getPublicKey(), {
label: "extension.getPublicKey",
const result = await provider.login({
nostrClient: this,
options: normalizedOptions,
legacy: true,
});
if (!pubkey || typeof pubkey !== "string") {
throw new Error(
"The NIP-07 extension did not return a public key. Please try again."
);
}
if (
normalizedExpectedPubkey &&
pubkey.toLowerCase() !== normalizedExpectedPubkey
) {
throw new Error(
"The selected account doesn't match the expected profile. Please try again."
);
}
const nip19Tools = await ensureNostrTools();
const npubEncode = nip19Tools?.nip19?.npubEncode;
if (typeof npubEncode !== "function") {
throw new Error("NostrTools nip19 encoder is unavailable.");
}
const npub = npubEncode(pubkey);
if (isDevMode) {
console.log("Got pubkey:", pubkey);
console.log("Converted to npub:", npub);
console.log("Whitelist:", accessControl.getWhitelist());
console.log("Blacklist:", accessControl.getBlacklist());
}
// Access control
if (!accessControl.canAccess(npub)) {
if (accessControl.isBlacklisted(npub)) {
throw new Error("Your account has been blocked on this platform.");
} else {
throw new Error("Access restricted to whitelisted users only.");
let pubkey = "";
if (typeof result === "string") {
pubkey = result;
} else if (result && typeof result === "object") {
if (typeof result.pubkey === "string") {
pubkey = result.pubkey;
} else if (typeof result.publicKey === "string") {
pubkey = result.publicKey;
}
}
this.pubkey = pubkey;
if (isDevMode) {
console.log("Logged in with extension. Pubkey:", this.pubkey);
const trimmed = typeof pubkey === "string" ? pubkey.trim() : "";
if (trimmed) {
this.pubkey = trimmed;
return trimmed;
}
return this.pubkey;
} catch (err) {
console.error("Login error:", err);
throw err;
return pubkey || null;
} catch (error) {
console.error("Login error:", error);
throw error;
}
}
logout() {
this.pubkey = null;
try {
this.clearActiveSigner();
} catch (error) {
if (isDevMode) {
console.warn("[nostr] Failed to clear active signer during logout:", error);
}
}
for (const timer of this.watchHistoryRepublishTimers.values()) {
if (timer && typeof timer.timer === "number") {
clearTimeout(timer.timer);
@@ -6080,19 +5979,14 @@ export class NostrClient {
return { ok: false, error: "nostr-uninitialized" };
}
const extension = window?.nostr;
if (!extension) {
return { ok: false, error: "nostr-extension-missing" };
}
const nip04 = extension.nip04;
if (!nip04 || typeof nip04.encrypt !== "function") {
return { ok: false, error: "nip04-unavailable" };
}
if (typeof extension.signEvent !== "function") {
return { ok: false, error: "sign-event-unavailable" };
}
const extension = typeof window !== "undefined" ? window.nostr : null;
const nip04 = extension?.nip04;
const extensionEncrypt =
nip04 && typeof nip04.encrypt === "function" ? nip04.encrypt.bind(nip04) : null;
const extensionSign =
extension && typeof extension.signEvent === "function"
? extension.signEvent.bind(extension)
: null;
let actorHex =
typeof actorPubkeyOverride === "string" && actorPubkeyOverride.trim()
@@ -6103,14 +5997,25 @@ export class NostrClient {
actorHex = this.pubkey.trim();
}
if (!actorHex && typeof extension.getPublicKey === "function") {
let activeSigner = null;
if (actorHex) {
activeSigner = this.getActiveSignerForPubkey(actorHex);
} else {
const candidate = this.getActiveSigner();
if (candidate?.pubkey) {
actorHex = candidate.pubkey;
activeSigner = this.getActiveSignerForPubkey(actorHex);
}
}
if (!actorHex && typeof extension?.getPublicKey === "function") {
try {
actorHex = await extension.getPublicKey();
} catch (error) {
if (isDevMode) {
console.warn(
"[nostr] Failed to fetch actor pubkey from extension:",
error
error,
);
}
}
@@ -6120,6 +6025,21 @@ export class NostrClient {
return { ok: false, error: "missing-actor-pubkey" };
}
const normalizedActor = actorHex.trim();
activeSigner = this.getActiveSignerForPubkey(normalizedActor) || activeSigner;
const signerEncrypt =
activeSigner && typeof activeSigner.encrypt === "function"
? activeSigner.encrypt
: null;
const encryptFn = signerEncrypt || extensionEncrypt;
if (!encryptFn) {
if (!extensionEncrypt && !extensionSign && !signerEncrypt) {
return { ok: false, error: "nostr-extension-missing" };
}
return { ok: false, error: "nip04-unavailable" };
}
const targetHex = decodeNpubToHex(trimmedTarget);
if (!targetHex) {
return { ok: false, error: "invalid-target" };
@@ -6127,7 +6047,7 @@ export class NostrClient {
let ciphertext = "";
try {
ciphertext = await nip04.encrypt(targetHex, trimmedMessage);
ciphertext = await encryptFn(targetHex, trimmedMessage);
} catch (error) {
return { ok: false, error: "encryption-failed", details: error };
}
@@ -6137,21 +6057,41 @@ export class NostrClient {
created_at: Math.floor(Date.now() / 1000),
tags: [["p", targetHex]],
content: ciphertext,
pubkey: actorHex,
pubkey: normalizedActor,
};
let signedEvent;
try {
signedEvent = await extension.signEvent(event);
} catch (error) {
return { ok: false, error: "signature-failed", details: error };
let signedEvent = null;
const signerSign =
activeSigner && typeof activeSigner.signEvent === "function"
? activeSigner.signEvent
: null;
if (signerSign) {
try {
signedEvent = await signerSign(event);
} catch (error) {
if (isDevMode) {
console.warn("[nostr] Active signer failed to sign DM:", error);
}
}
}
if (!signedEvent) {
if (!extensionSign) {
return { ok: false, error: "sign-event-unavailable" };
}
try {
signedEvent = await extensionSign(event);
} catch (error) {
return { ok: false, error: "signature-failed", details: error };
}
}
const relays =
Array.isArray(this.relays) && this.relays.length ? this.relays : RELAY_URLS;
const publishResults = await Promise.all(
relays.map((url) => publishEventToRelay(this.pool, url, signedEvent))
relays.map((url) => publishEventToRelay(this.pool, url, signedEvent)),
);
const success = publishResults.some((result) => result.success);
@@ -6200,57 +6140,82 @@ export class NostrClient {
let signedEvent = null;
let signerPubkey = null;
if (
extensionSigner &&
normalizedEventPubkey &&
normalizedEventPubkey === normalizedLogged
) {
signedEvent = await extensionSigner(event);
} else {
const activeSigner = this.getActiveSignerForPubkey(normalizedEventPubkey);
if (activeSigner && typeof activeSigner.signEvent === "function") {
try {
const currentSessionPubkey =
typeof this.sessionActor?.pubkey === "string"
? this.sessionActor.pubkey.toLowerCase()
: "";
signedEvent = await activeSigner.signEvent(event);
if (
usingSessionActor &&
normalizedEventPubkey &&
normalizedEventPubkey !== currentSessionPubkey
!signerPubkey &&
activeSigner &&
typeof activeSigner.pubkey === "string" &&
activeSigner.pubkey
) {
await this.ensureSessionActor(true);
} else {
await this.ensureSessionActor();
signerPubkey = activeSigner.pubkey;
}
const sessionActor = this.sessionActor;
if (
!sessionActor ||
typeof sessionActor.pubkey !== "string" ||
!sessionActor.pubkey ||
typeof sessionActor.privateKey !== "string" ||
!sessionActor.privateKey
) {
throw new Error("session-actor-unavailable");
}
const normalizedSessionPubkey = sessionActor.pubkey.toLowerCase();
if (
!normalizedEventPubkey ||
normalizedEventPubkey !== normalizedSessionPubkey ||
event.pubkey !== sessionActor.pubkey
) {
eventToSign = { ...event, pubkey: sessionActor.pubkey };
}
signedEvent = signEventWithPrivateKey(
eventToSign,
sessionActor.privateKey
);
signerPubkey = sessionActor.pubkey;
} catch (error) {
console.warn("[nostr] Failed to sign event with session key:", error);
throw error;
if (isDevMode) {
console.warn(
`[nostr] Active signer failed to sign ${context}:`,
error,
);
}
signedEvent = null;
}
}
if (!signedEvent) {
if (
extensionSigner &&
normalizedEventPubkey &&
normalizedEventPubkey === normalizedLogged
) {
signedEvent = await extensionSigner(event);
} else {
try {
const currentSessionPubkey =
typeof this.sessionActor?.pubkey === "string"
? this.sessionActor.pubkey.toLowerCase()
: "";
if (
usingSessionActor &&
normalizedEventPubkey &&
normalizedEventPubkey !== currentSessionPubkey
) {
await this.ensureSessionActor(true);
} else {
await this.ensureSessionActor();
}
const sessionActor = this.sessionActor;
if (
!sessionActor ||
typeof sessionActor.pubkey !== "string" ||
!sessionActor.pubkey ||
typeof sessionActor.privateKey !== "string" ||
!sessionActor.privateKey
) {
throw new Error("session-actor-unavailable");
}
const normalizedSessionPubkey = sessionActor.pubkey.toLowerCase();
if (
!normalizedEventPubkey ||
normalizedEventPubkey !== normalizedSessionPubkey ||
event.pubkey !== sessionActor.pubkey
) {
eventToSign = { ...event, pubkey: sessionActor.pubkey };
}
signedEvent = signEventWithPrivateKey(
eventToSign,
sessionActor.privateKey
);
signerPubkey = sessionActor.pubkey;
} catch (error) {
console.warn("[nostr] Failed to sign event with session key:", error);
throw error;
}
}
}
@@ -6755,44 +6720,20 @@ export class NostrClient {
}
try {
const signedEvent = await window.nostr.signEvent(event);
if (isDevMode) {
console.log("Signed edited event:", signedEvent);
}
const publishResults = await publishEventToRelays(
this.pool,
this.relays,
signedEvent
);
let publishSummary;
try {
publishSummary = assertAnyRelayAccepted(publishResults, {
context: "edited video note",
});
} catch (publishError) {
if (publishError?.relayFailures?.length) {
publishError.relayFailures.forEach(
({ url, error: relayError, reason }) => {
console.error(
`[nostr] Edited video rejected by ${url}: ${reason}`,
relayError || reason
);
}
);
}
throw publishError;
}
const { signedEvent, summary } = await this.signAndPublishEvent(event, {
context: "edited video note",
logName: "edited video note",
relaysOverride: this.relays,
});
if (isDevMode) {
publishSummary.accepted.forEach(({ url }) =>
summary.accepted.forEach(({ url }) =>
console.log(`Edited video published to ${url}`)
);
}
if (publishSummary.failed.length) {
publishSummary.failed.forEach(({ url, error: relayError }) => {
if (summary.failed.length) {
summary.failed.forEach(({ url, error: relayError }) => {
const reason =
relayError instanceof Error
? relayError.message
@@ -6895,40 +6836,20 @@ export class NostrClient {
content: JSON.stringify(contentObject),
};
const signedEvent = await window.nostr.signEvent(event);
const publishResults = await publishEventToRelays(
this.pool,
this.relays,
signedEvent
);
let publishSummary;
try {
publishSummary = assertAnyRelayAccepted(publishResults, {
context: "video revert",
});
} catch (publishError) {
if (publishError?.relayFailures?.length) {
publishError.relayFailures.forEach(
({ url, error: relayError, reason }) => {
console.error(
`[nostr] Video revert rejected by ${url}: ${reason}`,
relayError || reason
);
}
);
}
throw publishError;
}
const { signedEvent, summary } = await this.signAndPublishEvent(event, {
context: "video revert",
logName: "video revert",
relaysOverride: this.relays,
});
if (isDevMode) {
publishSummary.accepted.forEach(({ url }) =>
summary.accepted.forEach(({ url }) =>
console.log(`Revert event published to ${url}`)
);
}
if (publishSummary.failed.length) {
publishSummary.failed.forEach(({ url, error: relayError }) => {
if (summary.failed.length) {
summary.failed.forEach(({ url, error: relayError }) => {
const reason =
relayError instanceof Error
? relayError.message
+14 -5
View File
@@ -603,10 +603,19 @@ class RelayPreferencesManager {
throw error;
}
if (!window?.nostr?.signEvent) {
const error = new Error(
"A NIP-07 extension is required to publish relay preferences."
);
const activeSigner = nostrClient.getActiveSignerForPubkey(normalizedPubkey);
const signerSign =
activeSigner && typeof activeSigner.signEvent === "function"
? activeSigner.signEvent
: null;
const extensionSign =
typeof window?.nostr?.signEvent === "function"
? window.nostr.signEvent.bind(window.nostr)
: null;
const signFn = signerSign || extensionSign;
if (!signFn) {
const error = new Error("A NIP-07 extension is required to publish relay preferences.");
error.code = "nostr-extension-missing";
throw error;
}
@@ -632,7 +641,7 @@ class RelayPreferencesManager {
relays: entries,
});
const signedEvent = await window.nostr.signEvent(event);
const signedEvent = await signFn(event);
const targets = this.getPublishTargets(options?.relayUrls);
if (!targets.length) {
+246 -16
View File
@@ -1,3 +1,12 @@
import { isDevMode } from "../../config.js";
import { accessControl } from "../../accessControl.js";
import {
DEFAULT_NIP07_PERMISSION_METHODS,
getEnableVariantTimeoutMs,
NIP07_LOGIN_TIMEOUT_MS,
runNip07WithRetry,
} from "../../nip07Support.js";
const PROVIDER_ID = "nip07";
const PROVIDER_LABEL = "NIP-07 browser extension";
const PROVIDER_BUTTON_CLASS =
@@ -8,32 +17,253 @@ const PROVIDER_SLOW_HINT = "Waiting for the extension prompt…";
const PROVIDER_ERROR_MESSAGE =
"Failed to login with NIP-07. Please try again.";
async function resolveNip19Tools(nostrClient) {
if (nostrClient && typeof nostrClient.ensureNostrTools === "function") {
return nostrClient.ensureNostrTools();
}
return null;
}
async function login({ nostrClient, options } = {}) {
if (!nostrClient || typeof nostrClient.login !== "function") {
if (!nostrClient || typeof nostrClient !== "object") {
throw new Error("NIP-07 login is not available.");
}
const normalizedOptions =
options && typeof options === "object" ? options : {};
const result = await nostrClient.login(normalizedOptions);
const extension = typeof window !== "undefined" ? window.nostr : null;
if (!extension) {
throw new Error("Please install a NIP-07 compatible extension.");
}
let pubkey = "";
let signer = null;
if (typeof result === "string") {
pubkey = result;
} else if (result && typeof result === "object") {
if (typeof result.pubkey === "string") {
pubkey = result.pubkey;
} else if (typeof result.publicKey === "string") {
pubkey = result.publicKey;
if (typeof extension.getPublicKey !== "function") {
throw new Error(
"This NIP-07 extension is missing getPublicKey support. Please update the extension.",
);
}
const normalizedOptions =
options && typeof options === "object" ? { ...options } : {};
const allowAccountSelection = normalizedOptions.allowAccountSelection === true;
const expectPubkey =
typeof normalizedOptions.expectPubkey === "string"
? normalizedOptions.expectPubkey.trim()
: "";
const normalizedExpectedPubkey = expectPubkey
? expectPubkey.toLowerCase()
: null;
if (typeof extension.enable === "function") {
const requestedPermissionMethods = Array.from(
DEFAULT_NIP07_PERMISSION_METHODS,
);
const permissionVariants = [null];
const objectPermissions = requestedPermissionMethods
.map((method) =>
typeof method === "string" && method.trim()
? { method: method.trim() }
: null,
)
.filter(Boolean);
if (objectPermissions.length) {
permissionVariants.push({ permissions: objectPermissions });
}
const stringPermissions = Array.from(
new Set(objectPermissions.map((entry) => entry.method)),
);
if (stringPermissions.length) {
permissionVariants.push({ permissions: stringPermissions });
}
if (result.signer !== undefined) {
signer = result.signer;
let enableError = null;
for (const variant of permissionVariants) {
try {
await runNip07WithRetry(
() => (variant ? extension.enable(variant) : extension.enable()),
{
label: "extension.enable",
...(variant
? {
timeoutMs: Math.min(
NIP07_LOGIN_TIMEOUT_MS,
getEnableVariantTimeoutMs(),
),
retryMultiplier: 1,
}
: { retryMultiplier: 1 }),
},
);
enableError = null;
break;
} catch (error) {
enableError = error;
if (variant && isDevMode) {
console.warn(
"[auth:nip07] extension.enable request with explicit permissions failed:",
error,
);
}
}
}
if (enableError) {
throw new Error(
enableError && enableError.message
? enableError.message
: "The NIP-07 extension denied the permission request.",
);
}
if (typeof nostrClient.markExtensionPermissions === "function") {
try {
nostrClient.markExtensionPermissions(requestedPermissionMethods);
} catch (error) {
if (isDevMode) {
console.warn(
"[auth:nip07] Failed to mark extension permissions:",
error,
);
}
}
}
}
return { authType: PROVIDER_ID, pubkey, signer };
if (allowAccountSelection && typeof extension.selectAccounts === "function") {
try {
const selection = await runNip07WithRetry(
() => extension.selectAccounts(expectPubkey ? [expectPubkey] : undefined),
{ label: "extension.selectAccounts" },
);
const didCancelSelection =
selection === undefined ||
selection === null ||
selection === false ||
(Array.isArray(selection) && selection.length === 0);
if (didCancelSelection) {
throw new Error("Account selection was cancelled.");
}
} catch (selectionErr) {
const message =
selectionErr && typeof selectionErr.message === "string"
? selectionErr.message
: "Account selection was cancelled.";
throw new Error(message);
}
}
const pubkey = await runNip07WithRetry(() => extension.getPublicKey(), {
label: "extension.getPublicKey",
});
if (!pubkey || typeof pubkey !== "string") {
throw new Error(
"The NIP-07 extension did not return a public key. Please try again.",
);
}
if (normalizedExpectedPubkey && pubkey.toLowerCase() !== normalizedExpectedPubkey) {
throw new Error(
"The selected account doesn't match the expected profile. Please try again.",
);
}
const nip19Tools = await resolveNip19Tools(nostrClient);
const npubEncode = nip19Tools?.nip19?.npubEncode;
const npub = typeof npubEncode === "function" ? npubEncode(pubkey) : null;
if (isDevMode) {
console.log("[auth:nip07] Got pubkey:", pubkey);
if (npub) {
console.log("[auth:nip07] Converted to npub:", npub);
}
console.log("[auth:nip07] Whitelist:", accessControl.getWhitelist());
console.log("[auth:nip07] Blacklist:", accessControl.getBlacklist());
}
if (npub && !accessControl.canAccess(npub)) {
if (accessControl.isBlacklisted(npub)) {
throw new Error("Your account has been blocked on this platform.");
}
throw new Error("Access restricted to whitelisted users only.");
}
if (typeof nostrClient.markExtensionPermissions === "function") {
try {
nostrClient.markExtensionPermissions(DEFAULT_NIP07_PERMISSION_METHODS);
} catch (error) {
if (isDevMode) {
console.warn(
"[auth:nip07] Failed to memoize default permission set:",
error,
);
}
}
}
const signerPayload = (() => {
const signEvent =
typeof extension.signEvent === "function"
? extension.signEvent.bind(extension)
: null;
const encrypt =
extension.nip04 && typeof extension.nip04.encrypt === "function"
? extension.nip04.encrypt.bind(extension.nip04)
: null;
const decrypt =
extension.nip04 && typeof extension.nip04.decrypt === "function"
? extension.nip04.decrypt.bind(extension.nip04)
: null;
if (!signEvent && !encrypt && !decrypt) {
return null;
}
return {
providerId: PROVIDER_ID,
pubkey,
signEvent,
encrypt,
decrypt,
metadata: { label: PROVIDER_LABEL },
};
})();
if (signerPayload && typeof nostrClient.setActiveSigner === "function") {
try {
nostrClient.setActiveSigner(signerPayload);
} catch (error) {
if (isDevMode) {
console.warn("[auth:nip07] Failed to set active signer:", error);
}
}
} else if (typeof nostrClient.clearActiveSigner === "function") {
try {
nostrClient.clearActiveSigner(PROVIDER_ID);
} catch (error) {
if (isDevMode) {
console.warn(
"[auth:nip07] Failed to clear active signer after login:",
error,
);
}
}
}
if (typeof nostrClient.pubkey === "string" || nostrClient.pubkey === null) {
nostrClient.pubkey = pubkey;
}
const signerResult = signerPayload
? {
providerId: PROVIDER_ID,
signEvent: signerPayload.signEvent,
encrypt: signerPayload.encrypt,
decrypt: signerPayload.decrypt,
}
: null;
return { authType: PROVIDER_ID, pubkey, signer: signerResult };
}
export default Object.freeze({
+53 -6
View File
@@ -42,6 +42,27 @@ class SubscriptionsManager {
console.warn("[SubscriptionsManager] No pubkey => cannot load subs.");
return;
}
const activeSigner = nostrClient.getActiveSignerForPubkey(userPubkey);
const signerDecrypt =
activeSigner && typeof activeSigner.decrypt === "function"
? activeSigner.decrypt
: null;
const extensionDecrypt =
window?.nostr?.nip04?.decrypt
? window.nostr.nip04.decrypt.bind(window.nostr.nip04)
: null;
const decryptFn = signerDecrypt || extensionDecrypt;
if (!decryptFn) {
console.warn(
"[SubscriptionsManager] Decryption unavailable; treating subscription list as empty."
);
this.subscribedPubkeys.clear();
this.subsEventId = null;
this.loaded = true;
return;
}
try {
const filter = {
kinds: [30002],
@@ -76,10 +97,7 @@ class SubscriptionsManager {
let decryptedStr = "";
try {
decryptedStr = await window.nostr.nip04.decrypt(
userPubkey,
newest.content
);
decryptedStr = await decryptFn(userPubkey, newest.content);
} catch (errDecrypt) {
console.error("[SubscriptionsManager] Decryption failed:", errDecrypt);
this.subscribedPubkeys.clear();
@@ -157,6 +175,35 @@ class SubscriptionsManager {
throw new Error("No pubkey => cannot publish subscription list.");
}
const activeSigner = nostrClient.getActiveSignerForPubkey(userPubkey);
const signerEncrypt =
activeSigner && typeof activeSigner.encrypt === "function"
? activeSigner.encrypt
: null;
const extensionEncrypt =
window?.nostr?.nip04?.encrypt
? window.nostr.nip04.encrypt.bind(window.nostr.nip04)
: null;
const encryptFn = signerEncrypt || extensionEncrypt;
if (!encryptFn) {
throw new Error("NIP-04 encryption is required to update subscriptions.");
}
const signerSign =
activeSigner && typeof activeSigner.signEvent === "function"
? activeSigner.signEvent
: null;
const extensionSign =
typeof window?.nostr?.signEvent === "function"
? window.nostr.signEvent.bind(window.nostr)
: null;
const signFn = signerSign || extensionSign;
if (!signFn) {
throw new Error("Nostr signing support is required to update subscriptions.");
}
const plainObj = { subPubkeys: Array.from(this.subscribedPubkeys) };
const plainStr = JSON.stringify(plainObj);
@@ -170,7 +217,7 @@ class SubscriptionsManager {
*/
let cipherText = "";
try {
cipherText = await window.nostr.nip04.encrypt(userPubkey, plainStr);
cipherText = await encryptFn(userPubkey, plainStr);
} catch (err) {
console.error("Encryption failed:", err);
throw err;
@@ -184,7 +231,7 @@ class SubscriptionsManager {
let signedEvent;
try {
signedEvent = await window.nostr.signEvent(evt);
signedEvent = await signFn(evt);
} catch (signErr) {
console.error("Failed to sign subscription list:", signErr);
throw signErr;
+48 -18
View File
@@ -73,7 +73,18 @@ class UserBlockListManager {
return;
}
if (!window?.nostr?.nip04?.decrypt) {
const activeSigner = nostrClient.getActiveSignerForPubkey(normalized);
const signerDecrypt =
activeSigner && typeof activeSigner.decrypt === "function"
? activeSigner.decrypt
: null;
const extensionDecrypt =
window?.nostr?.nip04?.decrypt
? window.nostr.nip04.decrypt.bind(window.nostr.nip04)
: null;
const decryptFn = signerDecrypt || extensionDecrypt;
if (!decryptFn) {
console.warn(
"[UserBlockList] nip04.decrypt is unavailable; treating block list as empty."
);
@@ -200,7 +211,7 @@ class UserBlockListManager {
let decrypted = "";
try {
decrypted = await window.nostr.nip04.decrypt(normalized, newest.content);
decrypted = await decryptFn(normalized, newest.content);
} catch (err) {
console.error("[UserBlockList] Failed to decrypt block list:", err);
this.blockedPubkeys.clear();
@@ -373,25 +384,44 @@ class UserBlockListManager {
}
async publishBlockList(userPubkey) {
if (!window?.nostr?.nip04?.encrypt) {
const err = new Error(
"NIP-04 encryption is required to update the block list."
);
err.code = "nip04-missing";
throw err;
}
if (typeof window.nostr.signEvent !== "function") {
const err = new Error("Nostr extension missing signEvent support.");
err.code = "nip04-missing";
throw err;
}
const normalized = normalizeHex(userPubkey);
if (!normalized) {
throw new Error("Invalid user pubkey.");
}
const activeSigner = nostrClient.getActiveSignerForPubkey(normalized);
const signerEncrypt =
activeSigner && typeof activeSigner.encrypt === "function"
? activeSigner.encrypt
: null;
const extensionEncrypt =
window?.nostr?.nip04?.encrypt
? window.nostr.nip04.encrypt.bind(window.nostr.nip04)
: null;
const encryptFn = signerEncrypt || extensionEncrypt;
if (!encryptFn) {
const err = new Error("NIP-04 encryption is required to update the block list.");
err.code = "nip04-missing";
throw err;
}
const signerSign =
activeSigner && typeof activeSigner.signEvent === "function"
? activeSigner.signEvent
: null;
const extensionSign =
typeof window?.nostr?.signEvent === "function"
? window.nostr.signEvent.bind(window.nostr)
: null;
const signFn = signerSign || extensionSign;
if (!signFn) {
const err = new Error("Nostr extension missing signEvent support.");
err.code = "nip04-missing";
throw err;
}
const payload = {
blockedPubkeys: Array.from(this.blockedPubkeys).filter(
(candidate) => candidate && candidate !== normalized
@@ -401,7 +431,7 @@ class UserBlockListManager {
let cipherText = "";
try {
cipherText = await window.nostr.nip04.encrypt(normalized, plaintext);
cipherText = await encryptFn(normalized, plaintext);
} catch (error) {
const err = new Error("Failed to encrypt block list.");
err.code = "nip04-missing";
@@ -414,7 +444,7 @@ class UserBlockListManager {
content: cipherText,
});
const signedEvent = await window.nostr.signEvent(event);
const signedEvent = await signFn(event);
const publishResults = await publishEventToRelays(
nostrClient.pool,