super_ball/web/nostr-lite.js

4356 lines
148 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

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

/**
* NOSTR_LOGIN_LITE - Authentication Library
*
* ⚠️ WARNING: THIS FILE IS AUTO-GENERATED - DO NOT EDIT MANUALLY!
* ⚠️ To make changes, edit lite/build.js and run: cd lite && node build.js
* ⚠️ Any manual edits to this file will be OVERWRITTEN when build.js runs!
*
* 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-20T19:25:01.143Z
*/
// Verify dependencies are loaded
if (typeof window !== 'undefined') {
if (!window.NostrTools) {
console.error('NOSTR_LOGIN_LITE: nostr.bundle.js must be loaded first');
throw new Error('Missing dependency: nostr.bundle.js');
}
console.log('NOSTR_LOGIN_LITE: Dependencies verified ✓');
console.log('NOSTR_LOGIN_LITE: NostrTools available with keys:', Object.keys(window.NostrTools));
console.log('NOSTR_LOGIN_LITE: NIP-06 available:', !!window.NostrTools.nip06);
console.log('NOSTR_LOGIN_LITE: NIP-46 available:', !!window.NostrTools.nip46);
}
// ======================================
// NOSTR_LOGIN_LITE Components
// ======================================
// ======================================
// CSS-Only Theme System
// ======================================
const THEME_CSS = {
'default': `/**
* NOSTR_LOGIN_LITE - Default Monospace Theme
* Black/white/red color scheme with monospace typography
* Simplified 14-variable system (6 core + 8 floating tab)
*/
:root {
/* Core Variables (6) */
--nl-primary-color: #000000;
--nl-secondary-color: #ffffff;
--nl-accent-color: #ff0000;
--nl-muted-color: #CCCCCC;
--nl-font-family: "Courier New", Courier, monospace;
--nl-border-radius: 15px;
--nl-border-width: 3px;
/* Floating Tab Variables (8) */
--nl-tab-bg-logged-out: #ffffff;
--nl-tab-bg-logged-in: #ffffff;
--nl-tab-bg-opacity-logged-out: 0.9;
--nl-tab-bg-opacity-logged-in: 0.2;
--nl-tab-color-logged-out: #000000;
--nl-tab-color-logged-in: #ffffff;
--nl-tab-border-logged-out: #000000;
--nl-tab-border-logged-in: #ff0000;
--nl-tab-border-opacity-logged-out: 1.0;
--nl-tab-border-opacity-logged-in: 0.1;
}
/* Base component styles using simplified variables */
.nl-component {
font-family: var(--nl-font-family);
color: var(--nl-primary-color);
}
.nl-button {
background: var(--nl-secondary-color);
color: var(--nl-primary-color);
border: var(--nl-border-width) solid var(--nl-primary-color);
border-radius: var(--nl-border-radius);
font-family: var(--nl-font-family);
cursor: pointer;
transition: all 0.2s ease;
}
.nl-button:hover {
border-color: var(--nl-accent-color);
}
.nl-button:active {
background: var(--nl-accent-color);
color: var(--nl-secondary-color);
}
.nl-input {
background: var(--nl-secondary-color);
color: var(--nl-primary-color);
border: var(--nl-border-width) solid var(--nl-primary-color);
border-radius: var(--nl-border-radius);
font-family: var(--nl-font-family);
box-sizing: border-box;
}
.nl-input:focus {
border-color: var(--nl-accent-color);
outline: none;
}
.nl-container {
background: var(--nl-secondary-color);
border: var(--nl-border-width) solid var(--nl-primary-color);
border-radius: var(--nl-border-radius);
}
.nl-title, .nl-heading {
font-family: var(--nl-font-family);
color: var(--nl-primary-color);
margin: 0;
}
.nl-text {
font-family: var(--nl-font-family);
color: var(--nl-primary-color);
}
.nl-text--muted {
color: var(--nl-muted-color);
}
.nl-icon {
font-family: var(--nl-font-family);
color: var(--nl-primary-color);
}
/* Floating tab styles */
.nl-floating-tab {
font-family: var(--nl-font-family);
border-radius: var(--nl-border-radius);
border: var(--nl-border-width) solid;
transition: all 0.2s ease;
}
.nl-floating-tab--logged-out {
background: rgba(255, 255, 255, var(--nl-tab-bg-opacity-logged-out));
color: var(--nl-tab-color-logged-out);
border-color: rgba(0, 0, 0, var(--nl-tab-border-opacity-logged-out));
}
.nl-floating-tab--logged-in {
background: rgba(0, 0, 0, var(--nl-tab-bg-opacity-logged-in));
color: var(--nl-tab-color-logged-in);
border-color: rgba(255, 0, 0, var(--nl-tab-border-opacity-logged-in));
}
.nl-transition {
transition: all 0.2s ease;
}`,
'dark': `/**
* NOSTR_LOGIN_LITE - Dark Monospace Theme
*/
:root {
/* Core Variables (6) */
--nl-primary-color: #white;
--nl-secondary-color: #black;
--nl-accent-color: #ff0000;
--nl-muted-color: #666666;
--nl-font-family: "Courier New", Courier, monospace;
--nl-border-radius: 15px;
--nl-border-width: 3px;
/* Floating Tab Variables (8) */
--nl-tab-bg-logged-out: #ffffff;
--nl-tab-bg-logged-in: #000000;
--nl-tab-bg-opacity-logged-out: 0.9;
--nl-tab-bg-opacity-logged-in: 0.8;
--nl-tab-color-logged-out: #000000;
--nl-tab-color-logged-in: #ffffff;
--nl-tab-border-logged-out: #000000;
--nl-tab-border-logged-in: #ff0000;
--nl-tab-border-opacity-logged-out: 1.0;
--nl-tab-border-opacity-logged-in: 0.9;
}
/* Base component styles using simplified variables */
.nl-component {
font-family: var(--nl-font-family);
color: var(--nl-primary-color);
}
.nl-button {
background: var(--nl-secondary-color);
color: var(--nl-primary-color);
border: var(--nl-border-width) solid var(--nl-primary-color);
border-radius: var(--nl-border-radius);
font-family: var(--nl-font-family);
cursor: pointer;
transition: all 0.2s ease;
}
.nl-button:hover {
border-color: var(--nl-accent-color);
}
.nl-button:active {
background: var(--nl-accent-color);
color: var(--nl-secondary-color);
}
.nl-input {
background: var(--nl-secondary-color);
color: var(--nl-primary-color);
border: var(--nl-border-width) solid var(--nl-primary-color);
border-radius: var(--nl-border-radius);
font-family: var(--nl-font-family);
box-sizing: border-box;
}
.nl-input:focus {
border-color: var(--nl-accent-color);
outline: none;
}
.nl-container {
background: var(--nl-secondary-color);
border: var(--nl-border-width) solid var(--nl-primary-color);
border-radius: var(--nl-border-radius);
}
.nl-title, .nl-heading {
font-family: var(--nl-font-family);
color: var(--nl-primary-color);
margin: 0;
}
.nl-text {
font-family: var(--nl-font-family);
color: var(--nl-primary-color);
}
.nl-text--muted {
color: var(--nl-muted-color);
}
.nl-icon {
font-family: var(--nl-font-family);
color: var(--nl-primary-color);
}
/* Floating tab styles */
.nl-floating-tab {
font-family: var(--nl-font-family);
border-radius: var(--nl-border-radius);
border: var(--nl-border-width) solid;
transition: all 0.2s ease;
}
.nl-floating-tab--logged-out {
background: rgba(255, 255, 255, var(--nl-tab-bg-opacity-logged-out));
color: var(--nl-tab-color-logged-out);
border-color: rgba(0, 0, 0, var(--nl-tab-border-opacity-logged-out));
}
.nl-floating-tab--logged-in {
background: rgba(0, 0, 0, var(--nl-tab-bg-opacity-logged-in));
color: var(--nl-tab-color-logged-in);
border-color: rgba(255, 0, 0, var(--nl-tab-border-opacity-logged-in));
}
.nl-transition {
transition: all 0.2s ease;
}`
};
// Theme management functions
function injectThemeCSS(themeName = 'default') {
if (typeof document !== 'undefined') {
// Remove existing theme CSS
const existingStyle = document.getElementById('nl-theme-css');
if (existingStyle) {
existingStyle.remove();
}
// Inject selected theme CSS
const themeCss = THEME_CSS[themeName] || THEME_CSS['default'];
const style = document.createElement('style');
style.id = 'nl-theme-css';
style.textContent = themeCss;
document.head.appendChild(style);
console.log(`NOSTR_LOGIN_LITE: ${themeName} theme CSS injected`);
}
}
// Auto-inject default theme when DOM is ready
if (typeof document !== 'undefined') {
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', () => injectThemeCSS('default'));
} else {
injectThemeCSS('default');
}
}
// ======================================
// Modal UI Component
// ======================================
class Modal {
constructor(options = {}) {
this.options = options;
this.container = null;
this.isVisible = false;
this.currentScreen = null;
this.isEmbedded = !!options.embedded;
this.embeddedContainer = options.embedded;
// Initialize modal container and styles
this._initModal();
}
_initModal() {
// Create modal container
this.container = document.createElement('div');
this.container.id = this.isEmbedded ? 'nl-modal-embedded' : 'nl-modal';
if (this.isEmbedded) {
// Embedded mode: inline positioning, no overlay
this.container.style.cssText = `
position: relative;
display: none;
font-family: var(--nl-font-family, 'Courier New', monospace);
width: 100%;
`;
} else {
// Modal mode: fixed overlay
this.container.style.cssText = `
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.75);
display: none;
z-index: 10000;
font-family: var(--nl-font-family, 'Courier New', monospace);
`;
}
// Create modal content
const modalContent = document.createElement('div');
if (this.isEmbedded) {
// Embedded content: no centering margin, full width
modalContent.style.cssText = `
position: relative;
background: var(--nl-secondary-color);
color: var(--nl-primary-color);
width: 100%;
border-radius: var(--nl-border-radius, 15px);
border: var(--nl-border-width) solid var(--nl-primary-color);
overflow: hidden;
`;
} else {
// Modal content: centered with margin, no fixed height
modalContent.style.cssText = `
position: relative;
background: var(--nl-secondary-color);
color: var(--nl-primary-color);
width: 90%;
max-width: 400px;
margin: 50px auto;
border-radius: var(--nl-border-radius, 15px);
border: var(--nl-border-width) solid var(--nl-primary-color);
overflow: hidden;
`;
}
// Header
const modalHeader = document.createElement('div');
modalHeader.style.cssText = `
padding: 20px 24px 0 24px;
display: flex;
justify-content: space-between;
align-items: center;
background: transparent;
border-bottom: none;
`;
const modalTitle = document.createElement('h2');
modalTitle.textContent = 'Nostr Login';
modalTitle.style.cssText = `
margin: 0;
font-size: 24px;
font-weight: 600;
color: var(--nl-primary-color);
font-family: var(--nl-font-family, 'Courier New', monospace);
`;
modalHeader.appendChild(modalTitle);
// Only add close button for non-embedded modals
// Embedded modals shouldn't have a close button because there's no way to reopen them
if (!this.isEmbedded) {
const closeButton = document.createElement('button');
closeButton.innerHTML = '×';
closeButton.onclick = () => this.close();
closeButton.style.cssText = `
background: var(--nl-secondary-color);
border: var(--nl-border-width) solid var(--nl-primary-color);
border-radius: 4px;
font-size: 28px;
color: var(--nl-primary-color);
cursor: pointer;
padding: 0;
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
font-family: var(--nl-font-family, 'Courier New', monospace);
`;
closeButton.onmouseover = () => {
closeButton.style.borderColor = 'var(--nl-accent-color)';
closeButton.style.background = 'var(--nl-secondary-color)';
};
closeButton.onmouseout = () => {
closeButton.style.borderColor = 'var(--nl-primary-color)';
closeButton.style.background = 'var(--nl-secondary-color)';
};
modalHeader.appendChild(closeButton);
}
// Body
this.modalBody = document.createElement('div');
this.modalBody.style.cssText = `
padding: 24px;
background: transparent;
font-family: var(--nl-font-family, 'Courier New', monospace);
`;
modalContent.appendChild(modalHeader);
modalContent.appendChild(this.modalBody);
this.container.appendChild(modalContent);
// Add to appropriate parent
if (this.isEmbedded && this.embeddedContainer) {
// Append to specified container for embedding
if (typeof this.embeddedContainer === 'string') {
const targetElement = document.querySelector(this.embeddedContainer);
if (targetElement) {
targetElement.appendChild(this.container);
} else {
console.error('NOSTR_LOGIN_LITE: Embedded container not found:', this.embeddedContainer);
document.body.appendChild(this.container);
}
} else if (this.embeddedContainer instanceof HTMLElement) {
this.embeddedContainer.appendChild(this.container);
} else {
console.error('NOSTR_LOGIN_LITE: Invalid embedded container');
document.body.appendChild(this.container);
}
} else {
// Add to body for modal mode
document.body.appendChild(this.container);
}
// Click outside to close (only for modal mode)
if (!this.isEmbedded) {
this.container.onclick = (e) => {
if (e.target === this.container) {
this.close();
}
};
}
// Update theme
this.updateTheme();
}
updateTheme() {
// The theme will automatically update through CSS custom properties
// No manual styling needed - the CSS variables handle everything
}
open(opts = {}) {
this.currentScreen = opts.startScreen;
this.isVisible = true;
this.container.style.display = 'block';
// Render login options
this._renderLoginOptions();
}
close() {
this.isVisible = false;
this.container.style.display = 'none';
this.modalBody.innerHTML = '';
}
_renderLoginOptions() {
this.modalBody.innerHTML = '';
const options = [];
// Extension option
if (this.options?.methods?.extension !== false) {
options.push({
type: 'extension',
title: 'Browser Extension',
description: 'Use your browser extension',
icon: '🔌'
});
}
// Local key option
if (this.options?.methods?.local !== false) {
options.push({
type: 'local',
title: 'Local Key',
description: 'Create or import your own key',
icon: '🔑'
});
}
// Seed Phrase option - only show if explicitly enabled
if (this.options?.methods?.seedphrase === true) {
options.push({
type: 'seedphrase',
title: 'Seed Phrase',
description: 'Import from mnemonic seed phrase',
icon: '🌱'
});
}
// Nostr Connect option (check both 'connect' and 'remote' for compatibility)
if (this.options?.methods?.connect !== false && this.options?.methods?.remote !== false) {
options.push({
type: 'connect',
title: 'Nostr Connect',
description: 'Connect with external signer',
icon: '🌐'
});
}
// Read-only option
if (this.options?.methods?.readonly !== false) {
options.push({
type: 'readonly',
title: 'Read Only',
description: 'Browse without signing',
icon: '👁️'
});
}
// OTP/DM option
if (this.options?.methods?.otp !== false) {
options.push({
type: 'otp',
title: 'DM/OTP',
description: 'Receive OTP via DM',
icon: '📱'
});
}
// Render each option
options.forEach(option => {
const button = document.createElement('button');
button.onclick = () => this._handleOptionClick(option.type);
button.style.cssText = `
display: flex;
align-items: center;
width: 100%;
padding: 16px;
margin-bottom: 12px;
background: var(--nl-secondary-color);
color: var(--nl-primary-color);
border: var(--nl-border-width) solid var(--nl-primary-color);
border-radius: var(--nl-border-radius);
cursor: pointer;
transition: all 0.2s;
font-family: var(--nl-font-family, 'Courier New', monospace);
`;
button.onmouseover = () => {
button.style.borderColor = 'var(--nl-accent-color)';
button.style.background = 'var(--nl-secondary-color)';
};
button.onmouseout = () => {
button.style.borderColor = 'var(--nl-primary-color)';
button.style.background = 'var(--nl-secondary-color)';
};
const iconDiv = document.createElement('div');
// 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: 0px;
text-align: center;
color: var(--nl-primary-color);
font-family: var(--nl-font-family, 'Courier New', monospace);
`;
const contentDiv = document.createElement('div');
contentDiv.style.cssText = 'flex: 1; text-align: left;';
const titleDiv = document.createElement('div');
titleDiv.textContent = option.title;
titleDiv.style.cssText = `
font-weight: 600;
margin-bottom: 4px;
color: var(--nl-primary-color);
font-family: var(--nl-font-family, 'Courier New', monospace);
`;
const descDiv = document.createElement('div');
descDiv.textContent = option.description;
descDiv.style.cssText = `
font-size: 14px;
color: #666666;
font-family: var(--nl-font-family, 'Courier New', monospace);
`;
contentDiv.appendChild(titleDiv);
contentDiv.appendChild(descDiv);
button.appendChild(iconDiv);
button.appendChild(contentDiv);
this.modalBody.appendChild(button);
});
}
_handleOptionClick(type) {
console.log('Selected login type:', type);
// Handle different login types
switch (type) {
case 'extension':
this._handleExtension();
break;
case 'local':
this._showLocalKeyScreen();
break;
case 'seedphrase':
this._showSeedPhraseScreen();
break;
case 'connect':
this._showConnectScreen();
break;
case 'readonly':
this._handleReadonly();
break;
case 'otp':
this._showOtpScreen();
break;
}
}
_handleExtension() {
// SIMPLIFIED ARCHITECTURE: Check for single extension at window.nostr or preserved extension
let extension = null;
// Check if NostrLite instance has a preserved extension (real extension detected at init)
if (window.NOSTR_LOGIN_LITE?._instance?.preservedExtension) {
extension = window.NOSTR_LOGIN_LITE._instance.preservedExtension;
console.log('Modal: Using preserved extension:', extension.constructor?.name);
}
// Otherwise check current window.nostr
else if (window.nostr && this._isRealExtension(window.nostr)) {
extension = window.nostr;
console.log('Modal: Using current window.nostr extension:', extension.constructor?.name);
}
if (!extension) {
console.log('Modal: No extension detected yet, waiting for deferred detection...');
// DEFERRED EXTENSION CHECK: Extensions like nos2x might load after our library
let attempts = 0;
const maxAttempts = 10; // Try for 2 seconds
const checkForExtension = () => {
attempts++;
// Check again for preserved extension (might be set by deferred detection)
if (window.NOSTR_LOGIN_LITE?._instance?.preservedExtension) {
extension = window.NOSTR_LOGIN_LITE._instance.preservedExtension;
console.log('Modal: Found preserved extension after waiting:', extension.constructor?.name);
this._tryExtensionLogin(extension);
return;
}
// Check current window.nostr again
if (window.nostr && this._isRealExtension(window.nostr)) {
extension = window.nostr;
console.log('Modal: Found extension at window.nostr after waiting:', extension.constructor?.name);
this._tryExtensionLogin(extension);
return;
}
// Keep trying or give up
if (attempts < maxAttempts) {
setTimeout(checkForExtension, 200);
} else {
console.log('Modal: No browser extension found after waiting 2 seconds');
this._showExtensionRequired();
}
};
// Start checking after a brief delay
setTimeout(checkForExtension, 200);
return;
}
// Use the single detected extension directly - no choice UI
console.log('Modal: Single extension mode - using extension directly');
this._tryExtensionLogin(extension);
}
_detectAllExtensions() {
const extensions = [];
const seenExtensions = new Set(); // Track extensions by object reference to avoid duplicates
// Extension locations to check (in priority order)
const locations = [
{ path: 'window.navigator?.nostr', name: 'navigator.nostr', displayName: 'Standard Extension (navigator.nostr)', icon: '🌐', getter: () => window.navigator?.nostr },
{ path: 'window.webln?.nostr', name: 'webln.nostr', displayName: 'Alby WebLN Extension', icon: '⚡', getter: () => window.webln?.nostr },
{ path: 'window.alby?.nostr', name: 'alby.nostr', displayName: 'Alby Extension (Direct)', icon: '🐝', getter: () => window.alby?.nostr },
{ path: 'window.nos2x', name: 'nos2x', displayName: 'nos2x Extension', icon: '🔌', getter: () => window.nos2x },
{ path: 'window.flamingo?.nostr', name: 'flamingo.nostr', displayName: 'Flamingo Extension', icon: '🦩', getter: () => window.flamingo?.nostr },
{ path: 'window.mutiny?.nostr', name: 'mutiny.nostr', displayName: 'Mutiny Extension', icon: '⚔️', getter: () => window.mutiny?.nostr },
{ path: 'window.nostrich?.nostr', name: 'nostrich.nostr', displayName: 'Nostrich Extension', icon: '🐦', getter: () => window.nostrich?.nostr },
{ path: 'window.getAlby?.nostr', name: 'getAlby.nostr', displayName: 'getAlby Extension', icon: '🔧', getter: () => window.getAlby?.nostr }
];
// Check each location
for (const location of locations) {
try {
const obj = location.getter();
console.log(`Modal: Checking ${location.name}:`, !!obj, obj?.constructor?.name);
if (obj && this._isRealExtension(obj) && !seenExtensions.has(obj)) {
extensions.push({
name: location.name,
displayName: location.displayName,
icon: location.icon,
extension: obj
});
seenExtensions.add(obj);
console.log(`Modal: ✓ Detected extension at ${location.name} (${obj.constructor?.name})`);
} else if (obj) {
console.log(`Modal: ✗ Filtered out ${location.name} (${obj.constructor?.name})`);
}
} catch (e) {
// Location doesn't exist or can't be accessed
console.log(`Modal: ${location.name} not accessible:`, e.message);
}
}
// Also check window.nostr but be extra careful to avoid our library
console.log('Modal: Checking window.nostr:', !!window.nostr, window.nostr?.constructor?.name);
if (window.nostr) {
// Check if window.nostr is our WindowNostr facade with a preserved extension
if (window.nostr.constructor?.name === 'WindowNostr' && window.nostr.existingNostr) {
console.log('Modal: Found WindowNostr facade, checking existingNostr for preserved extension');
const preservedExtension = window.nostr.existingNostr;
console.log('Modal: Preserved extension:', !!preservedExtension, preservedExtension?.constructor?.name);
if (preservedExtension && this._isRealExtension(preservedExtension) && !seenExtensions.has(preservedExtension)) {
extensions.push({
name: 'window.nostr.existingNostr',
displayName: 'Extension (preserved by WindowNostr)',
icon: '🔑',
extension: preservedExtension
});
seenExtensions.add(preservedExtension);
console.log(`Modal: ✓ Detected preserved extension: ${preservedExtension.constructor?.name}`);
}
}
// Check if window.nostr is directly a real extension (not our facade)
else if (this._isRealExtension(window.nostr) && !seenExtensions.has(window.nostr)) {
extensions.push({
name: 'window.nostr',
displayName: 'Extension (window.nostr)',
icon: '🔑',
extension: window.nostr
});
seenExtensions.add(window.nostr);
console.log(`Modal: ✓ Detected extension at window.nostr: ${window.nostr.constructor?.name}`);
} else {
console.log(`Modal: ✗ Filtered out window.nostr (${window.nostr.constructor?.name}) - not a real extension`);
}
}
return extensions;
}
_isRealExtension(obj) {
console.log(`Modal: EXTENSIVE DEBUG - _isRealExtension called with:`, obj);
console.log(`Modal: Object type: ${typeof obj}`);
console.log(`Modal: Object truthy: ${!!obj}`);
if (!obj || typeof obj !== 'object') {
console.log(`Modal: REJECT - Not an object`);
return false;
}
console.log(`Modal: getPublicKey type: ${typeof obj.getPublicKey}`);
console.log(`Modal: signEvent type: ${typeof obj.signEvent}`);
// Must have required Nostr methods
if (typeof obj.getPublicKey !== 'function' || typeof obj.signEvent !== 'function') {
console.log(`Modal: REJECT - Missing required methods`);
return false;
}
// Exclude NostrTools library object
if (obj === window.NostrTools) {
console.log(`Modal: REJECT - Is NostrTools object`);
return false;
}
// Use the EXACT SAME logic as the comprehensive test (lines 804-809)
// This is the key fix - match the comprehensive test's successful detection logic
const constructorName = obj.constructor?.name;
const objectKeys = Object.keys(obj);
console.log(`Modal: Constructor name: "${constructorName}"`);
console.log(`Modal: Object keys: [${objectKeys.join(', ')}]`);
// COMPREHENSIVE TEST LOGIC - Accept anything with required methods that's not our specific library classes
const isRealExtension = (
typeof obj.getPublicKey === 'function' &&
typeof obj.signEvent === 'function' &&
constructorName !== 'WindowNostr' && // Our library class
constructorName !== 'NostrLite' // Our main class
);
console.log(`Modal: Using comprehensive test logic:`);
console.log(` Has getPublicKey: ${typeof obj.getPublicKey === 'function'}`);
console.log(` Has signEvent: ${typeof obj.signEvent === 'function'}`);
console.log(` Not WindowNostr: ${constructorName !== 'WindowNostr'}`);
console.log(` Not NostrLite: ${constructorName !== 'NostrLite'}`);
console.log(` Constructor: "${constructorName}"`);
// Additional debugging for comparison
const extensionPropChecks = {
_isEnabled: !!obj._isEnabled,
enabled: !!obj.enabled,
kind: !!obj.kind,
_eventEmitter: !!obj._eventEmitter,
_scope: !!obj._scope,
_requests: !!obj._requests,
_pubkey: !!obj._pubkey,
name: !!obj.name,
version: !!obj.version,
description: !!obj.description
};
console.log(`Modal: Extension property analysis:`, extensionPropChecks);
const hasExtensionProps = !!(
obj._isEnabled || obj.enabled || obj.kind ||
obj._eventEmitter || obj._scope || obj._requests || obj._pubkey ||
obj.name || obj.version || obj.description
);
const underscoreKeys = objectKeys.filter(key => key.startsWith('_'));
const hexToUint8Keys = objectKeys.filter(key => key.startsWith('_hex'));
console.log(`Modal: Underscore keys: [${underscoreKeys.join(', ')}]`);
console.log(`Modal: _hex* keys: [${hexToUint8Keys.join(', ')}]`);
console.log(`Modal: Additional analysis:`);
console.log(` hasExtensionProps: ${hasExtensionProps}`);
console.log(` hasLibraryMethod (_hexToUint8Array): ${objectKeys.includes('_hexToUint8Array')}`);
console.log(`Modal: COMPREHENSIVE TEST LOGIC RESULT: ${isRealExtension ? 'ACCEPT' : 'REJECT'}`);
console.log(`Modal: FINAL DECISION for ${constructorName}: ${isRealExtension ? 'ACCEPT' : 'REJECT'}`);
return isRealExtension;
}
_showExtensionChoice(extensions) {
this.modalBody.innerHTML = '';
const title = document.createElement('h3');
title.textContent = 'Choose Browser Extension';
title.style.cssText = `
margin: 0 0 16px 0;
font-size: 18px;
font-weight: 600;
color: var(--nl-primary-color);
font-family: var(--nl-font-family, 'Courier New', monospace);
`;
const description = document.createElement('p');
description.textContent = `Found ${extensions.length} Nostr extensions. Choose which one to use:`;
description.style.cssText = `
margin-bottom: 20px;
color: #666666;
font-size: 14px;
font-family: var(--nl-font-family, 'Courier New', monospace);
`;
this.modalBody.appendChild(title);
this.modalBody.appendChild(description);
// Create button for each extension
extensions.forEach((ext, index) => {
const button = document.createElement('button');
button.onclick = () => this._tryExtensionLogin(ext.extension);
button.style.cssText = `
display: flex;
align-items: center;
width: 100%;
padding: 16px;
margin-bottom: 12px;
background: var(--nl-secondary-color);
color: var(--nl-primary-color);
border: var(--nl-border-width) solid var(--nl-primary-color);
border-radius: var(--nl-border-radius);
cursor: pointer;
transition: all 0.2s;
text-align: left;
font-family: var(--nl-font-family, 'Courier New', monospace);
`;
button.onmouseover = () => {
button.style.borderColor = 'var(--nl-accent-color)';
button.style.background = 'var(--nl-secondary-color)';
};
button.onmouseout = () => {
button.style.borderColor = 'var(--nl-primary-color)';
button.style.background = 'var(--nl-secondary-color)';
};
const iconDiv = document.createElement('div');
iconDiv.textContent = ext.icon;
iconDiv.style.cssText = `
font-size: 24px;
margin-right: 16px;
width: 24px;
text-align: center;
`;
const contentDiv = document.createElement('div');
contentDiv.style.cssText = 'flex: 1;';
const nameDiv = document.createElement('div');
nameDiv.textContent = ext.displayName;
nameDiv.style.cssText = `
font-weight: 600;
margin-bottom: 4px;
color: var(--nl-primary-color);
font-family: var(--nl-font-family, 'Courier New', monospace);
`;
const pathDiv = document.createElement('div');
pathDiv.textContent = ext.name;
pathDiv.style.cssText = `
font-size: 12px;
color: #666666;
font-family: var(--nl-font-family, 'Courier New', monospace);
`;
contentDiv.appendChild(nameDiv);
contentDiv.appendChild(pathDiv);
button.appendChild(iconDiv);
button.appendChild(contentDiv);
this.modalBody.appendChild(button);
});
// Add back button
const backButton = document.createElement('button');
backButton.textContent = 'Back to Login Options';
backButton.onclick = () => this._renderLoginOptions();
backButton.style.cssText = this._getButtonStyle('secondary') + 'margin-top: 20px;';
this.modalBody.appendChild(backButton);
}
async _tryExtensionLogin(extensionObj) {
try {
// Show loading state
this.modalBody.innerHTML = '<div style="text-align: center; padding: 20px;">🔄 Connecting to extension...</div>';
// Get pubkey from extension
const pubkey = await extensionObj.getPublicKey();
console.log('Extension provided pubkey:', pubkey);
// Set extension method with the extension object
this._setAuthMethod('extension', { pubkey, extension: extensionObj });
} catch (error) {
console.error('Extension login failed:', error);
this._showError(`Extension login failed: ${error.message}`);
}
}
_showLocalKeyScreen() {
this.modalBody.innerHTML = '';
const title = document.createElement('h3');
title.textContent = 'Local Key';
title.style.cssText = 'margin: 0 0 20px 0; font-size: 18px; font-weight: 600;';
const createButton = document.createElement('button');
createButton.textContent = 'Create New Key';
createButton.onclick = () => this._createLocalKey();
createButton.style.cssText = this._getButtonStyle();
const importButton = document.createElement('button');
importButton.textContent = 'Import Existing Key';
importButton.onclick = () => this._showImportKeyForm();
importButton.style.cssText = this._getButtonStyle() + 'margin-top: 12px;';
const backButton = document.createElement('button');
backButton.textContent = 'Back';
backButton.onclick = () => this._renderLoginOptions();
backButton.style.cssText = `
display: block;
margin-top: 20px;
padding: 12px;
background: #6b7280;
color: white;
border: none;
border-radius: 6px;
cursor: pointer;
`;
this.modalBody.appendChild(title);
this.modalBody.appendChild(createButton);
this.modalBody.appendChild(importButton);
this.modalBody.appendChild(backButton);
}
_createLocalKey() {
try {
const sk = window.NostrTools.generateSecretKey();
const pk = window.NostrTools.getPublicKey(sk);
const nsec = window.NostrTools.nip19.nsecEncode(sk);
const npub = window.NostrTools.nip19.npubEncode(pk);
this._showKeyDisplay(pk, nsec, 'created');
} catch (error) {
this._showError('Failed to create key: ' + error.message);
}
}
_showImportKeyForm() {
this.modalBody.innerHTML = '';
const title = document.createElement('h3');
title.textContent = 'Import Local Key';
title.style.cssText = 'margin: 0 0 16px 0; font-size: 18px; font-weight: 600;';
const description = document.createElement('p');
description.textContent = 'Enter your secret key in either nsec or hex format:';
description.style.cssText = 'margin-bottom: 12px; color: #6b7280; font-size: 14px;';
const textarea = document.createElement('textarea');
textarea.placeholder = 'Enter your secret key:\n• nsec1... (bech32 format)\n• 64-character hex string';
textarea.style.cssText = `
width: 100%;
height: 100px;
padding: 12px;
border: 1px solid #d1d5db;
border-radius: 6px;
margin-bottom: 12px;
resize: none;
font-family: monospace;
font-size: 14px;
box-sizing: border-box;
`;
// Add real-time format detection
const formatHint = document.createElement('div');
formatHint.style.cssText = 'margin-bottom: 16px; font-size: 12px; color: #6b7280; min-height: 16px;';
textarea.oninput = () => {
const value = textarea.value.trim();
if (!value) {
formatHint.textContent = '';
return;
}
const format = this._detectKeyFormat(value);
if (format === 'nsec') {
formatHint.textContent = '✅ Valid nsec format detected';
formatHint.style.color = '#059669';
} else if (format === 'hex') {
formatHint.textContent = '✅ Valid hex format detected';
formatHint.style.color = '#059669';
} else {
formatHint.textContent = '❌ Invalid key format - must be nsec1... or 64-character hex';
formatHint.style.color = '#dc2626';
}
};
const importButton = document.createElement('button');
importButton.textContent = 'Import Key';
importButton.onclick = () => this._importLocalKey(textarea.value);
importButton.style.cssText = this._getButtonStyle();
const backButton = document.createElement('button');
backButton.textContent = 'Back';
backButton.onclick = () => this._showLocalKeyScreen();
backButton.style.cssText = this._getButtonStyle('secondary') + 'margin-top: 12px;';
this.modalBody.appendChild(title);
this.modalBody.appendChild(description);
this.modalBody.appendChild(textarea);
this.modalBody.appendChild(formatHint);
this.modalBody.appendChild(importButton);
this.modalBody.appendChild(backButton);
}
_detectKeyFormat(keyValue) {
const trimmed = keyValue.trim();
// Check for nsec format
if (trimmed.startsWith('nsec1') && trimmed.length === 63) {
try {
window.NostrTools.nip19.decode(trimmed);
return 'nsec';
} catch {
return 'invalid';
}
}
// Check for hex format (64 characters, valid hex)
if (trimmed.length === 64 && /^[a-fA-F0-9]{64}$/.test(trimmed)) {
return 'hex';
}
return 'invalid';
}
_importLocalKey(keyValue) {
try {
const trimmed = keyValue.trim();
if (!trimmed) {
throw new Error('Please enter a secret key');
}
const format = this._detectKeyFormat(trimmed);
let sk;
if (format === 'nsec') {
// Decode nsec format - this returns Uint8Array
const decoded = window.NostrTools.nip19.decode(trimmed);
if (decoded.type !== 'nsec') {
throw new Error('Invalid nsec format');
}
sk = decoded.data; // This is already Uint8Array
} else if (format === 'hex') {
// Convert hex string to Uint8Array
sk = this._hexToUint8Array(trimmed);
// Test that it's a valid secret key by trying to get public key
window.NostrTools.getPublicKey(sk);
} else {
throw new Error('Invalid key format. Please enter either nsec1... or 64-character hex string');
}
// Generate public key and encoded formats
const pk = window.NostrTools.getPublicKey(sk);
const nsec = window.NostrTools.nip19.nsecEncode(sk);
const npub = window.NostrTools.nip19.npubEncode(pk);
this._showKeyDisplay(pk, nsec, 'imported');
} catch (error) {
this._showError('Invalid key: ' + error.message);
}
}
_hexToUint8Array(hex) {
// Convert hex string to Uint8Array
if (hex.length % 2 !== 0) {
throw new Error('Invalid hex string length');
}
const bytes = new Uint8Array(hex.length / 2);
for (let i = 0; i < bytes.length; i++) {
bytes[i] = parseInt(hex.substr(i * 2, 2), 16);
}
return bytes;
}
_showKeyDisplay(pubkey, nsec, action) {
this.modalBody.innerHTML = '';
const title = document.createElement('h3');
title.textContent = `Key ${action} successfully!`;
title.style.cssText = 'margin: 0 0 16px 0; font-size: 18px; font-weight: 600; color: #059669;';
const warningDiv = document.createElement('div');
warningDiv.textContent = '⚠️ Save your secret key securely!';
warningDiv.style.cssText = 'background: #fef3c7; color: #92400e; padding: 12px; border-radius: 6px; margin-bottom: 16px; font-size: 12px;';
// Helper function to create copy button
const createCopyButton = (text, label) => {
const copyBtn = document.createElement('button');
copyBtn.textContent = `Copy ${label}`;
copyBtn.style.cssText = `
margin-left: 8px;
padding: 4px 8px;
font-size: 10px;
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);
`;
copyBtn.onclick = async (e) => {
e.preventDefault();
try {
await navigator.clipboard.writeText(text);
const originalText = copyBtn.textContent;
copyBtn.textContent = '✓ Copied!';
copyBtn.style.color = '#059669';
setTimeout(() => {
copyBtn.textContent = originalText;
copyBtn.style.color = 'var(--nl-primary-color)';
}, 2000);
} catch (err) {
console.error('Failed to copy:', err);
copyBtn.textContent = '✗ Failed';
copyBtn.style.color = '#dc2626';
setTimeout(() => {
copyBtn.textContent = originalText;
copyBtn.style.color = 'var(--nl-primary-color)';
}, 2000);
}
};
return copyBtn;
};
// Convert pubkey to hex for verification
const pubkeyHex = typeof pubkey === 'string' ? pubkey : Array.from(pubkey).map(b => b.toString(16).padStart(2, '0')).join('');
// Decode nsec to get secret key as hex
let secretKeyHex = '';
try {
const decoded = window.NostrTools.nip19.decode(nsec);
secretKeyHex = Array.from(decoded.data).map(b => b.toString(16).padStart(2, '0')).join('');
} catch (err) {
console.error('Failed to decode nsec for hex display:', err);
}
// Secret Key Section
const nsecSection = document.createElement('div');
nsecSection.style.cssText = 'margin-bottom: 16px;';
const nsecLabel = document.createElement('div');
nsecLabel.innerHTML = '<strong>Your Secret Key (nsec):</strong>';
nsecLabel.style.cssText = 'margin-bottom: 4px; font-size: 12px; font-weight: 600;';
const nsecContainer = document.createElement('div');
nsecContainer.style.cssText = 'display: flex; align-items: flex-start; margin-bottom: 8px;';
const nsecCode = document.createElement('code');
nsecCode.textContent = nsec;
nsecCode.style.cssText = `
flex: 1;
word-break: break-all;
background: #f3f4f6;
padding: 6px;
border-radius: 4px;
font-size: 10px;
line-height: 1.3;
font-family: 'Courier New', monospace;
display: block;
`;
nsecContainer.appendChild(nsecCode);
nsecContainer.appendChild(createCopyButton(nsec, 'nsec'));
nsecSection.appendChild(nsecLabel);
nsecSection.appendChild(nsecContainer);
// Secret Key Hex Section
if (secretKeyHex) {
const secretHexLabel = document.createElement('div');
secretHexLabel.innerHTML = '<strong>Secret Key (hex):</strong>';
secretHexLabel.style.cssText = 'margin-bottom: 4px; font-size: 12px; font-weight: 600;';
const secretHexContainer = document.createElement('div');
secretHexContainer.style.cssText = 'display: flex; align-items: flex-start; margin-bottom: 8px;';
const secretHexCode = document.createElement('code');
secretHexCode.textContent = secretKeyHex;
secretHexCode.style.cssText = `
flex: 1;
word-break: break-all;
background: #f3f4f6;
padding: 6px;
border-radius: 4px;
font-size: 10px;
line-height: 1.3;
font-family: 'Courier New', monospace;
display: block;
`;
secretHexContainer.appendChild(secretHexCode);
secretHexContainer.appendChild(createCopyButton(secretKeyHex, 'hex'));
nsecSection.appendChild(secretHexLabel);
nsecSection.appendChild(secretHexContainer);
}
// Public Key Section
const npubSection = document.createElement('div');
npubSection.style.cssText = 'margin-bottom: 16px;';
const npub = window.NostrTools.nip19.npubEncode(pubkey);
const npubLabel = document.createElement('div');
npubLabel.innerHTML = '<strong>Your Public Key (npub):</strong>';
npubLabel.style.cssText = 'margin-bottom: 4px; font-size: 12px; font-weight: 600;';
const npubContainer = document.createElement('div');
npubContainer.style.cssText = 'display: flex; align-items: flex-start; margin-bottom: 8px;';
const npubCode = document.createElement('code');
npubCode.textContent = npub;
npubCode.style.cssText = `
flex: 1;
word-break: break-all;
background: #f3f4f6;
padding: 6px;
border-radius: 4px;
font-size: 10px;
line-height: 1.3;
font-family: 'Courier New', monospace;
display: block;
`;
npubContainer.appendChild(npubCode);
npubContainer.appendChild(createCopyButton(npub, 'npub'));
npubSection.appendChild(npubLabel);
npubSection.appendChild(npubContainer);
// Public Key Hex Section
const pubHexLabel = document.createElement('div');
pubHexLabel.innerHTML = '<strong>Public Key (hex):</strong>';
pubHexLabel.style.cssText = 'margin-bottom: 4px; font-size: 12px; font-weight: 600;';
const pubHexContainer = document.createElement('div');
pubHexContainer.style.cssText = 'display: flex; align-items: flex-start;';
const pubHexCode = document.createElement('code');
pubHexCode.textContent = pubkeyHex;
pubHexCode.style.cssText = `
flex: 1;
word-break: break-all;
background: #f3f4f6;
padding: 6px;
border-radius: 4px;
font-size: 10px;
line-height: 1.3;
font-family: 'Courier New', monospace;
display: block;
`;
pubHexContainer.appendChild(pubHexCode);
pubHexContainer.appendChild(createCopyButton(pubkeyHex, 'hex'));
npubSection.appendChild(pubHexLabel);
npubSection.appendChild(pubHexContainer);
const continueButton = document.createElement('button');
continueButton.textContent = 'Continue';
continueButton.onclick = () => this._setAuthMethod('local', { secret: nsec, pubkey });
continueButton.style.cssText = this._getButtonStyle();
this.modalBody.appendChild(title);
this.modalBody.appendChild(warningDiv);
this.modalBody.appendChild(nsecSection);
this.modalBody.appendChild(npubSection);
this.modalBody.appendChild(continueButton);
}
_setAuthMethod(method, options = {}) {
// SINGLE-EXTENSION ARCHITECTURE: Handle method switching
console.log('Modal: _setAuthMethod called with:', method, options);
// CRITICAL: Never install facade for extension methods - leave window.nostr as the extension
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;
}
// For non-extension methods, we need to ensure WindowNostr facade is available
console.log('Modal: Non-extension method detected:', method);
// Check if we have a preserved extension but no WindowNostr facade installed
const hasPreservedExtension = !!window.NOSTR_LOGIN_LITE?._instance?.preservedExtension;
const hasWindowNostrFacade = window.nostr?.constructor?.name === 'WindowNostr';
console.log('Modal: Method switching check:');
console.log(' method:', method);
console.log(' hasPreservedExtension:', hasPreservedExtension);
console.log(' hasWindowNostrFacade:', hasWindowNostrFacade);
console.log(' current window.nostr constructor:', window.nostr?.constructor?.name);
// If we have a preserved extension but no facade, install facade for method switching
if (hasPreservedExtension && !hasWindowNostrFacade) {
console.log('Modal: Installing WindowNostr facade for method switching (non-extension authentication)');
// Get the NostrLite instance and install facade with preserved extension
const nostrLiteInstance = window.NOSTR_LOGIN_LITE?._instance;
if (nostrLiteInstance && typeof nostrLiteInstance._installFacade === 'function') {
const preservedExtension = nostrLiteInstance.preservedExtension;
console.log('Modal: Installing facade with preserved extension:', preservedExtension?.constructor?.name);
nostrLiteInstance._installFacade(preservedExtension);
console.log('Modal: WindowNostr facade installed for method switching');
} else {
console.error('Modal: Cannot access NostrLite instance or _installFacade method');
}
}
// If no extension at all, ensure facade is installed for local/NIP-46/readonly methods
else if (!hasPreservedExtension && !hasWindowNostrFacade) {
console.log('Modal: Installing WindowNostr facade for non-extension methods (no extension detected)');
const nostrLiteInstance = window.NOSTR_LOGIN_LITE?._instance;
if (nostrLiteInstance && typeof nostrLiteInstance._installFacade === 'function') {
nostrLiteInstance._installFacade();
console.log('Modal: WindowNostr facade installed for non-extension methods');
}
}
// Emit auth method selection
const event = new CustomEvent('nlMethodSelected', {
detail: { method, ...options }
});
window.dispatchEvent(event);
this.close();
}
_showError(message) {
this.modalBody.innerHTML = '';
const errorDiv = document.createElement('div');
errorDiv.style.cssText = 'background: #fee2e2; color: #dc2626; padding: 16px; border-radius: 6px; margin-bottom: 16px;';
errorDiv.innerHTML = `<strong>Error:</strong> ${message}`;
const backButton = document.createElement('button');
backButton.textContent = 'Back';
backButton.onclick = () => this._renderLoginOptions();
backButton.style.cssText = this._getButtonStyle('secondary');
this.modalBody.appendChild(errorDiv);
this.modalBody.appendChild(backButton);
}
_showExtensionRequired() {
this.modalBody.innerHTML = '';
const title = document.createElement('h3');
title.textContent = 'Browser Extension Required';
title.style.cssText = 'margin: 0 0 16px 0; font-size: 18px; font-weight: 600;';
const message = document.createElement('p');
message.innerHTML = `
Please install a Nostr browser extension and refresh the page.<br><br>
<strong>Important:</strong> If you have multiple extensions installed, please disable all but one to avoid conflicts.
<br><br>
Popular extensions: Alby, nos2x, Flamingo
`;
message.style.cssText = 'margin-bottom: 20px; color: #6b7280; font-size: 14px; line-height: 1.4;';
const backButton = document.createElement('button');
backButton.textContent = 'Back';
backButton.onclick = () => this._renderLoginOptions();
backButton.style.cssText = this._getButtonStyle('secondary');
this.modalBody.appendChild(title);
this.modalBody.appendChild(message);
this.modalBody.appendChild(backButton);
}
_showConnectScreen() {
this.modalBody.innerHTML = '';
const description = document.createElement('p');
description.textContent = 'Connect to a remote signer (bunker) server to use its keys for signing.';
description.style.cssText = 'margin-bottom: 20px; color: #6b7280; font-size: 14px;';
const formGroup = document.createElement('div');
formGroup.style.cssText = 'margin-bottom: 20px;';
const label = document.createElement('label');
label.textContent = 'Bunker Public Key:';
label.style.cssText = 'display: block; margin-bottom: 8px; font-weight: 500;';
const pubkeyInput = document.createElement('input');
pubkeyInput.type = 'text';
pubkeyInput.placeholder = 'bunker://pubkey?relay=..., bunker:hex, hex, or npub...';
pubkeyInput.style.cssText = `
width: 100%;
padding: 12px;
border: 1px solid #d1d5db;
border-radius: 6px;
margin-bottom: 12px;
font-family: monospace;
box-sizing: border-box;
`;
// 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.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';
backButton.onclick = () => this._renderLoginOptions();
backButton.style.cssText = this._getButtonStyle('secondary') + 'margin-top: 12px;';
formGroup.appendChild(label);
formGroup.appendChild(pubkeyInput);
formGroup.appendChild(formatHint);
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');
return;
}
this._showNip46Connecting(bunkerPubkey);
this._performNip46Connect(bunkerPubkey);
}
_showNip46Connecting(bunkerPubkey) {
this.modalBody.innerHTML = '';
const title = document.createElement('h3');
title.textContent = 'Connecting to Remote Signer...';
title.style.cssText = 'margin: 0 0 16px 0; font-size: 18px; font-weight: 600; color: #059669;';
const description = document.createElement('p');
description.textContent = 'Establishing secure connection to your remote signer.';
description.style.cssText = 'margin-bottom: 20px; color: #6b7280;';
// Normalize bunker pubkey for display (= show original format if bunker: prefix)
const displayPubkey = bunkerPubkey.startsWith('bunker:') || bunkerPubkey.startsWith('npub') || bunkerPubkey.length === 64 ? bunkerPubkey : bunkerPubkey;
const bunkerInfo = document.createElement('div');
bunkerInfo.style.cssText = 'background: #f1f5f9; padding: 12px; border-radius: 6px; margin-bottom: 20px; font-size: 14px;';
bunkerInfo.innerHTML = `
<strong>Connecting to bunker:</strong><br>
Connection: <code style="word-break: break-all;">${displayPubkey}</code><br>
<small style="color: #6b7280;">Connection string contains all necessary relay information.</small>
`;
const connectingDiv = document.createElement('div');
connectingDiv.style.cssText = 'text-align: center; color: #6b7280;';
connectingDiv.innerHTML = `
<div style="font-size: 24px; margin-bottom: 10px;">⏳</div>
<div>Please wait while we establish the connection...</div>
<div style="font-size: 12px; margin-top: 10px;">This may take a few seconds</div>
`;
this.modalBody.appendChild(title);
this.modalBody.appendChild(description);
this.modalBody.appendChild(bunkerInfo);
this.modalBody.appendChild(connectingDiv);
}
async _performNip46Connect(bunkerPubkey) {
try {
console.log('Starting NIP-46 connection to bunker:', bunkerPubkey);
// Check if nostr-tools NIP-46 is available
if (!window.NostrTools?.nip46) {
throw new Error('nostr-tools NIP-46 module not available');
}
// Use nostr-tools to parse bunker input - this handles all formats correctly
console.log('Parsing bunker input with nostr-tools...');
const bunkerPointer = await window.NostrTools.nip46.parseBunkerInput(bunkerPubkey);
if (!bunkerPointer) {
throw new Error('Unable to parse bunker connection string or resolve NIP-05 identifier');
}
console.log('Parsed bunker pointer:', bunkerPointer);
// Create local client keypair for this session
const localSecretKey = window.NostrTools.generateSecretKey();
console.log('Generated local client keypair for NIP-46 session');
// Use nostr-tools BunkerSigner factory method (not constructor - it's private)
console.log('Creating nip46 BunkerSigner...');
const signer = window.NostrTools.nip46.BunkerSigner.fromBunker(localSecretKey, bunkerPointer, {
onauth: (url) => {
console.log('Received auth URL from bunker:', url);
// Open auth URL in popup or redirect
window.open(url, '_blank', 'width=600,height=800');
}
});
console.log('NIP-46 BunkerSigner created successfully');
// Skip ping test - NIP-46 works through relays, not direct connection
// Try to connect directly (this may trigger auth flow)
console.log('Attempting NIP-46 connect...');
await signer.connect();
console.log('NIP-46 connect successful');
// Get the user's public key from the bunker
console.log('Getting public key from bunker...');
const userPubkey = await signer.getPublicKey();
console.log('NIP-46 user public key:', userPubkey);
// Store the NIP-46 authentication info
const nip46Info = {
pubkey: userPubkey,
signer: {
method: 'nip46',
remotePubkey: bunkerPointer.pubkey,
bunkerSigner: signer,
secret: bunkerPointer.secret,
relays: bunkerPointer.relays
}
};
console.log('NOSTR_LOGIN_LITE NIP-46 connection established successfully!');
// Set as current auth method
this._setAuthMethod('nip46', nip46Info);
return;
} catch (error) {
console.error('NIP-46 connection failed:', error);
this._showNip46Error(error.message);
}
}
_showNip46Error(message) {
this.modalBody.innerHTML = '';
const title = document.createElement('h3');
title.textContent = 'Connection Failed';
title.style.cssText = 'margin: 0 0 16px 0; font-size: 18px; font-weight: 600; color: #dc2626;';
const errorMsg = document.createElement('p');
errorMsg.textContent = `Unable to connect to remote signer: ${message}`;
errorMsg.style.cssText = 'margin-bottom: 20px; color: #6b7280;';
const retryButton = document.createElement('button');
retryButton.textContent = 'Try Again';
retryButton.onclick = () => this._showConnectScreen();
retryButton.style.cssText = this._getButtonStyle();
const backButton = document.createElement('button');
backButton.textContent = 'Back to Options';
backButton.onclick = () => this._renderLoginOptions();
backButton.style.cssText = this._getButtonStyle('secondary') + 'margin-top: 12px;';
this.modalBody.appendChild(title);
this.modalBody.appendChild(errorMsg);
this.modalBody.appendChild(retryButton);
this.modalBody.appendChild(backButton);
}
_handleReadonly() {
// Set read-only mode
this._setAuthMethod('readonly');
}
_showSeedPhraseScreen() {
this.modalBody.innerHTML = '';
const description = document.createElement('p');
description.innerHTML = 'Enter your 12 or 24-word mnemonic seed phrase to derive Nostr accounts, or <span id="generate-new" style="text-decoration: underline; cursor: pointer; color: var(--nl-primary-color);">generate new</span>.';
description.style.cssText = 'margin-bottom: 12px; color: #6b7280; font-size: 14px;';
const textarea = document.createElement('textarea');
// Remove default placeholder text as requested
textarea.placeholder = '';
textarea.style.cssText = `
width: 100%;
height: 100px;
padding: 12px;
border: 1px solid #d1d5db;
border-radius: 6px;
margin-bottom: 12px;
resize: none;
font-family: monospace;
font-size: 14px;
box-sizing: border-box;
`;
// Add real-time mnemonic validation
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;
}
const isValid = this._validateMnemonic(value);
if (isValid) {
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';
}
};
const backButton = document.createElement('button');
backButton.textContent = 'Back';
backButton.onclick = () => this._renderLoginOptions();
backButton.style.cssText = this._getButtonStyle('secondary') + 'margin-top: 12px;';
this.modalBody.appendChild(description);
this.modalBody.appendChild(textarea);
this.modalBody.appendChild(formatHint);
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) {
try {
// Check if NIP-06 is available
if (!window.NostrTools?.nip06) {
throw new Error('NIP-06 not available in bundle');
}
// Generate a random 12-word mnemonic using NostrTools
const mnemonic = window.NostrTools.nip06.generateSeedWords();
// Set the generated mnemonic in the textarea
textarea.value = mnemonic;
// Trigger the oninput event to properly validate and enable the button
if (textarea.oninput) {
textarea.oninput();
}
console.log('Generated new seed phrase:', mnemonic.split(/\s+/).length, 'words');
} catch (error) {
console.error('Failed to generate seed phrase:', error);
formatHint.textContent = '❌ Failed to generate seed phrase - NIP-06 not available';
formatHint.style.color = '#dc2626';
}
}
_validateMnemonic(mnemonic) {
try {
// Check if NIP-06 is available
if (!window.NostrTools?.nip06) {
console.error('NIP-06 not available in bundle');
return false;
}
const words = mnemonic.trim().split(/\s+/);
// Must be 12 or 24 words
if (words.length !== 12 && words.length !== 24) {
return false;
}
// Try to validate using NostrTools nip06 - this will throw if invalid
window.NostrTools.nip06.privateKeyFromSeedWords(mnemonic, '', 0);
return true;
} catch (error) {
console.log('Mnemonic validation failed:', error.message);
return false;
}
}
_importFromSeedPhrase(mnemonic) {
try {
const trimmed = mnemonic.trim();
if (!trimmed) {
throw new Error('Please enter a mnemonic seed phrase');
}
// Validate the mnemonic
if (!this._validateMnemonic(trimmed)) {
throw new Error('Invalid mnemonic. Please enter a valid 12 or 24-word BIP-39 seed phrase');
}
// Generate accounts 0-5 using NIP-06
const accounts = [];
for (let i = 0; i < 6; i++) {
try {
const privateKey = window.NostrTools.nip06.privateKeyFromSeedWords(trimmed, '', i);
const publicKey = window.NostrTools.getPublicKey(privateKey);
const nsec = window.NostrTools.nip19.nsecEncode(privateKey);
const npub = window.NostrTools.nip19.npubEncode(publicKey);
accounts.push({
index: i,
privateKey,
publicKey,
nsec,
npub
});
} catch (error) {
console.error(`Failed to derive account ${i}:`, error);
}
}
if (accounts.length === 0) {
throw new Error('Failed to derive any accounts from seed phrase');
}
console.log(`Successfully derived ${accounts.length} accounts from seed phrase`);
this._showAccountSelection(accounts);
} catch (error) {
console.error('Seed phrase import failed:', error);
this._showError('Seed phrase import failed: ' + error.message);
}
}
_showAccountSelection(accounts) {
this.modalBody.innerHTML = '';
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(description);
// Create table for account selection
const table = document.createElement('table');
table.style.cssText = `
width: 100%;
border-collapse: collapse;
margin-bottom: 20px;
font-family: var(--nl-font-family, 'Courier New', monospace);
font-size: 12px;
`;
// Table header
const thead = document.createElement('thead');
thead.innerHTML = `
<tr style="background: #f3f4f6;">
<th style="padding: 8px; text-align: center; border: 1px solid #d1d5db; font-weight: bold;">#</th>
<th style="padding: 8px; text-align: center; border: 1px solid #d1d5db; font-weight: bold;">Use</th>
</tr>
`;
table.appendChild(thead);
// Table body
const tbody = document.createElement('tbody');
accounts.forEach(account => {
const row = document.createElement('tr');
row.style.cssText = 'border: 1px solid #d1d5db;';
const indexCell = document.createElement('td');
indexCell.textContent = account.index;
indexCell.style.cssText = 'padding: 8px; text-align: center; border: 1px solid #d1d5db; font-weight: bold;';
const actionCell = document.createElement('td');
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 = truncatedNpub;
selectButton.onclick = () => this._selectAccount(account);
selectButton.style.cssText = `
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: 'Courier New', monospace;
text-align: center;
`;
selectButton.onmouseover = () => {
selectButton.style.borderColor = 'var(--nl-accent-color)';
};
selectButton.onmouseout = () => {
selectButton.style.borderColor = 'var(--nl-primary-color)';
};
actionCell.appendChild(selectButton);
row.appendChild(indexCell);
row.appendChild(actionCell);
tbody.appendChild(row);
});
table.appendChild(tbody);
this.modalBody.appendChild(table);
// Back button
const backButton = document.createElement('button');
backButton.textContent = 'Back to Seed Phrase';
backButton.onclick = () => this._showSeedPhraseScreen();
backButton.style.cssText = this._getButtonStyle('secondary');
this.modalBody.appendChild(backButton);
}
_selectAccount(account) {
console.log('Selected account:', account.index, account.npub);
// Use the same auth method as local keys, but with seedphrase identifier
this._setAuthMethod('local', {
secret: account.nsec,
pubkey: account.publicKey,
source: 'seedphrase',
accountIndex: account.index
});
}
_showOtpScreen() {
// Placeholder for OTP functionality
this._showError('OTP/DM not yet implemented - coming soon!');
}
_getButtonStyle(type = 'primary') {
const baseStyle = `
display: block;
width: 100%;
padding: 12px;
border: var(--nl-border-width) solid var(--nl-primary-color);
border-radius: var(--nl-border-radius);
font-size: 16px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
font-family: var(--nl-font-family, 'Courier New', monospace);
`;
if (type === 'primary') {
return baseStyle + `
background: var(--nl-secondary-color);
color: var(--nl-primary-color);
`;
} else {
return baseStyle + `
background: #cccccc;
color: var(--nl-primary-color);
`;
}
}
// Public API
static init(options) {
if (Modal.instance) return Modal.instance;
Modal.instance = new Modal(options);
return Modal.instance;
}
static getInstance() {
return Modal.instance;
}
}
// Initialize global instance
let modalInstance = null;
window.addEventListener('load', () => {
modalInstance = new Modal();
});
// ======================================
// FloatingTab Component (Recovered from git history)
// ======================================
class FloatingTab {
constructor(modal, options = {}) {
this.modal = modal;
this.options = {
enabled: true,
hPosition: 1.0, // 0.0 = left, 1.0 = right
vPosition: 0.5, // 0.0 = top, 1.0 = bottom
offset: { x: 0, y: 0 },
appearance: {
style: 'pill', // 'pill', 'square', 'circle'
theme: 'auto', // 'auto', 'light', 'dark'
icon: '',
text: 'Login',
iconOnly: false
},
behavior: {
hideWhenAuthenticated: true,
showUserInfo: true,
autoSlide: true,
persistent: false
},
getUserInfo: false,
getUserRelay: [],
...options
};
this.userProfile = null;
this.container = null;
this.isVisible = false;
if (this.options.enabled) {
this._init();
}
}
_init() {
console.log('FloatingTab: Initializing with options:', this.options);
this._createContainer();
this._setupEventListeners();
this._updateAppearance();
this._position();
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');
if (existingTab) {
existingTab.remove();
}
this.container = document.createElement('div');
this.container.id = 'nl-floating-tab';
this.container.className = 'nl-floating-tab';
// Base styles - positioning and behavior
this.container.style.cssText = `
position: fixed;
z-index: 9999;
cursor: pointer;
user-select: none;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s ease;
font-size: 14px;
font-weight: 500;
padding: 8px 16px;
min-width: 80px;
max-width: 200px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
`;
document.body.appendChild(this.container);
}
_setupEventListeners() {
if (!this.container) return;
// Click handler
this.container.addEventListener('click', (e) => {
e.preventDefault();
e.stopPropagation();
this._handleClick();
});
// Hover effects
this.container.addEventListener('mouseenter', () => {
if (this.options.behavior.autoSlide) {
this._slideIn();
}
});
this.container.addEventListener('mouseleave', () => {
if (this.options.behavior.autoSlide) {
this._slideOut();
}
});
// Listen for authentication events
window.addEventListener('nlMethodSelected', (e) => {
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 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');
const authState = this._getAuthState();
if (authState && this.options.behavior.showUserInfo) {
// Show user menu or profile options
this._showUserMenu();
} else {
// 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' });
}
}
}
async _handleAuth(authData) {
console.log('🔍 FloatingTab: === _handleAuth START ===');
console.log('🔍 FloatingTab: authData received:', authData);
// 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
console.log('🔍 FloatingTab: === _handleAuth END ===');
}
_handleLogout() {
console.log('FloatingTab: Handling logout');
this.userProfile = null;
if (this.options.behavior.hideWhenAuthenticated) {
this.show();
}
this._updateAppearance();
}
_showUserMenu() {
// Simple user menu - could be expanded
const menu = document.createElement('div');
menu.style.cssText = `
position: fixed;
background: var(--nl-secondary-color);
border: var(--nl-border-width) solid var(--nl-primary-color);
border-radius: var(--nl-border-radius);
padding: 12px;
z-index: 10000;
font-family: var(--nl-font-family);
box-shadow: 0 4px 12px rgba(0,0,0,0.15);
`;
// Position near the floating tab
const tabRect = this.container.getBoundingClientRect();
if (this.options.hPosition > 0.5) {
// Tab is on right side, show menu to the left
menu.style.right = (window.innerWidth - tabRect.left) + 'px';
} else {
// Tab is on left side, show menu to the right
menu.style.left = tabRect.right + 'px';
}
menu.style.top = tabRect.top + 'px';
// 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 = `
<div style="margin-bottom: 8px; font-weight: bold; color: var(--nl-primary-color);">${userDisplay}</div>
<button onclick="window.NOSTR_LOGIN_LITE.logout(); this.parentElement.remove();"
style="background: var(--nl-secondary-color); color: var(--nl-primary-color);
border: 1px solid var(--nl-primary-color); border-radius: 4px;
padding: 6px 12px; cursor: pointer; width: 100%;">
Logout
</button>
`;
document.body.appendChild(menu);
// Auto-remove menu after delay or on outside click
const removeMenu = () => menu.remove();
setTimeout(removeMenu, 5000);
document.addEventListener('click', function onOutsideClick(e) {
if (!menu.contains(e.target) && e.target !== this.container) {
removeMenu();
document.removeEventListener('click', onOutsideClick);
}
});
}
_updateAppearance() {
if (!this.container) return;
// Query authoritative source for all state information
const authState = this._getAuthState();
const isAuthenticated = authState !== null;
// Update content
if (isAuthenticated && this.options.behavior.showUserInfo) {
let display;
// Use profile name if available, otherwise fall back to pubkey
if (this.userProfile?.name || this.userProfile?.display_name) {
const userName = this.userProfile.name || this.userProfile.display_name;
display = this.options.appearance.iconOnly
? userName.slice(0, 8)
: userName;
} else if (authState?.pubkey) {
// Fallback to pubkey display
display = this.options.appearance.iconOnly
? authState.pubkey.slice(0, 6)
: `${authState.pubkey.slice(0, 6)}...`;
} else {
display = this.options.appearance.iconOnly ? 'User' : 'Authenticated';
}
this.container.textContent = display;
this.container.className = 'nl-floating-tab nl-floating-tab--logged-in';
} else {
const display = this.options.appearance.iconOnly ?
this.options.appearance.icon :
(this.options.appearance.icon ? `${this.options.appearance.icon} ${this.options.appearance.text}` : this.options.appearance.text);
this.container.textContent = display;
this.container.className = 'nl-floating-tab nl-floating-tab--logged-out';
}
// Apply appearance styles based on current state
this._applyThemeStyles();
}
_applyThemeStyles() {
if (!this.container) return;
// The CSS classes will handle the theming through CSS custom properties
// Additional style customizations can be added here if needed
// Apply style variant
if (this.options.appearance.style === 'circle') {
this.container.style.borderRadius = '50%';
this.container.style.width = '48px';
this.container.style.height = '48px';
this.container.style.minWidth = '48px';
this.container.style.padding = '0';
} else if (this.options.appearance.style === 'square') {
this.container.style.borderRadius = '4px';
} else {
// pill style (default)
this.container.style.borderRadius = 'var(--nl-border-radius)';
}
}
async _fetchUserProfile(pubkey) {
if (!this.options.getUserInfo) {
console.log('FloatingTab: getUserInfo disabled, skipping profile fetch');
return null;
}
// Determine which relays to use
const relays = this.options.getUserRelay.length > 0
? this.options.getUserRelay
: ['wss://relay.damus.io', 'wss://nos.lol'];
console.log('FloatingTab: Fetching profile from relays:', relays);
try {
// Create a SimplePool instance for querying
const pool = new window.NostrTools.SimplePool();
// Query for kind 0 (user metadata) events
const events = await pool.querySync(relays, {
kinds: [0],
authors: [pubkey],
limit: 1
}, { timeout: 5000 });
console.log('FloatingTab: Profile query returned', events.length, 'events');
if (events.length === 0) {
console.log('FloatingTab: No profile events found');
return null;
}
// Get the most recent event
const latestEvent = events.sort((a, b) => b.created_at - a.created_at)[0];
try {
const profile = JSON.parse(latestEvent.content);
console.log('FloatingTab: Parsed profile:', profile);
// Find the best name from any key containing "name" (case-insensitive)
let bestName = null;
const nameKeys = Object.keys(profile).filter(key =>
key.toLowerCase().includes('name') &&
typeof profile[key] === 'string' &&
profile[key].trim().length > 0
);
if (nameKeys.length > 0) {
// Find the shortest name value
bestName = nameKeys
.map(key => profile[key].trim())
.reduce((shortest, current) =>
current.length < shortest.length ? current : shortest
);
console.log('FloatingTab: Found name keys:', nameKeys, 'selected:', bestName);
}
// Return relevant profile fields with the best name
return {
name: bestName,
display_name: profile.display_name || null,
about: profile.about || null,
picture: profile.picture || null,
nip05: profile.nip05 || null
};
} catch (parseError) {
console.warn('FloatingTab: Failed to parse profile JSON:', parseError);
return null;
}
} catch (error) {
console.error('FloatingTab: Profile fetch error:', error);
return null;
}
}
_position() {
if (!this.container) return;
const padding = 16; // Distance from screen edge
// Calculate position based on percentage
const x = this.options.hPosition * (window.innerWidth - this.container.offsetWidth - padding * 2) + padding + this.options.offset.x;
const y = this.options.vPosition * (window.innerHeight - this.container.offsetHeight - padding * 2) + padding + this.options.offset.y;
this.container.style.left = `${x}px`;
this.container.style.top = `${y}px`;
console.log(`FloatingTab: Positioned at (${x}, ${y})`);
}
_slideIn() {
if (!this.container || !this.options.behavior.autoSlide) return;
// Slide towards center slightly
const currentTransform = this.container.style.transform || '';
if (this.options.hPosition > 0.5) {
this.container.style.transform = currentTransform + ' translateX(-8px)';
} else {
this.container.style.transform = currentTransform + ' translateX(8px)';
}
}
_slideOut() {
if (!this.container || !this.options.behavior.autoSlide) return;
// Reset position
this.container.style.transform = '';
}
show() {
if (!this.container) return;
this.container.style.display = 'flex';
this.isVisible = true;
console.log('FloatingTab: Shown');
}
hide() {
if (!this.container) return;
this.container.style.display = 'none';
this.isVisible = false;
console.log('FloatingTab: Hidden');
}
destroy() {
if (this.container) {
this.container.remove();
this.container = null;
}
this.isVisible = false;
console.log('FloatingTab: Destroyed');
}
// Update options and re-apply
updateOptions(newOptions) {
this.options = { ...this.options, ...newOptions };
if (this.container) {
this._updateAppearance();
this._position();
}
}
// Get current state
getState() {
const authState = this._getAuthState();
return {
isVisible: this.isVisible,
isAuthenticated: !!authState,
userInfo: authState,
options: this.options
};
}
}
// ======================================
// Main NOSTR_LOGIN_LITE Library
// ======================================
// Extension Bridge for managing browser extensions
class ExtensionBridge {
constructor() {
this.extensions = new Map();
this.primaryExtension = null;
this._detectExtensions();
}
_detectExtensions() {
// Common extension locations
const locations = [
{ path: 'window.nostr', name: 'Generic' },
{ path: 'window.alby?.nostr', name: 'Alby' },
{ path: 'window.nos2x?.nostr', name: 'nos2x' },
{ path: 'window.flamingo?.nostr', name: 'Flamingo' },
{ path: 'window.getAlby?.nostr', name: 'Alby Legacy' },
{ path: 'window.mutiny?.nostr', name: 'Mutiny' }
];
for (const location of locations) {
try {
const obj = eval(location.path);
if (obj && typeof obj.getPublicKey === 'function') {
this.extensions.set(location.name, {
name: location.name,
extension: obj,
constructor: obj.constructor?.name || 'Unknown'
});
if (!this.primaryExtension) {
this.primaryExtension = this.extensions.get(location.name);
}
}
} catch (e) {
// Extension not available
}
}
}
getAllExtensions() {
return Array.from(this.extensions.values());
}
getExtensionCount() {
return this.extensions.size;
}
}
// Main NostrLite class
class NostrLite {
constructor() {
this.options = {};
this.extensionBridge = new ExtensionBridge();
this.initialized = false;
this.currentTheme = 'default';
this.modal = null;
this.floatingTab = null;
}
async init(options = {}) {
console.log('NOSTR_LOGIN_LITE: Initializing with options:', options);
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,
seedphrase: false,
readonly: true,
connect: false,
otp: false
},
floatingTab: {
enabled: false,
hPosition: 1.0,
vPosition: 0.5,
offset: { x: 0, y: 0 },
appearance: {
style: 'pill',
theme: 'auto',
icon: '',
text: 'Login',
iconOnly: false
},
behavior: {
hideWhenAuthenticated: true,
showUserInfo: true,
autoSlide: true,
persistent: false
},
getUserInfo: false,
getUserRelay: []
},
...options
};
// Apply the selected theme (CSS-only)
this.switchTheme(this.options.theme);
// Always set up window.nostr facade to handle multiple extensions properly
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);
console.log('NOSTR_LOGIN_LITE: Modal created during init');
// Initialize floating tab if enabled
if (this.options.floatingTab.enabled) {
this.floatingTab = new FloatingTab(this.modal, this.options.floatingTab);
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;
}
async _setupWindowNostrFacade() {
if (typeof window !== 'undefined') {
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);
// 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
}
// 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');
this.hasExtension = false;
this._installFacade(window.nostr); // Install facade with any existing nostr object
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);
const facade = new WindowNostr(this, existingNostr, { isolateSession: this.options.isolateSession });
window.nostr = facade;
this.facadeInstalled = true;
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);
}
}
// Conservative method to identify real browser extensions
_isRealExtension(obj) {
console.log('NOSTR_LOGIN_LITE: === _isRealExtension (Conservative) ===');
console.log('NOSTR_LOGIN_LITE: obj:', obj);
console.log('NOSTR_LOGIN_LITE: typeof obj:', typeof obj);
if (!obj || typeof obj !== 'object') {
console.log('NOSTR_LOGIN_LITE: ✗ Not an object');
return false;
}
// Must have required Nostr methods
if (typeof obj.getPublicKey !== 'function' || typeof obj.signEvent !== 'function') {
console.log('NOSTR_LOGIN_LITE: ✗ Missing required NIP-07 methods');
return false;
}
// Exclude our own library classes
const constructorName = obj.constructor?.name;
console.log('NOSTR_LOGIN_LITE: Constructor name:', constructorName);
if (constructorName === 'WindowNostr' || constructorName === 'NostrLite') {
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 - NOT an extension');
return false;
}
// 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 hasIndicators = extensionIndicators.some(prop => obj.hasOwnProperty(prop));
// Additional check: Extensions often have specific constructor patterns
const hasExtensionConstructor = constructorName &&
constructorName !== 'Object' &&
constructorName !== 'Function';
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') {
console.log('NOSTR_LOGIN_LITE: Launching with screen:', startScreen);
if (this.modal) {
this.modal.open({ startScreen });
} else {
console.error('NOSTR_LOGIN_LITE: Modal not initialized - call init() first');
}
}
// 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 legacy stored data
if (typeof localStorage !== 'undefined') {
localStorage.removeItem('nl_current');
}
// 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() }
}));
}
}
// CSS-only theme switching
switchTheme(themeName) {
console.log(`NOSTR_LOGIN_LITE: Switching to ${themeName} theme`);
if (THEME_CSS[themeName]) {
injectThemeCSS(themeName);
this.currentTheme = themeName;
// Dispatch theme change event
if (typeof window !== 'undefined') {
window.dispatchEvent(new CustomEvent('nlThemeChanged', {
detail: { theme: themeName }
}));
}
return { theme: themeName };
} else {
console.warn(`Theme '${themeName}' not found, using default`);
injectThemeCSS('default');
this.currentTheme = 'default';
return { theme: 'default' };
}
}
getCurrentTheme() {
return this.currentTheme;
}
getAvailableThemes() {
return Object.keys(THEME_CSS);
}
embed(container, options = {}) {
console.log('NOSTR_LOGIN_LITE: Creating embedded modal in container:', container);
const embedOptions = {
...this.options,
...options,
embedded: container
};
// Create new modal instance for embedding
const embeddedModal = new Modal(embedOptions);
embeddedModal.open();
return embeddedModal;
}
// Floating tab management methods
showFloatingTab() {
if (this.floatingTab) {
this.floatingTab.show();
} else {
console.warn('NOSTR_LOGIN_LITE: Floating tab not enabled');
}
}
hideFloatingTab() {
if (this.floatingTab) {
this.floatingTab.hide();
}
}
toggleFloatingTab() {
if (this.floatingTab) {
if (this.floatingTab.isVisible) {
this.floatingTab.hide();
} else {
this.floatingTab.show();
}
}
}
updateFloatingTab(options) {
if (this.floatingTab) {
this.floatingTab.updateOptions(options);
}
}
getFloatingTabState() {
return this.floatingTab ? this.floatingTab.getState() : null;
}
}
// ======================================
// 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) {
this.nostrLite = nostrLite;
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', async (event) => {
this.authState = event.detail;
// If extension method, capture the specific extension the user chose
if (event.detail.method === 'extension') {
this.authenticatedExtension = event.detail.extension;
console.log('WindowNostr: Captured authenticated extension:', this.authenticatedExtension?.constructor?.name);
}
// 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);
});
window.addEventListener('nlLogout', () => {
this.authState = null;
this.authenticatedExtension = null;
// 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()');
}
switch (this.authState.method) {
case 'extension':
// Use the captured authenticated extension, not current window.nostr
const ext = this.authenticatedExtension || this.authState.extension || this.existingNostr;
if (!ext) throw new Error('Extension not available');
return await ext.getPublicKey();
case 'local':
case 'nip46':
return this.authState.pubkey;
case 'readonly':
throw new Error('Read-only mode - cannot get public key');
default:
throw new Error(`Unsupported auth method: ${this.authState.method}`);
}
}
async signEvent(event) {
if (!this.authState) {
throw new Error('Not authenticated - use NOSTR_LOGIN_LITE.launch()');
}
if (this.authState.method === 'readonly') {
throw new Error('Read-only mode - cannot sign events');
}
switch (this.authState.method) {
case 'extension':
// Use the captured authenticated extension, not current window.nostr
console.log('WindowNostr: signEvent - authenticatedExtension:', this.authenticatedExtension);
console.log('WindowNostr: signEvent - authState.extension:', this.authState.extension);
console.log('WindowNostr: signEvent - existingNostr:', this.existingNostr);
const ext = this.authenticatedExtension || this.authState.extension || this.existingNostr;
console.log('WindowNostr: signEvent - using extension:', ext);
console.log('WindowNostr: signEvent - extension constructor:', ext?.constructor?.name);
if (!ext) throw new Error('Extension not available');
return await ext.signEvent(event);
case 'local': {
// Use nostr-tools to sign with local secret key
const { nip19, finalizeEvent } = window.NostrTools;
let secretKey;
if (this.authState.secret.startsWith('nsec')) {
const decoded = nip19.decode(this.authState.secret);
secretKey = decoded.data;
} else {
// Convert hex to Uint8Array
secretKey = this._hexToUint8Array(this.authState.secret);
}
return finalizeEvent(event, secretKey);
}
case 'nip46': {
// Use BunkerSigner for NIP-46
if (!this.authState.signer?.bunkerSigner) {
throw new Error('NIP-46 signer not available');
}
return await this.authState.signer.bunkerSigner.signEvent(event);
}
default:
throw new Error(`Unsupported auth method: ${this.authState.method}`);
}
}
async getRelays() {
// Return default relays since we removed the relays configuration
return ['wss://relay.damus.io', 'wss://nos.lol'];
}
get nip04() {
return {
encrypt: async (pubkey, plaintext) => {
if (!this.authState) {
throw new Error('Not authenticated - use NOSTR_LOGIN_LITE.launch()');
}
if (this.authState.method === 'readonly') {
throw new Error('Read-only mode - cannot encrypt');
}
switch (this.authState.method) {
case 'extension': {
const ext = this.authenticatedExtension || this.authState.extension || this.existingNostr;
if (!ext) throw new Error('Extension not available');
return await ext.nip04.encrypt(pubkey, plaintext);
}
case 'local': {
const { nip04, nip19 } = window.NostrTools;
let secretKey;
if (this.authState.secret.startsWith('nsec')) {
const decoded = nip19.decode(this.authState.secret);
secretKey = decoded.data;
} else {
secretKey = this._hexToUint8Array(this.authState.secret);
}
return await nip04.encrypt(secretKey, pubkey, plaintext);
}
case 'nip46': {
if (!this.authState.signer?.bunkerSigner) {
throw new Error('NIP-46 signer not available');
}
return await this.authState.signer.bunkerSigner.nip04Encrypt(pubkey, plaintext);
}
default:
throw new Error(`Unsupported auth method: ${this.authState.method}`);
}
},
decrypt: async (pubkey, ciphertext) => {
if (!this.authState) {
throw new Error('Not authenticated - use NOSTR_LOGIN_LITE.launch()');
}
if (this.authState.method === 'readonly') {
throw new Error('Read-only mode - cannot decrypt');
}
switch (this.authState.method) {
case 'extension': {
const ext = this.authenticatedExtension || this.authState.extension || this.existingNostr;
if (!ext) throw new Error('Extension not available');
return await ext.nip04.decrypt(pubkey, ciphertext);
}
case 'local': {
const { nip04, nip19 } = window.NostrTools;
let secretKey;
if (this.authState.secret.startsWith('nsec')) {
const decoded = nip19.decode(this.authState.secret);
secretKey = decoded.data;
} else {
secretKey = this._hexToUint8Array(this.authState.secret);
}
return await nip04.decrypt(secretKey, pubkey, ciphertext);
}
case 'nip46': {
if (!this.authState.signer?.bunkerSigner) {
throw new Error('NIP-46 signer not available');
}
return await this.authState.signer.bunkerSigner.nip04Decrypt(pubkey, ciphertext);
}
default:
throw new Error(`Unsupported auth method: ${this.authState.method}`);
}
}
};
}
get nip44() {
return {
encrypt: async (pubkey, plaintext) => {
const authState = getAuthState();
if (!authState) {
throw new Error('Not authenticated - use NOSTR_LOGIN_LITE.launch()');
}
if (authState.method === 'readonly') {
throw new Error('Read-only mode - cannot encrypt');
}
switch (authState.method) {
case 'extension': {
const ext = this.authenticatedExtension || authState.extension || this.existingNostr;
if (!ext) throw new Error('Extension not available');
return await ext.nip44.encrypt(pubkey, plaintext);
}
case 'local': {
const { nip44, nip19 } = window.NostrTools;
let secretKey;
if (authState.secret.startsWith('nsec')) {
const decoded = nip19.decode(authState.secret);
secretKey = decoded.data;
} else {
secretKey = this._hexToUint8Array(authState.secret);
}
return nip44.encrypt(plaintext, nip44.getConversationKey(secretKey, pubkey));
}
case 'nip46': {
if (!authState.signer?.bunkerSigner) {
throw new Error('NIP-46 signer not available');
}
return await authState.signer.bunkerSigner.nip44Encrypt(pubkey, plaintext);
}
default:
throw new Error('Unsupported auth method: ' + authState.method);
}
},
decrypt: async (pubkey, ciphertext) => {
const authState = getAuthState();
if (!authState) {
throw new Error('Not authenticated - use NOSTR_LOGIN_LITE.launch()');
}
if (authState.method === 'readonly') {
throw new Error('Read-only mode - cannot decrypt');
}
switch (authState.method) {
case 'extension': {
const ext = this.authenticatedExtension || authState.extension || this.existingNostr;
if (!ext) throw new Error('Extension not available');
return await ext.nip44.decrypt(pubkey, ciphertext);
}
case 'local': {
const { nip44, nip19 } = window.NostrTools;
let secretKey;
if (authState.secret.startsWith('nsec')) {
const decoded = nip19.decode(authState.secret);
secretKey = decoded.data;
} else {
secretKey = this._hexToUint8Array(authState.secret);
}
return nip44.decrypt(ciphertext, nip44.getConversationKey(secretKey, pubkey));
}
case 'nip46': {
if (!authState.signer?.bunkerSigner) {
throw new Error('NIP-46 signer not available');
}
return await authState.signer.bunkerSigner.nip44Decrypt(pubkey, ciphertext);
}
default:
throw new Error('Unsupported auth method: ' + authState.method);
}
}
};
}
_hexToUint8Array(hex) {
if (hex.length % 2 !== 0) {
throw new Error('Invalid hex string length');
}
const bytes = new Uint8Array(hex.length / 2);
for (let i = 0; i < bytes.length; i++) {
bytes[i] = parseInt(hex.substr(i * 2, 2), 16);
}
return bytes;
}
}
// ======================================
// 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();
// Export main API
window.NOSTR_LOGIN_LITE = {
init: (options) => nostrLite.init(options),
launch: (startScreen) => nostrLite.launch(startScreen),
logout: () => nostrLite.logout(),
// Embedded modal method
embed: (container, options) => nostrLite.embed(container, options),
// CSS-only theme management API
switchTheme: (themeName) => nostrLite.switchTheme(themeName),
getCurrentTheme: () => nostrLite.getCurrentTheme(),
getAvailableThemes: () => nostrLite.getAvailableThemes(),
// Floating tab management API
showFloatingTab: () => nostrLite.showFloatingTab(),
hideFloatingTab: () => nostrLite.hideFloatingTab(),
toggleFloatingTab: () => nostrLite.toggleFloatingTab(),
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
};
console.log('NOSTR_LOGIN_LITE: Library loaded and ready');
console.log('NOSTR_LOGIN_LITE: Use window.NOSTR_LOGIN_LITE.init(options) to initialize');
console.log('NOSTR_LOGIN_LITE: Detected', nostrLite.extensionBridge.getExtensionCount(), 'browser extensions');
} else {
// Node.js environment
module.exports = { NostrLite };
}