messages.ts

28.9 KB · raw

1/**2 * messages.ts — the only background message handler.3 *4 * All `chrome.runtime.onMessage` handling for the service worker lives here.5 * Do not handle messages inline in entry.ts, alarms.ts, or any other6 * background file. Every background-bound MessageType in shared/types.ts7 * must have a case below. Content-script-bound message types (PING,8 * SUMMON_AUTOFILL, VAULT_LOCKED) are documented in shared/types.ts and9 * intentionally have no case here — they are sent *to* content scripts.10 */1112import { copyToClipboard } from "./clipboard";13import { setupAutoLock, touchAutoLock } from "./alarms";14import * as vault from "./vault";15import {16  activateLicense,17  deactivateLicense,18  getLicenseInfo,19  getLicenseKey,20  isProUser,21  markCelebrationPlayed,22  shouldPlayCelebration,23} from "./license";24import {25  cancelFirefoxPasskeyCreate,26  completeFirefoxPasskeyCreate,27  getPasskeyProviderState,28  getPendingPasskeyRequest,29  initiateFirefoxPasskeyCreate,30  isPasskeyOfferDeclined,31  recordPasskeyOfferDecline,32  resolvePendingPasskeyRequest,33  setPasskeyProviderEnabled,34} from "./passkey";35import { buildAdvancedHealthReport, resolveResetUrl } from "./health";36import type {37  Message,38  MessageResponse,39  MessageType,40  ProFeature,41} from "../shared/types";42import type { SyncStatus } from "../shared/sync-types";4344/**45 * The single Pro gate. Every gated handler calls this first; the broadcast46 * lets the popup react (navigate to the License section, highlight the47 * relevant feature). Background never touches the popup view directly —48 * the popup owns navigation. See Projects.md "Pro gate rule".49 */50async function requirePro(51  feature: ProFeature,52  trigger: "user" | "background" = "user"53): Promise<{ allowed: boolean }> {54  if (await isProUser()) return { allowed: true };5556  if (trigger === "user") {57    // User deliberately triggered this feature — redirect the popup immediately.58    chrome.runtime59      .sendMessage({ type: "PRO_GATE_TRIGGERED", payload: { feature } })60      .catch(() => {61        // Popup may be closed — the gate signal is best-effort.62      });63  } else {64    // Background trigger (e.g. passive sync initiation) — no popup is open.65    // Queue a notice so the next popup open shows a soft banner instead of66    // silently failing.67    if (chrome.storage.session) {68      chrome.storage.session69        .set({ pendingProNotice: { feature, triggeredAt: Date.now() } })70        .catch(() => {});71    }72  }7374  return { allowed: false };75}7677const PRIVATE_IPV4_RANGES = [78  { prefix: "10." },79  { prefix: "127." },80  { prefix: "169.254." },81  { prefix: "192.168." },82];8384// Empty disables network submission while the UI continues to function as a85// no-op (the handler returns ok:true without calling fetch). When set, the86// URL is validated by `isAllowedCanaryWebhookUrl` before any request, so the87// same private-IP/HTTPS rules that gate canary webhooks apply here.88const FEEDBACK_ENDPOINT = "";8990export function setupMessageHandler(): void {91  chrome.runtime.onMessage.addListener(92    (msg: unknown, sender, sendResponse) => {93      if (!isMessage(msg)) {94        sendResponse({ ok: false, error: "invalid message" });95        return true;96      }97      handleMessage(msg, sender)98        .then(sendResponse)99        .catch((err) =>100          sendResponse({101            ok: false,102            error: err instanceof Error ? err.message : String(err),103          })104        );105      return true;106    }107  );108}109110function isMessage(m: unknown): m is Message {111  return typeof m === "object" && m !== null && "type" in m;112}113114async function handleMessage(115  msg: Message,116  sender: chrome.runtime.MessageSender117): Promise<MessageResponse> {118  try {119    // Validate sender — only allow messages from our extension pages/scripts.120    // `sender.id` is undefined for messages originating from extension UI121    // (popup/options), which is legitimate; reject only when it is defined122    // and does not match our extension id.123    if (sender.id !== undefined && sender.id !== chrome.runtime.id) {124      return { ok: false, error: "invalid sender" };125    }126127    if (shouldRefreshAutoLock(msg.type) && vault.isUnlocked()) {128      touchAutoLock();129    }130131    switch (msg.type) {132      case "CREATE_VAULT": {133        const { password } = msg.payload as { password: string };134        await vault.createVault(password);135        await setupAutoLock();136        broadcastToContentScripts({ type: "VAULT_UNLOCKED" });137        return { ok: true };138      }139      case "UNLOCK": {140        const { password } = msg.payload as { password: string };141        const ok = await vault.unlock(password);142        if (ok) {143          await setupAutoLock();144          broadcastToContentScripts({ type: "VAULT_UNLOCKED" });145        }146        return { ok };147      }148      case "LOCK":149        vault.lock();150        return { ok: true };151      case "IS_UNLOCKED":152        return { ok: true, data: vault.isUnlocked() };153      case "GET_VAULT_STATUS":154        return { ok: true, unlocked: vault.isUnlocked() };155      case "HAS_VAULT":156        return { ok: true, data: await vault.hasVault() };157      case "GET_ENTRIES":158        return { ok: true, data: vault.getEntries() };159      case "ADD_ENTRY": {160        const entry = await vault.addEntry(msg.payload as any);161        return { ok: true, data: entry };162      }163      case "BATCH_ADD_ENTRIES": {164        const { entries } = msg.payload as { entries: any[] };165        const added = await vault.batchAddEntries(entries);166        return { ok: true, data: added };167      }168      case "RESTORE_ENTRIES": {169        const { entries, mode, settings } = msg.payload as {170          entries: any[];171          mode: "replace" | "merge";172          settings?: vault.VaultSettings;173        };174        const summary = await vault.restoreEntries(entries, mode);175        if (settings && typeof settings === "object") {176          await vault.updateSettings(settings);177        }178        return { ok: true, data: summary };179      }180      case "CLEAR_VAULT_ENTRIES": {181        await vault.clearVaultEntries("REPLACE_ALL");182        return { ok: true };183      }184      case "LIST_VAULT_SNAPSHOTS": {185        const snapshots = await vault.listSnapshots();186        return { ok: true, data: snapshots };187      }188      case "RESTORE_VAULT_SNAPSHOT": {189        const { index, password } = msg.payload as { index: number; password: string };190        const ok = await vault.restoreFromSnapshot(index, password);191        if (ok) broadcastToContentScripts({ type: "VAULT_UNLOCKED" });192        return { ok };193      }194      case "COUNT_DUPLICATES": {195        return { ok: true, data: vault.countDuplicates() };196      }197      case "DEDUPLICATE_ENTRIES": {198        const removed = await vault.deduplicateEntries();199        return { ok: true, data: removed };200      }201      case "UPDATE_ENTRY": {202        const { id, patch } = msg.payload as any;203        await vault.updateEntry(id, patch);204        return { ok: true };205      }206      case "ADD_ADDITIONAL_URL": {207        const { id, url } = msg.payload as { id: string; url: string };208        const trimmed = typeof url === "string" ? url.trim() : "";209        if (!trimmed) return { ok: false, error: "url required" };210        const existing = vault.getEntryById(id);211        if (!existing) return { ok: false, error: "entry not found" };212        if (existing.type !== "login") return { ok: false, error: "login entries only" };213        const merged = [...(existing.additionalUrls ?? []), trimmed];214        await vault.updateEntry(id, { additionalUrls: merged });215        return { ok: true };216      }217      case "DELETE_ENTRY": {218        const { id } = msg.payload as { id: string };219        await vault.deleteEntry(id);220        return { ok: true };221      }222      case "GET_DELETED_ENTRIES":223        return { ok: true, data: vault.getDeletedEntries() };224      case "RESTORE_ENTRY": {225        const { id } = msg.payload as { id: string };226        await vault.restoreEntry(id);227        return { ok: true };228      }229      case "PURGE_ENTRY": {230        const { id } = msg.payload as { id: string };231        await vault.purgeEntry(id);232        return { ok: true };233      }234      case "PURGE_EXPIRED_TRASH": {235        const { retentionMs } = msg.payload as { retentionMs: number };236        const purged = await vault.purgeExpiredTrash(retentionMs);237        return { ok: true, data: purged };238      }239      case "AUTOFILL_QUERY": {240        if (!vault.isUnlocked()) {241          return { ok: false, error: "VAULT_LOCKED" };242        }243        const { domain, includeAll, kind } = msg.payload as {244          domain: string;245          includeAll?: boolean;246          kind?: "login" | "card";247        };248        // kind="card": return saved cards as compact summaries for the249        // inline card silver platter. Cards are not domain-scoped — every250        // saved card is a candidate on every checkout page — so domain is251        // ignored when kind === "card".252        if (kind === "card") {253          const cards = vault.getEntries().filter((e) => e.type === "card");254          return {255            ok: true,256            data: cards.map((e) => ({257              id: e.id,258              title: e.cardNickname || e.title,259              cardholderName: e.cardholderName,260              last4: e.last4,261              expMonth: e.expMonth,262              expYear: e.expYear,263              network: e.network,264            })),265          };266        }267        const matches = includeAll268          ? vault269              .getEntries()270              .filter((entry) => entry.type !== "note" && entry.type !== "passkey" && entry.type !== "card")271          : vault.queryDomain(domain);272        return {273          ok: true,274          data: matches.map((e) =>275            includeAll276              ? {277                  id: e.id,278                  title: e.title,279                }280              : {281                  id: e.id,282                  title: e.title,283                  username: e.username,284                  url: e.url,285                  hasTotp: Boolean(e.totp),286                }287          ),288        };289      }290      case "GET_ENTRY_FOR_FILL": {291        if (!vault.isUnlocked()) return { ok: false, error: "vault locked" };292        const { id } = msg.payload as { id: string };293        const entry = vault.getEntryById(id);294        if (!entry) return { ok: false, error: "entry not found" };295        if (entry.type === "passkey") return { ok: false, error: "passkey not fillable" };296        // Content scripts never need the passkey private key; strip it297        // defensively even though login entries do not carry one.298        const { privateKeyJwk: _stripped, ...safeEntry } = entry;299        return { ok: true, data: safeEntry };300      }301      case "COPY": {302        const { text, label, alreadyWritten } = msg.payload as {303          text: string;304          label: string;305          alreadyWritten?: boolean;306        };307        await copyToClipboard(text, label, Boolean(alreadyWritten));308        return { ok: true };309      }310      case "CHECK_BREACH": {311        const { password } = msg.payload as { password: string };312        const count = await vault.checkBreach(password);313        return { ok: true, data: count };314      }315      case "CHECK_ALL_BREACHES": {316        const allEntries = vault.getEntries();317        const results: Record<string, number> = {};318        for (const entry of allEntries) {319          if (entry.type === "note") continue;320          if (!entry.password) continue;321          results[entry.id] = await vault.checkBreach(entry.password);322          // Small delay — be a good citizen to the HIBP API323          await new Promise((r) => setTimeout(r, 200));324        }325        // Persist any non-zero counts back to entries326        for (const [id, count] of Object.entries(results)) {327          const entry = vault.getEntryById(id);328          if (entry && (entry.breach ?? 0) !== count) {329            await vault.updateEntry(id, { breach: count > 0 ? count : undefined });330          }331        }332        return { ok: true, data: results };333      }334      case "GET_SETTINGS":335        return { ok: true, data: await vault.ensureSettingsLoaded() };336      case "UPDATE_SETTINGS": {337        const patch = msg.payload as Record<string, unknown>;338        await vault.updateSettings(patch as any);339        if (typeof patch.autoLockMinutes === "number") {340          await setupAutoLock();341          if (vault.isUnlocked()) touchAutoLock();342        }343        return { ok: true };344      }345      case "GET_PENDING_PASSKEY":346        return { ok: true, data: await getPendingPasskeyRequest() };347      case "APPROVE_PASSKEY": {348        const { requestId } = msg.payload as { requestId: number };349        await resolvePendingPasskeyRequest(requestId, true);350        return { ok: true };351      }352      case "DENY_PASSKEY": {353        const { requestId } = msg.payload as { requestId: number };354        await resolvePendingPasskeyRequest(requestId, false);355        return { ok: true };356      }357      case "GET_PASSKEYS":358        return {359          ok: true,360          data: vault.getEntries().filter((entry) => entry.type === "passkey"),361        };362      case "DELETE_PASSKEY": {363        const { id } = msg.payload as { id: string };364        await vault.deleteEntry(id);365        return { ok: true };366      }367      case "GET_PASSKEY_PROVIDER_STATE":368        return { ok: true, data: await getPasskeyProviderState() };369      case "SET_PASSKEY_PROVIDER_ENABLED": {370        const { enabled } = msg.payload as { enabled: boolean };371        // Disabling is always free; gating fires only when a free user is372        // trying to turn passkey interception on.373        if (enabled) {374          const gate = await requirePro("passkeys");375          if (!gate.allowed) return { ok: false, error: "pro_required" };376        }377        return { ok: true, data: await setPasskeyProviderEnabled(enabled) };378      }379      case "FIRE_CANARY_PING": {380        const { webhookUrl, entryTitle, trigger } = msg.payload as {381          webhookUrl: string;382          entryTitle: string;383          trigger: "password_copied" | "username_copied" | "autofilled" | "submitted" | "test";384        };385        if (!isAllowedCanaryWebhookUrl(webhookUrl)) {386          return { ok: false, error: "invalid webhook url" };387        }388        const body = JSON.stringify({389          event: trigger === "test" ? "peach_canary_test" : "peach_canary_accessed",390          entry: entryTitle,391          trigger,392          timestamp: new Date().toISOString(),393          source: "peach-extension",394        });395        if (trigger === "test") {396          // Permitted outbound fetch: user-configured canary alert endpoint397          // (validated by isAllowedCanaryWebhookUrl above). This is the398          // only non-HIBP outbound call in the extension — see399          // Projects.md §3.7. Test pings are awaited so the UI can show400          // success/failure.401          try {402            const res = await fetch(webhookUrl, {403              method: "POST",404              headers: { "Content-Type": "application/json" },405              body,406            });407            return { ok: res.ok, error: res.ok ? undefined : `HTTP ${res.status}` };408          } catch (err) {409            return { ok: false, error: err instanceof Error ? err.message : "network error" };410          }411        }412        // Non-test triggers are fire-and-forget413        fetch(webhookUrl, {414          method: "POST",415          headers: { "Content-Type": "application/json" },416          body,417        }).catch(() => {});418        return { ok: true };419      }420      case "ACTIVATE_LICENSE": {421        const { key } = msg.payload as { key: string };422        const licenseInfo = await activateLicense(key);423        return { ok: true, data: licenseInfo };424      }425      case "GET_LICENSE": {426        const licenseInfo = await getLicenseInfo();427        return { ok: true, data: licenseInfo };428      }429      case "GET_LICENSE_KEY": {430        const key = await getLicenseKey();431        return { ok: true, data: key };432      }433      case "DEACTIVATE_LICENSE": {434        await deactivateLicense();435        return { ok: true };436      }437      case "SHOULD_PLAY_CELEBRATION": {438        const should = await shouldPlayCelebration();439        return { ok: true, data: should };440      }441      case "MARK_CELEBRATION_PLAYED": {442        await markCelebrationPlayed();443        return { ok: true };444      }445      case "OPEN_PRO_GATE": {446        // Content script requests: open popup and navigate to License section.447        const { feature } = (msg.payload ?? {}) as { feature?: string };448        if (chrome.storage.session) {449          await chrome.storage.session.set({ pendingProGate: { feature: feature ?? "passkeys" } });450        }451        try { await chrome.action.openPopup(); } catch { /* badge fallback */ }452        return { ok: true };453      }454      case "GET_IDENTITIES": {455        if (!vault.isUnlocked()) return { ok: false, error: "vault locked" };456        return { ok: true, data: vault.getIdentities() };457      }458      case "GET_ADVANCED_HEALTH_REPORT": {459        const gate = await requirePro("advanced_health");460        if (!gate.allowed) return { ok: false, error: "pro_required" };461        if (!vault.isUnlocked()) return { ok: false, error: "vault locked" };462        const report = await buildAdvancedHealthReport(vault.getEntries());463        return { ok: true, data: report };464      }465      case "INITIATE_PASSWORD_CHANGE": {466        const gate = await requirePro("smart_password_change");467        if (!gate.allowed) return { ok: false, error: "pro_required" };468        if (!vault.isUnlocked()) return { ok: false, error: "vault locked" };469        const { id } = msg.payload as { id: string };470        const entry = vault.getEntryById(id);471        if (!entry) return { ok: false, error: "entry not found" };472        const url = resolveResetUrl(entry);473        if (!url) return { ok: false, error: "no reset url" };474        try {475          await chrome.tabs.create({ url });476          return { ok: true, data: { url } };477        } catch (err) {478          return {479            ok: false,480            error: err instanceof Error ? err.message : "failed to open tab",481          };482        }483      }484      case "IS_DURESS_ENABLED": {485        const enabled = await vault.isDuressEnabled();486        return { ok: true, data: enabled };487      }488      case "SETUP_DURESS": {489        const { duressPassword } = msg.payload as { duressPassword: string };490        await vault.setupDuress(duressPassword);491        return { ok: true };492      }493      case "DISABLE_DURESS": {494        await vault.disableDuress();495        return { ok: true };496      }497      case "VERIFY_MAIN_PASSWORD": {498        const { password } = msg.payload as { password: string };499        const ok = await vault.verifyMainPassword(password);500        return { ok: true, data: ok };501      }502      case "CHANGE_PASSWORD": {503        const { currentPassword, newPassword } = msg.payload as {504          currentPassword: string;505          newPassword: string;506        };507        const changed = await vault.changePassword(currentPassword, newPassword);508        return changed509          ? { ok: true }510          : { ok: false, error: "incorrect current password" };511      }512      case "GET_SITE_PASSWORD_POLICY": {513        const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });514        if (!tab?.id) return { ok: true, data: null };515        try {516          const response = await chrome.tabs.sendMessage(tab.id, {517            type: "GET_SITE_PASSWORD_POLICY",518          });519          return (response as MessageResponse) ?? { ok: true, data: null };520        } catch {521          return { ok: true, data: null };522        }523      }524      case "GET_SYNC_STATE": {525        const { getPairedDevices, getTrustedNetworks } = await import('./sync-storage');526        const { isOnTailscale } = await import('./sync-network');527        const { isProUser } = await import('./license');528        const pairedDevices = await getPairedDevices();529        const trustedNetworks = await getTrustedNetworks();530        const tailscale = await isOnTailscale();531        const pro = await isProUser();532        533        let status: SyncStatus = "offline";534        if (tailscale || trustedNetworks.some((n: { isTailscale?: boolean }) => !n.isTailscale)) {535          if (pairedDevices.length === 0) {536            status = "no_peers";537          } else {538            status = "synced";539          }540        } else {541          status = "not_trusted";542        }543        544        return { ok: true, data: { pairedDevices, trustedNetworks, status, isPro: pro } };545      }546      case "TRIGGER_SYNC": {547        const gate = await requirePro("sync");548        if (!gate.allowed) return { ok: false, error: "pro_required" };549        return { ok: true };550      }551      case "START_PAIRING": {552        const gate = await requirePro("sync");553        if (!gate.allowed) return { ok: false, error: "pro_required" };554        return { ok: true };555      }556      case "CANCEL_PAIRING": {557        return { ok: true };558      }559      case "ACCEPT_PAIRING": {560        const gate = await requirePro("sync");561        if (!gate.allowed) return { ok: false, error: "pro_required" };562        return { ok: true };563      }564      case "REMOVE_PAIRED_DEVICE": {565        const { deviceId } = msg.payload as { deviceId: string };566        const { removePairedDevice } = await import('./sync-storage');567        await removePairedDevice(deviceId);568        return { ok: true };569      }570      case "REMOVE_TRUSTED_NETWORK": {571        const { ssid, bssid, isTailscale } = msg.payload as any;572        const { removeTrustedNetwork, getTrustedNetworks } = await import('./sync-storage');573        const networks = await getTrustedNetworks();574        const network = networks.find((n: { ssid?: string; bssid?: string; isTailscale?: boolean }) => 575          (isTailscale ? n.isTailscale : n.ssid === ssid) && 576          (!bssid || n.bssid === bssid)577        );578        if (network) {579          await removeTrustedNetwork(network.addedAt);580        }581        return { ok: true };582      }583      case "MARK_NETWORK_TRUSTED": {584        const { addTrustedNetwork } = await import('./sync-storage');585        const { getCurrentNetwork } = await import('./sync-network');586        const network = await getCurrentNetwork();587        await addTrustedNetwork({588          ssid: network.ssid,589          bssid: network.bssid,590          isTailscale: network.isTailscale,591          addedAt: Date.now(),592        });593        return { ok: true };594      }595      case "RECORD_MANUAL_SUMMON": {596        const { domain } = msg.payload as { domain: string };597        const settings = vault.getSettings();598        const existing = settings.manualSummonDomains?.[domain];599        const now = Date.now();600        await vault.updateSettings({601          manualSummonDomains: {602            ...settings.manualSummonDomains,603            [domain]: {604              firstSummonedAt: existing?.firstSummonedAt ?? now,605              summonCount: (existing?.summonCount ?? 0) + 1,606              lastSummonedAt: now,607            },608          },609        });610        return { ok: true };611      }612      case "DECLINE_CARD_SAVE": {613        const { last4, expMonth, expYear } = msg.payload as { last4: string; expMonth: string; expYear: string };614        await vault.declineCardSaveOffer(last4, expMonth, expYear);615        return { ok: true };616      }617      case "IS_CARD_DECLINED": {618        const { last4, expMonth, expYear } = msg.payload as { last4: string; expMonth: string; expYear: string };619        const declined = await vault.isCardDeclined(last4, expMonth, expYear);620        return { ok: true, data: declined };621      }622      case "INITIATE_FIREFOX_PASSKEY_CREATE": {623        const { rpId, rpName, userName, userDisplayName } = (msg.payload ?? {}) as {624          rpId?: string;625          rpName?: string;626          userName?: string;627          userDisplayName?: string;628        };629        if (!rpId || !userName) return { ok: false, error: "rpId and userName required" };630        return await initiateFirefoxPasskeyCreate({631          rpId,632          rpName: rpName ?? rpId,633          userName,634          userDisplayName,635        });636      }637638      case "COMPLETE_FIREFOX_PASSKEY_CREATE": {639        const payload = msg.payload as Parameters<typeof completeFirefoxPasskeyCreate>[0] | undefined;640        if (!payload) return { ok: false, error: "missing payload" };641        return await completeFirefoxPasskeyCreate(payload);642      }643644      case "PASSKEY_OFFER_DECLINE": {645        const { domain } = (msg.payload ?? {}) as { domain?: string };646        if (!domain) return { ok: false, error: "domain required" };647        await recordPasskeyOfferDecline(domain);648        return { ok: true };649      }650651      case "GET_PASSKEY_OFFER_DECLINED": {652        const { domain } = (msg.payload ?? {}) as { domain?: string };653        if (!domain) return { ok: false, error: "domain required" };654        return { ok: true, data: await isPasskeyOfferDeclined(domain) };655      }656657      case "GET_PASSKEY_FOR_DOMAIN": {658        const { domain } = (msg.payload ?? {}) as { domain?: string };659        if (!domain) return { ok: false, error: "domain required" };660        if (!vault.isUnlocked()) return { ok: true, data: null };661        const match = vault662          .getEntries()663          .find((entry) => entry.type === "passkey" && entry.rpId === domain);664        if (!match) return { ok: true, data: null };665        // Slim shape — only the fields State 11 needs. Never expose private666        // key material, even though this is locked behind isUnlocked.667        return {668          ok: true,669          data: {670            rpId: match.rpId,671            userName: match.userName,672            browser: match.browser,673          },674        };675      }676677      case "DESTROY_VAULT": {678        // Irreversible by design. Lock zeroes the derived key + in-memory entries;679        // storage.local.clear() wipes every Peach key (slots, settings, license,680        // onboarded flag, snapshots, sync state, declined cards, recall, etc.);681        // session.clear() wipes pending passkey requests + pro-notice queues.682        // Caller (Panic.tsx) handles UI navigation back to onboarding.683        vault.lock();684        await chrome.storage.local.clear();685        if (chrome.storage.session) {686          await chrome.storage.session.clear();687        }688        return { ok: true };689      }690691      case "SUBMIT_FEEDBACK": {692        const { category, message, version, browser } = msg.payload as {693          category: string;694          message: string;695          version: string;696          browser: string;697        };698        if (!message.trim()) return { ok: false, error: "message is empty" };699        if (!FEEDBACK_ENDPOINT) return { ok: true };700        if (!isAllowedCanaryWebhookUrl(FEEDBACK_ENDPOINT)) {701          return { ok: false, error: "invalid feedback endpoint" };702        }703        try {704          const res = await fetch(FEEDBACK_ENDPOINT, {705            method: "POST",706            headers: { "Content-Type": "application/json" },707            body: JSON.stringify({ category, message, version, browser, source: "peach-extension" }),708          });709          return { ok: res.ok, error: res.ok ? undefined : `HTTP ${res.status}` };710        } catch (err) {711          return { ok: false, error: err instanceof Error ? err.message : "network error" };712        }713      }714      default:715        return { ok: false, error: "unknown message type" };716    }717  } catch (err) {718    return {719      ok: false,720      error: err instanceof Error ? err.message : "operation failed",721    };722  }723}724725/** Notify all content scripts that the vault just became available. */726function broadcastToContentScripts(message: { type: string }): void {727  chrome.tabs.query({}, (tabs) => {728    for (const tab of tabs) {729      if (tab.id !== undefined) {730        chrome.tabs.sendMessage(tab.id, message).catch(() => {731          // Tab may not have a content script — silently ignore732        });733      }734    }735  });736}737738function shouldRefreshAutoLock(type: MessageType): boolean {739  return (740    type !== "HAS_VAULT" &&741    type !== "IS_UNLOCKED" &&742    type !== "GET_VAULT_STATUS" &&743    type !== "LOCK" &&744    type !== "UPDATE_SETTINGS" &&745    type !== "GET_PENDING_PASSKEY" &&746    type !== "GET_PASSKEY_PROVIDER_STATE" &&747    type !== "DECLINE_CARD_SAVE" &&748    type !== "IS_CARD_DECLINED" &&749    type !== "SUBMIT_FEEDBACK"750  );751}752753function isAllowedCanaryWebhookUrl(value: string): boolean {754  try {755    const url = new URL(value);756    if (url.protocol !== "https:") return false;757758    const hostname = url.hostname.toLowerCase();759    if (760      hostname === "localhost" ||761      hostname.endsWith(".localhost") ||762      isPrivateIpv4(hostname) ||763      isPrivateIpv6(hostname)764    ) {765      return false;766    }767768    return true;769  } catch {770    return false;771  }772}773774function isPrivateIpv4(hostname: string): boolean {775  if (PRIVATE_IPV4_RANGES.some((range) => hostname.startsWith(range.prefix))) {776    return true;777  }778779  const octets = hostname.split(".").map((part) => Number(part));780  if (octets.length !== 4 || octets.some((octet) => !Number.isInteger(octet) || octet < 0 || octet > 255)) {781    return false;782  }783784  return octets[0] === 172 && octets[1] >= 16 && octets[1] <= 31;785}786787function isPrivateIpv6(hostname: string): boolean {788  const normalized = hostname.replace(/^\[|\]$/g, "").toLowerCase();789  return (790    normalized === "::1" ||791    normalized.startsWith("fc") ||792    normalized.startsWith("fd") ||793    normalized.startsWith("fe80:")794  );795}