/**
* NOSTR_LOGIN_LITE
* Single-file Nostr authentication library
* Generated on: 2025-09-13T13:03:05.960Z
*/
// ======================================
// 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();
if (availableExtensions.length === 0) {
console.log('Modal: No real extensions found');
this._showExtensionRequired();
} else if (availableExtensions.length === 1) {
console.log('Modal detected single extension:', availableExtensions[0].name);
this._tryExtensionLogin(availableExtensions[0].extension);
} else {
console.log('Modal detected multiple extensions:', availableExtensions.map(e => e.name));
this._showExtensionChoice(availableExtensions);
}
}
_detectAllExtensions() {
const extensions = [];
// Check navigator.nostr (NIP-07 standard location)
if (window.navigator?.nostr && typeof window.navigator.nostr.getPublicKey === 'function') {
extensions.push({
name: 'navigator.nostr',
displayName: 'Standard Extension (navigator.nostr)',
icon: 'š',
extension: window.navigator.nostr
});
}
// Check webln.nostr (Alby WebLN)
if (window.webln?.nostr && typeof window.webln.nostr.getPublicKey === 'function') {
extensions.push({
name: 'webln.nostr',
displayName: 'Alby WebLN Extension',
icon: 'ā”',
extension: window.webln.nostr
});
}
// Check alby.nostr (Alby direct)
if (window.alby?.nostr && typeof window.alby.nostr.getPublicKey === 'function') {
extensions.push({
name: 'alby.nostr',
displayName: 'Alby Extension (Direct)',
icon: 'š',
extension: window.alby.nostr
});
}
// Check nos2x
if (window.nos2x && typeof window.nos2x.getPublicKey === 'function') {
extensions.push({
name: 'nos2x',
displayName: 'nos2x Extension',
icon: 'š',
extension: window.nos2x
});
}
// Check window.nostr but make sure it's not our library
if (window.nostr && typeof window.nostr.getPublicKey === 'function') {
const isRealExtension = (
typeof window.nostr._hexToUint8Array !== 'function' && // Our library has this method
window.nostr.constructor.name !== 'Object' // Real extensions usually have proper constructors
);
if (isRealExtension) {
// Don't add if we already detected it via another path
const alreadyDetected = extensions.some(ext => ext.extension === window.nostr);
if (!alreadyDetected) {
extensions.push({
name: 'window.nostr',
displayName: 'Extension (window.nostr)',
icon: 'š',
extension: window.nostr
});
}
}
}
return extensions;
}
_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 = '
š Connecting to extension...
';
// Get pubkey from extension
const pubkey = await extensionObj.getPublicKey();
console.log('Extension provided pubkey:', pubkey);
// Set extension method with the extension object
this._setAuthMethod('extension', { pubkey, extension: extensionObj });
} catch (error) {
console.error('Extension login failed:', error);
this._showError(`Extension login failed: ${error.message}`);
}
}
_showLocalKeyScreen() {
this.modalBody.innerHTML = '';
const title = document.createElement('h3');
title.textContent = 'Local Key';
title.style.cssText = 'margin: 0 0 20px 0; font-size: 18px; font-weight: 600;';
const createButton = document.createElement('button');
createButton.textContent = 'Create New Key';
createButton.onclick = () => this._createLocalKey();
createButton.style.cssText = this._getButtonStyle();
const importButton = document.createElement('button');
importButton.textContent = 'Import Existing Key';
importButton.onclick = () => this._showImportKeyForm();
importButton.style.cssText = this._getButtonStyle() + 'margin-top: 12px;';
const backButton = document.createElement('button');
backButton.textContent = 'Back';
backButton.onclick = () => this._renderLoginOptions();
backButton.style.cssText = `
display: block;
margin-top: 20px;
padding: 12px;
background: #6b7280;
color: white;
border: none;
border-radius: 6px;
cursor: pointer;
`;
this.modalBody.appendChild(title);
this.modalBody.appendChild(createButton);
this.modalBody.appendChild(importButton);
this.modalBody.appendChild(backButton);
}
_createLocalKey() {
try {
const sk = window.NostrTools.generateSecretKey();
const pk = window.NostrTools.getPublicKey(sk);
const nsec = window.NostrTools.nip19.nsecEncode(sk);
const npub = window.NostrTools.nip19.npubEncode(pk);
this._showKeyDisplay(pk, nsec, 'created');
} catch (error) {
this._showError('Failed to create key: ' + error.message);
}
}
_showImportKeyForm() {
this.modalBody.innerHTML = '';
const title = document.createElement('h3');
title.textContent = 'Import Local Key';
title.style.cssText = 'margin: 0 0 16px 0; font-size: 18px; font-weight: 600;';
const description = document.createElement('p');
description.textContent = 'Enter your secret key in either nsec or hex format:';
description.style.cssText = 'margin-bottom: 12px; color: #6b7280; font-size: 14px;';
const textarea = document.createElement('textarea');
textarea.placeholder = 'Enter your secret key:\n⢠nsec1... (bech32 format)\n⢠64-character hex string';
textarea.style.cssText = `
width: 100%;
height: 100px;
padding: 12px;
border: 1px solid #d1d5db;
border-radius: 6px;
margin-bottom: 12px;
resize: none;
font-family: monospace;
font-size: 14px;
box-sizing: border-box;
`;
// Add real-time format detection
const formatHint = document.createElement('div');
formatHint.style.cssText = 'margin-bottom: 16px; font-size: 12px; color: #6b7280; min-height: 16px;';
textarea.oninput = () => {
const value = textarea.value.trim();
if (!value) {
formatHint.textContent = '';
return;
}
const format = this._detectKeyFormat(value);
if (format === 'nsec') {
formatHint.textContent = 'ā
Valid nsec format detected';
formatHint.style.color = '#059669';
} else if (format === 'hex') {
formatHint.textContent = 'ā
Valid hex format detected';
formatHint.style.color = '#059669';
} else {
formatHint.textContent = 'ā Invalid key format - must be nsec1... or 64-character hex';
formatHint.style.color = '#dc2626';
}
};
const importButton = document.createElement('button');
importButton.textContent = 'Import Key';
importButton.onclick = () => this._importLocalKey(textarea.value);
importButton.style.cssText = this._getButtonStyle();
const backButton = document.createElement('button');
backButton.textContent = 'Back';
backButton.onclick = () => this._showLocalKeyScreen();
backButton.style.cssText = this._getButtonStyle('secondary') + 'margin-top: 12px;';
this.modalBody.appendChild(title);
this.modalBody.appendChild(description);
this.modalBody.appendChild(textarea);
this.modalBody.appendChild(formatHint);
this.modalBody.appendChild(importButton);
this.modalBody.appendChild(backButton);
}
_detectKeyFormat(keyValue) {
const trimmed = keyValue.trim();
// Check for nsec format
if (trimmed.startsWith('nsec1') && trimmed.length === 63) {
try {
window.NostrTools.nip19.decode(trimmed);
return 'nsec';
} catch {
return 'invalid';
}
}
// Check for hex format (64 characters, valid hex)
if (trimmed.length === 64 && /^[a-fA-F0-9]{64}$/.test(trimmed)) {
return 'hex';
}
return 'invalid';
}
_importLocalKey(keyValue) {
try {
const trimmed = keyValue.trim();
if (!trimmed) {
throw new Error('Please enter a secret key');
}
const format = this._detectKeyFormat(trimmed);
let sk;
if (format === 'nsec') {
// Decode nsec format - this returns Uint8Array
const decoded = window.NostrTools.nip19.decode(trimmed);
if (decoded.type !== 'nsec') {
throw new Error('Invalid nsec format');
}
sk = decoded.data; // This is already Uint8Array
} else if (format === 'hex') {
// Convert hex string to Uint8Array
sk = this._hexToUint8Array(trimmed);
// Test that it's a valid secret key by trying to get public key
window.NostrTools.getPublicKey(sk);
} else {
throw new Error('Invalid key format. Please enter either nsec1... or 64-character hex string');
}
// Generate public key and encoded formats
const pk = window.NostrTools.getPublicKey(sk);
const nsec = window.NostrTools.nip19.nsecEncode(sk);
const npub = window.NostrTools.nip19.npubEncode(pk);
this._showKeyDisplay(pk, nsec, 'imported');
} catch (error) {
this._showError('Invalid key: ' + error.message);
}
}
_hexToUint8Array(hex) {
// Convert hex string to Uint8Array
if (hex.length % 2 !== 0) {
throw new Error('Invalid hex string length');
}
const bytes = new Uint8Array(hex.length / 2);
for (let i = 0; i < bytes.length; i++) {
bytes[i] = parseInt(hex.substr(i * 2, 2), 16);
}
return bytes;
}
_showKeyDisplay(pubkey, nsec, action) {
this.modalBody.innerHTML = '';
const title = document.createElement('h3');
title.textContent = `Key ${action} successfully!`;
title.style.cssText = 'margin: 0 0 16px 0; font-size: 18px; font-weight: 600; color: #059669;';
const warningDiv = document.createElement('div');
warningDiv.textContent = 'ā ļø Save your secret key securely!';
warningDiv.style.cssText = 'background: #fef3c7; color: #92400e; padding: 12px; border-radius: 6px; margin-bottom: 16px; font-size: 14px;';
const nsecDiv = document.createElement('div');
nsecDiv.innerHTML = `Your Secret Key:
${nsec}`;
nsecDiv.style.cssText = 'margin-bottom: 16px; font-size: 14px;';
const npubDiv = document.createElement('div');
npubDiv.innerHTML = `Your Public Key:
${window.NostrTools.nip19.npubEncode(pubkey)}`;
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 = `Error: ${message}`;
const backButton = document.createElement('button');
backButton.textContent = 'Back';
backButton.onclick = () => this._renderLoginOptions();
backButton.style.cssText = this._getButtonStyle('secondary');
this.modalBody.appendChild(errorDiv);
this.modalBody.appendChild(backButton);
}
_showExtensionRequired() {
this.modalBody.innerHTML = '';
const title = document.createElement('h3');
title.textContent = 'Browser Extension Required';
title.style.cssText = 'margin: 0 0 16px 0; font-size: 18px; font-weight: 600;';
const message = document.createElement('p');
message.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 = `
Connecting to bunker:
Pubkey: ${displayPubkey}
Relay: ${bunkerUrl || 'ws://localhost:8080'}
If this relay is offline, the bunker server may be unavailable.
`;
const connectingDiv = document.createElement('div');
connectingDiv.style.cssText = 'text-align: center; color: #6b7280;';
connectingDiv.innerHTML = `
ā³
Please wait while we establish the connection...
This may take a few seconds
`;
this.modalBody.appendChild(title);
this.modalBody.appendChild(description);
this.modalBody.appendChild(bunkerInfo);
this.modalBody.appendChild(connectingDiv);
}
async _performNip46Connect(bunkerPubkey, 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: '
);
}
// 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.foundExtension = null;
}
startChecking(nostrLite) {
if (this.checking) return;
this.checking = true;
const check = () => {
this.initExtension(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);
}
initExtension(nostrLite, lastTry = false) {
const extension = window.nostr;
if (extension && !this.foundExtension) {
// Check if this is actually a real extension, not our own library
const isRealExtension = (
extension !== nostrLite && // Not the same object we're about to assign
extension !== windowNostr && // Not our windowNostr object
typeof extension._hexToUint8Array !== 'function' && // Our library has this internal method
extension.constructor.name !== 'Object' // Real extensions usually have proper constructors
);
if (isRealExtension) {
this.foundExtension = extension;
// Cache the extension and reassign window.nostr to our lite version
this.originalNostr = window.nostr;
window.nostr = nostrLite;
console.log('Real Nostr extension detected and bridged:', extension.constructor.name);
// If currently authenticated, reconcile state
if (LiteState.auth?.signer?.method === 'extension') {
this.reconcileExtension();
}
} else {
console.log('Skipping non-extension object on window.nostr:', extension.constructor.name);
}
}
}
hasExtension() {
return !!this.foundExtension;
}
async setExtensionReadPubkey(expectedPubkey = null) {
if (!this.foundExtension) return false;
try {
// Temporarily set window.nostr to extension
const temp = window.nostr;
window.nostr = this.foundExtension;
const pubkey = await this.foundExtension.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.foundExtension) return;
window.nostr = this.foundExtension;
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.foundExtension = extension;
});
// 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.foundExtension = extension;
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');