Newer
Older
gnexus-creds / extensions / extension / src / background.js
import { listSecrets, revealSecret, createSecret, getMe, getCategories } from "./api.js";
import "./pack-shared.js";

const CACHE_TTL_MS = 5 * 60 * 1000;

async function getSettings() {
  return chrome.storage.local.get({ token: "", baseUrl: "https://creds.gnexus.space" });
}

async function setSettings(settings) {
  return chrome.storage.local.set(settings);
}

async function getSecretsCached(force = false) {
  const now = Date.now();
  if (!force) {
    try {
      const cached = await chrome.storage.session.get(["secretsCache", "cacheTimestamp"]);
      if (cached.secretsCache && cached.cacheTimestamp && now - cached.cacheTimestamp < CACHE_TTL_MS) {
        return cached.secretsCache;
      }
    } catch {
      // session storage may be unavailable in some contexts
    }
  }
  const { token, baseUrl } = await getSettings();
  if (!token) throw new Error("No API token configured");
  const data = await listSecrets(token, baseUrl);
  try {
    await chrome.storage.session.set({ secretsCache: data, cacheTimestamp: now });
  } catch {
    // ignore session storage errors
  }
  return data;
}

async function invalidateCache() {
  try {
    await chrome.storage.session.remove(["secretsCache", "cacheTimestamp"]);
  } catch {
    // ignore
  }
}

function secretsForDomain(secrets, domain) {
  if (!secrets?.items) return [];
  return secrets.items.filter((s) => {
    const src = (s.source || "").toLowerCase();
    const title = (s.title || "").toLowerCase();
    const d = domain.toLowerCase();
    return src.includes(d) || title.includes(d);
  });
}

// --- Shared UI language -------------------------------------------------------
// The service backend decides the UI language (locale_effective on GET
// /api/v1/me = settings override, else the auth-account locale). The
// background caches it in chrome.storage.local.uiLocale for the popup and
// the autofill card; strings themselves ship in the design pack.
async function syncUiLocale(reason) {
  const { token, baseUrl } = await getSettings();
  if (!token) {
    await chrome.storage.local.remove("uiLocale");
    return;
  }
  try {
    const me = await getMe(token, baseUrl);
    const locale = String(me?.locale_effective || me?.locale || "en").trim().toLowerCase();
    await chrome.storage.local.set({ uiLocale: locale || "en" });
    if (reason) console.debug(`[gnexus-creds] ui locale (${reason}):`, locale);
  } catch {
    // keep the previously cached locale
  }
}

chrome.alarms.create("refresh-secrets-cache", { periodInMinutes: 15 });

// --- Design-resource pack sync ----------------------------------------------
// The pack (CSS + data tables) is fetched by the background worker only —
// unauthenticated (design data is not secret, and content scripts hold no
// token) — validated against pack.json sha256es and stored once in
// chrome.storage.local. Content script/popup only READ stored pack state;
// a page load never blocks on the network here.

const PACK_REFRESH_ALARM = "refresh-design-pack";
chrome.alarms.create(PACK_REFRESH_ALARM, { periodInMinutes: 720 });

const PACK_KNOWN_FILES = ["content.css", "popup.css", "tables.json", "config.json"];
const PACK_FILE_LIMIT = 262144; // per-file cap echoed by pack-shared.js
const OWN_VERSION = chrome.runtime.getManifest().version;

async function setPackStatus(fields) {
  try {
    await chrome.storage.local.set({
      designPackStatus: { ...fields, lastAttemptAt: Date.now() },
    });
  } catch {
    // ignore
  }
}

