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