/**
* Design-resource pack: shared resolver for the server-published pack
* (extensions/pack, served by the backend at /api/v1/extension/pack*).
*
* MV3 forbids executing JS from the network in any extension context, so the
* pack is CSS + DATA only. This module resolves raw pack texts into safe
* values: a config whitelist with clamps and additive-over-default keyword
* tables for field-detect.js. The same UMD pattern as field-detect.js lets
* one file serve the content script, the popup and (via the classic-script
* bundling in tools/bundle-background.js) the background worker.
*
* Trust split: the background worker hashes every file against pack.json
* (sha256) before storing; this module never re-hashes — a content script may
* run on a plain-http page where crypto.subtle is unavailable. Storage is
* per-extension, so a validated pack read back here is the one the SW stored.
*/
(function (root, factory) {
const api = factory();
if (typeof module !== "undefined" && module.exports) {
module.exports = api;
} else {
root.GnPack = api;
}
})(typeof self !== "undefined" ? self : globalThis, function () {
"use strict";
// The pack may only ever change data the bundled code already consumes.
const PACK_FILES = ["content.css", "popup.css", "tables.json", "config.json"];
const MAX_FILE_CHARS = 262144; // per-file cap; the whole pack is < 10KB today
const DEFAULT_CONFIG = freezeDeep({
timings: { scanDebounceMs: 300, cardExitMs: 280 },
strings: { use: "Use", dismiss: "Dismiss", selectAccount: "Select account" },
});
const EMPTY_TABLES = freezeDeep({
usernameKeywords: [],
passwordKeywords: [],
antiKeywords: [],
autocompleteTokens: {},
});
function freezeDeep(value) {
if (value && typeof value === "object") {
for (const key of Object.keys(value)) freezeDeep(value[key]);
Object.freeze(value);
}
return value;
}
const globalRoot = typeof self !== "undefined" ? self : globalThis;
// Bundled defaults live in field-detect.js (loaded before this file in the
// content script); the popup and node tests can pass them explicitly.
function defaultTables() {
if (typeof globalRoot.GnCredsDetect !== "undefined" && globalRoot.GnCredsDetect.keywordTables) {
return globalRoot.GnCredsDetect.keywordTables;
}
return EMPTY_TABLES;
}
function isPlainObject(value) {
return value !== null && typeof value === "object" && !Array.isArray(value);
}
// "0.1.1" — dotted numeric compare, missing components count as 0. Anything
// unparseable is treated as incompatible (bundled behavior wins, safe side).
function isCompatible(minExtensionVersion, ownVersion) {
if (typeof minExtensionVersion !== "string" || typeof ownVersion !== "string") return false;
const parse = (text) => text.split(".").map((part) => {
const digits = /^(\d+)/.exec(part.trim());
return digits ? Number(digits[1]) : -1;
});
const min = parse(minExtensionVersion);
const own = parse(ownVersion);
if (min.some((n) => n < 0) || own.some((n) => n < 0)) return false;
for (let i = 0; i < Math.max(min.length, own.length); i++) {
const a = min[i] || 0;
const b = own[i] || 0;
if (a !== b) return b > a;
}
return true;
}
function clampInt(value, min, max, fallback) {
if (typeof value !== "number" || !Number.isFinite(value)) return fallback;
if (value < min) return min;
if (value > max) return max;
return Math.floor(value);
}
function clampWeight(value) {
const num = typeof value === "number" && Number.isFinite(value) ? Math.floor(value) : null;
if (num === null) return null;
return Math.max(-200, Math.min(200, num));
}
// Keywords become regex fragments over the underscore-normalized haystack,
// so anything beyond the normalize() alphabet can never match — reject it.
const KEYWORD_RE = /^[a-z0-9а-яё_]{1,64}$/i;
const TOKEN_RE = /^[a-z_]{1,40}$/;
function validKeyword(value) {
return typeof value === "string" && KEYWORD_RE.test(value);
}
/**
* Merge a raw pack tables object over the bundled tables. Additive only:
* keyword entries replace-or-append (deleting a bundled keyword requires an
* extension release), anti-keywords are a set-union, autocomplete tokens
* replace per key. Unknown keys/entries are ignored; every value is
* clamped, so a bad pack can only add signals, never break the detector.
*/
function resolveTables(raw, bundledOver) {
const bundled = bundledOver || defaultTables();
const merged = {
usernameKeywords: bundled.usernameKeywords.slice(),
passwordKeywords: bundled.passwordKeywords.slice(),
antiKeywords: bundled.antiKeywords.slice(),
autocompleteTokens: Object.assign({}, bundled.autocompleteTokens),
};
if (!isPlainObject(raw)) return merged;
const replaceOrAdd = (target, entries) => {
if (!Array.isArray(entries)) return;
const byKeyword = new Map(target.map(([kw, weight]) => [kw, weight]));
for (const entry of entries.slice(0, 512)) {
if (!Array.isArray(entry)) continue;
const [keyword, weight] = entry;
if (!validKeyword(keyword)) continue;
const clamped = clampWeight(weight);
if (clamped === null) continue;
byKeyword.set(keyword, clamped);
}
target.length = 0;
for (const [kw, weight] of byKeyword) target.push([kw, weight]);
};
replaceOrAdd(merged.usernameKeywords, raw.usernameKeywords);
replaceOrAdd(merged.passwordKeywords, raw.passwordKeywords);
if (Array.isArray(raw.antiKeywords)) {
for (const word of raw.antiKeywords.slice(0, 512)) {
if (validKeyword(word) && !merged.antiKeywords.includes(word)) {
merged.antiKeywords.push(word);
}
}
}
if (isPlainObject(raw.autocompleteTokens)) {
for (const [token, weights] of Object.entries(raw.autocompleteTokens)) {
if (!TOKEN_RE.test(token) || !isPlainObject(weights)) continue;
const username = clampWeight(weights.username);
const password = clampWeight(weights.password);
if (username === null && password === null) continue;
merged.autocompleteTokens[token] = {
username: username === null ? 0 : username,
password: password === null ? 0 : password,
};
}
}
return merged;
}
const TIMING_LIMITS = { scanDebounceMs: [30, 2000], cardExitMs: [50, 2000] };
const STRING_KEYS = [
"use", "dismiss", "selectAccount",
// popup UI
"drawerTitle", "serverUrl", "apiToken", "tokenHint", "save", "openApp",
"openAppTitle", "refresh", "settingsTitle", "searchPlaceholder",
"noSecrets", "saveCredentials", "account", "credentialsSaved",
"enterToken", "settingsSaved", "error", "openOnSite",
"tokenInvalid", "tokenId",
];
const LOCALIZED_LOCALES = ["en", "uk", "ru"];
// English strings as shipped pre-localization; every other locale in
// stringsByLocale must cover the same keys (falls back to these).
const DEFAULT_STRINGS = {
use: "Use",
dismiss: "Dismiss",
selectAccount: "Select account",
drawerTitle: "Settings",
serverUrl: "Server URL",
apiToken: "API token",
tokenHint: "Create a token with read, reveal, write scopes on the website.",
save: "Save",
openApp: "Open gnexus-creds",
openAppTitle: "Open gnexus-creds",
refresh: "Refresh",
settingsTitle: "Settings",
searchPlaceholder: "Search secrets...",
noSecrets: "No secrets",
saveCredentials: "Save credentials for {source}?",
account: "Account:",
credentialsSaved: "Credentials saved",
enterToken: "Enter API token",
settingsSaved: "Settings saved",
error: "Error",
openOnSite: "Open on gnexus-creds",
tokenInvalid: "The saved API token no longer works — open Settings and paste a new one.",
tokenId: "Token ID",
};
const DEFAULT_STRINGS_BY_LOCALE = {
en: DEFAULT_STRINGS,
uk: {},
ru: {},
};
function resolveString(value, fallback) {
if (typeof value !== "string") return fallback;
const trimmed = value.trim();
if (!trimmed || trimmed.length > 200) return fallback;
return trimmed;
}
/**
* A pack config object may only carry keys the code already consumes:
* two timings, legacy card strings, and per-locale strings. Anything
* else is ignored; out-of-range timings and empty/oversized strings
* fall back to defaults.
*/
function resolveConfig(raw) {
const resolved = {
timings: Object.assign({}, DEFAULT_CONFIG.timings),
strings: Object.assign({}, DEFAULT_CONFIG.strings),
stringsByLocale: Object.assign({}, DEFAULT_STRINGS_BY_LOCALE),
};
if (!isPlainObject(raw)) return resolved;
if (isPlainObject(raw.timings)) {
for (const [key, [min, max]] of Object.entries(TIMING_LIMITS)) {
const value = raw.timings[key];
if (typeof value === "number" && Number.isFinite(value)) {
resolved.timings[key] = clampInt(value, min, max, DEFAULT_CONFIG.timings[key]);
}
}
}
if (isPlainObject(raw.strings)) {
for (const key of STRING_KEYS) {
// never materialize keys the pack didn't send — undefined values
// would leak into consumers that spread this over the defaults
if (raw.strings[key] === undefined) continue;
// ultimate fallback is the bundled English default
const prior = resolved.strings[key] ?? DEFAULT_STRINGS[key];
resolved.strings[key] = resolveString(raw.strings[key], prior);
}
}
if (isPlainObject(raw.stringsByLocale)) {
for (const locale of LOCALIZED_LOCALES) {
const rawStrings = raw.stringsByLocale[locale];
// per-key fallback is the bundled English default (DEFAULT_STRINGS)
// — the merge may only override known keys with valid strings.
const merged = Object.assign({}, DEFAULT_STRINGS_BY_LOCALE[locale]);
if (isPlainObject(rawStrings)) {
for (const key of STRING_KEYS) {
merged[key] = resolveString(rawStrings[key], DEFAULT_STRINGS[key]);
}
}
resolved.stringsByLocale[locale] = merged;
}
}
return resolved;
}
/**
* Strings for one UI language: the per-locale pack dictionary (already
* resolved and clamped) over the legacy/bundled defaults. Unknown or
* empty lang falls back to English via DEFAULT_STRINGS.
*/
function resolveLocalized(config, lang) {
const source = config || DEFAULT_CONFIG;
const localized = source.stringsByLocale instanceof Object ? source.stringsByLocale : {};
const byLocale = LOCALIZED_LOCALES.includes(lang) ? localized[lang] : null;
return Object.assign({}, DEFAULT_STRINGS, source.strings || {}, byLocale || {});
}
// Shape validation with no clamping here — values were verified against
// pack.json hashes in the background before this was stored.
function validateStoredPack(stored, ownVersion) {
if (!isPlainObject(stored) || !isPlainObject(stored.files) || !isPlainObject(stored.texts)) return null;
const packVersion = stored.packVersion;
if (typeof packVersion !== "number" || packVersion < 1) return null;
// A pack that shipped markup this extension doesn't have yet must not
// be adopted (covers the storage-survives-extension-update case).
if (!isCompatible(stored.minExtensionVersion, ownVersion)) return null;
if (typeof stored.maxExtensionVersion === "string"
&& ownVersion.localeCompare(stored.maxExtensionVersion, undefined, { numeric: true }) > 0) {
return null;
}
for (const name of Object.keys(stored.files)) {
if (!PACK_FILES.includes(name) || typeof stored.texts[name] !== "string") return null;
if (stored.texts[name].length > MAX_FILE_CHARS) return null;
}
if (!Object.keys(stored.files).length) return null;
return stored;
}
/**
* Read the stored pack and return {pack, config, tables}. Never touches the
* network; callers should keep the bundled stylesheet as the fallback for
* pack === null. opts.tables overrides the default bundled tables lookup.
*/
async function load(opts) {
let stored = null;
try {
stored = await (await chrome.storage.local.get("designPack")).designPack;
} catch {
stored = null; // no storage access — bundled behavior
}
const pack = validateStoredPack(stored, (globalRoot.chrome?.runtime?.getManifest?.() || { version: "" }).version);
let config = DEFAULT_CONFIG;
let tables = (opts && opts.tables) || defaultTables();
if (pack) {
const configText = pack.texts["config.json"];
if (configText) {
try {
config = resolveConfig(JSON.parse(configText));
} catch {
config = DEFAULT_CONFIG;
}
}
const tablesText = pack.texts["tables.json"];
if (tablesText) {
try {
tables = resolveTables(JSON.parse(tablesText), tables);
} catch {
// keep bundled tables
}
}
}
return { pack, config, tables };
}
return {
DEFAULT_CONFIG,
EMPTY_TABLES,
isCompatible,
resolveConfig,
resolveLocalized,
resolveTables,
validateStoredPack,
load,
};
});