background.ts raw
1 /* eslint-disable @typescript-eslint/no-explicit-any */
2 import {
3 backgroundLogNip07Action,
4 backgroundLogPermissionStored,
5 NostrHelper,
6 NwcClient,
7 NwcConnection_DECRYPTED,
8 WeblnMethod,
9 Nip07Method,
10 GetInfoResponse,
11 SendPaymentResponse,
12 RequestInvoiceResponse,
13 } from '@common';
14 import {
15 BackgroundRequestMessage,
16 checkPermissions,
17 checkWeblnPermissions,
18 debug,
19 getBrowserSessionData,
20 getPosition,
21 handleUnlockRequest,
22 isWeblnMethod,
23 nip04Decrypt,
24 nip04Encrypt,
25 nip44Decrypt,
26 nip44Encrypt,
27 openUnlockPopup,
28 PromptResponse,
29 PromptResponseMessage,
30 shouldRecklessModeApprove,
31 signEvent,
32 storePermission,
33 UnlockRequestMessage,
34 UnlockResponseMessage,
35 } from './background-common';
36 import {
37 isMlsMethod,
38 mlsInit,
39 mlsSendDM,
40 mlsSubscribe,
41 mlsPublishKP,
42 mlsListGroups,
43 mlsDeliverEvent,
44 mlsHandleEvent,
45 mlsSetTab,
46 mlsBackupGroups,
47 mlsRestoreGroups,
48 mlsRatchetGroup,
49 } from './mls-engine';
50 import browser from 'webextension-polyfill';
51 import { Buffer } from 'buffer';
52
53 // Clear stale session data on extension install/update/reload.
54 // Session storage can survive reloads in Firefox, causing the vault
55 // to appear unlocked without the user entering their password.
56 browser.runtime.onInstalled.addListener(async () => {
57 debug('Extension installed/updated — clearing session storage');
58 await browser.storage.session.clear();
59 });
60
61 // Cache for NWC clients to avoid reconnecting for each request
62 const nwcClientCache = new Map<string, NwcClient>();
63
64 /**
65 * Get or create an NWC client for a connection
66 */
67 async function getNwcClient(connection: NwcConnection_DECRYPTED): Promise<NwcClient> {
68 const cached = nwcClientCache.get(connection.id);
69 if (cached && cached.isConnected()) {
70 return cached;
71 }
72
73 const client = new NwcClient({
74 walletPubkey: connection.walletPubkey,
75 relayUrl: connection.relayUrl,
76 secret: connection.secret,
77 });
78
79 await client.connect();
80 nwcClientCache.set(connection.id, client);
81 return client;
82 }
83
84 /**
85 * Parse invoice amount from a BOLT11 invoice string
86 * Returns amount in satoshis, or undefined if no amount specified
87 */
88 function parseInvoiceAmount(invoice: string): number | undefined {
89 try {
90 // BOLT11 invoices start with 'ln' followed by network prefix and amount
91 // Format: ln[network][amount][multiplier]1[data]
92 // Examples: lnbc1500n1... (1500 sat), lnbc1m1... (0.001 BTC = 100000 sat)
93 const match = invoice.toLowerCase().match(/^ln(bc|tb|tbs|bcrt)(\d+)([munp])?1/);
94 if (!match) {
95 return undefined;
96 }
97
98 const amountStr = match[2];
99 const multiplier = match[3];
100
101 let amount = parseInt(amountStr, 10);
102
103 // Apply multiplier (amount is in BTC by default)
104 switch (multiplier) {
105 case 'm': // milli-bitcoin (0.001 BTC)
106 amount = amount * 100000;
107 break;
108 case 'u': // micro-bitcoin (0.000001 BTC)
109 amount = amount * 100;
110 break;
111 case 'n': // nano-bitcoin (0.000000001 BTC) = 0.1 sat
112 amount = Math.floor(amount / 10);
113 break;
114 case 'p': // pico-bitcoin (0.000000000001 BTC) = 0.0001 sat
115 amount = Math.floor(amount / 10000);
116 break;
117 default:
118 // No multiplier means BTC
119 amount = amount * 100000000;
120 }
121
122 return amount;
123 } catch {
124 return undefined;
125 }
126 }
127
128 type Relays = Record<string, { read: boolean; write: boolean }>;
129
130 // ==========================================
131 // Permission Prompt Queue System (P0)
132 // ==========================================
133
134 // Timeout for permission prompts (30 seconds)
135 const PROMPT_TIMEOUT_MS = 30000;
136
137 // Maximum number of queued permission requests (prevent DoS)
138 const MAX_PERMISSION_QUEUE_SIZE = 100;
139
140 // Track open prompts with metadata for cleanup
141 const openPrompts = new Map<
142 string,
143 {
144 resolve: (response: PromptResponse) => void;
145 reject: (reason?: any) => void;
146 windowId?: number;
147 timeoutId?: ReturnType<typeof setTimeout>;
148 }
149 >();
150
151 // Track if unlock popup is already open
152 let unlockPopupOpen = false;
153 let unlockPopupWindowId: number | undefined;
154
155 // Queue of pending NIP-07 requests waiting for unlock
156 const pendingRequests: {
157 request: BackgroundRequestMessage;
158 resolve: (result: any) => void;
159 reject: (error: any) => void;
160 }[] = [];
161
162 // Queue for permission requests (only one prompt shown at a time)
163 interface PermissionQueueItem {
164 id: string;
165 url: string;
166 width: number;
167 height: number;
168 resolve: (response: PromptResponse) => void;
169 reject: (reason?: any) => void;
170 }
171
172 const permissionQueue: PermissionQueueItem[] = [];
173 let activePromptId: string | null = null;
174
175 /**
176 * Show the next permission prompt from the queue.
177 * Re-checks permissions before opening — if a prior "always" response
178 * already covers this request, auto-resolve it and skip to the next.
179 */
180 async function showNextPermissionPrompt(): Promise<void> {
181 while (!activePromptId && permissionQueue.length > 0) {
182 const next = permissionQueue[0];
183
184 // Re-check: a prior "always" may already cover this queued request.
185 const covered = await isQueuedRequestCovered(next);
186 if (covered !== undefined) {
187 // Auto-resolve without opening a window.
188 permissionQueue.shift();
189 const promptData = openPrompts.get(next.id);
190 if (promptData) {
191 if (promptData.timeoutId) clearTimeout(promptData.timeoutId);
192 promptData.resolve(covered ? 'approve-once' : 'reject-once');
193 openPrompts.delete(next.id);
194 }
195 debug(`Auto-resolved queued prompt ${next.id} (permission already ${covered ? 'allowed' : 'denied'})`);
196 continue; // check next item
197 }
198
199 // No stored permission — show the prompt window.
200 activePromptId = next.id;
201
202 const { top, left } = await getPosition(next.width, next.height);
203
204 try {
205 const window = await browser.windows.create({
206 type: 'popup',
207 url: next.url,
208 height: next.height,
209 width: next.width,
210 top,
211 left,
212 });
213
214 const promptData = openPrompts.get(next.id);
215 if (promptData && window.id) {
216 promptData.windowId = window.id;
217 promptData.timeoutId = setTimeout(() => {
218 debug(`Prompt ${next.id} timed out after ${PROMPT_TIMEOUT_MS}ms`);
219 cleanupPrompt(next.id, 'timeout');
220 }, PROMPT_TIMEOUT_MS);
221 }
222 } catch (error) {
223 debug(`Failed to create prompt window: ${error}`);
224 cleanupPrompt(next.id, 'error');
225 }
226 break; // only open one prompt at a time
227 }
228 }
229
230 /**
231 * Check if a queued prompt's request is already covered by a stored permission.
232 * Returns true (allowed), false (denied), or undefined (no stored permission).
233 */
234 async function isQueuedRequestCovered(item: PermissionQueueItem): Promise<boolean | undefined> {
235 const browserSessionData = await getBrowserSessionData();
236 if (!browserSessionData) return undefined;
237
238 const currentIdentity = browserSessionData.identities.find(
239 (x) => x.id === browserSessionData.selectedIdentityId
240 );
241 if (!currentIdentity) return undefined;
242
243 // Parse host and method from the prompt URL query params.
244 try {
245 const url = new URL(item.url, 'http://ext');
246 const host = url.searchParams.get('host');
247 const method = url.searchParams.get('method') as Nip07Method;
248 if (!host || !method) return undefined;
249
250 return checkPermissions(browserSessionData, currentIdentity, host, method, {});
251 } catch {
252 return undefined;
253 }
254 }
255
256 /**
257 * Clean up a prompt and process the next one in queue
258 */
259 function cleanupPrompt(promptId: string, reason: 'response' | 'timeout' | 'closed' | 'error'): void {
260 const promptData = openPrompts.get(promptId);
261
262 if (promptData) {
263 if (promptData.timeoutId) {
264 clearTimeout(promptData.timeoutId);
265 }
266 if (reason !== 'response') {
267 promptData.reject(new Error(`Permission prompt ${reason}`));
268 }
269 openPrompts.delete(promptId);
270 }
271
272 const queueIndex = permissionQueue.findIndex(item => item.id === promptId);
273 if (queueIndex !== -1) {
274 permissionQueue.splice(queueIndex, 1);
275 }
276
277 if (activePromptId === promptId) {
278 activePromptId = null;
279 }
280
281 showNextPermissionPrompt();
282 }
283
284 /**
285 * Queue a permission prompt request
286 */
287 function queuePermissionPrompt(
288 urlWithoutId: string,
289 width: number,
290 height: number
291 ): Promise<PromptResponse> {
292 return new Promise((resolve, reject) => {
293 if (permissionQueue.length >= MAX_PERMISSION_QUEUE_SIZE) {
294 reject(new Error('Too many pending permission requests. Please try again later.'));
295 return;
296 }
297
298 const id = crypto.randomUUID();
299 const separator = urlWithoutId.includes('?') ? '&' : '?';
300 const url = `${urlWithoutId}${separator}id=${id}`;
301
302 openPrompts.set(id, { resolve, reject });
303 permissionQueue.push({ id, url, width, height, resolve, reject });
304
305 debug(`Queued permission prompt ${id}. Queue size: ${permissionQueue.length}`);
306 showNextPermissionPrompt();
307 });
308 }
309
310 // Listen for window close events to clean up orphaned prompts and unlock popup
311 browser.windows.onRemoved.addListener((windowId: number) => {
312 // Handle unlock popup closed without successful unlock
313 if (unlockPopupWindowId === windowId) {
314 debug('Unlock popup closed without successful unlock');
315 unlockPopupOpen = false;
316 unlockPopupWindowId = undefined;
317 // Reject all pending requests — vault is still locked
318 while (pendingRequests.length > 0) {
319 const pending = pendingRequests.shift()!;
320 pending.reject(new Error('Vault unlock cancelled'));
321 }
322 }
323
324 for (const [promptId, promptData] of openPrompts.entries()) {
325 if (promptData.windowId === windowId) {
326 debug(`Prompt window ${windowId} closed without response`);
327 cleanupPrompt(promptId, 'closed');
328 break;
329 }
330 }
331 });
332
333 // ==========================================
334 // Request Deduplication (P1)
335 // ==========================================
336
337 const pendingRequestPromises = new Map<string, Promise<PromptResponse>>();
338
339 /**
340 * Generate a hash key for request deduplication
341 */
342 function getRequestHash(host: string, method: string, params: any): string {
343 if (method === 'signEvent' && params?.kind !== undefined) {
344 return `${host}:${method}:kind${params.kind}`;
345 }
346 // encrypt/decrypt permissions are blanket per host+method (no peerPubkey),
347 // so dedup must match that granularity — one prompt covers all peers.
348 return `${host}:${method}`;
349 }
350
351 /**
352 * Queue a permission prompt with deduplication
353 */
354 function queuePermissionPromptDeduped(
355 host: string,
356 method: string,
357 params: any,
358 urlWithoutId: string,
359 width: number,
360 height: number
361 ): Promise<PromptResponse> {
362 const hash = getRequestHash(host, method, params);
363
364 const existingPromise = pendingRequestPromises.get(hash);
365 if (existingPromise) {
366 debug(`Deduplicating request: ${hash}`);
367 return existingPromise;
368 }
369
370 const promise = queuePermissionPrompt(urlWithoutId, width, height)
371 .finally(() => {
372 pendingRequestPromises.delete(hash);
373 });
374
375 pendingRequestPromises.set(hash, promise);
376 debug(`New permission request: ${hash}`);
377
378 return promise;
379 }
380
381 browser.runtime.onMessage.addListener(async (message, sender) => {
382 debug('Message received');
383
384 // Handle unlock request from unlock popup
385 if ((message as UnlockRequestMessage)?.type === 'unlock-request') {
386 const unlockReq = message as UnlockRequestMessage;
387 debug('Processing unlock request');
388 const result = await handleUnlockRequest(unlockReq.password);
389 const response: UnlockResponseMessage = {
390 type: 'unlock-response',
391 id: unlockReq.id,
392 success: result.success,
393 error: result.error,
394 };
395
396 if (result.success) {
397 unlockPopupOpen = false;
398 unlockPopupWindowId = undefined;
399 // Process pending requests asynchronously — don't block the response
400 // to the unlock popup (Firefox may timeout the sendMessage otherwise).
401 const queued = [...pendingRequests];
402 pendingRequests.length = 0;
403 if (queued.length > 0) {
404 debug(`Scheduling ${queued.length} pending requests`);
405 setTimeout(async () => {
406 for (const pending of queued) {
407 try {
408 const pendingResult = await processNip07Request(pending.request);
409 pending.resolve(pendingResult);
410 } catch (error) {
411 pending.reject(error);
412 }
413 }
414 }, 0);
415 }
416 }
417
418 return response;
419 }
420
421 const request = message as BackgroundRequestMessage | PromptResponseMessage;
422 debug(request);
423
424 if ((request as PromptResponseMessage)?.id) {
425 // Handle prompt response
426 const promptResponse = request as PromptResponseMessage;
427 const openPrompt = openPrompts.get(promptResponse.id);
428 if (!openPrompt) {
429 debug('Prompt response could not be matched (may have timed out)');
430 return;
431 }
432
433 openPrompt.resolve(promptResponse.response);
434
435 // If "always" (approve/reject/approve-all/reject-all), auto-resolve all
436 // queued prompts for the same host:method so they never open a window.
437 if (['approve', 'reject', 'approve-all', 'reject-all'].includes(promptResponse.response)) {
438 const answeredItem = permissionQueue.find(item => item.id === promptResponse.id);
439 if (answeredItem) {
440 try {
441 const answeredUrl = new URL(answeredItem.url, 'http://ext');
442 const answeredHost = answeredUrl.searchParams.get('host');
443 const answeredMethod = answeredUrl.searchParams.get('method');
444 if (answeredHost && answeredMethod) {
445 const autoResponse: PromptResponse =
446 ['approve', 'approve-all'].includes(promptResponse.response) ? 'approve-once' : 'reject-once';
447 // Drain matching items from the queue (iterate in reverse to safely splice).
448 for (let i = permissionQueue.length - 1; i >= 0; i--) {
449 const item = permissionQueue[i];
450 if (item.id === promptResponse.id) continue;
451 try {
452 const itemUrl = new URL(item.url, 'http://ext');
453 if (itemUrl.searchParams.get('host') === answeredHost &&
454 itemUrl.searchParams.get('method') === answeredMethod) {
455 const pd = openPrompts.get(item.id);
456 if (pd) {
457 if (pd.timeoutId) clearTimeout(pd.timeoutId);
458 pd.resolve(autoResponse);
459 openPrompts.delete(item.id);
460 }
461 permissionQueue.splice(i, 1);
462 debug(`Auto-resolved queued prompt ${item.id} via ${promptResponse.response}`);
463 }
464 } catch { /* skip malformed */ }
465 }
466 }
467 } catch { /* skip malformed */ }
468 }
469 }
470
471 cleanupPrompt(promptResponse.id, 'response');
472 return;
473 }
474
475 const browserSessionData = await getBrowserSessionData();
476
477 if (!browserSessionData) {
478 // Vault is locked - open unlock popup and queue the request
479 const req = request as BackgroundRequestMessage;
480 debug('Vault locked, opening unlock popup');
481
482 if (!unlockPopupOpen) {
483 unlockPopupOpen = true;
484 unlockPopupWindowId = await openUnlockPopup(req.host);
485 }
486
487 // Queue this request to be processed after unlock
488 return new Promise((resolve, reject) => {
489 pendingRequests.push({ request: req, resolve, reject });
490 });
491 }
492
493 // Process the request (NIP-07 or WebLN)
494 const req = request as BackgroundRequestMessage;
495 if (isWeblnMethod(req.method)) {
496 return processWeblnRequest(req);
497 }
498 const tabId = sender?.tab?.id;
499 if (isMlsMethod(req.method) && tabId !== undefined) {
500 mlsSetTab(tabId);
501 }
502 return processNip07Request(req, tabId);
503 });
504
505 /**
506 * Process a NIP-07 request after vault is unlocked
507 */
508 async function processNip07Request(req: BackgroundRequestMessage, tabId?: number): Promise<any> {
509 const browserSessionData = await getBrowserSessionData();
510
511 if (!browserSessionData) {
512 throw new Error('Smesh Signer vault not unlocked by the user.');
513 }
514
515 const currentIdentity = browserSessionData.identities.find(
516 (x) => x.id === browserSessionData.selectedIdentityId
517 );
518
519 if (!currentIdentity) {
520 throw new Error('No Nostr identity available at endpoint.');
521 }
522
523 // Check reckless mode first
524 const recklessApprove = await shouldRecklessModeApprove(req.host);
525 debug(`recklessApprove result: ${recklessApprove}`);
526 if (recklessApprove) {
527 debug('Request auto-approved via reckless mode.');
528 } else {
529 // Normal permission flow
530 const permissionState = checkPermissions(
531 browserSessionData,
532 currentIdentity,
533 req.host,
534 req.method as Nip07Method,
535 req.params
536 );
537 debug(`permissionState result: ${permissionState}`);
538
539 if (permissionState === false) {
540 throw new Error('Permission denied');
541 }
542
543 if (permissionState === undefined) {
544 // Ask user for permission (queued + deduplicated)
545 const width = 375;
546 const height = 600;
547
548 // MLS methods are a single permission group — prompt and store as 'mls.*'
549 const isMls = (req.method as string).startsWith('mls.');
550 const promptMethod = isMls ? 'mls.*' : req.method;
551
552 const base64Event = Buffer.from(
553 JSON.stringify(req.params ?? {}, undefined, 2)
554 ).toString('base64');
555
556 // Include queue info for user awareness
557 const queueSize = permissionQueue.length;
558 const promptUrl = `prompt.html?method=${promptMethod}&host=${req.host}&nick=${encodeURIComponent(currentIdentity.nick)}&event=${base64Event}&queue=${queueSize}`;
559 const response = await queuePermissionPromptDeduped(req.host, promptMethod, req.params, promptUrl, width, height);
560 debug(response);
561
562 // Handle permission storage based on response type
563 if (response === 'approve' || response === 'reject') {
564 const policy = response === 'approve' ? 'allow' : 'deny';
565 await storePermission(
566 browserSessionData,
567 currentIdentity,
568 req.host,
569 promptMethod,
570 policy,
571 req.params?.kind
572 );
573 await backgroundLogPermissionStored(req.host, promptMethod, policy, req.params?.kind);
574 } else if (response === 'approve-all') {
575 await storePermission(
576 browserSessionData,
577 currentIdentity,
578 req.host,
579 promptMethod,
580 'allow',
581 undefined
582 );
583 await backgroundLogPermissionStored(req.host, promptMethod, 'allow', undefined);
584 debug(`Stored approve-all permission for ${promptMethod} from ${req.host}`);
585 } else if (response === 'reject-all') {
586 await storePermission(
587 browserSessionData,
588 currentIdentity,
589 req.host,
590 promptMethod,
591 'deny',
592 undefined
593 );
594 await backgroundLogPermissionStored(req.host, promptMethod, 'deny', undefined);
595 debug(`Stored reject-all permission for ${promptMethod} from ${req.host}`);
596 }
597
598 if (['reject', 'reject-once', 'reject-all'].includes(response)) {
599 await backgroundLogNip07Action(req.method, req.host, false, false, {
600 kind: req.params?.kind,
601 peerPubkey: req.params?.peerPubkey,
602 });
603 throw new Error('Permission denied');
604 }
605 } else {
606 debug('Request allowed (via saved permission).');
607 }
608 }
609
610 const relays: Relays = {};
611 let result: any;
612
613 switch (req.method) {
614 case 'getPublicKey':
615 result = NostrHelper.pubkeyFromPrivkey(currentIdentity.privkey);
616 await backgroundLogNip07Action(req.method, req.host, true, recklessApprove);
617 return result;
618
619 case 'signEvent':
620 result = signEvent(req.params, currentIdentity.privkey);
621 await backgroundLogNip07Action(req.method, req.host, true, recklessApprove, {
622 kind: req.params?.kind,
623 });
624 return result;
625
626 case 'getRelays':
627 browserSessionData.relays.forEach((x) => {
628 relays[x.url] = { read: x.read, write: x.write };
629 });
630 await backgroundLogNip07Action(req.method, req.host, true, recklessApprove);
631 return relays;
632
633 case 'nip04.encrypt':
634 result = await nip04Encrypt(
635 currentIdentity.privkey,
636 req.params.peerPubkey,
637 req.params.plaintext
638 );
639 await backgroundLogNip07Action(req.method, req.host, true, recklessApprove, {
640 peerPubkey: req.params.peerPubkey,
641 });
642 return result;
643
644 case 'nip44.encrypt':
645 result = await nip44Encrypt(
646 currentIdentity.privkey,
647 req.params.peerPubkey,
648 req.params.plaintext
649 );
650 await backgroundLogNip07Action(req.method, req.host, true, recklessApprove, {
651 peerPubkey: req.params.peerPubkey,
652 });
653 return result;
654
655 case 'nip04.decrypt':
656 result = await nip04Decrypt(
657 currentIdentity.privkey,
658 req.params.peerPubkey,
659 req.params.ciphertext
660 );
661 await backgroundLogNip07Action(req.method, req.host, true, recklessApprove, {
662 peerPubkey: req.params.peerPubkey,
663 });
664 return result;
665
666 case 'nip44.decrypt':
667 result = await nip44Decrypt(
668 currentIdentity.privkey,
669 req.params.peerPubkey,
670 req.params.ciphertext
671 );
672 await backgroundLogNip07Action(req.method, req.host, true, recklessApprove, {
673 peerPubkey: req.params.peerPubkey,
674 });
675 return result;
676
677 // MLS operations — all crypto happens locally in the extension
678 case 'mls.init':
679 if (tabId === undefined) throw new Error('MLS requires tab context');
680 result = await mlsInit(
681 currentIdentity.privkey,
682 NostrHelper.pubkeyFromPrivkey(currentIdentity.privkey),
683 req.params.relayURLs || [],
684 tabId,
685 req.params.lastEventTS || 0
686 );
687 return result;
688
689 case 'mls.sendDM':
690 return mlsSendDM(req.params.recipient, req.params.content);
691
692 case 'mls.subscribe':
693 return mlsSubscribe();
694
695 case 'mls.publishKP':
696 return mlsPublishKP();
697
698 case 'mls.listGroups':
699 return JSON.parse(await mlsListGroups());
700
701 case 'mls.deliverEvent':
702 mlsDeliverEvent(req.params.subId, req.params.eventJSON);
703 return 'ok';
704
705 case 'mls.backupGroups':
706 await mlsBackupGroups();
707 return 'ok';
708
709 case 'mls.restoreGroups':
710 await mlsRestoreGroups();
711 return 'ok';
712
713 case 'mls.ratchetGroup':
714 await mlsRatchetGroup(req.params.peerHex);
715 return 'ok';
716
717 default:
718 throw new Error(`Not supported request method '${req.method}'.`);
719 }
720 }
721
722 /**
723 * Process a WebLN request after vault is unlocked
724 */
725 async function processWeblnRequest(req: BackgroundRequestMessage): Promise<any> {
726 const browserSessionData = await getBrowserSessionData();
727
728 if (!browserSessionData) {
729 throw new Error('Smesh Signer vault not unlocked by the user.');
730 }
731
732 const nwcConnections = browserSessionData.nwcConnections ?? [];
733 const method = req.method as WeblnMethod;
734
735 // webln.enable just checks if NWC is configured
736 if (method === 'webln.enable') {
737 if (nwcConnections.length === 0) {
738 throw new Error('No wallet configured. Please add an NWC connection in Smesh Signer settings.');
739 }
740 debug('WebLN enabled');
741 return { enabled: true }; // Return explicit value (undefined gets filtered by content script)
742 }
743
744 // All other methods require an NWC connection
745 const defaultConnection = nwcConnections[0];
746 if (!defaultConnection) {
747 throw new Error('No wallet configured. Please add an NWC connection in Smesh Signer settings.');
748 }
749
750 // Check reckless mode (but still prompt for payments)
751 const recklessApprove = await shouldRecklessModeApprove(req.host);
752
753 // Check WebLN permissions
754 const permissionState = recklessApprove && method !== 'webln.sendPayment' && method !== 'webln.keysend'
755 ? true
756 : checkWeblnPermissions(browserSessionData, req.host, method);
757
758 if (permissionState === false) {
759 throw new Error('Permission denied');
760 }
761
762 if (permissionState === undefined) {
763 // Ask user for permission (queued + deduplicated)
764 const width = 375;
765 const height = 600;
766
767 // For sendPayment, include the invoice amount in the prompt data
768 let promptParams = req.params ?? {};
769 if (method === 'webln.sendPayment' && req.params?.paymentRequest) {
770 const amountSats = parseInvoiceAmount(req.params.paymentRequest);
771 promptParams = { ...promptParams, amountSats };
772 }
773
774 const base64Event = Buffer.from(
775 JSON.stringify(promptParams, undefined, 2)
776 ).toString('base64');
777
778 // Include queue info for user awareness
779 const queueSize = permissionQueue.length;
780 const promptUrl = `prompt.html?method=${method}&host=${req.host}&nick=WebLN&event=${base64Event}&queue=${queueSize}`;
781 const response = await queuePermissionPromptDeduped(req.host, method, req.params, promptUrl, width, height);
782
783 debug(response);
784
785 // Store permission for non-payment methods
786 if ((response === 'approve' || response === 'reject') && method !== 'webln.sendPayment' && method !== 'webln.keysend') {
787 const policy = response === 'approve' ? 'allow' : 'deny';
788 await storePermission(
789 browserSessionData,
790 null, // WebLN has no identity
791 req.host,
792 method,
793 policy
794 );
795 await backgroundLogPermissionStored(req.host, method, policy);
796 } else if (response === 'approve-all' && method !== 'webln.sendPayment' && method !== 'webln.keysend') {
797 // P2: Store permission for all uses of this WebLN method
798 await storePermission(
799 browserSessionData,
800 null,
801 req.host,
802 method,
803 'allow'
804 );
805 await backgroundLogPermissionStored(req.host, method, 'allow');
806 debug(`Stored approve-all permission for ${method} from ${req.host}`);
807 }
808
809 if (['reject', 'reject-once', 'reject-all'].includes(response)) {
810 throw new Error('Permission denied');
811 }
812 }
813
814 // Execute the WebLN method
815 let result: any;
816 const client = await getNwcClient(defaultConnection);
817
818 switch (method) {
819 case 'webln.getInfo': {
820 const info = await client.getInfo();
821 result = {
822 node: {
823 alias: info.alias,
824 pubkey: info.pubkey,
825 color: info.color,
826 },
827 } as GetInfoResponse;
828 debug('webln.getInfo result:');
829 debug(result);
830 return result;
831 }
832
833 case 'webln.sendPayment': {
834 const invoice = req.params.paymentRequest;
835 const payResult = await client.payInvoice({ invoice });
836 result = { preimage: payResult.preimage } as SendPaymentResponse;
837 debug('webln.sendPayment result:');
838 debug(result);
839 return result;
840 }
841
842 case 'webln.makeInvoice': {
843 // Convert sats to millisats (NWC uses millisats)
844 const amountSats = typeof req.params.amount === 'string'
845 ? parseInt(req.params.amount, 10)
846 : req.params.amount ?? req.params.defaultAmount ?? 0;
847 const amountMsat = amountSats * 1000;
848
849 const invoiceResult = await client.makeInvoice({
850 amount: amountMsat,
851 description: req.params.defaultMemo,
852 });
853 result = { paymentRequest: invoiceResult.invoice } as RequestInvoiceResponse;
854 debug('webln.makeInvoice result:');
855 debug(result);
856 return result;
857 }
858
859 case 'webln.keysend':
860 throw new Error('keysend is not yet supported');
861
862 default:
863 throw new Error(`Not supported WebLN method '${method}'.`);
864 }
865 }
866