PageSourceSearch

https://andersaamodt.com/static/vendor/citrine-nostr-web.js?v=20260622-citrine2

js andersaamodt.com collected 2026-10-08 10:31:39 UTC 64,616 bytes, 1,362 lines download raw bytes

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>&times;</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 ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[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.