1(function (root, factory) { 2 'use strict'; 3 if (typeof module === 'object' && module.exports) { 4 module.exports = factory(); 5 } else { 6 var api = factory(); 7 root.citrine = api; 8 root.Citrine = api; 9 root.CitrineNostrWeb = api; 10 } 11}(typeof globalThis !== 'undefined' ? globalThis : this, function () { 12 'use strict'; 13 14 var NIP46_KIND = 24133; 15 var DEFAULT_RETRY_DELAYS = [1200, 2400, 4800]; 16 17 function normalizeHex(value, length) { 18 var text = String(value || '').trim().toLowerCase(); 19 var re = new RegExp('^[0-9a-f]{' + String(length) + '}$'); 20 return re.test(text) ? text : ''; 21 } 22 23 function normalizePubkeyHex(value) { 24 return normalizeHex(value, 64); 25 } 26 27 function normalizeNostrPubkey(value, tools) { 28 var text = String(value || '').trim(); 29 var hex = normalizePubkeyHex(text); 30 var nostrTools = tools || null; 31 if (hex) return hex; 32 if (!/^npub1/i.test(text)) return ''; 33 if (!nostrTools || !nostrTools.nip19 || typeof nostrTools.nip19.decode !== 'function') return ''; 34 try { 35 var decoded = nostrTools.nip19.decode(text.toLowerCase()); 36 if (decoded && decoded.type === 'npub') return normalizePubkeyHex(decoded.data || ''); 37 } catch (_err) {} 38 return ''; 39 } 40 41 function bytesToHex(bytes) { 42 return Array.prototype.map.call(bytes || [], function (byte) { 43 return ('0' + Number(byte).toString(16)).slice(-2); 44 }).join(''); 45 } 46 47 function hexToBytes(hex) { 48 var value = normalizeHex(hex, String(hex || '').length); 49 if (!value || value.length % 2 !== 0) { 50 throw new Error('Invalid hex string.'); 51 } 52 var out = new Uint8Array(value.length / 2); 53 for (var i = 0; i < value.length; i += 2) { 54 out[i / 2] = parseInt(value.slice(i, i + 2), 16); 55 } 56 return out; 57 } 58 59 function randomHex(bytes, randomSource) { 60 var count = Math.max(1, Number(bytes || 16)); 61 var out = new Uint8Array(count); 62 var source = randomSource || (typeof crypto !== 'undefined' ? crypto : null); 63 if (source && typeof source.getRandomValues === 'function') { 64 source.getRandomValues(out); 65 return bytesToHex(out); 66 } 67 for (var i = 0; i < count; i += 1) { 68 out[i] = Math.floor(Math.random() * 256); 69 } 70 return bytesToHex(out); 71 } 72 73 function validRelay(value) { 74 var relay = String(value || '').trim(); 75 return /^wss:\/\/[^ ]+$/i.test(relay) ? relay : ''; 76 } 77 78 function cleanRelays(relays, fallbackRelays) { 79 var out = (Array.isArray(relays) ? relays : []).map(validRelay).filter(Boolean); 80 if (out.length) return out; 81 return (Array.isArray(fallbackRelays) ? fallbackRelays : []).map(validRelay).filter(Boolean); 82 } 83 84 function buildNostrConnectUri(options) { 85 var opts = options && typeof options === 'object' ? options : {}; 86 var appPubkey = normalizePubkeyHex(opts.appPubkey || ''); 87 var pairSecret = String(opts.pairSecret || '').trim(); 88 var relays = cleanRelays(opts.relays || [], []); 89 var params = new URLSearchParams(); 90 if (!appPubkey) throw new Error('Nostr Connect app pubkey is required.'); 91 if (!pairSecret) throw new Error('Nostr Connect pair secret is required.'); 92 relays.forEach(function (relay) { 93 params.append('relay', relay); 94 }); 95 params.set('secret', pairSecret); 96 if (opts.name) params.set('name', String(opts.name)); 97 if (opts.metadata) params.set('metadata', JSON.stringify(opts.metadata)); 98 if (opts.perms) params.set('perms', Array.isArray(opts.perms) ? opts.perms.join(',') : String(opts.perms)); 99 return 'nostrconnect://' + appPubkey + '?' + params.toString(); 100 } 101 102 function parseJson(value) { 103 if (value && typeof value === 'object') return value; 104 return JSON.parse(String(value || '{}')); 105 } 106 107 function extractConnectSecret(msg) { 108 if (!msg || typeof msg !== 'object') return ''; 109 if (typeof msg.result === 'string') return msg.result; 110 if (typeof msg.secret === 'string') return msg.secret; 111 if (Array.isArray(msg.params)) { 112 if (typeof msg.params[1] === 'string') return msg.params[1]; 113 if (typeof msg.params[0] === 'string' && msg.params.length === 1) return msg.params[0]; 114 if (msg.params[0] && typeof msg.params[0] === 'object' && typeof msg.params[0].secret === 'string') { 115 return msg.params[0].secret; 116 } 117 } 118 return ''; 119 } 120 121 function isConnectAck(msg, launchPending) { 122 if (!msg || typeof msg !== 'object' || msg.error) return false;
123 if (String(msg.result || '').trim().toLowerCase() === 'ack') return true; 124 if (typeof msg.result === 'string' && msg.result === '' && msg.id && launchPending) return true; 125 return String(msg.method || '').trim() === 'connect' && !extractConnectSecret(msg); 126 } 127 128 function createAuthEventTemplate(options) { 129 var opts = options && typeof options === 'object' ? options : {}; 130 var tags = []; 131 var challenge = String(opts.challenge || '').trim(); 132 var domain = String(opts.domain || opts.host || '').trim(); 133 var origin = String(opts.origin || '').trim(); 134 var url = String(opts.url || '').trim(); 135 var method = String(opts.method || '').trim().toUpperCase(); 136 var action = String(opts.action || 'login').trim(); 137 var pubkey = normalizePubkeyHex(opts.pubkey || ''); 138 if (challenge) tags.push(['challenge', challenge]); 139 if (origin) tags.push(['origin', origin]); 140 if (domain) tags.push(['domain', domain]); 141 if (url) tags.push(['u', url]); 142 if (method) tags.push(['method', method]); 143 if (action && action !== 'login') tags.push(['action', action]); 144 if (Array.isArray(opts.extraTags)) { 145 opts.extraTags.forEach(function (tag) { 146 if (Array.isArray(tag) && tag.length && tag[0]) { 147 tags.push(tag.map(function (part) { return String(part); })); 148 } 149 }); 150 } 151 var event = { 152 kind: Number(opts.kind || 22242), 153 created_at: Number(opts.createdAt || Math.floor(Date.now() / 1000)), 154 tags: tags, 155 content: String(opts.content || '') 156 }; 157 if (pubkey) event.pubkey = pubkey; 158 return event; 159 } 160 161 function wait(delayMs) { 162 return new Promise(function (resolve) { 163 setTimeout(resolve, Math.max(0, Number(delayMs || 0))); 164 }); 165 } 166 167 function withTimedOutRetry(task, options) { 168 var opts = options && typeof options === 'object' ? options : {}; 169 var delays = Array.isArray(opts.delays) ? opts.delays : DEFAULT_RETRY_DELAYS; 170 var waitFn = typeof opts.wait === 'function' ? opts.wait : wait; 171 var onRetry = typeof opts.onRetry === 'function' ? opts.onRetry : function () {}; 172 var attempt = 0; 173 174 function run() { 175 return Promise.resolve().then(task).catch(function (err) { 176 var message = String((err && err.message) || ''); 177 if (attempt >= delays.length || message.indexOf('timed out') < 0) { 178 throw err; 179 } 180 var delayMs = Number(delays[attempt] || 0); 181 attempt += 1; 182 onRetry({ attempt: attempt, delayMs: delayMs, error: err }); 183 return Promise.resolve(waitFn(delayMs)).then(run); 184 }); 185 } 186 187 return run(); 188 } 189 190 function createSessionStorageAdapter(storage, key) { 191 var target = storage || (typeof sessionStorage !== 'undefined' ? sessionStorage : null); 192 var storageKey = key || 'citrine.nip46.v1'; 193 return { 194 load: function () { 195 if (!target) return null; 196 try { 197 return parseJson(target.getItem(storageKey) || 'null'); 198 } catch (_err) { 199 return null; 200 } 201 }, 202 save: function (value) { 203 if (!target) return; 204 target.setItem(storageKey, JSON.stringify(value || {})); 205 }, 206 clear: function () { 207 if (!target) return; 208 target.removeItem(storageKey); 209 } 210 }; 211 } 212 213 function createMemoryStorageAdapter(initial) { 214 var value = initial || null; 215 return { 216 load: function () { return value; }, 217 save: function (next) { value = next || null; }, 218 clear: function () { value = null; } 219 }; 220 } 221 222 function hasNostrTools(target) { 223 var root = target || (typeof window !== 'undefined' ? window : {}); 224 var tools = root.NostrTools || null; 225 return !!(tools && 226 typeof tools.generateSecretKey === 'function' && 227 typeof tools.getPublicKey === 'function' && 228 typeof tools.finalizeEvent === 'function' && 229 tools.nip04 && 230 typeof tools.nip04.encrypt === 'function' && 231 typeof tools.nip04.decrypt === 'function' && 232 typeof tools.SimplePool === 'function'); 233 } 234 235 function waitForNostrTools(options) { 236 var opts = options && typeof options === 'object' ? options : {}; 237 var root = opts.target || (typeof window !== 'undefined' ? window : null); 238 var doc = opts.document || (root && root.document) || (typeof document !== 'undefined' ? document : null); 239 var timeout = Number(opts.timeoutMs || 8000); 240 var interval = Number(opts.intervalMs || 80); 241 var scriptSrc = String(opts.scriptSrc || ''); 242 var errorMessage = String(opts.errorMessage || 'Nostr signer tools did not load.'); 243 if (!root) return Promise.reject(new Error(errorMessage)); 244 if (hasNostrTools(root)) return Promise.resolve(root.NostrTools); 245 if (!isFinite(timeout) || timeout < 1000) timeout = 8000; 246 if (!isFinite(interval) || interval < 20) interval = 80; 247 if (root.__citrineNostrToolsPromise) return root.__citrineNostrToolsPromise; 248 root.__citrineNostrToolsPromise = new Promise(function (resolve, reject) { 249 var startedAt = Date.now(); 250 var done = false;
251 var timer = 0; 252 function clear() { 253 if (timer && root.clearTimeout) root.clearTimeout(timer); 254 timer = 0; 255 } 256 function finish(err) { 257 if (done) return; 258 done = true; 259 clear(); 260 root.__citrineNostrToolsPromise = null; 261 if (err) reject(err); 262 else resolve(root.NostrTools); 263 } 264 function check() { 265 if (hasNostrTools(root)) { 266 finish(null); 267 return; 268 } 269 if (Date.now() - startedAt >= timeout) { 270 finish(new Error(errorMessage)); 271 return; 272 } 273 timer = root.setTimeout(check, interval); 274 } 275 if (scriptSrc && doc && doc.head && !doc.querySelector('script[data-citrine-nostr-tools="true"]')) { 276 var script = doc.createElement('script'); 277 script.src = scriptSrc; 278 script.async = true; 279 script.dataset.citrineNostrTools = 'true'; 280 script.onload = check; 281 script.onerror = function () { finish(new Error(errorMessage)); }; 282 doc.head.appendChild(script); 283 } 284 check(); 285 }); 286 return root.__citrineNostrToolsPromise; 287 } 288 289 function defaultState(relays) { 290 return { 291 active: false, 292 appSecretHex: '', 293 appPubkey: '', 294 pairSecret: '', 295 relays: relays || [], 296 signerPubkey: '', 297 accountPubkey: '', 298 pending: {}, 299 pendingTimers: {}, 300 seenEvents: {}, 301 launchPending: false, 302 subscription: null, 303 pool: null 304 }; 305 } 306 307 function createNip46Client(options) { 308 var opts = options && typeof options === 'object' ? options : {}; 309 var nostrTools = opts.nostrTools || null; 310 var relays = cleanRelays(opts.relays || [], opts.defaultRelays || []); 311 var storage = opts.storage || createMemoryStorageAdapter(); 312 var nowEpoch = typeof opts.nowEpoch === 'function' ? opts.nowEpoch : function () { 313 return Math.floor(Date.now() / 1000); 314 }; 315 var randomHexFn = typeof opts.randomHex === 'function' ? opts.randomHex : randomHex; 316 var status = typeof opts.onStatus === 'function' ? opts.onStatus : function () {}; 317 var state = defaultState(relays); 318 319 function requireTools() { 320 if (!nostrTools) throw new Error('Nostr tools are required.'); 321 return nostrTools; 322 } 323 324 function save() { 325 if (!state.active || !state.appSecretHex || !state.appPubkey) return; 326 storage.save({ 327 appSecretHex: state.appSecretHex, 328 appPubkey: state.appPubkey, 329 pairSecret: state.pairSecret, 330 relays: state.relays, 331 signerPubkey: normalizePubkeyHex(state.signerPubkey || ''), 332 accountPubkey: normalizePubkeyHex(state.accountPubkey || '') 333 }); 334 } 335 336 function load() { 337 var saved = storage.load(); 338 if (!saved || typeof saved !== 'object') return false; 339 state.active = true; 340 state.appSecretHex = String(saved.appSecretHex || ''); 341 state.appPubkey = normalizePubkeyHex(saved.appPubkey || ''); 342 state.pairSecret = String(saved.pairSecret || ''); 343 state.relays = cleanRelays(saved.relays || [], relays); 344 state.signerPubkey = normalizePubkeyHex(saved.signerPubkey || ''); 345 state.accountPubkey = normalizePubkeyHex(saved.accountPubkey || ''); 346 return !!(state.appSecretHex && state.appPubkey && state.pairSecret); 347 } 348 349 function ensurePairing() { 350 var tools = requireTools(); 351 if (state.active && state.appPubkey && state.pairSecret) return Promise.resolve(api); 352 if (load()) return Promise.resolve(api); 353 state.active = true; 354 state.appSecretHex = bytesToHex(tools.generateSecretKey()); 355 state.appPubkey = tools.getPublicKey(hexToBytes(state.appSecretHex)); 356 state.pairSecret = randomHexFn(16); 357 state.relays = relays.slice(); 358 state.pending = {}; 359 state.pendingTimers = {}; 360 state.seenEvents = {}; 361 save(); 362 return Promise.resolve(api); 363 } 364 365 function currentUri(uriOptions) { 366 var extra = uriOptions && typeof uriOptions === 'object' ? uriOptions : {}; 367 return buildNostrConnectUri({ 368 appPubkey: state.appPubkey, 369 pairSecret: state.pairSecret, 370 relays: state.relays, 371 name: extra.name || opts.name || '', 372 metadata: extra.metadata || opts.metadata || null, 373 perms: extra.perms || opts.perms || 'get_public_key' 374 }); 375 } 376 377 function setLaunchPending(value) { 378 state.launchPending = !!value; 379 return state.launchPending; 380 } 381 382 function closeSubscription() { 383 if (state.subscription && typeof state.subscription.close === 'function') state.subscription.close(); 384 state.subscription = null; 385 } 386 387 function ensurePool() { 388 var tools = requireTools(); 389 if (!state.pool) state.pool = new tools.SimplePool(); 390 return state.pool; 391 } 392 393 function subscribe(lookbackSeconds) { 394 if (!state.appPubkey) return; 395 closeSubscription(); 396 var pool = ensurePool(); 397 state.subscription = pool.subscribeMany( 398 state.relays, 399 [{ kinds: [NIP46_KIND], '#p': [state.appPubkey], since: nowEpoch() - Math.max(30, Number(lookbackSeconds || 180)) }], 400 { onevent: handleRelayEvent } 401 ); 402 } 403 404 function decryptEvent(event) { 405 var tools = requireTools(); 406 if (tools.nip44 && typeof tools.nip44.getConversationKey === 'function') { 407 try { 408 var conversationKey = tools.nip44.getConversationKey(hexToBytes(state.appSecretHex), event.pubkey); 409 return Promise.resolve(tools.nip44.decrypt(event.content, conversationKey)); 410 } catch (_err) {} 411 } 412 return tools.nip04.decrypt(hexToBytes(state.appSecretHex), event.pubkey, event.content); 413 } 414 415 function encrypt(pubkey, plaintext) { 416 var tools = requireTools(); 417 if (tools.nip44 && typeof tools.nip44.getConversationKey === 'function') { 418 var conversationKey = tools.nip44.getConversationKey(hexToBytes(state.appSecretHex), pubkey); 419 return Promise.resolve(tools.nip44.encrypt(plaintext, conversationKey)); 420 } 421 return tools.nip04.encrypt(hexToBytes(state.appSecretHex), pubkey, plaintext); 422 } 423 424 function resolvePending(id, payload, isError) { 425 var entry = state.pending[id]; 426 if (!entry) return; 427 delete state.pending[id]; 428 if (state.pendingTimers[id]) { 429 clearTimeout(state.pendingTimers[id]); 430 delete state.pendingTimers[id]; 431 } 432 if (isError) entry.reject(new Error(payload || 'Nostr Connect request failed.')); 433 else entry.resolve(payload); 434 } 435 436 function handleRelayEvent(event) { 437 if (!event || !event.id || state.seenEvents[event.id]) return Promise.resolve(false); 438 state.seenEvents[event.id] = true; 439 return decryptEvent(event).then(function (plain) { 440 var msg = parseJson(plain); 441 var secret = extractConnectSecret(msg); 442 if (msg && msg.id && state.pending[msg.id]) { 443 resolvePending(msg.id, msg.error || msg.result, !!msg.error); 444 return true; 445 } 446 if ((msg && msg.method === 'connect' && (!secret || secret === state.pairSecret)) || secret === state.pairSecret || isConnectAck(msg, state.launchPending)) { 447 var signerPubkey = normalizePubkeyHex(event.pubkey || ''); 448 if (signerPubkey && state.signerPubkey && state.signerPubkey !== signerPubkey) { 449 state.accountPubkey = ''; 450 } 451 state.signerPubkey = signerPubkey; 452 state.launchPending = false;
453 save(); 454 status({ type: 'connected', signerPubkey: signerPubkey }); 455 return true; 456 } 457 return false; 458 }); 459 } 460 461 function sendRpc(method, params, timeoutMs) { 462 if (typeof opts.rpcRequest === 'function') { 463 return opts.rpcRequest(method, params || [], { state: state, timeoutMs: timeoutMs }); 464 } 465 if (!state.signerPubkey) return Promise.reject(new Error('Phone signer is not paired yet.')); 466 var tools = requireTools(); 467 var requestId = randomHexFn(12); 468 return encrypt(state.signerPubkey, JSON.stringify({ id: requestId, method: method, params: params || [] })).then(function (ciphertext) { 469 var eventTemplate = { 470 kind: NIP46_KIND, 471 created_at: nowEpoch(), 472 tags: [['p', state.signerPubkey]], 473 content: ciphertext 474 }; 475 var signed = tools.finalizeEvent(eventTemplate, hexToBytes(state.appSecretHex)); 476 return new Promise(function (resolve, reject) { 477 state.pending[requestId] = { resolve: resolve, reject: reject }; 478 state.pendingTimers[requestId] = setTimeout(function () { 479 resolvePending(requestId, 'Phone signer timed out.', true); 480 }, Number(timeoutMs || 60000)); 481 ensurePool().publish(state.relays, signed); 482 }); 483 }); 484 } 485 486 function getAccountPubkey(options) { 487 var account = normalizePubkeyHex(state.accountPubkey || ''); 488 var forceRefresh = options && options.forceRefresh === true; 489 if (account && !forceRefresh) return Promise.resolve(account); 490 if (forceRefresh) state.accountPubkey = ''; 491 return sendRpc('get_public_key', [], 30000).then(function (pubkey) { 492 var normalized = normalizePubkeyHex(pubkey || ''); 493 if (!normalized) throw new Error('Phone signer did not provide a valid public key.'); 494 state.accountPubkey = normalized; 495 save(); 496 return normalized; 497 }); 498 } 499 500 function getAccountPubkeyWithRetry(options) { 501 return withTimedOutRetry(function () { 502 return getAccountPubkey(options); 503 }, { 504 delays: opts.retryDelays || DEFAULT_RETRY_DELAYS, 505 wait: opts.wait, 506 onRetry: function (event) { 507 status({ type: 'retry', attempt: event.attempt, delayMs: event.delayMs }); 508 } 509 }); 510 } 511 512 function clear() { 513 closeSubscription(); 514 state = defaultState(relays); 515 storage.clear(); 516 } 517 518 function resetPairing(options) { 519 var resetOptions = options && typeof options === 'object' ? options : {}; 520 closeSubscription(); 521 Object.keys(state.pendingTimers || {}).forEach(function (id) { 522 clearTimeout(state.pendingTimers[id]); 523 }); 524 state = defaultState(relays); 525 if (resetOptions.clearStorage !== false) storage.clear(); 526 return state; 527 } 528 529 var api = { 530 state: function () { return state; }, 531 ensurePairing: ensurePairing, 532 currentUri: currentUri, 533 setLaunchPending: setLaunchPending, 534 subscribe: subscribe, 535 handleRelayEvent: handleRelayEvent, 536 sendRpc: sendRpc, 537 getAccountPubkey: getAccountPubkey, 538 getAccountPubkeyWithRetry: getAccountPubkeyWithRetry, 539 resetPairing: resetPairing, 540 clear: clear, 541 save: save, 542 load: load 543 }; 544 return api; 545 } 546 547 function getNip07Signer(target) { 548 var root = target || (typeof window !== 'undefined' ? window : {}); 549 var signer = root.nostr || null; 550 if (!signer) throw new Error('No browser signer detected.'); 551 if (typeof signer.getPublicKey !== 'function') throw new Error('Browser signer is missing getPublicKey.'); 552 if (typeof signer.signEvent !== 'function') throw new Error('Browser signer is missing signEvent.'); 553 return signer; 554 } 555 556 function signAuthChallenge(signer, options) { 557 var opts = options && typeof options === 'object' ? options : {}; 558 if (!signer || typeof signer.signEvent !== 'function') return Promise.reject(new Error('A Nostr signer is required.')); 559 return Promise.resolve(typeof signer.getPublicKey === 'function' ? signer.getPublicKey() : opts.pubkey || '').then(function (pubkey) { 560 var normalized = normalizePubkeyHex(pubkey || opts.pubkey || ''); 561 var template = createAuthEventTemplate({ 562 kind: opts.kind, 563 challenge: opts.challenge, 564 action: opts.action, 565 pubkey: normalized, 566 origin: opts.origin, 567 domain: opts.domain, 568 host: opts.host, 569 url: opts.url, 570 method: opts.method,
571 content: opts.content, 572 createdAt: opts.createdAt, 573 extraTags: opts.extraTags 574 }); 575 return Promise.resolve(signer.signEvent(template)).then(function (event) { 576 return { pubkey: normalized, event: typeof event === 'string' ? parseJson(event) : event }; 577 }); 578 }); 579 } 580 581 function createSharedNostrSigner(options) { 582 var opts = options && typeof options === 'object' ? options : {}; 583 var root = opts.target || (typeof window !== 'undefined' ? window : {}); 584 var client = opts.nip46Client || null; 585 var unavailableMessage = String(opts.unavailableMessage || 'No Nostr signer detected.'); 586 var lastPubkey = typeof opts.getLastPubkey === 'function' ? opts.getLastPubkey : function () { return ''; }; 587 588 function browserSigner() { 589 try { 590 return getNip07Signer(root); 591 } catch (_err) { 592 return null; 593 } 594 } 595 596 function resolve(resolveOptions) { 597 var ropts = resolveOptions && typeof resolveOptions === 'object' ? resolveOptions : {}; 598 var silent = !!ropts.silent; 599 var signer = browserSigner(); 600 if (signer) { 601 return Promise.resolve({ 602 method: 'browser', 603 signEvent: function (template) { return Promise.resolve(signer.signEvent(template)); }, 604 getPublicKey: function () { return Promise.resolve(signer.getPublicKey()).then(normalizePubkeyHex); } 605 }); 606 } 607 if (!client) { 608 return silent ? Promise.resolve(null) : Promise.reject(new Error(unavailableMessage)); 609 } 610 return Promise.resolve(typeof opts.ensureNip46Ready === 'function' ? opts.ensureNip46Ready(client) : client.ensurePairing()).then(function () { 611 var state = typeof client.state === 'function' ? client.state() : {}; 612 var paired = normalizePubkeyHex(state.signerPubkey || ''); 613 if (!paired) { 614 if (silent) return null; 615 throw new Error(unavailableMessage); 616 } 617 return { 618 method: 'nip46', 619 signEvent: function (template) { 620 return client.sendRpc('sign_event', [JSON.stringify(template)], ropts.signTimeoutMs || 70000).then(function (result) { 621 return typeof result === 'string' ? parseJson(result) : result; 622 }); 623 }, 624 getPublicKey: function () { return client.getAccountPubkey(ropts); } 625 }; 626 }).catch(function (err) { 627 if (silent) return null; 628 throw err; 629 }); 630 } 631 632 function status() { 633 return resolve({ silent: true }).then(function (signer) { 634 if (!signer) return { available: false, method: '', pubkey: '' }; 635 if (signer.method === 'nip46') { 636 var state = client && typeof client.state === 'function' ? client.state() : {}; 637 return { available: true, method: 'nip46', pubkey: normalizePubkeyHex(state.accountPubkey || lastPubkey() || '') }; 638 } 639 return signer.getPublicKey().then(function (pubkey) { 640 return { available: true, method: 'browser', pubkey: normalizePubkeyHex(pubkey || '') }; 641 }); 642 }); 643 } 644 645 return { 646 __wizardryShared: true, 647 resolve: resolve, 648 signEvent: function (template) { return resolve().then(function (signer) { return signer.signEvent(template); }); }, 649 getPublicKey: function () { return resolve().then(function (signer) { return signer.getPublicKey(); }); }, 650 getStatus: status 651 }; 652 } 653 654 function signerUnavailableError(err) { 655 return classifySignerError(err).unavailable; 656 } 657 658 function classifySignerError(err) { 659 var message = String((err && err.message) || err || '').toLowerCase(); 660 var code = 'unknown'; 661 if (message.indexOf('reject') !== -1 || message.indexOf('denied') !== -1) code = 'rejected'; 662 else if (message.indexOf('timed out') !== -1 || message.indexOf('timeout') !== -1) code = 'timeout'; 663 else if (message.indexOf('decrypt') !== -1) code = 'decrypt'; 664 else if (message.indexOf('invalid public key') !== -1 || message.indexOf('invalid pubkey') !== -1 || message.indexOf('valid public key') !== -1) code = 'invalid_pubkey'; 665 else if (message.indexOf('locked') !== -1) code = 'locked'; 666 else if (message.indexOf('not paired') !== -1 || message.indexOf('pairing expired') !== -1) code = 'pairing'; 667 else if (message.indexOf('no nostr signer') !== -1 || message.indexOf('no browser nostr signer') !== -1 || message.indexOf('no browser signer') !== -1) code = 'missing'; 668 else if (message.indexOf('fresh signer approval') !== -1) code = 'approval_required'; 669 return { 670 code: code, 671 message: String((err && err.message) || err || ''), 672 unavailable: message.indexOf('no nostr signer') !== -1 || 673 message.indexOf('no browser nostr signer') !== -1 || 674 message.indexOf('no browser signer') !== -1 || 675 message.indexOf('fresh signer approval') !== -1 || 676 message.indexOf('phone signer is not paired') !== -1 || 677 message.indexOf('signer is not paired') !== -1 || 678 message.indexOf('not paired') !== -1 679 }; 680 } 681 682 function signerIsAvailable(api) { 683 if (!api) return Promise.resolve(false); 684 if (typeof api.getStatus !== 'function') return Promise.resolve(true); 685 return Promise.resolve(api.getStatus()).then(function (status) { 686 return !!(status && status.available); 687 }).catch(function (err) { 688 if (signerUnavailableError(err)) return false;
689 throw err; 690 }); 691 } 692 693 function bindSignerReturnRefresh(target, refresh) { 694 var root = target || (typeof window !== 'undefined' ? window : null); 695 var fn = typeof refresh === 'function' ? refresh : function () {}; 696 if (!root || typeof root.addEventListener !== 'function') return function () {}; 697 function onVisibility() { 698 var doc = root.document || (typeof document !== 'undefined' ? document : null); 699 if (!doc || !doc.hidden) fn('visibility'); 700 } 701 function onPageshow() { fn('pageshow'); } 702 function onFocus() { fn('focus'); } 703 root.addEventListener('pageshow', onPageshow); 704 root.addEventListener('focus', onFocus); 705 if (root.document && typeof root.document.addEventListener === 'function') { 706 root.document.addEventListener('visibilitychange', onVisibility); 707 } 708 return function unbind() { 709 root.removeEventListener('pageshow', onPageshow); 710 root.removeEventListener('focus', onFocus); 711 if (root.document && typeof root.document.removeEventListener === 'function') { 712 root.document.removeEventListener('visibilitychange', onVisibility); 713 } 714 }; 715 } 716 717 function cleanNip55CallbackUrl(rawUrl) { 718 var url = new URL(String(rawUrl || (typeof location !== 'undefined' ? location.href : '')), 'https://example.invalid/'); 719 var hashParams = new URLSearchParams((url.hash || '').replace(/^#/, '')); 720 var hashChanged = false; 721 ['nostrSignerResult', 'result', 'package', 'rejected'].forEach(function (key) { 722 url.searchParams.delete(key); 723 if (hashParams.has(key)) { 724 hashParams.delete(key); 725 hashChanged = true; 726 } 727 }); 728 if (hashChanged) url.hash = hashParams.toString() ? '#' + hashParams.toString() : ''; 729 return url.pathname + (url.search ? url.search : '') + url.hash; 730 } 731 732 function buildNip55CallbackUrl(rawUrl, resultParam) { 733 var url = new URL(String(rawUrl || (typeof location !== 'undefined' ? location.href : '')), 'https://example.invalid/'); 734 return url.origin + url.pathname + '#' + encodeURIComponent(resultParam || 'nostrSignerResult') + '='; 735 } 736 737 function buildNip55Uri(options) { 738 var opts = options && typeof options === 'object' ? options : {}; 739 var query = new URLSearchParams(); 740 var type = String(opts.type || 'get_public_key'); 741 var params = opts.params && typeof opts.params === 'object' ? opts.params : {}; 742 if (type !== 'get_public_key') { 743 query.set('compressionType', params.compressionType || opts.compressionType || 'none'); 744 query.set('returnType', params.returnType || opts.returnType || 'signature'); 745 } 746 query.set('type', type); 747 Object.keys(params).forEach(function (key) { 748 if (key === 'compressionType' || key === 'returnType') return; 749 var value = params[key]; 750 if (value === null || typeof value === 'undefined' || value === '') return; 751 query.set(key, typeof value === 'string' ? value : JSON.stringify(value)); 752 }); 753 return 'nostrsigner:' + (opts.payload ? encodeURIComponent(String(opts.payload)) : '') + '?' + query.toString(); 754 } 755 756 function parseNip55Callback(rawUrl) { 757 var url = new URL(String(rawUrl || (typeof location !== 'undefined' ? location.href : '')), 'https://example.invalid/'); 758 var hashParams = new URLSearchParams((url.hash || '').replace(/^#/, '')); 759 var rejected = url.searchParams.get('rejected') === 'true' || hashParams.get('rejected') === 'true'; 760 var result = url.searchParams.get('nostrSignerResult') || 761 url.searchParams.get('result') || 762 hashParams.get('nostrSignerResult') || 763 hashParams.get('result') || 764 ''; 765 return { present: !!(result || rejected), rejected: rejected, result: result, cleanUrl: cleanNip55CallbackUrl(url.toString()) }; 766 } 767 768 function nostrLoginDialogHtml(options) { 769 var opts = options && typeof options === 'object' ? options : {}; 770 var title = String(opts.title || 'Sign in'); 771 return '' + 772 '<div class="auth-modal" id="auth-modal" hidden>' + 773 '<div class="auth-modal-backdrop" data-close-auth-modal></div>' + 774 '<div class="auth-modal-panel" role="dialog" aria-modal="true" aria-labelledby="auth-modal-title">' + 775 '<button class="auth-modal-close" type="button" aria-label="Close login" data-close-auth-modal>×</button>' + 776 '<div class="auth-modal-scroll">' + 777 '<h2 id="auth-modal-title">' + escapeHtml(title) + '</h2>' + 778 '<div class="auth-platform-grid" role="list" aria-label="Signer platform">' + 779 authPlatformButton('auth-tab-register', 'register', 'desktop', true, 'desktop', 'Desktop') + 780 authPlatformButton('auth-tab-phone', 'phone', 'android', false, 'android', 'Android') + 781 authPlatformButton('', 'phone', 'ios', false, 'ios', 'iPhone') + 782 authPlatformButton('', 'phone', 'remote', false, 'remote', 'Remote Signer') + 783 '</div>' + 784 '<button id="auth-tab-manual" class="auth-advanced-toggle" type="button" data-auth-route="manual" data-auth-flavor="manual" aria-pressed="false">Advanced...</button>' + 785 '<div class="auth-tab-frame">' + 786 '<div id="auth-register-panel" class="auth-panel" role="tabpanel" aria-labelledby="auth-tab-register">' + 787 '<p class="auth-modal-help">Recommended: Firefox with nos2x-fox. Browser sign-in uses a NIP-07 extension.</p>' + 788 '<div class="auth-actions">' + 789 '<button id="auth-register-btn" class="auth-primary-btn" type="button">Continue with browser signer</button>' + 790 '<span class="auth-action-reco">Recommended: <a class="auth-inline-link" href="https://addons.mozilla.org/en-US/firefox/addon/nos2x-fox/" target="_blank" rel="noopener noreferrer">nos2x-fox</a></span>' + 791 '</div>' + 792 '</div>' + 793 '<div id="auth-phone-panel" class="auth-panel" role="tabpanel" aria-labelledby="auth-tab-phone" hidden>' + 794 '<div class="auth-nip46-pairing">' + 795 '<div id="auth-nip46-qr" class="auth-qr" aria-label="Nostr Connect QR code"></div>' + 796 '<div class="auth-nip46-controls">' + 797 '<ol class="auth-nip46-steps" aria-label="Phone signer login steps">' + 798 '<li><span>1</span><strong>Signer app</strong><small>App link or QR.</small></li>' + 799 '<li><span>2</span><strong>Pairing approval</strong><small>Return here after approval.</small></li>' + 800 '<li><span>3</span><strong>Login approval</strong><small>Sign-in finishes here.</small></li>' + 801 '</ol>' + 802 '<div class="auth-nip46-link-actions">' + 803 '<a id="auth-nip46-open" class="auth-secondary-btn auth-nip46-open-l
803ink" href="#">Connect Nostr</a>' + 804 '<button id="auth-nip46-uri-copy" class="machine-string-copy" type="button" aria-label="Copy Nostr Connect link" title="Copy Nostr Connect link">' + 805 '<svg class="machine-copy-icon" viewBox="0 0 24 24" aria-hidden="true" focusable="false">' + 806 '<path d="M8 7.5a2 2 0 0 1 2-2h7a2 2 0 0 1 2 2v9a2 2 0 0 1-2 2h-7a2 2 0 0 1-2-2z"></path>' + 807 '<path d="M5 15.5V5.75A2.75 2.75 0 0 1 7.75 3H15"></path>' + 808 '</svg>' + 809 '</button>' + 810 '</div>' + 811 '<p class="auth-nip46-uri" id="auth-nip46-uri" hidden></p>' + 812 '<p class="auth-nip46-diagnostics" id="auth-nip46-diagnostics" aria-live="polite"></p>' + 813 '<div class="auth-actions"><button id="auth-phone-btn" class="auth-primary-btn" type="button" disabled hidden>Continue sign-in</button></div>' + 814 '</div>' + 815 '</div>' + 816 '</div>' + 817 '<div id="auth-manual-panel" class="auth-panel" role="tabpanel" aria-labelledby="auth-tab-manual" hidden>' + 818 '<p class="auth-modal-help">Manual fallback: sign the challenge event outside this page and paste signed JSON.</p>' + 819 '<div class="auth-actions"><button id="auth-manual-start" class="auth-secondary-btn" type="button">Create challenge</button></div>' + 820 '<div class="auth-manual-grid">' + 821 '<label for="auth-manual-request-id"><strong>Request ID</strong></label><input class="auth-input" id="auth-manual-request-id" type="text" readonly>' + 822 '<label for="auth-manual-challenge"><strong>Challenge</strong></label><input class="auth-input" id="auth-manual-challenge" type="text" readonly>' + 823 '<label for="auth-manual-expires"><strong>Expires At (epoch)</strong></label><input class="auth-input" id="auth-manual-expires" type="text" readonly>' + 824 '</div>' + 825 '<label for="auth-manual-template"><strong>Unsigned Auth Event Template</strong></label>' + 826 '<textarea id="auth-manual-template" class="auth-input auth-key-input" readonly></textarea>' + 827 '<label for="auth-manual-event"><strong>Signed Auth Event JSON</strong></label>' + 828 '<textarea id="auth-manual-event" class="auth-input auth-key-input" placeholder=\'{"kind":22242,...,"sig":"..."}\'></textarea>' + 829 '<div class="auth-actions"><button id="auth-manual-submit" class="auth-primary-btn" type="button">Submit signed login</button></div>' + 830 '</div>' + 831 '</div>' + 832 '<div id="auth-modal-message" class="auth-modal-message" aria-live="polite"></div>' + 833 '<section class="auth-recommended-apps auth-reco-card" aria-labelledby="auth-reco-title">' + 834 '<h3 id="auth-reco-title"><span class="auth-apps-icon" aria-hidden="true">' + 835 '<svg viewBox="0 0 24 24" focusable="false"><rect x="4" y="4" width="6.2" height="6.2" rx="1.35"></rect><rect x="13.8" y="4" width="6.2" height="6.2" rx="1.35"></rect><rect x="4" y="13.8" width="6.2" height="6.2" rx="1.35"></rect><rect x="13.8" y="13.8" width="6.2" height="6.2" rx="1.35"></rect></svg>' + 836 '</span><span>Recommended Apps</span></h3>' + 837 '<div class="auth-reco-group auth-login-onboarding"><p id="auth-login-summary" class="auth-reco-summary"></p><p id="auth-login-note" class="auth-reco-note"></p><ul id="auth-login-apps" class="auth-reco-apps"></ul></div>' + 838 '<div class="auth-reco-group auth-zap-onboarding"><p id="auth-zap-summary" class="auth-reco-summary auth-zap-summary"></p><p id="auth-zap-note" class="auth-reco-note auth-zap-note"></p><ul id="auth-zap-apps" class="auth-reco-apps auth-zap-apps"></ul></div>' + 839 '<p class="auth-zap-updated">Recommendations Updated May 2026</p>' + 840 '</section>' + 841 '</div>' + 842 '</div>' + 843 '</div>'; 844 } 845 846 function authPlatformButton(id, route, flavor, active, icon, label) { 847 var idAttr = id ? ' id="' + id + '"' : ''; 848 return '<button' + idAttr + ' class="auth-platform-card' + (active ? ' is-active' : '') + '" type="button" data-auth-route="' + route + '" data-auth-flavor="' + flavor + '" aria-pressed="' + (active ? 'true' : 'false') + '">' + 849 '<span class="auth-platform-icon" aria-hidden="true">' + platformIconSvg(icon) + '</span>' + 850 '<span class="auth-platform-copy"><strong>' + escapeHtml(label) + '</strong></span>' + 851 '</button>'; 852 } 853 854 function platformIconSvg(icon) { 855 if (icon === 'android') return '<svg viewBox="0 0 24 24" focusable="false">
855<path d="M7 9.5h10v7.5a2 2 0 0 1-2 2H9a2 2 0 0 1-2-2z"></path><path d="M8.5 7 6.8 5.3"></path><path d="M15.5 7l1.7-1.7"></path><path d="M9 13h.01"></path><path d="M15 13h.01"></path></svg>'; 856 if (icon === 'ios') return '<svg viewBox="0 0 24 24" focusable="false"><path d="M9 3.5h6a2 2 0 0 1 2 2v13a2 2 0 0 1-2 2H9a2 2 0 0 1-2-2v-13a2 2 0 0 1 2-2z"></path><path d="M11 6h2"></path><path d="M12 17.8h.01"></path></svg>'; 857 if (icon === 'remote') return '<svg viewBox="0 0 24 24" focusable="false"><path d="M7 7.5h10"></path><path d="M7 16.5h10"></path><path d="M8 5a2.5 2.5 0 1 1 0 5 2.5 2.5 0 0 1 0-5z"></path><path d="M16 14a2.5 2.5 0 1 1 0 5 2.5 2.5 0 0 1 0-5z"></path></svg>'; 858 return '<svg viewBox="0 0 24 24" focusable="false"><path d="M5 6.5h14v9H5z"></path><path d="M9 19h6"></path><path d="M12 15.5V19"></path></svg>'; 859 } 860 861 function ensureNostrLoginDialog(target, options) { 862 var doc = target || (typeof document !== 'undefined' ? document : null); 863 if (!doc) return null; 864 var existing = doc.getElementById('auth-modal'); 865 if (existing) return existing; 866 var wrap = doc.createElement('div'); 867 wrap.innerHTML = nostrLoginDialogHtml(options); 868 var modal = wrap.firstChild; 869 var anchor = doc.querySelector('script[src*="citrine-nostr-web.js"]') || doc.body.firstChild; 870 if (anchor && anchor.parentNode) { 871 anchor.parentNode.insertBefore(modal, anchor); 872 } else if (doc.body) { 873 doc.body.appendChild(modal); 874 } 875 return modal; 876 } 877 878 function escapeHtml(value) { 879 return String(value || '').replace(/[&<>"']/g, function (char) { 880 return ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[char]; 881 }); 882 } 883 884 function signInHelperMessage(tabName) { 885 var base = 'Choose a sign-in method. Your Nostr public key is your account, and the site never asks for a private key. First successful sign-in creates your account automatically. You can change your username after you log in.'; 886 var tab = String(tabName || 'register'); 887 if (tab === 'phone') return base + ' Connect Nostr with the link or QR. Sign-in continues after pairing.'; 888 if (tab === 'manual') return base + ' Create a challenge, then paste the signed event JSON.'; 889 return base; 890 } 891 892 function recommendationPlatformLabel(tabName, flavor) { 893 var tab = String(tabName || 'register'); 894 var key = String(flavor || '').trim(); 895 if (tab === 'phone' && key === 'android') return 'Android'; 896 if (tab === 'phone' && key === 'ios') return 'iPhone / iPad'; 897 if (tab === 'phone' && key === 'remote') return 'Remote signer'; 898 if (tab === 'manual') return 'Manual login'; 899 return 'Desktop'; 900 } 901 902 function nostrLoginRecommendation(tabName, flavor) { 903 var tab = String(tabName || 'register'); 904 var key = String(flavor || '').trim(); 905 var platformLabel = recommendationPlatformLabel(tab, key); 906 var amberFDroid = { source: 'F-Droid', label: 'Download Amber', url: 'https://f-droid.org/packages/com.greenart7c3.nostrsigner/' }; 907 if (tab === 'phone' && key === 'ios') return recommendation(platformLabel + ' login', 'Recommended for iPhone and iPad Nostr Connect login.', [{ iconKey: 'nostr-connect', name: 'Nostr Connect-compatible signer', platformLabel: platformLabel, purpose: 'Login via Nostr', url: 'https://github.com/nostr-protocol/nips/blob/master/46.md', stores: [{ source: 'NIP-46', label: 'Protocol details', url: 'https://github.com/nostr-protocol/nips/blob/master/46.md' }] }]); 908 if (tab === 'phone' && key === 'remote') return recommendation(platformLabel + ' login', 'Recommended for remote Nostr Connect signers.', [{ iconKey: 'nostr-connect', name: 'Nostr Connect remote signer', platformLabel: platformLabel, purpose: 'Login via Nostr', url: 'https://github.com/nostr-protocol/nips/blob/master/46.md', stores: [{ source: 'NIP-46', label: 'Protocol details', url: 'https://github.com/nostr-protocol/nips/blob/master/46.md' }] }]); 909 if (tab === 'phone') return recommendation(platformLabel + ' login', 'Recommended for Android Nostr Connect login.', [{ iconKey: 'amber', name: 'Amber', platformLabel: platformLabel, purpose: 'Login via Nostr', url: 'https://github.com/greenart7c3/Amber', stores: [amberFDroid] }]); 910 if (tab === 'manual') return recommendation(platformLabel, 'Fallback for signing the login challenge outside this page.', [{ iconKey: 'signed-challenge', name: 'Signed challenge', platformLabel: 'Any platform', purpose: 'Login via Nostr', url: 'https://github.com/nostr-protocol/nips/blob/master/98.md', stores: [{ source: 'NIP-98', label: 'Protocol details', url: 'https://github.com/nostr-protocol/nips/blob/master/98.md' }] }]); 911 return recommendation(platformLabel + ' login', 'Recommended for desktop browser sign-in.', [{ iconKey: 'nos2x', name: 'nos2x-fox', platformLabel: 'Desktop Firefox', purpose: 'Login via Nostr', url: 'https://addons.mozilla.org/en-US/firefox/addon/nos2x-fox/', stores: [{ source: 'Firefox Add-ons', label: 'Download nos2x-fox', url: 'https://addons.mozilla.org/en-US/firefox/addon/nos2x-fox/' }] }]); 912 } 913 914 function nostrZapRecommendation(tabName, flavor) { 915 var tab = String(tabName || 'register'); 916 var key = String(flavor || '').trim(); 917 var platformLabel = recommendationPlatformLabel(tab, key); 918 var amethystDownload = { source: 'GitHub', label: 'Download Amethyst', url: 'https://github.com/vitorpamplona/amethyst#installation' }; 919 var zeusDownload = { source: 'ZEUS', label: 'Download ZEUS', url: 'https://github.com/ZeusLN/zeus#app-store-links' }; 920 var auroraStore = { source: 'Aurora', label: 'Download Aurora Store', url: 'https://auroraoss.com/downloads/AuroraStore/' }; 921 if (tab === 'phone' && key === 'ios') return recommendation(platformLabel + ' zaps', 'Recommended for sending zaps from iPhone and iPad.', [ 922 { iconKey: 'damus', name: 'Damus', platformLabel: platformLabel, purpose: 'Zaps: Nostr client', url: 'https://damus.io/', stores: [{ source: 'App Store', label: 'Download Damus', url: 'https://apps.apple.com/us/app/damus/id1628663131' }] }, 923 { iconKey: 'nostur', name: 'Nostur', platformLabel: platformLabel, purpose: 'Zaps: Nostr client', url: 'https://nostur.com/', stores: [{ source: 'App Store', label: 'Download Nostur', url: 'https://nostur.com/appstore' }] }, 924 { iconKey: 'zeus', name: 'ZEUS', platformLabel: platformLabel, purpose: 'Zaps: Lightning wallet', url: 'https://github.com/ZeusLN/zeus#app-store-links', stores: [{ source: 'App Store', label: 'Download ZEUS', url: 'https://apps.apple.com/us/app/zeus-ln/id1456038895' }] } 925 ]); 926 if (tab === 'phone' && key === 'remote') return recommendation(platformLabel + ' zaps', 'Recommended when the signer is remote or the current platform is unknown.', [{ iconKey: 'zeus', name: 'ZEUS', platformLabel: 'Remote signer', purpose: 'Zaps: Lightning wallet', url: 'https://github.com/ZeusLN/zeus#app-store-links', stores: [zeusDownload, auroraStore] }]); 927 if (tab === 'phone') return recommendation(platformLabel + ' zaps', 'Recommended for sending zaps from Android.', [ 928 { iconKey: 'amethyst', name: 'Amethyst', platformLabel: platformLabel, purpose: 'Zaps: Nostr client', url: 'https://github.com/vitorpamplona/amethyst#installation', stores: [amethystDownload, auroraStore] }, 929 { iconKey: 'zeus', name: 'ZEUS', platformLabel: platformLabel, purpose: 'Zaps: Lightning wallet', url: 'https://github.com/ZeusLN/zeus#app-store-links', stores: [zeusDownload, auroraStore] } 930 ]); 931 if (tab === 'manual') return recommendation('Manual login zaps', 'Zap recommendations are separate from manual login and can use any compatible wallet.', [{ iconKey: 'zeus', name: 'ZEUS', platformLabel: 'Any platform', purpose: 'Zaps: Lightning wallet', url: 'https://github.com/ZeusLN/zeus#app-store-links', stores: [zeusDownload, auroraStore] }]); 932 return recommendation(platformLabel + ' zaps', 'Recommended for desktop or browser-based zap flows.', [{ iconKey: 'zeus', name: 'ZEUS', platformLabel: 'Desktop / web', purpose: 'Zaps: Lightning wallet', url: 'https://github.com/ZeusLN/zeus#app-store-links', stores: [zeusDownload, auroraStore] }]); 933 } 934 935 function recommendation(summary, note, apps) {
936 return { summary: summary, note: note, apps: apps || [] }; 937 } 938 939 function renderNostrRecommendations(options) { 940 var opts = options && typeof options === 'object' ? options : {}; 941 renderRecommendationList(opts.loginSummary, opts.loginApps, opts.loginNote, nostrLoginRecommendation(opts.tab, opts.flavor), opts.iconAssets || {}); 942 renderRecommendationList(opts.zapSummary, opts.zapApps, opts.zapNote, nostrZapRecommendation(opts.tab, opts.flavor), opts.iconAssets || {}); 943 } 944 945 function renderRecommendationList(summaryEl, appsEl, noteEl, recommendationData, iconAssets) { 946 if (!summaryEl || !appsEl) return; 947 summaryEl.textContent = recommendationData.summary || 'Install:'; 948 if (noteEl) noteEl.textContent = recommendationData.note || ''; 949 appsEl.innerHTML = ''; 950 recommendationData.apps.forEach(function (app) { 951 var doc = appsEl.ownerDocument || document; 952 var item = doc.createElement('li'); 953 var appLink = doc.createElement('a'); 954 var icon = doc.createElement('span'); 955 var label = doc.createElement('span'); 956 var name = doc.createElement('strong'); 957 var purpose = doc.createElement('span'); 958 var platform = doc.createElement('span'); 959 var stores = doc.createElement('span'); 960 appLink.className = 'auth-reco-app-link'; 961 appLink.href = app.url; 962 appLink.target = '_blank'; 963 appLink.rel = 'noopener noreferrer'; 964 icon.className = 'auth-reco-app-icon'; 965 renderRecommendationIcon(icon, app, iconAssets); 966 label.className = 'auth-reco-app-label'; 967 name.textContent = app.name; 968 purpose.className = 'auth-reco-app-purpose'; 969 purpose.textContent = app.purpose || recommendationData.purpose || ''; 970 platform.className = 'auth-reco-platform'; 971 platform.textContent = app.platformLabel || recommendationData.platformLabel || ''; 972 label.appendChild(name); 973 if (purpose.textContent) label.appendChild(purpose); 974 if (platform.textContent) label.appendChild(platform); 975 appLink.appendChild(icon); 976 appLink.appendChild(label); 977 stores.className = 'auth-reco-store-links'; 978 (app.stores || []).forEach(function (store, idx) { 979 var link = doc.createElement('a'); 980 var source = String(store.source || '').trim(); 981 link.href = store.url; 982 link.target = '_blank'; 983 link.rel = 'noopener noreferrer'; 984 link.textContent = store.label; 985 if (source) stores.appendChild(doc.createTextNode(source + ': ')); 986 stores.appendChild(link); 987 if (idx < app.stores.length - 1) stores.appendChild(doc.createTextNode(' / ')); 988 }); 989 item.appendChild(appLink); 990 item.appendChild(stores); 991 appsEl.appendChild(item); 992 }); 993 } 994 995 function renderRecommendationIcon(icon, app, iconAssets) { 996 var asset = iconAssets && iconAssets[app.iconKey] ? iconAssets[app.iconKey] : ''; 997 icon.textContent = ''; 998 if (asset) { 999 var img = (icon.ownerDocument || document).createElement('img'); 1000 img.className = 'auth-reco-app-img'; 1001 img.src = asset; 1002 img.alt = ''; 1003 img.loading = 'lazy'; 1004 img.decoding = 'async'; 1005 icon.appendChild(img); 1006 return; 1007 } 1008 icon.innerHTML = recommendationIconSvg(app.iconKey); 1009 } 1010 1011 function recommendationIconSvg(iconKey) { 1012 var key = String(iconKey || '').trim(); 1013 var icons = { 1014 'nostr-connect': '<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="M7 7.5h10"></path><path d="M7 16.5h10"></path><circle cx="7" cy="7.5" r="2.3"></circle><circle cx="17" cy="16.5" r="2.3"></circle></svg>', 1015 'signed-challenge': '<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="M8.5 8.5 5 12l3.5 3.5"></path><path d="M15.5 8.5 19 12l-3.5 3.5"></path><path d="m13.5 7-3 10"></path></svg>', 1016 'fallback': '<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="M12 3 20 10l-8 11-8-11z"></path><path d="M4 10h16"></path><path d="M8 10l4 11 4-11"></path><path d="M8 10l4-7 4 7"></path></svg>' 1017 }; 1018 return icons[key] || icons.fallback; 1019 } 1020 1021 function lud16ToUrl(lud16) {
1022 var value = String(lud16 || '').trim().toLowerCase(); 1023 var parts = value.split('@'); 1024 if (parts.length !== 2 || !parts[0] || !parts[1]) throw new Error('Lightning address must look like [email protected].'); 1025 return 'https://' + parts[1] + '/.well-known/lnurlp/' + encodeURIComponent(parts[0]); 1026 } 1027 1028 function bytesFromText(text) { 1029 if (typeof TextEncoder !== 'undefined') return Array.prototype.slice.call(new TextEncoder().encode(String(text || ''))); 1030 return String(text || '').split('').map(function (char) { return char.charCodeAt(0) & 255; }); 1031 } 1032 1033 function convertBits(data, fromBits, toBits, pad) { 1034 var acc = 0; 1035 var bits = 0; 1036 var ret = []; 1037 var maxv = (1 << toBits) - 1; 1038 var maxAcc = (1 << (fromBits + toBits - 1)) - 1; 1039 for (var i = 0; i < data.length; i += 1) { 1040 var value = data[i]; 1041 if (value < 0 || (value >> fromBits) !== 0) return []; 1042 acc = ((acc << fromBits) | value) & maxAcc; 1043 bits += fromBits; 1044 while (bits >= toBits) { 1045 bits -= toBits; 1046 ret.push((acc >> bits) & maxv); 1047 } 1048 } 1049 if (pad) { 1050 if (bits > 0) ret.push((acc << (toBits - bits)) & maxv); 1051 } else if (bits >= fromBits || ((acc << (toBits - bits)) & maxv)) { 1052 return []; 1053 } 1054 return ret; 1055 } 1056 1057 function bech32Polymod(values) { 1058 var generators = [0x3b6a57b2, 0x26508e6d, 0x1ea119fa, 0x3d4233dd, 0x2a1462b3]; 1059 var chk = 1; 1060 for (var i = 0; i < values.length; i += 1) { 1061 var top = chk >> 25; 1062 chk = ((chk & 0x1ffffff) << 5) ^ values[i]; 1063 for (var j = 0; j < generators.length; j += 1) { 1064 if ((top >> j) & 1) chk ^= generators[j]; 1065 } 1066 } 1067 return chk; 1068 } 1069 1070 function bech32HrpExpand(hrp) { 1071 var out = []; 1072 for (var i = 0; i < hrp.length; i += 1) out.push(hrp.charCodeAt(i) >> 5); 1073 out.push(0); 1074 for (var j = 0; j < hrp.length; j += 1) out.push(hrp.charCodeAt(j) & 31); 1075 return out; 1076 } 1077 1078 function bech32Encode(hrp, text) { 1079 var charset = 'qpzry9x8gf2tvdw0s3jn54khce6mua7l'; 1080 var words = convertBits(bytesFromText(text), 8, 5, true); 1081 if (!words.length) throw new Error('Could not encode LNURL.'); 1082 var values = bech32HrpExpand(hrp).concat(words).concat([0, 0, 0, 0, 0, 0]); 1083 var polymod = bech32Polymod(values) ^ 1; 1084 var combined = words.slice(); 1085 for (var i = 0; i < 6; i += 1) combined.push((polymod >> (5 * (5 - i))) & 31); 1086 var out = hrp + '1'; 1087 combined.forEach(function (value) { out += charset.charAt(value); }); 1088 return out; 1089 } 1090 1091 function fetchJson(url, options) { 1092 var fetchFn = options && typeof options.fetch === 'function' ? options.fetch : (typeof fetch === 'function' ? fetch : null); 1093 if (!fetchFn) return Promise.reject(new Error('Fetch is not available.')); 1094 return fetchFn(url, { method: 'GET', credentials: 'omit', cache: 'no-store' }).then(function (res) { 1095 return res.text().then(function (text) { 1096 var json = parseJson(text); 1097 if (!res.ok) throw new Error(json && json.reason ? json.reason : ('HTTP ' + String(res.status))); 1098 if (!json || typeof json !== 'object') throw new Error('Expected a JSON response.'); 1099 return json; 1100 }); 1101 }); 1102 } 1103 1104 function resolveZapCallbackUrl(callback, payUrl) { 1105 var raw = String(callback || '').trim(); 1106 if (!raw) throw new Error('Lightning provider did not return a callback URL.'); 1107 try { 1108 return new URL(raw, payUrl).toString(); 1109 } catch (_err) { 1110 throw new Error('Lightning provider returned an invalid callback URL.'); 1111 } 1112 } 1113 1114 function loadLnurlZapInfo(lud16, options) { 1115 var payUrl = lud16ToUrl(lud16); 1116 return fetchJson(payUrl, options).then(function (data) { 1117 var nostrPubkey = normalizePubkeyHex(data.nostrPubkey || ''); 1118 if (data.allowsNostr !== true || !nostrPubkey) throw new Error('Lightning provider does not advertise Nostr zap support.'); 1119 return { 1120 payUrl: payUrl, 1121 callback: resolveZapCallbackUrl(data.callback, payUrl), 1122 encodedLnurl: bech32Encode('lnurl', payUrl), 1123 nostrPubkey: nostrPubkey, 1124 minSendable: Number(data.minSendable || 0), 1125 maxSendable: Number(data.maxSendable || 0), 1126 commentAllowed: Number(data.commentAllowed || 0) 1127 }; 1128 }); 1129 } 1130 1131 function createZapRequestTemplate(options) { 1132 var opts = options && typeof options === 'object' ? options : {}; 1133 var lnurlInfo = opts.lnurlInfo || {}; 1134 var zapConfig = opts.zapConfig || {}; 1135 var target = opts.target || {}; 1136 var amountMsats = Number(opts.amountMsats || 0); 1137 var recipientPubkey = normalizePubkeyHex(lnurlInfo.nostrPubkey || ''); 1138 if (!recipientPubkey) throw new Error('Lightning provider returned an invali
1138d Nostr recipient pubkey.'); 1139 var tags = [ 1140 ['relays'].concat(zapConfig.relays || []), 1141 ['amount', String(amountMsats)], 1142 ['lnurl', String(lnurlInfo.encodedLnurl || '')], 1143 ['p', recipientPubkey] 1144 ]; 1145 if (target.eventId) tags.push(['e', String(target.eventId)]); 1146 if (target.address) tags.push(['a', String(target.address)]); 1147 if (target.kind) tags.push(['k', String(target.kind)]); 1148 var template = { 1149 kind: 9734, 1150 created_at: Number(opts.createdAt || Math.floor(Date.now() / 1000)), 1151 content: String(opts.note || ''), 1152 tags: tags 1153 }; 1154 var pubkey = normalizePubkeyHex(opts.pubkey || ''); 1155 if (pubkey) template.pubkey = pubkey; 1156 return template; 1157 } 1158 1159 function createSignedZapRequest(options) { 1160 var opts = options && typeof options === 'object' ? options : {}; 1161 var signer = opts.signer || null; 1162 if (!signer || typeof signer.signEvent !== 'function') return Promise.reject(new Error('A Nostr signer is required.')); 1163 return Promise.resolve(typeof signer.getPublicKey === 'function' ? signer.getPublicKey() : '').catch(function () { 1164 return ''; 1165 }).then(function (pubkey) { 1166 var template = createZapRequestTemplate(Object.assign({}, opts, { pubkey: pubkey })); 1167 return signer.signEvent(template); 1168 }).then(function (signed) { 1169 var normalized = typeof signed === 'string' ? parseJson(signed) : signed; 1170 if (!normalized || typeof normalized !== 'object') throw new Error('Signer returned an invalid zap request.'); 1171 return normalized; 1172 }); 1173 } 1174 1175 function requestZapInvoice(options) { 1176 var opts = options && typeof options === 'object' ? options : {}; 1177 var lnurlInfo = opts.lnurlInfo || {}; 1178 var url = new URL(lnurlInfo.callback); 1179 url.searchParams.set('amount', String(Number(opts.amountMsats || 0))); 1180 if (opts.signedEvent) { 1181 url.searchParams.set('nostr', JSON.stringify(opts.signedEvent)); 1182 url.searchParams.set('lnurl', String(lnurlInfo.encodedLnurl || '')); 1183 } else if (opts.note && Number(lnurlInfo.commentAllowed || 0) > 0) { 1184 url.searchParams.set('comment', String(opts.note || '').slice(0, Number(lnurlInfo.commentAllowed || 0))); 1185 } 1186 return fetchJson(url.toString(), opts).then(function (data) { 1187 var invoice = String(data.pr || '').trim(); 1188 if (!invoice) throw new Error((data && data.reason) || 'Lightning provider did not return an invoice.'); 1189 return invoice; 1190 }); 1191 } 1192 1193 function createZapInvoice(options) { 1194 var opts = options && typeof options === 'object' ? options : {}; 1195 return loadLnurlZapInfo(opts.lud16, opts).then(function (lnurlInfo) { 1196 var amountMsats = Number(opts.amountMsats || Number(opts.sats || 0) * 1000); 1197 if ((lnurlInfo.minSendable > 0 && amountMsats < lnurlInfo.minSendable) || (lnurlInfo.maxSendable > 0 && amountMsats > lnurlInfo.maxSendable)) { 1198 throw new Error('Amount must be between ' + String(Math.ceil(lnurlInfo.minSendable / 1000)) + ' and ' + String(Math.floor(lnurlInfo.maxSendable / 1000)) + ' sats for this wallet.'); 1199 } 1200 if (!opts.signer) { 1201 return requestZapInvoice(Object.assign({}, opts, { lnurlInfo: lnurlInfo, amountMsats: amountMsats })).then(function (invoice) { 1202 return { invoice: invoice, signed: false, lnurlInfo: lnurlInfo }; 1203 }); 1204 } 1205 return createSignedZapRequest(Object.assign({}, opts, { lnurlInfo: lnurlInfo, amountMsats: amountMsats })).then(function (signedEvent) { 1206 return requestZapInvoice(Object.assign({}, opts, { lnurlInfo: lnurlInfo, amountMsats: amountMsats, signedEvent: signedEvent })).then(function (invoice) { 1207 return { invoice: invoice, signed: true, signedEvent: signedEvent, lnurlInfo: lnurlInfo }; 1208 }); 1209 }); 1210 }); 1211 } 1212 1213 function payLightningInvoiceWithWebLN(invoice, target) { 1214 var root = target || (typeof window !== 'undefined' ? window : {}); 1215 var webln = root.webln || null; 1216 var value = String(invoice || '').trim(); 1217 if (!value) return Promise.reject(new Error('Lightning invoice is required.')); 1218 if (!webln || typeof webln.sendPayment !== 'function') return Promise.reject(new Error('WebLN is not available.')); 1219 return Promise.resolve(typeof webln.enable === 'function' ? webln.enable() : null).then(function () { 1220 return webln.sendPayment(value); 1221 }); 1222 } 1223 1224 function copyTextToClipboard(text, target) { 1225 var root = target || (typeof window !== 'undefined' ? window : {});
1226 var value = String(text || ''); 1227 if (root.navigator && root.navigator.clipboard && typeof root.navigator.clipboard.writeText === 'function') { 1228 return root.navigator.clipboard.writeText(value).then(function () { return value; }); 1229 } 1230 return Promise.reject(new Error('Clipboard is not available.')); 1231 } 1232 1233 function createZapFlow(options) { 1234 var defaults = options && typeof options === 'object' ? options : {}; 1235 return { 1236 createInvoice: function (nextOptions) { 1237 return createZapInvoice(Object.assign({}, defaults, nextOptions || {})); 1238 }, 1239 payInvoice: function (invoice, target) { 1240 return payLightningInvoiceWithWebLN(invoice, target || defaults.target); 1241 }, 1242 copyInvoice: function (invoice, target) { 1243 return copyTextToClipboard(invoice, target || defaults.target); 1244 }, 1245 signerIsAvailable: function (signer) { 1246 return signerIsAvailable(signer || defaults.signer); 1247 } 1248 }; 1249 } 1250 1251 var flat = { 1252 NIP46_KIND: NIP46_KIND, 1253 DEFAULT_RETRY_DELAYS: DEFAULT_RETRY_DELAYS.slice(), 1254 normalizePubkeyHex: normalizePubkeyHex, 1255 normalizeNostrPubkey: normalizeNostrPubkey, 1256 bytesToHex: bytesToHex, 1257 hexToBytes: hexToBytes, 1258 randomHex: randomHex, 1259 buildNostrConnectUri: buildNostrConnectUri, 1260 extractConnectSecret: extractConnectSecret, 1261 isConnectAck: isConnectAck, 1262 createAuthEventTemplate: createAuthEventTemplate, 1263 signAuthChallenge: signAuthChallenge, 1264 withTimedOutRetry: withTimedOutRetry, 1265 createSessionStorageAdapter: createSessionStorageAdapter, 1266 createMemoryStorageAdapter: createMemoryStorageAdapter, 1267 hasNostrTools: hasNostrTools, 1268 waitForNostrTools: waitForNostrTools, 1269 createNip46Client: createNip46Client, 1270 getNip07Signer: getNip07Signer, 1271 createSharedNostrSigner: createSharedNostrSigner, 1272 classifySignerError: classifySignerError, 1273 signerUnavailableError: signerUnavailableError, 1274 signerIsAvailable: signerIsAvailable, 1275 bindSignerReturnRefresh: bindSignerReturnRefresh, 1276 cleanNip55CallbackUrl: cleanNip55CallbackUrl, 1277 buildNip55CallbackUrl: buildNip55CallbackUrl, 1278 buildNip55Uri: buildNip55Uri, 1279 parseNip55Callback: parseNip55Callback, 1280 nostrLoginDialogHtml: nostrLoginDialogHtml, 1281 ensureNostrLoginDialog: ensureNostrLoginDialog, 1282 signInHelperMessage: signInHelperMessage, 1283 recommendationPlatformLabel: recommendationPlatformLabel, 1284 nostrLoginRecommendation: nostrLoginRecommendation, 1285 nostrZapRecommendation: nostrZapRecommendation, 1286 renderNostrRecommendations: renderNostrRecommendations, 1287 lud16ToUrl: lud16ToUrl, 1288 bech32Encode: bech32Encode, 1289 loadLnurlZapInfo: loadLnurlZapInfo, 1290 createZapRequestTemplate: createZapRequestTemplate, 1291 createSignedZapRequest: createSignedZapRequest, 1292 requestZapInvoice: requestZapInvoice, 1293 createZapInvoice: createZapInvoice, 1294 payLightningInvoiceWithWebLN: payLightningInvoiceWithWebLN, 1295 copyTextToClipboard: copyTextToClipboard, 1296 createZapFlow: createZapFlow 1297 }; 1298 1299 var nostr = { 1300 NIP46_KIND: flat.NIP46_KIND, 1301 DEFAULT_RETRY_DELAYS: flat.DEFAULT_RETRY_DELAYS.slice(), 1302 normalizePubkeyHex: normalizePubkeyHex, 1303 normalizeNostrPubkey: normalizeNostrPubkey, 1304 bytesToHex: bytesToHex, 1305 hexToBytes: hexToBytes, 1306 randomHex: randomHex, 1307 buildNostrConnectUri: buildNostrConnectUri, 1308 extractConnectSecret: extractConnectSecret, 1309 isConnectAck: isConnectAck, 1310 createAuthEventTemplate: createAuthEventTemplate, 1311 signAuthChallenge: signAuthChallenge, 1312 withTimedOutRetry: withTimedOutRetry, 1313 createSessionStorageAdapter: createSessionStorageAdapter, 1314 createMemoryStorageAdapter: createMemoryStorageAdapter, 1315 hasNostrTools: hasNostrTools, 1316 waitForNostrTools: waitForNostrTools, 1317 createNip46Client: createNip46Client, 1318 getNip07Signer: getNip07Signer, 1319 createSharedNostrSigner: createSharedNostrSigner, 1320 classifySignerError: classifySignerError, 1321 signerUnavailableError: signerUnavailableError, 1322 signerIsAvailable: signerIsAvailable, 1323 bindSignerReturnRefresh: bindSignerReturnRefresh, 1324 cleanNip55CallbackUrl: cleanNip55CallbackUrl, 1325 buildNip55CallbackUrl: buildNip55CallbackUrl, 1326 buildNip55Uri: buildNip55Uri, 1327 parseNip55Callback: parseNip55Callback 1328 }; 1329 1330 var zaps = { 1331 lud16ToUrl: lud16ToUrl, 1332 bech32Encode: bech32Encode, 1333 loadLnurlZapInfo: loadLnurlZapInfo, 1334 createZapRequestTemplate: createZapRequestTemplate, 1335 createSignedZapRequest: createSignedZapRequest, 1336 requestZapInvoice: requestZapInvoice, 1337 createZapInvoice: createZapInvoice, 1338 payLightningInvoiceWithWebLN: payLightningInvoiceWithWebLN, 1339 copyTextToClipboard: copyTextToClipboard, 1340 createZapFlow: createZapFlow, 1341 nostrZapRecommendation: nostrZapRecommendation 1342 }; 1343 1344 var web = { 1345 nostrLoginDialogHtml: nostrLoginDialogHtml, 1346 ensureNostrLoginDialog: ensureNostrLoginDialog,
1347 signInHelperMessage: signInHelperMessage, 1348 recommendationPlatformLabel: recommendationPlatformLabel, 1349 nostrLoginRecommendation: nostrLoginRecommendation, 1350 nostrZapRecommendation: nostrZapRecommendation, 1351 renderNostrRecommendations: renderNostrRecommendations, 1352 payLightningInvoiceWithWebLN: payLightningInvoiceWithWebLN, 1353 copyTextToClipboard: copyTextToClipboard 1354 }; 1355 1356 return Object.assign({}, flat, { 1357 nostr: nostr, 1358 zaps: zaps, 1359 web: web, 1360 legacy: flat 1361 }); 1362}));
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.