/** * NOSTR_LOGIN_LITE * Single-file Nostr authentication library * Generated on: 2025-09-13T13:03:05.960Z */ // ====================================== // Core Classes and Components // ====================================== // ====================================== // modal.js // ====================================== 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 if (this.options?.methods?.connect !== 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 && this.options?.otp) { 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(); if (availableExtensions.length === 0) { console.log('Modal: No real extensions found'); this._showExtensionRequired(); } else if (availableExtensions.length === 1) { console.log('Modal detected single extension:', availableExtensions[0].name); this._tryExtensionLogin(availableExtensions[0].extension); } else { console.log('Modal detected multiple extensions:', availableExtensions.map(e => e.name)); this._showExtensionChoice(availableExtensions); } } _detectAllExtensions() { const extensions = []; // Check navigator.nostr (NIP-07 standard location) if (window.navigator?.nostr && typeof window.navigator.nostr.getPublicKey === 'function') { extensions.push({ name: 'navigator.nostr', displayName: 'Standard Extension (navigator.nostr)', icon: '🌐', extension: window.navigator.nostr }); } // Check webln.nostr (Alby WebLN) if (window.webln?.nostr && typeof window.webln.nostr.getPublicKey === 'function') { extensions.push({ name: 'webln.nostr', displayName: 'Alby WebLN Extension', icon: '⚔', extension: window.webln.nostr }); } // Check alby.nostr (Alby direct) if (window.alby?.nostr && typeof window.alby.nostr.getPublicKey === 'function') { extensions.push({ name: 'alby.nostr', displayName: 'Alby Extension (Direct)', icon: 'šŸ', extension: window.alby.nostr }); } // Check nos2x if (window.nos2x && typeof window.nos2x.getPublicKey === 'function') { extensions.push({ name: 'nos2x', displayName: 'nos2x Extension', icon: 'šŸ”Œ', extension: window.nos2x }); } // Check window.nostr but make sure it's not our library if (window.nostr && typeof window.nostr.getPublicKey === 'function') { const isRealExtension = ( typeof window.nostr._hexToUint8Array !== 'function' && // Our library has this method window.nostr.constructor.name !== 'Object' // Real extensions usually have proper constructors ); if (isRealExtension) { // Don't add if we already detected it via another path const alreadyDetected = extensions.some(ext => ext.extension === window.nostr); if (!alreadyDetected) { extensions.push({ name: 'window.nostr', displayName: 'Extension (window.nostr)', icon: 'šŸ”‘', extension: window.nostr }); } } } return extensions; } _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 = '
šŸ”„ Connecting to extension...
'; // 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 = `Your Secret Key:
${nsec}`; nsecDiv.style.cssText = 'margin-bottom: 16px; font-size: 14px;'; const npubDiv = document.createElement('div'); npubDiv.innerHTML = `Your Public Key:
${window.NostrTools.nip19.npubEncode(pubkey)}`; 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 = `Error: ${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 = 'Connect to NIP-46 Remote Signer'; title.style.cssText = 'margin: 0 0 16px 0; font-size: 18px; font-weight: 600;'; const description = document.createElement('p'); description.textContent = 'Connect to a remote signer (bunker) server to use its keys for signing.'; 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 = 'Bunker Public Key:'; label.style.cssText = 'display: block; margin-bottom: 8px; font-weight: 500;'; const pubkeyInput = document.createElement('input'); pubkeyInput.type = 'text'; pubkeyInput.placeholder = 'bunker://pubkey?relay=..., bunker:hex, hex, or npub...'; pubkeyInput.style.cssText = ` width: 100%; padding: 12px; border: 1px solid #d1d5db; border-radius: 6px; margin-bottom: 12px; font-family: monospace; box-sizing: border-box; `; const urlLabel = document.createElement('label'); urlLabel.textContent = 'Remote URL (optional):'; urlLabel.style.cssText = 'display: block; margin-bottom: 8px; font-weight: 500;'; const urlInput = document.createElement('input'); urlInput.type = 'url'; urlInput.placeholder = 'ws://localhost:8080 (default)'; urlInput.style.cssText = ` width: 100%; padding: 12px; border: 1px solid #d1d5db; border-radius: 6px; margin-bottom: 16px; box-sizing: border-box; `; // Pre-fill with our bunker config if available if (window.NIP46_BUNKER_CONFIG) { pubkeyInput.value = window.NIP46_BUNKER_CONFIG.remoteSigner.pubkey; urlInput.value = window.NIP46_BUNKER_CONFIG.remoteSigner.url; } const connectButton = document.createElement('button'); connectButton.textContent = 'Connect to Bunker'; connectButton.onclick = () => this._handleNip46Connect(pubkeyInput.value, urlInput.value); 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); formGroup.appendChild(urlLabel); formGroup.appendChild(urlInput); this.modalBody.appendChild(title); this.modalBody.appendChild(description); this.modalBody.appendChild(formGroup); this.modalBody.appendChild(connectButton); this.modalBody.appendChild(backButton); } _handleNip46Connect(bunkerPubkey, bunkerUrl) { if (!bunkerPubkey || !bunkerPubkey.length) { this._showError('Bunker pubkey is required'); return; } this._showNip46Connecting(bunkerPubkey, bunkerUrl); this._performNip46Connect(bunkerPubkey, bunkerUrl); } _showNip46Connecting(bunkerPubkey, bunkerUrl) { 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.'; description.style.cssText = 'margin-bottom: 20px; color: #6b7280;'; // Normalize bunker pubkey for display (= show original format if bunker: prefix) const displayPubkey = bunkerPubkey.startsWith('bunker:') || bunkerPubkey.startsWith('npub') || bunkerPubkey.length === 64 ? bunkerPubkey : bunkerPubkey; const bunkerInfo = document.createElement('div'); bunkerInfo.style.cssText = 'background: #f1f5f9; padding: 12px; border-radius: 6px; margin-bottom: 20px; font-size: 14px;'; bunkerInfo.innerHTML = ` Connecting to bunker:
Pubkey: ${displayPubkey}
Relay: ${bunkerUrl || 'ws://localhost:8080'}
If this relay is offline, the bunker server may be unavailable. `; const connectingDiv = document.createElement('div'); connectingDiv.style.cssText = 'text-align: center; color: #6b7280;'; connectingDiv.innerHTML = `
ā³
Please wait while we establish the connection...
This may take a few seconds
`; 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.fromBunker() for bunker:// connections console.log('Creating nip46 BunkerSigner...'); const signer = window.NostrTools.nip46.BunkerSigner.fromBunker(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'); // Attempt initial ping to verify connection console.log('Testing bunker connection with ping...'); await signer.ping(); console.log('NIP-46 ping successful - bunker is reachable'); // Try to connect (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() { // Placeholder for OTP functionality this._showError('OTP/DM not yet implemented - coming soon!'); } _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(); }); // ====================================== // nip46-client.js // ====================================== class NIP46Client { constructor() { this.pool = null; this.localSk = null; this.localPk = null; this.remotePk = null; this.relays = []; this.sub = null; this.pendingRequests = {}; this.useNip44 = false; this.iframeOrigin = null; this.iframePort = null; } init(localSk, remotePk, relays, iframeOrigin) { // Create SimplePool this.pool = new window.NostrTools.SimplePool(); // Setup keys this.localSk = localSk; if (this.localSk) { this.localPk = window.NostrTools.getPublicKey(this.localSk); } this.remotePk = remotePk; this.relays = [...relays]; // Store iframe origin for future use this.iframeOrigin = iframeOrigin; console.log('NIP46Client initialized for', this.remotePk ? 'remote signer' : 'listening mode'); } setUseNip44(use) { this.useNip44 = use; } subscribeReplies() { if (!this.pool || !this.localPk) return; // Subscribe to replies to our pubkey on kind 24133 (NIP-46 methods) this.sub = this.pool.sub(this.relays, [{ kinds: [24133], '#p': [this.localPk] }]); this.sub.on('event', (event) => this.onEvent(event)); this.sub.on('eose', () => { console.log('NIP-46 subscription caught up'); }); console.log('Subscribed to NIP-46 replies on relays:', this.relays); } unsubscribe() { if (this.sub) { this.sub.unsub(); this.sub = null; } } async onEvent(event) { console.log('NIP-46 event received:', event); try { const parsed = await this.parseEvent(event); if (parsed) { if (parsed.id && this.pendingRequests[parsed.id]) { // Handle response const handler = this.pendingRequests[parsed.id]; delete this.pendingRequests[parsed.id]; if (parsed.result !== undefined) { handler.resolve(parsed.result); } else if (parsed.error) { handler.reject(new Error(parsed.error)); } else { handler.reject(new Error('Invalid response format')); } } else if (parsed.method === 'auth_url') { // Handle auth_url emissions (deduplication required) this.emitAuthUrlIfNeeded(parsed.params[0]); } } } catch (error) { console.error('Error processing NIP-46 event:', error); } } emitAuthUrlIfNeeded(url) { // Deduplicate auth_url emissions - only emit if not recently shown const lastUrl = sessionStorage.getItem('nl-last-auth-url'); if (lastUrl === url) { console.log('Auth URL already shown, skipping duplicate:', url); return; } sessionStorage.setItem('nl-last-auth-url', url); console.log('New auth URL:', url); // Emit event for UI window.dispatchEvent(new CustomEvent('nlAuthUrl', { detail: { url } })); } async parseEvent(event) { try { let content = event.content; // Determine encryption method based on content structure if (content.length > 44) { // Likely NIP-44 (encrypted) if (this.localSk && event.pubkey) { try { content = window.NostrTools.nip44?.decrypt(this.localSk, event.pubkey, content); } catch (e) { console.warn('NIP-44 decryption failed, trying NIP-04...'); content = await window.NostrTools.nip04.decrypt(this.localSk, event.pubkey, content); } } } else { // Likely NIP-04 if (this.localSk && event.pubkey) { content = await window.NostrTools.nip04.decrypt(this.localSk, event.pubkey, content); } } const payload = JSON.parse(content); console.log('Decrypted NIP-46 payload:', payload); return { id: payload.id, method: payload.method, params: payload.params, result: payload.result, error: payload.error, event: event }; } catch (e) { console.error('Failed to parse event:', e); return null; } } async listen(nostrConnectSecret) { return new Promise((resolve, reject) => { if (!this.localPk) { reject(new Error('No local pubkey available for listening')); return; } // Subscribe to unsolicited events to our pubkey let foundSecretOrAck = false; const listenSub = this.pool.sub(this.relays, [{ kinds: [24133], '#p': [this.localPk] }]); listenSub.on('event', async (event) => { try { const parsed = await this.parseEvent(event); if (parsed && parsed.method === 'connect') { // Accept if it's an ack or matches our secret const [userPubkey, token] = parsed.params || []; if (token === '' && parsed.result === 'ack') { // Ack received foundSecretOrAck = true; listenSub.unsub(); resolve(event.pubkey); } else if (token === nostrConnectSecret) { // Secret match foundSecretOrAck = true; listenSub.unsub(); resolve(event.pubkey); } } } catch (error) { console.error('Error in listen mode:', error); } }); // Timeout after 5 minutes setTimeout(() => { if (!foundSecretOrAck) { listenSub.unsub(); reject(new Error('Listen timeout - no signer connected')); } }, 300000); }); } async connect(token, perms) { return new Promise(async (resolve, reject) => { try { const result = await this.sendRequest( this.remotePk, 'connect', [this.localPk, token || '', perms || ''], 24133, (response) => { if (response === 'ack') { resolve(true); } else { reject(new Error('Connection not acknowledged')); } } ); // Set 30 second timeout setTimeout(() => reject(new Error('Connection timeout')), 30000); } catch (error) { reject(error); } }); } async initUserPubkey(hint) { if (hint) { this.remotePk = hint; return hint; } if (!this.remotePk) { // Request get_public_key return new Promise(async (resolve, reject) => { try { const pubkey = await this.sendRequest( this.remotePk, 'get_public_key', [], 24133 ); this.remotePk = pubkey; resolve(pubkey); } catch (error) { reject(error); } }); } return this.remotePk; } async sendRequest(remotePubkey, method, params, kind = 24133, cb) { if (!this.pool || !this.localSk || !this.localPk) { throw new Error('NIP46Client not properly initialized'); } if (!remotePubkey) { throw new Error('No remote pubkey specified'); } const id = this._generateId(); // Create request event const event = await this.createRequestEvent(id, remotePubkey, method, params, kind); console.log('Sending NIP-46 request:', { id, method, params }); // Publish to relays const pubs = await this.pool.publish(this.relays, event); console.log('Published to relays, waiting for response...'); return new Promise((resolve, reject) => { // Set timeout const timeout = setTimeout(() => { console.error('NIP-46 request timeout for id:', id); delete this.pendingRequests[id]; reject(new Error(`Request timeout for ${method}`)); }, 60000); // 1 minute timeout // Store handler this.pendingRequests[id] = { resolve: (result) => { clearTimeout(timeout); resolve(result); }, reject: (error) => { clearTimeout(timeout); reject(error); }, timestamp: Date.now() }; // If callback provided, override resolve if (cb) { const originalResolve = this.pendingRequests[id].resolve; this.pendingRequests[id].resolve = (result) => { cb(result); originalResolve(result); }; } }); } async createRequestEvent(id, remotePubkey, method, params, kind = 24133) { let content = JSON.stringify({ id, method, params }); // Choose encryption method let encrypted = content; if (method !== 'create_account') { // Use NIP-44 for non-account creation methods if available if (this.useNip44 && window.NostrTools.nip44) { encrypted = window.NostrTools.nip44.encrypt(this.localSk, remotePubkey, content); } else { // Fallback to NIP-04 encrypted = await window.NostrTools.nip04.encrypt(this.localSk, remotePubkey, content); } } // Create event structure const event = { kind: kind, content: encrypted, tags: [ ['p', remotePubkey] ], created_at: Math.floor(Date.now() / 1000), pubkey: this.localPk, id: '', // Will be set by finalizeEvent sig: '' // Will be set by finalizeEvent }; // Sign the event const signedEvent = window.NostrTools.finalizeEvent(event, this.localSk); return signedEvent; } _generateId() { return 'nl-' + Date.now() + '-' + Math.random().toString(36).substring(2, 15); } setWorkerIframePort(port) { this.iframePort = port; // Set up postMessage routing if needed if (this.iframePort && this.iframeOrigin) { this.iframePort.onmessage = (event) => { if (event.origin !== this.iframeOrigin) { console.warn('Ignoring message from unknown origin:', event.origin); return; } console.log('Received iframe message:', event.data); // Handle iframe messages }; // Send keepalive setInterval(() => { if (this.iframePort) { try { this.iframePort.postMessage({ type: 'ping' }); } catch (e) { console.warn('Iframe port closed'); this.iframePort = null; } } }, 30000); // 30 seconds } } teardown() { this.unsubscribe(); if (this.iframePort) { try { this.iframePort.close(); } catch (e) { console.warn('Error closing iframe port:', e); } this.iframePort = null; } if (this.pool) { this.pool.close(this.relays); this.pool = null; } // Clear all pending requests for (const id in this.pendingRequests) { this.pendingRequests[id].reject(new Error('Client teardown')); } this.pendingRequests = {}; } } // ====================================== // nostr-login-lite.js // ====================================== // Import NIP-46 client if (typeof NIP46Client === 'undefined') { // Load NIP46Client if not already available (for non-bundled version) const script = document.createElement('script'); script.src = './core/nip46-client.js'; document.head.appendChild(script); } // Global state const LiteState = { initialized: false, windowNostr: null, options: null, auth: null, modal: null, bus: null, pool: null, nip44Codec: null, extensionBridge: null, nip46Client: null }; // Dependencies verification class Deps { static ensureNostrToolsLoaded() { if (typeof window === 'undefined') { throw new Error('NOSTR_LOGIN_LITE must run in browser environment'); } if (!window.NostrTools) { throw new Error( 'window.NostrTools is required but not loaded. ' + 'Please include: ' ); } // Verify required APIs const required = ['SimplePool', 'getPublicKey', 'finalizeEvent', 'nip04']; for (const api of required) { if (!window.NostrTools[api]) { throw new Error(`window.NostrTools.${api} is required but missing`); } } // Check for key generation function (might be generateSecretKey or generatePrivateKey) if (!window.NostrTools.generateSecretKey && !window.NostrTools.generatePrivateKey) { throw new Error('window.NostrTools must have either generateSecretKey or generatePrivateKey'); } return true; } } // Event Bus for internal communication class Bus { constructor() { this.handlers = {}; } on(event, handler) { if (!this.handlers[event]) { this.handlers[event] = []; } this.handlers[event].push(handler); } off(event, handler) { if (!this.handlers[event]) return; this.handlers[event] = this.handlers[event].filter(h => h !== handler); } emit(event, payload) { if (!this.handlers[event]) return; this.handlers[event].forEach(handler => { try { handler(payload); } catch (e) { console.error(`Error in event handler for ${event}:`, e); } }); } } // Storage helpers class Store { static addAccount(info) { const accounts = this.getAccounts(); // Remove existing account with same pubkey if present const filtered = accounts.filter(acc => acc.pubkey !== info.pubkey); filtered.push(info); localStorage.setItem('nl_accounts', JSON.stringify(filtered)); } static removeCurrentAccount() { const current = this.getCurrent(); if (current && current.pubkey) { const accounts = this.getAccounts(); const filtered = accounts.filter(acc => acc.pubkey !== current.pubkey); localStorage.setItem('nl_accounts', JSON.stringify(filtered)); localStorage.removeItem('nl_current'); } } static getCurrent() { try { const stored = localStorage.getItem('nl_current'); return stored ? JSON.parse(stored) : null; } catch (e) { console.error('Error parsing current account:', e); return null; } } static setCurrent(info) { localStorage.setItem('nl_current', JSON.stringify(info)); } static getAccounts() { try { const stored = localStorage.getItem('nl_accounts'); return stored ? JSON.parse(stored) : []; } catch (e) { console.error('Error parsing accounts:', e); return []; } } static getRecents() { // Return last 5 used accounts in reverse chronological order const accounts = this.getAccounts().slice(-5).reverse(); return accounts; } static setItem(key, value) { localStorage.setItem(`nl-${key}`, value); } static getItem(key) { return localStorage.getItem(`nl-${key}`); } static async getIcon() { // Simple default icon - could be extended to fetch from profile return 'šŸ”‘'; } } // Relay configuration helpers class Relays { static getDefaultRelays(options) { if (options?.relays) { return this.normalize(options.relays); } // Default relays for fallbacks return [ 'wss://relay.damus.io', 'wss://relay.snort.social', 'wss://nos.lol' ]; } static normalize(relays) { return relays.map(relay => { // Ensure wss:// prefix if (relay.startsWith('ws://')) { return relay.replace('ws://', 'wss://'); } else if (!relay.startsWith('wss://')) { return `wss://${relay}`; } return relay; }).filter(relay => { // Remove duplicates and validate URLs try { new URL(relay); return true; } catch { return false; } }).filter((relay, index, self) => self.indexOf(relay) === index); // dedupe } } // Minimal NIP-44 codec fallback class Nip44 { constructor() { this.Nip44 = null; // Initialize with existing codec if available this.nip44Available = window.NostrTools?.nip44; } static encrypt(ourSk, theirPk, plaintext) { if (window.NostrTools?.nip44?.encrypt) { return window.NostrTools.nip44.encrypt(ourSk, theirPk, plaintext); } throw new Error('NIP-44 encryption not available. Please use nostr-tools@>=2.x or provide codec implementation.'); } static decrypt(ourSk, theirPk, ciphertext) { if (window.NostrTools?.nip44?.decrypt) { return window.NostrTools.nip44.decrypt(ourSk, theirPk, ciphertext); } throw new Error('NIP-44 decryption not available. Please use nostr-tools@>=2.x or provide codec implementation.'); } } // LocalSigner wrapping window.NostrTools class LocalSigner { constructor(sk) { this.sk = sk; // Generate pubkey from secret key this.pk = this._getPubKey(); } _getPubKey() { const seckey = this.sk.startsWith('nsec') ? window.NostrTools.nip19.decode(this.sk).data : this.sk; return window.NostrTools.getPublicKey(seckey); } pubkey() { return this.pk; } async sign(event) { // Prepare event for signing const ev = { ...event }; ev.pubkey = this.pk; // Generate event ID and sign const signedEvent = await window.NostrTools.finalizeEvent(ev, this.sk); return signedEvent; } async encrypt04(pubkey, plaintext) { return await window.NostrTools.nip04.encrypt(this.sk, pubkey, plaintext); } async decrypt04(pubkey, ciphertext) { return await window.NostrTools.nip04.decrypt(this.sk, pubkey, ciphertext); } async encrypt44(pubkey, plaintext) { return Nip44.encrypt(this.sk, pubkey, plaintext); } async decrypt44(pubkey, ciphertext) { return Nip44.decrypt(this.sk, pubkey, ciphertext); } } // ExtensionBridge for detecting and managing browser extensions class ExtensionBridge { constructor() { this.checking = false; this.checkInterval = null; this.originalNostr = null; this.foundExtension = null; } startChecking(nostrLite) { if (this.checking) return; this.checking = true; const check = () => { this.initExtension(nostrLite); }; // Check immediately check(); // Then check every 200ms for 30 seconds this.checkInterval = setInterval(check, 200); // Stop checking after 30 seconds setTimeout(() => { clearInterval(this.checkInterval); this.checkInterval = null; }, 30000); } initExtension(nostrLite, lastTry = false) { const extension = window.nostr; if (extension && !this.foundExtension) { // Check if this is actually a real extension, not our own library const isRealExtension = ( extension !== nostrLite && // Not the same object we're about to assign extension !== windowNostr && // Not our windowNostr object typeof extension._hexToUint8Array !== 'function' && // Our library has this internal method extension.constructor.name !== 'Object' // Real extensions usually have proper constructors ); if (isRealExtension) { this.foundExtension = extension; // Cache the extension and reassign window.nostr to our lite version this.originalNostr = window.nostr; window.nostr = nostrLite; console.log('Real Nostr extension detected and bridged:', extension.constructor.name); // If currently authenticated, reconcile state if (LiteState.auth?.signer?.method === 'extension') { this.reconcileExtension(); } } else { console.log('Skipping non-extension object on window.nostr:', extension.constructor.name); } } } hasExtension() { return !!this.foundExtension; } async setExtensionReadPubkey(expectedPubkey = null) { if (!this.foundExtension) return false; try { // Temporarily set window.nostr to extension const temp = window.nostr; window.nostr = this.foundExtension; const pubkey = await this.foundExtension.getPublicKey(); // Restore our lite implementation window.nostr = temp; if (expectedPubkey && pubkey !== expectedPubkey) { console.warn(`Extension pubkey ${pubkey} does not match expected ${expectedPubkey}`); } return pubkey; } catch (e) { console.error('Error reading extension pubkey:', e); return null; } } trySetForPubkey(expectedPubkey) { if (!this.hasExtension()) return false; this.setExtensionReadPubkey(expectedPubkey).then(pubkey => { if (pubkey) { LiteState.bus?.emit('extensionLogin', { pubkey }); } }); return true; } setExtension() { if (!this.foundExtension) return; window.nostr = this.foundExtension; this.setExtensionReadPubkey().then(pubkey => { if (pubkey) { LiteState.bus?.emit('extensionSet', { pubkey }); } }); } unset(nostrLite) { window.nostr = nostrLite; } reconcileExtension() { // Handle extension state changes this.setExtensionReadPubkey().then(pubkey => { if (pubkey) { // Update current account if extension is the signer const current = Store.getCurrent(); if (current && current.signer?.method === 'extension') { const info = { ...current, pubkey, signer: { method: 'extension' } }; Store.setCurrent(info); LiteState.bus?.emit('authStateUpdate', info); } } }); } } // Main API surface class NostrLite { static async init(options = {}) { // Ensure dependencies are loaded Deps.ensureNostrToolsLoaded(); // Prevent double initialization if (LiteState.initialized) { console.warn('NOSTR_LOGIN_LITE already initialized'); return; } // Initialize components LiteState.bus = new Bus(); LiteState.extensionBridge = new ExtensionBridge(); // Initialize NIP-46 client LiteState.nip46Client = new NIP46Client(); // Store options LiteState.options = { theme: 'light', darkMode: false, relays: Relays.getDefaultRelays(options), methods: { connect: true, extension: true, local: true, readonly: true, otp: true }, otp: {}, ...options }; // Start extension detection LiteState.extensionBridge.startChecking(windowNostr); // Setup auth methods this._setupAuth(); // Initialize modal UI // this._initModal(); console.log('NOSTR_LOGIN_LITE initialized with options:', LiteState.options); LiteState.initialized = true; } static _setupAuth() { // Set up event listeners for modal interactions window.addEventListener('nlMethodSelected', (event) => { this._handleMethodSelected(event.detail); }); // Set up other auth-related event listeners this._setupAuthEventListeners(); console.log('Auth system setup loaded'); } static _setupAuthEventListeners() { // Handle extension detection this.bus?.on('extensionDetected', (extension) => { console.log('Extension detected'); LiteState.extensionBridge.foundExtension = extension; }); // Handle auth URL from NIP-46 window.addEventListener('nlAuthUrl', (event) => { console.log('Auth URL received:', event.detail.url); // Could show URL in modal or trigger external flow }); // Handle logout events window.addEventListener('nlLogout', () => { console.log('Logout event received'); this.logout(); }); } static _handleMethodSelected(detail) { console.log('Method selected:', detail); const { method, pubkey, secret, extension } = detail; switch (method) { case 'local': if (secret && pubkey) { // Set up local key authentication const info = { pubkey, signer: { method: 'local', secret } }; Store.setCurrent(info); LiteState.bus?.emit('authStateUpdate', info); this._dispatchAuthEvent('login', info); } break; case 'extension': if (pubkey && extension) { // Store the extension object in the ExtensionBridge for future use LiteState.extensionBridge.foundExtension = extension; LiteState.extensionBridge.originalNostr = extension; // Set up extension authentication const info = { pubkey, signer: { method: 'extension' } }; Store.setCurrent(info); LiteState.bus?.emit('authStateUpdate', info); this._dispatchAuthEvent('login', info); console.log('Extension authentication set up successfully'); } else { // Fallback to extension bridge detection LiteState.bus?.emit('authMethodSelected', { method: 'extension' }); } break; case 'readonly': // Set read-only mode const readonlyInfo = { pubkey: '', signer: { method: 'readonly' } }; Store.setCurrent(readonlyInfo); LiteState.bus?.emit('authStateUpdate', readonlyInfo); this._dispatchAuthEvent('login', readonlyInfo); break; case 'nip46': if (secret && pubkey) { // Set up NIP-46 remote signing const info = { pubkey, signer: { method: 'nip46', ...secret } }; Store.setCurrent(info); LiteState.bus?.emit('authStateUpdate', info); this._dispatchAuthEvent('login', info); } break; default: console.warn('Unhandled auth method:', method); } } static _dispatchAuthEvent(type, info) { const eventPayload = { type, info, pubkey: info?.pubkey || '', method: info?.signer?.method || '', ...info }; // Dispatch the event window.dispatchEvent(new CustomEvent('nlAuth', { detail: eventPayload })); this.bus?.emit('nlAuth', eventPayload); } static launch(startScreen) { if (!LiteState.initialized) { throw new Error('NOSTR_LOGIN_LITE not initialized. Call init() first.'); } console.log('Launch requested with screen:', startScreen); // Initialize modal if needed if (!LiteState.modal) { // Import modal lazily if (typeof Modal !== 'undefined') { LiteState.modal = Modal.init(LiteState.options); } else { console.error('Modal component not available'); return; } } // Open modal with specified screen LiteState.modal.open({ startScreen }); } static logout() { if (!LiteState.initialized) return; // Clear current account and state Store.removeCurrentAccount(); // Reset internal state LiteState.auth = null; // Emit logout event window.dispatchEvent(new CustomEvent('nlLogout')); LiteState.bus?.emit('logout'); console.log('Logged out'); } static setDarkMode(dark) { if (!LiteState.options) return; LiteState.options.darkMode = dark; Store.setItem('darkMode', dark.toString()); // Update modal theme if initialized if (LiteState.modal) { // LiteState.modal.updateTheme(); } window.dispatchEvent(new CustomEvent('nlDarkMode', { detail: { dark } })); } static setAuth(o) { if (!o || !o.type) return; console.log('setAuth called:', o); // Validate request if (!['login', 'signup', 'logout'].includes(o.type)) { throw new Error(`Invalid auth type: ${o.type}`); } if (['login', 'signup'].includes(o.type) && !['connect', 'extension', 'local', 'otp', 'readOnly'].includes(o.method)) { throw new Error(`Invalid auth method: ${o.method}`); } // Handle based on type switch (o.type) { case 'logout': this.logout(); break; default: // Delegate to auth system - will be implemented console.log('Auth delegation not yet implemented'); } } static cancelNeedAuth() { // Cancel any ongoing auth flows LiteState.bus?.emit('cancelAuth'); console.log('Auth flow cancelled'); } } // Initialize the window.nostr facade const windowNostr = { async getPublicKey() { if (!LiteState.initialized) { throw new Error('NOSTR_LOGIN_LITE not initialized'); } const current = Store.getCurrent(); if (current && current.pubkey) { return current.pubkey; } // Trigger auth flow const authPromise = new Promise((resolve, reject) => { const handleAuth = (event) => { window.removeEventListener('nlAuth', handleAuth); if (event.detail.type === 'login' && event.detail.pubkey) { resolve(event.detail.pubkey); } else { reject(new Error('Authentication cancelled')); } }; window.addEventListener('nlAuth', handleAuth); // Set timeout setTimeout(() => { window.removeEventListener('nlAuth', handleAuth); reject(new Error('Authentication timeout')); }, 300000); // 5 minutes }); // Launch auth modal NostrLite.launch('login'); return authPromise; }, async signEvent(event) { if (!LiteState.initialized) { throw new Error('NOSTR_LOGIN_LITE not initialized'); } let current = Store.getCurrent(); // If no current account, trigger auth if (!current) { await window.nostr.getPublicKey(); // This will trigger auth current = Store.getCurrent(); if (!current) { throw new Error('Authentication failed'); } } // Route to appropriate signer if (current.signer?.method === 'local' && current.signer.secret) { const signer = new LocalSigner(this._hexToUint8Array(current.signer.secret)); return await signer.sign(event); } else if (current.signer?.method === 'nip46' && current.signer.bunkerSigner) { // Route to NIP-46 remote signer try { const bunkerSigner = current.signer.bunkerSigner; const signedEvent = await bunkerSigner.signEvent(event); return signedEvent; } catch (error) { console.error('NIP-46 signEvent failed:', error); throw new Error(`NIP-46 signing failed: ${error.message}`); } } else if (current.signer?.method === 'readonly') { throw new Error('Cannot sign events in read-only mode'); } else if (current.signer?.method === 'extension' && LiteState.extensionBridge?.hasExtension()) { // Route to extension const temp = window.nostr; window.nostr = LiteState.extensionBridge.foundExtension; try { const signedEvent = await window.nostr.signEvent(event); return signedEvent; } finally { window.nostr = temp; } } throw new Error('No suitable signer available for current account'); }, _hexToUint8Array(hex) { // Convert hex string to Uint8Array 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; }, nip04: { async encrypt(pubkey, plaintext) { if (!LiteState.initialized) { throw new Error('NOSTR_LOGIN_LITE not initialized'); } const current = Store.getCurrent(); if (!current) { throw new Error('No authenticated user'); } if (current.signer?.method === 'nip46' && current.signer.bunkerSigner) { // Route to NIP-46 remote signer try { const bunkerSigner = current.signer.bunkerSigner; return await bunkerSigner.nip04Encrypt(pubkey, plaintext); } catch (error) { console.error('NIP-46 nip04 encrypt failed:', error); throw new Error(`NIP-46 encrypting failed: ${error.message}`); } } else if (current.signer?.method === 'local' && current.signer.secret) { const signer = new LocalSigner(current.signer.secret); return await signer.encrypt04(pubkey, plaintext); } else if (current.signer?.method === 'extension' && LiteState.extensionBridge?.hasExtension()) { const temp = window.nostr; window.nostr = LiteState.extensionBridge.foundExtension; try { return await window.nostr.nip04.encrypt(pubkey, plaintext); } finally { window.nostr = temp; } } throw new Error('No suitable signer available for NIP-04 encryption'); }, async decrypt(pubkey, ciphertext) { if (!LiteState.initialized) { throw new Error('NOSTR_LOGIN_LITE not initialized'); } const current = Store.getCurrent(); if (!current) { throw new Error('No authenticated user'); } if (current.signer?.method === 'nip46' && current.signer.bunkerSigner) { // Route to NIP-46 remote signer try { const bunkerSigner = current.signer.bunkerSigner; return await bunkerSigner.nip04Decrypt(pubkey, ciphertext); } catch (error) { console.error('NIP-46 nip04 decrypt failed:', error); throw new Error(`NIP-46 decrypting failed: ${error.message}`); } } else if (current.signer?.method === 'local' && current.signer.secret) { const signer = new LocalSigner(current.signer.secret); return await signer.decrypt04(pubkey, ciphertext); } else if (current.signer?.method === 'extension' && LiteState.extensionBridge?.hasExtension()) { const temp = window.nostr; window.nostr = LiteState.extensionBridge.foundExtension; try { return await window.nostr.nip04.decrypt(pubkey, ciphertext); } finally { window.nostr = temp; } } throw new Error('No suitable signer available for NIP-04 decryption'); } }, nip44: { async encrypt(pubkey, plaintext) { if (!LiteState.initialized) { throw new Error('NOSTR_LOGIN_LITE not initialized'); } const current = Store.getCurrent(); if (!current) { throw new Error('No authenticated user'); } if (current.signer?.method === 'nip46' && current.signer.bunkerSigner) { // Route to NIP-46 remote signer try { const bunkerSigner = current.signer.bunkerSigner; return await bunkerSigner.nip44Encrypt(pubkey, plaintext); } catch (error) { console.error('NIP-46 nip44 encrypt failed:', error); throw new Error(`NIP-46 encrypting failed: ${error.message}`); } } else if (current.signer?.method === 'local' && current.signer.secret) { const signer = new LocalSigner(current.signer.secret); return await signer.encrypt44(pubkey, plaintext); } else if (current.signer?.method === 'extension' && LiteState.extensionBridge?.hasExtension()) { // Use extension if it supports nip44 const temp = window.nostr; window.nostr = LiteState.extensionBridge.foundExtension; try { if (window.nostr.nip44) { return await window.nostr.nip44.encrypt(pubkey, plaintext); } else { throw new Error('Extension does not support NIP-44'); } } finally { window.nostr = temp; } } throw new Error('No suitable signer available for NIP-44 encryption'); }, async decrypt(pubkey, ciphertext) { if (!LiteState.initialized) { throw new Error('NOSTR_LOGIN_LITE not initialized'); } const current = Store.getCurrent(); if (!current) { throw new Error('No authenticated user'); } if (current.signer?.method === 'nip46' && current.signer.bunkerSigner) { // Route to NIP-46 remote signer try { const bunkerSigner = current.signer.bunkerSigner; return await bunkerSigner.nip44Decrypt(pubkey, ciphertext); } catch (error) { console.error('NIP-46 nip44 decrypt failed:', error); throw new Error(`NIP-46 decrypting failed: ${error.message}`); } } else if (current.signer?.method === 'local' && current.signer.secret) { const signer = new LocalSigner(current.signer.secret); return await signer.decrypt44(pubkey, ciphertext); } else if (current.signer?.method === 'extension' && LiteState.extensionBridge?.hasExtension()) { const temp = window.nostr; window.nostr = LiteState.extensionBridge.foundExtension; try { if (window.nostr.nip44) { return await window.nostr.nip44.decrypt(pubkey, ciphertext); } else { throw new Error('Extension does not support NIP-44'); } } finally { window.nostr = temp; } } throw new Error('No suitable signer available for NIP-44 decryption'); } } }; // Export the API window.NOSTR_LOGIN_LITE = { init: NostrLite.init.bind(NostrLite), launch: NostrLite.launch.bind(NostrLite), logout: NostrLite.logout.bind(NostrLite), setDarkMode: NostrLite.setDarkMode.bind(NostrLite), setAuth: NostrLite.setAuth.bind(NostrLite), cancelNeedAuth: NostrLite.cancelNeedAuth.bind(NostrLite), // Expose internal components for debugging get _extensionBridge() { return LiteState.extensionBridge; }, get _state() { return LiteState; } }; // Set window.nostr facade properly (extensions will be handled by ExtensionBridge) if (typeof window !== 'undefined') { window.nostr = windowNostr; // Ensure all methods are properly exposed console.log('NOSTR_LOGIN_LITE: window.nostr facade installed with methods:', Object.keys(windowNostr)); } console.log('NOSTR_LOGIN_LITE loaded - use window.NOSTR_LOGIN_LITE.init(options) to initialize');