Files
nostr_login_lite/lite/nostr-login-lite.js
2025-09-13 09:06:32 -04:00

918 lines
26 KiB
JavaScript

/**
* NOSTR_LOGIN_LITE
* A minimal, dependency-light replacement for the current auth/UI stack
* Preserves all login methods and window.nostr surface
*/
// Import NIP-46 client
if (typeof NIP46Client === 'undefined') {
// Load NIP46Client if not already available (for non-bundled version)
const script = document.createElement('script');
script.src = './core/nip46-client.js';
document.head.appendChild(script);
}
// Global state
const LiteState = {
initialized: false,
windowNostr: null,
options: null,
auth: null,
modal: null,
bus: null,
pool: null,
nip44Codec: null,
extensionBridge: null,
nip46Client: null
};
// Dependencies verification
class Deps {
static ensureNostrToolsLoaded() {
if (typeof window === 'undefined') {
throw new Error('NOSTR_LOGIN_LITE must run in browser environment');
}
if (!window.NostrTools) {
throw new Error(
'window.NostrTools is required but not loaded. ' +
'Please include: <script src="./lite/nostr.bundle.js"></script>'
);
}
// Verify required APIs
const required = ['SimplePool', 'getPublicKey', 'finalizeEvent', 'nip04'];
for (const api of required) {
if (!window.NostrTools[api]) {
throw new Error(`window.NostrTools.${api} is required but missing`);
}
}
// Check for key generation function (might be generateSecretKey or generatePrivateKey)
if (!window.NostrTools.generateSecretKey && !window.NostrTools.generatePrivateKey) {
throw new Error('window.NostrTools must have either generateSecretKey or generatePrivateKey');
}
return true;
}
}
// Event Bus for internal communication
class Bus {
constructor() {
this.handlers = {};
}
on(event, handler) {
if (!this.handlers[event]) {
this.handlers[event] = [];
}
this.handlers[event].push(handler);
}
off(event, handler) {
if (!this.handlers[event]) return;
this.handlers[event] = this.handlers[event].filter(h => h !== handler);
}
emit(event, payload) {
if (!this.handlers[event]) return;
this.handlers[event].forEach(handler => {
try {
handler(payload);
} catch (e) {
console.error(`Error in event handler for ${event}:`, e);
}
});
}
}
// Storage helpers
class Store {
static addAccount(info) {
const accounts = this.getAccounts();
// Remove existing account with same pubkey if present
const filtered = accounts.filter(acc => acc.pubkey !== info.pubkey);
filtered.push(info);
localStorage.setItem('nl_accounts', JSON.stringify(filtered));
}
static removeCurrentAccount() {
const current = this.getCurrent();
if (current && current.pubkey) {
const accounts = this.getAccounts();
const filtered = accounts.filter(acc => acc.pubkey !== current.pubkey);
localStorage.setItem('nl_accounts', JSON.stringify(filtered));
localStorage.removeItem('nl_current');
}
}
static getCurrent() {
try {
const stored = localStorage.getItem('nl_current');
return stored ? JSON.parse(stored) : null;
} catch (e) {
console.error('Error parsing current account:', e);
return null;
}
}
static setCurrent(info) {
localStorage.setItem('nl_current', JSON.stringify(info));
}
static getAccounts() {
try {
const stored = localStorage.getItem('nl_accounts');
return stored ? JSON.parse(stored) : [];
} catch (e) {
console.error('Error parsing accounts:', e);
return [];
}
}
static getRecents() {
// Return last 5 used accounts in reverse chronological order
const accounts = this.getAccounts().slice(-5).reverse();
return accounts;
}
static setItem(key, value) {
localStorage.setItem(`nl-${key}`, value);
}
static getItem(key) {
return localStorage.getItem(`nl-${key}`);
}
static async getIcon() {
// Simple default icon - could be extended to fetch from profile
return '🔑';
}
}
// Relay configuration helpers
class Relays {
static getDefaultRelays(options) {
if (options?.relays) {
return this.normalize(options.relays);
}
// Default relays for fallbacks
return [
'wss://relay.damus.io',
'wss://relay.snort.social',
'wss://nos.lol'
];
}
static normalize(relays) {
return relays.map(relay => {
// Ensure wss:// prefix
if (relay.startsWith('ws://')) {
return relay.replace('ws://', 'wss://');
} else if (!relay.startsWith('wss://')) {
return `wss://${relay}`;
}
return relay;
}).filter(relay => {
// Remove duplicates and validate URLs
try {
new URL(relay);
return true;
} catch {
return false;
}
}).filter((relay, index, self) => self.indexOf(relay) === index); // dedupe
}
}
// Minimal NIP-44 codec fallback
class Nip44 {
constructor() {
this.Nip44 = null;
// Initialize with existing codec if available
this.nip44Available = window.NostrTools?.nip44;
}
static encrypt(ourSk, theirPk, plaintext) {
if (window.NostrTools?.nip44?.encrypt) {
return window.NostrTools.nip44.encrypt(ourSk, theirPk, plaintext);
}
throw new Error('NIP-44 encryption not available. Please use nostr-tools@>=2.x or provide codec implementation.');
}
static decrypt(ourSk, theirPk, ciphertext) {
if (window.NostrTools?.nip44?.decrypt) {
return window.NostrTools.nip44.decrypt(ourSk, theirPk, ciphertext);
}
throw new Error('NIP-44 decryption not available. Please use nostr-tools@>=2.x or provide codec implementation.');
}
}
// LocalSigner wrapping window.NostrTools
class LocalSigner {
constructor(sk) {
this.sk = sk;
// Generate pubkey from secret key
this.pk = this._getPubKey();
}
_getPubKey() {
const seckey = this.sk.startsWith('nsec') ?
window.NostrTools.nip19.decode(this.sk).data :
this.sk;
return window.NostrTools.getPublicKey(seckey);
}
pubkey() {
return this.pk;
}
async sign(event) {
// Prepare event for signing
const ev = { ...event };
ev.pubkey = this.pk;
// Generate event ID and sign
const signedEvent = await window.NostrTools.finalizeEvent(ev, this.sk);
return signedEvent;
}
async encrypt04(pubkey, plaintext) {
return await window.NostrTools.nip04.encrypt(this.sk, pubkey, plaintext);
}
async decrypt04(pubkey, ciphertext) {
return await window.NostrTools.nip04.decrypt(this.sk, pubkey, ciphertext);
}
async encrypt44(pubkey, plaintext) {
return Nip44.encrypt(this.sk, pubkey, plaintext);
}
async decrypt44(pubkey, ciphertext) {
return Nip44.decrypt(this.sk, pubkey, ciphertext);
}
}
// ExtensionBridge for detecting and managing browser extensions
class ExtensionBridge {
constructor() {
this.checking = false;
this.checkInterval = null;
this.originalNostr = null;
this.foundExtension = null;
}
startChecking(nostrLite) {
if (this.checking) return;
this.checking = true;
const check = () => {
this.initExtension(nostrLite);
};
// Check immediately
check();
// Then check every 200ms for 30 seconds
this.checkInterval = setInterval(check, 200);
// Stop checking after 30 seconds
setTimeout(() => {
clearInterval(this.checkInterval);
this.checkInterval = null;
}, 30000);
}
initExtension(nostrLite, lastTry = false) {
const extension = window.nostr;
if (extension && !this.foundExtension) {
// Check if this is actually a real extension, not our own library
const isRealExtension = (
extension !== nostrLite && // Not the same object we're about to assign
extension !== windowNostr && // Not our windowNostr object
typeof extension._hexToUint8Array !== 'function' && // Our library has this internal method
extension.constructor.name !== 'Object' // Real extensions usually have proper constructors
);
if (isRealExtension) {
this.foundExtension = extension;
// Cache the extension and reassign window.nostr to our lite version
this.originalNostr = window.nostr;
window.nostr = nostrLite;
console.log('Real Nostr extension detected and bridged:', extension.constructor.name);
// If currently authenticated, reconcile state
if (LiteState.auth?.signer?.method === 'extension') {
this.reconcileExtension();
}
} else {
console.log('Skipping non-extension object on window.nostr:', extension.constructor.name);
}
}
}
hasExtension() {
return !!this.foundExtension;
}
async setExtensionReadPubkey(expectedPubkey = null) {
if (!this.foundExtension) return false;
try {
// Temporarily set window.nostr to extension
const temp = window.nostr;
window.nostr = this.foundExtension;
const pubkey = await this.foundExtension.getPublicKey();
// Restore our lite implementation
window.nostr = temp;
if (expectedPubkey && pubkey !== expectedPubkey) {
console.warn(`Extension pubkey ${pubkey} does not match expected ${expectedPubkey}`);
}
return pubkey;
} catch (e) {
console.error('Error reading extension pubkey:', e);
return null;
}
}
trySetForPubkey(expectedPubkey) {
if (!this.hasExtension()) return false;
this.setExtensionReadPubkey(expectedPubkey).then(pubkey => {
if (pubkey) {
LiteState.bus?.emit('extensionLogin', { pubkey });
}
});
return true;
}
setExtension() {
if (!this.foundExtension) return;
window.nostr = this.foundExtension;
this.setExtensionReadPubkey().then(pubkey => {
if (pubkey) {
LiteState.bus?.emit('extensionSet', { pubkey });
}
});
}
unset(nostrLite) {
window.nostr = nostrLite;
}
reconcileExtension() {
// Handle extension state changes
this.setExtensionReadPubkey().then(pubkey => {
if (pubkey) {
// Update current account if extension is the signer
const current = Store.getCurrent();
if (current && current.signer?.method === 'extension') {
const info = {
...current,
pubkey,
signer: { method: 'extension' }
};
Store.setCurrent(info);
LiteState.bus?.emit('authStateUpdate', info);
}
}
});
}
}
// Main API surface
class NostrLite {
static async init(options = {}) {
// Ensure dependencies are loaded
Deps.ensureNostrToolsLoaded();
// Prevent double initialization
if (LiteState.initialized) {
console.warn('NOSTR_LOGIN_LITE already initialized');
return;
}
// Initialize components
LiteState.bus = new Bus();
LiteState.extensionBridge = new ExtensionBridge();
// Initialize NIP-46 client
LiteState.nip46Client = new NIP46Client();
// Store options
LiteState.options = {
theme: 'light',
darkMode: false,
relays: Relays.getDefaultRelays(options),
methods: {
connect: true,
extension: true,
local: true,
readonly: true,
otp: true
},
otp: {},
...options
};
// Start extension detection
LiteState.extensionBridge.startChecking(windowNostr);
// Setup auth methods
this._setupAuth();
// Initialize modal UI
// this._initModal();
console.log('NOSTR_LOGIN_LITE initialized with options:', LiteState.options);
LiteState.initialized = true;
}
static _setupAuth() {
// Set up event listeners for modal interactions
window.addEventListener('nlMethodSelected', (event) => {
this._handleMethodSelected(event.detail);
});
// Set up other auth-related event listeners
this._setupAuthEventListeners();
console.log('Auth system setup loaded');
}
static _setupAuthEventListeners() {
// Handle extension detection
this.bus?.on('extensionDetected', (extension) => {
console.log('Extension detected');
LiteState.extensionBridge.foundExtension = extension;
});
// Handle auth URL from NIP-46
window.addEventListener('nlAuthUrl', (event) => {
console.log('Auth URL received:', event.detail.url);
// Could show URL in modal or trigger external flow
});
// Handle logout events
window.addEventListener('nlLogout', () => {
console.log('Logout event received');
this.logout();
});
}
static _handleMethodSelected(detail) {
console.log('Method selected:', detail);
const { method, pubkey, secret, extension } = detail;
switch (method) {
case 'local':
if (secret && pubkey) {
// Set up local key authentication
const info = {
pubkey,
signer: { method: 'local', secret }
};
Store.setCurrent(info);
LiteState.bus?.emit('authStateUpdate', info);
this._dispatchAuthEvent('login', info);
}
break;
case 'extension':
if (pubkey && extension) {
// Store the extension object in the ExtensionBridge for future use
LiteState.extensionBridge.foundExtension = extension;
LiteState.extensionBridge.originalNostr = extension;
// Set up extension authentication
const info = {
pubkey,
signer: { method: 'extension' }
};
Store.setCurrent(info);
LiteState.bus?.emit('authStateUpdate', info);
this._dispatchAuthEvent('login', info);
console.log('Extension authentication set up successfully');
} else {
// Fallback to extension bridge detection
LiteState.bus?.emit('authMethodSelected', { method: 'extension' });
}
break;
case 'readonly':
// Set read-only mode
const readonlyInfo = {
pubkey: '',
signer: { method: 'readonly' }
};
Store.setCurrent(readonlyInfo);
LiteState.bus?.emit('authStateUpdate', readonlyInfo);
this._dispatchAuthEvent('login', readonlyInfo);
break;
case 'nip46':
if (secret && pubkey) {
// Set up NIP-46 remote signing
const info = {
pubkey,
signer: { method: 'nip46', ...secret }
};
Store.setCurrent(info);
LiteState.bus?.emit('authStateUpdate', info);
this._dispatchAuthEvent('login', info);
}
break;
default:
console.warn('Unhandled auth method:', method);
}
}
static _dispatchAuthEvent(type, info) {
const eventPayload = {
type,
info,
pubkey: info?.pubkey || '',
method: info?.signer?.method || '',
...info
};
// Dispatch the event
window.dispatchEvent(new CustomEvent('nlAuth', { detail: eventPayload }));
this.bus?.emit('nlAuth', eventPayload);
}
static launch(startScreen) {
if (!LiteState.initialized) {
throw new Error('NOSTR_LOGIN_LITE not initialized. Call init() first.');
}
console.log('Launch requested with screen:', startScreen);
// Initialize modal if needed
if (!LiteState.modal) {
// Import modal lazily
if (typeof Modal !== 'undefined') {
LiteState.modal = Modal.init(LiteState.options);
} else {
console.error('Modal component not available');
return;
}
}
// Open modal with specified screen
LiteState.modal.open({ startScreen });
}
static logout() {
if (!LiteState.initialized) return;
// Clear current account and state
Store.removeCurrentAccount();
// Reset internal state
LiteState.auth = null;
// Emit logout event
window.dispatchEvent(new CustomEvent('nlLogout'));
LiteState.bus?.emit('logout');
console.log('Logged out');
}
static setDarkMode(dark) {
if (!LiteState.options) return;
LiteState.options.darkMode = dark;
Store.setItem('darkMode', dark.toString());
// Update modal theme if initialized
if (LiteState.modal) {
// LiteState.modal.updateTheme();
}
window.dispatchEvent(new CustomEvent('nlDarkMode', { detail: { dark } }));
}
static setAuth(o) {
if (!o || !o.type) return;
console.log('setAuth called:', o);
// Validate request
if (!['login', 'signup', 'logout'].includes(o.type)) {
throw new Error(`Invalid auth type: ${o.type}`);
}
if (['login', 'signup'].includes(o.type) && !['connect', 'extension', 'local', 'otp', 'readOnly'].includes(o.method)) {
throw new Error(`Invalid auth method: ${o.method}`);
}
// Handle based on type
switch (o.type) {
case 'logout':
this.logout();
break;
default:
// Delegate to auth system - will be implemented
console.log('Auth delegation not yet implemented');
}
}
static cancelNeedAuth() {
// Cancel any ongoing auth flows
LiteState.bus?.emit('cancelAuth');
console.log('Auth flow cancelled');
}
}
// Initialize the window.nostr facade
const windowNostr = {
async getPublicKey() {
if (!LiteState.initialized) {
throw new Error('NOSTR_LOGIN_LITE not initialized');
}
const current = Store.getCurrent();
if (current && current.pubkey) {
return current.pubkey;
}
// Trigger auth flow
const authPromise = new Promise((resolve, reject) => {
const handleAuth = (event) => {
window.removeEventListener('nlAuth', handleAuth);
if (event.detail.type === 'login' && event.detail.pubkey) {
resolve(event.detail.pubkey);
} else {
reject(new Error('Authentication cancelled'));
}
};
window.addEventListener('nlAuth', handleAuth);
// Set timeout
setTimeout(() => {
window.removeEventListener('nlAuth', handleAuth);
reject(new Error('Authentication timeout'));
}, 300000); // 5 minutes
});
// Launch auth modal
NostrLite.launch('login');
return authPromise;
},
async signEvent(event) {
if (!LiteState.initialized) {
throw new Error('NOSTR_LOGIN_LITE not initialized');
}
let current = Store.getCurrent();
// If no current account, trigger auth
if (!current) {
await window.nostr.getPublicKey(); // This will trigger auth
current = Store.getCurrent();
if (!current) {
throw new Error('Authentication failed');
}
}
// Route to appropriate signer
if (current.signer?.method === 'local' && current.signer.secret) {
const signer = new LocalSigner(this._hexToUint8Array(current.signer.secret));
return await signer.sign(event);
} else if (current.signer?.method === 'nip46' && current.signer.bunkerSigner) {
// Route to NIP-46 remote signer
try {
const bunkerSigner = current.signer.bunkerSigner;
const signedEvent = await bunkerSigner.signEvent(event);
return signedEvent;
} catch (error) {
console.error('NIP-46 signEvent failed:', error);
throw new Error(`NIP-46 signing failed: ${error.message}`);
}
} else if (current.signer?.method === 'readonly') {
throw new Error('Cannot sign events in read-only mode');
} else if (current.signer?.method === 'extension' && LiteState.extensionBridge?.hasExtension()) {
// Route to extension
const temp = window.nostr;
window.nostr = LiteState.extensionBridge.foundExtension;
try {
const signedEvent = await window.nostr.signEvent(event);
return signedEvent;
} finally {
window.nostr = temp;
}
}
throw new Error('No suitable signer available for current account');
},
_hexToUint8Array(hex) {
// Convert hex string to Uint8Array
const bytes = new Uint8Array(hex.length / 2);
for (let i = 0; i < bytes.length; i++) {
bytes[i] = parseInt(hex.substr(i * 2, 2), 16);
}
return bytes;
},
nip04: {
async encrypt(pubkey, plaintext) {
if (!LiteState.initialized) {
throw new Error('NOSTR_LOGIN_LITE not initialized');
}
const current = Store.getCurrent();
if (!current) {
throw new Error('No authenticated user');
}
if (current.signer?.method === 'nip46' && current.signer.bunkerSigner) {
// Route to NIP-46 remote signer
try {
const bunkerSigner = current.signer.bunkerSigner;
return await bunkerSigner.nip04Encrypt(pubkey, plaintext);
} catch (error) {
console.error('NIP-46 nip04 encrypt failed:', error);
throw new Error(`NIP-46 encrypting failed: ${error.message}`);
}
} else if (current.signer?.method === 'local' && current.signer.secret) {
const signer = new LocalSigner(current.signer.secret);
return await signer.encrypt04(pubkey, plaintext);
} else if (current.signer?.method === 'extension' && LiteState.extensionBridge?.hasExtension()) {
const temp = window.nostr;
window.nostr = LiteState.extensionBridge.foundExtension;
try {
return await window.nostr.nip04.encrypt(pubkey, plaintext);
} finally {
window.nostr = temp;
}
}
throw new Error('No suitable signer available for NIP-04 encryption');
},
async decrypt(pubkey, ciphertext) {
if (!LiteState.initialized) {
throw new Error('NOSTR_LOGIN_LITE not initialized');
}
const current = Store.getCurrent();
if (!current) {
throw new Error('No authenticated user');
}
if (current.signer?.method === 'nip46' && current.signer.bunkerSigner) {
// Route to NIP-46 remote signer
try {
const bunkerSigner = current.signer.bunkerSigner;
return await bunkerSigner.nip04Decrypt(pubkey, ciphertext);
} catch (error) {
console.error('NIP-46 nip04 decrypt failed:', error);
throw new Error(`NIP-46 decrypting failed: ${error.message}`);
}
} else if (current.signer?.method === 'local' && current.signer.secret) {
const signer = new LocalSigner(current.signer.secret);
return await signer.decrypt04(pubkey, ciphertext);
} else if (current.signer?.method === 'extension' && LiteState.extensionBridge?.hasExtension()) {
const temp = window.nostr;
window.nostr = LiteState.extensionBridge.foundExtension;
try {
return await window.nostr.nip04.decrypt(pubkey, ciphertext);
} finally {
window.nostr = temp;
}
}
throw new Error('No suitable signer available for NIP-04 decryption');
}
},
nip44: {
async encrypt(pubkey, plaintext) {
if (!LiteState.initialized) {
throw new Error('NOSTR_LOGIN_LITE not initialized');
}
const current = Store.getCurrent();
if (!current) {
throw new Error('No authenticated user');
}
if (current.signer?.method === 'nip46' && current.signer.bunkerSigner) {
// Route to NIP-46 remote signer
try {
const bunkerSigner = current.signer.bunkerSigner;
return await bunkerSigner.nip44Encrypt(pubkey, plaintext);
} catch (error) {
console.error('NIP-46 nip44 encrypt failed:', error);
throw new Error(`NIP-46 encrypting failed: ${error.message}`);
}
} else if (current.signer?.method === 'local' && current.signer.secret) {
const signer = new LocalSigner(current.signer.secret);
return await signer.encrypt44(pubkey, plaintext);
} else if (current.signer?.method === 'extension' && LiteState.extensionBridge?.hasExtension()) {
// Use extension if it supports nip44
const temp = window.nostr;
window.nostr = LiteState.extensionBridge.foundExtension;
try {
if (window.nostr.nip44) {
return await window.nostr.nip44.encrypt(pubkey, plaintext);
} else {
throw new Error('Extension does not support NIP-44');
}
} finally {
window.nostr = temp;
}
}
throw new Error('No suitable signer available for NIP-44 encryption');
},
async decrypt(pubkey, ciphertext) {
if (!LiteState.initialized) {
throw new Error('NOSTR_LOGIN_LITE not initialized');
}
const current = Store.getCurrent();
if (!current) {
throw new Error('No authenticated user');
}
if (current.signer?.method === 'nip46' && current.signer.bunkerSigner) {
// Route to NIP-46 remote signer
try {
const bunkerSigner = current.signer.bunkerSigner;
return await bunkerSigner.nip44Decrypt(pubkey, ciphertext);
} catch (error) {
console.error('NIP-46 nip44 decrypt failed:', error);
throw new Error(`NIP-46 decrypting failed: ${error.message}`);
}
} else if (current.signer?.method === 'local' && current.signer.secret) {
const signer = new LocalSigner(current.signer.secret);
return await signer.decrypt44(pubkey, ciphertext);
} else if (current.signer?.method === 'extension' && LiteState.extensionBridge?.hasExtension()) {
const temp = window.nostr;
window.nostr = LiteState.extensionBridge.foundExtension;
try {
if (window.nostr.nip44) {
return await window.nostr.nip44.decrypt(pubkey, ciphertext);
} else {
throw new Error('Extension does not support NIP-44');
}
} finally {
window.nostr = temp;
}
}
throw new Error('No suitable signer available for NIP-44 decryption');
}
}
};
// Export the API
window.NOSTR_LOGIN_LITE = {
init: NostrLite.init.bind(NostrLite),
launch: NostrLite.launch.bind(NostrLite),
logout: NostrLite.logout.bind(NostrLite),
setDarkMode: NostrLite.setDarkMode.bind(NostrLite),
setAuth: NostrLite.setAuth.bind(NostrLite),
cancelNeedAuth: NostrLite.cancelNeedAuth.bind(NostrLite),
// Expose internal components for debugging
get _extensionBridge() {
return LiteState.extensionBridge;
},
get _state() {
return LiteState;
}
};
// Set window.nostr facade properly (extensions will be handled by ExtensionBridge)
if (typeof window !== 'undefined') {
window.nostr = windowNostr;
// Ensure all methods are properly exposed
console.log('NOSTR_LOGIN_LITE: window.nostr facade installed with methods:', Object.keys(windowNostr));
}
console.log('NOSTR_LOGIN_LITE loaded - use window.NOSTR_LOGIN_LITE.init(options) to initialize');