2145 lines
73 KiB
JavaScript
2145 lines
73 KiB
JavaScript
/**
|
|
* 🏗️ NOSTR_LOGIN_LITE Build Script
|
|
*
|
|
* ⚠️ IMPORTANT: This file contains the source code for the NOSTR_LOGIN_LITE library!
|
|
* ⚠️ DO NOT edit lite/nostr-lite.js directly - it's auto-generated by this script!
|
|
* ⚠️ To modify the library, edit this file (build.js) and run: node build.js
|
|
*
|
|
* This script builds the two-file architecture:
|
|
* 1. nostr.bundle.js (official nostr-tools bundle - static file)
|
|
* 2. nostr-lite.js (NOSTR_LOGIN_LITE library - built by this script)
|
|
*
|
|
* Features included:
|
|
* - CSS-Only Theme System (no JSON duplication)
|
|
* - Modal UI Component
|
|
* - FloatingTab Component
|
|
* - Extension Bridge
|
|
* - Window.nostr facade
|
|
* - Main NostrLite class with all functionality
|
|
*/
|
|
|
|
const fs = require('fs');
|
|
const path = require('path');
|
|
|
|
function createNostrLoginLiteBundle() {
|
|
console.log('🔧 Creating NOSTR_LOGIN_LITE bundle for two-file architecture...');
|
|
|
|
const outputPath = path.join(__dirname, 'nostr-lite.js');
|
|
|
|
// Remove old bundle
|
|
try {
|
|
if (fs.existsSync(outputPath)) {
|
|
fs.unlinkSync(outputPath);
|
|
}
|
|
} catch (e) {
|
|
console.log('No old bundle to remove');
|
|
}
|
|
|
|
// Start with the bundle header
|
|
let bundle = `/**
|
|
* NOSTR_LOGIN_LITE - Authentication Library
|
|
*
|
|
* ⚠️ WARNING: THIS FILE IS AUTO-GENERATED - DO NOT EDIT MANUALLY!
|
|
* ⚠️ To make changes, edit lite/build.js and run: cd lite && node build.js
|
|
* ⚠️ Any manual edits to this file will be OVERWRITTEN when build.js runs!
|
|
*
|
|
* Two-file architecture:
|
|
* 1. Load nostr.bundle.js (official nostr-tools bundle)
|
|
* 2. Load nostr-lite.js (this file - NOSTR_LOGIN_LITE library with CSS-only themes)
|
|
* Generated on: ${new Date().toISOString()}
|
|
*/
|
|
|
|
// Verify dependencies are loaded
|
|
if (typeof window !== 'undefined') {
|
|
if (!window.NostrTools) {
|
|
console.error('NOSTR_LOGIN_LITE: nostr.bundle.js must be loaded first');
|
|
throw new Error('Missing dependency: nostr.bundle.js');
|
|
}
|
|
|
|
console.log('NOSTR_LOGIN_LITE: Dependencies verified ✓');
|
|
console.log('NOSTR_LOGIN_LITE: NostrTools available with keys:', Object.keys(window.NostrTools));
|
|
console.log('NOSTR_LOGIN_LITE: NIP-06 available:', !!window.NostrTools.nip06);
|
|
console.log('NOSTR_LOGIN_LITE: NIP-46 available:', !!window.NostrTools.nip46);
|
|
}
|
|
|
|
// ======================================
|
|
// NOSTR_LOGIN_LITE Components
|
|
// ======================================
|
|
|
|
`;
|
|
|
|
// Embed CSS themes
|
|
console.log('🎨 Adding CSS-Only Theme System...');
|
|
|
|
const defaultThemeCssPath = path.join(__dirname, '../themes/default/theme.css');
|
|
const darkThemeCssPath = path.join(__dirname, '../themes/dark/theme.css');
|
|
|
|
if (fs.existsSync(defaultThemeCssPath) && fs.existsSync(darkThemeCssPath)) {
|
|
const defaultThemeCss = fs.readFileSync(defaultThemeCssPath, 'utf8')
|
|
.replace(/\\/g, '\\\\')
|
|
.replace(/`/g, '\\`')
|
|
.replace(/\${/g, '\\${');
|
|
|
|
const darkThemeCss = fs.readFileSync(darkThemeCssPath, 'utf8')
|
|
.replace(/\\/g, '\\\\')
|
|
.replace(/`/g, '\\`')
|
|
.replace(/\${/g, '\\${');
|
|
|
|
bundle += `// ======================================\n`;
|
|
bundle += `// CSS-Only Theme System\n`;
|
|
bundle += `// ======================================\n\n`;
|
|
|
|
bundle += `const THEME_CSS = {\n`;
|
|
bundle += ` 'default': \`${defaultThemeCss}\`,\n`;
|
|
bundle += ` 'dark': \`${darkThemeCss}\`\n`;
|
|
bundle += `};\n\n`;
|
|
|
|
bundle += `// Theme management functions\n`;
|
|
bundle += `function injectThemeCSS(themeName = 'default') {\n`;
|
|
bundle += ` if (typeof document !== 'undefined') {\n`;
|
|
bundle += ` // Remove existing theme CSS\n`;
|
|
bundle += ` const existingStyle = document.getElementById('nl-theme-css');\n`;
|
|
bundle += ` if (existingStyle) {\n`;
|
|
bundle += ` existingStyle.remove();\n`;
|
|
bundle += ` }\n`;
|
|
bundle += ` \n`;
|
|
bundle += ` // Inject selected theme CSS\n`;
|
|
bundle += ` const themeCss = THEME_CSS[themeName] || THEME_CSS['default'];\n`;
|
|
bundle += ` const style = document.createElement('style');\n`;
|
|
bundle += ` style.id = 'nl-theme-css';\n`;
|
|
bundle += ` style.textContent = themeCss;\n`;
|
|
bundle += ` document.head.appendChild(style);\n`;
|
|
bundle += ` console.log(\`NOSTR_LOGIN_LITE: \${themeName} theme CSS injected\`);\n`;
|
|
bundle += ` }\n`;
|
|
bundle += `}\n\n`;
|
|
|
|
// Auto-inject default theme on load
|
|
bundle += `// Auto-inject default theme when DOM is ready\n`;
|
|
bundle += `if (typeof document !== 'undefined') {\n`;
|
|
bundle += ` if (document.readyState === 'loading') {\n`;
|
|
bundle += ` document.addEventListener('DOMContentLoaded', () => injectThemeCSS('default'));\n`;
|
|
bundle += ` } else {\n`;
|
|
bundle += ` injectThemeCSS('default');\n`;
|
|
bundle += ` }\n`;
|
|
bundle += `}\n\n`;
|
|
}
|
|
|
|
// Add Modal UI
|
|
const modalPath = path.join(__dirname, 'ui/modal.js');
|
|
if (fs.existsSync(modalPath)) {
|
|
console.log('📄 Adding Modal UI...');
|
|
|
|
let modalContent = fs.readFileSync(modalPath, 'utf8');
|
|
|
|
// Skip header comments
|
|
let lines = modalContent.split('\n');
|
|
let contentStartIndex = 0;
|
|
|
|
for (let i = 0; i < Math.min(15, lines.length); i++) {
|
|
const line = lines[i].trim();
|
|
if (line.startsWith('/**') || line.startsWith('*') ||
|
|
line.startsWith('/*') || line.startsWith('//')) {
|
|
contentStartIndex = i + 1;
|
|
} else if (line && !line.startsWith('*') && !line.startsWith('//')) {
|
|
break;
|
|
}
|
|
}
|
|
|
|
if (contentStartIndex > 0) {
|
|
lines = lines.slice(contentStartIndex);
|
|
}
|
|
|
|
bundle += `// ======================================\n`;
|
|
bundle += `// Modal UI Component\n`;
|
|
bundle += `// ======================================\n\n`;
|
|
bundle += lines.join('\n');
|
|
bundle += '\n\n';
|
|
} else {
|
|
console.warn('⚠️ Modal UI not found: ui/modal.js');
|
|
}
|
|
|
|
// Add main library code
|
|
console.log('📄 Adding Main Library...');
|
|
bundle += `
|
|
// ======================================
|
|
// FloatingTab Component (Recovered from git history)
|
|
// ======================================
|
|
|
|
class FloatingTab {
|
|
constructor(modal, options = {}) {
|
|
this.modal = modal;
|
|
this.options = {
|
|
enabled: true,
|
|
hPosition: 1.0, // 0.0 = left, 1.0 = right
|
|
vPosition: 0.5, // 0.0 = top, 1.0 = bottom
|
|
offset: { x: 0, y: 0 },
|
|
appearance: {
|
|
style: 'pill', // 'pill', 'square', 'circle'
|
|
theme: 'auto', // 'auto', 'light', 'dark'
|
|
icon: '',
|
|
text: 'Login',
|
|
iconOnly: false
|
|
},
|
|
behavior: {
|
|
hideWhenAuthenticated: true,
|
|
showUserInfo: true,
|
|
autoSlide: true,
|
|
persistent: false
|
|
},
|
|
getUserInfo: false,
|
|
getUserRelay: [],
|
|
...options
|
|
};
|
|
|
|
this.isAuthenticated = false;
|
|
this.userInfo = null;
|
|
this.userProfile = null;
|
|
this.container = null;
|
|
this.isVisible = false;
|
|
|
|
if (this.options.enabled) {
|
|
this._init();
|
|
}
|
|
}
|
|
|
|
_init() {
|
|
console.log('FloatingTab: Initializing with options:', this.options);
|
|
this._createContainer();
|
|
this._setupEventListeners();
|
|
this._updateAppearance();
|
|
this._position();
|
|
this.show();
|
|
}
|
|
|
|
_createContainer() {
|
|
// Remove existing floating tab if any
|
|
const existingTab = document.getElementById('nl-floating-tab');
|
|
if (existingTab) {
|
|
existingTab.remove();
|
|
}
|
|
|
|
this.container = document.createElement('div');
|
|
this.container.id = 'nl-floating-tab';
|
|
this.container.className = 'nl-floating-tab';
|
|
|
|
// Base styles - positioning and behavior
|
|
this.container.style.cssText = \`
|
|
position: fixed;
|
|
z-index: 9999;
|
|
cursor: pointer;
|
|
user-select: none;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
transition: all 0.2s ease;
|
|
font-size: 14px;
|
|
font-weight: 500;
|
|
padding: 8px 16px;
|
|
min-width: 80px;
|
|
max-width: 200px;
|
|
white-space: nowrap;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
\`;
|
|
|
|
document.body.appendChild(this.container);
|
|
}
|
|
|
|
_setupEventListeners() {
|
|
if (!this.container) return;
|
|
|
|
// Click handler
|
|
this.container.addEventListener('click', (e) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
this._handleClick();
|
|
});
|
|
|
|
// Hover effects
|
|
this.container.addEventListener('mouseenter', () => {
|
|
if (this.options.behavior.autoSlide) {
|
|
this._slideIn();
|
|
}
|
|
});
|
|
|
|
this.container.addEventListener('mouseleave', () => {
|
|
if (this.options.behavior.autoSlide) {
|
|
this._slideOut();
|
|
}
|
|
});
|
|
|
|
// Listen for authentication events
|
|
window.addEventListener('nlMethodSelected', (e) => {
|
|
console.log('🔍 FloatingTab: Authentication method selected event received');
|
|
console.log('🔍 FloatingTab: Event detail:', e.detail);
|
|
this._handleAuth(e.detail);
|
|
});
|
|
|
|
window.addEventListener('nlAuthRestored', (e) => {
|
|
console.log('🔍 FloatingTab: ✅ Authentication restored event received');
|
|
console.log('🔍 FloatingTab: Event detail:', e.detail);
|
|
console.log('🔍 FloatingTab: Calling _handleAuth with restored data...');
|
|
this._handleAuth(e.detail);
|
|
});
|
|
|
|
window.addEventListener('nlLogout', () => {
|
|
console.log('🔍 FloatingTab: Logout event received');
|
|
this._handleLogout();
|
|
});
|
|
}
|
|
|
|
async _handleClick() {
|
|
console.log('FloatingTab: Clicked');
|
|
|
|
if (this.isAuthenticated && this.options.behavior.showUserInfo) {
|
|
// Show user menu or profile options
|
|
this._showUserMenu();
|
|
} else {
|
|
// Check if extension is available for direct login
|
|
if (window.nostr && this._isRealExtension(window.nostr)) {
|
|
console.log('FloatingTab: Extension available, attempting direct extension login');
|
|
await this._tryExtensionLogin(window.nostr);
|
|
} else {
|
|
// Open login modal
|
|
if (this.modal) {
|
|
this.modal.open({ startScreen: 'login' });
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Check if object is a real extension (same logic as NostrLite._isRealExtension)
|
|
_isRealExtension(obj) {
|
|
if (!obj || typeof obj !== 'object') {
|
|
return false;
|
|
}
|
|
|
|
// Must have required Nostr methods
|
|
if (typeof obj.getPublicKey !== 'function' || typeof obj.signEvent !== 'function') {
|
|
return false;
|
|
}
|
|
|
|
// Exclude our own library classes
|
|
const constructorName = obj.constructor?.name;
|
|
if (constructorName === 'WindowNostr' || constructorName === 'NostrLite') {
|
|
return false;
|
|
}
|
|
|
|
// Exclude NostrTools library object
|
|
if (obj === window.NostrTools) {
|
|
return false;
|
|
}
|
|
|
|
// Conservative check: Look for common extension characteristics
|
|
const extensionIndicators = [
|
|
'_isEnabled', 'enabled', 'kind', '_eventEmitter', '_scope',
|
|
'_requests', '_pubkey', 'name', 'version', 'description'
|
|
];
|
|
|
|
const hasIndicators = extensionIndicators.some(prop => obj.hasOwnProperty(prop));
|
|
|
|
// Additional check: Extensions often have specific constructor patterns
|
|
const hasExtensionConstructor = constructorName &&
|
|
constructorName !== 'Object' &&
|
|
constructorName !== 'Function';
|
|
|
|
return hasIndicators || hasExtensionConstructor;
|
|
}
|
|
|
|
// Try to login with extension and trigger proper persistence
|
|
async _tryExtensionLogin(extension) {
|
|
try {
|
|
console.log('FloatingTab: Attempting extension login');
|
|
|
|
// Get pubkey from extension
|
|
const pubkey = await extension.getPublicKey();
|
|
console.log('FloatingTab: Extension provided pubkey:', pubkey);
|
|
|
|
// Create extension auth data
|
|
const extensionAuth = {
|
|
method: 'extension',
|
|
pubkey: pubkey,
|
|
extension: extension
|
|
};
|
|
|
|
// **CRITICAL FIX**: Dispatch nlMethodSelected event to trigger persistence
|
|
console.log('FloatingTab: Dispatching nlMethodSelected for persistence');
|
|
if (typeof window !== 'undefined') {
|
|
window.dispatchEvent(new CustomEvent('nlMethodSelected', {
|
|
detail: extensionAuth
|
|
}));
|
|
}
|
|
|
|
// Also call our local _handleAuth for UI updates
|
|
await this._handleAuth(extensionAuth);
|
|
|
|
} catch (error) {
|
|
console.error('FloatingTab: Extension login failed:', error);
|
|
// Fall back to opening modal
|
|
if (this.modal) {
|
|
this.modal.open({ startScreen: 'login' });
|
|
}
|
|
}
|
|
}
|
|
|
|
async _handleAuth(authData) {
|
|
console.log('🔍 FloatingTab: === _handleAuth START ===');
|
|
console.log('🔍 FloatingTab: authData received:', authData);
|
|
console.log('🔍 FloatingTab: Current isAuthenticated before:', this.isAuthenticated);
|
|
|
|
this.isAuthenticated = true;
|
|
this.userInfo = authData;
|
|
|
|
console.log('🔍 FloatingTab: Set isAuthenticated to true');
|
|
console.log('🔍 FloatingTab: Set userInfo to:', this.userInfo);
|
|
|
|
// Fetch user profile if enabled and we have a pubkey
|
|
if (this.options.getUserInfo && authData.pubkey) {
|
|
console.log('🔍 FloatingTab: getUserInfo enabled, fetching profile for:', authData.pubkey);
|
|
try {
|
|
const profile = await this._fetchUserProfile(authData.pubkey);
|
|
this.userProfile = profile;
|
|
console.log('🔍 FloatingTab: User profile fetched:', profile);
|
|
} catch (error) {
|
|
console.warn('🔍 FloatingTab: Failed to fetch user profile:', error);
|
|
this.userProfile = null;
|
|
}
|
|
} else {
|
|
console.log('🔍 FloatingTab: getUserInfo disabled or no pubkey, skipping profile fetch');
|
|
}
|
|
|
|
console.log('🔍 FloatingTab: hideWhenAuthenticated option:', this.options.behavior.hideWhenAuthenticated);
|
|
|
|
if (this.options.behavior.hideWhenAuthenticated) {
|
|
console.log('🔍 FloatingTab: Hiding tab (hideWhenAuthenticated=true)');
|
|
this.hide();
|
|
} else {
|
|
console.log('🔍 FloatingTab: Updating appearance (hideWhenAuthenticated=false)');
|
|
this._updateAppearance();
|
|
}
|
|
|
|
console.log('🔍 FloatingTab: === _handleAuth END ===');
|
|
}
|
|
|
|
_handleLogout() {
|
|
console.log('FloatingTab: Handling logout');
|
|
this.isAuthenticated = false;
|
|
this.userInfo = null;
|
|
this.userProfile = null;
|
|
|
|
if (this.options.behavior.hideWhenAuthenticated) {
|
|
this.show();
|
|
}
|
|
|
|
this._updateAppearance();
|
|
}
|
|
|
|
_showUserMenu() {
|
|
// Simple user menu - could be expanded
|
|
const menu = document.createElement('div');
|
|
menu.style.cssText = \`
|
|
position: fixed;
|
|
background: var(--nl-secondary-color);
|
|
border: var(--nl-border-width) solid var(--nl-primary-color);
|
|
border-radius: var(--nl-border-radius);
|
|
padding: 12px;
|
|
z-index: 10000;
|
|
font-family: var(--nl-font-family);
|
|
box-shadow: 0 4px 12px rgba(0,0,0,0.15);
|
|
\`;
|
|
|
|
// Position near the floating tab
|
|
const tabRect = this.container.getBoundingClientRect();
|
|
if (this.options.hPosition > 0.5) {
|
|
// Tab is on right side, show menu to the left
|
|
menu.style.right = (window.innerWidth - tabRect.left) + 'px';
|
|
} else {
|
|
// Tab is on left side, show menu to the right
|
|
menu.style.left = tabRect.right + 'px';
|
|
}
|
|
menu.style.top = tabRect.top + 'px';
|
|
|
|
// Menu content
|
|
const userDisplay = this.userInfo?.pubkey ?
|
|
\`\${this.userInfo.pubkey.slice(0, 8)}...\${this.userInfo.pubkey.slice(-4)}\` :
|
|
'Authenticated';
|
|
|
|
menu.innerHTML = \`
|
|
<div style="margin-bottom: 8px; font-weight: bold; color: var(--nl-primary-color);">\${userDisplay}</div>
|
|
<button onclick="window.NOSTR_LOGIN_LITE.logout(); this.parentElement.remove();"
|
|
style="background: var(--nl-secondary-color); color: var(--nl-primary-color);
|
|
border: 1px solid var(--nl-primary-color); border-radius: 4px;
|
|
padding: 6px 12px; cursor: pointer; width: 100%;">
|
|
Logout
|
|
</button>
|
|
\`;
|
|
|
|
document.body.appendChild(menu);
|
|
|
|
// Auto-remove menu after delay or on outside click
|
|
const removeMenu = () => menu.remove();
|
|
setTimeout(removeMenu, 5000);
|
|
|
|
document.addEventListener('click', function onOutsideClick(e) {
|
|
if (!menu.contains(e.target) && e.target !== this.container) {
|
|
removeMenu();
|
|
document.removeEventListener('click', onOutsideClick);
|
|
}
|
|
});
|
|
}
|
|
|
|
_updateAppearance() {
|
|
if (!this.container) return;
|
|
|
|
// Update content
|
|
if (this.isAuthenticated && this.options.behavior.showUserInfo) {
|
|
let display;
|
|
|
|
// Use profile name if available, otherwise fall back to pubkey
|
|
if (this.userProfile?.name || this.userProfile?.display_name) {
|
|
const userName = this.userProfile.name || this.userProfile.display_name;
|
|
display = this.options.appearance.iconOnly
|
|
? userName.slice(0, 8)
|
|
: userName;
|
|
} else if (this.userInfo?.pubkey) {
|
|
// Fallback to pubkey display
|
|
display = this.options.appearance.iconOnly
|
|
? this.userInfo.pubkey.slice(0, 6)
|
|
: \`\${this.userInfo.pubkey.slice(0, 6)}...\`;
|
|
} else {
|
|
display = this.options.appearance.iconOnly ? 'User' : 'Authenticated';
|
|
}
|
|
|
|
this.container.textContent = display;
|
|
this.container.className = 'nl-floating-tab nl-floating-tab--logged-in';
|
|
} else {
|
|
const display = this.options.appearance.iconOnly ?
|
|
this.options.appearance.icon :
|
|
(this.options.appearance.icon ? \`\${this.options.appearance.icon} \${this.options.appearance.text}\` : this.options.appearance.text);
|
|
|
|
this.container.textContent = display;
|
|
this.container.className = 'nl-floating-tab nl-floating-tab--logged-out';
|
|
}
|
|
|
|
// Apply appearance styles based on current state
|
|
this._applyThemeStyles();
|
|
}
|
|
|
|
_applyThemeStyles() {
|
|
if (!this.container) return;
|
|
|
|
// The CSS classes will handle the theming through CSS custom properties
|
|
// Additional style customizations can be added here if needed
|
|
|
|
// Apply style variant
|
|
if (this.options.appearance.style === 'circle') {
|
|
this.container.style.borderRadius = '50%';
|
|
this.container.style.width = '48px';
|
|
this.container.style.height = '48px';
|
|
this.container.style.minWidth = '48px';
|
|
this.container.style.padding = '0';
|
|
} else if (this.options.appearance.style === 'square') {
|
|
this.container.style.borderRadius = '4px';
|
|
} else {
|
|
// pill style (default)
|
|
this.container.style.borderRadius = 'var(--nl-border-radius)';
|
|
}
|
|
}
|
|
|
|
async _fetchUserProfile(pubkey) {
|
|
if (!this.options.getUserInfo) {
|
|
console.log('FloatingTab: getUserInfo disabled, skipping profile fetch');
|
|
return null;
|
|
}
|
|
|
|
// Determine which relays to use
|
|
const relays = this.options.getUserRelay.length > 0
|
|
? this.options.getUserRelay
|
|
: ['wss://relay.damus.io', 'wss://nos.lol'];
|
|
|
|
console.log('FloatingTab: Fetching profile from relays:', relays);
|
|
|
|
try {
|
|
// Create a SimplePool instance for querying
|
|
const pool = new window.NostrTools.SimplePool();
|
|
|
|
// Query for kind 0 (user metadata) events
|
|
const events = await pool.querySync(relays, {
|
|
kinds: [0],
|
|
authors: [pubkey],
|
|
limit: 1
|
|
}, { timeout: 5000 });
|
|
|
|
console.log('FloatingTab: Profile query returned', events.length, 'events');
|
|
|
|
if (events.length === 0) {
|
|
console.log('FloatingTab: No profile events found');
|
|
return null;
|
|
}
|
|
|
|
// Get the most recent event
|
|
const latestEvent = events.sort((a, b) => b.created_at - a.created_at)[0];
|
|
|
|
try {
|
|
const profile = JSON.parse(latestEvent.content);
|
|
console.log('FloatingTab: Parsed profile:', profile);
|
|
|
|
// Find the best name from any key containing "name" (case-insensitive)
|
|
let bestName = null;
|
|
const nameKeys = Object.keys(profile).filter(key =>
|
|
key.toLowerCase().includes('name') &&
|
|
typeof profile[key] === 'string' &&
|
|
profile[key].trim().length > 0
|
|
);
|
|
|
|
if (nameKeys.length > 0) {
|
|
// Find the shortest name value
|
|
bestName = nameKeys
|
|
.map(key => profile[key].trim())
|
|
.reduce((shortest, current) =>
|
|
current.length < shortest.length ? current : shortest
|
|
);
|
|
console.log('FloatingTab: Found name keys:', nameKeys, 'selected:', bestName);
|
|
}
|
|
|
|
// Return relevant profile fields with the best name
|
|
return {
|
|
name: bestName,
|
|
display_name: profile.display_name || null,
|
|
about: profile.about || null,
|
|
picture: profile.picture || null,
|
|
nip05: profile.nip05 || null
|
|
};
|
|
} catch (parseError) {
|
|
console.warn('FloatingTab: Failed to parse profile JSON:', parseError);
|
|
return null;
|
|
}
|
|
} catch (error) {
|
|
console.error('FloatingTab: Profile fetch error:', error);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
_position() {
|
|
if (!this.container) return;
|
|
|
|
const padding = 16; // Distance from screen edge
|
|
|
|
// Calculate position based on percentage
|
|
const x = this.options.hPosition * (window.innerWidth - this.container.offsetWidth - padding * 2) + padding + this.options.offset.x;
|
|
const y = this.options.vPosition * (window.innerHeight - this.container.offsetHeight - padding * 2) + padding + this.options.offset.y;
|
|
|
|
this.container.style.left = \`\${x}px\`;
|
|
this.container.style.top = \`\${y}px\`;
|
|
|
|
console.log(\`FloatingTab: Positioned at (\${x}, \${y})\`);
|
|
}
|
|
|
|
_slideIn() {
|
|
if (!this.container || !this.options.behavior.autoSlide) return;
|
|
|
|
// Slide towards center slightly
|
|
const currentTransform = this.container.style.transform || '';
|
|
if (this.options.hPosition > 0.5) {
|
|
this.container.style.transform = currentTransform + ' translateX(-8px)';
|
|
} else {
|
|
this.container.style.transform = currentTransform + ' translateX(8px)';
|
|
}
|
|
}
|
|
|
|
_slideOut() {
|
|
if (!this.container || !this.options.behavior.autoSlide) return;
|
|
|
|
// Reset position
|
|
this.container.style.transform = '';
|
|
}
|
|
|
|
show() {
|
|
if (!this.container) return;
|
|
this.container.style.display = 'flex';
|
|
this.isVisible = true;
|
|
console.log('FloatingTab: Shown');
|
|
}
|
|
|
|
hide() {
|
|
if (!this.container) return;
|
|
this.container.style.display = 'none';
|
|
this.isVisible = false;
|
|
console.log('FloatingTab: Hidden');
|
|
}
|
|
|
|
destroy() {
|
|
if (this.container) {
|
|
this.container.remove();
|
|
this.container = null;
|
|
}
|
|
this.isVisible = false;
|
|
console.log('FloatingTab: Destroyed');
|
|
}
|
|
|
|
// Update options and re-apply
|
|
updateOptions(newOptions) {
|
|
this.options = { ...this.options, ...newOptions };
|
|
if (this.container) {
|
|
this._updateAppearance();
|
|
this._position();
|
|
}
|
|
}
|
|
|
|
// Get current state
|
|
getState() {
|
|
return {
|
|
isVisible: this.isVisible,
|
|
isAuthenticated: this.isAuthenticated,
|
|
userInfo: this.userInfo,
|
|
options: this.options
|
|
};
|
|
}
|
|
}
|
|
|
|
// ======================================
|
|
// Main NOSTR_LOGIN_LITE Library
|
|
// ======================================
|
|
|
|
// Extension Bridge for managing browser extensions
|
|
class ExtensionBridge {
|
|
constructor() {
|
|
this.extensions = new Map();
|
|
this.primaryExtension = null;
|
|
this._detectExtensions();
|
|
}
|
|
|
|
_detectExtensions() {
|
|
// Common extension locations
|
|
const locations = [
|
|
{ path: 'window.nostr', name: 'Generic' },
|
|
{ path: 'window.alby?.nostr', name: 'Alby' },
|
|
{ path: 'window.nos2x?.nostr', name: 'nos2x' },
|
|
{ path: 'window.flamingo?.nostr', name: 'Flamingo' },
|
|
{ path: 'window.getAlby?.nostr', name: 'Alby Legacy' },
|
|
{ path: 'window.mutiny?.nostr', name: 'Mutiny' }
|
|
];
|
|
|
|
for (const location of locations) {
|
|
try {
|
|
const obj = eval(location.path);
|
|
if (obj && typeof obj.getPublicKey === 'function') {
|
|
this.extensions.set(location.name, {
|
|
name: location.name,
|
|
extension: obj,
|
|
constructor: obj.constructor?.name || 'Unknown'
|
|
});
|
|
|
|
if (!this.primaryExtension) {
|
|
this.primaryExtension = this.extensions.get(location.name);
|
|
}
|
|
}
|
|
} catch (e) {
|
|
// Extension not available
|
|
}
|
|
}
|
|
}
|
|
|
|
getAllExtensions() {
|
|
return Array.from(this.extensions.values());
|
|
}
|
|
|
|
getExtensionCount() {
|
|
return this.extensions.size;
|
|
}
|
|
}
|
|
|
|
// Main NostrLite class
|
|
class NostrLite {
|
|
constructor() {
|
|
this.options = {};
|
|
this.extensionBridge = new ExtensionBridge();
|
|
this.initialized = false;
|
|
this.currentTheme = 'default';
|
|
this.modal = null;
|
|
this.floatingTab = null;
|
|
}
|
|
|
|
async init(options = {}) {
|
|
console.log('NOSTR_LOGIN_LITE: Initializing with options:', options);
|
|
|
|
this.options = {
|
|
theme: 'default',
|
|
persistence: true, // Enable persistent authentication by default
|
|
methods: {
|
|
extension: true,
|
|
local: true,
|
|
seedphrase: false,
|
|
readonly: true,
|
|
connect: false,
|
|
otp: false
|
|
},
|
|
floatingTab: {
|
|
enabled: false,
|
|
hPosition: 1.0,
|
|
vPosition: 0.5,
|
|
offset: { x: 0, y: 0 },
|
|
appearance: {
|
|
style: 'pill',
|
|
theme: 'auto',
|
|
icon: '',
|
|
text: 'Login',
|
|
iconOnly: false
|
|
},
|
|
behavior: {
|
|
hideWhenAuthenticated: true,
|
|
showUserInfo: true,
|
|
autoSlide: true,
|
|
persistent: false
|
|
},
|
|
getUserInfo: false,
|
|
getUserRelay: []
|
|
},
|
|
...options
|
|
};
|
|
|
|
// Apply the selected theme (CSS-only)
|
|
this.switchTheme(this.options.theme);
|
|
|
|
// Always set up window.nostr facade to handle multiple extensions properly
|
|
console.log('🔍 NOSTR_LOGIN_LITE: Setting up facade before other initialization...');
|
|
await this._setupWindowNostrFacade();
|
|
console.log('🔍 NOSTR_LOGIN_LITE: Facade setup complete, continuing initialization...');
|
|
|
|
// Create modal during init (matching original git architecture)
|
|
this.modal = new Modal(this.options);
|
|
console.log('NOSTR_LOGIN_LITE: Modal created during init');
|
|
|
|
// Initialize floating tab if enabled
|
|
if (this.options.floatingTab.enabled) {
|
|
this.floatingTab = new FloatingTab(this.modal, this.options.floatingTab);
|
|
console.log('NOSTR_LOGIN_LITE: Floating tab initialized');
|
|
}
|
|
|
|
// Attempt to restore authentication state if persistence is enabled (AFTER facade is ready)
|
|
if (this.options.persistence) {
|
|
console.log('🔍 NOSTR_LOGIN_LITE: Persistence enabled, attempting auth restoration...');
|
|
await this._attemptAuthRestore();
|
|
} else {
|
|
console.log('🔍 NOSTR_LOGIN_LITE: Persistence disabled in options');
|
|
}
|
|
|
|
this.initialized = true;
|
|
console.log('NOSTR_LOGIN_LITE: Initialization complete');
|
|
|
|
return this;
|
|
}
|
|
|
|
async _setupWindowNostrFacade() {
|
|
if (typeof window !== 'undefined') {
|
|
console.log('🔍 NOSTR_LOGIN_LITE: === EXTENSION-FIRST FACADE SETUP ===');
|
|
console.log('🔍 NOSTR_LOGIN_LITE: Current window.nostr:', window.nostr);
|
|
console.log('🔍 NOSTR_LOGIN_LITE: Constructor:', window.nostr?.constructor?.name);
|
|
|
|
// EXTENSION-FIRST ARCHITECTURE: Never interfere with real extensions
|
|
if (this._isRealExtension(window.nostr)) {
|
|
console.log('🔍 NOSTR_LOGIN_LITE: ✅ REAL EXTENSION DETECTED - WILL NOT INSTALL FACADE');
|
|
console.log('🔍 NOSTR_LOGIN_LITE: Extension constructor:', window.nostr.constructor?.name);
|
|
console.log('🔍 NOSTR_LOGIN_LITE: Extensions will handle window.nostr directly');
|
|
|
|
// Store reference for persistence verification
|
|
this.detectedExtension = window.nostr;
|
|
this.hasExtension = true;
|
|
this.facadeInstalled = false; // We deliberately don't install facade for extensions
|
|
|
|
console.log('🔍 NOSTR_LOGIN_LITE: Extension mode - no facade interference');
|
|
return; // Don't install facade at all for extensions
|
|
}
|
|
|
|
// NO EXTENSION: Install facade for local/NIP-46/readonly methods
|
|
console.log('🔍 NOSTR_LOGIN_LITE: ❌ No real extension detected');
|
|
console.log('🔍 NOSTR_LOGIN_LITE: Installing facade for non-extension authentication');
|
|
|
|
this.hasExtension = false;
|
|
this._installFacade(window.nostr); // Install facade with any existing nostr object
|
|
|
|
console.log('🔍 NOSTR_LOGIN_LITE: ✅ Facade installed for local/NIP-46/readonly methods');
|
|
}
|
|
}
|
|
|
|
_installFacade(existingNostr = null) {
|
|
if (typeof window !== 'undefined' && !this.facadeInstalled) {
|
|
console.log('🔍 NOSTR_LOGIN_LITE: === _installFacade CALLED ===');
|
|
console.log('🔍 NOSTR_LOGIN_LITE: existingNostr parameter:', existingNostr);
|
|
console.log('🔍 NOSTR_LOGIN_LITE: existingNostr constructor:', existingNostr?.constructor?.name);
|
|
console.log('🔍 NOSTR_LOGIN_LITE: window.nostr before installation:', window.nostr);
|
|
console.log('🔍 NOSTR_LOGIN_LITE: window.nostr constructor before:', window.nostr?.constructor?.name);
|
|
|
|
const facade = new WindowNostr(this, existingNostr);
|
|
window.nostr = facade;
|
|
this.facadeInstalled = true;
|
|
|
|
console.log('🔍 NOSTR_LOGIN_LITE: === FACADE INSTALLED FOR PERSISTENCE ===');
|
|
console.log('🔍 NOSTR_LOGIN_LITE: window.nostr after installation:', window.nostr);
|
|
console.log('🔍 NOSTR_LOGIN_LITE: window.nostr constructor after:', window.nostr.constructor?.name);
|
|
console.log('🔍 NOSTR_LOGIN_LITE: facade.existingNostr:', window.nostr.existingNostr);
|
|
}
|
|
}
|
|
|
|
// Conservative method to identify real browser extensions
|
|
_isRealExtension(obj) {
|
|
console.log('NOSTR_LOGIN_LITE: === _isRealExtension (Conservative) ===');
|
|
console.log('NOSTR_LOGIN_LITE: obj:', obj);
|
|
console.log('NOSTR_LOGIN_LITE: typeof obj:', typeof obj);
|
|
|
|
if (!obj || typeof obj !== 'object') {
|
|
console.log('NOSTR_LOGIN_LITE: ✗ Not an object');
|
|
return false;
|
|
}
|
|
|
|
// Must have required Nostr methods
|
|
if (typeof obj.getPublicKey !== 'function' || typeof obj.signEvent !== 'function') {
|
|
console.log('NOSTR_LOGIN_LITE: ✗ Missing required NIP-07 methods');
|
|
return false;
|
|
}
|
|
|
|
// Exclude our own library classes
|
|
const constructorName = obj.constructor?.name;
|
|
console.log('NOSTR_LOGIN_LITE: Constructor name:', constructorName);
|
|
|
|
if (constructorName === 'WindowNostr' || constructorName === 'NostrLite') {
|
|
console.log('NOSTR_LOGIN_LITE: ✗ Is our library class - NOT an extension');
|
|
return false;
|
|
}
|
|
|
|
// Exclude NostrTools library object
|
|
if (obj === window.NostrTools) {
|
|
console.log('NOSTR_LOGIN_LITE: ✗ Is NostrTools object - NOT an extension');
|
|
return false;
|
|
}
|
|
|
|
// Conservative check: Look for common extension characteristics
|
|
// Real extensions usually have some of these internal properties
|
|
const extensionIndicators = [
|
|
'_isEnabled', 'enabled', 'kind', '_eventEmitter', '_scope',
|
|
'_requests', '_pubkey', 'name', 'version', 'description'
|
|
];
|
|
|
|
const hasIndicators = extensionIndicators.some(prop => obj.hasOwnProperty(prop));
|
|
|
|
// Additional check: Extensions often have specific constructor patterns
|
|
const hasExtensionConstructor = constructorName &&
|
|
constructorName !== 'Object' &&
|
|
constructorName !== 'Function';
|
|
|
|
const isExtension = hasIndicators || hasExtensionConstructor;
|
|
|
|
console.log('NOSTR_LOGIN_LITE: Extension indicators found:', hasIndicators);
|
|
console.log('NOSTR_LOGIN_LITE: Has extension constructor:', hasExtensionConstructor);
|
|
console.log('NOSTR_LOGIN_LITE: Final result for', constructorName, ':', isExtension);
|
|
|
|
return isExtension;
|
|
}
|
|
|
|
launch(startScreen = 'login') {
|
|
console.log('NOSTR_LOGIN_LITE: Launching with screen:', startScreen);
|
|
|
|
if (this.modal) {
|
|
this.modal.open({ startScreen });
|
|
} else {
|
|
console.error('NOSTR_LOGIN_LITE: Modal not initialized - call init() first');
|
|
}
|
|
}
|
|
|
|
// Attempt to restore authentication state
|
|
async _attemptAuthRestore() {
|
|
try {
|
|
console.log('🔍 NOSTR_LOGIN_LITE: === _attemptAuthRestore START ===');
|
|
console.log('🔍 NOSTR_LOGIN_LITE: hasExtension:', this.hasExtension);
|
|
console.log('🔍 NOSTR_LOGIN_LITE: facadeInstalled:', this.facadeInstalled);
|
|
console.log('🔍 NOSTR_LOGIN_LITE: window.nostr:', window.nostr?.constructor?.name);
|
|
|
|
if (this.hasExtension) {
|
|
// EXTENSION MODE: Use custom extension persistence logic
|
|
console.log('🔍 NOSTR_LOGIN_LITE: Extension mode - using extension-specific restore');
|
|
const restoredAuth = await this._attemptExtensionRestore();
|
|
|
|
if (restoredAuth) {
|
|
console.log('🔍 NOSTR_LOGIN_LITE: ✅ Extension auth restored successfully!');
|
|
return restoredAuth;
|
|
} else {
|
|
console.log('🔍 NOSTR_LOGIN_LITE: ❌ Extension auth could not be restored');
|
|
return null;
|
|
}
|
|
} else if (this.facadeInstalled && window.nostr?.restoreAuthState) {
|
|
// NON-EXTENSION MODE: Use facade persistence logic
|
|
console.log('🔍 NOSTR_LOGIN_LITE: Non-extension mode - using facade restore');
|
|
const restoredAuth = await window.nostr.restoreAuthState();
|
|
|
|
if (restoredAuth) {
|
|
console.log('🔍 NOSTR_LOGIN_LITE: ✅ Facade auth restored successfully!');
|
|
console.log('🔍 NOSTR_LOGIN_LITE: Method:', restoredAuth.method);
|
|
console.log('🔍 NOSTR_LOGIN_LITE: Pubkey:', restoredAuth.pubkey);
|
|
|
|
// Handle NIP-46 reconnection requirement
|
|
if (restoredAuth.requiresReconnection) {
|
|
console.log('🔍 NOSTR_LOGIN_LITE: NIP-46 connection requires user reconnection');
|
|
this._showReconnectionPrompt(restoredAuth);
|
|
}
|
|
|
|
return restoredAuth;
|
|
} else {
|
|
console.log('🔍 NOSTR_LOGIN_LITE: ❌ Facade auth could not be restored');
|
|
return null;
|
|
}
|
|
} else {
|
|
console.log('🔍 NOSTR_LOGIN_LITE: ❌ No restoration method available');
|
|
console.log('🔍 NOSTR_LOGIN_LITE: hasExtension:', this.hasExtension);
|
|
console.log('🔍 NOSTR_LOGIN_LITE: facadeInstalled:', this.facadeInstalled);
|
|
console.log('🔍 NOSTR_LOGIN_LITE: window.nostr.restoreAuthState:', typeof window.nostr?.restoreAuthState);
|
|
return null;
|
|
}
|
|
|
|
} catch (error) {
|
|
console.error('🔍 NOSTR_LOGIN_LITE: Auth restoration failed with error:', error);
|
|
console.error('🔍 NOSTR_LOGIN_LITE: Error stack:', error.stack);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
// Extension-specific authentication restoration
|
|
async _attemptExtensionRestore() {
|
|
try {
|
|
console.log('🔍 NOSTR_LOGIN_LITE: === _attemptExtensionRestore START ===');
|
|
|
|
// Use a simple AuthManager instance for extension persistence
|
|
const authManager = new AuthManager();
|
|
const storedAuth = await authManager.restoreAuthState();
|
|
|
|
if (!storedAuth || storedAuth.method !== 'extension') {
|
|
console.log('🔍 NOSTR_LOGIN_LITE: No extension auth state stored');
|
|
return null;
|
|
}
|
|
|
|
// Verify the extension is still available and working
|
|
if (!window.nostr || !this._isRealExtension(window.nostr)) {
|
|
console.log('🔍 NOSTR_LOGIN_LITE: Extension no longer available');
|
|
authManager.clearAuthState(); // Clear invalid state
|
|
return null;
|
|
}
|
|
|
|
try {
|
|
// Test that the extension still works with the same pubkey
|
|
const currentPubkey = await window.nostr.getPublicKey();
|
|
if (currentPubkey !== storedAuth.pubkey) {
|
|
console.log('🔍 NOSTR_LOGIN_LITE: Extension pubkey changed, clearing state');
|
|
authManager.clearAuthState();
|
|
return null;
|
|
}
|
|
|
|
console.log('🔍 NOSTR_LOGIN_LITE: ✅ Extension auth verification successful');
|
|
|
|
// Create extension auth data for UI restoration
|
|
const extensionAuth = {
|
|
method: 'extension',
|
|
pubkey: storedAuth.pubkey,
|
|
extension: window.nostr
|
|
};
|
|
|
|
// Dispatch restoration event so UI can update
|
|
if (typeof window !== 'undefined') {
|
|
console.log('🔍 NOSTR_LOGIN_LITE: Dispatching nlAuthRestored event for extension');
|
|
window.dispatchEvent(new CustomEvent('nlAuthRestored', {
|
|
detail: extensionAuth
|
|
}));
|
|
}
|
|
|
|
return extensionAuth;
|
|
|
|
} catch (error) {
|
|
console.log('🔍 NOSTR_LOGIN_LITE: Extension verification failed:', error);
|
|
authManager.clearAuthState(); // Clear invalid state
|
|
return null;
|
|
}
|
|
|
|
} catch (error) {
|
|
console.error('🔍 NOSTR_LOGIN_LITE: Extension restore failed:', error);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
// Show prompt for NIP-46 reconnection
|
|
_showReconnectionPrompt(authData) {
|
|
console.log('NOSTR_LOGIN_LITE: Showing reconnection prompt for NIP-46');
|
|
|
|
// Dispatch event that UI can listen to
|
|
if (typeof window !== 'undefined') {
|
|
window.dispatchEvent(new CustomEvent('nlReconnectionRequired', {
|
|
detail: {
|
|
method: authData.method,
|
|
pubkey: authData.pubkey,
|
|
connectionData: authData.connectionData,
|
|
message: 'Your NIP-46 session has expired. Please reconnect to continue.'
|
|
}
|
|
}));
|
|
}
|
|
}
|
|
|
|
logout() {
|
|
console.log('NOSTR_LOGIN_LITE: Logout called');
|
|
|
|
// Clear legacy stored data
|
|
if (typeof localStorage !== 'undefined') {
|
|
localStorage.removeItem('nl_current');
|
|
}
|
|
|
|
// Dispatch logout event (AuthManager will clear its own data via WindowNostr listener)
|
|
if (typeof window !== 'undefined') {
|
|
window.dispatchEvent(new CustomEvent('nlLogout', {
|
|
detail: { timestamp: Date.now() }
|
|
}));
|
|
}
|
|
}
|
|
|
|
// CSS-only theme switching
|
|
switchTheme(themeName) {
|
|
console.log(\`NOSTR_LOGIN_LITE: Switching to \${themeName} theme\`);
|
|
|
|
if (THEME_CSS[themeName]) {
|
|
injectThemeCSS(themeName);
|
|
this.currentTheme = themeName;
|
|
|
|
// Dispatch theme change event
|
|
if (typeof window !== 'undefined') {
|
|
window.dispatchEvent(new CustomEvent('nlThemeChanged', {
|
|
detail: { theme: themeName }
|
|
}));
|
|
}
|
|
|
|
return { theme: themeName };
|
|
} else {
|
|
console.warn(\`Theme '\${themeName}' not found, using default\`);
|
|
injectThemeCSS('default');
|
|
this.currentTheme = 'default';
|
|
return { theme: 'default' };
|
|
}
|
|
}
|
|
|
|
getCurrentTheme() {
|
|
return this.currentTheme;
|
|
}
|
|
|
|
getAvailableThemes() {
|
|
return Object.keys(THEME_CSS);
|
|
}
|
|
|
|
embed(container, options = {}) {
|
|
console.log('NOSTR_LOGIN_LITE: Creating embedded modal in container:', container);
|
|
|
|
const embedOptions = {
|
|
...this.options,
|
|
...options,
|
|
embedded: container
|
|
};
|
|
|
|
// Create new modal instance for embedding
|
|
const embeddedModal = new Modal(embedOptions);
|
|
embeddedModal.open();
|
|
|
|
return embeddedModal;
|
|
}
|
|
|
|
// Floating tab management methods
|
|
showFloatingTab() {
|
|
if (this.floatingTab) {
|
|
this.floatingTab.show();
|
|
} else {
|
|
console.warn('NOSTR_LOGIN_LITE: Floating tab not enabled');
|
|
}
|
|
}
|
|
|
|
hideFloatingTab() {
|
|
if (this.floatingTab) {
|
|
this.floatingTab.hide();
|
|
}
|
|
}
|
|
|
|
toggleFloatingTab() {
|
|
if (this.floatingTab) {
|
|
if (this.floatingTab.isVisible) {
|
|
this.floatingTab.hide();
|
|
} else {
|
|
this.floatingTab.show();
|
|
}
|
|
}
|
|
}
|
|
|
|
updateFloatingTab(options) {
|
|
if (this.floatingTab) {
|
|
this.floatingTab.updateOptions(options);
|
|
}
|
|
}
|
|
|
|
getFloatingTabState() {
|
|
return this.floatingTab ? this.floatingTab.getState() : null;
|
|
}
|
|
}
|
|
|
|
// ======================================
|
|
// Authentication Manager for Persistent Login
|
|
// ======================================
|
|
|
|
// Encryption utilities for secure local storage
|
|
class CryptoUtils {
|
|
static async generateKey() {
|
|
if (!window.crypto?.subtle) {
|
|
throw new Error('Web Crypto API not available');
|
|
}
|
|
|
|
return await window.crypto.subtle.generateKey(
|
|
{
|
|
name: 'AES-GCM',
|
|
length: 256,
|
|
},
|
|
true,
|
|
['encrypt', 'decrypt']
|
|
);
|
|
}
|
|
|
|
static async deriveKey(password, salt) {
|
|
if (!window.crypto?.subtle) {
|
|
throw new Error('Web Crypto API not available');
|
|
}
|
|
|
|
const encoder = new TextEncoder();
|
|
const keyMaterial = await window.crypto.subtle.importKey(
|
|
'raw',
|
|
encoder.encode(password),
|
|
{ name: 'PBKDF2' },
|
|
false,
|
|
['deriveBits', 'deriveKey']
|
|
);
|
|
|
|
return await window.crypto.subtle.deriveKey(
|
|
{
|
|
name: 'PBKDF2',
|
|
salt: salt,
|
|
iterations: 100000,
|
|
hash: 'SHA-256',
|
|
},
|
|
keyMaterial,
|
|
{ name: 'AES-GCM', length: 256 },
|
|
true,
|
|
['encrypt', 'decrypt']
|
|
);
|
|
}
|
|
|
|
static async encrypt(data, key) {
|
|
if (!window.crypto?.subtle) {
|
|
throw new Error('Web Crypto API not available');
|
|
}
|
|
|
|
const encoder = new TextEncoder();
|
|
const iv = window.crypto.getRandomValues(new Uint8Array(12));
|
|
|
|
const encrypted = await window.crypto.subtle.encrypt(
|
|
{
|
|
name: 'AES-GCM',
|
|
iv: iv,
|
|
},
|
|
key,
|
|
encoder.encode(data)
|
|
);
|
|
|
|
return {
|
|
encrypted: new Uint8Array(encrypted),
|
|
iv: iv
|
|
};
|
|
}
|
|
|
|
static async decrypt(encryptedData, key, iv) {
|
|
if (!window.crypto?.subtle) {
|
|
throw new Error('Web Crypto API not available');
|
|
}
|
|
|
|
const decrypted = await window.crypto.subtle.decrypt(
|
|
{
|
|
name: 'AES-GCM',
|
|
iv: iv,
|
|
},
|
|
key,
|
|
encryptedData
|
|
);
|
|
|
|
const decoder = new TextDecoder();
|
|
return decoder.decode(decrypted);
|
|
}
|
|
|
|
static arrayBufferToBase64(buffer) {
|
|
const bytes = new Uint8Array(buffer);
|
|
let binary = '';
|
|
for (let i = 0; i < bytes.byteLength; i++) {
|
|
binary += String.fromCharCode(bytes[i]);
|
|
}
|
|
return window.btoa(binary);
|
|
}
|
|
|
|
static base64ToArrayBuffer(base64) {
|
|
const binary = window.atob(base64);
|
|
const bytes = new Uint8Array(binary.length);
|
|
for (let i = 0; i < binary.length; i++) {
|
|
bytes[i] = binary.charCodeAt(i);
|
|
}
|
|
return bytes.buffer;
|
|
}
|
|
}
|
|
|
|
// Unified authentication state manager
|
|
class AuthManager {
|
|
constructor() {
|
|
this.storageKey = 'nostr_login_lite_auth';
|
|
this.currentAuthState = null;
|
|
}
|
|
|
|
// Save authentication state with method-specific security
|
|
async saveAuthState(authData) {
|
|
try {
|
|
const authState = {
|
|
method: authData.method,
|
|
timestamp: Date.now(),
|
|
pubkey: authData.pubkey
|
|
};
|
|
|
|
switch (authData.method) {
|
|
case 'extension':
|
|
// For extensions, only store verification data - no secrets
|
|
authState.extensionVerification = {
|
|
constructor: authData.extension?.constructor?.name,
|
|
hasGetPublicKey: typeof authData.extension?.getPublicKey === 'function',
|
|
hasSignEvent: typeof authData.extension?.signEvent === 'function'
|
|
};
|
|
break;
|
|
|
|
case 'local':
|
|
// For local keys, encrypt the secret key
|
|
if (authData.secret) {
|
|
const password = this._generateSessionPassword();
|
|
const salt = window.crypto.getRandomValues(new Uint8Array(16));
|
|
const key = await CryptoUtils.deriveKey(password, salt);
|
|
const encrypted = await CryptoUtils.encrypt(authData.secret, key);
|
|
|
|
authState.encrypted = {
|
|
data: CryptoUtils.arrayBufferToBase64(encrypted.encrypted),
|
|
iv: CryptoUtils.arrayBufferToBase64(encrypted.iv),
|
|
salt: CryptoUtils.arrayBufferToBase64(salt)
|
|
};
|
|
|
|
// Store session password in sessionStorage (cleared on tab close)
|
|
sessionStorage.setItem('nostr_session_key', password);
|
|
}
|
|
break;
|
|
|
|
case 'nip46':
|
|
// For NIP-46, store connection parameters (no secrets)
|
|
if (authData.signer) {
|
|
authState.nip46 = {
|
|
remotePubkey: authData.signer.remotePubkey,
|
|
relays: authData.signer.relays,
|
|
// Don't store secret - user will need to reconnect
|
|
};
|
|
}
|
|
break;
|
|
|
|
case 'readonly':
|
|
// Read-only mode has no secrets to store
|
|
break;
|
|
|
|
default:
|
|
throw new Error(\`Unknown auth method: \${authData.method}\`);
|
|
}
|
|
|
|
localStorage.setItem(this.storageKey, JSON.stringify(authState));
|
|
this.currentAuthState = authState;
|
|
console.log('AuthManager: Auth state saved for method:', authData.method);
|
|
|
|
} catch (error) {
|
|
console.error('AuthManager: Failed to save auth state:', error);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
// Restore authentication state on page load
|
|
async restoreAuthState() {
|
|
try {
|
|
console.log('🔍 AuthManager: === restoreAuthState START ===');
|
|
console.log('🔍 AuthManager: storageKey:', this.storageKey);
|
|
|
|
const stored = localStorage.getItem(this.storageKey);
|
|
console.log('🔍 AuthManager: localStorage raw value:', stored);
|
|
|
|
if (!stored) {
|
|
console.log('🔍 AuthManager: ❌ No stored auth state found');
|
|
return null;
|
|
}
|
|
|
|
const authState = JSON.parse(stored);
|
|
console.log('🔍 AuthManager: ✅ Parsed stored auth state:', authState);
|
|
console.log('🔍 AuthManager: Method:', authState.method);
|
|
console.log('🔍 AuthManager: Timestamp:', authState.timestamp);
|
|
console.log('🔍 AuthManager: Age (ms):', Date.now() - authState.timestamp);
|
|
|
|
// Check if stored state is too old (24 hours for most methods, 1 hour for extensions)
|
|
const maxAge = authState.method === 'extension' ? 60 * 60 * 1000 : 24 * 60 * 60 * 1000;
|
|
console.log('🔍 AuthManager: Max age for method:', maxAge, 'ms');
|
|
|
|
if (Date.now() - authState.timestamp > maxAge) {
|
|
console.log('🔍 AuthManager: ❌ Stored auth state expired, clearing');
|
|
this.clearAuthState();
|
|
return null;
|
|
}
|
|
|
|
console.log('🔍 AuthManager: ✅ Auth state not expired, attempting restore for method:', authState.method);
|
|
|
|
let result;
|
|
switch (authState.method) {
|
|
case 'extension':
|
|
console.log('🔍 AuthManager: Calling _restoreExtensionAuth...');
|
|
result = await this._restoreExtensionAuth(authState);
|
|
break;
|
|
|
|
case 'local':
|
|
console.log('🔍 AuthManager: Calling _restoreLocalAuth...');
|
|
result = await this._restoreLocalAuth(authState);
|
|
break;
|
|
|
|
case 'nip46':
|
|
console.log('🔍 AuthManager: Calling _restoreNip46Auth...');
|
|
result = await this._restoreNip46Auth(authState);
|
|
break;
|
|
|
|
case 'readonly':
|
|
console.log('🔍 AuthManager: Calling _restoreReadonlyAuth...');
|
|
result = await this._restoreReadonlyAuth(authState);
|
|
break;
|
|
|
|
default:
|
|
console.warn('🔍 AuthManager: ❌ Unknown auth method in stored state:', authState.method);
|
|
return null;
|
|
}
|
|
|
|
console.log('🔍 AuthManager: Restore method result:', result);
|
|
console.log('🔍 AuthManager: === restoreAuthState END ===');
|
|
return result;
|
|
|
|
} catch (error) {
|
|
console.error('🔍 AuthManager: ❌ Failed to restore auth state:', error);
|
|
console.error('🔍 AuthManager: Error stack:', error.stack);
|
|
this.clearAuthState(); // Clear corrupted state
|
|
return null;
|
|
}
|
|
}
|
|
|
|
async _restoreExtensionAuth(authState) {
|
|
console.log('🔍 AuthManager: === _restoreExtensionAuth START ===');
|
|
console.log('🔍 AuthManager: authState:', authState);
|
|
console.log('🔍 AuthManager: window.nostr available:', !!window.nostr);
|
|
console.log('🔍 AuthManager: window.nostr constructor:', window.nostr?.constructor?.name);
|
|
|
|
// SMART EXTENSION WAITING SYSTEM
|
|
// Extensions often load after our library, so we need to wait for them
|
|
const extension = await this._waitForExtension(authState, 3000); // Wait up to 3 seconds
|
|
|
|
if (!extension) {
|
|
console.log('🔍 AuthManager: ❌ No extension found after waiting');
|
|
return null;
|
|
}
|
|
|
|
console.log('🔍 AuthManager: ✅ Extension found:', extension.constructor?.name);
|
|
|
|
try {
|
|
// Verify extension still works and has same pubkey
|
|
const currentPubkey = await extension.getPublicKey();
|
|
if (currentPubkey !== authState.pubkey) {
|
|
console.log('🔍 AuthManager: ❌ Extension pubkey changed, not restoring');
|
|
console.log('🔍 AuthManager: Expected:', authState.pubkey);
|
|
console.log('🔍 AuthManager: Got:', currentPubkey);
|
|
return null;
|
|
}
|
|
|
|
console.log('🔍 AuthManager: ✅ Extension auth restored successfully');
|
|
return {
|
|
method: 'extension',
|
|
pubkey: authState.pubkey,
|
|
extension: extension
|
|
};
|
|
|
|
} catch (error) {
|
|
console.log('🔍 AuthManager: ❌ Extension verification failed:', error);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
// Smart extension waiting system - polls multiple locations for extensions
|
|
async _waitForExtension(authState, maxWaitMs = 3000) {
|
|
console.log('🔍 AuthManager: === _waitForExtension START ===');
|
|
console.log('🔍 AuthManager: maxWaitMs:', maxWaitMs);
|
|
console.log('🔍 AuthManager: Looking for extension with constructor:', authState.extensionVerification?.constructor);
|
|
|
|
const startTime = Date.now();
|
|
const pollInterval = 100; // Check every 100ms
|
|
|
|
// Extension locations to check (in priority order)
|
|
const extensionLocations = [
|
|
{ path: 'window.nostr', getter: () => window.nostr },
|
|
{ path: 'navigator.nostr', getter: () => navigator?.nostr },
|
|
{ path: 'window.navigator?.nostr', getter: () => window.navigator?.nostr },
|
|
{ path: 'window.alby?.nostr', getter: () => window.alby?.nostr },
|
|
{ path: 'window.webln?.nostr', getter: () => window.webln?.nostr },
|
|
{ path: 'window.nos2x', getter: () => window.nos2x },
|
|
{ path: 'window.flamingo?.nostr', getter: () => window.flamingo?.nostr },
|
|
{ path: 'window.mutiny?.nostr', getter: () => window.mutiny?.nostr }
|
|
];
|
|
|
|
while (Date.now() - startTime < maxWaitMs) {
|
|
console.log('🔍 AuthManager: Polling for extensions... (elapsed:', Date.now() - startTime, 'ms)');
|
|
|
|
// If our facade is currently installed and blocking, temporarily remove it
|
|
let facadeRemoved = false;
|
|
let originalNostr = null;
|
|
if (window.nostr?.constructor?.name === 'WindowNostr') {
|
|
console.log('🔍 AuthManager: Temporarily removing our facade to check for real extensions');
|
|
originalNostr = window.nostr;
|
|
window.nostr = window.nostr.existingNostr || undefined;
|
|
facadeRemoved = true;
|
|
}
|
|
|
|
try {
|
|
// Check all extension locations
|
|
for (const location of extensionLocations) {
|
|
try {
|
|
const extension = location.getter();
|
|
console.log('🔍 AuthManager: Checking', location.path, ':', !!extension, extension?.constructor?.name);
|
|
|
|
if (this._isValidExtensionForRestore(extension, authState)) {
|
|
console.log('🔍 AuthManager: ✅ Found matching extension at', location.path);
|
|
|
|
// Restore facade if we removed it
|
|
if (facadeRemoved && originalNostr) {
|
|
console.log('🔍 AuthManager: Restoring facade after finding extension');
|
|
window.nostr = originalNostr;
|
|
}
|
|
|
|
return extension;
|
|
}
|
|
} catch (error) {
|
|
console.log('🔍 AuthManager: Error checking', location.path, ':', error.message);
|
|
}
|
|
}
|
|
|
|
// Restore facade if we removed it and haven't found an extension yet
|
|
if (facadeRemoved && originalNostr) {
|
|
window.nostr = originalNostr;
|
|
facadeRemoved = false;
|
|
}
|
|
|
|
} catch (error) {
|
|
console.error('🔍 AuthManager: Error during extension polling:', error);
|
|
|
|
// Restore facade if we removed it
|
|
if (facadeRemoved && originalNostr) {
|
|
window.nostr = originalNostr;
|
|
}
|
|
}
|
|
|
|
// Wait before next poll
|
|
await new Promise(resolve => setTimeout(resolve, pollInterval));
|
|
}
|
|
|
|
console.log('🔍 AuthManager: ❌ Extension waiting timeout after', maxWaitMs, 'ms');
|
|
return null;
|
|
}
|
|
|
|
// Check if an extension is valid for restoration
|
|
_isValidExtensionForRestore(extension, authState) {
|
|
if (!extension || typeof extension !== 'object') {
|
|
return false;
|
|
}
|
|
|
|
// Must have required Nostr methods
|
|
if (typeof extension.getPublicKey !== 'function' ||
|
|
typeof extension.signEvent !== 'function') {
|
|
return false;
|
|
}
|
|
|
|
// Must not be our own classes
|
|
const constructorName = extension.constructor?.name;
|
|
if (constructorName === 'WindowNostr' || constructorName === 'NostrLite') {
|
|
return false;
|
|
}
|
|
|
|
// Must not be NostrTools
|
|
if (extension === window.NostrTools) {
|
|
return false;
|
|
}
|
|
|
|
// If we have stored verification data, check constructor match
|
|
const verification = authState.extensionVerification;
|
|
if (verification && verification.constructor) {
|
|
if (constructorName !== verification.constructor) {
|
|
console.log('🔍 AuthManager: Constructor mismatch -',
|
|
'expected:', verification.constructor,
|
|
'got:', constructorName);
|
|
return false;
|
|
}
|
|
}
|
|
|
|
console.log('🔍 AuthManager: ✅ Extension validation passed for:', constructorName);
|
|
return true;
|
|
}
|
|
|
|
async _restoreLocalAuth(authState) {
|
|
if (!authState.encrypted) {
|
|
console.log('AuthManager: No encrypted data found for local auth');
|
|
return null;
|
|
}
|
|
|
|
// Get session password
|
|
const sessionPassword = sessionStorage.getItem('nostr_session_key');
|
|
if (!sessionPassword) {
|
|
console.log('AuthManager: Session password not found, cannot decrypt');
|
|
return null;
|
|
}
|
|
|
|
try {
|
|
// Decrypt the secret key
|
|
const salt = CryptoUtils.base64ToArrayBuffer(authState.encrypted.salt);
|
|
const key = await CryptoUtils.deriveKey(sessionPassword, new Uint8Array(salt));
|
|
|
|
const encryptedData = CryptoUtils.base64ToArrayBuffer(authState.encrypted.data);
|
|
const iv = CryptoUtils.base64ToArrayBuffer(authState.encrypted.iv);
|
|
|
|
const secret = await CryptoUtils.decrypt(encryptedData, key, new Uint8Array(iv));
|
|
|
|
console.log('AuthManager: Local auth restored successfully');
|
|
return {
|
|
method: 'local',
|
|
pubkey: authState.pubkey,
|
|
secret: secret
|
|
};
|
|
|
|
} catch (error) {
|
|
console.error('AuthManager: Failed to decrypt local key:', error);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
async _restoreNip46Auth(authState) {
|
|
if (!authState.nip46) {
|
|
console.log('AuthManager: No NIP-46 data found');
|
|
return null;
|
|
}
|
|
|
|
// For NIP-46, we can't automatically restore the connection
|
|
// because it requires the user to re-authenticate with the remote signer
|
|
// Instead, we return the connection parameters so the UI can prompt for reconnection
|
|
console.log('AuthManager: NIP-46 connection data found, requires user reconnection');
|
|
return {
|
|
method: 'nip46',
|
|
pubkey: authState.pubkey,
|
|
requiresReconnection: true,
|
|
connectionData: authState.nip46
|
|
};
|
|
}
|
|
|
|
async _restoreReadonlyAuth(authState) {
|
|
console.log('AuthManager: Read-only auth restored successfully');
|
|
return {
|
|
method: 'readonly',
|
|
pubkey: authState.pubkey
|
|
};
|
|
}
|
|
|
|
// Clear stored authentication state
|
|
clearAuthState() {
|
|
localStorage.removeItem(this.storageKey);
|
|
sessionStorage.removeItem('nostr_session_key');
|
|
this.currentAuthState = null;
|
|
console.log('AuthManager: Auth state cleared');
|
|
}
|
|
|
|
// Generate a session-specific password for local key encryption
|
|
_generateSessionPassword() {
|
|
const array = new Uint8Array(32);
|
|
window.crypto.getRandomValues(array);
|
|
return Array.from(array, byte => byte.toString(16).padStart(2, '0')).join('');
|
|
}
|
|
|
|
// Check if we have valid stored auth
|
|
hasStoredAuth() {
|
|
const stored = localStorage.getItem(this.storageKey);
|
|
return !!stored;
|
|
}
|
|
|
|
// Get current auth method without full restoration
|
|
getStoredAuthMethod() {
|
|
try {
|
|
const stored = localStorage.getItem(this.storageKey);
|
|
if (!stored) return null;
|
|
|
|
const authState = JSON.parse(stored);
|
|
return authState.method;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
}
|
|
|
|
// NIP-07 compliant window.nostr provider
|
|
class WindowNostr {
|
|
constructor(nostrLite, existingNostr = null) {
|
|
this.nostrLite = nostrLite;
|
|
this.authState = null;
|
|
this.existingNostr = existingNostr;
|
|
this.authenticatedExtension = null;
|
|
this.authManager = new AuthManager();
|
|
this._setupEventListeners();
|
|
}
|
|
|
|
_setupEventListeners() {
|
|
// Listen for authentication events to store auth state
|
|
if (typeof window !== 'undefined') {
|
|
window.addEventListener('nlMethodSelected', async (event) => {
|
|
this.authState = event.detail;
|
|
|
|
// If extension method, capture the specific extension the user chose
|
|
if (event.detail.method === 'extension') {
|
|
this.authenticatedExtension = event.detail.extension;
|
|
console.log('WindowNostr: Captured authenticated extension:', this.authenticatedExtension?.constructor?.name);
|
|
}
|
|
|
|
// Save authentication state for persistence
|
|
try {
|
|
await this.authManager.saveAuthState(event.detail);
|
|
console.log('WindowNostr: Auth state saved for persistence');
|
|
} catch (error) {
|
|
console.error('WindowNostr: Failed to save auth state:', error);
|
|
}
|
|
|
|
// EXTENSION-FIRST: Only reinstall facade for non-extension methods
|
|
// Extensions handle their own window.nostr - don't interfere!
|
|
if (event.detail.method !== 'extension' && typeof window !== 'undefined') {
|
|
console.log('WindowNostr: Re-installing facade after', this.authState?.method, 'authentication');
|
|
window.nostr = this;
|
|
} else if (event.detail.method === 'extension') {
|
|
console.log('WindowNostr: Extension authentication - NOT reinstalling facade');
|
|
}
|
|
|
|
console.log('WindowNostr: Auth state updated:', this.authState?.method);
|
|
});
|
|
|
|
window.addEventListener('nlLogout', () => {
|
|
this.authState = null;
|
|
this.authenticatedExtension = null;
|
|
|
|
// Clear persistent auth state
|
|
this.authManager.clearAuthState();
|
|
console.log('WindowNostr: Auth state cleared and persistence removed');
|
|
|
|
// EXTENSION-FIRST: Only reinstall facade if we're not in extension mode
|
|
if (typeof window !== 'undefined' && !this.nostrLite?.hasExtension) {
|
|
console.log('WindowNostr: Re-installing facade after logout (non-extension mode)');
|
|
window.nostr = this;
|
|
} else {
|
|
console.log('WindowNostr: Logout in extension mode - NOT reinstalling facade');
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
// Restore authentication state on page load
|
|
async restoreAuthState() {
|
|
try {
|
|
console.log('🔍 WindowNostr: === restoreAuthState START ===');
|
|
console.log('🔍 WindowNostr: authManager available:', !!this.authManager);
|
|
|
|
const restoredAuth = await this.authManager.restoreAuthState();
|
|
console.log('🔍 WindowNostr: authManager.restoreAuthState result:', restoredAuth);
|
|
|
|
if (restoredAuth) {
|
|
console.log('🔍 WindowNostr: ✅ Setting authState to restored auth');
|
|
this.authState = restoredAuth;
|
|
console.log('🔍 WindowNostr: this.authState now:', this.authState);
|
|
|
|
// Handle method-specific restoration
|
|
if (restoredAuth.method === 'extension') {
|
|
console.log('🔍 WindowNostr: Extension method - setting authenticatedExtension');
|
|
this.authenticatedExtension = restoredAuth.extension;
|
|
console.log('🔍 WindowNostr: authenticatedExtension set to:', this.authenticatedExtension);
|
|
}
|
|
|
|
console.log('🔍 WindowNostr: ✅ Authentication state restored successfully!');
|
|
console.log('🔍 WindowNostr: Method:', restoredAuth.method);
|
|
console.log('🔍 WindowNostr: Pubkey:', restoredAuth.pubkey);
|
|
|
|
// Dispatch restoration event so UI can update
|
|
if (typeof window !== 'undefined') {
|
|
console.log('🔍 WindowNostr: Dispatching nlAuthRestored event...');
|
|
const event = new CustomEvent('nlAuthRestored', {
|
|
detail: restoredAuth
|
|
});
|
|
console.log('🔍 WindowNostr: Event detail:', event.detail);
|
|
window.dispatchEvent(event);
|
|
console.log('🔍 WindowNostr: ✅ nlAuthRestored event dispatched');
|
|
}
|
|
|
|
console.log('🔍 WindowNostr: === restoreAuthState END (success) ===');
|
|
return restoredAuth;
|
|
} else {
|
|
console.log('🔍 WindowNostr: ❌ No authentication state to restore (null from authManager)');
|
|
console.log('🔍 WindowNostr: === restoreAuthState END (no restore) ===');
|
|
return null;
|
|
}
|
|
} catch (error) {
|
|
console.error('🔍 WindowNostr: ❌ Failed to restore auth state:', error);
|
|
console.error('🔍 WindowNostr: Error stack:', error.stack);
|
|
console.log('🔍 WindowNostr: === restoreAuthState END (error) ===');
|
|
return null;
|
|
}
|
|
}
|
|
|
|
async getPublicKey() {
|
|
if (!this.authState) {
|
|
throw new Error('Not authenticated - use NOSTR_LOGIN_LITE.launch()');
|
|
}
|
|
|
|
switch (this.authState.method) {
|
|
case 'extension':
|
|
// Use the captured authenticated extension, not current window.nostr
|
|
const ext = this.authenticatedExtension || this.authState.extension || this.existingNostr;
|
|
if (!ext) throw new Error('Extension not available');
|
|
return await ext.getPublicKey();
|
|
|
|
case 'local':
|
|
case 'nip46':
|
|
return this.authState.pubkey;
|
|
|
|
case 'readonly':
|
|
throw new Error('Read-only mode - cannot get public key');
|
|
|
|
default:
|
|
throw new Error(\`Unsupported auth method: \${this.authState.method}\`);
|
|
}
|
|
}
|
|
|
|
async signEvent(event) {
|
|
if (!this.authState) {
|
|
throw new Error('Not authenticated - use NOSTR_LOGIN_LITE.launch()');
|
|
}
|
|
|
|
if (this.authState.method === 'readonly') {
|
|
throw new Error('Read-only mode - cannot sign events');
|
|
}
|
|
|
|
switch (this.authState.method) {
|
|
case 'extension':
|
|
// Use the captured authenticated extension, not current window.nostr
|
|
console.log('WindowNostr: signEvent - authenticatedExtension:', this.authenticatedExtension);
|
|
console.log('WindowNostr: signEvent - authState.extension:', this.authState.extension);
|
|
console.log('WindowNostr: signEvent - existingNostr:', this.existingNostr);
|
|
|
|
const ext = this.authenticatedExtension || this.authState.extension || this.existingNostr;
|
|
console.log('WindowNostr: signEvent - using extension:', ext);
|
|
console.log('WindowNostr: signEvent - extension constructor:', ext?.constructor?.name);
|
|
|
|
if (!ext) throw new Error('Extension not available');
|
|
return await ext.signEvent(event);
|
|
|
|
case 'local': {
|
|
// Use nostr-tools to sign with local secret key
|
|
const { nip19, finalizeEvent } = window.NostrTools;
|
|
let secretKey;
|
|
|
|
if (this.authState.secret.startsWith('nsec')) {
|
|
const decoded = nip19.decode(this.authState.secret);
|
|
secretKey = decoded.data;
|
|
} else {
|
|
// Convert hex to Uint8Array
|
|
secretKey = this._hexToUint8Array(this.authState.secret);
|
|
}
|
|
|
|
return finalizeEvent(event, secretKey);
|
|
}
|
|
|
|
case 'nip46': {
|
|
// Use BunkerSigner for NIP-46
|
|
if (!this.authState.signer?.bunkerSigner) {
|
|
throw new Error('NIP-46 signer not available');
|
|
}
|
|
return await this.authState.signer.bunkerSigner.signEvent(event);
|
|
}
|
|
|
|
default:
|
|
throw new Error(\`Unsupported auth method: \${this.authState.method}\`);
|
|
}
|
|
}
|
|
|
|
async getRelays() {
|
|
// Return default relays since we removed the relays configuration
|
|
return ['wss://relay.damus.io', 'wss://nos.lol'];
|
|
}
|
|
|
|
get nip04() {
|
|
return {
|
|
encrypt: async (pubkey, plaintext) => {
|
|
if (!this.authState) {
|
|
throw new Error('Not authenticated - use NOSTR_LOGIN_LITE.launch()');
|
|
}
|
|
|
|
if (this.authState.method === 'readonly') {
|
|
throw new Error('Read-only mode - cannot encrypt');
|
|
}
|
|
|
|
switch (this.authState.method) {
|
|
case 'extension': {
|
|
const ext = this.authenticatedExtension || this.authState.extension || this.existingNostr;
|
|
if (!ext) throw new Error('Extension not available');
|
|
return await ext.nip04.encrypt(pubkey, plaintext);
|
|
}
|
|
|
|
case 'local': {
|
|
const { nip04, nip19 } = window.NostrTools;
|
|
let secretKey;
|
|
|
|
if (this.authState.secret.startsWith('nsec')) {
|
|
const decoded = nip19.decode(this.authState.secret);
|
|
secretKey = decoded.data;
|
|
} else {
|
|
secretKey = this._hexToUint8Array(this.authState.secret);
|
|
}
|
|
|
|
return await nip04.encrypt(secretKey, pubkey, plaintext);
|
|
}
|
|
|
|
case 'nip46': {
|
|
if (!this.authState.signer?.bunkerSigner) {
|
|
throw new Error('NIP-46 signer not available');
|
|
}
|
|
return await this.authState.signer.bunkerSigner.nip04Encrypt(pubkey, plaintext);
|
|
}
|
|
|
|
default:
|
|
throw new Error(\`Unsupported auth method: \${this.authState.method}\`);
|
|
}
|
|
},
|
|
|
|
decrypt: async (pubkey, ciphertext) => {
|
|
if (!this.authState) {
|
|
throw new Error('Not authenticated - use NOSTR_LOGIN_LITE.launch()');
|
|
}
|
|
|
|
if (this.authState.method === 'readonly') {
|
|
throw new Error('Read-only mode - cannot decrypt');
|
|
}
|
|
|
|
switch (this.authState.method) {
|
|
case 'extension': {
|
|
const ext = this.authenticatedExtension || this.authState.extension || this.existingNostr;
|
|
if (!ext) throw new Error('Extension not available');
|
|
return await ext.nip04.decrypt(pubkey, ciphertext);
|
|
}
|
|
|
|
case 'local': {
|
|
const { nip04, nip19 } = window.NostrTools;
|
|
let secretKey;
|
|
|
|
if (this.authState.secret.startsWith('nsec')) {
|
|
const decoded = nip19.decode(this.authState.secret);
|
|
secretKey = decoded.data;
|
|
} else {
|
|
secretKey = this._hexToUint8Array(this.authState.secret);
|
|
}
|
|
|
|
return await nip04.decrypt(secretKey, pubkey, ciphertext);
|
|
}
|
|
|
|
case 'nip46': {
|
|
if (!this.authState.signer?.bunkerSigner) {
|
|
throw new Error('NIP-46 signer not available');
|
|
}
|
|
return await this.authState.signer.bunkerSigner.nip04Decrypt(pubkey, ciphertext);
|
|
}
|
|
|
|
default:
|
|
throw new Error(\`Unsupported auth method: \${this.authState.method}\`);
|
|
}
|
|
}
|
|
};
|
|
}
|
|
|
|
get nip44() {
|
|
return {
|
|
encrypt: async (pubkey, plaintext) => {
|
|
if (!this.authState) {
|
|
throw new Error('Not authenticated - use NOSTR_LOGIN_LITE.launch()');
|
|
}
|
|
|
|
if (this.authState.method === 'readonly') {
|
|
throw new Error('Read-only mode - cannot encrypt');
|
|
}
|
|
|
|
switch (this.authState.method) {
|
|
case 'extension': {
|
|
const ext = this.authenticatedExtension || this.authState.extension || this.existingNostr;
|
|
if (!ext) throw new Error('Extension not available');
|
|
return await ext.nip44.encrypt(pubkey, plaintext);
|
|
}
|
|
|
|
case 'local': {
|
|
const { nip44, nip19 } = window.NostrTools;
|
|
let secretKey;
|
|
|
|
if (this.authState.secret.startsWith('nsec')) {
|
|
const decoded = nip19.decode(this.authState.secret);
|
|
secretKey = decoded.data;
|
|
} else {
|
|
secretKey = this._hexToUint8Array(this.authState.secret);
|
|
}
|
|
|
|
return nip44.encrypt(plaintext, nip44.getConversationKey(secretKey, pubkey));
|
|
}
|
|
|
|
case 'nip46': {
|
|
if (!this.authState.signer?.bunkerSigner) {
|
|
throw new Error('NIP-46 signer not available');
|
|
}
|
|
return await this.authState.signer.bunkerSigner.nip44Encrypt(pubkey, plaintext);
|
|
}
|
|
|
|
default:
|
|
throw new Error(\`Unsupported auth method: \${this.authState.method}\`);
|
|
}
|
|
},
|
|
|
|
decrypt: async (pubkey, ciphertext) => {
|
|
if (!this.authState) {
|
|
throw new Error('Not authenticated - use NOSTR_LOGIN_LITE.launch()');
|
|
}
|
|
|
|
if (this.authState.method === 'readonly') {
|
|
throw new Error('Read-only mode - cannot decrypt');
|
|
}
|
|
|
|
switch (this.authState.method) {
|
|
case 'extension': {
|
|
const ext = this.authenticatedExtension || this.authState.extension || this.existingNostr;
|
|
if (!ext) throw new Error('Extension not available');
|
|
return await ext.nip44.decrypt(pubkey, ciphertext);
|
|
}
|
|
|
|
case 'local': {
|
|
const { nip44, nip19 } = window.NostrTools;
|
|
let secretKey;
|
|
|
|
if (this.authState.secret.startsWith('nsec')) {
|
|
const decoded = nip19.decode(this.authState.secret);
|
|
secretKey = decoded.data;
|
|
} else {
|
|
secretKey = this._hexToUint8Array(this.authState.secret);
|
|
}
|
|
|
|
return nip44.decrypt(ciphertext, nip44.getConversationKey(secretKey, pubkey));
|
|
}
|
|
|
|
case 'nip46': {
|
|
if (!this.authState.signer?.bunkerSigner) {
|
|
throw new Error('NIP-46 signer not available');
|
|
}
|
|
return await this.authState.signer.bunkerSigner.nip44Decrypt(pubkey, ciphertext);
|
|
}
|
|
|
|
default:
|
|
throw new Error(\`Unsupported auth method: \${this.authState.method}\`);
|
|
}
|
|
}
|
|
};
|
|
}
|
|
|
|
_hexToUint8Array(hex) {
|
|
if (hex.length % 2 !== 0) {
|
|
throw new Error('Invalid hex string length');
|
|
}
|
|
const bytes = new Uint8Array(hex.length / 2);
|
|
for (let i = 0; i < bytes.length; i++) {
|
|
bytes[i] = parseInt(hex.substr(i * 2, 2), 16);
|
|
}
|
|
return bytes;
|
|
}
|
|
}
|
|
|
|
// Initialize and export
|
|
if (typeof window !== 'undefined') {
|
|
const nostrLite = new NostrLite();
|
|
|
|
// Export main API
|
|
window.NOSTR_LOGIN_LITE = {
|
|
init: (options) => nostrLite.init(options),
|
|
launch: (startScreen) => nostrLite.launch(startScreen),
|
|
logout: () => nostrLite.logout(),
|
|
|
|
// Embedded modal method
|
|
embed: (container, options) => nostrLite.embed(container, options),
|
|
|
|
// CSS-only theme management API
|
|
switchTheme: (themeName) => nostrLite.switchTheme(themeName),
|
|
getCurrentTheme: () => nostrLite.getCurrentTheme(),
|
|
getAvailableThemes: () => nostrLite.getAvailableThemes(),
|
|
|
|
// Floating tab management API
|
|
showFloatingTab: () => nostrLite.showFloatingTab(),
|
|
hideFloatingTab: () => nostrLite.hideFloatingTab(),
|
|
toggleFloatingTab: () => nostrLite.toggleFloatingTab(),
|
|
updateFloatingTab: (options) => nostrLite.updateFloatingTab(options),
|
|
getFloatingTabState: () => nostrLite.getFloatingTabState(),
|
|
|
|
// Expose for debugging
|
|
_extensionBridge: nostrLite.extensionBridge,
|
|
_instance: nostrLite
|
|
};
|
|
|
|
console.log('NOSTR_LOGIN_LITE: Library loaded and ready');
|
|
console.log('NOSTR_LOGIN_LITE: Use window.NOSTR_LOGIN_LITE.init(options) to initialize');
|
|
console.log('NOSTR_LOGIN_LITE: Detected', nostrLite.extensionBridge.getExtensionCount(), 'browser extensions');
|
|
} else {
|
|
// Node.js environment
|
|
module.exports = { NostrLite };
|
|
}
|
|
`;
|
|
|
|
// Write the complete bundle
|
|
fs.writeFileSync(outputPath, bundle, 'utf8');
|
|
|
|
const sizeKB = (bundle.length / 1024).toFixed(2);
|
|
console.log(`\n✅ nostr-lite.js bundle created: ${outputPath}`);
|
|
console.log(`📏 Bundle size: ${sizeKB} KB`);
|
|
console.log(`📄 Total lines: ${bundle.split('\n').length}`);
|
|
|
|
// Check what's included
|
|
const hasModal = bundle.includes('class Modal');
|
|
const hasNostrLite = bundle.includes('NOSTR_LOGIN_LITE');
|
|
const hasThemeCss = bundle.includes('THEME_CSS');
|
|
|
|
console.log('\n📋 Bundle contents:');
|
|
console.log(` Modal UI: ${hasModal ? '✅ Included' : '❌ Missing'}`);
|
|
console.log(` NOSTR_LOGIN_LITE: ${hasNostrLite ? '✅ Included' : '❌ Missing'}`);
|
|
console.log(` CSS-Only Themes: ${hasThemeCss ? '✅ Included' : '❌ Missing'}`);
|
|
console.log(` Extension Bridge: ✅ Included`);
|
|
console.log(` Window.nostr facade: ✅ Included`);
|
|
|
|
console.log('\n📋 Two-file architecture:');
|
|
console.log(' 1. nostr.bundle.js (official nostr-tools - 220KB)');
|
|
console.log(` 2. nostr-lite.js (NOSTR_LOGIN_LITE with CSS-only themes - ${sizeKB}KB)`);
|
|
|
|
return bundle;
|
|
}
|
|
|
|
// Run if called directly
|
|
if (typeof require !== 'undefined' && require.main === module) {
|
|
createNostrLoginLiteBundle();
|
|
}
|
|
|
|
module.exports = { createNostrLoginLiteBundle }; |