2380 lines
80 KiB
JavaScript
2380 lines
80 KiB
JavaScript
/**
|
||
* NOSTR_LOGIN_LITE - Authentication Library
|
||
* Two-file architecture:
|
||
* 1. Load nostr.bundle.js (official nostr-tools bundle)
|
||
* 2. Load nostr-lite.js (this file - consolidated NOSTR_LOGIN_LITE library with NIP-46)
|
||
* Generated on: 2025-09-13T18:23:00.000Z
|
||
*/
|
||
|
||
// 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));
|
||
}
|
||
|
||
// ======================================
|
||
// NIP-46 Extension (formerly nip46-extension.js)
|
||
// ======================================
|
||
|
||
(function() {
|
||
'use strict';
|
||
|
||
// Check if NostrTools is available
|
||
if (typeof window.NostrTools === 'undefined') {
|
||
console.error('NIP-46 Extension requires nostr-tools to be loaded first');
|
||
return;
|
||
}
|
||
|
||
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 (embedded)');
|
||
console.log('Available: NostrTools.nip46');
|
||
|
||
})();
|
||
|
||
// Verify NIP-46 extension is now available
|
||
if (typeof window !== 'undefined') {
|
||
console.log('NOSTR_LOGIN_LITE: NIP-46 available:', !!window.NostrTools.nip46);
|
||
}
|
||
|
||
// ======================================
|
||
// NOSTR_LOGIN_LITE Components
|
||
// ======================================
|
||
|
||
// ======================================
|
||
// Modal UI Component
|
||
// ======================================
|
||
|
||
|
||
class Modal {
|
||
constructor(options) {
|
||
this.options = options;
|
||
this.container = null;
|
||
this.isVisible = false;
|
||
this.currentScreen = null;
|
||
|
||
// Initialize modal container and styles
|
||
this._initModal();
|
||
}
|
||
|
||
_initModal() {
|
||
// Create modal container
|
||
this.container = document.createElement('div');
|
||
this.container.id = 'nl-modal';
|
||
this.container.style.cssText = `
|
||
position: fixed;
|
||
top: 0;
|
||
left: 0;
|
||
right: 0;
|
||
bottom: 0;
|
||
background: rgba(0, 0, 0, 0.75);
|
||
display: none;
|
||
z-index: 10000;
|
||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||
`;
|
||
|
||
// Create modal content
|
||
const modalContent = document.createElement('div');
|
||
modalContent.style.cssText = `
|
||
position: relative;
|
||
background: white;
|
||
width: 90%;
|
||
max-width: 400px;
|
||
margin: 50px auto;
|
||
border-radius: 12px;
|
||
box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04);
|
||
max-height: 600px;
|
||
overflow: hidden;
|
||
`;
|
||
|
||
// Header
|
||
const modalHeader = document.createElement('div');
|
||
modalHeader.style.cssText = `
|
||
padding: 20px 24px 0 24px;
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
`;
|
||
|
||
const modalTitle = document.createElement('h2');
|
||
modalTitle.textContent = 'Nostr Login';
|
||
modalTitle.style.cssText = `
|
||
margin: 0;
|
||
font-size: 24px;
|
||
font-weight: 600;
|
||
color: #1f2937;
|
||
`;
|
||
|
||
const closeButton = document.createElement('button');
|
||
closeButton.innerHTML = '×';
|
||
closeButton.onclick = () => this.close();
|
||
closeButton.style.cssText = `
|
||
background: none;
|
||
border: none;
|
||
font-size: 28px;
|
||
color: #6b7280;
|
||
cursor: pointer;
|
||
padding: 0;
|
||
width: 32px;
|
||
height: 32px;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
border-radius: 6px;
|
||
`;
|
||
closeButton.onmouseover = () => closeButton.style.background = '#f3f4f6';
|
||
closeButton.onmouseout = () => closeButton.style.background = 'none';
|
||
|
||
modalHeader.appendChild(modalTitle);
|
||
modalHeader.appendChild(closeButton);
|
||
|
||
// Body
|
||
this.modalBody = document.createElement('div');
|
||
this.modalBody.style.cssText = `
|
||
padding: 24px;
|
||
overflow-y: auto;
|
||
max-height: 500px;
|
||
`;
|
||
|
||
modalContent.appendChild(modalHeader);
|
||
modalContent.appendChild(this.modalBody);
|
||
this.container.appendChild(modalContent);
|
||
|
||
// Add to body
|
||
document.body.appendChild(this.container);
|
||
|
||
// Click outside to close
|
||
this.container.onclick = (e) => {
|
||
if (e.target === this.container) {
|
||
this.close();
|
||
}
|
||
};
|
||
|
||
// Update theme
|
||
this.updateTheme();
|
||
}
|
||
|
||
updateTheme() {
|
||
const isDark = this.options?.darkMode;
|
||
const modalContent = this.container.querySelector(':nth-child(1)');
|
||
const title = this.container.querySelector('h2');
|
||
|
||
if (isDark) {
|
||
modalContent.style.background = '#1f2937';
|
||
title.style.color = 'white';
|
||
} else {
|
||
modalContent.style.background = 'white';
|
||
title.style.color = '#1f2937';
|
||
}
|
||
}
|
||
|
||
open(opts = {}) {
|
||
this.currentScreen = opts.startScreen;
|
||
this.isVisible = true;
|
||
this.container.style.display = 'block';
|
||
|
||
// Render login options
|
||
this._renderLoginOptions();
|
||
}
|
||
|
||
close() {
|
||
this.isVisible = false;
|
||
this.container.style.display = 'none';
|
||
this.modalBody.innerHTML = '';
|
||
}
|
||
|
||
_renderLoginOptions() {
|
||
this.modalBody.innerHTML = '';
|
||
|
||
const options = [];
|
||
|
||
// Extension option
|
||
if (this.options?.methods?.extension !== false) {
|
||
options.push({
|
||
type: 'extension',
|
||
title: 'Browser Extension',
|
||
description: 'Use your browser extension',
|
||
icon: '🔌'
|
||
});
|
||
}
|
||
|
||
// Local key option
|
||
if (this.options?.methods?.local !== false) {
|
||
options.push({
|
||
type: 'local',
|
||
title: 'Local Key',
|
||
description: 'Create or import your own key',
|
||
icon: '🔑'
|
||
});
|
||
}
|
||
|
||
// Nostr Connect option (check both 'connect' and 'remote' for compatibility)
|
||
if (this.options?.methods?.connect !== false && this.options?.methods?.remote !== false) {
|
||
options.push({
|
||
type: 'connect',
|
||
title: 'Nostr Connect',
|
||
description: 'Connect with external signer',
|
||
icon: '🌐'
|
||
});
|
||
}
|
||
|
||
// Read-only option
|
||
if (this.options?.methods?.readonly !== false) {
|
||
options.push({
|
||
type: 'readonly',
|
||
title: 'Read Only',
|
||
description: 'Browse without signing',
|
||
icon: '👁️'
|
||
});
|
||
}
|
||
|
||
// OTP/DM option
|
||
if (this.options?.methods?.otp !== false) {
|
||
options.push({
|
||
type: 'otp',
|
||
title: 'DM/OTP',
|
||
description: 'Receive OTP via DM',
|
||
icon: '📱'
|
||
});
|
||
}
|
||
|
||
// Render each option
|
||
options.forEach(option => {
|
||
const button = document.createElement('button');
|
||
button.onclick = () => this._handleOptionClick(option.type);
|
||
button.style.cssText = `
|
||
display: flex;
|
||
align-items: center;
|
||
width: 100%;
|
||
padding: 16px;
|
||
margin-bottom: 12px;
|
||
background: ${this.options?.darkMode ? '#374151' : 'white'};
|
||
border: 1px solid ${this.options?.darkMode ? '#4b5563' : '#d1d5db'};
|
||
border-radius: 8px;
|
||
cursor: pointer;
|
||
transition: all 0.2s;
|
||
`;
|
||
button.onmouseover = () => {
|
||
button.style.boxShadow = '0 4px 6px -1px rgba(0, 0, 0, 0.1)';
|
||
};
|
||
button.onmouseout = () => {
|
||
button.style.boxShadow = 'none';
|
||
};
|
||
|
||
const iconDiv = document.createElement('div');
|
||
iconDiv.textContent = option.icon;
|
||
iconDiv.style.cssText = `
|
||
font-size: 24px;
|
||
margin-right: 16px;
|
||
width: 24px;
|
||
text-align: center;
|
||
`;
|
||
|
||
const contentDiv = document.createElement('div');
|
||
contentDiv.style.cssText = 'flex: 1; text-align: left;';
|
||
|
||
const titleDiv = document.createElement('div');
|
||
titleDiv.textContent = option.title;
|
||
titleDiv.style.cssText = `
|
||
font-weight: 600;
|
||
margin-bottom: 4px;
|
||
color: ${this.options?.darkMode ? 'white' : '#1f2937'};
|
||
`;
|
||
|
||
const descDiv = document.createElement('div');
|
||
descDiv.textContent = option.description;
|
||
descDiv.style.cssText = `
|
||
font-size: 14px;
|
||
color: ${this.options?.darkMode ? '#9ca3af' : '#6b7280'};
|
||
`;
|
||
|
||
contentDiv.appendChild(titleDiv);
|
||
contentDiv.appendChild(descDiv);
|
||
|
||
button.appendChild(iconDiv);
|
||
button.appendChild(contentDiv);
|
||
this.modalBody.appendChild(button);
|
||
});
|
||
}
|
||
|
||
_handleOptionClick(type) {
|
||
console.log('Selected login type:', type);
|
||
|
||
// Handle different login types
|
||
switch (type) {
|
||
case 'extension':
|
||
this._handleExtension();
|
||
break;
|
||
case 'local':
|
||
this._showLocalKeyScreen();
|
||
break;
|
||
case 'connect':
|
||
this._showConnectScreen();
|
||
break;
|
||
case 'readonly':
|
||
this._handleReadonly();
|
||
break;
|
||
case 'otp':
|
||
this._showOtpScreen();
|
||
break;
|
||
}
|
||
}
|
||
|
||
_handleExtension() {
|
||
// Detect all available real extensions
|
||
const availableExtensions = this._detectAllExtensions();
|
||
|
||
console.log(`Modal: Found ${availableExtensions.length} extensions:`, availableExtensions.map(e => e.displayName));
|
||
|
||
if (availableExtensions.length === 0) {
|
||
console.log('Modal: No real extensions found');
|
||
this._showExtensionRequired();
|
||
} else if (availableExtensions.length === 1) {
|
||
// Single extension - use it directly without showing choice UI
|
||
console.log('Modal: Single extension detected, using it directly:', availableExtensions[0].displayName);
|
||
this._tryExtensionLogin(availableExtensions[0].extension);
|
||
} else {
|
||
// Multiple extensions - show choice UI
|
||
console.log('Modal: Multiple extensions detected, showing choice UI for', availableExtensions.length, 'extensions');
|
||
this._showExtensionChoice(availableExtensions);
|
||
}
|
||
}
|
||
|
||
_detectAllExtensions() {
|
||
const extensions = [];
|
||
const seenExtensions = new Set(); // Track extensions by object reference to avoid duplicates
|
||
|
||
// Extension locations to check (in priority order)
|
||
const locations = [
|
||
{ path: 'window.navigator?.nostr', name: 'navigator.nostr', displayName: 'Standard Extension (navigator.nostr)', icon: '🌐', getter: () => window.navigator?.nostr },
|
||
{ path: 'window.webln?.nostr', name: 'webln.nostr', displayName: 'Alby WebLN Extension', icon: '⚡', getter: () => window.webln?.nostr },
|
||
{ path: 'window.alby?.nostr', name: 'alby.nostr', displayName: 'Alby Extension (Direct)', icon: '🐝', getter: () => window.alby?.nostr },
|
||
{ path: 'window.nos2x', name: 'nos2x', displayName: 'nos2x Extension', icon: '🔌', getter: () => window.nos2x },
|
||
{ path: 'window.flamingo?.nostr', name: 'flamingo.nostr', displayName: 'Flamingo Extension', icon: '🦩', getter: () => window.flamingo?.nostr },
|
||
{ path: 'window.mutiny?.nostr', name: 'mutiny.nostr', displayName: 'Mutiny Extension', icon: '⚔️', getter: () => window.mutiny?.nostr },
|
||
{ path: 'window.nostrich?.nostr', name: 'nostrich.nostr', displayName: 'Nostrich Extension', icon: '🐦', getter: () => window.nostrich?.nostr },
|
||
{ path: 'window.getAlby?.nostr', name: 'getAlby.nostr', displayName: 'getAlby Extension', icon: '🔧', getter: () => window.getAlby?.nostr }
|
||
];
|
||
|
||
// Check each location
|
||
for (const location of locations) {
|
||
try {
|
||
const obj = location.getter();
|
||
|
||
console.log(`Modal: Checking ${location.name}:`, !!obj, obj?.constructor?.name);
|
||
|
||
if (obj && this._isRealExtension(obj) && !seenExtensions.has(obj)) {
|
||
extensions.push({
|
||
name: location.name,
|
||
displayName: location.displayName,
|
||
icon: location.icon,
|
||
extension: obj
|
||
});
|
||
seenExtensions.add(obj);
|
||
console.log(`Modal: ✓ Detected extension at ${location.name} (${obj.constructor?.name})`);
|
||
} else if (obj) {
|
||
console.log(`Modal: ✗ Filtered out ${location.name} (${obj.constructor?.name})`);
|
||
}
|
||
} catch (e) {
|
||
// Location doesn't exist or can't be accessed
|
||
console.log(`Modal: ${location.name} not accessible:`, e.message);
|
||
}
|
||
}
|
||
|
||
// Also check window.nostr but be extra careful to avoid our library
|
||
console.log('Modal: Checking window.nostr:', !!window.nostr, window.nostr?.constructor?.name);
|
||
if (window.nostr && this._isRealExtension(window.nostr) && !seenExtensions.has(window.nostr)) {
|
||
extensions.push({
|
||
name: 'window.nostr',
|
||
displayName: 'Extension (window.nostr)',
|
||
icon: '🔑',
|
||
extension: window.nostr
|
||
});
|
||
seenExtensions.add(window.nostr);
|
||
console.log(`Modal: ✓ Detected extension at window.nostr: ${window.nostr.constructor?.name}`);
|
||
} else if (window.nostr) {
|
||
console.log(`Modal: ✗ Filtered out window.nostr (${window.nostr.constructor?.name}) - likely our library`);
|
||
}
|
||
|
||
return extensions;
|
||
}
|
||
|
||
_isRealExtension(obj) {
|
||
console.log(`Modal: EXTENSIVE DEBUG - _isRealExtension called with:`, obj);
|
||
console.log(`Modal: Object type: ${typeof obj}`);
|
||
console.log(`Modal: Object truthy: ${!!obj}`);
|
||
|
||
if (!obj || typeof obj !== 'object') {
|
||
console.log(`Modal: REJECT - Not an object`);
|
||
return false;
|
||
}
|
||
|
||
console.log(`Modal: getPublicKey type: ${typeof obj.getPublicKey}`);
|
||
console.log(`Modal: signEvent type: ${typeof obj.signEvent}`);
|
||
|
||
// Must have required Nostr methods
|
||
if (typeof obj.getPublicKey !== 'function' || typeof obj.signEvent !== 'function') {
|
||
console.log(`Modal: REJECT - Missing required methods`);
|
||
return false;
|
||
}
|
||
|
||
// Exclude NostrTools library object
|
||
if (obj === window.NostrTools) {
|
||
console.log(`Modal: REJECT - Is NostrTools object`);
|
||
return false;
|
||
}
|
||
|
||
// Use the EXACT SAME logic as the comprehensive test (lines 804-809)
|
||
// This is the key fix - match the comprehensive test's successful detection logic
|
||
const constructorName = obj.constructor?.name;
|
||
const objectKeys = Object.keys(obj);
|
||
|
||
console.log(`Modal: Constructor name: "${constructorName}"`);
|
||
console.log(`Modal: Object keys: [${objectKeys.join(', ')}]`);
|
||
|
||
// COMPREHENSIVE TEST LOGIC - Accept anything with required methods that's not our specific library classes
|
||
const isRealExtension = (
|
||
typeof obj.getPublicKey === 'function' &&
|
||
typeof obj.signEvent === 'function' &&
|
||
constructorName !== 'WindowNostr' && // Our library class
|
||
constructorName !== 'NostrLite' // Our main class
|
||
);
|
||
|
||
console.log(`Modal: Using comprehensive test logic:`);
|
||
console.log(` Has getPublicKey: ${typeof obj.getPublicKey === 'function'}`);
|
||
console.log(` Has signEvent: ${typeof obj.signEvent === 'function'}`);
|
||
console.log(` Not WindowNostr: ${constructorName !== 'WindowNostr'}`);
|
||
console.log(` Not NostrLite: ${constructorName !== 'NostrLite'}`);
|
||
console.log(` Constructor: "${constructorName}"`);
|
||
|
||
// Additional debugging for comparison
|
||
const extensionPropChecks = {
|
||
_isEnabled: !!obj._isEnabled,
|
||
enabled: !!obj.enabled,
|
||
kind: !!obj.kind,
|
||
_eventEmitter: !!obj._eventEmitter,
|
||
_scope: !!obj._scope,
|
||
_requests: !!obj._requests,
|
||
_pubkey: !!obj._pubkey,
|
||
name: !!obj.name,
|
||
version: !!obj.version,
|
||
description: !!obj.description
|
||
};
|
||
|
||
console.log(`Modal: Extension property analysis:`, extensionPropChecks);
|
||
|
||
const hasExtensionProps = !!(
|
||
obj._isEnabled || obj.enabled || obj.kind ||
|
||
obj._eventEmitter || obj._scope || obj._requests || obj._pubkey ||
|
||
obj.name || obj.version || obj.description
|
||
);
|
||
|
||
const underscoreKeys = objectKeys.filter(key => key.startsWith('_'));
|
||
const hexToUint8Keys = objectKeys.filter(key => key.startsWith('_hex'));
|
||
console.log(`Modal: Underscore keys: [${underscoreKeys.join(', ')}]`);
|
||
console.log(`Modal: _hex* keys: [${hexToUint8Keys.join(', ')}]`);
|
||
|
||
console.log(`Modal: Additional analysis:`);
|
||
console.log(` hasExtensionProps: ${hasExtensionProps}`);
|
||
console.log(` hasLibraryMethod (_hexToUint8Array): ${objectKeys.includes('_hexToUint8Array')}`);
|
||
|
||
console.log(`Modal: COMPREHENSIVE TEST LOGIC RESULT: ${isRealExtension ? 'ACCEPT' : 'REJECT'}`);
|
||
console.log(`Modal: FINAL DECISION for ${constructorName}: ${isRealExtension ? 'ACCEPT' : 'REJECT'}`);
|
||
|
||
return isRealExtension;
|
||
}
|
||
|
||
_showExtensionChoice(extensions) {
|
||
this.modalBody.innerHTML = '';
|
||
|
||
const title = document.createElement('h3');
|
||
title.textContent = 'Choose Browser Extension';
|
||
title.style.cssText = 'margin: 0 0 16px 0; font-size: 18px; font-weight: 600;';
|
||
|
||
const description = document.createElement('p');
|
||
description.textContent = `Found ${extensions.length} Nostr extensions. Choose which one to use:`;
|
||
description.style.cssText = 'margin-bottom: 20px; color: #6b7280; font-size: 14px;';
|
||
|
||
this.modalBody.appendChild(title);
|
||
this.modalBody.appendChild(description);
|
||
|
||
// Create button for each extension
|
||
extensions.forEach((ext, index) => {
|
||
const button = document.createElement('button');
|
||
button.onclick = () => this._tryExtensionLogin(ext.extension);
|
||
button.style.cssText = `
|
||
display: flex;
|
||
align-items: center;
|
||
width: 100%;
|
||
padding: 16px;
|
||
margin-bottom: 12px;
|
||
background: ${this.options?.darkMode ? '#374151' : 'white'};
|
||
border: 1px solid ${this.options?.darkMode ? '#4b5563' : '#d1d5db'};
|
||
border-radius: 8px;
|
||
cursor: pointer;
|
||
transition: all 0.2s;
|
||
text-align: left;
|
||
`;
|
||
|
||
button.onmouseover = () => {
|
||
button.style.boxShadow = '0 4px 6px -1px rgba(0, 0, 0, 0.1)';
|
||
button.style.transform = 'translateY(-1px)';
|
||
};
|
||
button.onmouseout = () => {
|
||
button.style.boxShadow = 'none';
|
||
button.style.transform = 'none';
|
||
};
|
||
|
||
const iconDiv = document.createElement('div');
|
||
iconDiv.textContent = ext.icon;
|
||
iconDiv.style.cssText = `
|
||
font-size: 24px;
|
||
margin-right: 16px;
|
||
width: 24px;
|
||
text-align: center;
|
||
`;
|
||
|
||
const contentDiv = document.createElement('div');
|
||
contentDiv.style.cssText = 'flex: 1;';
|
||
|
||
const nameDiv = document.createElement('div');
|
||
nameDiv.textContent = ext.displayName;
|
||
nameDiv.style.cssText = `
|
||
font-weight: 600;
|
||
margin-bottom: 4px;
|
||
color: ${this.options?.darkMode ? 'white' : '#1f2937'};
|
||
`;
|
||
|
||
const pathDiv = document.createElement('div');
|
||
pathDiv.textContent = ext.name;
|
||
pathDiv.style.cssText = `
|
||
font-size: 12px;
|
||
color: ${this.options?.darkMode ? '#9ca3af' : '#6b7280'};
|
||
font-family: monospace;
|
||
`;
|
||
|
||
contentDiv.appendChild(nameDiv);
|
||
contentDiv.appendChild(pathDiv);
|
||
|
||
button.appendChild(iconDiv);
|
||
button.appendChild(contentDiv);
|
||
this.modalBody.appendChild(button);
|
||
});
|
||
|
||
// Add back button
|
||
const backButton = document.createElement('button');
|
||
backButton.textContent = 'Back to Login Options';
|
||
backButton.onclick = () => this._renderLoginOptions();
|
||
backButton.style.cssText = this._getButtonStyle('secondary') + 'margin-top: 20px;';
|
||
|
||
this.modalBody.appendChild(backButton);
|
||
}
|
||
|
||
async _tryExtensionLogin(extensionObj) {
|
||
try {
|
||
// Show loading state
|
||
this.modalBody.innerHTML = '<div style="text-align: center; padding: 20px;">🔄 Connecting to extension...</div>';
|
||
|
||
// Get pubkey from extension
|
||
const pubkey = await extensionObj.getPublicKey();
|
||
console.log('Extension provided pubkey:', pubkey);
|
||
|
||
// Set extension method with the extension object
|
||
this._setAuthMethod('extension', { pubkey, extension: extensionObj });
|
||
|
||
} catch (error) {
|
||
console.error('Extension login failed:', error);
|
||
this._showError(`Extension login failed: ${error.message}`);
|
||
}
|
||
}
|
||
|
||
_showLocalKeyScreen() {
|
||
this.modalBody.innerHTML = '';
|
||
|
||
const title = document.createElement('h3');
|
||
title.textContent = 'Local Key';
|
||
title.style.cssText = 'margin: 0 0 20px 0; font-size: 18px; font-weight: 600;';
|
||
|
||
const createButton = document.createElement('button');
|
||
createButton.textContent = 'Create New Key';
|
||
createButton.onclick = () => this._createLocalKey();
|
||
createButton.style.cssText = this._getButtonStyle();
|
||
|
||
const importButton = document.createElement('button');
|
||
importButton.textContent = 'Import Existing Key';
|
||
importButton.onclick = () => this._showImportKeyForm();
|
||
importButton.style.cssText = this._getButtonStyle() + 'margin-top: 12px;';
|
||
|
||
const backButton = document.createElement('button');
|
||
backButton.textContent = 'Back';
|
||
backButton.onclick = () => this._renderLoginOptions();
|
||
backButton.style.cssText = `
|
||
display: block;
|
||
margin-top: 20px;
|
||
padding: 12px;
|
||
background: #6b7280;
|
||
color: white;
|
||
border: none;
|
||
border-radius: 6px;
|
||
cursor: pointer;
|
||
`;
|
||
|
||
this.modalBody.appendChild(title);
|
||
this.modalBody.appendChild(createButton);
|
||
this.modalBody.appendChild(importButton);
|
||
this.modalBody.appendChild(backButton);
|
||
}
|
||
|
||
_createLocalKey() {
|
||
try {
|
||
const sk = window.NostrTools.generateSecretKey();
|
||
const pk = window.NostrTools.getPublicKey(sk);
|
||
const nsec = window.NostrTools.nip19.nsecEncode(sk);
|
||
const npub = window.NostrTools.nip19.npubEncode(pk);
|
||
|
||
this._showKeyDisplay(pk, nsec, 'created');
|
||
} catch (error) {
|
||
this._showError('Failed to create key: ' + error.message);
|
||
}
|
||
}
|
||
|
||
_showImportKeyForm() {
|
||
this.modalBody.innerHTML = '';
|
||
|
||
const title = document.createElement('h3');
|
||
title.textContent = 'Import Local Key';
|
||
title.style.cssText = 'margin: 0 0 16px 0; font-size: 18px; font-weight: 600;';
|
||
|
||
const description = document.createElement('p');
|
||
description.textContent = 'Enter your secret key in either nsec or hex format:';
|
||
description.style.cssText = 'margin-bottom: 12px; color: #6b7280; font-size: 14px;';
|
||
|
||
const textarea = document.createElement('textarea');
|
||
textarea.placeholder = 'Enter your secret key:\n• nsec1... (bech32 format)\n• 64-character hex string';
|
||
textarea.style.cssText = `
|
||
width: 100%;
|
||
height: 100px;
|
||
padding: 12px;
|
||
border: 1px solid #d1d5db;
|
||
border-radius: 6px;
|
||
margin-bottom: 12px;
|
||
resize: none;
|
||
font-family: monospace;
|
||
font-size: 14px;
|
||
box-sizing: border-box;
|
||
`;
|
||
|
||
// Add real-time format detection
|
||
const formatHint = document.createElement('div');
|
||
formatHint.style.cssText = 'margin-bottom: 16px; font-size: 12px; color: #6b7280; min-height: 16px;';
|
||
|
||
textarea.oninput = () => {
|
||
const value = textarea.value.trim();
|
||
if (!value) {
|
||
formatHint.textContent = '';
|
||
return;
|
||
}
|
||
|
||
const format = this._detectKeyFormat(value);
|
||
if (format === 'nsec') {
|
||
formatHint.textContent = '✅ Valid nsec format detected';
|
||
formatHint.style.color = '#059669';
|
||
} else if (format === 'hex') {
|
||
formatHint.textContent = '✅ Valid hex format detected';
|
||
formatHint.style.color = '#059669';
|
||
} else {
|
||
formatHint.textContent = '❌ Invalid key format - must be nsec1... or 64-character hex';
|
||
formatHint.style.color = '#dc2626';
|
||
}
|
||
};
|
||
|
||
const importButton = document.createElement('button');
|
||
importButton.textContent = 'Import Key';
|
||
importButton.onclick = () => this._importLocalKey(textarea.value);
|
||
importButton.style.cssText = this._getButtonStyle();
|
||
|
||
const backButton = document.createElement('button');
|
||
backButton.textContent = 'Back';
|
||
backButton.onclick = () => this._showLocalKeyScreen();
|
||
backButton.style.cssText = this._getButtonStyle('secondary') + 'margin-top: 12px;';
|
||
|
||
this.modalBody.appendChild(title);
|
||
this.modalBody.appendChild(description);
|
||
this.modalBody.appendChild(textarea);
|
||
this.modalBody.appendChild(formatHint);
|
||
this.modalBody.appendChild(importButton);
|
||
this.modalBody.appendChild(backButton);
|
||
}
|
||
|
||
_detectKeyFormat(keyValue) {
|
||
const trimmed = keyValue.trim();
|
||
|
||
// Check for nsec format
|
||
if (trimmed.startsWith('nsec1') && trimmed.length === 63) {
|
||
try {
|
||
window.NostrTools.nip19.decode(trimmed);
|
||
return 'nsec';
|
||
} catch {
|
||
return 'invalid';
|
||
}
|
||
}
|
||
|
||
// Check for hex format (64 characters, valid hex)
|
||
if (trimmed.length === 64 && /^[a-fA-F0-9]{64}$/.test(trimmed)) {
|
||
return 'hex';
|
||
}
|
||
|
||
return 'invalid';
|
||
}
|
||
|
||
_importLocalKey(keyValue) {
|
||
try {
|
||
const trimmed = keyValue.trim();
|
||
if (!trimmed) {
|
||
throw new Error('Please enter a secret key');
|
||
}
|
||
|
||
const format = this._detectKeyFormat(trimmed);
|
||
let sk;
|
||
|
||
if (format === 'nsec') {
|
||
// Decode nsec format - this returns Uint8Array
|
||
const decoded = window.NostrTools.nip19.decode(trimmed);
|
||
if (decoded.type !== 'nsec') {
|
||
throw new Error('Invalid nsec format');
|
||
}
|
||
sk = decoded.data; // This is already Uint8Array
|
||
} else if (format === 'hex') {
|
||
// Convert hex string to Uint8Array
|
||
sk = this._hexToUint8Array(trimmed);
|
||
// Test that it's a valid secret key by trying to get public key
|
||
window.NostrTools.getPublicKey(sk);
|
||
} else {
|
||
throw new Error('Invalid key format. Please enter either nsec1... or 64-character hex string');
|
||
}
|
||
|
||
// Generate public key and encoded formats
|
||
const pk = window.NostrTools.getPublicKey(sk);
|
||
const nsec = window.NostrTools.nip19.nsecEncode(sk);
|
||
const npub = window.NostrTools.nip19.npubEncode(pk);
|
||
|
||
this._showKeyDisplay(pk, nsec, 'imported');
|
||
} catch (error) {
|
||
this._showError('Invalid key: ' + error.message);
|
||
}
|
||
}
|
||
|
||
_hexToUint8Array(hex) {
|
||
// Convert hex string to Uint8Array
|
||
if (hex.length % 2 !== 0) {
|
||
throw new Error('Invalid hex string length');
|
||
}
|
||
const bytes = new Uint8Array(hex.length / 2);
|
||
for (let i = 0; i < bytes.length; i++) {
|
||
bytes[i] = parseInt(hex.substr(i * 2, 2), 16);
|
||
}
|
||
return bytes;
|
||
}
|
||
|
||
_showKeyDisplay(pubkey, nsec, action) {
|
||
this.modalBody.innerHTML = '';
|
||
|
||
const title = document.createElement('h3');
|
||
title.textContent = `Key ${action} successfully!`;
|
||
title.style.cssText = 'margin: 0 0 16px 0; font-size: 18px; font-weight: 600; color: #059669;';
|
||
|
||
const warningDiv = document.createElement('div');
|
||
warningDiv.textContent = '⚠️ Save your secret key securely!';
|
||
warningDiv.style.cssText = 'background: #fef3c7; color: #92400e; padding: 12px; border-radius: 6px; margin-bottom: 16px; font-size: 14px;';
|
||
|
||
const nsecDiv = document.createElement('div');
|
||
nsecDiv.innerHTML = `<strong>Your Secret Key:</strong><br><code style="word-break: break-all; background: #f3f4f6; padding: 8px; border-radius: 4px;">${nsec}</code>`;
|
||
nsecDiv.style.cssText = 'margin-bottom: 16px; font-size: 14px;';
|
||
|
||
const npubDiv = document.createElement('div');
|
||
npubDiv.innerHTML = `<strong>Your Public Key:</strong><br><code style="word-break: break-all; background: #f3f4f6; padding: 8px; border-radius: 4px;">${window.NostrTools.nip19.npubEncode(pubkey)}</code>`;
|
||
npubDiv.style.cssText = 'margin-bottom: 16px; font-size: 14px;';
|
||
|
||
const continueButton = document.createElement('button');
|
||
continueButton.textContent = 'Continue';
|
||
continueButton.onclick = () => this._setAuthMethod('local', { secret: nsec, pubkey });
|
||
continueButton.style.cssText = this._getButtonStyle();
|
||
|
||
this.modalBody.appendChild(title);
|
||
this.modalBody.appendChild(warningDiv);
|
||
this.modalBody.appendChild(nsecDiv);
|
||
this.modalBody.appendChild(npubDiv);
|
||
this.modalBody.appendChild(continueButton);
|
||
}
|
||
|
||
_setAuthMethod(method, options = {}) {
|
||
// Emit auth method selection
|
||
const event = new CustomEvent('nlMethodSelected', {
|
||
detail: { method, ...options }
|
||
});
|
||
window.dispatchEvent(event);
|
||
|
||
this.close();
|
||
}
|
||
|
||
_showError(message) {
|
||
this.modalBody.innerHTML = '';
|
||
|
||
const errorDiv = document.createElement('div');
|
||
errorDiv.style.cssText = 'background: #fee2e2; color: #dc2626; padding: 16px; border-radius: 6px; margin-bottom: 16px;';
|
||
errorDiv.innerHTML = `<strong>Error:</strong> ${message}`;
|
||
|
||
const backButton = document.createElement('button');
|
||
backButton.textContent = 'Back';
|
||
backButton.onclick = () => this._renderLoginOptions();
|
||
backButton.style.cssText = this._getButtonStyle('secondary');
|
||
|
||
this.modalBody.appendChild(errorDiv);
|
||
this.modalBody.appendChild(backButton);
|
||
}
|
||
|
||
_showExtensionRequired() {
|
||
this.modalBody.innerHTML = '';
|
||
|
||
const title = document.createElement('h3');
|
||
title.textContent = 'Browser Extension Required';
|
||
title.style.cssText = 'margin: 0 0 16px 0; font-size: 18px; font-weight: 600;';
|
||
|
||
const message = document.createElement('p');
|
||
message.textContent = 'Please install a Nostr browser extension like Alby or getflattr and refresh the page.';
|
||
message.style.cssText = 'margin-bottom: 20px; color: #6b7280;';
|
||
|
||
const backButton = document.createElement('button');
|
||
backButton.textContent = 'Back';
|
||
backButton.onclick = () => this._renderLoginOptions();
|
||
backButton.style.cssText = this._getButtonStyle('secondary');
|
||
|
||
this.modalBody.appendChild(title);
|
||
this.modalBody.appendChild(message);
|
||
this.modalBody.appendChild(backButton);
|
||
}
|
||
|
||
_showConnectScreen() {
|
||
this.modalBody.innerHTML = '';
|
||
|
||
const title = document.createElement('h3');
|
||
title.textContent = 'Nostr Connect';
|
||
title.style.cssText = 'margin: 0 0 20px 0; font-size: 18px; font-weight: 600;';
|
||
|
||
const formGroup = document.createElement('div');
|
||
formGroup.style.cssText = 'margin-bottom: 20px;';
|
||
|
||
const label = document.createElement('label');
|
||
label.textContent = 'Connection String:';
|
||
label.style.cssText = 'display: block; margin-bottom: 8px; font-weight: 500;';
|
||
|
||
const pubkeyInput = document.createElement('input');
|
||
pubkeyInput.type = 'text';
|
||
pubkeyInput.placeholder = 'bunker://...';
|
||
pubkeyInput.style.cssText = `
|
||
width: 100%;
|
||
padding: 12px;
|
||
border: 1px solid #d1d5db;
|
||
border-radius: 6px;
|
||
margin-bottom: 16px;
|
||
font-family: monospace;
|
||
box-sizing: border-box;
|
||
`;
|
||
|
||
const connectButton = document.createElement('button');
|
||
connectButton.textContent = 'Connect';
|
||
connectButton.onclick = () => this._handleNip46Connect(pubkeyInput.value, null);
|
||
connectButton.style.cssText = this._getButtonStyle();
|
||
|
||
const backButton = document.createElement('button');
|
||
backButton.textContent = 'Back';
|
||
backButton.onclick = () => this._renderLoginOptions();
|
||
backButton.style.cssText = this._getButtonStyle('secondary') + 'margin-top: 12px;';
|
||
|
||
formGroup.appendChild(label);
|
||
formGroup.appendChild(pubkeyInput);
|
||
|
||
this.modalBody.appendChild(title);
|
||
this.modalBody.appendChild(formGroup);
|
||
this.modalBody.appendChild(connectButton);
|
||
this.modalBody.appendChild(backButton);
|
||
}
|
||
|
||
_handleNip46Connect(bunkerPubkey, bunkerUrl) {
|
||
if (!bunkerPubkey || !bunkerPubkey.length) {
|
||
this._showError('Bunker connection string is required');
|
||
return;
|
||
}
|
||
|
||
this._showNip46Connecting(bunkerPubkey);
|
||
this._performNip46Connect(bunkerPubkey, null);
|
||
}
|
||
|
||
_showNip46Connecting(bunkerPubkey) {
|
||
this.modalBody.innerHTML = '';
|
||
|
||
const title = document.createElement('h3');
|
||
title.textContent = 'Connecting to Remote Signer...';
|
||
title.style.cssText = 'margin: 0 0 16px 0; font-size: 18px; font-weight: 600; color: #059669;';
|
||
|
||
const description = document.createElement('p');
|
||
description.textContent = 'Establishing secure connection to your remote signer via Nostr relays.';
|
||
description.style.cssText = 'margin-bottom: 20px; color: #6b7280;';
|
||
|
||
// Show the connection string being used
|
||
const connectionString = bunkerPubkey.length > 60 ?
|
||
`${bunkerPubkey.substring(0, 30)}...${bunkerPubkey.substring(bunkerPubkey.length - 30)}` :
|
||
bunkerPubkey;
|
||
|
||
const bunkerInfo = document.createElement('div');
|
||
bunkerInfo.style.cssText = 'background: #f1f5f9; padding: 12px; border-radius: 6px; margin-bottom: 20px; font-size: 14px;';
|
||
bunkerInfo.innerHTML = `
|
||
<strong>Connecting via:</strong><br>
|
||
<code style="word-break: break-all; font-size: 12px;">${connectionString}</code><br>
|
||
<small style="color: #6b7280;">Using NIP-46 protocol over Nostr relays for secure communication.</small>
|
||
`;
|
||
|
||
const connectingDiv = document.createElement('div');
|
||
connectingDiv.style.cssText = 'text-align: center; color: #6b7280;';
|
||
connectingDiv.innerHTML = `
|
||
<div style="font-size: 24px; margin-bottom: 10px;">⏳</div>
|
||
<div>Please wait while we establish the connection...</div>
|
||
<div style="font-size: 12px; margin-top: 10px;">This may take a few seconds</div>
|
||
`;
|
||
|
||
this.modalBody.appendChild(title);
|
||
this.modalBody.appendChild(description);
|
||
this.modalBody.appendChild(bunkerInfo);
|
||
this.modalBody.appendChild(connectingDiv);
|
||
}
|
||
|
||
async _performNip46Connect(bunkerPubkey, bunkerUrl) {
|
||
try {
|
||
console.log('Starting NIP-46 connection to bunker:', bunkerPubkey, bunkerUrl);
|
||
|
||
// Check if nostr-tools NIP-46 is available
|
||
if (!window.NostrTools?.nip46) {
|
||
throw new Error('nostr-tools NIP-46 module not available');
|
||
}
|
||
|
||
// Use nostr-tools to parse bunker input - this handles all formats correctly
|
||
console.log('Parsing bunker input with nostr-tools...');
|
||
const bunkerPointer = await window.NostrTools.nip46.parseBunkerInput(bunkerPubkey);
|
||
|
||
if (!bunkerPointer) {
|
||
throw new Error('Unable to parse bunker connection string or resolve NIP-05 identifier');
|
||
}
|
||
|
||
console.log('Parsed bunker pointer:', bunkerPointer);
|
||
|
||
// Create local client keypair for this session
|
||
const localSecretKey = window.NostrTools.generateSecretKey();
|
||
console.log('Generated local client keypair for NIP-46 session');
|
||
|
||
// Use nostr-tools BunkerSigner constructor
|
||
console.log('Creating nip46 BunkerSigner...');
|
||
const signer = new window.NostrTools.nip46.BunkerSigner(localSecretKey, bunkerPointer, {
|
||
onauth: (url) => {
|
||
console.log('Received auth URL from bunker:', url);
|
||
// Open auth URL in popup or redirect
|
||
window.open(url, '_blank', 'width=600,height=800');
|
||
}
|
||
});
|
||
|
||
console.log('NIP-46 BunkerSigner created successfully');
|
||
|
||
// Skip ping test - NIP-46 works through relays, not direct connection
|
||
// Try to connect directly (this may trigger auth flow)
|
||
console.log('Attempting NIP-46 connect...');
|
||
await signer.connect();
|
||
console.log('NIP-46 connect successful');
|
||
|
||
// Get the user's public key from the bunker
|
||
console.log('Getting public key from bunker...');
|
||
const userPubkey = await signer.getPublicKey();
|
||
console.log('NIP-46 user public key:', userPubkey);
|
||
|
||
// Store the NIP-46 authentication info
|
||
const nip46Info = {
|
||
pubkey: userPubkey,
|
||
signer: {
|
||
method: 'nip46',
|
||
remotePubkey: bunkerPointer.pubkey,
|
||
bunkerSigner: signer,
|
||
secret: bunkerPointer.secret,
|
||
relays: bunkerPointer.relays
|
||
}
|
||
};
|
||
|
||
console.log('NOSTR_LOGIN_LITE NIP-46 connection established successfully!');
|
||
|
||
// Set as current auth method
|
||
this._setAuthMethod('nip46', nip46Info);
|
||
return;
|
||
|
||
} catch (error) {
|
||
console.error('NIP-46 connection failed:', error);
|
||
this._showNip46Error(error.message);
|
||
}
|
||
}
|
||
|
||
_showNip46Error(message) {
|
||
this.modalBody.innerHTML = '';
|
||
|
||
const title = document.createElement('h3');
|
||
title.textContent = 'Connection Failed';
|
||
title.style.cssText = 'margin: 0 0 16px 0; font-size: 18px; font-weight: 600; color: #dc2626;';
|
||
|
||
const errorMsg = document.createElement('p');
|
||
errorMsg.textContent = `Unable to connect to remote signer: ${message}`;
|
||
errorMsg.style.cssText = 'margin-bottom: 20px; color: #6b7280;';
|
||
|
||
const retryButton = document.createElement('button');
|
||
retryButton.textContent = 'Try Again';
|
||
retryButton.onclick = () => this._showConnectScreen();
|
||
retryButton.style.cssText = this._getButtonStyle();
|
||
|
||
const backButton = document.createElement('button');
|
||
backButton.textContent = 'Back to Options';
|
||
backButton.onclick = () => this._renderLoginOptions();
|
||
backButton.style.cssText = this._getButtonStyle('secondary') + 'margin-top: 12px;';
|
||
|
||
this.modalBody.appendChild(title);
|
||
this.modalBody.appendChild(errorMsg);
|
||
this.modalBody.appendChild(retryButton);
|
||
this.modalBody.appendChild(backButton);
|
||
}
|
||
|
||
_handleReadonly() {
|
||
// Set read-only mode
|
||
this._setAuthMethod('readonly');
|
||
}
|
||
|
||
_showOtpScreen() {
|
||
this.modalBody.innerHTML = '';
|
||
|
||
const title = document.createElement('h3');
|
||
title.textContent = 'DM/OTP Login';
|
||
title.style.cssText = 'margin: 0 0 16px 0; font-size: 18px; font-weight: 600;';
|
||
|
||
const description = document.createElement('p');
|
||
description.textContent = 'Enter your public key to receive a login code via direct message.';
|
||
description.style.cssText = 'margin-bottom: 12px; color: #6b7280; font-size: 14px;';
|
||
|
||
const pubkeyInput = document.createElement('input');
|
||
pubkeyInput.type = 'text';
|
||
pubkeyInput.placeholder = 'Enter your public key:\n• npub1... (bech32 format)\n• 64-character hex string';
|
||
pubkeyInput.style.cssText = `
|
||
width: 100%;
|
||
padding: 12px;
|
||
border: 1px solid #d1d5db;
|
||
border-radius: 6px;
|
||
margin-bottom: 12px;
|
||
font-family: monospace;
|
||
font-size: 14px;
|
||
box-sizing: border-box;
|
||
`;
|
||
|
||
// Add real-time format detection (reusing logic from local key)
|
||
const formatHint = document.createElement('div');
|
||
formatHint.style.cssText = 'margin-bottom: 16px; font-size: 12px; color: #6b7280; min-height: 16px;';
|
||
|
||
pubkeyInput.oninput = () => {
|
||
const value = pubkeyInput.value.trim();
|
||
if (!value) {
|
||
formatHint.textContent = '';
|
||
return;
|
||
}
|
||
|
||
const format = this._detectPubkeyFormat(value);
|
||
if (format === 'npub') {
|
||
formatHint.textContent = '✅ Valid npub format detected';
|
||
formatHint.style.color = '#059669';
|
||
} else if (format === 'hex') {
|
||
formatHint.textContent = '✅ Valid hex format detected';
|
||
formatHint.style.color = '#059669';
|
||
} else {
|
||
formatHint.textContent = '❌ Invalid public key format - must be npub1... or 64-character hex';
|
||
formatHint.style.color = '#dc2626';
|
||
}
|
||
};
|
||
|
||
const sendButton = document.createElement('button');
|
||
sendButton.textContent = 'Send Login Code';
|
||
sendButton.onclick = () => this._handleOtpRequest(pubkeyInput.value);
|
||
sendButton.style.cssText = this._getButtonStyle();
|
||
|
||
const backButton = document.createElement('button');
|
||
backButton.textContent = 'Back';
|
||
backButton.onclick = () => this._renderLoginOptions();
|
||
backButton.style.cssText = this._getButtonStyle('secondary') + 'margin-top: 12px;';
|
||
|
||
this.modalBody.appendChild(title);
|
||
this.modalBody.appendChild(description);
|
||
this.modalBody.appendChild(pubkeyInput);
|
||
this.modalBody.appendChild(formatHint);
|
||
this.modalBody.appendChild(sendButton);
|
||
this.modalBody.appendChild(backButton);
|
||
}
|
||
|
||
_detectPubkeyFormat(keyValue) {
|
||
const trimmed = keyValue.trim();
|
||
|
||
// Check for npub format
|
||
if (trimmed.startsWith('npub1') && trimmed.length === 63) {
|
||
try {
|
||
const decoded = window.NostrTools.nip19.decode(trimmed);
|
||
if (decoded.type === 'npub') {
|
||
return 'npub';
|
||
}
|
||
} catch {
|
||
return 'invalid';
|
||
}
|
||
}
|
||
|
||
// Check for hex format (64 characters, valid hex)
|
||
if (trimmed.length === 64 && /^[a-fA-F0-9]{64}$/.test(trimmed)) {
|
||
return 'hex';
|
||
}
|
||
|
||
return 'invalid';
|
||
}
|
||
|
||
_handleOtpRequest(pubkeyValue) {
|
||
try {
|
||
const trimmed = pubkeyValue.trim();
|
||
if (!trimmed) {
|
||
throw new Error('Please enter a public key');
|
||
}
|
||
|
||
const format = this._detectPubkeyFormat(trimmed);
|
||
let pubkey, displayKey;
|
||
|
||
if (format === 'npub') {
|
||
// Decode npub format
|
||
const decoded = window.NostrTools.nip19.decode(trimmed);
|
||
if (decoded.type !== 'npub') {
|
||
throw new Error('Invalid npub format');
|
||
}
|
||
pubkey = decoded.data; // This is the hex pubkey
|
||
displayKey = trimmed; // Keep the original npub for display
|
||
} else if (format === 'hex') {
|
||
// Use hex directly as pubkey
|
||
pubkey = trimmed;
|
||
// Generate npub for display
|
||
displayKey = window.NostrTools.nip19.npubEncode(pubkey);
|
||
} else {
|
||
throw new Error('Invalid public key format. Please enter either npub1... or 64-character hex string');
|
||
}
|
||
|
||
this._showOtpCodeScreen(pubkey, displayKey);
|
||
} catch (error) {
|
||
this._showError('Invalid public key: ' + error.message);
|
||
}
|
||
}
|
||
|
||
_showOtpCodeScreen(pubkey, npub) {
|
||
this.modalBody.innerHTML = '';
|
||
|
||
const title = document.createElement('h3');
|
||
title.textContent = 'Enter Login Code';
|
||
title.style.cssText = 'margin: 0 0 16px 0; font-size: 18px; font-weight: 600;';
|
||
|
||
const description = document.createElement('p');
|
||
description.innerHTML = `Check your DMs for a login code sent to:<br><code style="word-break: break-all;">${npub}</code>`;
|
||
description.style.cssText = 'margin-bottom: 20px; color: #6b7280; font-size: 14px;';
|
||
|
||
const formGroup = document.createElement('div');
|
||
formGroup.style.cssText = 'margin-bottom: 20px;';
|
||
|
||
const label = document.createElement('label');
|
||
label.textContent = 'Login Code:';
|
||
label.style.cssText = 'display: block; margin-bottom: 8px; font-weight: 500;';
|
||
|
||
const codeInput = document.createElement('input');
|
||
codeInput.type = 'text';
|
||
codeInput.placeholder = 'Enter 6-digit code';
|
||
codeInput.maxLength = 6;
|
||
codeInput.style.cssText = `
|
||
width: 100%;
|
||
padding: 12px;
|
||
border: 1px solid #d1d5db;
|
||
border-radius: 6px;
|
||
margin-bottom: 16px;
|
||
font-family: monospace;
|
||
box-sizing: border-box;
|
||
text-align: center;
|
||
font-size: 18px;
|
||
letter-spacing: 2px;
|
||
`;
|
||
|
||
const verifyButton = document.createElement('button');
|
||
verifyButton.textContent = 'Verify Code';
|
||
verifyButton.onclick = () => this._handleOtpVerification(pubkey, codeInput.value);
|
||
verifyButton.style.cssText = this._getButtonStyle();
|
||
|
||
const resendButton = document.createElement('button');
|
||
resendButton.textContent = 'Resend Code';
|
||
resendButton.onclick = () => this._handleOtpRequest(npub);
|
||
resendButton.style.cssText = this._getButtonStyle('secondary') + 'margin-top: 12px; margin-right: 8px;';
|
||
|
||
const backButton = document.createElement('button');
|
||
backButton.textContent = 'Back';
|
||
backButton.onclick = () => this._showOtpScreen();
|
||
backButton.style.cssText = this._getButtonStyle('secondary') + 'margin-top: 12px;';
|
||
|
||
formGroup.appendChild(label);
|
||
formGroup.appendChild(codeInput);
|
||
|
||
this.modalBody.appendChild(title);
|
||
this.modalBody.appendChild(description);
|
||
this.modalBody.appendChild(formGroup);
|
||
this.modalBody.appendChild(verifyButton);
|
||
|
||
const buttonRow = document.createElement('div');
|
||
buttonRow.style.cssText = 'display: flex; gap: 8px;';
|
||
buttonRow.appendChild(resendButton);
|
||
buttonRow.appendChild(backButton);
|
||
this.modalBody.appendChild(buttonRow);
|
||
|
||
// Simulate sending DM (in a real implementation, this would send a DM)
|
||
this._simulateOtpSend(pubkey);
|
||
}
|
||
|
||
async _simulateOtpSend(pubkey) {
|
||
// Generate a random 6-digit code
|
||
const code = Math.floor(100000 + Math.random() * 900000).toString();
|
||
|
||
// Store the code temporarily (in a real implementation, this would be server-side)
|
||
sessionStorage.setItem(`otp_${pubkey}`, code);
|
||
sessionStorage.setItem(`otp_${pubkey}_timestamp`, Date.now().toString());
|
||
|
||
console.log(`OTP/DM: Generated code ${code} for pubkey ${pubkey}`);
|
||
|
||
// Actually send the DM
|
||
await this._sendOtpDm(pubkey, code);
|
||
}
|
||
|
||
async _sendOtpDm(recipientPubkey, code) {
|
||
try {
|
||
console.log('🚀 OTP/DM: Starting modern NIP-17 DM send process...');
|
||
|
||
// Generate a temporary key pair for sending the DM
|
||
const senderSecretKey = window.NostrTools.generateSecretKey();
|
||
const senderPubkey = window.NostrTools.getPublicKey(senderSecretKey);
|
||
|
||
console.log('🔑 OTP/DM: Generated temporary sender pubkey:', senderPubkey);
|
||
console.log('👤 OTP/DM: Sending DM to recipient pubkey:', recipientPubkey);
|
||
|
||
// Create the DM content
|
||
const dmContent = `Your NOSTR_LOGIN_LITE login code: ${code}\n\nThis code expires in 5 minutes.`;
|
||
|
||
console.log('💬 OTP/DM: Original message:', dmContent);
|
||
|
||
// Check if NIP-17 is available in nostr-tools
|
||
if (!window.NostrTools?.nip17) {
|
||
console.warn('⚠️ OTP/DM: NIP-17 not available in nostr-tools, falling back to NIP-44');
|
||
return await this._sendLegacyNip44Dm(senderSecretKey, recipientPubkey, dmContent, code);
|
||
}
|
||
|
||
// Create recipient object for NIP-17
|
||
const recipient = {
|
||
publicKey: recipientPubkey,
|
||
relayUrl: undefined // Will use default relays
|
||
};
|
||
|
||
console.log('🔄 OTP/DM: Creating NIP-17 gift-wrapped DM using nostr-tools...');
|
||
|
||
// Use nostr-tools NIP-17 to create properly wrapped DM
|
||
const wrappedEvent = window.NostrTools.nip17.wrapEvent(
|
||
senderSecretKey,
|
||
recipient,
|
||
dmContent,
|
||
undefined, // no conversation title
|
||
undefined // no reply
|
||
);
|
||
|
||
console.log('📝 OTP/DM: Created NIP-17 gift wrap (kind 1059):', JSON.stringify(wrappedEvent, null, 2));
|
||
|
||
// Get relays to send to
|
||
const relays = this.options.relays || [
|
||
'wss://relay.damus.io',
|
||
'wss://relay.primal.net',
|
||
'wss://relay.laantungir.net',
|
||
'wss://nostr.mom'
|
||
];
|
||
|
||
console.log('🌐 OTP/DM: Sending NIP-17 DM to relays:', relays);
|
||
|
||
// Create a pool using our enhanced SimplePool from NIP-46 extension
|
||
const pool = new window.NostrTools.nip46.SimplePool();
|
||
|
||
// Send the gift-wrapped event to all relays
|
||
console.log('📡 OTP/DM: === Sending NIP-17 Gift-Wrapped DM ===');
|
||
const publishPromises = relays.map(async (relay, index) => {
|
||
console.log(`📡 OTP/DM: NIP-17 [${index + 1}/${relays.length}] Connecting to relay ${relay}...`);
|
||
|
||
try {
|
||
const results = await pool.publish([relay], wrappedEvent);
|
||
console.log(`📡 OTP/DM: NIP-17 Raw JSON response from ${relay}:`, JSON.stringify(results, null, 2));
|
||
|
||
// Check if the result status is fulfilled (successful)
|
||
const success = results.length > 0 && results[0].status === 'fulfilled';
|
||
if (!success && results.length > 0 && results[0].status === 'rejected') {
|
||
console.warn(`⚠️ OTP/DM: NIP-17 rejected by ${relay}:`, results[0].reason);
|
||
}
|
||
return { relay, success, results, type: 'nip17' };
|
||
} catch (error) {
|
||
console.error(`❌ OTP/DM: NIP-17 Failed to publish to ${relay}:`, error);
|
||
return { relay, success: false, error: error.message, type: 'nip17' };
|
||
}
|
||
});
|
||
|
||
// Wait for all publish attempts
|
||
const results = await Promise.allSettled(publishPromises);
|
||
|
||
const successfulResults = results.filter(result =>
|
||
result.status === 'fulfilled' && result.value.success
|
||
);
|
||
|
||
console.log('📊 OTP/DM: Publishing summary:');
|
||
console.log(` - Total relays attempted: ${relays.length}`);
|
||
console.log(` - NIP-17 successful publishes: ${successfulResults.length}/${relays.length}`);
|
||
console.log(` - Overall success rate: ${Math.round((successfulResults.length / relays.length) * 100)}%`);
|
||
|
||
if (successfulResults.length > 0) {
|
||
console.log('✅ OTP/DM: NIP-17 DM sent successfully to at least one relay');
|
||
console.log('📱 OTP/DM: Check your Nostr client for the encrypted DM');
|
||
console.log('🔐 OTP/DM: Message uses modern NIP-44 encryption with NIP-17 gift wrapping');
|
||
console.log('🎯 OTP/DM: This should work with all modern Nostr clients and relays');
|
||
} else {
|
||
console.error('❌ OTP/DM: Failed to send DM to any relays');
|
||
console.log('💡 OTP/DM: This might indicate relay connectivity issues or client compatibility problems');
|
||
}
|
||
|
||
// Clean up the pool safely
|
||
try {
|
||
console.log('🧹 OTP/DM: Cleaning up relay connections...');
|
||
pool.close(relays);
|
||
console.log('🧹 OTP/DM: Pool cleanup completed');
|
||
} catch (cleanupError) {
|
||
console.warn('⚠️ OTP/DM: Pool cleanup warning (non-critical):', cleanupError.message);
|
||
}
|
||
|
||
// For demo purposes, also show the code in console
|
||
setTimeout(() => {
|
||
console.log(`🔐 OTP/DM: Demo - Login code sent was: ${code}`);
|
||
console.log('💡 OTP/DM: Check your Nostr DMs or use the code above for testing');
|
||
}, 2000);
|
||
|
||
return successfulResults.length > 0;
|
||
|
||
} catch (error) {
|
||
console.error('💥 OTP/DM: NIP-17 implementation error:', error);
|
||
console.log('💡 OTP/DM: This might indicate a problem with the nostr-tools NIP-17 implementation');
|
||
console.log(`🔐 OTP/DM: Fallback - Login code for testing: ${code}`);
|
||
return false;
|
||
}
|
||
}
|
||
|
||
async _sendLegacyNip44Dm(senderSecretKey, recipientPubkey, dmContent, code) {
|
||
console.log('🔄 OTP/DM: Using legacy NIP-44 direct message format');
|
||
|
||
try {
|
||
// Encrypt with NIP-44
|
||
const conversationKey = window.NostrTools.nip44.getConversationKey(senderSecretKey, recipientPubkey);
|
||
const nip44Content = window.NostrTools.nip44.encrypt(dmContent, conversationKey);
|
||
console.log('🔒 OTP/DM: NIP-44 encrypted length:', nip44Content.length, 'chars');
|
||
|
||
// Create NIP-44 DM event (modern approach - no custom tags)
|
||
const nip44Event = {
|
||
kind: 4,
|
||
created_at: Math.floor(Date.now() / 1000),
|
||
tags: [
|
||
['p', recipientPubkey]
|
||
],
|
||
content: nip44Content
|
||
};
|
||
|
||
// Sign the event
|
||
const signedEvent = window.NostrTools.finalizeEvent(nip44Event, senderSecretKey);
|
||
console.log('📝 OTP/DM: NIP-44 signed event:', JSON.stringify(signedEvent, null, 2));
|
||
|
||
// Get relays to send to
|
||
const relays = this.options.relays || [
|
||
'wss://relay.damus.io',
|
||
'wss://relay.primal.net',
|
||
'wss://relay.laantungir.net',
|
||
'wss://nostr.mom'
|
||
];
|
||
|
||
// Create a pool and send
|
||
const pool = new window.NostrTools.nip46.SimplePool();
|
||
const publishPromises = relays.map(async (relay, index) => {
|
||
console.log(`📡 OTP/DM: Legacy NIP-44 [${index + 1}/${relays.length}] Connecting to relay ${relay}...`);
|
||
|
||
try {
|
||
const results = await pool.publish([relay], signedEvent);
|
||
console.log(`📡 OTP/DM: Legacy NIP-44 Raw JSON response from ${relay}:`, JSON.stringify(results, null, 2));
|
||
|
||
const success = results.length > 0 && results[0].status === 'fulfilled';
|
||
if (!success && results.length > 0 && results[0].status === 'rejected') {
|
||
console.warn(`⚠️ OTP/DM: Legacy NIP-44 rejected by ${relay}:`, results[0].reason);
|
||
}
|
||
return { relay, success, results, type: 'legacy-nip44' };
|
||
} catch (error) {
|
||
console.error(`❌ OTP/DM: Legacy NIP-44 Failed to publish to ${relay}:`, error);
|
||
return { relay, success: false, error: error.message, type: 'legacy-nip44' };
|
||
}
|
||
});
|
||
|
||
const results = await Promise.allSettled(publishPromises);
|
||
const successfulResults = results.filter(result =>
|
||
result.status === 'fulfilled' && result.value.success
|
||
);
|
||
|
||
console.log('📊 OTP/DM: Legacy publishing summary:');
|
||
console.log(` - Legacy NIP-44 successful publishes: ${successfulResults.length}/${relays.length}`);
|
||
|
||
pool.close(relays);
|
||
return successfulResults.length > 0;
|
||
|
||
} catch (error) {
|
||
console.error('💥 OTP/DM: Legacy NIP-44 error:', error);
|
||
console.log(`🔐 OTP/DM: Fallback - Login code for testing: ${code}`);
|
||
return false;
|
||
}
|
||
}
|
||
|
||
_handleOtpVerification(pubkey, enteredCode) {
|
||
if (!enteredCode || enteredCode.length !== 6) {
|
||
this._showError('Please enter a 6-digit code');
|
||
return;
|
||
}
|
||
|
||
const storedCode = sessionStorage.getItem(`otp_${pubkey}`);
|
||
const timestamp = parseInt(sessionStorage.getItem(`otp_${pubkey}_timestamp`) || '0');
|
||
|
||
// Check if code is expired (5 minutes)
|
||
if (Date.now() - timestamp > 5 * 60 * 1000) {
|
||
sessionStorage.removeItem(`otp_${pubkey}`);
|
||
sessionStorage.removeItem(`otp_${pubkey}_timestamp`);
|
||
this._showError('Login code has expired. Please request a new one.');
|
||
return;
|
||
}
|
||
|
||
if (enteredCode === storedCode) {
|
||
// Clean up stored code
|
||
sessionStorage.removeItem(`otp_${pubkey}`);
|
||
sessionStorage.removeItem(`otp_${pubkey}_timestamp`);
|
||
|
||
console.log('OTP/DM: Code verified successfully');
|
||
|
||
// Set as read-only authentication (since we don't have the private key)
|
||
this._setAuthMethod('readonly', { pubkey });
|
||
} else {
|
||
this._showError('Invalid login code. Please check and try again.');
|
||
}
|
||
}
|
||
|
||
_getButtonStyle(type = 'primary') {
|
||
const baseStyle = `
|
||
display: block;
|
||
width: 100%;
|
||
padding: 12px;
|
||
border: none;
|
||
border-radius: 8px;
|
||
font-size: 16px;
|
||
font-weight: 500;
|
||
cursor: pointer;
|
||
transition: all 0.2s;
|
||
`;
|
||
|
||
if (type === 'primary') {
|
||
return baseStyle + `
|
||
background: #3b82f6;
|
||
color: white;
|
||
`;
|
||
} else {
|
||
return baseStyle + `
|
||
background: #6b7280;
|
||
color: white;
|
||
`;
|
||
}
|
||
}
|
||
|
||
// Public API
|
||
static init(options) {
|
||
if (Modal.instance) return Modal.instance;
|
||
Modal.instance = new Modal(options);
|
||
return Modal.instance;
|
||
}
|
||
|
||
static getInstance() {
|
||
return Modal.instance;
|
||
}
|
||
}
|
||
|
||
// Initialize global instance
|
||
let modalInstance = null;
|
||
|
||
window.addEventListener('load', () => {
|
||
modalInstance = new Modal();
|
||
});
|
||
|
||
|
||
// ======================================
|
||
// 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;
|
||
}
|
||
|
||
async init(options = {}) {
|
||
console.log('NOSTR_LOGIN_LITE: Initializing with options:', options);
|
||
|
||
this.options = {
|
||
theme: 'light',
|
||
darkMode: false,
|
||
relays: ['wss://relay.damus.io', 'wss://nos.lol'],
|
||
methods: {
|
||
extension: true,
|
||
local: true,
|
||
readonly: true,
|
||
connect: false,
|
||
otp: false
|
||
},
|
||
...options
|
||
};
|
||
|
||
// Set up window.nostr facade if no extension detected
|
||
if (this.extensionBridge.getExtensionCount() === 0) {
|
||
this._setupWindowNostrFacade();
|
||
}
|
||
|
||
this.initialized = true;
|
||
console.log('NOSTR_LOGIN_LITE: Initialization complete');
|
||
|
||
// Set up event listeners for authentication flow
|
||
this._setupAuthEventHandlers();
|
||
|
||
return this;
|
||
}
|
||
|
||
_setupWindowNostrFacade() {
|
||
if (typeof window !== 'undefined' && !window.nostr) {
|
||
window.nostr = new WindowNostr(this);
|
||
console.log('NOSTR_LOGIN_LITE: window.nostr facade installed');
|
||
}
|
||
}
|
||
|
||
_setupAuthEventHandlers() {
|
||
if (typeof window === 'undefined') return;
|
||
|
||
// Listen for authentication method selection
|
||
window.addEventListener('nlMethodSelected', async (event) => {
|
||
console.log('NOSTR_LOGIN_LITE: Authentication method selected:', event.detail);
|
||
|
||
const { method, pubkey, signer, extension, secret } = event.detail;
|
||
|
||
try {
|
||
// Complete the authentication flow
|
||
await this._completeAuthentication(method, event.detail);
|
||
} catch (error) {
|
||
console.error('NOSTR_LOGIN_LITE: Authentication completion failed:', error);
|
||
this._dispatchAuthEvent('nlAuthFailed', { error: error.message });
|
||
}
|
||
});
|
||
}
|
||
|
||
async _completeAuthentication(method, authData) {
|
||
console.log('NOSTR_LOGIN_LITE: Completing authentication for method:', method);
|
||
|
||
const authResult = {
|
||
method,
|
||
pubkey: authData.pubkey,
|
||
timestamp: Date.now()
|
||
};
|
||
|
||
// Add method-specific data
|
||
switch (method) {
|
||
case 'extension':
|
||
authResult.extension = authData.extension;
|
||
break;
|
||
case 'local':
|
||
authResult.secret = authData.secret;
|
||
break;
|
||
case 'nip46':
|
||
authResult.signer = authData.signer;
|
||
authResult.remotePubkey = authData.signer.remotePubkey;
|
||
break;
|
||
case 'readonly':
|
||
// No additional data needed
|
||
break;
|
||
}
|
||
|
||
// Store authentication state
|
||
if (typeof localStorage !== 'undefined') {
|
||
localStorage.setItem('nl_current', JSON.stringify({
|
||
method,
|
||
pubkey: authData.pubkey,
|
||
timestamp: Date.now()
|
||
}));
|
||
}
|
||
|
||
console.log('NOSTR_LOGIN_LITE: Authentication completed successfully');
|
||
console.log('NOSTR_LOGIN_LITE: User pubkey:', authData.pubkey);
|
||
|
||
// Fetch and display profile information
|
||
await this._fetchAndDisplayProfile(authData.pubkey);
|
||
|
||
// Dispatch success event
|
||
this._dispatchAuthEvent('nlAuth', authResult);
|
||
}
|
||
|
||
async _fetchAndDisplayProfile(pubkey) {
|
||
console.log('NOSTR_LOGIN_LITE: Fetching profile for pubkey:', pubkey);
|
||
|
||
try {
|
||
// Create a simple pool for fetching profile
|
||
const pool = new window.NostrTools.SimplePool();
|
||
const relays = this.options.relays || ['wss://relay.damus.io', 'wss://nos.lol'];
|
||
|
||
console.log('NOSTR_LOGIN_LITE: Querying relays for profile:', relays);
|
||
|
||
// Fetch profile metadata (kind 0)
|
||
const profileEvents = await pool.querySync(relays, {
|
||
kinds: [0],
|
||
authors: [pubkey],
|
||
limit: 1
|
||
});
|
||
|
||
pool.close(relays);
|
||
|
||
if (profileEvents && profileEvents.length > 0) {
|
||
const profileEvent = profileEvents[0];
|
||
const profile = JSON.parse(profileEvent.content);
|
||
|
||
console.log('NOSTR_LOGIN_LITE: Profile fetched successfully:', profile);
|
||
|
||
// Display profile information
|
||
this._displayProfileInfo(pubkey, profile);
|
||
} else {
|
||
console.log('NOSTR_LOGIN_LITE: No profile found, displaying pubkey only');
|
||
this._displayProfileInfo(pubkey, null);
|
||
}
|
||
|
||
} catch (error) {
|
||
console.error('NOSTR_LOGIN_LITE: Failed to fetch profile:', error);
|
||
this._displayProfileInfo(pubkey, null);
|
||
}
|
||
}
|
||
|
||
_displayProfileInfo(pubkey, profile) {
|
||
console.log('NOSTR_LOGIN_LITE: Displaying profile info');
|
||
|
||
// Create or update profile display
|
||
let profileDiv = document.getElementById('nl-profile-info');
|
||
if (!profileDiv) {
|
||
profileDiv = document.createElement('div');
|
||
profileDiv.id = 'nl-profile-info';
|
||
profileDiv.style.cssText = `
|
||
position: fixed;
|
||
top: 20px;
|
||
right: 20px;
|
||
background: white;
|
||
border: 1px solid #d1d5db;
|
||
border-radius: 8px;
|
||
padding: 16px;
|
||
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
|
||
z-index: 9999;
|
||
max-width: 300px;
|
||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||
`;
|
||
document.body.appendChild(profileDiv);
|
||
}
|
||
|
||
const npub = window.NostrTools.nip19.npubEncode(pubkey);
|
||
const shortPubkey = `${pubkey.slice(0, 8)}...${pubkey.slice(-8)}`;
|
||
|
||
profileDiv.innerHTML = `
|
||
<div style="display: flex; align-items: center; margin-bottom: 12px;">
|
||
<div style="font-size: 24px; margin-right: 8px;">✅</div>
|
||
<div>
|
||
<div style="font-weight: 600; color: #059669;">Logged In</div>
|
||
<div style="font-size: 12px; color: #6b7280;">NOSTR_LOGIN_LITE</div>
|
||
</div>
|
||
</div>
|
||
|
||
${profile ? `
|
||
<div style="margin-bottom: 12px;">
|
||
<div style="font-weight: 500; color: #1f2937;">${profile.name || 'Anonymous'}</div>
|
||
${profile.about ? `<div style="font-size: 12px; color: #6b7280; margin-top: 4px;">${profile.about.slice(0, 100)}${profile.about.length > 100 ? '...' : ''}</div>` : ''}
|
||
</div>
|
||
` : ''}
|
||
|
||
<div style="font-size: 12px; color: #6b7280; margin-bottom: 8px;">
|
||
<strong>Pubkey:</strong> ${shortPubkey}
|
||
</div>
|
||
|
||
<div style="font-size: 12px; color: #6b7280; margin-bottom: 12px;">
|
||
<strong>npub:</strong> ${npub.slice(0, 12)}...${npub.slice(-8)}
|
||
</div>
|
||
|
||
<button onclick="NOSTR_LOGIN_LITE.logout(); document.getElementById('nl-profile-info').remove();"
|
||
style="background: #dc2626; color: white; border: none; padding: 6px 12px; border-radius: 4px; cursor: pointer; font-size: 12px;">
|
||
Logout
|
||
</button>
|
||
`;
|
||
|
||
console.log('NOSTR_LOGIN_LITE: Profile display updated');
|
||
}
|
||
|
||
_dispatchAuthEvent(eventName, data) {
|
||
if (typeof window !== 'undefined') {
|
||
window.dispatchEvent(new CustomEvent(eventName, {
|
||
detail: data
|
||
}));
|
||
console.log('NOSTR_LOGIN_LITE: Dispatched event:', eventName, data);
|
||
}
|
||
}
|
||
|
||
launch(startScreen = 'login') {
|
||
console.log('NOSTR_LOGIN_LITE: Launching with screen:', startScreen);
|
||
|
||
if (typeof Modal !== 'undefined') {
|
||
const modal = new Modal(this.options);
|
||
modal.open({ startScreen });
|
||
} else {
|
||
console.error('NOSTR_LOGIN_LITE: Modal component not available');
|
||
}
|
||
}
|
||
|
||
logout() {
|
||
console.log('NOSTR_LOGIN_LITE: Logout called');
|
||
|
||
// Clear stored data
|
||
if (typeof localStorage !== 'undefined') {
|
||
localStorage.removeItem('nl_current');
|
||
}
|
||
|
||
// Dispatch logout event
|
||
if (typeof window !== 'undefined') {
|
||
window.dispatchEvent(new CustomEvent('nlLogout', {
|
||
detail: { timestamp: Date.now() }
|
||
}));
|
||
}
|
||
}
|
||
}
|
||
|
||
// Window.nostr facade for when no extension is available
|
||
class WindowNostr {
|
||
constructor(nostrLite) {
|
||
this.nostrLite = nostrLite;
|
||
}
|
||
|
||
async getPublicKey() {
|
||
throw new Error('Authentication required - use NOSTR_LOGIN_LITE.launch()');
|
||
}
|
||
|
||
async signEvent(event) {
|
||
throw new Error('Authentication required - use NOSTR_LOGIN_LITE.launch()');
|
||
}
|
||
|
||
async getRelays() {
|
||
throw new Error('Authentication required - use NOSTR_LOGIN_LITE.launch()');
|
||
}
|
||
|
||
get nip04() {
|
||
return {
|
||
async encrypt(pubkey, plaintext) {
|
||
throw new Error('Authentication required - use NOSTR_LOGIN_LITE.launch()');
|
||
},
|
||
async decrypt(pubkey, ciphertext) {
|
||
throw new Error('Authentication required - use NOSTR_LOGIN_LITE.launch()');
|
||
}
|
||
};
|
||
}
|
||
|
||
get nip44() {
|
||
return {
|
||
async encrypt(pubkey, plaintext) {
|
||
throw new Error('Authentication required - use NOSTR_LOGIN_LITE.launch()');
|
||
},
|
||
async decrypt(pubkey, ciphertext) {
|
||
throw new Error('Authentication required - use NOSTR_LOGIN_LITE.launch()');
|
||
}
|
||
};
|
||
}
|
||
}
|
||
|
||
// 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(),
|
||
|
||
// 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 };
|
||
}
|