diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000..e1abf4c --- /dev/null +++ b/.gitmodules @@ -0,0 +1,6 @@ +[submodule "style_guide"] + path = style_guide + url = ssh://git@git.laantungir.net:222/laantungir/style_guide.git +[submodule "nostr_login_lite"] + path = nostr_login_lite + url = ssh://git@git.laantungir.net:222/laantungir/nostr_login_lite.git diff --git a/SUPs.md b/SUPs.md index 39e0c2d..0d4224c 100644 --- a/SUPs.md +++ b/SUPs.md @@ -62,6 +62,29 @@ Current Nostr implementations reveal users' network locations through direct rel 3. **Delay Compliance**: Wait specified time plus random jitter 4. **Padding Operations**: Apply size modifications as instructed 5. **Multi-Relay Posting**: Post to all specified relays +6. **Relay Authentication Constraint**: Throwers can only write to relays that do not require authentication (AUTH) + +#### Relay Authentication Requirements + +**Critical Constraint**: Throwers MUST be able to post events signed by other users without possessing their private keys. This creates a fundamental limitation: + +- **Read Capability**: Throwers can monitor any relay (AUTH or non-AUTH) for incoming Superballs +- **Write Capability**: Throwers can ONLY post to relays that do not require NIP-42 authentication +- **NIP-65 Compliance**: Throwers must maintain accurate relay lists distinguishing read vs write capabilities + +**NIP-65 Relay List Format for Throwers**: +```json +{ + "kind": 10002, + "tags": [ + ["r", "wss://noauth.relay.com"], // Read+Write (no AUTH required) + ["r", "wss://auth-required.relay.com", "read"], // Read only (AUTH required) + ["r", "wss://write-only.relay.com", "write"] // Write only (no AUTH required) + ] +} +``` + +**Authentication Testing**: Throwers should automatically test relay authentication requirements by attempting anonymous event publication and classify relays accordingly. ### Rationale diff --git a/THROWER.md b/THROWER.md index a492e6e..bfb82be 100644 --- a/THROWER.md +++ b/THROWER.md @@ -78,9 +78,10 @@ I am a Thrower - an anonymizing node that provides location privacy for Nostr us - Queue the event for delayed processing #### Relays -- Post to ALL relays in the `relays` array +- Post to ALL relays in the `relays` array that don't require AUTH +- Skip AUTH-required relays when posting final events (can't authenticate as original author) - Validate all relay URLs are properly formatted -- Provides redundancy and availability +- Provides redundancy and availability within AUTH constraints #### Next Hop Logic - **`p` field present**: Forward to next Thrower with padding-only wrapper @@ -147,8 +148,11 @@ I am a Thrower - an anonymizing node that provides location privacy for Nostr us ### Network Rules 1. **Multiple relays** - Connect to diverse set of relays 2. **Separate connections** - Use different connections for input/output -3. **AUTH support** - Prefer relays that support AUTH for privacy -4. **Rotate connections** - Periodically reconnect to prevent fingerprinting +3. **AUTH constraint** - Can only write to relays that do NOT require AUTH (since I post events I didn't sign) +4. **Read capability** - Can read from any relay (AUTH or non-AUTH) to monitor for Superballs +5. **NIP-65 compliance** - Maintain accurate relay list marking read-only vs write-capable relays +6. **Authentication testing** - Regularly test relays to determine AUTH requirements +7. **Rotate connections** - Periodically reconnect to prevent fingerprinting ## What I Never Do diff --git a/deploy.sh b/deploy.sh new file mode 100755 index 0000000..ecc7286 --- /dev/null +++ b/deploy.sh @@ -0,0 +1,3 @@ +#!/bin/bash + +rsync -avz --progress web/{superball.html,thrower.html,superball-shared.css} ubuntu@laantungir.net:WWW/superball/ diff --git a/nostr_login_lite b/nostr_login_lite new file mode 160000 index 0000000..a7dceb1 --- /dev/null +++ b/nostr_login_lite @@ -0,0 +1 @@ +Subproject commit a7dceb115626f3cab558802a753e52b34a527c2b diff --git a/style_guide b/style_guide new file mode 160000 index 0000000..111a063 --- /dev/null +++ b/style_guide @@ -0,0 +1 @@ +Subproject commit 111a0631f2d5db4ec4d57df10a2203a4fa71faa4 diff --git a/web/nostr-lite.js b/web/nostr-lite.js index 74ac713..ca61418 100644 --- a/web/nostr-lite.js +++ b/web/nostr-lite.js @@ -8,7 +8,7 @@ * Two-file architecture: * 1. Load nostr.bundle.js (official nostr-tools bundle) * 2. Load nostr-lite.js (this file - NOSTR_LOGIN_LITE library with CSS-only themes) - * Generated on: 2025-09-16T22:12:00.192Z + * Generated on: 2025-09-20T19:25:01.143Z */ // Verify dependencies are loaded @@ -44,7 +44,7 @@ const THEME_CSS = { --nl-primary-color: #000000; --nl-secondary-color: #ffffff; --nl-accent-color: #ff0000; - --nl-muted-color: #666666; + --nl-muted-color: #CCCCCC; --nl-font-family: "Courier New", Courier, monospace; --nl-border-radius: 15px; --nl-border-width: 3px; @@ -401,7 +401,7 @@ class Modal { closeButton.style.cssText = ` background: var(--nl-secondary-color); border: var(--nl-border-width) solid var(--nl-primary-color); - border-radius: var(--nl-border-radius); + border-radius: 4px; font-size: 28px; color: var(--nl-primary-color); cursor: pointer; @@ -585,21 +585,13 @@ class Modal { }; const iconDiv = document.createElement('div'); - // Replace emoji icons with text-based ones - const iconMap = { - '🔌': '[EXT]', - '🔑': '[KEY]', - '🌱': '[SEED]', - '🌐': '[NET]', - '👁️': '[VIEW]', - '📱': '[SMS]' - }; - iconDiv.textContent = iconMap[option.icon] || option.icon; + // Remove the icon entirely - no emojis or text-based icons + iconDiv.textContent = ''; iconDiv.style.cssText = ` font-size: 16px; font-weight: bold; margin-right: 16px; - width: 50px; + width: 0px; text-align: center; color: var(--nl-primary-color); font-family: var(--nl-font-family, 'Courier New', monospace); @@ -1390,11 +1382,18 @@ class Modal { if (method === 'extension') { console.log('Modal: Extension method - NOT installing facade, leaving window.nostr as extension'); + // Save extension authentication state using global setAuthState function + if (typeof window.setAuthState === 'function') { + console.log('Modal: Saving extension auth state to storage'); + window.setAuthState({ method, ...options }, { isolateSession: this.options?.isolateSession }); + } + // Emit auth method selection directly for extension const event = new CustomEvent('nlMethodSelected', { detail: { method, ...options } }); window.dispatchEvent(event); + this.close(); return; } @@ -1494,10 +1493,6 @@ class Modal { _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;'; @@ -1522,12 +1517,67 @@ class Modal { box-sizing: border-box; `; - // Users will enter the complete bunker connection string with relay info + // Add real-time bunker key validation + const formatHint = document.createElement('div'); + formatHint.style.cssText = 'margin-bottom: 16px; font-size: 12px; color: #6b7280; min-height: 16px;'; const connectButton = document.createElement('button'); connectButton.textContent = 'Connect to Bunker'; - connectButton.onclick = () => this._handleNip46Connect(pubkeyInput.value); - connectButton.style.cssText = this._getButtonStyle(); + connectButton.disabled = true; + connectButton.onclick = () => { + if (!connectButton.disabled) { + this._handleNip46Connect(pubkeyInput.value); + } + }; + + // Set initial disabled state + connectButton.style.cssText = ` + display: block; + width: 100%; + padding: 12px; + border: var(--nl-border-width) solid var(--nl-muted-color); + border-radius: var(--nl-border-radius); + font-size: 16px; + font-weight: 500; + cursor: not-allowed; + transition: all 0.2s; + font-family: var(--nl-font-family, 'Courier New', monospace); + background: var(--nl-secondary-color); + color: var(--nl-muted-color); + margin-bottom: 12px; + `; + + pubkeyInput.oninput = () => { + const value = pubkeyInput.value.trim(); + if (!value) { + formatHint.textContent = ''; + // Disable button + connectButton.disabled = true; + connectButton.style.borderColor = 'var(--nl-muted-color)'; + connectButton.style.color = 'var(--nl-muted-color)'; + connectButton.style.cursor = 'not-allowed'; + return; + } + + const isValid = this._validateBunkerKey(value); + if (isValid) { + formatHint.textContent = '✅ Valid bunker connection format detected'; + formatHint.style.color = '#059669'; + // Enable button + connectButton.disabled = false; + connectButton.style.borderColor = 'var(--nl-primary-color)'; + connectButton.style.color = 'var(--nl-primary-color)'; + connectButton.style.cursor = 'pointer'; + } else { + formatHint.textContent = '❌ Invalid format - must be bunker://, npub, or 64-char hex'; + formatHint.style.color = '#dc2626'; + // Disable button + connectButton.disabled = true; + connectButton.style.borderColor = 'var(--nl-muted-color)'; + connectButton.style.color = 'var(--nl-muted-color)'; + connectButton.style.cursor = 'not-allowed'; + } + }; const backButton = document.createElement('button'); backButton.textContent = 'Back'; @@ -1536,14 +1586,49 @@ class Modal { formGroup.appendChild(label); formGroup.appendChild(pubkeyInput); + formGroup.appendChild(formatHint); - this.modalBody.appendChild(title); this.modalBody.appendChild(description); this.modalBody.appendChild(formGroup); this.modalBody.appendChild(connectButton); this.modalBody.appendChild(backButton); } + _validateBunkerKey(bunkerKey) { + try { + const trimmed = bunkerKey.trim(); + + // Check for bunker:// format + if (trimmed.startsWith('bunker://')) { + // Should have format: bunker://pubkey or bunker://pubkey?param=value + const match = trimmed.match(/^bunker:\/\/([0-9a-fA-F]{64})(\?.*)?$/); + return !!match; + } + + // Check for npub format + if (trimmed.startsWith('npub1') && trimmed.length === 63) { + try { + if (window.NostrTools?.nip19) { + const decoded = window.NostrTools.nip19.decode(trimmed); + return decoded.type === 'npub'; + } + } catch { + return false; + } + } + + // Check for hex format (64 characters, valid hex) + if (trimmed.length === 64 && /^[a-fA-F0-9]{64}$/.test(trimmed)) { + return true; + } + + return false; + } catch (error) { + console.log('Bunker key validation failed:', error.message); + return false; + } + } + _handleNip46Connect(bunkerPubkey) { if (!bunkerPubkey || !bunkerPubkey.length) { this._showError('Bunker pubkey is required'); @@ -1613,9 +1698,9 @@ class Modal { const localSecretKey = window.NostrTools.generateSecretKey(); console.log('Generated local client keypair for NIP-46 session'); - // Use nostr-tools BunkerSigner constructor + // Use nostr-tools BunkerSigner factory method (not constructor - it's private) console.log('Creating nip46 BunkerSigner...'); - const signer = new window.NostrTools.nip46.BunkerSigner(localSecretKey, bunkerPointer, { + 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 @@ -1695,12 +1780,8 @@ class Modal { _showSeedPhraseScreen() { this.modalBody.innerHTML = ''; - const title = document.createElement('h3'); - title.textContent = 'Import from Seed Phrase'; - title.style.cssText = 'margin: 0 0 16px 0; font-size: 18px; font-weight: 600;'; - const description = document.createElement('p'); - description.textContent = 'Enter your 12 or 24-word mnemonic seed phrase to derive Nostr accounts:'; + description.innerHTML = 'Enter your 12 or 24-word mnemonic seed phrase to derive Nostr accounts, or generate new.'; description.style.cssText = 'margin-bottom: 12px; color: #6b7280; font-size: 14px;'; const textarea = document.createElement('textarea'); @@ -1723,10 +1804,40 @@ class Modal { const formatHint = document.createElement('div'); formatHint.style.cssText = 'margin-bottom: 16px; font-size: 12px; color: #6b7280; min-height: 16px;'; + const importButton = document.createElement('button'); + importButton.textContent = 'Import Accounts'; + importButton.disabled = true; + importButton.onclick = () => { + if (!importButton.disabled) { + this._importFromSeedPhrase(textarea.value); + } + }; + + // Set initial disabled state + importButton.style.cssText = ` + display: block; + width: 100%; + padding: 12px; + border: var(--nl-border-width) solid var(--nl-muted-color); + border-radius: var(--nl-border-radius); + font-size: 16px; + font-weight: 500; + cursor: not-allowed; + transition: all 0.2s; + font-family: var(--nl-font-family, 'Courier New', monospace); + background: var(--nl-secondary-color); + color: var(--nl-muted-color); + `; + textarea.oninput = () => { const value = textarea.value.trim(); if (!value) { formatHint.textContent = ''; + // Disable button + importButton.disabled = true; + importButton.style.borderColor = 'var(--nl-muted-color)'; + importButton.style.color = 'var(--nl-muted-color)'; + importButton.style.cursor = 'not-allowed'; return; } @@ -1735,35 +1846,46 @@ class Modal { const wordCount = value.split(/\s+/).length; formatHint.textContent = `✅ Valid ${wordCount}-word mnemonic detected`; formatHint.style.color = '#059669'; + // Enable button + importButton.disabled = false; + importButton.style.borderColor = 'var(--nl-primary-color)'; + importButton.style.color = 'var(--nl-primary-color)'; + importButton.style.cursor = 'pointer'; } else { formatHint.textContent = '❌ Invalid mnemonic - must be 12 or 24 valid BIP-39 words'; formatHint.style.color = '#dc2626'; + // Disable button + importButton.disabled = true; + importButton.style.borderColor = 'var(--nl-muted-color)'; + importButton.style.color = 'var(--nl-muted-color)'; + importButton.style.cursor = 'not-allowed'; } }; - // Generate new seed phrase button - const generateButton = document.createElement('button'); - generateButton.textContent = 'Generate New Seed Phrase'; - generateButton.onclick = () => this._generateNewSeedPhrase(textarea, formatHint); - generateButton.style.cssText = this._getButtonStyle() + 'margin-bottom: 12px;'; - - const importButton = document.createElement('button'); - importButton.textContent = 'Import Accounts'; - importButton.onclick = () => this._importFromSeedPhrase(textarea.value); - importButton.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(textarea); this.modalBody.appendChild(formatHint); - this.modalBody.appendChild(generateButton); this.modalBody.appendChild(importButton); this.modalBody.appendChild(backButton); + + // Add click handler for the "generate new" link + const generateLink = document.getElementById('generate-new'); + if (generateLink) { + generateLink.addEventListener('mouseenter', () => { + generateLink.style.color = 'var(--nl-accent-color)'; + }); + generateLink.addEventListener('mouseleave', () => { + generateLink.style.color = 'var(--nl-primary-color)'; + }); + generateLink.addEventListener('click', () => { + this._generateNewSeedPhrase(textarea, formatHint); + }); + } } _generateNewSeedPhrase(textarea, formatHint) { @@ -1779,12 +1901,12 @@ class Modal { // Set the generated mnemonic in the textarea textarea.value = mnemonic; - // Trigger validation to show it's valid - const wordCount = mnemonic.split(/\s+/).length; - formatHint.textContent = `✅ Generated valid ${wordCount}-word mnemonic`; - formatHint.style.color = '#059669'; + // Trigger the oninput event to properly validate and enable the button + if (textarea.oninput) { + textarea.oninput(); + } - console.log('Generated new seed phrase:', wordCount, 'words'); + console.log('Generated new seed phrase:', mnemonic.split(/\s+/).length, 'words'); } catch (error) { console.error('Failed to generate seed phrase:', error); @@ -1866,15 +1988,10 @@ class Modal { _showAccountSelection(accounts) { this.modalBody.innerHTML = ''; - const title = document.createElement('h3'); - title.textContent = 'Select Account'; - title.style.cssText = 'margin: 0 0 16px 0; font-size: 18px; font-weight: 600;'; - const description = document.createElement('p'); description.textContent = `Select which account to use (${accounts.length} accounts derived from seed phrase):`; description.style.cssText = 'margin-bottom: 20px; color: #6b7280; font-size: 14px;'; - this.modalBody.appendChild(title); this.modalBody.appendChild(description); // Create table for account selection @@ -1892,8 +2009,7 @@ class Modal { thead.innerHTML = ` # - Public Key (npub) - Action + Use `; table.appendChild(thead); @@ -1908,31 +2024,26 @@ class Modal { indexCell.textContent = account.index; indexCell.style.cssText = 'padding: 8px; text-align: center; border: 1px solid #d1d5db; font-weight: bold;'; - const pubkeyCell = document.createElement('td'); - pubkeyCell.style.cssText = 'padding: 8px; border: 1px solid #d1d5db; font-family: monospace; word-break: break-all;'; - - // Show truncated npub for readability - const truncatedNpub = `${account.npub.slice(0, 12)}...${account.npub.slice(-8)}`; - pubkeyCell.innerHTML = ` - ${truncatedNpub}
- Full: ${account.npub} - `; - const actionCell = document.createElement('td'); - actionCell.style.cssText = 'padding: 8px; text-align: center; border: 1px solid #d1d5db;'; + actionCell.style.cssText = 'padding: 8px; border: 1px solid #d1d5db;'; + + // Show truncated npub in the button + const truncatedNpub = `${account.npub.slice(0, 12)}...${account.npub.slice(-8)}`; const selectButton = document.createElement('button'); - selectButton.textContent = 'Use'; + selectButton.textContent = truncatedNpub; selectButton.onclick = () => this._selectAccount(account); selectButton.style.cssText = ` - padding: 4px 12px; + width: 100%; + padding: 8px 12px; font-size: 11px; background: var(--nl-secondary-color); color: var(--nl-primary-color); border: 1px solid var(--nl-primary-color); border-radius: 4px; cursor: pointer; - font-family: var(--nl-font-family, 'Courier New', monospace); + font-family: 'Courier New', monospace; + text-align: center; `; selectButton.onmouseover = () => { selectButton.style.borderColor = 'var(--nl-accent-color)'; @@ -1944,7 +2055,6 @@ class Modal { actionCell.appendChild(selectButton); row.appendChild(indexCell); - row.appendChild(pubkeyCell); row.appendChild(actionCell); tbody.appendChild(row); }); @@ -2055,8 +2165,6 @@ class FloatingTab { ...options }; - this.isAuthenticated = false; - this.userInfo = null; this.userProfile = null; this.container = null; this.isVisible = false; @@ -2075,6 +2183,12 @@ class FloatingTab { this.show(); } + // Get authentication state from authoritative source (Global Storage-Based Function) + _getAuthState() { + return window.NOSTR_LOGIN_LITE?.getAuthState?.() || null; + } + + _createContainer() { // Remove existing floating tab if any const existingTab = document.getElementById('nl-floating-tab'); @@ -2134,24 +2248,167 @@ class FloatingTab { // Listen for authentication events window.addEventListener('nlMethodSelected', (e) => { - console.log('FloatingTab: Authentication method selected:', e.detail); + console.log('🔍 FloatingTab: Authentication method selected event received'); + console.log('🔍 FloatingTab: Event detail:', e.detail); + this._handleAuth(e.detail); + }); + + window.addEventListener('nlAuthRestored', (e) => { + console.log('🔍 FloatingTab: ✅ Authentication restored event received'); + console.log('🔍 FloatingTab: Event detail:', e.detail); + console.log('🔍 FloatingTab: Calling _handleAuth with restored data...'); this._handleAuth(e.detail); }); window.addEventListener('nlLogout', () => { - console.log('FloatingTab: Logout detected'); + console.log('🔍 FloatingTab: Logout event received'); this._handleLogout(); }); + + // Check for existing authentication state on initialization + window.addEventListener('load', () => { + setTimeout(() => { + this._checkExistingAuth(); + }, 1000); // Wait 1 second for all initialization to complete + }); + } + + // Check for existing authentication on page load + async _checkExistingAuth() { + console.log('🔍 FloatingTab: === _checkExistingAuth START ==='); + + try { + const storageKey = 'nostr_login_lite_auth'; + let storedAuth = null; + + // Try sessionStorage first, then localStorage + if (sessionStorage.getItem(storageKey)) { + storedAuth = JSON.parse(sessionStorage.getItem(storageKey)); + console.log('🔍 FloatingTab: Found auth in sessionStorage:', storedAuth.method); + } else if (localStorage.getItem(storageKey)) { + storedAuth = JSON.parse(localStorage.getItem(storageKey)); + console.log('🔍 FloatingTab: Found auth in localStorage:', storedAuth.method); + } + + if (storedAuth) { + // Check if stored auth is not expired + const maxAge = storedAuth.method === 'extension' ? 60 * 60 * 1000 : 24 * 60 * 60 * 1000; + if (Date.now() - storedAuth.timestamp <= maxAge) { + console.log('🔍 FloatingTab: Found valid stored auth, simulating auth event'); + + // Create auth data object for FloatingTab + const authData = { + method: storedAuth.method, + pubkey: storedAuth.pubkey + }; + + // For extensions, try to find the extension + if (storedAuth.method === 'extension') { + if (window.nostr && window.nostr.constructor?.name !== 'WindowNostr') { + authData.extension = window.nostr; + } + } + + await this._handleAuth(authData); + } else { + console.log('🔍 FloatingTab: Stored auth expired, clearing'); + sessionStorage.removeItem(storageKey); + localStorage.removeItem(storageKey); + } + } else { + console.log('🔍 FloatingTab: No existing authentication found'); + } + + } catch (error) { + console.error('🔍 FloatingTab: Error checking existing auth:', error); + } + + console.log('🔍 FloatingTab: === _checkExistingAuth END ==='); } _handleClick() { console.log('FloatingTab: Clicked'); - if (this.isAuthenticated && this.options.behavior.showUserInfo) { + const authState = this._getAuthState(); + if (authState && this.options.behavior.showUserInfo) { // Show user menu or profile options this._showUserMenu(); } else { - // Open login modal + // Always open login modal (consistent with login buttons) + if (this.modal) { + this.modal.open({ startScreen: 'login' }); + } + } + } + + // Check if object is a real extension (same logic as NostrLite._isRealExtension) + _isRealExtension(obj) { + if (!obj || typeof obj !== 'object') { + return false; + } + + // Must have required Nostr methods + if (typeof obj.getPublicKey !== 'function' || typeof obj.signEvent !== 'function') { + return false; + } + + // Exclude our own library classes + const constructorName = obj.constructor?.name; + if (constructorName === 'WindowNostr' || constructorName === 'NostrLite') { + return false; + } + + // Exclude NostrTools library object + if (obj === window.NostrTools) { + return false; + } + + // Conservative check: Look for common extension characteristics + const extensionIndicators = [ + '_isEnabled', 'enabled', 'kind', '_eventEmitter', '_scope', + '_requests', '_pubkey', 'name', 'version', 'description' + ]; + + const hasIndicators = extensionIndicators.some(prop => obj.hasOwnProperty(prop)); + + // Additional check: Extensions often have specific constructor patterns + const hasExtensionConstructor = constructorName && + constructorName !== 'Object' && + constructorName !== 'Function'; + + return hasIndicators || hasExtensionConstructor; + } + + // Try to login with extension and trigger proper persistence + async _tryExtensionLogin(extension) { + try { + console.log('FloatingTab: Attempting extension login'); + + // Get pubkey from extension + const pubkey = await extension.getPublicKey(); + console.log('FloatingTab: Extension provided pubkey:', pubkey); + + // Create extension auth data + const extensionAuth = { + method: 'extension', + pubkey: pubkey, + extension: extension + }; + + // **CRITICAL FIX**: Dispatch nlMethodSelected event to trigger persistence + console.log('FloatingTab: Dispatching nlMethodSelected for persistence'); + if (typeof window !== 'undefined') { + window.dispatchEvent(new CustomEvent('nlMethodSelected', { + detail: extensionAuth + })); + } + + // Also call our local _handleAuth for UI updates + await this._handleAuth(extensionAuth); + + } catch (error) { + console.error('FloatingTab: Extension login failed:', error); + // Fall back to opening modal if (this.modal) { this.modal.open({ startScreen: 'login' }); } @@ -2159,34 +2416,58 @@ class FloatingTab { } async _handleAuth(authData) { - console.log('FloatingTab: Handling authentication:', authData); - this.isAuthenticated = true; - this.userInfo = authData; + console.log('🔍 FloatingTab: === _handleAuth START ==='); + console.log('🔍 FloatingTab: authData received:', authData); - // Fetch user profile if enabled and we have a pubkey - if (this.options.getUserInfo && authData.pubkey) { - console.log('FloatingTab: Fetching user profile for:', authData.pubkey); - try { - const profile = await this._fetchUserProfile(authData.pubkey); - this.userProfile = profile; - console.log('FloatingTab: User profile fetched:', profile); - } catch (error) { - console.warn('FloatingTab: Failed to fetch user profile:', error); - this.userProfile = null; + // Wait a brief moment for WindowNostr to process the authentication + setTimeout(async () => { + console.log('🔍 FloatingTab: Checking authentication state from authoritative source...'); + + const authState = this._getAuthState(); + const isAuthenticated = !!authState; + + console.log('🔍 FloatingTab: Authoritative auth state:', authState); + console.log('🔍 FloatingTab: Is authenticated:', isAuthenticated); + + if (isAuthenticated) { + console.log('🔍 FloatingTab: ✅ Authentication verified from authoritative source'); + } else { + console.error('🔍 FloatingTab: ❌ Authentication not found in authoritative source'); } - } + + // Fetch user profile if enabled and we have a pubkey + if (this.options.getUserInfo && authData.pubkey) { + console.log('🔍 FloatingTab: getUserInfo enabled, fetching profile for:', authData.pubkey); + try { + const profile = await this._fetchUserProfile(authData.pubkey); + this.userProfile = profile; + console.log('🔍 FloatingTab: User profile fetched:', profile); + } catch (error) { + console.warn('🔍 FloatingTab: Failed to fetch user profile:', error); + this.userProfile = null; + } + } else { + console.log('🔍 FloatingTab: getUserInfo disabled or no pubkey, skipping profile fetch'); + } + + this._updateAppearance(); // Update UI based on authoritative state + + console.log('🔍 FloatingTab: hideWhenAuthenticated option:', this.options.behavior.hideWhenAuthenticated); + + if (this.options.behavior.hideWhenAuthenticated && isAuthenticated) { + console.log('🔍 FloatingTab: Hiding tab (hideWhenAuthenticated=true and authenticated)'); + this.hide(); + } else { + console.log('🔍 FloatingTab: Keeping tab visible'); + } + + }, 500); // Wait 500ms for WindowNostr to complete authentication processing - if (this.options.behavior.hideWhenAuthenticated) { - this.hide(); - } else { - this._updateAppearance(); - } + console.log('🔍 FloatingTab: === _handleAuth END ==='); } _handleLogout() { console.log('FloatingTab: Handling logout'); - this.isAuthenticated = false; - this.userInfo = null; this.userProfile = null; if (this.options.behavior.hideWhenAuthenticated) { @@ -2221,10 +2502,21 @@ class FloatingTab { } menu.style.top = tabRect.top + 'px'; - // Menu content - const userDisplay = this.userInfo?.pubkey ? - `${this.userInfo.pubkey.slice(0, 8)}...${this.userInfo.pubkey.slice(-4)}` : - 'Authenticated'; + // Menu content - use _getAuthState() as single source of truth + const authState = this._getAuthState(); + let userDisplay; + + if (authState?.pubkey) { + // Use profile name if available, otherwise pubkey + if (this.userProfile?.name || this.userProfile?.display_name) { + const userName = this.userProfile.name || this.userProfile.display_name; + userDisplay = userName.length > 16 ? `${userName.slice(0, 16)}...` : userName; + } else { + userDisplay = `${authState.pubkey.slice(0, 8)}...${authState.pubkey.slice(-4)}`; + } + } else { + userDisplay = 'Authenticated'; + } menu.innerHTML = `
${userDisplay}
@@ -2253,8 +2545,12 @@ class FloatingTab { _updateAppearance() { if (!this.container) return; + // Query authoritative source for all state information + const authState = this._getAuthState(); + const isAuthenticated = authState !== null; + // Update content - if (this.isAuthenticated && this.options.behavior.showUserInfo) { + if (isAuthenticated && this.options.behavior.showUserInfo) { let display; // Use profile name if available, otherwise fall back to pubkey @@ -2263,11 +2559,11 @@ class FloatingTab { display = this.options.appearance.iconOnly ? userName.slice(0, 8) : userName; - } else if (this.userInfo?.pubkey) { + } else if (authState?.pubkey) { // Fallback to pubkey display display = this.options.appearance.iconOnly - ? this.userInfo.pubkey.slice(0, 6) - : `${this.userInfo.pubkey.slice(0, 6)}...`; + ? authState.pubkey.slice(0, 6) + : `${authState.pubkey.slice(0, 6)}...`; } else { display = this.options.appearance.iconOnly ? 'User' : 'Authenticated'; } @@ -2450,10 +2746,11 @@ class FloatingTab { // Get current state getState() { + const authState = this._getAuthState(); return { isVisible: this.isVisible, - isAuthenticated: this.isAuthenticated, - userInfo: this.userInfo, + isAuthenticated: !!authState, + userInfo: authState, options: this.options }; } @@ -2527,6 +2824,8 @@ class NostrLite { this.options = { theme: 'default', + persistence: true, // Enable persistent authentication by default + isolateSession: false, // Use localStorage by default for cross-window persistence methods: { extension: true, local: true, @@ -2563,7 +2862,9 @@ class NostrLite { this.switchTheme(this.options.theme); // Always set up window.nostr facade to handle multiple extensions properly - this._setupWindowNostrFacade(); + console.log('🔍 NOSTR_LOGIN_LITE: Setting up facade before other initialization...'); + await this._setupWindowNostrFacade(); + console.log('🔍 NOSTR_LOGIN_LITE: Facade setup complete, continuing initialization...'); // Create modal during init (matching original git architecture) this.modal = new Modal(this.options); @@ -2575,101 +2876,74 @@ class NostrLite { console.log('NOSTR_LOGIN_LITE: Floating tab initialized'); } + // Attempt to restore authentication state if persistence is enabled (AFTER facade is ready) + if (this.options.persistence) { + console.log('🔍 NOSTR_LOGIN_LITE: Persistence enabled, attempting auth restoration...'); + await this._attemptAuthRestore(); + } else { + console.log('🔍 NOSTR_LOGIN_LITE: Persistence disabled in options'); + } + this.initialized = true; console.log('NOSTR_LOGIN_LITE: Initialization complete'); return this; } - _setupWindowNostrFacade() { + async _setupWindowNostrFacade() { if (typeof window !== 'undefined') { - console.log('NOSTR_LOGIN_LITE: === TRUE SINGLE-EXTENSION ARCHITECTURE ==='); - console.log('NOSTR_LOGIN_LITE: Initial window.nostr:', window.nostr); - console.log('NOSTR_LOGIN_LITE: Initial window.nostr constructor:', window.nostr?.constructor?.name); + console.log('🔍 NOSTR_LOGIN_LITE: === EXTENSION-FIRST FACADE SETUP ==='); + console.log('🔍 NOSTR_LOGIN_LITE: Current window.nostr:', window.nostr); + console.log('🔍 NOSTR_LOGIN_LITE: Constructor:', window.nostr?.constructor?.name); - // Store existing window.nostr if it exists (from extensions) - const existingNostr = window.nostr; - - // TRUE SINGLE-EXTENSION ARCHITECTURE: Don't install facade when extensions detected - if (this._isRealExtension(existingNostr)) { - console.log('NOSTR_LOGIN_LITE: ✓ REAL EXTENSION DETECTED IMMEDIATELY - PRESERVING WITHOUT FACADE'); - console.log('NOSTR_LOGIN_LITE: Extension constructor:', existingNostr.constructor?.name); - console.log('NOSTR_LOGIN_LITE: Extension keys:', Object.keys(existingNostr)); - console.log('NOSTR_LOGIN_LITE: Leaving window.nostr untouched for extension compatibility'); - this.preservedExtension = existingNostr; - this.facadeInstalled = false; - // DON'T install facade - leave window.nostr as the extension - return; + // EXTENSION-FIRST ARCHITECTURE: Never interfere with real extensions + if (this._isRealExtension(window.nostr)) { + console.log('🔍 NOSTR_LOGIN_LITE: ✅ REAL EXTENSION DETECTED - WILL NOT INSTALL FACADE'); + console.log('🔍 NOSTR_LOGIN_LITE: Extension constructor:', window.nostr.constructor?.name); + console.log('🔍 NOSTR_LOGIN_LITE: Extensions will handle window.nostr directly'); + + // Store reference for persistence verification + this.detectedExtension = window.nostr; + this.hasExtension = true; + this.facadeInstalled = false; // We deliberately don't install facade for extensions + + console.log('🔍 NOSTR_LOGIN_LITE: Extension mode - no facade interference'); + return; // Don't install facade at all for extensions } - // DEFERRED EXTENSION DETECTION: Extensions like nos2x may load after us - console.log('NOSTR_LOGIN_LITE: No real extension detected initially, starting deferred detection...'); - this.facadeInstalled = false; + // NO EXTENSION: Install facade for local/NIP-46/readonly methods + console.log('🔍 NOSTR_LOGIN_LITE: ❌ No real extension detected'); + console.log('🔍 NOSTR_LOGIN_LITE: Installing facade for non-extension authentication'); - let checkCount = 0; - const maxChecks = 10; // Check for up to 2 seconds - const checkInterval = setInterval(() => { - checkCount++; - const currentNostr = window.nostr; - - console.log('NOSTR_LOGIN_LITE: === DEFERRED CHECK ' + checkCount + '/' + maxChecks + ' ==='); - console.log('NOSTR_LOGIN_LITE: Current window.nostr:', currentNostr); - console.log('NOSTR_LOGIN_LITE: Constructor:', currentNostr?.constructor?.name); - - // Skip if it's our facade - if (currentNostr?.constructor?.name === 'WindowNostr') { - console.log('NOSTR_LOGIN_LITE: Skipping - this is our facade'); - return; - } - - if (this._isRealExtension(currentNostr)) { - console.log('NOSTR_LOGIN_LITE: ✓✓✓ LATE EXTENSION DETECTED - PRESERVING WITHOUT FACADE ✓✓✓'); - console.log('NOSTR_LOGIN_LITE: Extension detected after ' + (checkCount * 200) + 'ms!'); - console.log('NOSTR_LOGIN_LITE: Extension constructor:', currentNostr.constructor?.name); - console.log('NOSTR_LOGIN_LITE: Extension keys:', Object.keys(currentNostr)); - console.log('NOSTR_LOGIN_LITE: Leaving window.nostr untouched for extension compatibility'); - this.preservedExtension = currentNostr; - this.facadeInstalled = false; - clearInterval(checkInterval); - // DON'T install facade - leave window.nostr as the extension - return; - } - - // Stop checking after max attempts - no extension found - if (checkCount >= maxChecks) { - console.log('NOSTR_LOGIN_LITE: ⚠️ MAX CHECKS REACHED - NO EXTENSION FOUND'); - clearInterval(checkInterval); - console.log('NOSTR_LOGIN_LITE: Installing facade for local/NIP-46/readonly methods'); - this._installFacade(); - } - }, 200); // Check every 200ms + this.hasExtension = false; + this._installFacade(window.nostr); // Install facade with any existing nostr object - console.log('NOSTR_LOGIN_LITE: Waiting for deferred detection to complete...'); + console.log('🔍 NOSTR_LOGIN_LITE: ✅ Facade installed for local/NIP-46/readonly methods'); } } _installFacade(existingNostr = null) { if (typeof window !== 'undefined' && !this.facadeInstalled) { - console.log('NOSTR_LOGIN_LITE: === _installFacade CALLED ==='); - console.log('NOSTR_LOGIN_LITE: existingNostr parameter:', existingNostr); - console.log('NOSTR_LOGIN_LITE: existingNostr constructor:', existingNostr?.constructor?.name); - console.log('NOSTR_LOGIN_LITE: window.nostr before installation:', window.nostr); - console.log('NOSTR_LOGIN_LITE: window.nostr constructor before:', window.nostr?.constructor?.name); + console.log('🔍 NOSTR_LOGIN_LITE: === _installFacade CALLED ==='); + console.log('🔍 NOSTR_LOGIN_LITE: existingNostr parameter:', existingNostr); + console.log('🔍 NOSTR_LOGIN_LITE: existingNostr constructor:', existingNostr?.constructor?.name); + console.log('🔍 NOSTR_LOGIN_LITE: window.nostr before installation:', window.nostr); + console.log('🔍 NOSTR_LOGIN_LITE: window.nostr constructor before:', window.nostr?.constructor?.name); - const facade = new WindowNostr(this, existingNostr); + const facade = new WindowNostr(this, existingNostr, { isolateSession: this.options.isolateSession }); window.nostr = facade; this.facadeInstalled = true; - console.log('NOSTR_LOGIN_LITE: === FACADE INSTALLED WITH EXTENSION ==='); - console.log('NOSTR_LOGIN_LITE: window.nostr after installation:', window.nostr); - console.log('NOSTR_LOGIN_LITE: window.nostr constructor after:', window.nostr.constructor?.name); - console.log('NOSTR_LOGIN_LITE: facade.existingNostr:', window.nostr.existingNostr); + console.log('🔍 NOSTR_LOGIN_LITE: === FACADE INSTALLED FOR PERSISTENCE ==='); + console.log('🔍 NOSTR_LOGIN_LITE: window.nostr after installation:', window.nostr); + console.log('🔍 NOSTR_LOGIN_LITE: window.nostr constructor after:', window.nostr.constructor?.name); + console.log('🔍 NOSTR_LOGIN_LITE: facade.existingNostr:', window.nostr.existingNostr); } } - // Helper method to identify real browser extensions + // Conservative method to identify real browser extensions _isRealExtension(obj) { - console.log('NOSTR_LOGIN_LITE: === _isRealExtension DEBUG ==='); + console.log('NOSTR_LOGIN_LITE: === _isRealExtension (Conservative) ==='); console.log('NOSTR_LOGIN_LITE: obj:', obj); console.log('NOSTR_LOGIN_LITE: typeof obj:', typeof obj); @@ -2678,13 +2952,9 @@ class NostrLite { return false; } - console.log('NOSTR_LOGIN_LITE: Object keys:', Object.keys(obj)); - console.log('NOSTR_LOGIN_LITE: getPublicKey type:', typeof obj.getPublicKey); - console.log('NOSTR_LOGIN_LITE: signEvent type:', typeof obj.signEvent); - // Must have required Nostr methods if (typeof obj.getPublicKey !== 'function' || typeof obj.signEvent !== 'function') { - console.log('NOSTR_LOGIN_LITE: ✗ Missing required methods'); + console.log('NOSTR_LOGIN_LITE: ✗ Missing required NIP-07 methods'); return false; } @@ -2693,37 +2963,37 @@ class NostrLite { console.log('NOSTR_LOGIN_LITE: Constructor name:', constructorName); if (constructorName === 'WindowNostr' || constructorName === 'NostrLite') { - console.log('NOSTR_LOGIN_LITE: ✗ Is our library class'); + console.log('NOSTR_LOGIN_LITE: ✗ Is our library class - NOT an extension'); return false; } // Exclude NostrTools library object if (obj === window.NostrTools) { - console.log('NOSTR_LOGIN_LITE: ✗ Is NostrTools object'); + console.log('NOSTR_LOGIN_LITE: ✗ Is NostrTools object - NOT an extension'); return false; } - // Real extensions typically have internal properties or specific characteristics - console.log('NOSTR_LOGIN_LITE: Extension property check:'); - console.log(' _isEnabled:', !!obj._isEnabled); - console.log(' enabled:', !!obj.enabled); - console.log(' kind:', !!obj.kind); - console.log(' _eventEmitter:', !!obj._eventEmitter); - console.log(' _scope:', !!obj._scope); - console.log(' _requests:', !!obj._requests); - console.log(' _pubkey:', !!obj._pubkey); - console.log(' name:', !!obj.name); - console.log(' version:', !!obj.version); - console.log(' description:', !!obj.description); + // Conservative check: Look for common extension characteristics + // Real extensions usually have some of these internal properties + const extensionIndicators = [ + '_isEnabled', 'enabled', 'kind', '_eventEmitter', '_scope', + '_requests', '_pubkey', 'name', 'version', 'description' + ]; - const hasExtensionProps = !!( - obj._isEnabled || obj.enabled || obj.kind || - obj._eventEmitter || obj._scope || obj._requests || obj._pubkey || - obj.name || obj.version || obj.description - ); + const hasIndicators = extensionIndicators.some(prop => obj.hasOwnProperty(prop)); + + // Additional check: Extensions often have specific constructor patterns + const hasExtensionConstructor = constructorName && + constructorName !== 'Object' && + constructorName !== 'Function'; - console.log('NOSTR_LOGIN_LITE: Extension detection result for', constructorName, ':', hasExtensionProps); - return hasExtensionProps; + const isExtension = hasIndicators || hasExtensionConstructor; + + console.log('NOSTR_LOGIN_LITE: Extension indicators found:', hasIndicators); + console.log('NOSTR_LOGIN_LITE: Has extension constructor:', hasExtensionConstructor); + console.log('NOSTR_LOGIN_LITE: Final result for', constructorName, ':', isExtension); + + return isExtension; } launch(startScreen = 'login') { @@ -2736,15 +3006,153 @@ class NostrLite { } } + // Attempt to restore authentication state + async _attemptAuthRestore() { + try { + console.log('🔍 NOSTR_LOGIN_LITE: === _attemptAuthRestore START ==='); + console.log('🔍 NOSTR_LOGIN_LITE: hasExtension:', this.hasExtension); + console.log('🔍 NOSTR_LOGIN_LITE: facadeInstalled:', this.facadeInstalled); + console.log('🔍 NOSTR_LOGIN_LITE: window.nostr:', window.nostr?.constructor?.name); + + if (this.hasExtension) { + // EXTENSION MODE: Use custom extension persistence logic + console.log('🔍 NOSTR_LOGIN_LITE: Extension mode - using extension-specific restore'); + const restoredAuth = await this._attemptExtensionRestore(); + + if (restoredAuth) { + console.log('🔍 NOSTR_LOGIN_LITE: ✅ Extension auth restored successfully!'); + return restoredAuth; + } else { + console.log('🔍 NOSTR_LOGIN_LITE: ❌ Extension auth could not be restored'); + return null; + } + } else if (this.facadeInstalled && window.nostr?.restoreAuthState) { + // NON-EXTENSION MODE: Use facade persistence logic + console.log('🔍 NOSTR_LOGIN_LITE: Non-extension mode - using facade restore'); + const restoredAuth = await window.nostr.restoreAuthState(); + + if (restoredAuth) { + console.log('🔍 NOSTR_LOGIN_LITE: ✅ Facade auth restored successfully!'); + console.log('🔍 NOSTR_LOGIN_LITE: Method:', restoredAuth.method); + console.log('🔍 NOSTR_LOGIN_LITE: Pubkey:', restoredAuth.pubkey); + + // Handle NIP-46 reconnection requirement + if (restoredAuth.requiresReconnection) { + console.log('🔍 NOSTR_LOGIN_LITE: NIP-46 connection requires user reconnection'); + this._showReconnectionPrompt(restoredAuth); + } + + return restoredAuth; + } else { + console.log('🔍 NOSTR_LOGIN_LITE: ❌ Facade auth could not be restored'); + return null; + } + } else { + console.log('🔍 NOSTR_LOGIN_LITE: ❌ No restoration method available'); + console.log('🔍 NOSTR_LOGIN_LITE: hasExtension:', this.hasExtension); + console.log('🔍 NOSTR_LOGIN_LITE: facadeInstalled:', this.facadeInstalled); + console.log('🔍 NOSTR_LOGIN_LITE: window.nostr.restoreAuthState:', typeof window.nostr?.restoreAuthState); + return null; + } + + } catch (error) { + console.error('🔍 NOSTR_LOGIN_LITE: Auth restoration failed with error:', error); + console.error('🔍 NOSTR_LOGIN_LITE: Error stack:', error.stack); + return null; + } + } + + // Extension-specific authentication restoration + async _attemptExtensionRestore() { + try { + console.log('🔍 NOSTR_LOGIN_LITE: === _attemptExtensionRestore START ==='); + + // Use a simple AuthManager instance for extension persistence + const authManager = new AuthManager({ isolateSession: this.options?.isolateSession }); + const storedAuth = await authManager.restoreAuthState(); + + if (!storedAuth || storedAuth.method !== 'extension') { + console.log('🔍 NOSTR_LOGIN_LITE: No extension auth state stored'); + return null; + } + + // Verify the extension is still available and working + if (!window.nostr || !this._isRealExtension(window.nostr)) { + console.log('🔍 NOSTR_LOGIN_LITE: Extension no longer available'); + authManager.clearAuthState(); // Clear invalid state + return null; + } + + try { + // Test that the extension still works with the same pubkey + const currentPubkey = await window.nostr.getPublicKey(); + if (currentPubkey !== storedAuth.pubkey) { + console.log('🔍 NOSTR_LOGIN_LITE: Extension pubkey changed, clearing state'); + authManager.clearAuthState(); + return null; + } + + console.log('🔍 NOSTR_LOGIN_LITE: ✅ Extension auth verification successful'); + + // Create extension auth data for UI restoration + const extensionAuth = { + method: 'extension', + pubkey: storedAuth.pubkey, + extension: window.nostr + }; + + // Dispatch restoration event so UI can update + if (typeof window !== 'undefined') { + console.log('🔍 NOSTR_LOGIN_LITE: Dispatching nlAuthRestored event for extension'); + window.dispatchEvent(new CustomEvent('nlAuthRestored', { + detail: extensionAuth + })); + } + + return extensionAuth; + + } catch (error) { + console.log('🔍 NOSTR_LOGIN_LITE: Extension verification failed:', error); + authManager.clearAuthState(); // Clear invalid state + return null; + } + + } catch (error) { + console.error('🔍 NOSTR_LOGIN_LITE: Extension restore failed:', error); + return null; + } + } + + // Show prompt for NIP-46 reconnection + _showReconnectionPrompt(authData) { + console.log('NOSTR_LOGIN_LITE: Showing reconnection prompt for NIP-46'); + + // Dispatch event that UI can listen to + if (typeof window !== 'undefined') { + window.dispatchEvent(new CustomEvent('nlReconnectionRequired', { + detail: { + method: authData.method, + pubkey: authData.pubkey, + connectionData: authData.connectionData, + message: 'Your NIP-46 session has expired. Please reconnect to continue.' + } + })); + } + } + logout() { console.log('NOSTR_LOGIN_LITE: Logout called'); - // Clear stored data + // Clear legacy stored data if (typeof localStorage !== 'undefined') { localStorage.removeItem('nl_current'); } - // Dispatch logout event + // Clear current authentication state directly from storage + // This works for ALL methods including extensions (fixes the bug) + clearAuthState(); + + // Dispatch logout event for UI updates if (typeof window !== 'undefined') { window.dispatchEvent(new CustomEvent('nlLogout', { detail: { timestamp: Date.now() } @@ -2836,6 +3244,524 @@ class NostrLite { } } +// ====================================== +// Authentication Manager for Persistent Login +// ====================================== + +// Encryption utilities for secure local storage +class CryptoUtils { + static async generateKey() { + if (!window.crypto?.subtle) { + throw new Error('Web Crypto API not available'); + } + + return await window.crypto.subtle.generateKey( + { + name: 'AES-GCM', + length: 256, + }, + true, + ['encrypt', 'decrypt'] + ); + } + + static async deriveKey(password, salt) { + if (!window.crypto?.subtle) { + throw new Error('Web Crypto API not available'); + } + + const encoder = new TextEncoder(); + const keyMaterial = await window.crypto.subtle.importKey( + 'raw', + encoder.encode(password), + { name: 'PBKDF2' }, + false, + ['deriveBits', 'deriveKey'] + ); + + return await window.crypto.subtle.deriveKey( + { + name: 'PBKDF2', + salt: salt, + iterations: 100000, + hash: 'SHA-256', + }, + keyMaterial, + { name: 'AES-GCM', length: 256 }, + true, + ['encrypt', 'decrypt'] + ); + } + + static async encrypt(data, key) { + if (!window.crypto?.subtle) { + throw new Error('Web Crypto API not available'); + } + + const encoder = new TextEncoder(); + const iv = window.crypto.getRandomValues(new Uint8Array(12)); + + const encrypted = await window.crypto.subtle.encrypt( + { + name: 'AES-GCM', + iv: iv, + }, + key, + encoder.encode(data) + ); + + return { + encrypted: new Uint8Array(encrypted), + iv: iv + }; + } + + static async decrypt(encryptedData, key, iv) { + if (!window.crypto?.subtle) { + throw new Error('Web Crypto API not available'); + } + + const decrypted = await window.crypto.subtle.decrypt( + { + name: 'AES-GCM', + iv: iv, + }, + key, + encryptedData + ); + + const decoder = new TextDecoder(); + return decoder.decode(decrypted); + } + + static arrayBufferToBase64(buffer) { + const bytes = new Uint8Array(buffer); + let binary = ''; + for (let i = 0; i < bytes.byteLength; i++) { + binary += String.fromCharCode(bytes[i]); + } + return window.btoa(binary); + } + + static base64ToArrayBuffer(base64) { + const binary = window.atob(base64); + const bytes = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i++) { + bytes[i] = binary.charCodeAt(i); + } + return bytes.buffer; + } +} + +// Unified authentication state manager +class AuthManager { + constructor(options = {}) { + this.storageKey = 'nostr_login_lite_auth'; + this.currentAuthState = null; + + // Configure storage type based on isolateSession option + if (options.isolateSession) { + this.storage = sessionStorage; + console.log('AuthManager: Using sessionStorage for per-window isolation'); + } else { + this.storage = localStorage; + console.log('AuthManager: Using localStorage for cross-window persistence'); + } + } + + // Save authentication state with method-specific security + async saveAuthState(authData) { + try { + const authState = { + method: authData.method, + timestamp: Date.now(), + pubkey: authData.pubkey + }; + + switch (authData.method) { + case 'extension': + // For extensions, only store verification data - no secrets + authState.extensionVerification = { + constructor: authData.extension?.constructor?.name, + hasGetPublicKey: typeof authData.extension?.getPublicKey === 'function', + hasSignEvent: typeof authData.extension?.signEvent === 'function' + }; + break; + + case 'local': + // For local keys, encrypt the secret key + if (authData.secret) { + const password = this._generateSessionPassword(); + const salt = window.crypto.getRandomValues(new Uint8Array(16)); + const key = await CryptoUtils.deriveKey(password, salt); + const encrypted = await CryptoUtils.encrypt(authData.secret, key); + + authState.encrypted = { + data: CryptoUtils.arrayBufferToBase64(encrypted.encrypted), + iv: CryptoUtils.arrayBufferToBase64(encrypted.iv), + salt: CryptoUtils.arrayBufferToBase64(salt) + }; + + // Store session password in sessionStorage (cleared on tab close) + sessionStorage.setItem('nostr_session_key', password); + } + break; + + case 'nip46': + // For NIP-46, store connection parameters (no secrets) + if (authData.signer) { + authState.nip46 = { + remotePubkey: authData.signer.remotePubkey, + relays: authData.signer.relays, + // Don't store secret - user will need to reconnect + }; + } + break; + + case 'readonly': + // Read-only mode has no secrets to store + break; + + default: + throw new Error(`Unknown auth method: ${authData.method}`); + } + + this.storage.setItem(this.storageKey, JSON.stringify(authState)); + this.currentAuthState = authState; + console.log('AuthManager: Auth state saved for method:', authData.method); + + } catch (error) { + console.error('AuthManager: Failed to save auth state:', error); + throw error; + } + } + + // Restore authentication state on page load + async restoreAuthState() { + try { + console.log('🔍 AuthManager: === restoreAuthState START ==='); + console.log('🔍 AuthManager: storageKey:', this.storageKey); + + const stored = this.storage.getItem(this.storageKey); + console.log('🔍 AuthManager: localStorage raw value:', stored); + + if (!stored) { + console.log('🔍 AuthManager: ❌ No stored auth state found'); + return null; + } + + const authState = JSON.parse(stored); + console.log('🔍 AuthManager: ✅ Parsed stored auth state:', authState); + console.log('🔍 AuthManager: Method:', authState.method); + console.log('🔍 AuthManager: Timestamp:', authState.timestamp); + console.log('🔍 AuthManager: Age (ms):', Date.now() - authState.timestamp); + + // Check if stored state is too old (24 hours for most methods, 1 hour for extensions) + const maxAge = authState.method === 'extension' ? 60 * 60 * 1000 : 24 * 60 * 60 * 1000; + console.log('🔍 AuthManager: Max age for method:', maxAge, 'ms'); + + if (Date.now() - authState.timestamp > maxAge) { + console.log('🔍 AuthManager: ❌ Stored auth state expired, clearing'); + this.clearAuthState(); + return null; + } + + console.log('🔍 AuthManager: ✅ Auth state not expired, attempting restore for method:', authState.method); + + let result; + switch (authState.method) { + case 'extension': + console.log('🔍 AuthManager: Calling _restoreExtensionAuth...'); + result = await this._restoreExtensionAuth(authState); + break; + + case 'local': + console.log('🔍 AuthManager: Calling _restoreLocalAuth...'); + result = await this._restoreLocalAuth(authState); + break; + + case 'nip46': + console.log('🔍 AuthManager: Calling _restoreNip46Auth...'); + result = await this._restoreNip46Auth(authState); + break; + + case 'readonly': + console.log('🔍 AuthManager: Calling _restoreReadonlyAuth...'); + result = await this._restoreReadonlyAuth(authState); + break; + + default: + console.warn('🔍 AuthManager: ❌ Unknown auth method in stored state:', authState.method); + return null; + } + + console.log('🔍 AuthManager: Restore method result:', result); + console.log('🔍 AuthManager: === restoreAuthState END ==='); + return result; + + } catch (error) { + console.error('🔍 AuthManager: ❌ Failed to restore auth state:', error); + console.error('🔍 AuthManager: Error stack:', error.stack); + this.clearAuthState(); // Clear corrupted state + return null; + } + } + + async _restoreExtensionAuth(authState) { + console.log('🔍 AuthManager: === _restoreExtensionAuth START ==='); + console.log('🔍 AuthManager: authState:', authState); + console.log('🔍 AuthManager: window.nostr available:', !!window.nostr); + console.log('🔍 AuthManager: window.nostr constructor:', window.nostr?.constructor?.name); + + // SMART EXTENSION WAITING SYSTEM + // Extensions often load after our library, so we need to wait for them + const extension = await this._waitForExtension(authState, 3000); // Wait up to 3 seconds + + if (!extension) { + console.log('🔍 AuthManager: ❌ No extension found after waiting'); + return null; + } + + console.log('🔍 AuthManager: ✅ Extension found:', extension.constructor?.name); + + try { + // Verify extension still works and has same pubkey + const currentPubkey = await extension.getPublicKey(); + if (currentPubkey !== authState.pubkey) { + console.log('🔍 AuthManager: ❌ Extension pubkey changed, not restoring'); + console.log('🔍 AuthManager: Expected:', authState.pubkey); + console.log('🔍 AuthManager: Got:', currentPubkey); + return null; + } + + console.log('🔍 AuthManager: ✅ Extension auth restored successfully'); + return { + method: 'extension', + pubkey: authState.pubkey, + extension: extension + }; + + } catch (error) { + console.log('🔍 AuthManager: ❌ Extension verification failed:', error); + return null; + } + } + + // Smart extension waiting system - polls multiple locations for extensions + async _waitForExtension(authState, maxWaitMs = 3000) { + console.log('🔍 AuthManager: === _waitForExtension START ==='); + console.log('🔍 AuthManager: maxWaitMs:', maxWaitMs); + console.log('🔍 AuthManager: Looking for extension with constructor:', authState.extensionVerification?.constructor); + + const startTime = Date.now(); + const pollInterval = 100; // Check every 100ms + + // Extension locations to check (in priority order) + const extensionLocations = [ + { path: 'window.nostr', getter: () => window.nostr }, + { path: 'navigator.nostr', getter: () => navigator?.nostr }, + { path: 'window.navigator?.nostr', getter: () => window.navigator?.nostr }, + { path: 'window.alby?.nostr', getter: () => window.alby?.nostr }, + { path: 'window.webln?.nostr', getter: () => window.webln?.nostr }, + { path: 'window.nos2x', getter: () => window.nos2x }, + { path: 'window.flamingo?.nostr', getter: () => window.flamingo?.nostr }, + { path: 'window.mutiny?.nostr', getter: () => window.mutiny?.nostr } + ]; + + while (Date.now() - startTime < maxWaitMs) { + console.log('🔍 AuthManager: Polling for extensions... (elapsed:', Date.now() - startTime, 'ms)'); + + // If our facade is currently installed and blocking, temporarily remove it + let facadeRemoved = false; + let originalNostr = null; + if (window.nostr?.constructor?.name === 'WindowNostr') { + console.log('🔍 AuthManager: Temporarily removing our facade to check for real extensions'); + originalNostr = window.nostr; + window.nostr = window.nostr.existingNostr || undefined; + facadeRemoved = true; + } + + try { + // Check all extension locations + for (const location of extensionLocations) { + try { + const extension = location.getter(); + console.log('🔍 AuthManager: Checking', location.path, ':', !!extension, extension?.constructor?.name); + + if (this._isValidExtensionForRestore(extension, authState)) { + console.log('🔍 AuthManager: ✅ Found matching extension at', location.path); + + // Restore facade if we removed it + if (facadeRemoved && originalNostr) { + console.log('🔍 AuthManager: Restoring facade after finding extension'); + window.nostr = originalNostr; + } + + return extension; + } + } catch (error) { + console.log('🔍 AuthManager: Error checking', location.path, ':', error.message); + } + } + + // Restore facade if we removed it and haven't found an extension yet + if (facadeRemoved && originalNostr) { + window.nostr = originalNostr; + facadeRemoved = false; + } + + } catch (error) { + console.error('🔍 AuthManager: Error during extension polling:', error); + + // Restore facade if we removed it + if (facadeRemoved && originalNostr) { + window.nostr = originalNostr; + } + } + + // Wait before next poll + await new Promise(resolve => setTimeout(resolve, pollInterval)); + } + + console.log('🔍 AuthManager: ❌ Extension waiting timeout after', maxWaitMs, 'ms'); + return null; + } + + // Check if an extension is valid for restoration + _isValidExtensionForRestore(extension, authState) { + if (!extension || typeof extension !== 'object') { + return false; + } + + // Must have required Nostr methods + if (typeof extension.getPublicKey !== 'function' || + typeof extension.signEvent !== 'function') { + return false; + } + + // Must not be our own classes + const constructorName = extension.constructor?.name; + if (constructorName === 'WindowNostr' || constructorName === 'NostrLite') { + return false; + } + + // Must not be NostrTools + if (extension === window.NostrTools) { + return false; + } + + // If we have stored verification data, check constructor match + const verification = authState.extensionVerification; + if (verification && verification.constructor) { + if (constructorName !== verification.constructor) { + console.log('🔍 AuthManager: Constructor mismatch -', + 'expected:', verification.constructor, + 'got:', constructorName); + return false; + } + } + + console.log('🔍 AuthManager: ✅ Extension validation passed for:', constructorName); + return true; + } + + async _restoreLocalAuth(authState) { + if (!authState.encrypted) { + console.log('AuthManager: No encrypted data found for local auth'); + return null; + } + + // Get session password + const sessionPassword = sessionStorage.getItem('nostr_session_key'); + if (!sessionPassword) { + console.log('AuthManager: Session password not found, cannot decrypt'); + return null; + } + + try { + // Decrypt the secret key + const salt = CryptoUtils.base64ToArrayBuffer(authState.encrypted.salt); + const key = await CryptoUtils.deriveKey(sessionPassword, new Uint8Array(salt)); + + const encryptedData = CryptoUtils.base64ToArrayBuffer(authState.encrypted.data); + const iv = CryptoUtils.base64ToArrayBuffer(authState.encrypted.iv); + + const secret = await CryptoUtils.decrypt(encryptedData, key, new Uint8Array(iv)); + + console.log('AuthManager: Local auth restored successfully'); + return { + method: 'local', + pubkey: authState.pubkey, + secret: secret + }; + + } catch (error) { + console.error('AuthManager: Failed to decrypt local key:', error); + return null; + } + } + + async _restoreNip46Auth(authState) { + if (!authState.nip46) { + console.log('AuthManager: No NIP-46 data found'); + return null; + } + + // For NIP-46, we can't automatically restore the connection + // because it requires the user to re-authenticate with the remote signer + // Instead, we return the connection parameters so the UI can prompt for reconnection + console.log('AuthManager: NIP-46 connection data found, requires user reconnection'); + return { + method: 'nip46', + pubkey: authState.pubkey, + requiresReconnection: true, + connectionData: authState.nip46 + }; + } + + async _restoreReadonlyAuth(authState) { + console.log('AuthManager: Read-only auth restored successfully'); + return { + method: 'readonly', + pubkey: authState.pubkey + }; + } + + // Clear stored authentication state + clearAuthState() { + this.storage.removeItem(this.storageKey); + sessionStorage.removeItem('nostr_session_key'); + this.currentAuthState = null; + console.log('AuthManager: Auth state cleared'); + } + + // Generate a session-specific password for local key encryption + _generateSessionPassword() { + const array = new Uint8Array(32); + window.crypto.getRandomValues(array); + return Array.from(array, byte => byte.toString(16).padStart(2, '0')).join(''); + } + + // Check if we have valid stored auth + hasStoredAuth() { + const stored = this.storage.getItem(this.storageKey); + return !!stored; + } + + // Get current auth method without full restoration + getStoredAuthMethod() { + try { + const stored = this.storage.getItem(this.storageKey); + if (!stored) return null; + + const authState = JSON.parse(stored); + return authState.method; + } catch { + return null; + } + } +} + // NIP-07 compliant window.nostr provider class WindowNostr { constructor(nostrLite, existingNostr = null) { @@ -2843,13 +3769,14 @@ class WindowNostr { this.authState = null; this.existingNostr = existingNostr; this.authenticatedExtension = null; + this.authManager = new AuthManager({ isolateSession: nostrLite.options?.isolateSession }); this._setupEventListeners(); } _setupEventListeners() { // Listen for authentication events to store auth state if (typeof window !== 'undefined') { - window.addEventListener('nlMethodSelected', (event) => { + window.addEventListener('nlMethodSelected', async (event) => { this.authState = event.detail; // If extension method, capture the specific extension the user chose @@ -2858,11 +3785,21 @@ class WindowNostr { console.log('WindowNostr: Captured authenticated extension:', this.authenticatedExtension?.constructor?.name); } - // CRITICAL FIX: Re-install our facade for ALL authentication methods - // Extensions may overwrite window.nostr after ANY authentication, not just extension auth - if (typeof window !== 'undefined') { + // Use global setAuthState function for unified persistence + try { + setAuthState(event.detail, { isolateSession: this.nostrLite.options?.isolateSession }); + console.log('WindowNostr: Auth state saved via global setAuthState'); + } catch (error) { + console.error('WindowNostr: Failed to save auth state via global setAuthState:', error); + } + + // EXTENSION-FIRST: Only reinstall facade for non-extension methods + // Extensions handle their own window.nostr - don't interfere! + if (event.detail.method !== 'extension' && typeof window !== 'undefined') { console.log('WindowNostr: Re-installing facade after', this.authState?.method, 'authentication'); window.nostr = this; + } else if (event.detail.method === 'extension') { + console.log('WindowNostr: Extension authentication - NOT reinstalling facade'); } console.log('WindowNostr: Auth state updated:', this.authState?.method); @@ -2871,17 +3808,73 @@ class WindowNostr { window.addEventListener('nlLogout', () => { this.authState = null; this.authenticatedExtension = null; - console.log('WindowNostr: Auth state cleared'); - // Re-install facade after logout to ensure we maintain control - if (typeof window !== 'undefined') { - console.log('WindowNostr: Re-installing facade after logout'); + // Clear persistent auth state + this.authManager.clearAuthState(); + console.log('WindowNostr: Auth state cleared and persistence removed'); + + // EXTENSION-FIRST: Only reinstall facade if we're not in extension mode + if (typeof window !== 'undefined' && !this.nostrLite?.hasExtension) { + console.log('WindowNostr: Re-installing facade after logout (non-extension mode)'); window.nostr = this; + } else { + console.log('WindowNostr: Logout in extension mode - NOT reinstalling facade'); } }); } } + // Restore authentication state on page load + async restoreAuthState() { + try { + console.log('🔍 WindowNostr: === restoreAuthState START ==='); + console.log('🔍 WindowNostr: authManager available:', !!this.authManager); + + const restoredAuth = await this.authManager.restoreAuthState(); + console.log('🔍 WindowNostr: authManager.restoreAuthState result:', restoredAuth); + + if (restoredAuth) { + console.log('🔍 WindowNostr: ✅ Setting authState to restored auth'); + this.authState = restoredAuth; + console.log('🔍 WindowNostr: this.authState now:', this.authState); + + // Handle method-specific restoration + if (restoredAuth.method === 'extension') { + console.log('🔍 WindowNostr: Extension method - setting authenticatedExtension'); + this.authenticatedExtension = restoredAuth.extension; + console.log('🔍 WindowNostr: authenticatedExtension set to:', this.authenticatedExtension); + } + + console.log('🔍 WindowNostr: ✅ Authentication state restored successfully!'); + console.log('🔍 WindowNostr: Method:', restoredAuth.method); + console.log('🔍 WindowNostr: Pubkey:', restoredAuth.pubkey); + + // Dispatch restoration event so UI can update + if (typeof window !== 'undefined') { + console.log('🔍 WindowNostr: Dispatching nlAuthRestored event...'); + const event = new CustomEvent('nlAuthRestored', { + detail: restoredAuth + }); + console.log('🔍 WindowNostr: Event detail:', event.detail); + window.dispatchEvent(event); + console.log('🔍 WindowNostr: ✅ nlAuthRestored event dispatched'); + } + + console.log('🔍 WindowNostr: === restoreAuthState END (success) ==='); + return restoredAuth; + } else { + console.log('🔍 WindowNostr: ❌ No authentication state to restore (null from authManager)'); + console.log('🔍 WindowNostr: === restoreAuthState END (no restore) ==='); + return null; + } + } catch (error) { + console.error('🔍 WindowNostr: ❌ Failed to restore auth state:', error); + console.error('🔍 WindowNostr: Error stack:', error.stack); + console.log('🔍 WindowNostr: === restoreAuthState END (error) ==='); + return null; + } + } + async getPublicKey() { if (!this.authState) { throw new Error('Not authenticated - use NOSTR_LOGIN_LITE.launch()'); @@ -3054,17 +4047,18 @@ class WindowNostr { get nip44() { return { encrypt: async (pubkey, plaintext) => { - if (!this.authState) { + const authState = getAuthState(); + if (!authState) { throw new Error('Not authenticated - use NOSTR_LOGIN_LITE.launch()'); } - if (this.authState.method === 'readonly') { + if (authState.method === 'readonly') { throw new Error('Read-only mode - cannot encrypt'); } - switch (this.authState.method) { + switch (authState.method) { case 'extension': { - const ext = this.authenticatedExtension || this.authState.extension || this.existingNostr; + const ext = this.authenticatedExtension || authState.extension || this.existingNostr; if (!ext) throw new Error('Extension not available'); return await ext.nip44.encrypt(pubkey, plaintext); } @@ -3073,40 +4067,41 @@ class WindowNostr { const { nip44, nip19 } = window.NostrTools; let secretKey; - if (this.authState.secret.startsWith('nsec')) { - const decoded = nip19.decode(this.authState.secret); + if (authState.secret.startsWith('nsec')) { + const decoded = nip19.decode(authState.secret); secretKey = decoded.data; } else { - secretKey = this._hexToUint8Array(this.authState.secret); + secretKey = this._hexToUint8Array(authState.secret); } return nip44.encrypt(plaintext, nip44.getConversationKey(secretKey, pubkey)); } case 'nip46': { - if (!this.authState.signer?.bunkerSigner) { + if (!authState.signer?.bunkerSigner) { throw new Error('NIP-46 signer not available'); } - return await this.authState.signer.bunkerSigner.nip44Encrypt(pubkey, plaintext); + return await authState.signer.bunkerSigner.nip44Encrypt(pubkey, plaintext); } default: - throw new Error(`Unsupported auth method: ${this.authState.method}`); + throw new Error('Unsupported auth method: ' + authState.method); } }, decrypt: async (pubkey, ciphertext) => { - if (!this.authState) { + const authState = getAuthState(); + if (!authState) { throw new Error('Not authenticated - use NOSTR_LOGIN_LITE.launch()'); } - if (this.authState.method === 'readonly') { + if (authState.method === 'readonly') { throw new Error('Read-only mode - cannot decrypt'); } - switch (this.authState.method) { + switch (authState.method) { case 'extension': { - const ext = this.authenticatedExtension || this.authState.extension || this.existingNostr; + const ext = this.authenticatedExtension || authState.extension || this.existingNostr; if (!ext) throw new Error('Extension not available'); return await ext.nip44.decrypt(pubkey, ciphertext); } @@ -3115,25 +4110,25 @@ class WindowNostr { const { nip44, nip19 } = window.NostrTools; let secretKey; - if (this.authState.secret.startsWith('nsec')) { - const decoded = nip19.decode(this.authState.secret); + if (authState.secret.startsWith('nsec')) { + const decoded = nip19.decode(authState.secret); secretKey = decoded.data; } else { - secretKey = this._hexToUint8Array(this.authState.secret); + secretKey = this._hexToUint8Array(authState.secret); } return nip44.decrypt(ciphertext, nip44.getConversationKey(secretKey, pubkey)); } case 'nip46': { - if (!this.authState.signer?.bunkerSigner) { + if (!authState.signer?.bunkerSigner) { throw new Error('NIP-46 signer not available'); } - return await this.authState.signer.bunkerSigner.nip44Decrypt(pubkey, ciphertext); + return await authState.signer.bunkerSigner.nip44Decrypt(pubkey, ciphertext); } default: - throw new Error(`Unsupported auth method: ${this.authState.method}`); + throw new Error('Unsupported auth method: ' + authState.method); } } }; @@ -3151,6 +4146,171 @@ class WindowNostr { } } +// ====================================== +// Global Authentication State Manager - Single Source of Truth +// ====================================== + +// Storage-based authentication state - works regardless of extension presence +function getAuthState() { + try { + console.log('🔍 getAuthState: === GLOBAL AUTH STATE CHECK ==='); + + const storageKey = 'nostr_login_lite_auth'; + let stored = null; + let storageType = null; + + // Check sessionStorage first (per-window isolation), then localStorage + if (sessionStorage.getItem(storageKey)) { + stored = sessionStorage.getItem(storageKey); + storageType = 'sessionStorage'; + console.log('🔍 getAuthState: Found auth in sessionStorage'); + } else if (localStorage.getItem(storageKey)) { + stored = localStorage.getItem(storageKey); + storageType = 'localStorage'; + console.log('🔍 getAuthState: Found auth in localStorage'); + } + + if (!stored) { + console.log('🔍 getAuthState: ❌ No stored auth state found'); + return null; + } + + const authState = JSON.parse(stored); + console.log('🔍 getAuthState: ✅ Parsed stored auth state from', storageType); + console.log('🔍 getAuthState: Method:', authState.method); + console.log('🔍 getAuthState: Pubkey:', authState.pubkey); + console.log('🔍 getAuthState: Age (ms):', Date.now() - authState.timestamp); + + // Check if auth state is expired + const maxAge = authState.method === 'extension' ? 60 * 60 * 1000 : 24 * 60 * 60 * 1000; + if (Date.now() - authState.timestamp > maxAge) { + console.log('🔍 getAuthState: ❌ Auth state expired, clearing'); + sessionStorage.removeItem(storageKey); + localStorage.removeItem(storageKey); + return null; + } + + console.log('🔍 getAuthState: ✅ Valid auth state found'); + return authState; + + } catch (error) { + console.error('🔍 getAuthState: ❌ Error reading auth state:', error); + return null; + } +} + +// ====================================== +// Global Authentication State Management - Unified Persistence +// ====================================== + +// Global setAuthState function for unified persistence across all authentication methods +function setAuthState(authData, options = {}) { + try { + console.log('🔍 setAuthState: === GLOBAL AUTH STATE SAVE ==='); + console.log('🔍 setAuthState: authData:', authData); + console.log('🔍 setAuthState: options:', options); + + const storageKey = 'nostr_login_lite_auth'; + + // Determine which storage to use based on isolateSession option + const storage = options.isolateSession ? sessionStorage : localStorage; + const storageType = options.isolateSession ? 'sessionStorage' : 'localStorage'; + + console.log('🔍 setAuthState: Using', storageType, 'for persistence'); + + // Create auth state object + const authState = { + method: authData.method, + timestamp: Date.now(), + pubkey: authData.pubkey + }; + + // Add method-specific data (but no secrets for extension method) + switch (authData.method) { + case 'extension': + // For extensions, only store verification data - no secrets + authState.extensionVerification = { + constructor: authData.extension?.constructor?.name, + hasGetPublicKey: typeof authData.extension?.getPublicKey === 'function', + hasSignEvent: typeof authData.extension?.signEvent === 'function' + }; + console.log('🔍 setAuthState: Extension method - storing verification data only'); + break; + + case 'local': + // For local keys, store the secret (will be encrypted by AuthManager if needed) + if (authData.secret) { + authState.secret = authData.secret; + console.log('🔍 setAuthState: Local method - storing secret key'); + } + break; + + case 'nip46': + // For NIP-46, store connection parameters + if (authData.signer) { + authState.nip46 = { + remotePubkey: authData.signer.remotePubkey, + relays: authData.signer.relays, + // Don't store secret - user will need to reconnect + }; + console.log('🔍 setAuthState: NIP-46 method - storing connection parameters'); + } + break; + + case 'readonly': + // Read-only mode has no additional data to store + console.log('🔍 setAuthState: Read-only method - storing basic auth state'); + break; + + default: + console.warn('🔍 setAuthState: Unknown auth method:', authData.method); + break; + } + + // Store the auth state + storage.setItem(storageKey, JSON.stringify(authState)); + console.log('🔍 setAuthState: ✅ Auth state saved successfully'); + console.log('🔍 setAuthState: Final auth state:', authState); + + return authState; + + } catch (error) { + console.error('🔍 setAuthState: ❌ Error saving auth state:', error); + throw error; + } +} + +// ====================================== +// Global Authentication State Clearing +// ====================================== + +// Global clearAuthState function for unified auth state clearing +function clearAuthState() { + try { + console.log('🔍 clearAuthState: === GLOBAL AUTH STATE CLEAR ==='); + + const storageKey = 'nostr_login_lite_auth'; + + // Clear from both storage types to ensure complete cleanup + if (typeof sessionStorage !== 'undefined') { + sessionStorage.removeItem(storageKey); + sessionStorage.removeItem('nostr_session_key'); + console.log('🔍 clearAuthState: ✅ Cleared auth state from sessionStorage'); + } + + if (typeof localStorage !== 'undefined') { + localStorage.removeItem(storageKey); + console.log('🔍 clearAuthState: ✅ Cleared auth state from localStorage'); + } + + console.log('🔍 clearAuthState: ✅ All auth state cleared successfully'); + + } catch (error) { + console.error('🔍 clearAuthState: ❌ Error clearing auth state:', error); + } +} + + // Initialize and export if (typeof window !== 'undefined') { const nostrLite = new NostrLite(); @@ -3176,6 +4336,11 @@ if (typeof window !== 'undefined') { updateFloatingTab: (options) => nostrLite.updateFloatingTab(options), getFloatingTabState: () => nostrLite.getFloatingTabState(), + // GLOBAL AUTHENTICATION STATE API - Single Source of Truth + getAuthState: getAuthState, + setAuthState: setAuthState, + clearAuthState: clearAuthState, + // Expose for debugging _extensionBridge: nostrLite.extensionBridge, _instance: nostrLite diff --git a/web/superball-shared.css b/web/superball-shared.css new file mode 100644 index 0000000..f0417ee --- /dev/null +++ b/web/superball-shared.css @@ -0,0 +1,734 @@ +/* Superball Shared Styles */ + +/* Apply theme variables and classes */ +:root { + /* Core Variables (6) */ + --primary-color: #000000; + --secondary-color: #ffffff; + --accent-color: #ff0000; + --muted-color: #666666; + --font-family: "Courier New", Courier, monospace; + --border-radius: 15px; + --border-width: 3px; + + /* Floating Tab Variables (8) - from theme.css */ + --tab-bg-logged-out: #ffffff; + --tab-bg-logged-in: #ffffff; + --tab-bg-opacity-logged-out: 0.9; + --tab-bg-opacity-logged-in: 0.2; + --tab-color-logged-out: #000000; + --tab-color-logged-in: #ffffff; + --tab-border-logged-out: #000000; + --tab-border-logged-in: #ff0000; + --tab-border-opacity-logged-out: 1.0; + --tab-border-opacity-logged-in: 0.1; +} + +body { + font-family: var(--font-family); + margin: 0; + padding: 20px; + background: var(--secondary-color); + min-height: 100vh; + color: var(--primary-color); +} + +.section { + margin: 20px 0; + border: var(--border-width) solid var(--primary-color); + padding: 15px; + border-radius: var(--border-radius); + background: var(--secondary-color); +} + +.section h2 { + margin: 0 0 15px 0; + font-size: 18px; + font-family: var(--font-family); + color: var(--primary-color); +} + +input, +textarea, +select { + width: 100%; + padding: 8px; + margin: 5px 0; + border: var(--border-width) solid var(--primary-color); + border-radius: var(--border-radius); + font-family: var(--font-family); + background: var(--secondary-color); + color: var(--primary-color); + box-sizing: border-box; +} + +input:focus, +textarea:focus, +select:focus { + border-color: var(--accent-color); + outline: none; +} + +button { + background: var(--secondary-color); + color: var(--primary-color); + border: var(--border-width) solid var(--primary-color); + border-radius: var(--border-radius); + font-family: var(--font-family); + cursor: pointer; + width: auto; + padding: 10px 20px; + margin: 5px 0; + transition: all 0.2s ease; +} + +button:hover { + border-color: var(--accent-color); +} + +button:active { + background: var(--accent-color); + color: var(--secondary-color); +} + +#main-content button { + background: var(--secondary-color); + color: var(--primary-color); + border: var(--border-width) solid var(--primary-color); + border-radius: var(--border-radius); + font-family: var(--font-family); + cursor: pointer; + width: auto; + padding: 10px 20px; + margin: 5px 0; + transition: border-color 0.2s ease; + height: 40px; + box-sizing: border-box; +} + +#main-content button:hover { + border-color: var(--accent-color); +} + +#main-content button:active { + background: var(--accent-color); + color: var(--secondary-color); +} + +#main-content .button-primary { + background: var(--secondary-color); + color: var(--primary-color); + border-color: var(--primary-color); +} + +#main-content .button-primary:hover { + border-color: var(--accent-color); +} + +#main-content .button-danger { + background: var(--accent-color); + color: var(--secondary-color); + border-color: var(--accent-color); +} + +#main-content .button-danger:hover { + border-color: var(--primary-color); +} + +.input-group { + margin: 10px 0; +} + +label { + display: block; + font-weight: bold; + margin-bottom: 3px; + font-family: var(--font-family); + color: var(--primary-color); +} + +.relay-item { + display: flex; + align-items: center; + gap: 10px; + margin: 5px 0; + padding: 8px; + border: var(--border-width) solid var(--primary-color); + border-radius: var(--border-radius); + background: var(--secondary-color); +} + +.relay-url { + flex: 1; + font-family: var(--font-family); + font-size: 12px; + word-break: break-all; + color: var(--primary-color); +} + +.relay-type { + min-width: 80px; + font-size: 12px; + color: var(--muted-color); + text-align: center; + font-family: var(--font-family); +} + +.relay-auth-status { + min-width: 100px; + font-size: 11px; + text-align: center; + font-family: var(--font-family); + display: flex; + align-items: center; + justify-content: center; + gap: 4px; +} + +.auth-indicator { + width: 12px; + height: 12px; + border-radius: 50%; + display: inline-block; +} + +.auth-indicator.read-write { + background-color: #28a745; + /* Green - fully compatible */ +} + +.auth-indicator.read-only { + background-color: #ffc107; + /* Yellow - read only */ +} + +.auth-indicator.error { + background-color: #dc3545; + /* Red - connection error */ +} + +.auth-indicator.testing { + background-color: #6c757d; + /* Gray - testing in progress */ + animation: pulse 1.5s infinite; +} + +@keyframes pulse { + 0%, + 100% { + opacity: 1; + } + + 50% { + opacity: 0.5; + } +} + +.auth-status-text { + font-size: 10px; + font-weight: bold; +} + +.relay-actions { + display: flex; + gap: 5px; +} + +#main-content .relay-actions button { + padding: 4px 8px; + font-size: 12px; + width: auto; + height: 32px; +} + +.hidden { + display: none; +} + +.status-message { + padding: 10px; + margin: 10px 0; + border-radius: var(--border-radius); + border: var(--border-width) solid var(--primary-color); + font-family: var(--font-family); +} + +.success { + background: var(--secondary-color); + color: var(--primary-color); + border-color: var(--primary-color); +} + +.error { + background: var(--secondary-color); + color: var(--accent-color); + border-color: var(--accent-color); +} + +.info { + background: var(--secondary-color); + color: var(--primary-color); + border-color: var(--muted-color); +} + +.add-relay-form { + display: flex; + gap: 10px; + align-items: end; +} + +.add-relay-form input { + flex: 1; +} + +.add-relay-form select { + width: 100px; +} + +.add-relay-form button { + width: auto; + margin: 5px 0; +} + +.pubkey-display { + font-family: var(--font-family); + font-size: 12px; + word-break: break-all; + background: var(--secondary-color); + color: var(--muted-color); + padding: 5px; + border: var(--border-width) solid var(--muted-color); + border-radius: var(--border-radius); +} + +.action-buttons { + display: flex; + gap: 10px; + flex-wrap: wrap; +} + +.daemon-control { + margin: 15px 0; +} + +#main-content #daemon-toggle { + font-size: 16px; + padding: 10px 20px; + margin-bottom: 15px; + height: 40px; +} + +#main-content #daemon-toggle.running { + background: var(--accent-color); + color: var(--secondary-color); + border-color: var(--accent-color); +} + +#main-content #daemon-toggle.running:hover { + border-color: var(--primary-color); +} + +#daemon-status { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 10px; + background: var(--secondary-color); + border: var(--border-width) solid var(--primary-color); + border-radius: var(--border-radius); + padding: 10px; + font-size: 14px; + font-family: var(--font-family); + color: var(--primary-color); +} + +.event-queue-item { + background: var(--secondary-color); + border: var(--border-width) solid var(--muted-color); + border-radius: var(--border-radius); + padding: 10px; + margin: 5px 0; + font-family: var(--font-family); + font-size: 12px; + color: var(--primary-color); +} + +.event-queue-item.processing { + border-color: var(--accent-color); +} + +.log-entry { + padding: 8px; + margin: 2px 0; + border-left: var(--border-width) solid var(--muted-color); + background: var(--secondary-color); + font-family: var(--font-family); + font-size: 12px; + color: var(--primary-color); +} + +.log-entry.success { + border-left-color: var(--primary-color); +} + +.log-entry.error { + border-left-color: var(--accent-color); + color: var(--accent-color); +} + +.log-entry.info { + border-left-color: var(--muted-color); +} + +.log-timestamp { + color: var(--muted-color); + font-weight: bold; +} + +#processing-log { + max-height: 300px; + overflow-y: auto; + border: var(--border-width) solid var(--primary-color); + border-radius: var(--border-radius); + background: var(--secondary-color); +} + +h1 { + font-family: var(--font-family); + color: var(--primary-color); + text-align: center; +} + +p { + font-family: var(--font-family); + color: var(--primary-color); +} + +strong { + font-family: var(--font-family); + color: var(--primary-color); +} + +small { + font-family: var(--font-family); + color: var(--muted-color); +} + +#thrower-banner { + border: var(--border-width) solid var(--primary-color); + border-radius: var(--border-radius); + filter: grayscale(100%); + transition: filter 0.3s ease; +} + +#thrower-banner:hover { + filter: grayscale(0%) saturate(50%); +} + +#thrower-icon { + width: 50px; + height: 50px; + border-radius: var(--border-radius); + border: var(--border-width) solid var(--primary-color); + filter: grayscale(100%); + transition: filter 0.3s ease; +} + +#thrower-icon:hover { + filter: grayscale(0%) saturate(50%); +} + +#login-container { + margin: 20px auto; + max-width: 500px; +} + +#profile-picture { + width: 50px; + height: 50px; + border-radius: var(--border-radius); + border: var(--border-width) solid var(--primary-color); + filter: grayscale(100%); + transition: filter 0.3s ease; +} + +#profile-picture:hover { + filter: grayscale(0%) saturate(50%); +} + +#profile-name, +#profile-pubkey { + font-family: var(--font-family); + color: var(--primary-color); +} + +#profile-pubkey { + color: var(--muted-color); + font-size: 12px; + word-break: break-all; +} + +/* Superball Builder Specific Styles */ +.json-display { + background: var(--secondary-color); + border: var(--border-width) solid var(--muted-color); + border-radius: var(--border-radius); + padding: 10px; + font-family: var(--font-family); + font-size: 12px; + white-space: pre-wrap; + word-wrap: break-word; + max-height: 400px; + overflow-y: auto; + margin: 10px 0; + color: var(--muted-color); + display: none; +} + +.json-display:not(:empty) { + display: block; +} + +.bounce-section { + background: var(--secondary-color); + border: var(--border-width) solid var(--muted-color); +} + +.small-input { + width: 200px; +} + +/* Visualization Styles */ +.visualization { + background: var(--secondary-color); + border: var(--border-width) solid var(--accent-color); + padding: 15px; + margin: 15px 0; + border-radius: var(--border-radius); +} + +.timeline { + display: flex; + flex-direction: column; + gap: 15px; +} + +.timeline-step { + display: flex; + align-items: center; + padding: 10px; + border: var(--border-width) solid var(--primary-color); + border-radius: var(--border-radius); + background: var(--secondary-color); + transition: all 0.2s ease; +} + +.step-time { + min-width: 80px; + font-weight: bold; + color: var(--muted-color); + font-size: 12px; + font-family: var(--font-family); +} + +.step-actor { + min-width: 100px; + font-weight: bold; + color: var(--primary-color); + font-family: var(--font-family); +} + +.step-action { + flex: 1; + margin: 0 10px; + font-family: var(--font-family); + color: var(--primary-color); +} + +.step-size { + min-width: 80px; + text-align: right; + font-size: 12px; + color: var(--muted-color); + font-family: var(--font-family); +} + +.step-relays { + font-size: 11px; + color: var(--muted-color); + font-style: italic; + font-family: var(--font-family); +} + +/* Throw button hover effect */ +.throw-button:hover { + border-color: var(--accent-color) !important; +} + +#profile-about { + font-family: var(--font-family); + color: var(--primary-color); +} + +/* Thrower Discovery Styles */ +.thrower-item { + background: var(--secondary-color); + border: var(--border-width) solid var(--muted-color); + border-radius: var(--border-radius); + padding: 8px 15px; + margin: 5px 0; + position: relative; +} + +.thrower-item.online { + border-color: #28a745; +} + +.thrower-item.offline { + border-color: #dc3545; + opacity: 0.7; +} + +.thrower-header { + display: flex; + align-items: center; + justify-content: space-between; + cursor: pointer; + margin-bottom: 0; +} + +.thrower-header-left { + display: flex; + align-items: center; + flex: 1; +} + +.thrower-details-section { + margin-top: 10px; + overflow: hidden; + transition: max-height 0.3s ease, opacity 0.3s ease; +} + +.thrower-details-section.collapsed { + max-height: 0; + opacity: 0; +} + +.thrower-details-section.expanded { + max-height: 1000px; + opacity: 1; +} + +.expand-triangle { + width: 0; + height: 0; + border-left: 6px solid transparent; + border-right: 6px solid transparent; + border-top: 8px solid var(--muted-color); + transition: transform 0.3s ease; + margin-left: 10px; +} + +.expand-triangle.expanded { + transform: rotate(180deg); +} + +.thrower-condensed-info { + display: flex; + align-items: center; + gap: 10px; + flex: 1; +} + +.thrower-status { + width: 12px; + height: 12px; + border-radius: 50%; + margin-right: 10px; +} + +.thrower-status.online { + background-color: #28a745; +} + +.thrower-status.offline { + background-color: #dc3545; +} + +.thrower-name { + font-weight: bold; + font-size: 16px; + flex: 1; +} + +.thrower-pubkey { + font-family: var(--font-family); + font-size: 10px; + color: var(--muted-color); + word-break: break-all; + margin: 5px 0; +} + +.thrower-description { + color: var(--primary-color); + margin: 8px 0; + font-size: 14px; +} + +.thrower-details { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 10px; + font-size: 12px; + color: var(--muted-color); + margin-top: 10px; +} + +.thrower-detail { + display: flex; + justify-content: space-between; +} + +#discovery-status { + font-style: italic; +} + +.loading { + animation: pulse 1.5s infinite; +} + +/* Additional theme styles from theme.css */ +.container { + max-width: 1200px; + margin: 0 auto; + padding: 20px; +} + +.floating-tab { + position: fixed; + top: 20px; + right: 20px; + padding: 10px 15px; + border-radius: var(--border-radius); + border: var(--border-width) solid; + font-family: var(--font-family); + font-size: 14px; + z-index: 1000; + cursor: pointer; + transition: all 0.3s ease; +} + +.floating-tab.logged-out { + background: rgba(255, 255, 255, var(--tab-bg-opacity-logged-out)); + color: var(--tab-color-logged-out); + border-color: rgba(0, 0, 0, var(--tab-border-opacity-logged-out)); +} + +.floating-tab.logged-in { + background: rgba(255, 255, 255, var(--tab-bg-opacity-logged-in)); + color: var(--tab-color-logged-in); + border-color: rgba(255, 0, 0, var(--tab-border-opacity-logged-in)); +} + +.floating-tab:hover { + transform: scale(1.05); +} \ No newline at end of file diff --git a/web/superball.html b/web/superball.html index de5ee85..42e25b2 100644 --- a/web/superball.html +++ b/web/superball.html @@ -5,156 +5,17 @@ Superball Builder - +
- +

Superball Builder

+
-
+ @@ -181,18 +42,41 @@
-
- - +
+
+ + + + + + - + + - + + + + - + + - + \ No newline at end of file