function isPackSchemaValid(pack) {
  if (!pack || typeof pack !== "object") return false;
  if (!Number.isInteger(pack.packVersion) || pack.packVersion < 1) return false;
  if (typeof pack.minExtensionVersion !== "string") return false;
  if (pack.maxExtensionVersion != null && typeof pack.maxExtensionVersion !== "string") return false;
  if (!pack.files || typeof pack.files !== "object") return false;
  for (const [name, meta] of Object.entries(pack.files)) {
    if (!PACK_KNOWN_FILES.includes(name)) return false;
    if (!meta || typeof meta.sha256 !== "string" || !/^[0-9a-f]{64}$/.test(meta.sha256)) return false;
    if (!Number.isInteger(meta.size) || meta.size < 1 || meta.size > PACK_FILE_LIMIT) return false;
  }
  return Object.keys(pack.files).length > 0;
}

async function refreshDesignPack(reason) {
  const { baseUrl } = await getSettings();
  const base = String(baseUrl || "").replace(/\/+$/, "");
  if (!base) return;
  try {
    const response = await fetch(`${base}/api/v1/extension/pack`, { cache: "no-store" });
    if (!response.ok) throw new Error(`pack.json ${response.status}`);
    const pack = await response.json();
    if (!isPackSchemaValid(pack)) throw new Error("bad_pack_schema");

    const stored = (await chrome.storage.local.get("designPack")).designPack;
    if (stored && pack.packVersion < stored.packVersion) {
      await setPackStatus({ lastError: "", lastReason: "stale_pack_version" }); // rollback ignored
      return;
    }
    // A pack declaring markup newer than this build's markup stays unadopted.
    if (!self.GnPack.isCompatible(pack.minExtensionVersion, OWN_VERSION)) {
      await setPackStatus({ lastError: "", lastReason: "extension_too_old" });
      return;
    }

    const texts = {};
    for (const name of Object.keys(pack.files)) {
      const fileResponse = await fetch(
        `${base}/api/v1/extension/pack/file/${pack.packVersion}/${name}`,
        { cache: "no-store" }
      );
      if (!fileResponse.ok) throw new Error(`${name} ${fileResponse.status}`);
      const text = await fileResponse.text();
      const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(text));
      const hex = [...new Uint8Array(digest)].map((b) => b.toString(16).padStart(2, "0")).join("");
      if (hex !== pack.files[name].sha256) throw new Error(`sha_mismatch:${name}`);
      texts[name] = text;
    }

    await chrome.storage.local.set({
      designPack: {
        packVersion: pack.packVersion,
        minExtensionVersion: pack.minExtensionVersion,
        maxExtensionVersion: pack.maxExtensionVersion ?? null,
        publishedAt: pack.publishedAt ?? null,
        adoptedAt: Date.now(),
        files: pack.files,
        texts,
      },
      designPackStatus: { lastAttemptAt: Date.now(), lastError: "", lastReason: reason },
    });
  } catch (err) {
    await setPackStatus({ lastError: String(err.message || err), lastReason: reason });
  }
}

// These top-level registrations survive the MV3 service worker sleeping.
chrome.runtime.onInstalled.addListener((details) => {
  refreshDesignPack(details.reason === "update" ? "update" : "install");
  syncUiLocale("install");
});
chrome.runtime.onStartup.addListener(() => {
  refreshDesignPack("startup");
  syncUiLocale("startup");
});

chrome.alarms.onAlarm.addListener(async (alarm) => {
  if (alarm.name !== "refresh-secrets-cache") return;
  try {
    await getSecretsCached(true);
  } catch {
    // ignore — no token or network error
  }
  await syncUiLocale("alarm");
});

// Revoked token visibility: a 401 from any server call must not stay
// silent — the popup reads the flag and shows "token invalid" instead of
// looking like a working (but stale) extension
async function setTokenInvalid(value) {
  if (value) {
    await chrome.storage.local.set({ tokenInvalid: true });
  } else {
    await chrome.storage.local.remove("tokenInvalid");
  }
}

chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
  (async () => {
    try {
      const { token, baseUrl } = await getSettings();
      switch (message.type) {
        case "GET_SETTINGS":
          {
            sendResponse({ ok: true, data: await getSettings() });
            // every popup open re-syncs the cached UI language (cheap /me)
            syncUiLocale("popup");
          }
          break;
        case "SAVE_SETTINGS":
          await setSettings(message.payload);
          invalidateCache();
          sendResponse({ ok: true });
          break;
        case "VERIFY_TOKEN":
          {
            const me = await getMe(message.payload.token, message.payload.baseUrl);
            await setTokenInvalid(false);
            // cache the per-user UI language while we hold a fresh /me
            try {
              const locale = String(me?.locale_effective || me?.locale || "en").trim().toLowerCase();
              await chrome.storage.local.set({ uiLocale: locale || "en" });
            } catch {
              // ignore
            }
            sendResponse({ ok: true, data: me });
          }
          break;
        case "LIST_SECRETS":
          {
            const data = await getSecretsCached(message.payload?.force);
            await setTokenInvalid(false);
            sendResponse({ ok: true, data });
          }
          break;
        case "SEARCH_SECRETS":
          {
            const data = await listSecrets(token, baseUrl, { q: message.payload.q });
            sendResponse({ ok: true, data });
          }
          break;
        case "REVEAL_SECRET":
          {
            const data = await revealSecret(token, baseUrl, message.payload.id);
            sendResponse({ ok: true, data });
          }
          break;
        case "CREATE_SECRET":
          {
            const data = await createSecret(token, baseUrl, message.payload);
            await setTokenInvalid(false);
            invalidateCache();
            try {
              await chrome.storage.session.remove("pendingSave");
              await chrome.action.setBadgeText({ text: "" });
            } catch {
              // ignore
            }
            sendResponse({ ok: true, data });
          }
          break;
        case "GET_SECRETS_FOR_DOMAIN":
          {
            const all = await getSecretsCached();
            const matched = secretsForDomain(all, message.payload.domain);
            sendResponse({ ok: true, data: matched });
          }
          break;
        case "GET_CATEGORIES":
          {
            const data = await getCategories(token, baseUrl);
            sendResponse({ ok: true, data });
          }
          break;
        case "PENDING_SAVE":
          {
            const pending = {
              ...message.payload,
              timestamp: Date.now(),
            };
            await chrome.storage.session.set({ pendingSave: pending });
            try {
              await chrome.action.setBadgeText({ text: "1" });
              await chrome.action.setBadgeBackgroundColor({ color: "#238636" });
            } catch {
              // ignore
            }
            sendResponse({ ok: true });
          }
          break;
        case "GET_PENDING_SAVE":
          {
            const stored = await chrome.storage.session.get("pendingSave");
            sendResponse({ ok: true, data: stored.pendingSave || null });
          }
          break;
        case "CLEAR_PENDING_SAVE":
          {
            await chrome.storage.session.remove("pendingSave");
            try {
              await chrome.action.setBadgeText({ text: "" });
            } catch {
              // ignore
            }
            sendResponse({ ok: true });
          }
          break;
        case "REFRESH_DESIGN_PACK":
          {
            await refreshDesignPack("manual");
            const { designPack } = await chrome.storage.local.get("designPack");
            sendResponse({ ok: true, data: { packVersion: designPack ? designPack.packVersion : null } });
          }
          break;
        case "PACK_STATUS":
          {
            const { designPack, designPackStatus } = await chrome.storage.local.get(["designPack", "designPackStatus"]);
            sendResponse({
              ok: true,
              data: {
                packVersion: designPack ? designPack.packVersion : null,
                publishedAt: designPack ? designPack.publishedAt : null,
                adoptedAt: designPack ? designPack.adoptedAt : null,
                status: designPackStatus || null,
              },
            });
          }
          break;
        default:
          sendResponse({ ok: false, error: "Unknown message type" });
      }
    } catch (err) {
      // 401 from any API call means the stored token no longer works —
      // remember it for the popup; a successful VERIFY_TOKEN clears it
      if (err?.status === 401) {
        await setTokenInvalid(true).catch(() => {});
      }
      sendResponse({ ok: false, error: err.message, status: err.status });
    }
  })();
  return true;
});