2542 lines
78 KiB
JavaScript
2542 lines
78 KiB
JavaScript
/**
|
||
* 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');
|
||
|