/**
* 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 = '
🔄 Connecting to extension...
';
// Get pubkey from extension
const pubkey = await extensionObj.getPublicKey();
console.log('Extension provided pubkey:', pubkey);
// Set extension method with the extension object
this._setAuthMethod('extension', { pubkey, extension: extensionObj });
} catch (error) {
console.error('Extension login failed:', error);
this._showError(`Extension login failed: ${error.message}`);
}
}
_showLocalKeyScreen() {
this.modalBody.innerHTML = '';
const title = document.createElement('h3');
title.textContent = 'Local Key';
title.style.cssText = 'margin: 0 0 20px 0; font-size: 18px; font-weight: 600;';
const createButton = document.createElement('button');
createButton.textContent = 'Create New Key';
createButton.onclick = () => this._createLocalKey();
createButton.style.cssText = this._getButtonStyle();
const importButton = document.createElement('button');
importButton.textContent = 'Import Existing Key';
importButton.onclick = () => this._showImportKeyForm();
importButton.style.cssText = this._getButtonStyle() + 'margin-top: 12px;';
const backButton = document.createElement('button');
backButton.textContent = 'Back';
backButton.onclick = () => this._renderLoginOptions();
backButton.style.cssText = `
display: block;
margin-top: 20px;
padding: 12px;
background: #6b7280;
color: white;
border: none;
border-radius: 6px;
cursor: pointer;
`;
this.modalBody.appendChild(title);
this.modalBody.appendChild(createButton);
this.modalBody.appendChild(importButton);
this.modalBody.appendChild(backButton);
}
_createLocalKey() {
try {
const sk = window.NostrTools.generateSecretKey();
const pk = window.NostrTools.getPublicKey(sk);
const nsec = window.NostrTools.nip19.nsecEncode(sk);
const npub = window.NostrTools.nip19.npubEncode(pk);
this._showKeyDisplay(pk, nsec, 'created');
} catch (error) {
this._showError('Failed to create key: ' + error.message);
}
}
_showImportKeyForm() {
this.modalBody.innerHTML = '';
const title = document.createElement('h3');
title.textContent = 'Import Local Key';
title.style.cssText = 'margin: 0 0 16px 0; font-size: 18px; font-weight: 600;';
const description = document.createElement('p');
description.textContent = 'Enter your secret key in either nsec or hex format:';
description.style.cssText = 'margin-bottom: 12px; color: #6b7280; font-size: 14px;';
const textarea = document.createElement('textarea');
textarea.placeholder = 'Enter your secret key:\n• nsec1... (bech32 format)\n• 64-character hex string';
textarea.style.cssText = `
width: 100%;
height: 100px;
padding: 12px;
border: 1px solid #d1d5db;
border-radius: 6px;
margin-bottom: 12px;
resize: none;
font-family: monospace;
font-size: 14px;
box-sizing: border-box;
`;
// Add real-time format detection
const formatHint = document.createElement('div');
formatHint.style.cssText = 'margin-bottom: 16px; font-size: 12px; color: #6b7280; min-height: 16px;';
textarea.oninput = () => {
const value = textarea.value.trim();
if (!value) {
formatHint.textContent = '';
return;
}
const format = this._detectKeyFormat(value);
if (format === 'nsec') {
formatHint.textContent = '✅ Valid nsec format detected';
formatHint.style.color = '#059669';
} else if (format === 'hex') {
formatHint.textContent = '✅ Valid hex format detected';
formatHint.style.color = '#059669';
} else {
formatHint.textContent = '❌ Invalid key format - must be nsec1... or 64-character hex';
formatHint.style.color = '#dc2626';
}
};
const importButton = document.createElement('button');
importButton.textContent = 'Import Key';
importButton.onclick = () => this._importLocalKey(textarea.value);
importButton.style.cssText = this._getButtonStyle();
const backButton = document.createElement('button');
backButton.textContent = 'Back';
backButton.onclick = () => this._showLocalKeyScreen();
backButton.style.cssText = this._getButtonStyle('secondary') + 'margin-top: 12px;';
this.modalBody.appendChild(title);
this.modalBody.appendChild(description);
this.modalBody.appendChild(textarea);
this.modalBody.appendChild(formatHint);
this.modalBody.appendChild(importButton);
this.modalBody.appendChild(backButton);
}
_detectKeyFormat(keyValue) {
const trimmed = keyValue.trim();
// Check for nsec format
if (trimmed.startsWith('nsec1') && trimmed.length === 63) {
try {
window.NostrTools.nip19.decode(trimmed);
return 'nsec';
} catch {
return 'invalid';
}
}
// Check for hex format (64 characters, valid hex)
if (trimmed.length === 64 && /^[a-fA-F0-9]{64}$/.test(trimmed)) {
return 'hex';
}
return 'invalid';
}
_importLocalKey(keyValue) {
try {
const trimmed = keyValue.trim();
if (!trimmed) {
throw new Error('Please enter a secret key');
}
const format = this._detectKeyFormat(trimmed);
let sk;
if (format === 'nsec') {
// Decode nsec format - this returns Uint8Array
const decoded = window.NostrTools.nip19.decode(trimmed);
if (decoded.type !== 'nsec') {
throw new Error('Invalid nsec format');
}
sk = decoded.data; // This is already Uint8Array
} else if (format === 'hex') {
// Convert hex string to Uint8Array
sk = this._hexToUint8Array(trimmed);
// Test that it's a valid secret key by trying to get public key
window.NostrTools.getPublicKey(sk);
} else {
throw new Error('Invalid key format. Please enter either nsec1... or 64-character hex string');
}
// Generate public key and encoded formats
const pk = window.NostrTools.getPublicKey(sk);
const nsec = window.NostrTools.nip19.nsecEncode(sk);
const npub = window.NostrTools.nip19.npubEncode(pk);
this._showKeyDisplay(pk, nsec, 'imported');
} catch (error) {
this._showError('Invalid key: ' + error.message);
}
}
_hexToUint8Array(hex) {
// Convert hex string to Uint8Array
if (hex.length % 2 !== 0) {
throw new Error('Invalid hex string length');
}
const bytes = new Uint8Array(hex.length / 2);
for (let i = 0; i < bytes.length; i++) {
bytes[i] = parseInt(hex.substr(i * 2, 2), 16);
}
return bytes;
}
_showKeyDisplay(pubkey, nsec, action) {
this.modalBody.innerHTML = '';
const title = document.createElement('h3');
title.textContent = `Key ${action} successfully!`;
title.style.cssText = 'margin: 0 0 16px 0; font-size: 18px; font-weight: 600; color: #059669;';
const warningDiv = document.createElement('div');
warningDiv.textContent = '⚠️ Save your secret key securely!';
warningDiv.style.cssText = 'background: #fef3c7; color: #92400e; padding: 12px; border-radius: 6px; margin-bottom: 16px; font-size: 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 = 'Your Secret Key (nsec):';
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 = 'Secret Key (hex):';
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 = 'Your Public Key (npub):';
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 = 'Public Key (hex):';
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 = `Error: ${message}`;
const backButton = document.createElement('button');
backButton.textContent = 'Back';
backButton.onclick = () => this._renderLoginOptions();
backButton.style.cssText = this._getButtonStyle('secondary');
this.modalBody.appendChild(errorDiv);
this.modalBody.appendChild(backButton);
}
_showExtensionRequired() {
this.modalBody.innerHTML = '';
const title = document.createElement('h3');
title.textContent = 'Browser Extension Required';
title.style.cssText = 'margin: 0 0 16px 0; font-size: 18px; font-weight: 600;';
const message = document.createElement('p');
message.innerHTML = `
Please install a Nostr browser extension and refresh the page.
Important: If you have multiple extensions installed, please disable all but one to avoid conflicts.
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 = `
Connecting to bunker:
Connection: ${displayPubkey}
Connection string contains all necessary relay information.
`;
const connectingDiv = document.createElement('div');
connectingDiv.style.cssText = 'text-align: center; color: #6b7280;';
connectingDiv.innerHTML = `
⏳
Please wait while we establish the connection...
This may take a few seconds
`;
this.modalBody.appendChild(title);
this.modalBody.appendChild(description);
this.modalBody.appendChild(bunkerInfo);
this.modalBody.appendChild(connectingDiv);
}
async _performNip46Connect(bunkerPubkey) {
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 generate new.';
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 = `
# |
Use |
`;
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 = `
${userDisplay}
`;
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 };
}