Files
nostr_login_lite/lite/nostr-lite.js

2380 lines
80 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* NOSTR_LOGIN_LITE - Authentication Library
* Two-file architecture:
* 1. Load nostr.bundle.js (official nostr-tools bundle)
* 2. Load nostr-lite.js (this file - consolidated NOSTR_LOGIN_LITE library with NIP-46)
* Generated on: 2025-09-13T18:23:00.000Z
*/
// Verify dependencies are loaded
if (typeof window !== 'undefined') {
if (!window.NostrTools) {
console.error('NOSTR_LOGIN_LITE: nostr.bundle.js must be loaded first');
throw new Error('Missing dependency: nostr.bundle.js');
}
console.log('NOSTR_LOGIN_LITE: Dependencies verified ✓');
console.log('NOSTR_LOGIN_LITE: NostrTools available with keys:', Object.keys(window.NostrTools));
}
// ======================================
// NIP-46 Extension (formerly nip46-extension.js)
// ======================================
(function() {
'use strict';
// Check if NostrTools is available
if (typeof window.NostrTools === 'undefined') {
console.error('NIP-46 Extension requires nostr-tools to be loaded first');
return;
}
const { nip44, generateSecretKey, getPublicKey, finalizeEvent, verifyEvent, utils } = window.NostrTools;
// NIP-05 regex for parsing
const NIP05_REGEX = /^(?:([\w.+-]+)@)?([\w_-]+(\.[\w_-]+)+)$/;
const BUNKER_REGEX = /^bunker:\/\/([0-9a-f]{64})\??([?\/\w:.=&%-]*)$/;
const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
// Event kinds
const NostrConnect = 24133;
const ClientAuth = 22242;
const Handlerinformation = 31990;
// Fetch implementation
let _fetch;
try {
_fetch = fetch;
} catch {
_fetch = null;
}
function useFetchImplementation(fetchImplementation) {
_fetch = fetchImplementation;
}
// Simple Pool implementation for NIP-46
class SimplePool {
constructor() {
this.relays = new Map();
this.subscriptions = new Map();
}
async ensureRelay(url) {
if (!this.relays.has(url)) {
console.log(`NIP-46: Connecting to relay ${url}`);
const ws = new WebSocket(url);
const relay = {
ws,
connected: false,
subscriptions: new Map()
};
this.relays.set(url, relay);
// Wait for connection with proper event handlers
await new Promise((resolve, reject) => {
const timeout = setTimeout(() => {
console.error(`NIP-46: Connection timeout for ${url}`);
reject(new Error(`Connection timeout to ${url}`));
}, 10000); // 10 second timeout
ws.onopen = () => {
console.log(`NIP-46: Successfully connected to relay ${url}, WebSocket state: ${ws.readyState}`);
relay.connected = true;
clearTimeout(timeout);
resolve();
};
ws.onerror = (error) => {
console.error(`NIP-46: Failed to connect to ${url}:`, error);
clearTimeout(timeout);
reject(new Error(`Failed to connect to ${url}: ${error.message || 'Connection failed'}`));
};
ws.onclose = (event) => {
console.log(`NIP-46: Disconnected from relay ${url}:`, event.code, event.reason);
relay.connected = false;
if (this.relays.has(url)) {
this.relays.delete(url);
}
clearTimeout(timeout);
reject(new Error(`Connection closed during setup: ${event.reason || 'Unknown reason'}`));
};
});
} else {
const relay = this.relays.get(url);
// Verify the existing connection is still open
if (!relay.connected || relay.ws.readyState !== WebSocket.OPEN) {
console.log(`NIP-46: Reconnecting to relay ${url}`);
this.relays.delete(url);
return await this.ensureRelay(url); // Recursively reconnect
}
}
const relay = this.relays.get(url);
console.log(`NIP-46: Relay ${url} ready, WebSocket state: ${relay.ws.readyState}`);
return relay;
}
subscribe(relays, filters, params = {}) {
const subId = Math.random().toString(36).substring(7);
relays.forEach(async (url) => {
try {
const relay = await this.ensureRelay(url);
relay.ws.onmessage = (event) => {
try {
const data = JSON.parse(event.data);
if (data[0] === 'EVENT' && data[1] === subId) {
params.onevent?.(data[2]);
} else if (data[0] === 'EOSE' && data[1] === subId) {
params.oneose?.();
}
} catch (err) {
console.warn('Failed to parse message:', err);
}
};
// Ensure filters is an array
const filtersArray = Array.isArray(filters) ? filters : [filters];
const reqMsg = JSON.stringify(['REQ', subId, ...filtersArray]);
relay.ws.send(reqMsg);
} catch (err) {
console.warn('Failed to connect to relay:', url, err);
}
});
return {
close: () => {
relays.forEach(async (url) => {
const relay = this.relays.get(url);
if (relay?.connected) {
relay.ws.send(JSON.stringify(['CLOSE', subId]));
}
});
}
};
}
async publish(relays, event) {
console.log(`NIP-46: Publishing event to ${relays.length} relays:`, event);
const promises = relays.map(async (url) => {
try {
console.log(`NIP-46: Attempting to publish to ${url}`);
const relay = await this.ensureRelay(url);
return new Promise((resolve, reject) => {
const timeout = setTimeout(() => {
console.error(`NIP-46: Publish timeout to ${url}`);
reject(new Error(`Publish timeout to ${url}`));
}, 10000); // Increased timeout to 10 seconds
// Set up message handler for this specific event
const messageHandler = (msg) => {
try {
const data = JSON.parse(msg.data);
if (data[0] === 'OK' && data[1] === event.id) {
clearTimeout(timeout);
relay.ws.removeEventListener('message', messageHandler);
if (data[2]) {
console.log(`NIP-46: Publish success to ${url}:`, data[3]);
resolve(data[3]);
} else {
console.error(`NIP-46: Publish rejected by ${url}:`, data[3]);
reject(new Error(`Publish rejected: ${data[3]}`));
}
}
} catch (err) {
console.error(`NIP-46: Error parsing message from ${url}:`, err);
clearTimeout(timeout);
relay.ws.removeEventListener('message', messageHandler);
reject(err);
}
};
relay.ws.addEventListener('message', messageHandler);
// Double-check WebSocket state before sending
console.log(`NIP-46: About to publish to ${url}, WebSocket state: ${relay.ws.readyState} (0=CONNECTING, 1=OPEN, 2=CLOSING, 3=CLOSED)`);
if (relay.ws.readyState === WebSocket.OPEN) {
console.log(`NIP-46: Sending event to ${url}`);
relay.ws.send(JSON.stringify(['EVENT', event]));
} else {
console.error(`NIP-46: WebSocket not ready for ${url}, state: ${relay.ws.readyState}`);
clearTimeout(timeout);
relay.ws.removeEventListener('message', messageHandler);
reject(new Error(`WebSocket not ready for ${url}, state: ${relay.ws.readyState}`));
}
});
} catch (err) {
console.error(`NIP-46: Failed to publish to ${url}:`, err);
return Promise.reject(new Error(`Failed to publish to ${url}: ${err.message}`));
}
});
const results = await Promise.allSettled(promises);
console.log(`NIP-46: Publish results:`, results);
return results;
}
async querySync(relays, filter, params = {}) {
return new Promise((resolve) => {
const events = [];
this.subscribe(relays, [filter], {
...params,
onevent: (event) => events.push(event),
oneose: () => resolve(events)
});
});
}
}
// Bunker URL utilities
function toBunkerURL(bunkerPointer) {
let bunkerURL = new URL(`bunker://${bunkerPointer.pubkey}`);
bunkerPointer.relays.forEach((relay) => {
bunkerURL.searchParams.append('relay', relay);
});
if (bunkerPointer.secret) {
bunkerURL.searchParams.set('secret', bunkerPointer.secret);
}
return bunkerURL.toString();
}
async function parseBunkerInput(input) {
let match = input.match(BUNKER_REGEX);
if (match) {
try {
const pubkey = match[1];
const qs = new URLSearchParams(match[2]);
return {
pubkey,
relays: qs.getAll('relay'),
secret: qs.get('secret')
};
} catch (_err) {
// Continue to NIP-05 parsing
}
}
return queryBunkerProfile(input);
}
async function queryBunkerProfile(nip05) {
if (!_fetch) {
throw new Error('Fetch implementation not available');
}
const match = nip05.match(NIP05_REGEX);
if (!match) return null;
const [_, name = '_', domain] = match;
try {
const url = `https://${domain}/.well-known/nostr.json?name=${name}`;
const res = await (await _fetch(url, { redirect: 'error' })).json();
let pubkey = res.names[name];
let relays = res.nip46[pubkey] || [];
return { pubkey, relays, secret: null };
} catch (_err) {
return null;
}
}
// BunkerSigner class
class BunkerSigner {
constructor(clientSecretKey, bp, params = {}) {
if (bp.relays.length === 0) {
throw new Error('no relays are specified for this bunker');
}
this.params = params;
this.pool = params.pool || new SimplePool();
this.secretKey = clientSecretKey;
this.conversationKey = nip44.getConversationKey(clientSecretKey, bp.pubkey);
this.bp = bp;
this.isOpen = false;
this.idPrefix = Math.random().toString(36).substring(7);
this.serial = 0;
this.listeners = {};
this.waitingForAuth = {};
this.ready = false;
this.readyPromise = this.setupSubscription(params);
}
async setupSubscription(params) {
console.log('NIP-46: Setting up subscription to relays:', this.bp.relays);
const listeners = this.listeners;
const waitingForAuth = this.waitingForAuth;
const convKey = this.conversationKey;
// Ensure all relays are connected first
await Promise.all(this.bp.relays.map(url => this.pool.ensureRelay(url)));
console.log('NIP-46: All relays connected, setting up subscription');
this.subCloser = this.pool.subscribe(
this.bp.relays,
[{ kinds: [NostrConnect], authors: [this.bp.pubkey], '#p': [getPublicKey(this.secretKey)] }],
{
onevent: async (event) => {
const o = JSON.parse(nip44.decrypt(event.content, convKey));
const { id, result, error } = o;
if (result === 'auth_url' && waitingForAuth[id]) {
delete waitingForAuth[id];
if (params.onauth) {
params.onauth(error);
} else {
console.warn(
`NIP-46: remote signer ${this.bp.pubkey} tried to send an "auth_url"='${error}' but there was no onauth() callback configured.`
);
}
return;
}
let handler = listeners[id];
if (handler) {
if (error) handler.reject(error);
else if (result) handler.resolve(result);
delete listeners[id];
}
},
onclose: () => {
this.subCloser = undefined;
}
}
);
this.isOpen = true;
this.ready = true;
console.log('NIP-46: BunkerSigner setup complete and ready');
}
async ensureReady() {
if (!this.ready) {
console.log('NIP-46: Waiting for BunkerSigner to be ready...');
await this.readyPromise;
}
}
async close() {
this.isOpen = false;
this.subCloser?.close();
}
async sendRequest(method, params) {
return new Promise(async (resolve, reject) => {
try {
await this.ensureReady(); // Wait for BunkerSigner to be ready
if (!this.isOpen) {
throw new Error('this signer is not open anymore, create a new one');
}
if (!this.subCloser) {
await this.setupSubscription(this.params);
}
this.serial++;
const id = `${this.idPrefix}-${this.serial}`;
const encryptedContent = nip44.encrypt(JSON.stringify({ id, method, params }), this.conversationKey);
const verifiedEvent = finalizeEvent(
{
kind: NostrConnect,
tags: [['p', this.bp.pubkey]],
content: encryptedContent,
created_at: Math.floor(Date.now() / 1000)
},
this.secretKey
);
this.listeners[id] = { resolve, reject };
this.waitingForAuth[id] = true;
console.log(`NIP-46: Sending ${method} request with id ${id}`);
const publishResults = await this.pool.publish(this.bp.relays, verifiedEvent);
// Check if at least one publish succeeded
const hasSuccess = publishResults.some(result => result.status === 'fulfilled');
if (!hasSuccess) {
throw new Error('Failed to publish to any relay');
}
console.log(`NIP-46: ${method} request sent successfully`);
} catch (err) {
console.error(`NIP-46: sendRequest ${method} failed:`, err);
reject(err);
}
});
}
async ping() {
let resp = await this.sendRequest('ping', []);
if (resp !== 'pong') {
throw new Error(`result is not pong: ${resp}`);
}
}
async connect() {
await this.sendRequest('connect', [this.bp.pubkey, this.bp.secret || '']);
}
async getPublicKey() {
if (!this.cachedPubKey) {
this.cachedPubKey = await this.sendRequest('get_public_key', []);
}
return this.cachedPubKey;
}
async signEvent(event) {
let resp = await this.sendRequest('sign_event', [JSON.stringify(event)]);
let signed = JSON.parse(resp);
if (verifyEvent(signed)) {
return signed;
} else {
throw new Error(`event returned from bunker is improperly signed: ${JSON.stringify(signed)}`);
}
}
async nip04Encrypt(thirdPartyPubkey, plaintext) {
return await this.sendRequest('nip04_encrypt', [thirdPartyPubkey, plaintext]);
}
async nip04Decrypt(thirdPartyPubkey, ciphertext) {
return await this.sendRequest('nip04_decrypt', [thirdPartyPubkey, ciphertext]);
}
async nip44Encrypt(thirdPartyPubkey, plaintext) {
return await this.sendRequest('nip44_encrypt', [thirdPartyPubkey, plaintext]);
}
async nip44Decrypt(thirdPartyPubkey, ciphertext) {
return await this.sendRequest('nip44_decrypt', [thirdPartyPubkey, ciphertext]);
}
}
async function createAccount(bunker, params, username, domain, email, localSecretKey = generateSecretKey()) {
if (email && !EMAIL_REGEX.test(email)) {
throw new Error('Invalid email');
}
let rpc = new BunkerSigner(localSecretKey, bunker.bunkerPointer, params);
let pubkey = await rpc.sendRequest('create_account', [username, domain, email || '']);
rpc.bp.pubkey = pubkey;
await rpc.connect();
return rpc;
}
async function fetchBunkerProviders(pool, relays) {
const events = await pool.querySync(relays, {
kinds: [Handlerinformation],
'#k': [NostrConnect.toString()]
});
events.sort((a, b) => b.created_at - a.created_at);
const validatedBunkers = await Promise.all(
events.map(async (event, i) => {
try {
const content = JSON.parse(event.content);
try {
if (events.findIndex((ev) => JSON.parse(ev.content).nip05 === content.nip05) !== i) {
return undefined;
}
} catch (err) {
// Continue processing
}
const bp = await queryBunkerProfile(content.nip05);
if (bp && bp.pubkey === event.pubkey && bp.relays.length) {
return {
bunkerPointer: bp,
nip05: content.nip05,
domain: content.nip05.split('@')[1],
name: content.name || content.display_name,
picture: content.picture,
about: content.about,
website: content.website,
local: false
};
}
} catch (err) {
return undefined;
}
})
);
return validatedBunkers.filter((b) => b !== undefined);
}
// Extend NostrTools with NIP-46 functionality
window.NostrTools.nip46 = {
BunkerSigner,
parseBunkerInput,
toBunkerURL,
queryBunkerProfile,
createAccount,
fetchBunkerProviders,
useFetchImplementation,
BUNKER_REGEX,
SimplePool
};
console.log('NIP-46 extension loaded successfully (embedded)');
console.log('Available: NostrTools.nip46');
})();
// Verify NIP-46 extension is now available
if (typeof window !== 'undefined') {
console.log('NOSTR_LOGIN_LITE: NIP-46 available:', !!window.NostrTools.nip46);
}
// ======================================
// NOSTR_LOGIN_LITE Components
// ======================================
// ======================================
// Modal UI Component
// ======================================
class Modal {
constructor(options) {
this.options = options;
this.container = null;
this.isVisible = false;
this.currentScreen = null;
// Initialize modal container and styles
this._initModal();
}
_initModal() {
// Create modal container
this.container = document.createElement('div');
this.container.id = 'nl-modal';
this.container.style.cssText = `
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.75);
display: none;
z-index: 10000;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
`;
// Create modal content
const modalContent = document.createElement('div');
modalContent.style.cssText = `
position: relative;
background: white;
width: 90%;
max-width: 400px;
margin: 50px auto;
border-radius: 12px;
box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04);
max-height: 600px;
overflow: hidden;
`;
// Header
const modalHeader = document.createElement('div');
modalHeader.style.cssText = `
padding: 20px 24px 0 24px;
display: flex;
justify-content: space-between;
align-items: center;
`;
const modalTitle = document.createElement('h2');
modalTitle.textContent = 'Nostr Login';
modalTitle.style.cssText = `
margin: 0;
font-size: 24px;
font-weight: 600;
color: #1f2937;
`;
const closeButton = document.createElement('button');
closeButton.innerHTML = '×';
closeButton.onclick = () => this.close();
closeButton.style.cssText = `
background: none;
border: none;
font-size: 28px;
color: #6b7280;
cursor: pointer;
padding: 0;
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 6px;
`;
closeButton.onmouseover = () => closeButton.style.background = '#f3f4f6';
closeButton.onmouseout = () => closeButton.style.background = 'none';
modalHeader.appendChild(modalTitle);
modalHeader.appendChild(closeButton);
// Body
this.modalBody = document.createElement('div');
this.modalBody.style.cssText = `
padding: 24px;
overflow-y: auto;
max-height: 500px;
`;
modalContent.appendChild(modalHeader);
modalContent.appendChild(this.modalBody);
this.container.appendChild(modalContent);
// Add to body
document.body.appendChild(this.container);
// Click outside to close
this.container.onclick = (e) => {
if (e.target === this.container) {
this.close();
}
};
// Update theme
this.updateTheme();
}
updateTheme() {
const isDark = this.options?.darkMode;
const modalContent = this.container.querySelector(':nth-child(1)');
const title = this.container.querySelector('h2');
if (isDark) {
modalContent.style.background = '#1f2937';
title.style.color = 'white';
} else {
modalContent.style.background = 'white';
title.style.color = '#1f2937';
}
}
open(opts = {}) {
this.currentScreen = opts.startScreen;
this.isVisible = true;
this.container.style.display = 'block';
// Render login options
this._renderLoginOptions();
}
close() {
this.isVisible = false;
this.container.style.display = 'none';
this.modalBody.innerHTML = '';
}
_renderLoginOptions() {
this.modalBody.innerHTML = '';
const options = [];
// Extension option
if (this.options?.methods?.extension !== false) {
options.push({
type: 'extension',
title: 'Browser Extension',
description: 'Use your browser extension',
icon: '🔌'
});
}
// Local key option
if (this.options?.methods?.local !== false) {
options.push({
type: 'local',
title: 'Local Key',
description: 'Create or import your own key',
icon: '🔑'
});
}
// Nostr Connect option (check both 'connect' and 'remote' for compatibility)
if (this.options?.methods?.connect !== false && this.options?.methods?.remote !== false) {
options.push({
type: 'connect',
title: 'Nostr Connect',
description: 'Connect with external signer',
icon: '🌐'
});
}
// Read-only option
if (this.options?.methods?.readonly !== false) {
options.push({
type: 'readonly',
title: 'Read Only',
description: 'Browse without signing',
icon: '👁️'
});
}
// OTP/DM option
if (this.options?.methods?.otp !== false) {
options.push({
type: 'otp',
title: 'DM/OTP',
description: 'Receive OTP via DM',
icon: '📱'
});
}
// Render each option
options.forEach(option => {
const button = document.createElement('button');
button.onclick = () => this._handleOptionClick(option.type);
button.style.cssText = `
display: flex;
align-items: center;
width: 100%;
padding: 16px;
margin-bottom: 12px;
background: ${this.options?.darkMode ? '#374151' : 'white'};
border: 1px solid ${this.options?.darkMode ? '#4b5563' : '#d1d5db'};
border-radius: 8px;
cursor: pointer;
transition: all 0.2s;
`;
button.onmouseover = () => {
button.style.boxShadow = '0 4px 6px -1px rgba(0, 0, 0, 0.1)';
};
button.onmouseout = () => {
button.style.boxShadow = 'none';
};
const iconDiv = document.createElement('div');
iconDiv.textContent = option.icon;
iconDiv.style.cssText = `
font-size: 24px;
margin-right: 16px;
width: 24px;
text-align: center;
`;
const contentDiv = document.createElement('div');
contentDiv.style.cssText = 'flex: 1; text-align: left;';
const titleDiv = document.createElement('div');
titleDiv.textContent = option.title;
titleDiv.style.cssText = `
font-weight: 600;
margin-bottom: 4px;
color: ${this.options?.darkMode ? 'white' : '#1f2937'};
`;
const descDiv = document.createElement('div');
descDiv.textContent = option.description;
descDiv.style.cssText = `
font-size: 14px;
color: ${this.options?.darkMode ? '#9ca3af' : '#6b7280'};
`;
contentDiv.appendChild(titleDiv);
contentDiv.appendChild(descDiv);
button.appendChild(iconDiv);
button.appendChild(contentDiv);
this.modalBody.appendChild(button);
});
}
_handleOptionClick(type) {
console.log('Selected login type:', type);
// Handle different login types
switch (type) {
case 'extension':
this._handleExtension();
break;
case 'local':
this._showLocalKeyScreen();
break;
case 'connect':
this._showConnectScreen();
break;
case 'readonly':
this._handleReadonly();
break;
case 'otp':
this._showOtpScreen();
break;
}
}
_handleExtension() {
// Detect all available real extensions
const availableExtensions = this._detectAllExtensions();
console.log(`Modal: Found ${availableExtensions.length} extensions:`, availableExtensions.map(e => e.displayName));
if (availableExtensions.length === 0) {
console.log('Modal: No real extensions found');
this._showExtensionRequired();
} else if (availableExtensions.length === 1) {
// Single extension - use it directly without showing choice UI
console.log('Modal: Single extension detected, using it directly:', availableExtensions[0].displayName);
this._tryExtensionLogin(availableExtensions[0].extension);
} else {
// Multiple extensions - show choice UI
console.log('Modal: Multiple extensions detected, showing choice UI for', availableExtensions.length, 'extensions');
this._showExtensionChoice(availableExtensions);
}
}
_detectAllExtensions() {
const extensions = [];
const seenExtensions = new Set(); // Track extensions by object reference to avoid duplicates
// Extension locations to check (in priority order)
const locations = [
{ path: 'window.navigator?.nostr', name: 'navigator.nostr', displayName: 'Standard Extension (navigator.nostr)', icon: '🌐', getter: () => window.navigator?.nostr },
{ path: 'window.webln?.nostr', name: 'webln.nostr', displayName: 'Alby WebLN Extension', icon: '⚡', getter: () => window.webln?.nostr },
{ path: 'window.alby?.nostr', name: 'alby.nostr', displayName: 'Alby Extension (Direct)', icon: '🐝', getter: () => window.alby?.nostr },
{ path: 'window.nos2x', name: 'nos2x', displayName: 'nos2x Extension', icon: '🔌', getter: () => window.nos2x },
{ path: 'window.flamingo?.nostr', name: 'flamingo.nostr', displayName: 'Flamingo Extension', icon: '🦩', getter: () => window.flamingo?.nostr },
{ path: 'window.mutiny?.nostr', name: 'mutiny.nostr', displayName: 'Mutiny Extension', icon: '⚔️', getter: () => window.mutiny?.nostr },
{ path: 'window.nostrich?.nostr', name: 'nostrich.nostr', displayName: 'Nostrich Extension', icon: '🐦', getter: () => window.nostrich?.nostr },
{ path: 'window.getAlby?.nostr', name: 'getAlby.nostr', displayName: 'getAlby Extension', icon: '🔧', getter: () => window.getAlby?.nostr }
];
// Check each location
for (const location of locations) {
try {
const obj = location.getter();
console.log(`Modal: Checking ${location.name}:`, !!obj, obj?.constructor?.name);
if (obj && this._isRealExtension(obj) && !seenExtensions.has(obj)) {
extensions.push({
name: location.name,
displayName: location.displayName,
icon: location.icon,
extension: obj
});
seenExtensions.add(obj);
console.log(`Modal: ✓ Detected extension at ${location.name} (${obj.constructor?.name})`);
} else if (obj) {
console.log(`Modal: ✗ Filtered out ${location.name} (${obj.constructor?.name})`);
}
} catch (e) {
// Location doesn't exist or can't be accessed
console.log(`Modal: ${location.name} not accessible:`, e.message);
}
}
// Also check window.nostr but be extra careful to avoid our library
console.log('Modal: Checking window.nostr:', !!window.nostr, window.nostr?.constructor?.name);
if (window.nostr && this._isRealExtension(window.nostr) && !seenExtensions.has(window.nostr)) {
extensions.push({
name: 'window.nostr',
displayName: 'Extension (window.nostr)',
icon: '🔑',
extension: window.nostr
});
seenExtensions.add(window.nostr);
console.log(`Modal: ✓ Detected extension at window.nostr: ${window.nostr.constructor?.name}`);
} else if (window.nostr) {
console.log(`Modal: ✗ Filtered out window.nostr (${window.nostr.constructor?.name}) - likely our library`);
}
return extensions;
}
_isRealExtension(obj) {
console.log(`Modal: EXTENSIVE DEBUG - _isRealExtension called with:`, obj);
console.log(`Modal: Object type: ${typeof obj}`);
console.log(`Modal: Object truthy: ${!!obj}`);
if (!obj || typeof obj !== 'object') {
console.log(`Modal: REJECT - Not an object`);
return false;
}
console.log(`Modal: getPublicKey type: ${typeof obj.getPublicKey}`);
console.log(`Modal: signEvent type: ${typeof obj.signEvent}`);
// Must have required Nostr methods
if (typeof obj.getPublicKey !== 'function' || typeof obj.signEvent !== 'function') {
console.log(`Modal: REJECT - Missing required methods`);
return false;
}
// Exclude NostrTools library object
if (obj === window.NostrTools) {
console.log(`Modal: REJECT - Is NostrTools object`);
return false;
}
// Use the EXACT SAME logic as the comprehensive test (lines 804-809)
// This is the key fix - match the comprehensive test's successful detection logic
const constructorName = obj.constructor?.name;
const objectKeys = Object.keys(obj);
console.log(`Modal: Constructor name: "${constructorName}"`);
console.log(`Modal: Object keys: [${objectKeys.join(', ')}]`);
// COMPREHENSIVE TEST LOGIC - Accept anything with required methods that's not our specific library classes
const isRealExtension = (
typeof obj.getPublicKey === 'function' &&
typeof obj.signEvent === 'function' &&
constructorName !== 'WindowNostr' && // Our library class
constructorName !== 'NostrLite' // Our main class
);
console.log(`Modal: Using comprehensive test logic:`);
console.log(` Has getPublicKey: ${typeof obj.getPublicKey === 'function'}`);
console.log(` Has signEvent: ${typeof obj.signEvent === 'function'}`);
console.log(` Not WindowNostr: ${constructorName !== 'WindowNostr'}`);
console.log(` Not NostrLite: ${constructorName !== 'NostrLite'}`);
console.log(` Constructor: "${constructorName}"`);
// Additional debugging for comparison
const extensionPropChecks = {
_isEnabled: !!obj._isEnabled,
enabled: !!obj.enabled,
kind: !!obj.kind,
_eventEmitter: !!obj._eventEmitter,
_scope: !!obj._scope,
_requests: !!obj._requests,
_pubkey: !!obj._pubkey,
name: !!obj.name,
version: !!obj.version,
description: !!obj.description
};
console.log(`Modal: Extension property analysis:`, extensionPropChecks);
const hasExtensionProps = !!(
obj._isEnabled || obj.enabled || obj.kind ||
obj._eventEmitter || obj._scope || obj._requests || obj._pubkey ||
obj.name || obj.version || obj.description
);
const underscoreKeys = objectKeys.filter(key => key.startsWith('_'));
const hexToUint8Keys = objectKeys.filter(key => key.startsWith('_hex'));
console.log(`Modal: Underscore keys: [${underscoreKeys.join(', ')}]`);
console.log(`Modal: _hex* keys: [${hexToUint8Keys.join(', ')}]`);
console.log(`Modal: Additional analysis:`);
console.log(` hasExtensionProps: ${hasExtensionProps}`);
console.log(` hasLibraryMethod (_hexToUint8Array): ${objectKeys.includes('_hexToUint8Array')}`);
console.log(`Modal: COMPREHENSIVE TEST LOGIC RESULT: ${isRealExtension ? 'ACCEPT' : 'REJECT'}`);
console.log(`Modal: FINAL DECISION for ${constructorName}: ${isRealExtension ? 'ACCEPT' : 'REJECT'}`);
return isRealExtension;
}
_showExtensionChoice(extensions) {
this.modalBody.innerHTML = '';
const title = document.createElement('h3');
title.textContent = 'Choose Browser Extension';
title.style.cssText = 'margin: 0 0 16px 0; font-size: 18px; font-weight: 600;';
const description = document.createElement('p');
description.textContent = `Found ${extensions.length} Nostr extensions. Choose which one to use:`;
description.style.cssText = 'margin-bottom: 20px; color: #6b7280; font-size: 14px;';
this.modalBody.appendChild(title);
this.modalBody.appendChild(description);
// Create button for each extension
extensions.forEach((ext, index) => {
const button = document.createElement('button');
button.onclick = () => this._tryExtensionLogin(ext.extension);
button.style.cssText = `
display: flex;
align-items: center;
width: 100%;
padding: 16px;
margin-bottom: 12px;
background: ${this.options?.darkMode ? '#374151' : 'white'};
border: 1px solid ${this.options?.darkMode ? '#4b5563' : '#d1d5db'};
border-radius: 8px;
cursor: pointer;
transition: all 0.2s;
text-align: left;
`;
button.onmouseover = () => {
button.style.boxShadow = '0 4px 6px -1px rgba(0, 0, 0, 0.1)';
button.style.transform = 'translateY(-1px)';
};
button.onmouseout = () => {
button.style.boxShadow = 'none';
button.style.transform = 'none';
};
const iconDiv = document.createElement('div');
iconDiv.textContent = ext.icon;
iconDiv.style.cssText = `
font-size: 24px;
margin-right: 16px;
width: 24px;
text-align: center;
`;
const contentDiv = document.createElement('div');
contentDiv.style.cssText = 'flex: 1;';
const nameDiv = document.createElement('div');
nameDiv.textContent = ext.displayName;
nameDiv.style.cssText = `
font-weight: 600;
margin-bottom: 4px;
color: ${this.options?.darkMode ? 'white' : '#1f2937'};
`;
const pathDiv = document.createElement('div');
pathDiv.textContent = ext.name;
pathDiv.style.cssText = `
font-size: 12px;
color: ${this.options?.darkMode ? '#9ca3af' : '#6b7280'};
font-family: monospace;
`;
contentDiv.appendChild(nameDiv);
contentDiv.appendChild(pathDiv);
button.appendChild(iconDiv);
button.appendChild(contentDiv);
this.modalBody.appendChild(button);
});
// Add back button
const backButton = document.createElement('button');
backButton.textContent = 'Back to Login Options';
backButton.onclick = () => this._renderLoginOptions();
backButton.style.cssText = this._getButtonStyle('secondary') + 'margin-top: 20px;';
this.modalBody.appendChild(backButton);
}
async _tryExtensionLogin(extensionObj) {
try {
// Show loading state
this.modalBody.innerHTML = '<div style="text-align: center; padding: 20px;">🔄 Connecting to extension...</div>';
// Get pubkey from extension
const pubkey = await extensionObj.getPublicKey();
console.log('Extension provided pubkey:', pubkey);
// Set extension method with the extension object
this._setAuthMethod('extension', { pubkey, extension: extensionObj });
} catch (error) {
console.error('Extension login failed:', error);
this._showError(`Extension login failed: ${error.message}`);
}
}
_showLocalKeyScreen() {
this.modalBody.innerHTML = '';
const title = document.createElement('h3');
title.textContent = 'Local Key';
title.style.cssText = 'margin: 0 0 20px 0; font-size: 18px; font-weight: 600;';
const createButton = document.createElement('button');
createButton.textContent = 'Create New Key';
createButton.onclick = () => this._createLocalKey();
createButton.style.cssText = this._getButtonStyle();
const importButton = document.createElement('button');
importButton.textContent = 'Import Existing Key';
importButton.onclick = () => this._showImportKeyForm();
importButton.style.cssText = this._getButtonStyle() + 'margin-top: 12px;';
const backButton = document.createElement('button');
backButton.textContent = 'Back';
backButton.onclick = () => this._renderLoginOptions();
backButton.style.cssText = `
display: block;
margin-top: 20px;
padding: 12px;
background: #6b7280;
color: white;
border: none;
border-radius: 6px;
cursor: pointer;
`;
this.modalBody.appendChild(title);
this.modalBody.appendChild(createButton);
this.modalBody.appendChild(importButton);
this.modalBody.appendChild(backButton);
}
_createLocalKey() {
try {
const sk = window.NostrTools.generateSecretKey();
const pk = window.NostrTools.getPublicKey(sk);
const nsec = window.NostrTools.nip19.nsecEncode(sk);
const npub = window.NostrTools.nip19.npubEncode(pk);
this._showKeyDisplay(pk, nsec, 'created');
} catch (error) {
this._showError('Failed to create key: ' + error.message);
}
}
_showImportKeyForm() {
this.modalBody.innerHTML = '';
const title = document.createElement('h3');
title.textContent = 'Import Local Key';
title.style.cssText = 'margin: 0 0 16px 0; font-size: 18px; font-weight: 600;';
const description = document.createElement('p');
description.textContent = 'Enter your secret key in either nsec or hex format:';
description.style.cssText = 'margin-bottom: 12px; color: #6b7280; font-size: 14px;';
const textarea = document.createElement('textarea');
textarea.placeholder = 'Enter your secret key:\n• nsec1... (bech32 format)\n• 64-character hex string';
textarea.style.cssText = `
width: 100%;
height: 100px;
padding: 12px;
border: 1px solid #d1d5db;
border-radius: 6px;
margin-bottom: 12px;
resize: none;
font-family: monospace;
font-size: 14px;
box-sizing: border-box;
`;
// Add real-time format detection
const formatHint = document.createElement('div');
formatHint.style.cssText = 'margin-bottom: 16px; font-size: 12px; color: #6b7280; min-height: 16px;';
textarea.oninput = () => {
const value = textarea.value.trim();
if (!value) {
formatHint.textContent = '';
return;
}
const format = this._detectKeyFormat(value);
if (format === 'nsec') {
formatHint.textContent = '✅ Valid nsec format detected';
formatHint.style.color = '#059669';
} else if (format === 'hex') {
formatHint.textContent = '✅ Valid hex format detected';
formatHint.style.color = '#059669';
} else {
formatHint.textContent = '❌ Invalid key format - must be nsec1... or 64-character hex';
formatHint.style.color = '#dc2626';
}
};
const importButton = document.createElement('button');
importButton.textContent = 'Import Key';
importButton.onclick = () => this._importLocalKey(textarea.value);
importButton.style.cssText = this._getButtonStyle();
const backButton = document.createElement('button');
backButton.textContent = 'Back';
backButton.onclick = () => this._showLocalKeyScreen();
backButton.style.cssText = this._getButtonStyle('secondary') + 'margin-top: 12px;';
this.modalBody.appendChild(title);
this.modalBody.appendChild(description);
this.modalBody.appendChild(textarea);
this.modalBody.appendChild(formatHint);
this.modalBody.appendChild(importButton);
this.modalBody.appendChild(backButton);
}
_detectKeyFormat(keyValue) {
const trimmed = keyValue.trim();
// Check for nsec format
if (trimmed.startsWith('nsec1') && trimmed.length === 63) {
try {
window.NostrTools.nip19.decode(trimmed);
return 'nsec';
} catch {
return 'invalid';
}
}
// Check for hex format (64 characters, valid hex)
if (trimmed.length === 64 && /^[a-fA-F0-9]{64}$/.test(trimmed)) {
return 'hex';
}
return 'invalid';
}
_importLocalKey(keyValue) {
try {
const trimmed = keyValue.trim();
if (!trimmed) {
throw new Error('Please enter a secret key');
}
const format = this._detectKeyFormat(trimmed);
let sk;
if (format === 'nsec') {
// Decode nsec format - this returns Uint8Array
const decoded = window.NostrTools.nip19.decode(trimmed);
if (decoded.type !== 'nsec') {
throw new Error('Invalid nsec format');
}
sk = decoded.data; // This is already Uint8Array
} else if (format === 'hex') {
// Convert hex string to Uint8Array
sk = this._hexToUint8Array(trimmed);
// Test that it's a valid secret key by trying to get public key
window.NostrTools.getPublicKey(sk);
} else {
throw new Error('Invalid key format. Please enter either nsec1... or 64-character hex string');
}
// Generate public key and encoded formats
const pk = window.NostrTools.getPublicKey(sk);
const nsec = window.NostrTools.nip19.nsecEncode(sk);
const npub = window.NostrTools.nip19.npubEncode(pk);
this._showKeyDisplay(pk, nsec, 'imported');
} catch (error) {
this._showError('Invalid key: ' + error.message);
}
}
_hexToUint8Array(hex) {
// Convert hex string to Uint8Array
if (hex.length % 2 !== 0) {
throw new Error('Invalid hex string length');
}
const bytes = new Uint8Array(hex.length / 2);
for (let i = 0; i < bytes.length; i++) {
bytes[i] = parseInt(hex.substr(i * 2, 2), 16);
}
return bytes;
}
_showKeyDisplay(pubkey, nsec, action) {
this.modalBody.innerHTML = '';
const title = document.createElement('h3');
title.textContent = `Key ${action} successfully!`;
title.style.cssText = 'margin: 0 0 16px 0; font-size: 18px; font-weight: 600; color: #059669;';
const warningDiv = document.createElement('div');
warningDiv.textContent = '⚠️ Save your secret key securely!';
warningDiv.style.cssText = 'background: #fef3c7; color: #92400e; padding: 12px; border-radius: 6px; margin-bottom: 16px; font-size: 14px;';
const nsecDiv = document.createElement('div');
nsecDiv.innerHTML = `<strong>Your Secret Key:</strong><br><code style="word-break: break-all; background: #f3f4f6; padding: 8px; border-radius: 4px;">${nsec}</code>`;
nsecDiv.style.cssText = 'margin-bottom: 16px; font-size: 14px;';
const npubDiv = document.createElement('div');
npubDiv.innerHTML = `<strong>Your Public Key:</strong><br><code style="word-break: break-all; background: #f3f4f6; padding: 8px; border-radius: 4px;">${window.NostrTools.nip19.npubEncode(pubkey)}</code>`;
npubDiv.style.cssText = 'margin-bottom: 16px; font-size: 14px;';
const continueButton = document.createElement('button');
continueButton.textContent = 'Continue';
continueButton.onclick = () => this._setAuthMethod('local', { secret: nsec, pubkey });
continueButton.style.cssText = this._getButtonStyle();
this.modalBody.appendChild(title);
this.modalBody.appendChild(warningDiv);
this.modalBody.appendChild(nsecDiv);
this.modalBody.appendChild(npubDiv);
this.modalBody.appendChild(continueButton);
}
_setAuthMethod(method, options = {}) {
// Emit auth method selection
const event = new CustomEvent('nlMethodSelected', {
detail: { method, ...options }
});
window.dispatchEvent(event);
this.close();
}
_showError(message) {
this.modalBody.innerHTML = '';
const errorDiv = document.createElement('div');
errorDiv.style.cssText = 'background: #fee2e2; color: #dc2626; padding: 16px; border-radius: 6px; margin-bottom: 16px;';
errorDiv.innerHTML = `<strong>Error:</strong> ${message}`;
const backButton = document.createElement('button');
backButton.textContent = 'Back';
backButton.onclick = () => this._renderLoginOptions();
backButton.style.cssText = this._getButtonStyle('secondary');
this.modalBody.appendChild(errorDiv);
this.modalBody.appendChild(backButton);
}
_showExtensionRequired() {
this.modalBody.innerHTML = '';
const title = document.createElement('h3');
title.textContent = 'Browser Extension Required';
title.style.cssText = 'margin: 0 0 16px 0; font-size: 18px; font-weight: 600;';
const message = document.createElement('p');
message.textContent = 'Please install a Nostr browser extension like Alby or getflattr and refresh the page.';
message.style.cssText = 'margin-bottom: 20px; color: #6b7280;';
const backButton = document.createElement('button');
backButton.textContent = 'Back';
backButton.onclick = () => this._renderLoginOptions();
backButton.style.cssText = this._getButtonStyle('secondary');
this.modalBody.appendChild(title);
this.modalBody.appendChild(message);
this.modalBody.appendChild(backButton);
}
_showConnectScreen() {
this.modalBody.innerHTML = '';
const title = document.createElement('h3');
title.textContent = 'Nostr Connect';
title.style.cssText = 'margin: 0 0 20px 0; font-size: 18px; font-weight: 600;';
const formGroup = document.createElement('div');
formGroup.style.cssText = 'margin-bottom: 20px;';
const label = document.createElement('label');
label.textContent = 'Connection String:';
label.style.cssText = 'display: block; margin-bottom: 8px; font-weight: 500;';
const pubkeyInput = document.createElement('input');
pubkeyInput.type = 'text';
pubkeyInput.placeholder = 'bunker://...';
pubkeyInput.style.cssText = `
width: 100%;
padding: 12px;
border: 1px solid #d1d5db;
border-radius: 6px;
margin-bottom: 16px;
font-family: monospace;
box-sizing: border-box;
`;
const connectButton = document.createElement('button');
connectButton.textContent = 'Connect';
connectButton.onclick = () => this._handleNip46Connect(pubkeyInput.value, null);
connectButton.style.cssText = this._getButtonStyle();
const backButton = document.createElement('button');
backButton.textContent = 'Back';
backButton.onclick = () => this._renderLoginOptions();
backButton.style.cssText = this._getButtonStyle('secondary') + 'margin-top: 12px;';
formGroup.appendChild(label);
formGroup.appendChild(pubkeyInput);
this.modalBody.appendChild(title);
this.modalBody.appendChild(formGroup);
this.modalBody.appendChild(connectButton);
this.modalBody.appendChild(backButton);
}
_handleNip46Connect(bunkerPubkey, bunkerUrl) {
if (!bunkerPubkey || !bunkerPubkey.length) {
this._showError('Bunker connection string is required');
return;
}
this._showNip46Connecting(bunkerPubkey);
this._performNip46Connect(bunkerPubkey, null);
}
_showNip46Connecting(bunkerPubkey) {
this.modalBody.innerHTML = '';
const title = document.createElement('h3');
title.textContent = 'Connecting to Remote Signer...';
title.style.cssText = 'margin: 0 0 16px 0; font-size: 18px; font-weight: 600; color: #059669;';
const description = document.createElement('p');
description.textContent = 'Establishing secure connection to your remote signer via Nostr relays.';
description.style.cssText = 'margin-bottom: 20px; color: #6b7280;';
// Show the connection string being used
const connectionString = bunkerPubkey.length > 60 ?
`${bunkerPubkey.substring(0, 30)}...${bunkerPubkey.substring(bunkerPubkey.length - 30)}` :
bunkerPubkey;
const bunkerInfo = document.createElement('div');
bunkerInfo.style.cssText = 'background: #f1f5f9; padding: 12px; border-radius: 6px; margin-bottom: 20px; font-size: 14px;';
bunkerInfo.innerHTML = `
<strong>Connecting via:</strong><br>
<code style="word-break: break-all; font-size: 12px;">${connectionString}</code><br>
<small style="color: #6b7280;">Using NIP-46 protocol over Nostr relays for secure communication.</small>
`;
const connectingDiv = document.createElement('div');
connectingDiv.style.cssText = 'text-align: center; color: #6b7280;';
connectingDiv.innerHTML = `
<div style="font-size: 24px; margin-bottom: 10px;">⏳</div>
<div>Please wait while we establish the connection...</div>
<div style="font-size: 12px; margin-top: 10px;">This may take a few seconds</div>
`;
this.modalBody.appendChild(title);
this.modalBody.appendChild(description);
this.modalBody.appendChild(bunkerInfo);
this.modalBody.appendChild(connectingDiv);
}
async _performNip46Connect(bunkerPubkey, bunkerUrl) {
try {
console.log('Starting NIP-46 connection to bunker:', bunkerPubkey, bunkerUrl);
// Check if nostr-tools NIP-46 is available
if (!window.NostrTools?.nip46) {
throw new Error('nostr-tools NIP-46 module not available');
}
// Use nostr-tools to parse bunker input - this handles all formats correctly
console.log('Parsing bunker input with nostr-tools...');
const bunkerPointer = await window.NostrTools.nip46.parseBunkerInput(bunkerPubkey);
if (!bunkerPointer) {
throw new Error('Unable to parse bunker connection string or resolve NIP-05 identifier');
}
console.log('Parsed bunker pointer:', bunkerPointer);
// Create local client keypair for this session
const localSecretKey = window.NostrTools.generateSecretKey();
console.log('Generated local client keypair for NIP-46 session');
// Use nostr-tools BunkerSigner constructor
console.log('Creating nip46 BunkerSigner...');
const signer = new window.NostrTools.nip46.BunkerSigner(localSecretKey, bunkerPointer, {
onauth: (url) => {
console.log('Received auth URL from bunker:', url);
// Open auth URL in popup or redirect
window.open(url, '_blank', 'width=600,height=800');
}
});
console.log('NIP-46 BunkerSigner created successfully');
// Skip ping test - NIP-46 works through relays, not direct connection
// Try to connect directly (this may trigger auth flow)
console.log('Attempting NIP-46 connect...');
await signer.connect();
console.log('NIP-46 connect successful');
// Get the user's public key from the bunker
console.log('Getting public key from bunker...');
const userPubkey = await signer.getPublicKey();
console.log('NIP-46 user public key:', userPubkey);
// Store the NIP-46 authentication info
const nip46Info = {
pubkey: userPubkey,
signer: {
method: 'nip46',
remotePubkey: bunkerPointer.pubkey,
bunkerSigner: signer,
secret: bunkerPointer.secret,
relays: bunkerPointer.relays
}
};
console.log('NOSTR_LOGIN_LITE NIP-46 connection established successfully!');
// Set as current auth method
this._setAuthMethod('nip46', nip46Info);
return;
} catch (error) {
console.error('NIP-46 connection failed:', error);
this._showNip46Error(error.message);
}
}
_showNip46Error(message) {
this.modalBody.innerHTML = '';
const title = document.createElement('h3');
title.textContent = 'Connection Failed';
title.style.cssText = 'margin: 0 0 16px 0; font-size: 18px; font-weight: 600; color: #dc2626;';
const errorMsg = document.createElement('p');
errorMsg.textContent = `Unable to connect to remote signer: ${message}`;
errorMsg.style.cssText = 'margin-bottom: 20px; color: #6b7280;';
const retryButton = document.createElement('button');
retryButton.textContent = 'Try Again';
retryButton.onclick = () => this._showConnectScreen();
retryButton.style.cssText = this._getButtonStyle();
const backButton = document.createElement('button');
backButton.textContent = 'Back to Options';
backButton.onclick = () => this._renderLoginOptions();
backButton.style.cssText = this._getButtonStyle('secondary') + 'margin-top: 12px;';
this.modalBody.appendChild(title);
this.modalBody.appendChild(errorMsg);
this.modalBody.appendChild(retryButton);
this.modalBody.appendChild(backButton);
}
_handleReadonly() {
// Set read-only mode
this._setAuthMethod('readonly');
}
_showOtpScreen() {
this.modalBody.innerHTML = '';
const title = document.createElement('h3');
title.textContent = 'DM/OTP Login';
title.style.cssText = 'margin: 0 0 16px 0; font-size: 18px; font-weight: 600;';
const description = document.createElement('p');
description.textContent = 'Enter your public key to receive a login code via direct message.';
description.style.cssText = 'margin-bottom: 12px; color: #6b7280; font-size: 14px;';
const pubkeyInput = document.createElement('input');
pubkeyInput.type = 'text';
pubkeyInput.placeholder = 'Enter your public key:\n• npub1... (bech32 format)\n• 64-character hex string';
pubkeyInput.style.cssText = `
width: 100%;
padding: 12px;
border: 1px solid #d1d5db;
border-radius: 6px;
margin-bottom: 12px;
font-family: monospace;
font-size: 14px;
box-sizing: border-box;
`;
// Add real-time format detection (reusing logic from local key)
const formatHint = document.createElement('div');
formatHint.style.cssText = 'margin-bottom: 16px; font-size: 12px; color: #6b7280; min-height: 16px;';
pubkeyInput.oninput = () => {
const value = pubkeyInput.value.trim();
if (!value) {
formatHint.textContent = '';
return;
}
const format = this._detectPubkeyFormat(value);
if (format === 'npub') {
formatHint.textContent = '✅ Valid npub format detected';
formatHint.style.color = '#059669';
} else if (format === 'hex') {
formatHint.textContent = '✅ Valid hex format detected';
formatHint.style.color = '#059669';
} else {
formatHint.textContent = '❌ Invalid public key format - must be npub1... or 64-character hex';
formatHint.style.color = '#dc2626';
}
};
const sendButton = document.createElement('button');
sendButton.textContent = 'Send Login Code';
sendButton.onclick = () => this._handleOtpRequest(pubkeyInput.value);
sendButton.style.cssText = this._getButtonStyle();
const backButton = document.createElement('button');
backButton.textContent = 'Back';
backButton.onclick = () => this._renderLoginOptions();
backButton.style.cssText = this._getButtonStyle('secondary') + 'margin-top: 12px;';
this.modalBody.appendChild(title);
this.modalBody.appendChild(description);
this.modalBody.appendChild(pubkeyInput);
this.modalBody.appendChild(formatHint);
this.modalBody.appendChild(sendButton);
this.modalBody.appendChild(backButton);
}
_detectPubkeyFormat(keyValue) {
const trimmed = keyValue.trim();
// Check for npub format
if (trimmed.startsWith('npub1') && trimmed.length === 63) {
try {
const decoded = window.NostrTools.nip19.decode(trimmed);
if (decoded.type === 'npub') {
return 'npub';
}
} catch {
return 'invalid';
}
}
// Check for hex format (64 characters, valid hex)
if (trimmed.length === 64 && /^[a-fA-F0-9]{64}$/.test(trimmed)) {
return 'hex';
}
return 'invalid';
}
_handleOtpRequest(pubkeyValue) {
try {
const trimmed = pubkeyValue.trim();
if (!trimmed) {
throw new Error('Please enter a public key');
}
const format = this._detectPubkeyFormat(trimmed);
let pubkey, displayKey;
if (format === 'npub') {
// Decode npub format
const decoded = window.NostrTools.nip19.decode(trimmed);
if (decoded.type !== 'npub') {
throw new Error('Invalid npub format');
}
pubkey = decoded.data; // This is the hex pubkey
displayKey = trimmed; // Keep the original npub for display
} else if (format === 'hex') {
// Use hex directly as pubkey
pubkey = trimmed;
// Generate npub for display
displayKey = window.NostrTools.nip19.npubEncode(pubkey);
} else {
throw new Error('Invalid public key format. Please enter either npub1... or 64-character hex string');
}
this._showOtpCodeScreen(pubkey, displayKey);
} catch (error) {
this._showError('Invalid public key: ' + error.message);
}
}
_showOtpCodeScreen(pubkey, npub) {
this.modalBody.innerHTML = '';
const title = document.createElement('h3');
title.textContent = 'Enter Login Code';
title.style.cssText = 'margin: 0 0 16px 0; font-size: 18px; font-weight: 600;';
const description = document.createElement('p');
description.innerHTML = `Check your DMs for a login code sent to:<br><code style="word-break: break-all;">${npub}</code>`;
description.style.cssText = 'margin-bottom: 20px; color: #6b7280; font-size: 14px;';
const formGroup = document.createElement('div');
formGroup.style.cssText = 'margin-bottom: 20px;';
const label = document.createElement('label');
label.textContent = 'Login Code:';
label.style.cssText = 'display: block; margin-bottom: 8px; font-weight: 500;';
const codeInput = document.createElement('input');
codeInput.type = 'text';
codeInput.placeholder = 'Enter 6-digit code';
codeInput.maxLength = 6;
codeInput.style.cssText = `
width: 100%;
padding: 12px;
border: 1px solid #d1d5db;
border-radius: 6px;
margin-bottom: 16px;
font-family: monospace;
box-sizing: border-box;
text-align: center;
font-size: 18px;
letter-spacing: 2px;
`;
const verifyButton = document.createElement('button');
verifyButton.textContent = 'Verify Code';
verifyButton.onclick = () => this._handleOtpVerification(pubkey, codeInput.value);
verifyButton.style.cssText = this._getButtonStyle();
const resendButton = document.createElement('button');
resendButton.textContent = 'Resend Code';
resendButton.onclick = () => this._handleOtpRequest(npub);
resendButton.style.cssText = this._getButtonStyle('secondary') + 'margin-top: 12px; margin-right: 8px;';
const backButton = document.createElement('button');
backButton.textContent = 'Back';
backButton.onclick = () => this._showOtpScreen();
backButton.style.cssText = this._getButtonStyle('secondary') + 'margin-top: 12px;';
formGroup.appendChild(label);
formGroup.appendChild(codeInput);
this.modalBody.appendChild(title);
this.modalBody.appendChild(description);
this.modalBody.appendChild(formGroup);
this.modalBody.appendChild(verifyButton);
const buttonRow = document.createElement('div');
buttonRow.style.cssText = 'display: flex; gap: 8px;';
buttonRow.appendChild(resendButton);
buttonRow.appendChild(backButton);
this.modalBody.appendChild(buttonRow);
// Simulate sending DM (in a real implementation, this would send a DM)
this._simulateOtpSend(pubkey);
}
async _simulateOtpSend(pubkey) {
// Generate a random 6-digit code
const code = Math.floor(100000 + Math.random() * 900000).toString();
// Store the code temporarily (in a real implementation, this would be server-side)
sessionStorage.setItem(`otp_${pubkey}`, code);
sessionStorage.setItem(`otp_${pubkey}_timestamp`, Date.now().toString());
console.log(`OTP/DM: Generated code ${code} for pubkey ${pubkey}`);
// Actually send the DM
await this._sendOtpDm(pubkey, code);
}
async _sendOtpDm(recipientPubkey, code) {
try {
console.log('🚀 OTP/DM: Starting modern NIP-17 DM send process...');
// Generate a temporary key pair for sending the DM
const senderSecretKey = window.NostrTools.generateSecretKey();
const senderPubkey = window.NostrTools.getPublicKey(senderSecretKey);
console.log('🔑 OTP/DM: Generated temporary sender pubkey:', senderPubkey);
console.log('👤 OTP/DM: Sending DM to recipient pubkey:', recipientPubkey);
// Create the DM content
const dmContent = `Your NOSTR_LOGIN_LITE login code: ${code}\n\nThis code expires in 5 minutes.`;
console.log('💬 OTP/DM: Original message:', dmContent);
// Check if NIP-17 is available in nostr-tools
if (!window.NostrTools?.nip17) {
console.warn('⚠️ OTP/DM: NIP-17 not available in nostr-tools, falling back to NIP-44');
return await this._sendLegacyNip44Dm(senderSecretKey, recipientPubkey, dmContent, code);
}
// Create recipient object for NIP-17
const recipient = {
publicKey: recipientPubkey,
relayUrl: undefined // Will use default relays
};
console.log('🔄 OTP/DM: Creating NIP-17 gift-wrapped DM using nostr-tools...');
// Use nostr-tools NIP-17 to create properly wrapped DM
const wrappedEvent = window.NostrTools.nip17.wrapEvent(
senderSecretKey,
recipient,
dmContent,
undefined, // no conversation title
undefined // no reply
);
console.log('📝 OTP/DM: Created NIP-17 gift wrap (kind 1059):', JSON.stringify(wrappedEvent, null, 2));
// Get relays to send to
const relays = this.options.relays || [
'wss://relay.damus.io',
'wss://relay.primal.net',
'wss://relay.laantungir.net',
'wss://nostr.mom'
];
console.log('🌐 OTP/DM: Sending NIP-17 DM to relays:', relays);
// Create a pool using our enhanced SimplePool from NIP-46 extension
const pool = new window.NostrTools.nip46.SimplePool();
// Send the gift-wrapped event to all relays
console.log('📡 OTP/DM: === Sending NIP-17 Gift-Wrapped DM ===');
const publishPromises = relays.map(async (relay, index) => {
console.log(`📡 OTP/DM: NIP-17 [${index + 1}/${relays.length}] Connecting to relay ${relay}...`);
try {
const results = await pool.publish([relay], wrappedEvent);
console.log(`📡 OTP/DM: NIP-17 Raw JSON response from ${relay}:`, JSON.stringify(results, null, 2));
// Check if the result status is fulfilled (successful)
const success = results.length > 0 && results[0].status === 'fulfilled';
if (!success && results.length > 0 && results[0].status === 'rejected') {
console.warn(`⚠️ OTP/DM: NIP-17 rejected by ${relay}:`, results[0].reason);
}
return { relay, success, results, type: 'nip17' };
} catch (error) {
console.error(`❌ OTP/DM: NIP-17 Failed to publish to ${relay}:`, error);
return { relay, success: false, error: error.message, type: 'nip17' };
}
});
// Wait for all publish attempts
const results = await Promise.allSettled(publishPromises);
const successfulResults = results.filter(result =>
result.status === 'fulfilled' && result.value.success
);
console.log('📊 OTP/DM: Publishing summary:');
console.log(` - Total relays attempted: ${relays.length}`);
console.log(` - NIP-17 successful publishes: ${successfulResults.length}/${relays.length}`);
console.log(` - Overall success rate: ${Math.round((successfulResults.length / relays.length) * 100)}%`);
if (successfulResults.length > 0) {
console.log('✅ OTP/DM: NIP-17 DM sent successfully to at least one relay');
console.log('📱 OTP/DM: Check your Nostr client for the encrypted DM');
console.log('🔐 OTP/DM: Message uses modern NIP-44 encryption with NIP-17 gift wrapping');
console.log('🎯 OTP/DM: This should work with all modern Nostr clients and relays');
} else {
console.error('❌ OTP/DM: Failed to send DM to any relays');
console.log('💡 OTP/DM: This might indicate relay connectivity issues or client compatibility problems');
}
// Clean up the pool safely
try {
console.log('🧹 OTP/DM: Cleaning up relay connections...');
pool.close(relays);
console.log('🧹 OTP/DM: Pool cleanup completed');
} catch (cleanupError) {
console.warn('⚠️ OTP/DM: Pool cleanup warning (non-critical):', cleanupError.message);
}
// For demo purposes, also show the code in console
setTimeout(() => {
console.log(`🔐 OTP/DM: Demo - Login code sent was: ${code}`);
console.log('💡 OTP/DM: Check your Nostr DMs or use the code above for testing');
}, 2000);
return successfulResults.length > 0;
} catch (error) {
console.error('💥 OTP/DM: NIP-17 implementation error:', error);
console.log('💡 OTP/DM: This might indicate a problem with the nostr-tools NIP-17 implementation');
console.log(`🔐 OTP/DM: Fallback - Login code for testing: ${code}`);
return false;
}
}
async _sendLegacyNip44Dm(senderSecretKey, recipientPubkey, dmContent, code) {
console.log('🔄 OTP/DM: Using legacy NIP-44 direct message format');
try {
// Encrypt with NIP-44
const conversationKey = window.NostrTools.nip44.getConversationKey(senderSecretKey, recipientPubkey);
const nip44Content = window.NostrTools.nip44.encrypt(dmContent, conversationKey);
console.log('🔒 OTP/DM: NIP-44 encrypted length:', nip44Content.length, 'chars');
// Create NIP-44 DM event (modern approach - no custom tags)
const nip44Event = {
kind: 4,
created_at: Math.floor(Date.now() / 1000),
tags: [
['p', recipientPubkey]
],
content: nip44Content
};
// Sign the event
const signedEvent = window.NostrTools.finalizeEvent(nip44Event, senderSecretKey);
console.log('📝 OTP/DM: NIP-44 signed event:', JSON.stringify(signedEvent, null, 2));
// Get relays to send to
const relays = this.options.relays || [
'wss://relay.damus.io',
'wss://relay.primal.net',
'wss://relay.laantungir.net',
'wss://nostr.mom'
];
// Create a pool and send
const pool = new window.NostrTools.nip46.SimplePool();
const publishPromises = relays.map(async (relay, index) => {
console.log(`📡 OTP/DM: Legacy NIP-44 [${index + 1}/${relays.length}] Connecting to relay ${relay}...`);
try {
const results = await pool.publish([relay], signedEvent);
console.log(`📡 OTP/DM: Legacy NIP-44 Raw JSON response from ${relay}:`, JSON.stringify(results, null, 2));
const success = results.length > 0 && results[0].status === 'fulfilled';
if (!success && results.length > 0 && results[0].status === 'rejected') {
console.warn(`⚠️ OTP/DM: Legacy NIP-44 rejected by ${relay}:`, results[0].reason);
}
return { relay, success, results, type: 'legacy-nip44' };
} catch (error) {
console.error(`❌ OTP/DM: Legacy NIP-44 Failed to publish to ${relay}:`, error);
return { relay, success: false, error: error.message, type: 'legacy-nip44' };
}
});
const results = await Promise.allSettled(publishPromises);
const successfulResults = results.filter(result =>
result.status === 'fulfilled' && result.value.success
);
console.log('📊 OTP/DM: Legacy publishing summary:');
console.log(` - Legacy NIP-44 successful publishes: ${successfulResults.length}/${relays.length}`);
pool.close(relays);
return successfulResults.length > 0;
} catch (error) {
console.error('💥 OTP/DM: Legacy NIP-44 error:', error);
console.log(`🔐 OTP/DM: Fallback - Login code for testing: ${code}`);
return false;
}
}
_handleOtpVerification(pubkey, enteredCode) {
if (!enteredCode || enteredCode.length !== 6) {
this._showError('Please enter a 6-digit code');
return;
}
const storedCode = sessionStorage.getItem(`otp_${pubkey}`);
const timestamp = parseInt(sessionStorage.getItem(`otp_${pubkey}_timestamp`) || '0');
// Check if code is expired (5 minutes)
if (Date.now() - timestamp > 5 * 60 * 1000) {
sessionStorage.removeItem(`otp_${pubkey}`);
sessionStorage.removeItem(`otp_${pubkey}_timestamp`);
this._showError('Login code has expired. Please request a new one.');
return;
}
if (enteredCode === storedCode) {
// Clean up stored code
sessionStorage.removeItem(`otp_${pubkey}`);
sessionStorage.removeItem(`otp_${pubkey}_timestamp`);
console.log('OTP/DM: Code verified successfully');
// Set as read-only authentication (since we don't have the private key)
this._setAuthMethod('readonly', { pubkey });
} else {
this._showError('Invalid login code. Please check and try again.');
}
}
_getButtonStyle(type = 'primary') {
const baseStyle = `
display: block;
width: 100%;
padding: 12px;
border: none;
border-radius: 8px;
font-size: 16px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
`;
if (type === 'primary') {
return baseStyle + `
background: #3b82f6;
color: white;
`;
} else {
return baseStyle + `
background: #6b7280;
color: white;
`;
}
}
// Public API
static init(options) {
if (Modal.instance) return Modal.instance;
Modal.instance = new Modal(options);
return Modal.instance;
}
static getInstance() {
return Modal.instance;
}
}
// Initialize global instance
let modalInstance = null;
window.addEventListener('load', () => {
modalInstance = new Modal();
});
// ======================================
// Main NOSTR_LOGIN_LITE Library
// ======================================
// Extension Bridge for managing browser extensions
class ExtensionBridge {
constructor() {
this.extensions = new Map();
this.primaryExtension = null;
this._detectExtensions();
}
_detectExtensions() {
// Common extension locations
const locations = [
{ path: 'window.nostr', name: 'Generic' },
{ path: 'window.alby?.nostr', name: 'Alby' },
{ path: 'window.nos2x?.nostr', name: 'nos2x' },
{ path: 'window.flamingo?.nostr', name: 'Flamingo' },
{ path: 'window.getAlby?.nostr', name: 'Alby Legacy' },
{ path: 'window.mutiny?.nostr', name: 'Mutiny' }
];
for (const location of locations) {
try {
const obj = eval(location.path);
if (obj && typeof obj.getPublicKey === 'function') {
this.extensions.set(location.name, {
name: location.name,
extension: obj,
constructor: obj.constructor?.name || 'Unknown'
});
if (!this.primaryExtension) {
this.primaryExtension = this.extensions.get(location.name);
}
}
} catch (e) {
// Extension not available
}
}
}
getAllExtensions() {
return Array.from(this.extensions.values());
}
getExtensionCount() {
return this.extensions.size;
}
}
// Main NostrLite class
class NostrLite {
constructor() {
this.options = {};
this.extensionBridge = new ExtensionBridge();
this.initialized = false;
}
async init(options = {}) {
console.log('NOSTR_LOGIN_LITE: Initializing with options:', options);
this.options = {
theme: 'light',
darkMode: false,
relays: ['wss://relay.damus.io', 'wss://nos.lol'],
methods: {
extension: true,
local: true,
readonly: true,
connect: false,
otp: false
},
...options
};
// Set up window.nostr facade if no extension detected
if (this.extensionBridge.getExtensionCount() === 0) {
this._setupWindowNostrFacade();
}
this.initialized = true;
console.log('NOSTR_LOGIN_LITE: Initialization complete');
// Set up event listeners for authentication flow
this._setupAuthEventHandlers();
return this;
}
_setupWindowNostrFacade() {
if (typeof window !== 'undefined' && !window.nostr) {
window.nostr = new WindowNostr(this);
console.log('NOSTR_LOGIN_LITE: window.nostr facade installed');
}
}
_setupAuthEventHandlers() {
if (typeof window === 'undefined') return;
// Listen for authentication method selection
window.addEventListener('nlMethodSelected', async (event) => {
console.log('NOSTR_LOGIN_LITE: Authentication method selected:', event.detail);
const { method, pubkey, signer, extension, secret } = event.detail;
try {
// Complete the authentication flow
await this._completeAuthentication(method, event.detail);
} catch (error) {
console.error('NOSTR_LOGIN_LITE: Authentication completion failed:', error);
this._dispatchAuthEvent('nlAuthFailed', { error: error.message });
}
});
}
async _completeAuthentication(method, authData) {
console.log('NOSTR_LOGIN_LITE: Completing authentication for method:', method);
const authResult = {
method,
pubkey: authData.pubkey,
timestamp: Date.now()
};
// Add method-specific data
switch (method) {
case 'extension':
authResult.extension = authData.extension;
break;
case 'local':
authResult.secret = authData.secret;
break;
case 'nip46':
authResult.signer = authData.signer;
authResult.remotePubkey = authData.signer.remotePubkey;
break;
case 'readonly':
// No additional data needed
break;
}
// Store authentication state
if (typeof localStorage !== 'undefined') {
localStorage.setItem('nl_current', JSON.stringify({
method,
pubkey: authData.pubkey,
timestamp: Date.now()
}));
}
console.log('NOSTR_LOGIN_LITE: Authentication completed successfully');
console.log('NOSTR_LOGIN_LITE: User pubkey:', authData.pubkey);
// Fetch and display profile information
await this._fetchAndDisplayProfile(authData.pubkey);
// Dispatch success event
this._dispatchAuthEvent('nlAuth', authResult);
}
async _fetchAndDisplayProfile(pubkey) {
console.log('NOSTR_LOGIN_LITE: Fetching profile for pubkey:', pubkey);
try {
// Create a simple pool for fetching profile
const pool = new window.NostrTools.SimplePool();
const relays = this.options.relays || ['wss://relay.damus.io', 'wss://nos.lol'];
console.log('NOSTR_LOGIN_LITE: Querying relays for profile:', relays);
// Fetch profile metadata (kind 0)
const profileEvents = await pool.querySync(relays, {
kinds: [0],
authors: [pubkey],
limit: 1
});
pool.close(relays);
if (profileEvents && profileEvents.length > 0) {
const profileEvent = profileEvents[0];
const profile = JSON.parse(profileEvent.content);
console.log('NOSTR_LOGIN_LITE: Profile fetched successfully:', profile);
// Display profile information
this._displayProfileInfo(pubkey, profile);
} else {
console.log('NOSTR_LOGIN_LITE: No profile found, displaying pubkey only');
this._displayProfileInfo(pubkey, null);
}
} catch (error) {
console.error('NOSTR_LOGIN_LITE: Failed to fetch profile:', error);
this._displayProfileInfo(pubkey, null);
}
}
_displayProfileInfo(pubkey, profile) {
console.log('NOSTR_LOGIN_LITE: Displaying profile info');
// Create or update profile display
let profileDiv = document.getElementById('nl-profile-info');
if (!profileDiv) {
profileDiv = document.createElement('div');
profileDiv.id = 'nl-profile-info';
profileDiv.style.cssText = `
position: fixed;
top: 20px;
right: 20px;
background: white;
border: 1px solid #d1d5db;
border-radius: 8px;
padding: 16px;
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
z-index: 9999;
max-width: 300px;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
`;
document.body.appendChild(profileDiv);
}
const npub = window.NostrTools.nip19.npubEncode(pubkey);
const shortPubkey = `${pubkey.slice(0, 8)}...${pubkey.slice(-8)}`;
profileDiv.innerHTML = `
<div style="display: flex; align-items: center; margin-bottom: 12px;">
<div style="font-size: 24px; margin-right: 8px;">✅</div>
<div>
<div style="font-weight: 600; color: #059669;">Logged In</div>
<div style="font-size: 12px; color: #6b7280;">NOSTR_LOGIN_LITE</div>
</div>
</div>
${profile ? `
<div style="margin-bottom: 12px;">
<div style="font-weight: 500; color: #1f2937;">${profile.name || 'Anonymous'}</div>
${profile.about ? `<div style="font-size: 12px; color: #6b7280; margin-top: 4px;">${profile.about.slice(0, 100)}${profile.about.length > 100 ? '...' : ''}</div>` : ''}
</div>
` : ''}
<div style="font-size: 12px; color: #6b7280; margin-bottom: 8px;">
<strong>Pubkey:</strong> ${shortPubkey}
</div>
<div style="font-size: 12px; color: #6b7280; margin-bottom: 12px;">
<strong>npub:</strong> ${npub.slice(0, 12)}...${npub.slice(-8)}
</div>
<button onclick="NOSTR_LOGIN_LITE.logout(); document.getElementById('nl-profile-info').remove();"
style="background: #dc2626; color: white; border: none; padding: 6px 12px; border-radius: 4px; cursor: pointer; font-size: 12px;">
Logout
</button>
`;
console.log('NOSTR_LOGIN_LITE: Profile display updated');
}
_dispatchAuthEvent(eventName, data) {
if (typeof window !== 'undefined') {
window.dispatchEvent(new CustomEvent(eventName, {
detail: data
}));
console.log('NOSTR_LOGIN_LITE: Dispatched event:', eventName, data);
}
}
launch(startScreen = 'login') {
console.log('NOSTR_LOGIN_LITE: Launching with screen:', startScreen);
if (typeof Modal !== 'undefined') {
const modal = new Modal(this.options);
modal.open({ startScreen });
} else {
console.error('NOSTR_LOGIN_LITE: Modal component not available');
}
}
logout() {
console.log('NOSTR_LOGIN_LITE: Logout called');
// Clear stored data
if (typeof localStorage !== 'undefined') {
localStorage.removeItem('nl_current');
}
// Dispatch logout event
if (typeof window !== 'undefined') {
window.dispatchEvent(new CustomEvent('nlLogout', {
detail: { timestamp: Date.now() }
}));
}
}
}
// Window.nostr facade for when no extension is available
class WindowNostr {
constructor(nostrLite) {
this.nostrLite = nostrLite;
}
async getPublicKey() {
throw new Error('Authentication required - use NOSTR_LOGIN_LITE.launch()');
}
async signEvent(event) {
throw new Error('Authentication required - use NOSTR_LOGIN_LITE.launch()');
}
async getRelays() {
throw new Error('Authentication required - use NOSTR_LOGIN_LITE.launch()');
}
get nip04() {
return {
async encrypt(pubkey, plaintext) {
throw new Error('Authentication required - use NOSTR_LOGIN_LITE.launch()');
},
async decrypt(pubkey, ciphertext) {
throw new Error('Authentication required - use NOSTR_LOGIN_LITE.launch()');
}
};
}
get nip44() {
return {
async encrypt(pubkey, plaintext) {
throw new Error('Authentication required - use NOSTR_LOGIN_LITE.launch()');
},
async decrypt(pubkey, ciphertext) {
throw new Error('Authentication required - use NOSTR_LOGIN_LITE.launch()');
}
};
}
}
// Initialize and export
if (typeof window !== 'undefined') {
const nostrLite = new NostrLite();
// Export main API
window.NOSTR_LOGIN_LITE = {
init: (options) => nostrLite.init(options),
launch: (startScreen) => nostrLite.launch(startScreen),
logout: () => nostrLite.logout(),
// Expose for debugging
_extensionBridge: nostrLite.extensionBridge,
_instance: nostrLite
};
console.log('NOSTR_LOGIN_LITE: Library loaded and ready');
console.log('NOSTR_LOGIN_LITE: Use window.NOSTR_LOGIN_LITE.init(options) to initialize');
console.log('NOSTR_LOGIN_LITE: Detected', nostrLite.extensionBridge.getExtensionCount(), 'browser extensions');
} else {
// Node.js environment
module.exports = { NostrLite };
}