/** * šļø 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-46 available:', !!window.NostrTools.nip46); } // ===== NIP-46 Extension Integration ===== // Add NIP-46 functionality to NostrTools if not already present if (typeof window.NostrTools !== 'undefined' && !window.NostrTools.nip46) { console.log('NOSTR_LOGIN_LITE: Adding NIP-46 extension to NostrTools'); const { nip44, generateSecretKey, getPublicKey, finalizeEvent, verifyEvent, utils } = window.NostrTools; // NIP-05 regex for parsing const NIP05_REGEX = /^(?:([\\w.+-]+)@)?([\\w_-]+(\.[\\w_-]+)+)$/; const BUNKER_REGEX = /^bunker:\\/\\/([0-9a-f]{64})\\??([?\\/\\w:.=&%-]*)$/; const EMAIL_REGEX = /^[^\\s@]+@[^\\s@]+\\.[^\\s@]+$/; // Event kinds const NostrConnect = 24133; const ClientAuth = 22242; const Handlerinformation = 31990; // Fetch implementation let _fetch; try { _fetch = fetch; } catch { _fetch = null; } function useFetchImplementation(fetchImplementation) { _fetch = fetchImplementation; } // Simple Pool implementation for NIP-46 class SimplePool { constructor() { this.relays = new Map(); this.subscriptions = new Map(); } async ensureRelay(url) { if (!this.relays.has(url)) { console.log(\`NIP-46: Connecting to relay \${url}\`); const ws = new WebSocket(url); const relay = { ws, connected: false, subscriptions: new Map() }; this.relays.set(url, relay); // Wait for connection with proper event handlers await new Promise((resolve, reject) => { const timeout = setTimeout(() => { console.error(\`NIP-46: Connection timeout for \${url}\`); reject(new Error(\`Connection timeout to \${url}\`)); }, 10000); // 10 second timeout ws.onopen = () => { console.log(\`NIP-46: Successfully connected to relay \${url}, WebSocket state: \${ws.readyState}\`); relay.connected = true; clearTimeout(timeout); resolve(); }; ws.onerror = (error) => { console.error(\`NIP-46: Failed to connect to \${url}:\`, error); clearTimeout(timeout); reject(new Error(\`Failed to connect to \${url}: \${error.message || 'Connection failed'}\`)); }; ws.onclose = (event) => { console.log(\`NIP-46: Disconnected from relay \${url}:\`, event.code, event.reason); relay.connected = false; if (this.relays.has(url)) { this.relays.delete(url); } clearTimeout(timeout); reject(new Error(\`Connection closed during setup: \${event.reason || 'Unknown reason'}\`)); }; }); } else { const relay = this.relays.get(url); // Verify the existing connection is still open if (!relay.connected || relay.ws.readyState !== WebSocket.OPEN) { console.log(\`NIP-46: Reconnecting to relay \${url}\`); this.relays.delete(url); return await this.ensureRelay(url); // Recursively reconnect } } const relay = this.relays.get(url); console.log(\`NIP-46: Relay \${url} ready, WebSocket state: \${relay.ws.readyState}\`); return relay; } subscribe(relays, filters, params = {}) { const subId = Math.random().toString(36).substring(7); relays.forEach(async (url) => { try { const relay = await this.ensureRelay(url); relay.ws.onmessage = (event) => { try { const data = JSON.parse(event.data); if (data[0] === 'EVENT' && data[1] === subId) { params.onevent?.(data[2]); } else if (data[0] === 'EOSE' && data[1] === subId) { params.oneose?.(); } } catch (err) { console.warn('Failed to parse message:', err); } }; // Ensure filters is an array const filtersArray = Array.isArray(filters) ? filters : [filters]; const reqMsg = JSON.stringify(['REQ', subId, ...filtersArray]); relay.ws.send(reqMsg); } catch (err) { console.warn('Failed to connect to relay:', url, err); } }); return { close: () => { relays.forEach(async (url) => { const relay = this.relays.get(url); if (relay?.connected) { relay.ws.send(JSON.stringify(['CLOSE', subId])); } }); } }; } async publish(relays, event) { console.log(\`NIP-46: Publishing event to \${relays.length} relays:\`, event); const promises = relays.map(async (url) => { try { console.log(\`NIP-46: Attempting to publish to \${url}\`); const relay = await this.ensureRelay(url); return new Promise((resolve, reject) => { const timeout = setTimeout(() => { console.error(\`NIP-46: Publish timeout to \${url}\`); reject(new Error(\`Publish timeout to \${url}\`)); }, 10000); // Increased timeout to 10 seconds // Set up message handler for this specific event const messageHandler = (msg) => { try { const data = JSON.parse(msg.data); if (data[0] === 'OK' && data[1] === event.id) { clearTimeout(timeout); relay.ws.removeEventListener('message', messageHandler); if (data[2]) { console.log(\`NIP-46: Publish success to \${url}:\`, data[3]); resolve(data[3]); } else { console.error(\`NIP-46: Publish rejected by \${url}:\`, data[3]); reject(new Error(\`Publish rejected: \${data[3]}\`)); } } } catch (err) { console.error(\`NIP-46: Error parsing message from \${url}:\`, err); clearTimeout(timeout); relay.ws.removeEventListener('message', messageHandler); reject(err); } }; relay.ws.addEventListener('message', messageHandler); // Double-check WebSocket state before sending console.log(\`NIP-46: About to publish to \${url}, WebSocket state: \${relay.ws.readyState} (0=CONNECTING, 1=OPEN, 2=CLOSING, 3=CLOSED)\`); if (relay.ws.readyState === WebSocket.OPEN) { console.log(\`NIP-46: Sending event to \${url}\`); relay.ws.send(JSON.stringify(['EVENT', event])); } else { console.error(\`NIP-46: WebSocket not ready for \${url}, state: \${relay.ws.readyState}\`); clearTimeout(timeout); relay.ws.removeEventListener('message', messageHandler); reject(new Error(\`WebSocket not ready for \${url}, state: \${relay.ws.readyState}\`)); } }); } catch (err) { console.error(\`NIP-46: Failed to publish to \${url}:\`, err); return Promise.reject(new Error(\`Failed to publish to \${url}: \${err.message}\`)); } }); const results = await Promise.allSettled(promises); console.log(\`NIP-46: Publish results:\`, results); return results; } async querySync(relays, filter, params = {}) { return new Promise((resolve) => { const events = []; this.subscribe(relays, [filter], { ...params, onevent: (event) => events.push(event), oneose: () => resolve(events) }); }); } } // Bunker URL utilities function toBunkerURL(bunkerPointer) { let bunkerURL = new URL(\`bunker://\${bunkerPointer.pubkey}\`); bunkerPointer.relays.forEach((relay) => { bunkerURL.searchParams.append('relay', relay); }); if (bunkerPointer.secret) { bunkerURL.searchParams.set('secret', bunkerPointer.secret); } return bunkerURL.toString(); } async function parseBunkerInput(input) { let match = input.match(BUNKER_REGEX); if (match) { try { const pubkey = match[1]; const qs = new URLSearchParams(match[2]); return { pubkey, relays: qs.getAll('relay'), secret: qs.get('secret') }; } catch (_err) { // Continue to NIP-05 parsing } } return queryBunkerProfile(input); } async function queryBunkerProfile(nip05) { if (!_fetch) { throw new Error('Fetch implementation not available'); } const match = nip05.match(NIP05_REGEX); if (!match) return null; const [_, name = '_', domain] = match; try { const url = \`https://\${domain}/.well-known/nostr.json?name=\${name}\`; const res = await (await _fetch(url, { redirect: 'error' })).json(); let pubkey = res.names[name]; let relays = res.nip46[pubkey] || []; return { pubkey, relays, secret: null }; } catch (_err) { return null; } } // BunkerSigner class class BunkerSigner { constructor(clientSecretKey, bp, params = {}) { if (bp.relays.length === 0) { throw new Error('no relays are specified for this bunker'); } this.params = params; this.pool = params.pool || new SimplePool(); this.secretKey = clientSecretKey; this.conversationKey = nip44.getConversationKey(clientSecretKey, bp.pubkey); this.bp = bp; this.isOpen = false; this.idPrefix = Math.random().toString(36).substring(7); this.serial = 0; this.listeners = {}; this.waitingForAuth = {}; this.ready = false; this.readyPromise = this.setupSubscription(params); } async setupSubscription(params) { console.log('NIP-46: Setting up subscription to relays:', this.bp.relays); const listeners = this.listeners; const waitingForAuth = this.waitingForAuth; const convKey = this.conversationKey; // Ensure all relays are connected first await Promise.all(this.bp.relays.map(url => this.pool.ensureRelay(url))); console.log('NIP-46: All relays connected, setting up subscription'); this.subCloser = this.pool.subscribe( this.bp.relays, [{ kinds: [NostrConnect], authors: [this.bp.pubkey], '#p': [getPublicKey(this.secretKey)] }], { onevent: async (event) => { const o = JSON.parse(nip44.decrypt(event.content, convKey)); const { id, result, error } = o; if (result === 'auth_url' && waitingForAuth[id]) { delete waitingForAuth[id]; if (params.onauth) { params.onauth(error); } else { console.warn( \`NIP-46: remote signer \${this.bp.pubkey} tried to send an "auth_url"='\${error}' but there was no onauth() callback configured.\` ); } return; } let handler = listeners[id]; if (handler) { if (error) handler.reject(error); else if (result) handler.resolve(result); delete listeners[id]; } }, onclose: () => { this.subCloser = undefined; } } ); this.isOpen = true; this.ready = true; console.log('NIP-46: BunkerSigner setup complete and ready'); } async ensureReady() { if (!this.ready) { console.log('NIP-46: Waiting for BunkerSigner to be ready...'); await this.readyPromise; } } async close() { this.isOpen = false; this.subCloser?.close(); } async sendRequest(method, params) { return new Promise(async (resolve, reject) => { try { await this.ensureReady(); // Wait for BunkerSigner to be ready if (!this.isOpen) { throw new Error('this signer is not open anymore, create a new one'); } if (!this.subCloser) { await this.setupSubscription(this.params); } this.serial++; const id = \`\${this.idPrefix}-\${this.serial}\`; const encryptedContent = nip44.encrypt(JSON.stringify({ id, method, params }), this.conversationKey); const verifiedEvent = finalizeEvent( { kind: NostrConnect, tags: [['p', this.bp.pubkey]], content: encryptedContent, created_at: Math.floor(Date.now() / 1000) }, this.secretKey ); this.listeners[id] = { resolve, reject }; this.waitingForAuth[id] = true; console.log(\`NIP-46: Sending \${method} request with id \${id}\`); const publishResults = await this.pool.publish(this.bp.relays, verifiedEvent); // Check if at least one publish succeeded const hasSuccess = publishResults.some(result => result.status === 'fulfilled'); if (!hasSuccess) { throw new Error('Failed to publish to any relay'); } console.log(\`NIP-46: \${method} request sent successfully\`); } catch (err) { console.error(\`NIP-46: sendRequest \${method} failed:\`, err); reject(err); } }); } async ping() { let resp = await this.sendRequest('ping', []); if (resp !== 'pong') { throw new Error(\`result is not pong: \${resp}\`); } } async connect() { await this.sendRequest('connect', [this.bp.pubkey, this.bp.secret || '']); } async getPublicKey() { if (!this.cachedPubKey) { this.cachedPubKey = await this.sendRequest('get_public_key', []); } return this.cachedPubKey; } async signEvent(event) { let resp = await this.sendRequest('sign_event', [JSON.stringify(event)]); let signed = JSON.parse(resp); if (verifyEvent(signed)) { return signed; } else { throw new Error(\`event returned from bunker is improperly signed: \${JSON.stringify(signed)}\`); } } async nip04Encrypt(thirdPartyPubkey, plaintext) { return await this.sendRequest('nip04_encrypt', [thirdPartyPubkey, plaintext]); } async nip04Decrypt(thirdPartyPubkey, ciphertext) { return await this.sendRequest('nip04_decrypt', [thirdPartyPubkey, ciphertext]); } async nip44Encrypt(thirdPartyPubkey, plaintext) { return await this.sendRequest('nip44_encrypt', [thirdPartyPubkey, plaintext]); } async nip44Decrypt(thirdPartyPubkey, ciphertext) { return await this.sendRequest('nip44_decrypt', [thirdPartyPubkey, ciphertext]); } } async function createAccount(bunker, params, username, domain, email, localSecretKey = generateSecretKey()) { if (email && !EMAIL_REGEX.test(email)) { throw new Error('Invalid email'); } let rpc = new BunkerSigner(localSecretKey, bunker.bunkerPointer, params); let pubkey = await rpc.sendRequest('create_account', [username, domain, email || '']); rpc.bp.pubkey = pubkey; await rpc.connect(); return rpc; } async function fetchBunkerProviders(pool, relays) { const events = await pool.querySync(relays, { kinds: [Handlerinformation], '#k': [NostrConnect.toString()] }); events.sort((a, b) => b.created_at - a.created_at); const validatedBunkers = await Promise.all( events.map(async (event, i) => { try { const content = JSON.parse(event.content); try { if (events.findIndex((ev) => JSON.parse(ev.content).nip05 === content.nip05) !== i) { return undefined; } } catch (err) { // Continue processing } const bp = await queryBunkerProfile(content.nip05); if (bp && bp.pubkey === event.pubkey && bp.relays.length) { return { bunkerPointer: bp, nip05: content.nip05, domain: content.nip05.split('@')[1], name: content.name || content.display_name, picture: content.picture, about: content.about, website: content.website, local: false }; } } catch (err) { return undefined; } }) ); return validatedBunkers.filter((b) => b !== undefined); } // Extend NostrTools with NIP-46 functionality window.NostrTools.nip46 = { BunkerSigner, parseBunkerInput, toBunkerURL, queryBunkerProfile, createAccount, fetchBunkerProviders, useFetchImplementation, BUNKER_REGEX, SimplePool }; console.log('NIP-46 extension loaded successfully'); console.log('Available: 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: '[LOGIN]', text: 'Login', iconOnly: false }, behavior: { hideWhenAuthenticated: true, showUserInfo: true, autoSlide: true, persistent: false }, ...options }; this.isAuthenticated = false; this.userInfo = 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:', e.detail); this._handleAuth(e.detail); }); window.addEventListener('nlLogout', () => { console.log('FloatingTab: Logout detected'); this._handleLogout(); }); } _handleClick() { console.log('FloatingTab: Clicked'); if (this.isAuthenticated && this.options.behavior.showUserInfo) { // Show user menu or profile options this._showUserMenu(); } else { // Open login modal if (this.modal) { this.modal.open({ startScreen: 'login' }); } } } _handleAuth(authData) { console.log('FloatingTab: Handling authentication:', authData); this.isAuthenticated = true; this.userInfo = authData; if (this.options.behavior.hideWhenAuthenticated) { this.hide(); } else { this._updateAppearance(); } } _handleLogout() { console.log('FloatingTab: Handling logout'); this.isAuthenticated = false; this.userInfo = 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 = \`