Files
nostr_login_lite/lite/nostr-login-lite.bundle.js
2025-09-13 10:22:57 -04:00

2542 lines
78 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

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

/**
* NOSTR_LOGIN_LITE
* Single-file Nostr authentication library
* Generated on: 2025-09-13T13:54:03.650Z
*/
// ======================================
// Core Classes and Components
// ======================================
// ======================================
// modal.js
// ======================================
class Modal {
constructor(options) {
this.options = options;
this.container = null;
this.isVisible = false;
this.currentScreen = null;
// Initialize modal container and styles
this._initModal();
}
_initModal() {
// Create modal container
this.container = document.createElement('div');
this.container.id = 'nl-modal';
this.container.style.cssText = `
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.75);
display: none;
z-index: 10000;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
`;
// Create modal content
const modalContent = document.createElement('div');
modalContent.style.cssText = `
position: relative;
background: white;
width: 90%;
max-width: 400px;
margin: 50px auto;
border-radius: 12px;
box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04);
max-height: 600px;
overflow: hidden;
`;
// Header
const modalHeader = document.createElement('div');
modalHeader.style.cssText = `
padding: 20px 24px 0 24px;
display: flex;
justify-content: space-between;
align-items: center;
`;
const modalTitle = document.createElement('h2');
modalTitle.textContent = 'Nostr Login';
modalTitle.style.cssText = `
margin: 0;
font-size: 24px;
font-weight: 600;
color: #1f2937;
`;
const closeButton = document.createElement('button');
closeButton.innerHTML = '×';
closeButton.onclick = () => this.close();
closeButton.style.cssText = `
background: none;
border: none;
font-size: 28px;
color: #6b7280;
cursor: pointer;
padding: 0;
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 6px;
`;
closeButton.onmouseover = () => closeButton.style.background = '#f3f4f6';
closeButton.onmouseout = () => closeButton.style.background = 'none';
modalHeader.appendChild(modalTitle);
modalHeader.appendChild(closeButton);
// Body
this.modalBody = document.createElement('div');
this.modalBody.style.cssText = `
padding: 24px;
overflow-y: auto;
max-height: 500px;
`;
modalContent.appendChild(modalHeader);
modalContent.appendChild(this.modalBody);
this.container.appendChild(modalContent);
// Add to body
document.body.appendChild(this.container);
// Click outside to close
this.container.onclick = (e) => {
if (e.target === this.container) {
this.close();
}
};
// Update theme
this.updateTheme();
}
updateTheme() {
const isDark = this.options?.darkMode;
const modalContent = this.container.querySelector(':nth-child(1)');
const title = this.container.querySelector('h2');
if (isDark) {
modalContent.style.background = '#1f2937';
title.style.color = 'white';
} else {
modalContent.style.background = 'white';
title.style.color = '#1f2937';
}
}
open(opts = {}) {
this.currentScreen = opts.startScreen;
this.isVisible = true;
this.container.style.display = 'block';
// Render login options
this._renderLoginOptions();
}
close() {
this.isVisible = false;
this.container.style.display = 'none';
this.modalBody.innerHTML = '';
}
_renderLoginOptions() {
this.modalBody.innerHTML = '';
const options = [];
// Extension option
if (this.options?.methods?.extension !== false) {
options.push({
type: 'extension',
title: 'Browser Extension',
description: 'Use your browser extension',
icon: '🔌'
});
}
// Local key option
if (this.options?.methods?.local !== false) {
options.push({
type: 'local',
title: 'Local Key',
description: 'Create or import your own key',
icon: '🔑'
});
}
// Nostr Connect option
if (this.options?.methods?.connect !== false) {
options.push({
type: 'connect',
title: 'Nostr Connect',
description: 'Connect with external signer',
icon: '🌐'
});
}
// Read-only option
if (this.options?.methods?.readonly !== false) {
options.push({
type: 'readonly',
title: 'Read Only',
description: 'Browse without signing',
icon: '👁️'
});
}
// OTP/DM option
if (this.options?.methods?.otp !== false && this.options?.otp) {
options.push({
type: 'otp',
title: 'DM/OTP',
description: 'Receive OTP via DM',
icon: '📱'
});
}
// Render each option
options.forEach(option => {
const button = document.createElement('button');
button.onclick = () => this._handleOptionClick(option.type);
button.style.cssText = `
display: flex;
align-items: center;
width: 100%;
padding: 16px;
margin-bottom: 12px;
background: ${this.options?.darkMode ? '#374151' : 'white'};
border: 1px solid ${this.options?.darkMode ? '#4b5563' : '#d1d5db'};
border-radius: 8px;
cursor: pointer;
transition: all 0.2s;
`;
button.onmouseover = () => {
button.style.boxShadow = '0 4px 6px -1px rgba(0, 0, 0, 0.1)';
};
button.onmouseout = () => {
button.style.boxShadow = 'none';
};
const iconDiv = document.createElement('div');
iconDiv.textContent = option.icon;
iconDiv.style.cssText = `
font-size: 24px;
margin-right: 16px;
width: 24px;
text-align: center;
`;
const contentDiv = document.createElement('div');
contentDiv.style.cssText = 'flex: 1; text-align: left;';
const titleDiv = document.createElement('div');
titleDiv.textContent = option.title;
titleDiv.style.cssText = `
font-weight: 600;
margin-bottom: 4px;
color: ${this.options?.darkMode ? 'white' : '#1f2937'};
`;
const descDiv = document.createElement('div');
descDiv.textContent = option.description;
descDiv.style.cssText = `
font-size: 14px;
color: ${this.options?.darkMode ? '#9ca3af' : '#6b7280'};
`;
contentDiv.appendChild(titleDiv);
contentDiv.appendChild(descDiv);
button.appendChild(iconDiv);
button.appendChild(contentDiv);
this.modalBody.appendChild(button);
});
}
_handleOptionClick(type) {
console.log('Selected login type:', type);
// Handle different login types
switch (type) {
case 'extension':
this._handleExtension();
break;
case 'local':
this._showLocalKeyScreen();
break;
case 'connect':
this._showConnectScreen();
break;
case 'readonly':
this._handleReadonly();
break;
case 'otp':
this._showOtpScreen();
break;
}
}
_handleExtension() {
// Detect all available real extensions
const availableExtensions = this._detectAllExtensions();
console.log(`Modal: Found ${availableExtensions.length} extensions:`, availableExtensions.map(e => e.displayName));
if (availableExtensions.length === 0) {
console.log('Modal: No real extensions found');
this._showExtensionRequired();
} else if (availableExtensions.length === 1) {
// Single extension - use it directly without showing choice UI
console.log('Modal: Single extension detected, using it directly:', availableExtensions[0].displayName);
this._tryExtensionLogin(availableExtensions[0].extension);
} else {
// Multiple extensions - show choice UI
console.log('Modal: Multiple extensions detected, showing choice UI for', availableExtensions.length, 'extensions');
this._showExtensionChoice(availableExtensions);
}
}
_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 && 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 if (window.nostr) {
console.log(`Modal: ✗ Filtered out window.nostr (${window.nostr.constructor?.name}) - likely our library`);
}
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;';
const description = document.createElement('p');
description.textContent = `Found ${extensions.length} Nostr extensions. Choose which one to use:`;
description.style.cssText = 'margin-bottom: 20px; color: #6b7280; font-size: 14px;';
this.modalBody.appendChild(title);
this.modalBody.appendChild(description);
// Create button for each extension
extensions.forEach((ext, index) => {
const button = document.createElement('button');
button.onclick = () => this._tryExtensionLogin(ext.extension);
button.style.cssText = `
display: flex;
align-items: center;
width: 100%;
padding: 16px;
margin-bottom: 12px;
background: ${this.options?.darkMode ? '#374151' : 'white'};
border: 1px solid ${this.options?.darkMode ? '#4b5563' : '#d1d5db'};
border-radius: 8px;
cursor: pointer;
transition: all 0.2s;
text-align: left;
`;
button.onmouseover = () => {
button.style.boxShadow = '0 4px 6px -1px rgba(0, 0, 0, 0.1)';
button.style.transform = 'translateY(-1px)';
};
button.onmouseout = () => {
button.style.boxShadow = 'none';
button.style.transform = 'none';
};
const iconDiv = document.createElement('div');
iconDiv.textContent = ext.icon;
iconDiv.style.cssText = `
font-size: 24px;
margin-right: 16px;
width: 24px;
text-align: center;
`;
const contentDiv = document.createElement('div');
contentDiv.style.cssText = 'flex: 1;';
const nameDiv = document.createElement('div');
nameDiv.textContent = ext.displayName;
nameDiv.style.cssText = `
font-weight: 600;
margin-bottom: 4px;
color: ${this.options?.darkMode ? 'white' : '#1f2937'};
`;
const pathDiv = document.createElement('div');
pathDiv.textContent = ext.name;
pathDiv.style.cssText = `
font-size: 12px;
color: ${this.options?.darkMode ? '#9ca3af' : '#6b7280'};
font-family: monospace;
`;
contentDiv.appendChild(nameDiv);
contentDiv.appendChild(pathDiv);
button.appendChild(iconDiv);
button.appendChild(contentDiv);
this.modalBody.appendChild(button);
});
// Add back button
const backButton = document.createElement('button');
backButton.textContent = 'Back to Login Options';
backButton.onclick = () => this._renderLoginOptions();
backButton.style.cssText = this._getButtonStyle('secondary') + 'margin-top: 20px;';
this.modalBody.appendChild(backButton);
}
async _tryExtensionLogin(extensionObj) {
try {
// Show loading state
this.modalBody.innerHTML = '<div style="text-align: center; padding: 20px;">🔄 Connecting to extension...</div>';
// Get pubkey from extension
const pubkey = await extensionObj.getPublicKey();
console.log('Extension provided pubkey:', pubkey);
// Set extension method with the extension object
this._setAuthMethod('extension', { pubkey, extension: extensionObj });
} catch (error) {
console.error('Extension login failed:', error);
this._showError(`Extension login failed: ${error.message}`);
}
}
_showLocalKeyScreen() {
this.modalBody.innerHTML = '';
const title = document.createElement('h3');
title.textContent = 'Local Key';
title.style.cssText = 'margin: 0 0 20px 0; font-size: 18px; font-weight: 600;';
const createButton = document.createElement('button');
createButton.textContent = 'Create New Key';
createButton.onclick = () => this._createLocalKey();
createButton.style.cssText = this._getButtonStyle();
const importButton = document.createElement('button');
importButton.textContent = 'Import Existing Key';
importButton.onclick = () => this._showImportKeyForm();
importButton.style.cssText = this._getButtonStyle() + 'margin-top: 12px;';
const backButton = document.createElement('button');
backButton.textContent = 'Back';
backButton.onclick = () => this._renderLoginOptions();
backButton.style.cssText = `
display: block;
margin-top: 20px;
padding: 12px;
background: #6b7280;
color: white;
border: none;
border-radius: 6px;
cursor: pointer;
`;
this.modalBody.appendChild(title);
this.modalBody.appendChild(createButton);
this.modalBody.appendChild(importButton);
this.modalBody.appendChild(backButton);
}
_createLocalKey() {
try {
const sk = window.NostrTools.generateSecretKey();
const pk = window.NostrTools.getPublicKey(sk);
const nsec = window.NostrTools.nip19.nsecEncode(sk);
const npub = window.NostrTools.nip19.npubEncode(pk);
this._showKeyDisplay(pk, nsec, 'created');
} catch (error) {
this._showError('Failed to create key: ' + error.message);
}
}
_showImportKeyForm() {
this.modalBody.innerHTML = '';
const title = document.createElement('h3');
title.textContent = 'Import Local Key';
title.style.cssText = 'margin: 0 0 16px 0; font-size: 18px; font-weight: 600;';
const description = document.createElement('p');
description.textContent = 'Enter your secret key in either nsec or hex format:';
description.style.cssText = 'margin-bottom: 12px; color: #6b7280; font-size: 14px;';
const textarea = document.createElement('textarea');
textarea.placeholder = 'Enter your secret key:\n• nsec1... (bech32 format)\n• 64-character hex string';
textarea.style.cssText = `
width: 100%;
height: 100px;
padding: 12px;
border: 1px solid #d1d5db;
border-radius: 6px;
margin-bottom: 12px;
resize: none;
font-family: monospace;
font-size: 14px;
box-sizing: border-box;
`;
// Add real-time format detection
const formatHint = document.createElement('div');
formatHint.style.cssText = 'margin-bottom: 16px; font-size: 12px; color: #6b7280; min-height: 16px;';
textarea.oninput = () => {
const value = textarea.value.trim();
if (!value) {
formatHint.textContent = '';
return;
}
const format = this._detectKeyFormat(value);
if (format === 'nsec') {
formatHint.textContent = '✅ Valid nsec format detected';
formatHint.style.color = '#059669';
} else if (format === 'hex') {
formatHint.textContent = '✅ Valid hex format detected';
formatHint.style.color = '#059669';
} else {
formatHint.textContent = '❌ Invalid key format - must be nsec1... or 64-character hex';
formatHint.style.color = '#dc2626';
}
};
const importButton = document.createElement('button');
importButton.textContent = 'Import Key';
importButton.onclick = () => this._importLocalKey(textarea.value);
importButton.style.cssText = this._getButtonStyle();
const backButton = document.createElement('button');
backButton.textContent = 'Back';
backButton.onclick = () => this._showLocalKeyScreen();
backButton.style.cssText = this._getButtonStyle('secondary') + 'margin-top: 12px;';
this.modalBody.appendChild(title);
this.modalBody.appendChild(description);
this.modalBody.appendChild(textarea);
this.modalBody.appendChild(formatHint);
this.modalBody.appendChild(importButton);
this.modalBody.appendChild(backButton);
}
_detectKeyFormat(keyValue) {
const trimmed = keyValue.trim();
// Check for nsec format
if (trimmed.startsWith('nsec1') && trimmed.length === 63) {
try {
window.NostrTools.nip19.decode(trimmed);
return 'nsec';
} catch {
return 'invalid';
}
}
// Check for hex format (64 characters, valid hex)
if (trimmed.length === 64 && /^[a-fA-F0-9]{64}$/.test(trimmed)) {
return 'hex';
}
return 'invalid';
}
_importLocalKey(keyValue) {
try {
const trimmed = keyValue.trim();
if (!trimmed) {
throw new Error('Please enter a secret key');
}
const format = this._detectKeyFormat(trimmed);
let sk;
if (format === 'nsec') {
// Decode nsec format - this returns Uint8Array
const decoded = window.NostrTools.nip19.decode(trimmed);
if (decoded.type !== 'nsec') {
throw new Error('Invalid nsec format');
}
sk = decoded.data; // This is already Uint8Array
} else if (format === 'hex') {
// Convert hex string to Uint8Array
sk = this._hexToUint8Array(trimmed);
// Test that it's a valid secret key by trying to get public key
window.NostrTools.getPublicKey(sk);
} else {
throw new Error('Invalid key format. Please enter either nsec1... or 64-character hex string');
}
// Generate public key and encoded formats
const pk = window.NostrTools.getPublicKey(sk);
const nsec = window.NostrTools.nip19.nsecEncode(sk);
const npub = window.NostrTools.nip19.npubEncode(pk);
this._showKeyDisplay(pk, nsec, 'imported');
} catch (error) {
this._showError('Invalid key: ' + error.message);
}
}
_hexToUint8Array(hex) {
// Convert hex string to Uint8Array
if (hex.length % 2 !== 0) {
throw new Error('Invalid hex string length');
}
const bytes = new Uint8Array(hex.length / 2);
for (let i = 0; i < bytes.length; i++) {
bytes[i] = parseInt(hex.substr(i * 2, 2), 16);
}
return bytes;
}
_showKeyDisplay(pubkey, nsec, action) {
this.modalBody.innerHTML = '';
const title = document.createElement('h3');
title.textContent = `Key ${action} successfully!`;
title.style.cssText = 'margin: 0 0 16px 0; font-size: 18px; font-weight: 600; color: #059669;';
const warningDiv = document.createElement('div');
warningDiv.textContent = '⚠️ Save your secret key securely!';
warningDiv.style.cssText = 'background: #fef3c7; color: #92400e; padding: 12px; border-radius: 6px; margin-bottom: 16px; font-size: 14px;';
const nsecDiv = document.createElement('div');
nsecDiv.innerHTML = `<strong>Your Secret Key:</strong><br><code style="word-break: break-all; background: #f3f4f6; padding: 8px; border-radius: 4px;">${nsec}</code>`;
nsecDiv.style.cssText = 'margin-bottom: 16px; font-size: 14px;';
const npubDiv = document.createElement('div');
npubDiv.innerHTML = `<strong>Your Public Key:</strong><br><code style="word-break: break-all; background: #f3f4f6; padding: 8px; border-radius: 4px;">${window.NostrTools.nip19.npubEncode(pubkey)}</code>`;
npubDiv.style.cssText = 'margin-bottom: 16px; font-size: 14px;';
const continueButton = document.createElement('button');
continueButton.textContent = 'Continue';
continueButton.onclick = () => this._setAuthMethod('local', { secret: nsec, pubkey });
continueButton.style.cssText = this._getButtonStyle();
this.modalBody.appendChild(title);
this.modalBody.appendChild(warningDiv);
this.modalBody.appendChild(nsecDiv);
this.modalBody.appendChild(npubDiv);
this.modalBody.appendChild(continueButton);
}
_setAuthMethod(method, options = {}) {
// Emit auth method selection
const event = new CustomEvent('nlMethodSelected', {
detail: { method, ...options }
});
window.dispatchEvent(event);
this.close();
}
_showError(message) {
this.modalBody.innerHTML = '';
const errorDiv = document.createElement('div');
errorDiv.style.cssText = 'background: #fee2e2; color: #dc2626; padding: 16px; border-radius: 6px; margin-bottom: 16px;';
errorDiv.innerHTML = `<strong>Error:</strong> ${message}`;
const backButton = document.createElement('button');
backButton.textContent = 'Back';
backButton.onclick = () => this._renderLoginOptions();
backButton.style.cssText = this._getButtonStyle('secondary');
this.modalBody.appendChild(errorDiv);
this.modalBody.appendChild(backButton);
}
_showExtensionRequired() {
this.modalBody.innerHTML = '';
const title = document.createElement('h3');
title.textContent = 'Browser Extension Required';
title.style.cssText = 'margin: 0 0 16px 0; font-size: 18px; font-weight: 600;';
const message = document.createElement('p');
message.textContent = 'Please install a Nostr browser extension like Alby or getflattr and refresh the page.';
message.style.cssText = 'margin-bottom: 20px; color: #6b7280;';
const backButton = document.createElement('button');
backButton.textContent = 'Back';
backButton.onclick = () => this._renderLoginOptions();
backButton.style.cssText = this._getButtonStyle('secondary');
this.modalBody.appendChild(title);
this.modalBody.appendChild(message);
this.modalBody.appendChild(backButton);
}
_showConnectScreen() {
this.modalBody.innerHTML = '';
const title = document.createElement('h3');
title.textContent = 'Connect to NIP-46 Remote Signer';
title.style.cssText = 'margin: 0 0 16px 0; font-size: 18px; font-weight: 600;';
const description = document.createElement('p');
description.textContent = 'Connect to a remote signer (bunker) server to use its keys for signing.';
description.style.cssText = 'margin-bottom: 20px; color: #6b7280; font-size: 14px;';
const formGroup = document.createElement('div');
formGroup.style.cssText = 'margin-bottom: 20px;';
const label = document.createElement('label');
label.textContent = 'Bunker Public Key:';
label.style.cssText = 'display: block; margin-bottom: 8px; font-weight: 500;';
const pubkeyInput = document.createElement('input');
pubkeyInput.type = 'text';
pubkeyInput.placeholder = 'bunker://pubkey?relay=..., bunker:hex, hex, or npub...';
pubkeyInput.style.cssText = `
width: 100%;
padding: 12px;
border: 1px solid #d1d5db;
border-radius: 6px;
margin-bottom: 12px;
font-family: monospace;
box-sizing: border-box;
`;
const urlLabel = document.createElement('label');
urlLabel.textContent = 'Remote URL (optional):';
urlLabel.style.cssText = 'display: block; margin-bottom: 8px; font-weight: 500;';
const urlInput = document.createElement('input');
urlInput.type = 'url';
urlInput.placeholder = 'ws://localhost:8080 (default)';
urlInput.style.cssText = `
width: 100%;
padding: 12px;
border: 1px solid #d1d5db;
border-radius: 6px;
margin-bottom: 16px;
box-sizing: border-box;
`;
// Pre-fill with our bunker config if available
if (window.NIP46_BUNKER_CONFIG) {
pubkeyInput.value = window.NIP46_BUNKER_CONFIG.remoteSigner.pubkey;
urlInput.value = window.NIP46_BUNKER_CONFIG.remoteSigner.url;
}
const connectButton = document.createElement('button');
connectButton.textContent = 'Connect to Bunker';
connectButton.onclick = () => this._handleNip46Connect(pubkeyInput.value, urlInput.value);
connectButton.style.cssText = this._getButtonStyle();
const backButton = document.createElement('button');
backButton.textContent = 'Back';
backButton.onclick = () => this._renderLoginOptions();
backButton.style.cssText = this._getButtonStyle('secondary') + 'margin-top: 12px;';
formGroup.appendChild(label);
formGroup.appendChild(pubkeyInput);
formGroup.appendChild(urlLabel);
formGroup.appendChild(urlInput);
this.modalBody.appendChild(title);
this.modalBody.appendChild(description);
this.modalBody.appendChild(formGroup);
this.modalBody.appendChild(connectButton);
this.modalBody.appendChild(backButton);
}
_handleNip46Connect(bunkerPubkey, bunkerUrl) {
if (!bunkerPubkey || !bunkerPubkey.length) {
this._showError('Bunker pubkey is required');
return;
}
this._showNip46Connecting(bunkerPubkey, bunkerUrl);
this._performNip46Connect(bunkerPubkey, bunkerUrl);
}
_showNip46Connecting(bunkerPubkey, bunkerUrl) {
this.modalBody.innerHTML = '';
const title = document.createElement('h3');
title.textContent = 'Connecting to Remote Signer...';
title.style.cssText = 'margin: 0 0 16px 0; font-size: 18px; font-weight: 600; color: #059669;';
const description = document.createElement('p');
description.textContent = 'Establishing secure connection to your remote signer.';
description.style.cssText = 'margin-bottom: 20px; color: #6b7280;';
// Normalize bunker pubkey for display (= show original format if bunker: prefix)
const displayPubkey = bunkerPubkey.startsWith('bunker:') || bunkerPubkey.startsWith('npub') || bunkerPubkey.length === 64 ? bunkerPubkey : bunkerPubkey;
const bunkerInfo = document.createElement('div');
bunkerInfo.style.cssText = 'background: #f1f5f9; padding: 12px; border-radius: 6px; margin-bottom: 20px; font-size: 14px;';
bunkerInfo.innerHTML = `
<strong>Connecting to bunker:</strong><br>
Pubkey: <code style="word-break: break-all;">${displayPubkey}</code><br>
Relay: <code style="word-break: break-all;">${bunkerUrl || 'ws://localhost:8080'}</code><br>
<small style="color: #6b7280;">If this relay is offline, the bunker server may be unavailable.</small>
`;
const connectingDiv = document.createElement('div');
connectingDiv.style.cssText = 'text-align: center; color: #6b7280;';
connectingDiv.innerHTML = `
<div style="font-size: 24px; margin-bottom: 10px;">⏳</div>
<div>Please wait while we establish the connection...</div>
<div style="font-size: 12px; margin-top: 10px;">This may take a few seconds</div>
`;
this.modalBody.appendChild(title);
this.modalBody.appendChild(description);
this.modalBody.appendChild(bunkerInfo);
this.modalBody.appendChild(connectingDiv);
}
async _performNip46Connect(bunkerPubkey, bunkerUrl) {
try {
console.log('Starting NIP-46 connection to bunker:', bunkerPubkey, bunkerUrl);
// Check if nostr-tools NIP-46 is available
if (!window.NostrTools?.nip46) {
throw new Error('nostr-tools NIP-46 module not available');
}
// Use nostr-tools to parse bunker input - this handles all formats correctly
console.log('Parsing bunker input with nostr-tools...');
const bunkerPointer = await window.NostrTools.nip46.parseBunkerInput(bunkerPubkey);
if (!bunkerPointer) {
throw new Error('Unable to parse bunker connection string or resolve NIP-05 identifier');
}
console.log('Parsed bunker pointer:', bunkerPointer);
// Create local client keypair for this session
const localSecretKey = window.NostrTools.generateSecretKey();
console.log('Generated local client keypair for NIP-46 session');
// Use nostr-tools BunkerSigner.fromBunker() for bunker:// connections
console.log('Creating nip46 BunkerSigner...');
const signer = window.NostrTools.nip46.BunkerSigner.fromBunker(localSecretKey, bunkerPointer, {
onauth: (url) => {
console.log('Received auth URL from bunker:', url);
// Open auth URL in popup or redirect
window.open(url, '_blank', 'width=600,height=800');
}
});
console.log('NIP-46 BunkerSigner created successfully');
// Attempt initial ping to verify connection
console.log('Testing bunker connection with ping...');
await signer.ping();
console.log('NIP-46 ping successful - bunker is reachable');
// Try to connect (this may trigger auth flow)
console.log('Attempting NIP-46 connect...');
await signer.connect();
console.log('NIP-46 connect successful');
// Get the user's public key from the bunker
console.log('Getting public key from bunker...');
const userPubkey = await signer.getPublicKey();
console.log('NIP-46 user public key:', userPubkey);
// Store the NIP-46 authentication info
const nip46Info = {
pubkey: userPubkey,
signer: {
method: 'nip46',
remotePubkey: bunkerPointer.pubkey,
bunkerSigner: signer,
secret: bunkerPointer.secret,
relays: bunkerPointer.relays
}
};
console.log('NOSTR_LOGIN_LITE NIP-46 connection established successfully!');
// Set as current auth method
this._setAuthMethod('nip46', nip46Info);
return;
} catch (error) {
console.error('NIP-46 connection failed:', error);
this._showNip46Error(error.message);
}
}
_showNip46Error(message) {
this.modalBody.innerHTML = '';
const title = document.createElement('h3');
title.textContent = 'Connection Failed';
title.style.cssText = 'margin: 0 0 16px 0; font-size: 18px; font-weight: 600; color: #dc2626;';
const errorMsg = document.createElement('p');
errorMsg.textContent = `Unable to connect to remote signer: ${message}`;
errorMsg.style.cssText = 'margin-bottom: 20px; color: #6b7280;';
const retryButton = document.createElement('button');
retryButton.textContent = 'Try Again';
retryButton.onclick = () => this._showConnectScreen();
retryButton.style.cssText = this._getButtonStyle();
const backButton = document.createElement('button');
backButton.textContent = 'Back to Options';
backButton.onclick = () => this._renderLoginOptions();
backButton.style.cssText = this._getButtonStyle('secondary') + 'margin-top: 12px;';
this.modalBody.appendChild(title);
this.modalBody.appendChild(errorMsg);
this.modalBody.appendChild(retryButton);
this.modalBody.appendChild(backButton);
}
_handleReadonly() {
// Set read-only mode
this._setAuthMethod('readonly');
}
_showOtpScreen() {
// Placeholder for OTP functionality
this._showError('OTP/DM not yet implemented - coming soon!');
}
_getButtonStyle(type = 'primary') {
const baseStyle = `
display: block;
width: 100%;
padding: 12px;
border: none;
border-radius: 8px;
font-size: 16px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
`;
if (type === 'primary') {
return baseStyle + `
background: #3b82f6;
color: white;
`;
} else {
return baseStyle + `
background: #6b7280;
color: white;
`;
}
}
// Public API
static init(options) {
if (Modal.instance) return Modal.instance;
Modal.instance = new Modal(options);
return Modal.instance;
}
static getInstance() {
return Modal.instance;
}
}
// Initialize global instance
let modalInstance = null;
window.addEventListener('load', () => {
modalInstance = new Modal();
});
// ======================================
// nip46-client.js
// ======================================
class NIP46Client {
constructor() {
this.pool = null;
this.localSk = null;
this.localPk = null;
this.remotePk = null;
this.relays = [];
this.sub = null;
this.pendingRequests = {};
this.useNip44 = false;
this.iframeOrigin = null;
this.iframePort = null;
}
init(localSk, remotePk, relays, iframeOrigin) {
// Create SimplePool
this.pool = new window.NostrTools.SimplePool();
// Setup keys
this.localSk = localSk;
if (this.localSk) {
this.localPk = window.NostrTools.getPublicKey(this.localSk);
}
this.remotePk = remotePk;
this.relays = [...relays];
// Store iframe origin for future use
this.iframeOrigin = iframeOrigin;
console.log('NIP46Client initialized for', this.remotePk ? 'remote signer' : 'listening mode');
}
setUseNip44(use) {
this.useNip44 = use;
}
subscribeReplies() {
if (!this.pool || !this.localPk) return;
// Subscribe to replies to our pubkey on kind 24133 (NIP-46 methods)
this.sub = this.pool.sub(this.relays, [{
kinds: [24133],
'#p': [this.localPk]
}]);
this.sub.on('event', (event) => this.onEvent(event));
this.sub.on('eose', () => {
console.log('NIP-46 subscription caught up');
});
console.log('Subscribed to NIP-46 replies on relays:', this.relays);
}
unsubscribe() {
if (this.sub) {
this.sub.unsub();
this.sub = null;
}
}
async onEvent(event) {
console.log('NIP-46 event received:', event);
try {
const parsed = await this.parseEvent(event);
if (parsed) {
if (parsed.id && this.pendingRequests[parsed.id]) {
// Handle response
const handler = this.pendingRequests[parsed.id];
delete this.pendingRequests[parsed.id];
if (parsed.result !== undefined) {
handler.resolve(parsed.result);
} else if (parsed.error) {
handler.reject(new Error(parsed.error));
} else {
handler.reject(new Error('Invalid response format'));
}
} else if (parsed.method === 'auth_url') {
// Handle auth_url emissions (deduplication required)
this.emitAuthUrlIfNeeded(parsed.params[0]);
}
}
} catch (error) {
console.error('Error processing NIP-46 event:', error);
}
}
emitAuthUrlIfNeeded(url) {
// Deduplicate auth_url emissions - only emit if not recently shown
const lastUrl = sessionStorage.getItem('nl-last-auth-url');
if (lastUrl === url) {
console.log('Auth URL already shown, skipping duplicate:', url);
return;
}
sessionStorage.setItem('nl-last-auth-url', url);
console.log('New auth URL:', url);
// Emit event for UI
window.dispatchEvent(new CustomEvent('nlAuthUrl', { detail: { url } }));
}
async parseEvent(event) {
try {
let content = event.content;
// Determine encryption method based on content structure
if (content.length > 44) {
// Likely NIP-44 (encrypted)
if (this.localSk && event.pubkey) {
try {
content = window.NostrTools.nip44?.decrypt(this.localSk, event.pubkey, content);
} catch (e) {
console.warn('NIP-44 decryption failed, trying NIP-04...');
content = await window.NostrTools.nip04.decrypt(this.localSk, event.pubkey, content);
}
}
} else {
// Likely NIP-04
if (this.localSk && event.pubkey) {
content = await window.NostrTools.nip04.decrypt(this.localSk, event.pubkey, content);
}
}
const payload = JSON.parse(content);
console.log('Decrypted NIP-46 payload:', payload);
return {
id: payload.id,
method: payload.method,
params: payload.params,
result: payload.result,
error: payload.error,
event: event
};
} catch (e) {
console.error('Failed to parse event:', e);
return null;
}
}
async listen(nostrConnectSecret) {
return new Promise((resolve, reject) => {
if (!this.localPk) {
reject(new Error('No local pubkey available for listening'));
return;
}
// Subscribe to unsolicited events to our pubkey
let foundSecretOrAck = false;
const listenSub = this.pool.sub(this.relays, [{
kinds: [24133],
'#p': [this.localPk]
}]);
listenSub.on('event', async (event) => {
try {
const parsed = await this.parseEvent(event);
if (parsed && parsed.method === 'connect') {
// Accept if it's an ack or matches our secret
const [userPubkey, token] = parsed.params || [];
if (token === '' && parsed.result === 'ack') {
// Ack received
foundSecretOrAck = true;
listenSub.unsub();
resolve(event.pubkey);
} else if (token === nostrConnectSecret) {
// Secret match
foundSecretOrAck = true;
listenSub.unsub();
resolve(event.pubkey);
}
}
} catch (error) {
console.error('Error in listen mode:', error);
}
});
// Timeout after 5 minutes
setTimeout(() => {
if (!foundSecretOrAck) {
listenSub.unsub();
reject(new Error('Listen timeout - no signer connected'));
}
}, 300000);
});
}
async connect(token, perms) {
return new Promise(async (resolve, reject) => {
try {
const result = await this.sendRequest(
this.remotePk,
'connect',
[this.localPk, token || '', perms || ''],
24133,
(response) => {
if (response === 'ack') {
resolve(true);
} else {
reject(new Error('Connection not acknowledged'));
}
}
);
// Set 30 second timeout
setTimeout(() => reject(new Error('Connection timeout')), 30000);
} catch (error) {
reject(error);
}
});
}
async initUserPubkey(hint) {
if (hint) {
this.remotePk = hint;
return hint;
}
if (!this.remotePk) {
// Request get_public_key
return new Promise(async (resolve, reject) => {
try {
const pubkey = await this.sendRequest(
this.remotePk,
'get_public_key',
[],
24133
);
this.remotePk = pubkey;
resolve(pubkey);
} catch (error) {
reject(error);
}
});
}
return this.remotePk;
}
async sendRequest(remotePubkey, method, params, kind = 24133, cb) {
if (!this.pool || !this.localSk || !this.localPk) {
throw new Error('NIP46Client not properly initialized');
}
if (!remotePubkey) {
throw new Error('No remote pubkey specified');
}
const id = this._generateId();
// Create request event
const event = await this.createRequestEvent(id, remotePubkey, method, params, kind);
console.log('Sending NIP-46 request:', { id, method, params });
// Publish to relays
const pubs = await this.pool.publish(this.relays, event);
console.log('Published to relays, waiting for response...');
return new Promise((resolve, reject) => {
// Set timeout
const timeout = setTimeout(() => {
console.error('NIP-46 request timeout for id:', id);
delete this.pendingRequests[id];
reject(new Error(`Request timeout for ${method}`));
}, 60000); // 1 minute timeout
// Store handler
this.pendingRequests[id] = {
resolve: (result) => {
clearTimeout(timeout);
resolve(result);
},
reject: (error) => {
clearTimeout(timeout);
reject(error);
},
timestamp: Date.now()
};
// If callback provided, override resolve
if (cb) {
const originalResolve = this.pendingRequests[id].resolve;
this.pendingRequests[id].resolve = (result) => {
cb(result);
originalResolve(result);
};
}
});
}
async createRequestEvent(id, remotePubkey, method, params, kind = 24133) {
let content = JSON.stringify({
id,
method,
params
});
// Choose encryption method
let encrypted = content;
if (method !== 'create_account') {
// Use NIP-44 for non-account creation methods if available
if (this.useNip44 && window.NostrTools.nip44) {
encrypted = window.NostrTools.nip44.encrypt(this.localSk, remotePubkey, content);
} else {
// Fallback to NIP-04
encrypted = await window.NostrTools.nip04.encrypt(this.localSk, remotePubkey, content);
}
}
// Create event structure
const event = {
kind: kind,
content: encrypted,
tags: [
['p', remotePubkey]
],
created_at: Math.floor(Date.now() / 1000),
pubkey: this.localPk,
id: '', // Will be set by finalizeEvent
sig: '' // Will be set by finalizeEvent
};
// Sign the event
const signedEvent = window.NostrTools.finalizeEvent(event, this.localSk);
return signedEvent;
}
_generateId() {
return 'nl-' + Date.now() + '-' + Math.random().toString(36).substring(2, 15);
}
setWorkerIframePort(port) {
this.iframePort = port;
// Set up postMessage routing if needed
if (this.iframePort && this.iframeOrigin) {
this.iframePort.onmessage = (event) => {
if (event.origin !== this.iframeOrigin) {
console.warn('Ignoring message from unknown origin:', event.origin);
return;
}
console.log('Received iframe message:', event.data);
// Handle iframe messages
};
// Send keepalive
setInterval(() => {
if (this.iframePort) {
try {
this.iframePort.postMessage({ type: 'ping' });
} catch (e) {
console.warn('Iframe port closed');
this.iframePort = null;
}
}
}, 30000); // 30 seconds
}
}
teardown() {
this.unsubscribe();
if (this.iframePort) {
try {
this.iframePort.close();
} catch (e) {
console.warn('Error closing iframe port:', e);
}
this.iframePort = null;
}
if (this.pool) {
this.pool.close(this.relays);
this.pool = null;
}
// Clear all pending requests
for (const id in this.pendingRequests) {
this.pendingRequests[id].reject(new Error('Client teardown'));
}
this.pendingRequests = {};
}
}
// ======================================
// nostr-login-lite.js
// ======================================
// Import NIP-46 client
if (typeof NIP46Client === 'undefined') {
// Load NIP46Client if not already available (for non-bundled version)
const script = document.createElement('script');
script.src = './core/nip46-client.js';
document.head.appendChild(script);
}
// Global state
const LiteState = {
initialized: false,
windowNostr: null,
options: null,
auth: null,
modal: null,
bus: null,
pool: null,
nip44Codec: null,
extensionBridge: null,
nip46Client: null
};
// Dependencies verification
class Deps {
static ensureNostrToolsLoaded() {
if (typeof window === 'undefined') {
throw new Error('NOSTR_LOGIN_LITE must run in browser environment');
}
if (!window.NostrTools) {
throw new Error(
'window.NostrTools is required but not loaded. ' +
'Please include: <script src="./lite/nostr.bundle.js"></script>'
);
}
// Verify required APIs
const required = ['SimplePool', 'getPublicKey', 'finalizeEvent', 'nip04'];
for (const api of required) {
if (!window.NostrTools[api]) {
throw new Error(`window.NostrTools.${api} is required but missing`);
}
}
// Check for key generation function (might be generateSecretKey or generatePrivateKey)
if (!window.NostrTools.generateSecretKey && !window.NostrTools.generatePrivateKey) {
throw new Error('window.NostrTools must have either generateSecretKey or generatePrivateKey');
}
return true;
}
}
// Event Bus for internal communication
class Bus {
constructor() {
this.handlers = {};
}
on(event, handler) {
if (!this.handlers[event]) {
this.handlers[event] = [];
}
this.handlers[event].push(handler);
}
off(event, handler) {
if (!this.handlers[event]) return;
this.handlers[event] = this.handlers[event].filter(h => h !== handler);
}
emit(event, payload) {
if (!this.handlers[event]) return;
this.handlers[event].forEach(handler => {
try {
handler(payload);
} catch (e) {
console.error(`Error in event handler for ${event}:`, e);
}
});
}
}
// Storage helpers
class Store {
static addAccount(info) {
const accounts = this.getAccounts();
// Remove existing account with same pubkey if present
const filtered = accounts.filter(acc => acc.pubkey !== info.pubkey);
filtered.push(info);
localStorage.setItem('nl_accounts', JSON.stringify(filtered));
}
static removeCurrentAccount() {
const current = this.getCurrent();
if (current && current.pubkey) {
const accounts = this.getAccounts();
const filtered = accounts.filter(acc => acc.pubkey !== current.pubkey);
localStorage.setItem('nl_accounts', JSON.stringify(filtered));
localStorage.removeItem('nl_current');
}
}
static getCurrent() {
try {
const stored = localStorage.getItem('nl_current');
return stored ? JSON.parse(stored) : null;
} catch (e) {
console.error('Error parsing current account:', e);
return null;
}
}
static setCurrent(info) {
localStorage.setItem('nl_current', JSON.stringify(info));
}
static getAccounts() {
try {
const stored = localStorage.getItem('nl_accounts');
return stored ? JSON.parse(stored) : [];
} catch (e) {
console.error('Error parsing accounts:', e);
return [];
}
}
static getRecents() {
// Return last 5 used accounts in reverse chronological order
const accounts = this.getAccounts().slice(-5).reverse();
return accounts;
}
static setItem(key, value) {
localStorage.setItem(`nl-${key}`, value);
}
static getItem(key) {
return localStorage.getItem(`nl-${key}`);
}
static async getIcon() {
// Simple default icon - could be extended to fetch from profile
return '🔑';
}
}
// Relay configuration helpers
class Relays {
static getDefaultRelays(options) {
if (options?.relays) {
return this.normalize(options.relays);
}
// Default relays for fallbacks
return [
'wss://relay.damus.io',
'wss://relay.snort.social',
'wss://nos.lol'
];
}
static normalize(relays) {
return relays.map(relay => {
// Ensure wss:// prefix
if (relay.startsWith('ws://')) {
return relay.replace('ws://', 'wss://');
} else if (!relay.startsWith('wss://')) {
return `wss://${relay}`;
}
return relay;
}).filter(relay => {
// Remove duplicates and validate URLs
try {
new URL(relay);
return true;
} catch {
return false;
}
}).filter((relay, index, self) => self.indexOf(relay) === index); // dedupe
}
}
// Minimal NIP-44 codec fallback
class Nip44 {
constructor() {
this.Nip44 = null;
// Initialize with existing codec if available
this.nip44Available = window.NostrTools?.nip44;
}
static encrypt(ourSk, theirPk, plaintext) {
if (window.NostrTools?.nip44?.encrypt) {
return window.NostrTools.nip44.encrypt(ourSk, theirPk, plaintext);
}
throw new Error('NIP-44 encryption not available. Please use nostr-tools@>=2.x or provide codec implementation.');
}
static decrypt(ourSk, theirPk, ciphertext) {
if (window.NostrTools?.nip44?.decrypt) {
return window.NostrTools.nip44.decrypt(ourSk, theirPk, ciphertext);
}
throw new Error('NIP-44 decryption not available. Please use nostr-tools@>=2.x or provide codec implementation.');
}
}
// LocalSigner wrapping window.NostrTools
class LocalSigner {
constructor(sk) {
this.sk = sk;
// Generate pubkey from secret key
this.pk = this._getPubKey();
}
_getPubKey() {
const seckey = this.sk.startsWith('nsec') ?
window.NostrTools.nip19.decode(this.sk).data :
this.sk;
return window.NostrTools.getPublicKey(seckey);
}
pubkey() {
return this.pk;
}
async sign(event) {
// Prepare event for signing
const ev = { ...event };
ev.pubkey = this.pk;
// Generate event ID and sign
const signedEvent = await window.NostrTools.finalizeEvent(ev, this.sk);
return signedEvent;
}
async encrypt04(pubkey, plaintext) {
return await window.NostrTools.nip04.encrypt(this.sk, pubkey, plaintext);
}
async decrypt04(pubkey, ciphertext) {
return await window.NostrTools.nip04.decrypt(this.sk, pubkey, ciphertext);
}
async encrypt44(pubkey, plaintext) {
return Nip44.encrypt(this.sk, pubkey, plaintext);
}
async decrypt44(pubkey, ciphertext) {
return Nip44.decrypt(this.sk, pubkey, ciphertext);
}
}
// ExtensionBridge for detecting and managing browser extensions
class ExtensionBridge {
constructor() {
this.checking = false;
this.checkInterval = null;
this.originalNostr = null;
this.foundExtensions = new Map(); // Store multiple extensions by location
this.primaryExtension = null; // The currently selected extension
}
startChecking(nostrLite) {
if (this.checking) return;
this.checking = true;
const check = () => {
this.detectAllExtensions(nostrLite);
};
// Check immediately
check();
// Then check every 200ms for 30 seconds
this.checkInterval = setInterval(check, 200);
// Stop checking after 30 seconds
setTimeout(() => {
clearInterval(this.checkInterval);
this.checkInterval = null;
}, 30000);
}
detectAllExtensions(nostrLite) {
// Extension locations to check (in priority order)
const locations = [
{ path: 'window.navigator?.nostr', name: 'navigator.nostr', getter: () => window.navigator?.nostr },
{ path: 'window.webln?.nostr', name: 'webln.nostr', getter: () => window.webln?.nostr },
{ path: 'window.alby?.nostr', name: 'alby.nostr', getter: () => window.alby?.nostr },
{ path: 'window.nos2x', name: 'nos2x', getter: () => window.nos2x },
{ path: 'window.flamingo?.nostr', name: 'flamingo.nostr', getter: () => window.flamingo?.nostr },
{ path: 'window.mutiny?.nostr', name: 'mutiny.nostr', getter: () => window.mutiny?.nostr },
{ path: 'window.nostrich?.nostr', name: 'nostrich.nostr', getter: () => window.nostrich?.nostr },
{ path: 'window.getAlby?.nostr', name: 'getAlby.nostr', getter: () => window.getAlby?.nostr }
];
let foundNew = false;
// Check each location
for (const location of locations) {
try {
const obj = location.getter();
if (obj && this.isRealExtension(obj, nostrLite)) {
if (!this.foundExtensions.has(location.name)) {
this.foundExtensions.set(location.name, {
name: location.name,
path: location.path,
extension: obj,
constructor: obj.constructor?.name || 'Unknown'
});
console.log(`Real Nostr extension detected: ${location.name} (${obj.constructor?.name})`);
foundNew = true;
}
}
} catch (e) {
// Location doesn't exist or can't be accessed
}
}
// Also check window.nostr but be extra careful to avoid our library
if (window.nostr && this.isRealExtension(window.nostr, nostrLite)) {
// Make sure we haven't already detected this extension via another path
const existingExtension = Array.from(this.foundExtensions.values()).find(
ext => ext.extension === window.nostr
);
if (!existingExtension && !this.foundExtensions.has('window.nostr')) {
this.foundExtensions.set('window.nostr', {
name: 'window.nostr',
path: 'window.nostr',
extension: window.nostr,
constructor: window.nostr.constructor?.name || 'Unknown'
});
console.log(`Real Nostr extension detected at window.nostr: ${window.nostr.constructor?.name}`);
foundNew = true;
}
}
// Set primary extension if we don't have one and found extensions
if (!this.primaryExtension && this.foundExtensions.size > 0) {
// Prefer navigator.nostr if available, otherwise use first found
this.primaryExtension = this.foundExtensions.get('navigator.nostr') ||
Array.from(this.foundExtensions.values())[0];
// Cache the extension and reassign window.nostr to our lite version
this.originalNostr = this.primaryExtension.extension;
if (window.nostr !== nostrLite) {
window.nostr = nostrLite;
}
console.log(`Primary extension set: ${this.primaryExtension.name}`);
// If currently authenticated, reconcile state
if (LiteState.auth?.signer?.method === 'extension') {
this.reconcileExtension();
}
}
}
isRealExtension(obj, nostrLite) {
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 objects
if (obj === nostrLite || obj === windowNostr) {
return false;
}
// Exclude objects with our library's internal methods
if (typeof obj._hexToUint8Array === 'function' || typeof obj._call === 'function') {
return false;
}
// Exclude NostrTools library object
if (obj === window.NostrTools) {
return false;
}
// Real extensions typically have proper constructors (not plain Object)
const constructorName = obj.constructor?.name;
if (constructorName === 'Object' && !obj._isEnabled && !obj.enabled) {
// Plain objects without extension-specific properties are likely our library
return false;
}
return true;
}
getAllExtensions() {
return Array.from(this.foundExtensions.values());
}
getExtensionCount() {
return this.foundExtensions.size;
}
hasExtension() {
return this.foundExtensions.size > 0;
}
// Legacy compatibility - return primary extension
get foundExtension() {
return this.primaryExtension?.extension || null;
}
// Method to properly set primary extension
setPrimaryExtension(extension, name = 'selected') {
// Find the extension in our map or create new entry
let extensionInfo = null;
// Check if this extension is already in our map
for (const [key, info] of this.foundExtensions) {
if (info.extension === extension) {
extensionInfo = info;
break;
}
}
// If not found, create a new entry
if (!extensionInfo) {
extensionInfo = {
name: name,
path: name,
extension: extension,
constructor: extension?.constructor?.name || 'Unknown'
};
this.foundExtensions.set(name, extensionInfo);
}
this.primaryExtension = extensionInfo;
console.log(`Primary extension set to: ${extensionInfo.name}`);
}
async setExtensionReadPubkey(expectedPubkey = null) {
if (!this.primaryExtension) return false;
try {
// Temporarily set window.nostr to extension
const temp = window.nostr;
window.nostr = this.primaryExtension.extension;
const pubkey = await this.primaryExtension.extension.getPublicKey();
// Restore our lite implementation
window.nostr = temp;
if (expectedPubkey && pubkey !== expectedPubkey) {
console.warn(`Extension pubkey ${pubkey} does not match expected ${expectedPubkey}`);
}
return pubkey;
} catch (e) {
console.error('Error reading extension pubkey:', e);
return null;
}
}
trySetForPubkey(expectedPubkey) {
if (!this.hasExtension()) return false;
this.setExtensionReadPubkey(expectedPubkey).then(pubkey => {
if (pubkey) {
LiteState.bus?.emit('extensionLogin', { pubkey });
}
});
return true;
}
setExtension() {
if (!this.primaryExtension) return;
window.nostr = this.primaryExtension.extension;
this.setExtensionReadPubkey().then(pubkey => {
if (pubkey) {
LiteState.bus?.emit('extensionSet', { pubkey });
}
});
}
unset(nostrLite) {
window.nostr = nostrLite;
}
reconcileExtension() {
// Handle extension state changes
this.setExtensionReadPubkey().then(pubkey => {
if (pubkey) {
// Update current account if extension is the signer
const current = Store.getCurrent();
if (current && current.signer?.method === 'extension') {
const info = {
...current,
pubkey,
signer: { method: 'extension' }
};
Store.setCurrent(info);
LiteState.bus?.emit('authStateUpdate', info);
}
}
});
}
}
// Main API surface
class NostrLite {
static async init(options = {}) {
// Ensure dependencies are loaded
Deps.ensureNostrToolsLoaded();
// Prevent double initialization
if (LiteState.initialized) {
console.warn('NOSTR_LOGIN_LITE already initialized');
return;
}
// Initialize components
LiteState.bus = new Bus();
LiteState.extensionBridge = new ExtensionBridge();
// Initialize NIP-46 client
LiteState.nip46Client = new NIP46Client();
// Store options
LiteState.options = {
theme: 'light',
darkMode: false,
relays: Relays.getDefaultRelays(options),
methods: {
connect: true,
extension: true,
local: true,
readonly: true,
otp: true
},
otp: {},
...options
};
// Start extension detection
LiteState.extensionBridge.startChecking(windowNostr);
// Setup auth methods
this._setupAuth();
// Initialize modal UI
// this._initModal();
console.log('NOSTR_LOGIN_LITE initialized with options:', LiteState.options);
LiteState.initialized = true;
}
static _setupAuth() {
// Set up event listeners for modal interactions
window.addEventListener('nlMethodSelected', (event) => {
this._handleMethodSelected(event.detail);
});
// Set up other auth-related event listeners
this._setupAuthEventListeners();
console.log('Auth system setup loaded');
}
static _setupAuthEventListeners() {
// Handle extension detection
this.bus?.on('extensionDetected', (extension) => {
console.log('Extension detected');
LiteState.extensionBridge.setPrimaryExtension(extension, 'detected');
});
// Handle auth URL from NIP-46
window.addEventListener('nlAuthUrl', (event) => {
console.log('Auth URL received:', event.detail.url);
// Could show URL in modal or trigger external flow
});
// Handle logout events
window.addEventListener('nlLogout', () => {
console.log('Logout event received');
this.logout();
});
}
static _handleMethodSelected(detail) {
console.log('Method selected:', detail);
const { method, pubkey, secret, extension } = detail;
switch (method) {
case 'local':
if (secret && pubkey) {
// Set up local key authentication
const info = {
pubkey,
signer: { method: 'local', secret }
};
Store.setCurrent(info);
LiteState.bus?.emit('authStateUpdate', info);
this._dispatchAuthEvent('login', info);
}
break;
case 'extension':
if (pubkey && extension) {
// Store the extension object in the ExtensionBridge for future use
LiteState.extensionBridge.setPrimaryExtension(extension, 'modal-selected');
LiteState.extensionBridge.originalNostr = extension;
// Set up extension authentication
const info = {
pubkey,
signer: { method: 'extension' }
};
Store.setCurrent(info);
LiteState.bus?.emit('authStateUpdate', info);
this._dispatchAuthEvent('login', info);
console.log('Extension authentication set up successfully');
} else {
// Fallback to extension bridge detection
LiteState.bus?.emit('authMethodSelected', { method: 'extension' });
}
break;
case 'readonly':
// Set read-only mode
const readonlyInfo = {
pubkey: '',
signer: { method: 'readonly' }
};
Store.setCurrent(readonlyInfo);
LiteState.bus?.emit('authStateUpdate', readonlyInfo);
this._dispatchAuthEvent('login', readonlyInfo);
break;
case 'nip46':
if (secret && pubkey) {
// Set up NIP-46 remote signing
const info = {
pubkey,
signer: { method: 'nip46', ...secret }
};
Store.setCurrent(info);
LiteState.bus?.emit('authStateUpdate', info);
this._dispatchAuthEvent('login', info);
}
break;
default:
console.warn('Unhandled auth method:', method);
}
}
static _dispatchAuthEvent(type, info) {
const eventPayload = {
type,
info,
pubkey: info?.pubkey || '',
method: info?.signer?.method || '',
...info
};
// Dispatch the event
window.dispatchEvent(new CustomEvent('nlAuth', { detail: eventPayload }));
this.bus?.emit('nlAuth', eventPayload);
}
static launch(startScreen) {
if (!LiteState.initialized) {
throw new Error('NOSTR_LOGIN_LITE not initialized. Call init() first.');
}
console.log('Launch requested with screen:', startScreen);
// Initialize modal if needed
if (!LiteState.modal) {
// Import modal lazily
if (typeof Modal !== 'undefined') {
LiteState.modal = Modal.init(LiteState.options);
} else {
console.error('Modal component not available');
return;
}
}
// Open modal with specified screen
LiteState.modal.open({ startScreen });
}
static logout() {
if (!LiteState.initialized) return;
// Clear current account and state
Store.removeCurrentAccount();
// Reset internal state
LiteState.auth = null;
// Emit logout event
window.dispatchEvent(new CustomEvent('nlLogout'));
LiteState.bus?.emit('logout');
console.log('Logged out');
}
static setDarkMode(dark) {
if (!LiteState.options) return;
LiteState.options.darkMode = dark;
Store.setItem('darkMode', dark.toString());
// Update modal theme if initialized
if (LiteState.modal) {
// LiteState.modal.updateTheme();
}
window.dispatchEvent(new CustomEvent('nlDarkMode', { detail: { dark } }));
}
static setAuth(o) {
if (!o || !o.type) return;
console.log('setAuth called:', o);
// Validate request
if (!['login', 'signup', 'logout'].includes(o.type)) {
throw new Error(`Invalid auth type: ${o.type}`);
}
if (['login', 'signup'].includes(o.type) && !['connect', 'extension', 'local', 'otp', 'readOnly'].includes(o.method)) {
throw new Error(`Invalid auth method: ${o.method}`);
}
// Handle based on type
switch (o.type) {
case 'logout':
this.logout();
break;
default:
// Delegate to auth system - will be implemented
console.log('Auth delegation not yet implemented');
}
}
static cancelNeedAuth() {
// Cancel any ongoing auth flows
LiteState.bus?.emit('cancelAuth');
console.log('Auth flow cancelled');
}
}
// Initialize the window.nostr facade
const windowNostr = {
async getPublicKey() {
if (!LiteState.initialized) {
throw new Error('NOSTR_LOGIN_LITE not initialized');
}
const current = Store.getCurrent();
if (current && current.pubkey) {
return current.pubkey;
}
// Trigger auth flow
const authPromise = new Promise((resolve, reject) => {
const handleAuth = (event) => {
window.removeEventListener('nlAuth', handleAuth);
if (event.detail.type === 'login' && event.detail.pubkey) {
resolve(event.detail.pubkey);
} else {
reject(new Error('Authentication cancelled'));
}
};
window.addEventListener('nlAuth', handleAuth);
// Set timeout
setTimeout(() => {
window.removeEventListener('nlAuth', handleAuth);
reject(new Error('Authentication timeout'));
}, 300000); // 5 minutes
});
// Launch auth modal
NostrLite.launch('login');
return authPromise;
},
async signEvent(event) {
if (!LiteState.initialized) {
throw new Error('NOSTR_LOGIN_LITE not initialized');
}
let current = Store.getCurrent();
// If no current account, trigger auth
if (!current) {
await window.nostr.getPublicKey(); // This will trigger auth
current = Store.getCurrent();
if (!current) {
throw new Error('Authentication failed');
}
}
// Route to appropriate signer
if (current.signer?.method === 'local' && current.signer.secret) {
const signer = new LocalSigner(this._hexToUint8Array(current.signer.secret));
return await signer.sign(event);
} else if (current.signer?.method === 'nip46' && current.signer.bunkerSigner) {
// Route to NIP-46 remote signer
try {
const bunkerSigner = current.signer.bunkerSigner;
const signedEvent = await bunkerSigner.signEvent(event);
return signedEvent;
} catch (error) {
console.error('NIP-46 signEvent failed:', error);
throw new Error(`NIP-46 signing failed: ${error.message}`);
}
} else if (current.signer?.method === 'readonly') {
throw new Error('Cannot sign events in read-only mode');
} else if (current.signer?.method === 'extension' && LiteState.extensionBridge?.hasExtension()) {
// Route to extension
const temp = window.nostr;
window.nostr = LiteState.extensionBridge.foundExtension;
try {
const signedEvent = await window.nostr.signEvent(event);
return signedEvent;
} finally {
window.nostr = temp;
}
}
throw new Error('No suitable signer available for current account');
},
_hexToUint8Array(hex) {
// Convert hex string to Uint8Array
const bytes = new Uint8Array(hex.length / 2);
for (let i = 0; i < bytes.length; i++) {
bytes[i] = parseInt(hex.substr(i * 2, 2), 16);
}
return bytes;
},
nip04: {
async encrypt(pubkey, plaintext) {
if (!LiteState.initialized) {
throw new Error('NOSTR_LOGIN_LITE not initialized');
}
const current = Store.getCurrent();
if (!current) {
throw new Error('No authenticated user');
}
if (current.signer?.method === 'nip46' && current.signer.bunkerSigner) {
// Route to NIP-46 remote signer
try {
const bunkerSigner = current.signer.bunkerSigner;
return await bunkerSigner.nip04Encrypt(pubkey, plaintext);
} catch (error) {
console.error('NIP-46 nip04 encrypt failed:', error);
throw new Error(`NIP-46 encrypting failed: ${error.message}`);
}
} else if (current.signer?.method === 'local' && current.signer.secret) {
const signer = new LocalSigner(current.signer.secret);
return await signer.encrypt04(pubkey, plaintext);
} else if (current.signer?.method === 'extension' && LiteState.extensionBridge?.hasExtension()) {
const temp = window.nostr;
window.nostr = LiteState.extensionBridge.foundExtension;
try {
return await window.nostr.nip04.encrypt(pubkey, plaintext);
} finally {
window.nostr = temp;
}
}
throw new Error('No suitable signer available for NIP-04 encryption');
},
async decrypt(pubkey, ciphertext) {
if (!LiteState.initialized) {
throw new Error('NOSTR_LOGIN_LITE not initialized');
}
const current = Store.getCurrent();
if (!current) {
throw new Error('No authenticated user');
}
if (current.signer?.method === 'nip46' && current.signer.bunkerSigner) {
// Route to NIP-46 remote signer
try {
const bunkerSigner = current.signer.bunkerSigner;
return await bunkerSigner.nip04Decrypt(pubkey, ciphertext);
} catch (error) {
console.error('NIP-46 nip04 decrypt failed:', error);
throw new Error(`NIP-46 decrypting failed: ${error.message}`);
}
} else if (current.signer?.method === 'local' && current.signer.secret) {
const signer = new LocalSigner(current.signer.secret);
return await signer.decrypt04(pubkey, ciphertext);
} else if (current.signer?.method === 'extension' && LiteState.extensionBridge?.hasExtension()) {
const temp = window.nostr;
window.nostr = LiteState.extensionBridge.foundExtension;
try {
return await window.nostr.nip04.decrypt(pubkey, ciphertext);
} finally {
window.nostr = temp;
}
}
throw new Error('No suitable signer available for NIP-04 decryption');
}
},
nip44: {
async encrypt(pubkey, plaintext) {
if (!LiteState.initialized) {
throw new Error('NOSTR_LOGIN_LITE not initialized');
}
const current = Store.getCurrent();
if (!current) {
throw new Error('No authenticated user');
}
if (current.signer?.method === 'nip46' && current.signer.bunkerSigner) {
// Route to NIP-46 remote signer
try {
const bunkerSigner = current.signer.bunkerSigner;
return await bunkerSigner.nip44Encrypt(pubkey, plaintext);
} catch (error) {
console.error('NIP-46 nip44 encrypt failed:', error);
throw new Error(`NIP-46 encrypting failed: ${error.message}`);
}
} else if (current.signer?.method === 'local' && current.signer.secret) {
const signer = new LocalSigner(current.signer.secret);
return await signer.encrypt44(pubkey, plaintext);
} else if (current.signer?.method === 'extension' && LiteState.extensionBridge?.hasExtension()) {
// Use extension if it supports nip44
const temp = window.nostr;
window.nostr = LiteState.extensionBridge.foundExtension;
try {
if (window.nostr.nip44) {
return await window.nostr.nip44.encrypt(pubkey, plaintext);
} else {
throw new Error('Extension does not support NIP-44');
}
} finally {
window.nostr = temp;
}
}
throw new Error('No suitable signer available for NIP-44 encryption');
},
async decrypt(pubkey, ciphertext) {
if (!LiteState.initialized) {
throw new Error('NOSTR_LOGIN_LITE not initialized');
}
const current = Store.getCurrent();
if (!current) {
throw new Error('No authenticated user');
}
if (current.signer?.method === 'nip46' && current.signer.bunkerSigner) {
// Route to NIP-46 remote signer
try {
const bunkerSigner = current.signer.bunkerSigner;
return await bunkerSigner.nip44Decrypt(pubkey, ciphertext);
} catch (error) {
console.error('NIP-46 nip44 decrypt failed:', error);
throw new Error(`NIP-46 decrypting failed: ${error.message}`);
}
} else if (current.signer?.method === 'local' && current.signer.secret) {
const signer = new LocalSigner(current.signer.secret);
return await signer.decrypt44(pubkey, ciphertext);
} else if (current.signer?.method === 'extension' && LiteState.extensionBridge?.hasExtension()) {
const temp = window.nostr;
window.nostr = LiteState.extensionBridge.foundExtension;
try {
if (window.nostr.nip44) {
return await window.nostr.nip44.decrypt(pubkey, ciphertext);
} else {
throw new Error('Extension does not support NIP-44');
}
} finally {
window.nostr = temp;
}
}
throw new Error('No suitable signer available for NIP-44 decryption');
}
}
};
// Export the API
window.NOSTR_LOGIN_LITE = {
init: NostrLite.init.bind(NostrLite),
launch: NostrLite.launch.bind(NostrLite),
logout: NostrLite.logout.bind(NostrLite),
setDarkMode: NostrLite.setDarkMode.bind(NostrLite),
setAuth: NostrLite.setAuth.bind(NostrLite),
cancelNeedAuth: NostrLite.cancelNeedAuth.bind(NostrLite),
// Expose internal components for debugging
get _extensionBridge() {
return LiteState.extensionBridge;
},
get _state() {
return LiteState;
}
};
// Set window.nostr facade properly (extensions will be handled by ExtensionBridge)
if (typeof window !== 'undefined') {
window.nostr = windowNostr;
// Ensure all methods are properly exposed
console.log('NOSTR_LOGIN_LITE: window.nostr facade installed with methods:', Object.keys(windowNostr));
}
console.log('NOSTR_LOGIN_LITE loaded - use window.NOSTR_LOGIN_LITE.init(options) to initialize');