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
@@ -163,7 +24,7 @@
-
+
Final Event (What gets posted at the end)
@@ -172,7 +33,7 @@
-
+
@@ -181,18 +42,41 @@
-
-
-
+
+
+
+
+
Available Throwers
+
Discover Throwers on your relay network. Throwers provide anonymity by routing your Superballs.
+
+
+
+
+
+
+
+
+ Click "Refresh List" to search your relay network for available Throwers.
+
+
+
+
+
+
+
+
+
-
+
+
-
+
+
+
+
-
+
+
-