From 3a34630020ca762c4afe5d312ae75752486962e8 Mon Sep 17 00:00:00 2001 From: harsha balakrishnan Date: Mon, 12 Oct 2026 19:14:41 +0530 Subject: [PATCH 1/6] docs: add production readiness baseline --- docs/PRODUCTION_READINESS.md | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) create mode 100644 docs/PRODUCTION_READINESS.md diff --git a/docs/PRODUCTION_READINESS.md b/docs/PRODUCTION_READINESS.md new file mode 100644 index 0000000..8c60acf --- /dev/null +++ b/docs/PRODUCTION_READINESS.md @@ -0,0 +1,24 @@ +# Production readiness + +Scored 0-10 per dimension. A 10 means every item is verifiably complete in the repository. Scores are deliberately conservative. + +Audited at commit `c2eee32` (v0.5.0, main). + +## Baseline (before this work) + +| # | Dimension | Score | Evidence | +|---|-----------|-------|----------| +| 1 | Build and run reproducible | 7 | No build step, Node 20+ declared. No lockfile, no `.nvmrc`, no single verify entry that includes packaging. | +| 2 | Tests | 8 | 91 passing tests: P&L, provider parsing, backoff, vault, CSV, background poll error paths. Popup, options and onboarding UI barely covered; no coverage floor. | +| 3 | Static quality | 4 | Syntax check only. No linter, no formatter config, no type checking. | +| 4 | Security | 8 | Permissions are minimal and tested against code hosts, extension-page CSP, encrypted key vault, sender checks, CSV caps. No dependency audit, no SECURITY.md, no secret scan. | +| 5 | Reliability | 8 | 12 s timeouts, persisted exponential backoff, 30 s cache, stale flags, partial results, storage-quota pruning, settings migration. No schema version for local data. | +| 6 | Config and packaging | 3 | No zip script, no version-consistency check, no `package.json` version. CHANGELOG still "Unreleased" for a shipped 0.5.0. Store assets exist but are not listed. | +| 7 | CI | 4 | One workflow: syntax check and tests only. No lint, audit or package. | +| 8 | Docs | 5 | README, AGENTS.md, PRIVACY.md exist. No ARCHITECTURE, CONTRIBUTING, SECURITY. PRIVACY and STORE_LISTING point to the old repo name; STORE_LISTING is stale. | +| 9 | UX, accessibility, performance | 7 | ARIA labels, reduced-motion and reduced-transparency handled, closed Shadow DOM, transform-only animation. Contrast and heavy-page cost not documented or verified. | +| 10 | Operability | 7 | Local-only bounded diagnostics log with an allowlist (no symbols, no amounts), copy button in Options. No documented triage path. | + +Sensitive-content scan of tracked files: no personal absolute paths, emails, tokens or keys found. Commit author emails are the owner's public noreply-style proton addresses. + +Unmerged remote branches (not touched, not deleted): `feature/live-market-tape` (61 commits ahead), `feature/myticker-mvp`, `feature/tape-layout-reservation`, `docs/release-gate-audit`, `harsha/focused-burnell-333e39`, `backup/local-main-2026-07`, `sync/local-work-20261011`, `chore/sync-local-work-scrubbed`. From e53cd8e7313d27fedef4b7550d3ca8ee6732a890 Mon Sep 17 00:00:00 2001 From: harsha balakrishnan Date: Mon, 12 Oct 2026 19:36:34 +0530 Subject: [PATCH 2/6] build: add ESLint (dev-only), lockfile, .nvmrc; fix unused code it found --- .editorconfig | 9 + .nvmrc | 1 + background.js | 1 - eslint.config.js | 43 ++ onboarding.js | 2 +- options.js | 6 +- package-lock.json | 1010 +++++++++++++++++++++++++++++++++ package.json | 15 +- popup.js | 4 +- priceProviders.js | 2 +- test_fixtures/test_shared.mjs | 1 - 11 files changed, 1083 insertions(+), 11 deletions(-) create mode 100644 .editorconfig create mode 100644 .nvmrc create mode 100644 eslint.config.js create mode 100644 package-lock.json diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..79621be --- /dev/null +++ b/.editorconfig @@ -0,0 +1,9 @@ +root = true + +[*] +charset = utf-8 +end_of_line = lf +insert_final_newline = true +trim_trailing_whitespace = true +indent_style = space +indent_size = 2 diff --git a/.nvmrc b/.nvmrc new file mode 100644 index 0000000..2bd5a0a --- /dev/null +++ b/.nvmrc @@ -0,0 +1 @@ +22 diff --git a/background.js b/background.js index 0324aba..a72d4f4 100644 --- a/background.js +++ b/background.js @@ -15,7 +15,6 @@ import { summarizeMarketState, CONTENT_LIFECYCLE_STAGES, CRYPTO_CATALOG, - resolveCryptoCatalogEntry, normalizeCryptoId } from "./shared.js"; diff --git a/eslint.config.js b/eslint.config.js new file mode 100644 index 0000000..3e0782f --- /dev/null +++ b/eslint.config.js @@ -0,0 +1,43 @@ +import js from "@eslint/js"; +import globals from "globals"; + +const extensionGlobals = { chrome: "readonly" }; + +export default [ + { ignores: ["node_modules/**", "dist/**", "assets/**", "docs/**", ".impeccable/**"] }, + js.configs.recommended, + { + // Shipped ES modules (service worker, popup, options, shared libraries). + files: ["*.js"], + languageOptions: { + ecmaVersion: 2024, + sourceType: "module", + globals: { ...globals.browser, ...globals.serviceworker, ...extensionGlobals } + }, + rules: { + "no-eval": "error", + "no-implied-eval": "error", + "no-new-func": "error", + "no-var": "error", + "prefer-const": "error", + eqeqeq: ["error", "always", { null: "ignore" }], + "no-unused-vars": ["error", { args: "none", caughtErrors: "none" }] + } + }, + { + // Content scripts are classic scripts injected into web pages. + files: ["contentShared.js", "contentScript.js"], + languageOptions: { + sourceType: "script", + globals: { ...globals.browser, ...extensionGlobals } + } + }, + { + files: ["scripts/**/*.mjs", "tests/**/*.mjs", "test_fixtures/**/*.mjs", "eslint.config.js"], + languageOptions: { + ecmaVersion: 2024, + sourceType: "module", + globals: { ...globals.node, ...globals.browser, ...extensionGlobals } + } + } +]; diff --git a/onboarding.js b/onboarding.js index 8078186..e709ab8 100644 --- a/onboarding.js +++ b/onboarding.js @@ -17,7 +17,7 @@ export async function getSetupStatus() { chrome.storage.sync.get([STORAGE_KEYS.settings]) ]); - let vaultStatus = null; + let vaultStatus; try { vaultStatus = await new Promise((resolve) => chrome.runtime.sendMessage({ type: "vault-status", payload: {} }, resolve)); } catch { diff --git a/options.js b/options.js index f189324..8c6be67 100644 --- a/options.js +++ b/options.js @@ -397,13 +397,13 @@ const WIZARD_STEP_TO_TAB = { 3: "data" // go live / status }; -const DATA_PANEL_IDS = new Set(["setup", "market", "diagnostics", "tips"]); +const DATA_PANEL_IDS = ["tab-setup", "tab-market", "tab-diagnostics", "tab-tips"]; function consolidateDataPanels() { const dataPanel = document.getElementById("tab-data"); const workspace = document.getElementById("dataWorkspace"); if (!dataPanel || !workspace) return; - ["tab-setup", "tab-market", "tab-diagnostics", "tab-tips"].forEach((id) => { + DATA_PANEL_IDS.forEach((id) => { const section = document.getElementById(id); if (!section) return; section.removeAttribute("hidden"); @@ -913,7 +913,7 @@ async function handleImportCsv(fileOverride = null) { importInFlight = true; setFinderButtonBusy(true); - let presetKey = brokerPresetEl.value || "zerodha"; + const presetKey = brokerPresetEl.value || "zerodha"; try { const text = await readFileAsText(file); await processCsvText(text, presetKey); diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..001fef5 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,1010 @@ +{ + "name": "myticker-extension", + "version": "0.5.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "myticker-extension", + "version": "0.5.0", + "devDependencies": { + "@eslint/js": "10.0.1", + "eslint": "10.12.0", + "globals": "17.13.0" + }, + "engines": { + "node": ">=20.19" + } + }, + "node_modules/@cacheable/memory": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@cacheable/memory/-/memory-2.2.0.tgz", + "integrity": "sha512-CTLKqLItRCEixEAewD3/j9DB3/o96gpTPD4eJ1v+DGOlxZRZncRQkGYqqnAGCscYd6RNeXfGeiuCphsPtqyIfQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@cacheable/utils": "^2.5.0", + "@keyv/bigmap": "^1.3.1", + "hookified": "^1.15.1", + "keyv": "^5.6.0" + } + }, + "node_modules/@cacheable/utils": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@cacheable/utils/-/utils-2.5.0.tgz", + "integrity": "sha512-buipgOVDkkPXNR5+xBpDw7Zk2n1EvU7qBJCNUcL7rhQ//kfpOXPAvQ511Os0vpLYJ1pZnvudNytkQt2hst3wqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "hashery": "^1.5.1", + "keyv": "^5.6.0" + } + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.10.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.10.1.tgz", + "integrity": "sha512-cuadcxVFE8sDK6iWJbs8Sn0av2Nrh2QSGQhVlBW9AaAHqHwjWsZHT8LJ4hFGPh7ASBV2deFdM7H/DPjulmh8rg==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.23.5", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.23.5.tgz", + "integrity": "sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^3.0.5", + "debug": "^4.3.1", + "minimatch": "^10.2.4" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.7.0.tgz", + "integrity": "sha512-DObd/KKUsU+FaFv4PLxSRenpXfQWmPXXP3pPZ6/K1PCrMu2vQpMDMuQe/BqYeoLcz8ro0bVDF1RxOJgfVEdhUw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^1.2.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/core": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-1.2.1.tgz", + "integrity": "sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/js": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-10.0.1.tgz", + "integrity": "sha512-zeR9k5pd4gxjZ0abRoIaxdc7I3nDktoXZk2qOv9gCNWx3mVwEn32VRhyLaRsDiJjTs0xq/T8mfPtyuXu7GWBcA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "eslint": "^10.0.0" + }, + "peerDependenciesMeta": { + "eslint": { + "optional": true + } + } + }, + "node_modules/@eslint/object-schema": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-3.0.5.tgz", + "integrity": "sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.7.3", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.7.3.tgz", + "integrity": "sha512-IkO+/KEUvwbVpiURZg+P7zF74z5Jxe0UgJxVni+RtoHQ6IZieXaO02kmadomap/q+l6bc/jdPGGqTjhuZnuz1Q==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^1.2.1", + "levn": "^0.4.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@humanfs/core": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", + "integrity": "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/types": "^0.15.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.8", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.8.tgz", + "integrity": "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.2", + "@humanfs/types": "^0.15.0", + "@humanwhocodes/retry": "^0.4.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/types": { + "version": "0.15.0", + "resolved": "https://registry.npmjs.org/@humanfs/types/-/types-0.15.0.tgz", + "integrity": "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@keyv/bigmap": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@keyv/bigmap/-/bigmap-1.3.1.tgz", + "integrity": "sha512-WbzE9sdmQtKy8vrNPa9BRnwZh5UF4s1KTmSK0KUVLo3eff5BlQNNWDnFOouNpKfPKDnms9xynJjsMYjMaT/aFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "hashery": "^1.4.0", + "hookified": "^1.15.0" + }, + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "keyv": "^5.6.0" + } + }, + "node_modules/@keyv/serialize": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@keyv/serialize/-/serialize-1.1.1.tgz", + "integrity": "sha512-dXn3FZhPv0US+7dtJsIi2R+c7qWYiReoEh5zUntWCf4oSpMNib8FDhSoed6m3QyZdx5hK7iLFkYk3rNxwt8vTA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/esrecurse": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz", + "integrity": "sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/acorn": { + "version": "8.19.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.19.0.tgz", + "integrity": "sha512-oJlA3XiRm7Cyk6qFD2Jn8ak9B7jSy0qy00ADO3+8dpT0LSjFihQYv4C02LFCSYJV3Q37xYwwRy5m+IpIiUzqWw==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/ajv": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/brace-expansion": { + "version": "5.0.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz", + "integrity": "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/cacheable": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/cacheable/-/cacheable-2.5.0.tgz", + "integrity": "sha512-60cyAOytib/OzBw1JNSoSV/boK1AtHryDIjvVBk7XbN4ugfkM3+Sry7fEjNgPMGgOjuaZPAp8ruZ0Cxafwyq9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@cacheable/memory": "^2.2.0", + "@cacheable/utils": "^2.5.0", + "hookified": "^1.15.0", + "keyv": "^5.6.0", + "qified": "^0.10.1" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "10.12.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.12.0.tgz", + "integrity": "sha512-npHjrHb2o4ehH3mE+YuxABR+0gyb0aVWCIolgJDArwCiHEIsonBm3ZKjel5b0U5+a+MbEvIyRCGCzD3eKKK2NA==", + "dev": true, + "license": "MIT", + "workspaces": [ + "packages/*" + ], + "dependencies": { + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.2", + "@eslint/config-array": "^0.23.5", + "@eslint/config-helpers": "^0.7.0", + "@eslint/core": "^1.2.1", + "@eslint/plugin-kit": "^0.7.3", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "ajv": "^6.14.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^9.1.2", + "eslint-visitor-keys": "^5.0.1", + "espree": "^11.2.0", + "esquery": "^1.7.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "11.1.5 || >11.1.6 <12", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "minimatch": "^10.2.5", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-scope": { + "version": "9.1.2", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-9.1.2.tgz", + "integrity": "sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "@types/esrecurse": "^4.3.1", + "@types/estree": "^1.0.8", + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/espree": { + "version": "11.2.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-11.2.0.tgz", + "integrity": "sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.16.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^5.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esquery": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, + "node_modules/file-entry-cache": { + "version": "11.1.5", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-11.1.5.tgz", + "integrity": "sha512-+PFTHITI08JIGhnNpGNI8T8inUpgZfk3GNEqfT9R2zZV2iFXg3CvqzSl/uEhs7TSGujYRELEANyDvS8Fj7+S7Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^6.1.23" + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "6.1.23", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-6.1.23.tgz", + "integrity": "sha512-f++BY9pTk+983xK1FLzlLpmM0i0z+jHmx3QESGkURMXujQZz1k5wzwX6hjnQ8goaD0B+sYnDK1yZ6MTyZfUaqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "cacheable": "^2.5.0", + "flatted": "^3.4.2", + "hookified": "^1.15.0" + } + }, + "node_modules/flatted": { + "version": "3.4.4", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.4.tgz", + "integrity": "sha512-5+ybhBZANEJxaH3X5evAFatUxLfEHSr7n6kYJ+1Qd0mUqr4eu9gIf6GDbWHf8RJijHrjjO8G+la14SlL2SeS1Q==", + "dev": true, + "license": "ISC" + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/globals": { + "version": "17.13.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-17.13.0.tgz", + "integrity": "sha512-RwMTC61u7hrG3ZJMkZQOK4JLJrKS8QtoTii63EmWvFXHqycY9FObBJQCbsLxSO8kjKhWE5kV1GC+Vb1g3RI0Zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/hashery": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/hashery/-/hashery-1.5.1.tgz", + "integrity": "sha512-iZyKG96/JwPz1N55vj2Ie2vXbhu440zfUfJvSwEqEbeLluk7NnapfGqa7LH0mOsnDxTF85Mx8/dyR6HfqcbmbQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "hookified": "^1.15.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/hookified": { + "version": "1.15.1", + "resolved": "https://registry.npmjs.org/hookified/-/hookified-1.15.1.tgz", + "integrity": "sha512-MvG/clsADq1GPM2KGo2nyfaWVyn9naPiXrqIe4jYjXNZQt238kWyOGrsyc/DmRAQ+Re6yeo6yX/yoNCG5KAEVg==", + "dev": true, + "license": "MIT" + }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/keyv": { + "version": "5.6.0", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-5.6.0.tgz", + "integrity": "sha512-CYDD3SOtsHtyXeEORYRx2qBtpDJFjRTGXUtmNEMGyzYOKj1TE3tycdlho7kA1Ufx9OYWZzg52QFBGALTirzDSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@keyv/serialize": "^1.1.1" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/minimatch": { + "version": "10.2.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", + "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.8" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/qified": { + "version": "0.10.1", + "resolved": "https://registry.npmjs.org/qified/-/qified-0.10.1.tgz", + "integrity": "sha512-+Owyggi9IxT1ePKGafcI87ubSmxol6smwJ+RAHDQlx9+9cPwFWDiKFFCPuWhr9ignlGpZ9vDQLw67N4dcTVFEA==", + "dev": true, + "license": "MIT", + "dependencies": { + "hookified": "^2.1.1" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/qified/node_modules/hookified": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/hookified/-/hookified-2.2.0.tgz", + "integrity": "sha512-p/LgFzRN5FeoD3DLS6bkUapeye6E4SI6yJs6KetENd18S+FBthqYq2amJUWpt5z0EQwwHemidjY5OqJGEKm5uA==", + "dev": true, + "license": "MIT" + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + } + } +} diff --git a/package.json b/package.json index ecc91c8..ce10645 100644 --- a/package.json +++ b/package.json @@ -1,14 +1,25 @@ { "name": "myticker-extension", + "version": "0.5.0", "private": true, "type": "module", "description": "MyTicker Chrome extension (no build step, no runtime dependencies)", "scripts": { "test": "node --test test_fixtures/test_*.mjs tests/*.test.mjs", "check": "node scripts/check-syntax.mjs", - "verify": "npm run check && npm test" + "lint": "eslint .", + "check:version": "node scripts/check-version.mjs", + "scan": "node scripts/scan-sensitive.mjs", + "audit:deps": "npm audit --audit-level=low", + "package": "node scripts/package.mjs", + "verify": "npm run check && npm run lint && npm run check:version && npm run scan && npm test" }, "engines": { - "node": ">=20" + "node": ">=20.19" + }, + "devDependencies": { + "@eslint/js": "10.0.1", + "eslint": "10.12.0", + "globals": "17.13.0" } } diff --git a/popup.js b/popup.js index d111b4f..12ce47e 100644 --- a/popup.js +++ b/popup.js @@ -98,7 +98,7 @@ function setTab(tab, mainContent) { function handleTabKeydown(event, mainContent) { const tabs = [document.getElementById("tabHoldings"), document.getElementById("tabWatchlist")].filter(Boolean); const currentIndex = tabs.indexOf(event.currentTarget); - let nextIndex = currentIndex; + let nextIndex; if (event.key === "ArrowRight" || event.key === "ArrowDown") nextIndex = (currentIndex + 1) % tabs.length; else if (event.key === "ArrowLeft" || event.key === "ArrowUp") nextIndex = (currentIndex - 1 + tabs.length) % tabs.length; else if (event.key === "Home") nextIndex = 0; @@ -321,7 +321,7 @@ export function updatePnlInPlace(viewEl, state, watchlistItems) { } if (!state?.positions?.length) return; - const { currency, dayPnl, dayDisplay, fiveDisplay } = aggregateDisplays(state); + const { currency, dayDisplay, fiveDisplay } = aggregateDisplays(state); const pnlValue = viewEl.querySelector(".pnl-value"); const pnlPct = viewEl.querySelector(".pnl-pct"); diff --git a/priceProviders.js b/priceProviders.js index 4514b8c..185820e 100644 --- a/priceProviders.js +++ b/priceProviders.js @@ -230,7 +230,7 @@ export async function getCryptoQuotes(ids, providers = {}, { backoff } = {}) { const uniqueIds = [...new Set((ids || []).filter(Boolean))]; const coinGecko = providers.coinGecko || new CoinGeckoPriceProvider({ backoff }); const binance = providers.binance || new BinancePriceProvider({ backoff }); - let coinGeckoQuotes = []; + let coinGeckoQuotes; try { coinGeckoQuotes = await coinGecko.getQuotes(uniqueIds); diff --git a/test_fixtures/test_shared.mjs b/test_fixtures/test_shared.mjs index 2245daf..381bb7a 100644 --- a/test_fixtures/test_shared.mjs +++ b/test_fixtures/test_shared.mjs @@ -21,7 +21,6 @@ import { normalizeExcludedSites, isHostTapeExcluded, migrateSettings, - CRYPTO_CATALOG, normalizeWatchlistSymbol, resolveCryptoCatalogEntry, normalizeCryptoConfig, From ed21303a65418a16b3300a61cccfe399006d3b2d Mon Sep 17 00:00:00 2001 From: harsha balakrishnan Date: Mon, 12 Oct 2026 19:36:34 +0530 Subject: [PATCH 3/6] build: add deterministic store packager, version and sensitive-content checks --- .gitignore | 1 + scripts/check-version.mjs | 32 ++++++++ scripts/package.mjs | 162 +++++++++++++++++++++++++++++++++++++ scripts/scan-sensitive.mjs | 45 +++++++++++ tests/package.test.mjs | 51 ++++++++++++ 5 files changed, 291 insertions(+) create mode 100644 scripts/check-version.mjs create mode 100644 scripts/package.mjs create mode 100644 scripts/scan-sensitive.mjs create mode 100644 tests/package.test.mjs diff --git a/.gitignore b/.gitignore index 0f46242..eda33c2 100644 --- a/.gitignore +++ b/.gitignore @@ -31,3 +31,4 @@ MyTicker.pem .worktrees/ * 2.* .DS_Store +test_fixtures/user_holdings.csv diff --git a/scripts/check-version.mjs b/scripts/check-version.mjs new file mode 100644 index 0000000..f03b29c --- /dev/null +++ b/scripts/check-version.mjs @@ -0,0 +1,32 @@ +// Version consistency: manifest.json is the source of truth. package.json, +// package-lock.json, the README badge and the CHANGELOG must agree with it. +// Run with: npm run check:version +import { readFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +const root = join(dirname(fileURLToPath(import.meta.url)), ".."); +const read = (file) => readFileSync(join(root, file), "utf8"); +const version = JSON.parse(read("manifest.json")).version; + +const problems = []; +const expect = (label, actual) => { + if (actual !== version) problems.push(`${label} is ${actual ?? "missing"}, manifest.json is ${version}`); +}; + +if (!/^\d+\.\d+\.\d+$/.test(version)) problems.push(`manifest version "${version}" is not MAJOR.MINOR.PATCH`); +expect("package.json version", JSON.parse(read("package.json")).version); +const lock = JSON.parse(read("package-lock.json")); +expect("package-lock.json version", lock.version); +expect("package-lock.json root package version", lock.packages?.[""]?.version); +expect("README version badge", /badge\/version-(\d+\.\d+\.\d+)-/.exec(read("README.md"))?.[1]); +const changelog = read("CHANGELOG.md"); +if (!changelog.includes("## Unreleased") && !new RegExp(`^## \\[?${version.replaceAll(".", "\\.")}\\]?`, "m").test(changelog)) { + problems.push(`CHANGELOG.md has neither an "Unreleased" section nor a "${version}" section`); +} + +if (problems.length) { + for (const problem of problems) console.error(`FAIL ${problem}`); + process.exit(1); +} +console.log(`OK version ${version} is consistent across manifest, package, lockfile, README and CHANGELOG`); diff --git a/scripts/package.mjs b/scripts/package.mjs new file mode 100644 index 0000000..ec74985 --- /dev/null +++ b/scripts/package.mjs @@ -0,0 +1,162 @@ +// Builds the Chrome Web Store upload: dist/myticker-.zip +// +// Only files the extension actually loads are included (manifest references, +// local imports, HTML script/style/icon references, and the packaged sample +// CSV). Docs, tests, assets, node_modules, lockfiles, signing keys and any +// local holdings file are never shipped. The archive is deterministic: sorted +// entries and a fixed timestamp, so the same sources give the same bytes. +// +// Run with: npm run package (does not publish anything) +import { createHash } from "node:crypto"; +import { existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from "node:fs"; +import { dirname, join, posix } from "node:path"; +import { fileURLToPath } from "node:url"; +import { deflateRawSync } from "node:zlib"; + +export const ROOT = join(dirname(fileURLToPath(import.meta.url)), ".."); + +// Files fetched at runtime via chrome.runtime.getURL that static scanning cannot see. +const RUNTIME_FILES = ["test_fixtures/sample_holdings_zerodha.csv"]; +const FORBIDDEN = [/\.pem$/i, /\.crx$/i, /\.zip$/i, /\.env/i, /user_holdings/i, /(^|\/)node_modules\//, /(^|\/)\.git/, /\.DS_Store$/]; + +function readText(file) { + return readFileSync(join(ROOT, file), "utf8"); +} + +function localRefs(file, text) { + const refs = []; + const add = (ref) => { + const clean = ref.split(/[?#]/)[0]; + if (!clean || /^(?:[a-z]+:|\/\/|data:)/i.test(clean)) return; + refs.push(posix.normalize(posix.join(posix.dirname(file), clean))); + }; + if (file.endsWith(".js")) { + for (const match of text.matchAll(/\bfrom\s*["'](\.[^"']+)["']|\bimport\s*\(\s*["'](\.[^"']+)["']\s*\)/g)) add(match[1] || match[2]); + } else if (file.endsWith(".html")) { + for (const match of text.matchAll(/\b(?:src|href)\s*=\s*"([^"]+)"/g)) add(match[1]); + } else if (file.endsWith(".css")) { + for (const match of text.matchAll(/@import\s+(?:url\()?["']([^"']+)["']/g)) add(match[1]); + } + return refs; +} + +/** Sorted list of repo-relative files that make up the extension. */ +export function collectFiles() { + const manifest = JSON.parse(readText("manifest.json")); + const queue = [ + "manifest.json", + manifest.background?.service_worker, + manifest.action?.default_popup, + manifest.options_page, + ...Object.values(manifest.icons || {}), + ...Object.values(manifest.action?.default_icon || {}), + ...(manifest.content_scripts || []).flatMap((entry) => [...(entry.js || []), ...(entry.css || [])]), + ...(manifest.web_accessible_resources || []).flatMap((entry) => entry.resources || []), + ...RUNTIME_FILES + ].filter(Boolean); + const seen = new Set(); + while (queue.length) { + const file = queue.pop(); + if (seen.has(file)) continue; + if (!existsSync(join(ROOT, file))) throw new Error(`Referenced file is missing: ${file}`); + seen.add(file); + if (/\.(?:js|html|css)$/.test(file)) queue.push(...localRefs(file, readText(file))); + } + const files = [...seen].sort(); + for (const file of files) { + if (FORBIDDEN.some((pattern) => pattern.test(file))) throw new Error(`Refusing to package forbidden file: ${file}`); + } + return files; +} + +const CRC_TABLE = Array.from({ length: 256 }, (_, n) => { + let c = n; + for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1; + return c >>> 0; +}); + +function crc32(buffer) { + let crc = 0xffffffff; + for (const byte of buffer) crc = CRC_TABLE[(crc ^ byte) & 0xff] ^ (crc >>> 8); + return (crc ^ 0xffffffff) >>> 0; +} + +// 1980-01-01 00:00:00 in DOS time/date format. +const DOS_TIME = 0; +const DOS_DATE = (0 << 9) | (1 << 5) | 1; + +/** Create a deterministic ZIP archive from [{ name, data }] entries. */ +export function createZip(entries) { + const locals = []; + const centrals = []; + let offset = 0; + for (const { name, data } of entries) { + const nameBuffer = Buffer.from(name, "utf8"); + const deflated = deflateRawSync(data, { level: 9 }); + const useDeflate = deflated.length < data.length; + const body = useDeflate ? deflated : data; + const method = useDeflate ? 8 : 0; + const crc = crc32(data); + + const local = Buffer.alloc(30); + local.writeUInt32LE(0x04034b50, 0); + local.writeUInt16LE(20, 4); + local.writeUInt16LE(0x0800, 6); // UTF-8 names + local.writeUInt16LE(method, 8); + local.writeUInt16LE(DOS_TIME, 10); + local.writeUInt16LE(DOS_DATE, 12); + local.writeUInt32LE(crc, 14); + local.writeUInt32LE(body.length, 18); + local.writeUInt32LE(data.length, 22); + local.writeUInt16LE(nameBuffer.length, 26); + locals.push(local, nameBuffer, body); + + const central = Buffer.alloc(46); + central.writeUInt32LE(0x02014b50, 0); + central.writeUInt16LE(20, 4); + central.writeUInt16LE(20, 6); + central.writeUInt16LE(0x0800, 8); + central.writeUInt16LE(method, 10); + central.writeUInt16LE(DOS_TIME, 12); + central.writeUInt16LE(DOS_DATE, 14); + central.writeUInt32LE(crc, 16); + central.writeUInt32LE(body.length, 20); + central.writeUInt32LE(data.length, 24); + central.writeUInt16LE(nameBuffer.length, 28); + central.writeUInt32LE(0o100644 << 16 >>> 0, 38); + central.writeUInt32LE(offset, 42); + centrals.push(central, nameBuffer); + + offset += 30 + nameBuffer.length + body.length; + } + const centralSize = centrals.reduce((sum, part) => sum + part.length, 0); + const end = Buffer.alloc(22); + end.writeUInt32LE(0x06054b50, 0); + end.writeUInt16LE(entries.length, 8); + end.writeUInt16LE(entries.length, 10); + end.writeUInt32LE(centralSize, 12); + end.writeUInt32LE(offset, 16); + return Buffer.concat([...locals, ...centrals, end]); +} + +export function buildPackage() { + const manifest = JSON.parse(readText("manifest.json")); + const pkg = JSON.parse(readText("package.json")); + if (manifest.version !== pkg.version) { + throw new Error(`manifest.json (${manifest.version}) and package.json (${pkg.version}) versions differ`); + } + const files = collectFiles(); + const zip = createZip(files.map((name) => ({ name, data: readFileSync(join(ROOT, name)) }))); + return { version: manifest.version, files, zip }; +} + +if (process.argv[1] === fileURLToPath(import.meta.url)) { + const { version, files, zip } = buildPackage(); + const outDir = join(ROOT, "dist"); + mkdirSync(outDir, { recursive: true }); + const out = join(outDir, `myticker-${version}.zip`); + writeFileSync(out, zip); + console.log(`Packaged ${files.length} files into dist/myticker-${version}.zip`); + console.log(`size ${statSync(out).size} bytes`); + console.log(`sha256 ${createHash("sha256").update(zip).digest("hex")}`); +} diff --git a/scripts/scan-sensitive.mjs b/scripts/scan-sensitive.mjs new file mode 100644 index 0000000..e37eefd --- /dev/null +++ b/scripts/scan-sensitive.mjs @@ -0,0 +1,45 @@ +// Fails if a tracked file looks like it contains a secret or personal data. +// This repository is public: run before every push. Run with: npm run scan +import { execFileSync } from "node:child_process"; +import { readFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +const root = join(dirname(fileURLToPath(import.meta.url)), ".."); +const PATTERNS = [ + ["absolute home path", /(?:\/Users\/|\/home\/)[A-Za-z0-9._-]+\/|[A-Za-z]:\\Users\\/], + ["private key block", /-----BEGIN [A-Z ]*PRIVATE KEY-----/], + ["GitHub token", /\bgh[pousr]_[A-Za-z0-9]{30,}\b/], + ["Slack token", /\bxox[abprs]-[A-Za-z0-9-]{10,}\b/], + ["AWS access key", /\bAKIA[0-9A-Z]{16}\b/], + ["API token in URL", /[?&](?:token|apikey|api_key|key)=[A-Za-z0-9]{16,}/i], + ["assigned secret literal", /\b(?:api[_-]?key|secret|password|passwd)\b["']?\s*[:=]\s*["'][A-Za-z0-9/+_-]{16,}["']/i] +]; +const FORBIDDEN_FILES = [/\.pem$/i, /\.crx$/i, /\.zip$/i, /(^|\/)\.env/i, /user_holdings/i]; +// Documented placeholder values that are not secrets. +const ALLOW = [/token=\$\{/, /legacy-sync-secret/]; + +const files = execFileSync("git", ["ls-files", "-z"], { cwd: root, encoding: "utf8" }).split("\0").filter(Boolean); +const findings = []; +for (const file of files) { + if (FORBIDDEN_FILES.some((pattern) => pattern.test(file))) findings.push(`${file}: forbidden file type`); + if (/\.(?:png|jpg|jpeg|gif|webp|ico|zip)$/i.test(file) || file === "package-lock.json" || file === "scripts/scan-sensitive.mjs") continue; + let text; + try { + text = readFileSync(join(root, file), "utf8"); + } catch { + continue; + } + text.split("\n").forEach((line, index) => { + if (ALLOW.some((pattern) => pattern.test(line))) return; + for (const [label, pattern] of PATTERNS) { + if (pattern.test(line)) findings.push(`${file}:${index + 1}: ${label}`); + } + }); +} + +if (findings.length) { + for (const finding of findings) console.error(`FAIL ${finding}`); + process.exit(1); +} +console.log(`OK scanned ${files.length} tracked files: no secrets, personal paths or forbidden file types`); diff --git a/tests/package.test.mjs b/tests/package.test.mjs new file mode 100644 index 0000000..72960c9 --- /dev/null +++ b/tests/package.test.mjs @@ -0,0 +1,51 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { inflateRawSync } from "node:zlib"; +import { join } from "node:path"; +import { ROOT, buildPackage, collectFiles, createZip } from "../scripts/package.mjs"; + +function readZip(buffer) { + const end = buffer.lastIndexOf(Buffer.from([0x50, 0x4b, 0x05, 0x06])); + const count = buffer.readUInt16LE(end + 10); + let offset = buffer.readUInt32LE(end + 16); + const entries = new Map(); + for (let i = 0; i < count; i++) { + const method = buffer.readUInt16LE(offset + 10); + const size = buffer.readUInt32LE(offset + 20); + const nameLength = buffer.readUInt16LE(offset + 28); + const localOffset = buffer.readUInt32LE(offset + 42); + const name = buffer.toString("utf8", offset + 46, offset + 46 + nameLength); + const localNameLength = buffer.readUInt16LE(localOffset + 26); + const start = localOffset + 30 + localNameLength; + const body = buffer.subarray(start, start + size); + entries.set(name, method === 8 ? inflateRawSync(body) : body); + offset += 46 + nameLength; + } + return entries; +} + +test("the store package contains exactly what the extension loads, and round-trips", () => { + const { files, zip, version } = buildPackage(); + assert.equal(version, JSON.parse(readFileSync(join(ROOT, "manifest.json"), "utf8")).version); + const entries = readZip(zip); + assert.deepEqual([...entries.keys()], files); + for (const file of files) assert.ok(entries.get(file).equals(readFileSync(join(ROOT, file))), `${file} differs`); + for (const required of ["manifest.json", "background.js", "contentScript.js", "contentShared.js", "popup.html", "options.html", "ticker.css", "brand.css", "motion.css", "vault.js", "csvParser.js", "priceProviders.js", "icons/icon128.png", "icons/phosphor-symbols.svg", "test_fixtures/sample_holdings_zerodha.csv"]) { + assert.ok(entries.has(required), `${required} must be packaged`); + } +}); + +test("the package never ships docs, tests, secrets, lockfiles, dev tooling or personal holdings", () => { + for (const file of collectFiles()) { + assert.doesNotMatch(file, /^(docs|tests|scripts|assets|node_modules|\.github|\.impeccable|dist)\//, file); + assert.doesNotMatch(file, /\.(md|pem|crx|zip|env)$|package(-lock)?\.json$|eslint\.config|user_holdings/, file); + } + assert.ok(!collectFiles().some((file) => file.startsWith("test_fixtures/") && file !== "test_fixtures/sample_holdings_zerodha.csv")); +}); + +test("packaging is deterministic", () => { + assert.ok(buildPackage().zip.equals(buildPackage().zip)); + const small = createZip([{ name: "a.txt", data: Buffer.from("hello hello hello hello") }]); + assert.equal(readZip(small).get("a.txt").toString(), "hello hello hello hello"); +}); From f8cc7f99710e54acee3ab3df4e68792107d74718 Mon Sep 17 00:00:00 2001 From: harsha balakrishnan Date: Mon, 12 Oct 2026 19:38:32 +0530 Subject: [PATCH 4/6] security: restrict extension-page CSP connect-src, base-uri, form-action, frames; pin in tests --- manifest.json | 2 +- test_fixtures/test_ui_copy.mjs | 2 +- tests/network-boundary.test.mjs | 27 +++++++++++++++++++++++++++ 3 files changed, 29 insertions(+), 2 deletions(-) diff --git a/manifest.json b/manifest.json index b0acaa5..346c119 100644 --- a/manifest.json +++ b/manifest.json @@ -24,7 +24,7 @@ "type": "module" }, "content_security_policy": { - "extension_pages": "script-src 'self'; object-src 'self';" + "extension_pages": "script-src 'self'; object-src 'self'; connect-src 'self' https://finnhub.io https://query1.finance.yahoo.com https://api.coingecko.com https://data-api.binance.vision; base-uri 'none'; form-action 'none'; frame-src 'none';" }, "permissions": [ "storage", diff --git a/test_fixtures/test_ui_copy.mjs b/test_fixtures/test_ui_copy.mjs index 1ed37bb..93aeb94 100644 --- a/test_fixtures/test_ui_copy.mjs +++ b/test_fixtures/test_ui_copy.mjs @@ -106,7 +106,7 @@ const expectedProviderHosts = [ "https://data-api.binance.vision/*" ]; assert(JSON.stringify(manifest.host_permissions) === JSON.stringify(expectedProviderHosts), "declares only the required Yahoo, CoinGecko, Binance, and Finnhub provider hosts"); -assert(manifest.content_security_policy?.extension_pages === "script-src 'self'; object-src 'self';", "pins extension pages to self-hosted scripts with an explicit MV3 CSP"); +assert(/^script-src 'self'; object-src 'self'; /.test(manifest.content_security_policy?.extension_pages || ""), "pins extension pages to self-hosted scripts with an explicit MV3 CSP"); assert(manifest.permissions?.length === 2 && manifest.permissions.includes("storage") && manifest.permissions.includes("alarms"), "requests only storage and alarms extension permissions"); assert(manifest.web_accessible_resources?.length === 1 && manifest.web_accessible_resources[0]?.resources?.length === 1 && manifest.web_accessible_resources[0]?.resources?.[0] === "ticker.css" && manifest.web_accessible_resources[0]?.matches?.join() === "", "exposes only the tape stylesheet to pages where the closed shadow-root tape needs it"); const releaseCopy = `${readmeSource}\n${privacySource}\n${storeListingSource}`; diff --git a/tests/network-boundary.test.mjs b/tests/network-boundary.test.mjs index e5b9ede..32cb62e 100644 --- a/tests/network-boundary.test.mjs +++ b/tests/network-boundary.test.mjs @@ -38,3 +38,30 @@ test("console output never interpolates symbols, keys, or URLs", async () => { } } }); + +test("extension-page CSP blocks remote code and limits connections to the declared hosts", async () => { + const manifest = JSON.parse(await read("manifest.json")); + const csp = Object.fromEntries( + manifest.content_security_policy.extension_pages.split(";").map((part) => part.trim()).filter(Boolean).map((part) => { + const [name, ...values] = part.split(/\s+/); + return [name, values]; + }) + ); + assert.deepEqual(csp["script-src"], ["'self'"]); + assert.deepEqual(csp["object-src"], ["'self'"]); + assert.deepEqual(csp["base-uri"], ["'none'"]); + assert.deepEqual(csp["form-action"], ["'none'"]); + assert.deepEqual(csp["frame-src"], ["'none'"]); + const hosts = manifest.host_permissions.map((p) => p.replace("/*", "")); + assert.deepEqual(csp["connect-src"], ["'self'", ...hosts]); +}); + +test("no inline event handlers, inline scripts or remote scripts in extension pages", async () => { + for (const file of ["popup.html", "options.html"]) { + const html = await read(file); + assert.doesNotMatch(html, /\son[a-z]+\s*=\s*["']/i, `${file}: inline handler`); + assert.doesNotMatch(html, /]*\ssrc=)[^>]*>/i, `${file}: inline script`); + assert.doesNotMatch(html, /]*\ssrc=["']https?:/i, `${file}: remote script`); + assert.doesNotMatch(html, /]*href=["']https?:/i, `${file}: remote stylesheet`); + } +}); From 6de5c5234e9d1776747da1e80de73355e7da5ad6 Mon Sep 17 00:00:00 2001 From: harsha balakrishnan Date: Mon, 12 Oct 2026 19:39:09 +0530 Subject: [PATCH 5/6] fix: ignore malformed stored holdings instead of failing every refresh --- background.js | 14 ++++++++++++-- tests/background-poll.test.mjs | 11 +++++++++++ 2 files changed, 23 insertions(+), 2 deletions(-) diff --git a/background.js b/background.js index a72d4f4..f2e94e2 100644 --- a/background.js +++ b/background.js @@ -393,7 +393,7 @@ async function handlePricePoll() { const settings = migrateSettings(syncData[STORAGE_KEYS.settings]); if (!settings.enabled) return; - const baseHoldings = localData[STORAGE_KEYS.holdings] || []; + const baseHoldings = sanitizeStoredHoldings(localData[STORAGE_KEYS.holdings]); const watchlist = normalizeWatchlist(localData[STORAGE_KEYS.watchlist]); const crypto = buildCryptoTickerItems(settings); const diagnosticCounts = { @@ -502,7 +502,7 @@ async function handlePricePoll() { // Holdings may have been re-imported or cleared while quotes were in // flight; writing now would resurrect deleted positions. Skip this write; // the poll requested by the import will rebuild state. - const latestHoldings = (await chrome.storage.local.get([STORAGE_KEYS.holdings]))[STORAGE_KEYS.holdings] || []; + const latestHoldings = sanitizeStoredHoldings((await chrome.storage.local.get([STORAGE_KEYS.holdings]))[STORAGE_KEYS.holdings]); if (JSON.stringify(latestHoldings) !== JSON.stringify(baseHoldings)) { return; } @@ -541,6 +541,16 @@ async function handlePricePoll() { } } +/** + * Stored holdings come from CSV import, but storage can be corrupted or edited + * (or written by another version). A non-array or malformed entry must not + * make every refresh fail, so keep only well-formed rows. + */ +function sanitizeStoredHoldings(value) { + if (!Array.isArray(value)) return []; + return value.filter((item) => item && typeof item === "object" && typeof item.symbol === "string" && item.symbol.trim() && Number.isFinite(Number(item.quantity))); +} + function buildCombinedHoldings(baseHoldings, settings) { const filters = settings.portfolioFilters || DEFAULT_SETTINGS.portfolioFilters; if (!filters.showStocks) return []; diff --git a/tests/background-poll.test.mjs b/tests/background-poll.test.mjs index b40ae34..533ff41 100644 --- a/tests/background-poll.test.mjs +++ b/tests/background-poll.test.mjs @@ -120,3 +120,14 @@ test("messages: unknown types and non-extension senders get no response and no s assert.equal(calls.length, 0); assert.equal(fake.local.has("pts_finnhub_vault"), false); }); + +test("corrupted stored holdings are ignored instead of failing every refresh", async () => { + for (const [tag, bad] of [["string", "oops"], ["object", { a: 1 }], ["mixed", [null, 5, { symbol: 7, quantity: 1 }, { symbol: "TCS.NS", quantity: "x" }, { symbol: "TCS.NS", quantity: 2, currency: "INR" }]]]) { + const { fake } = setup(() => jsonResponse(yahoo), { pts_holdings: bad }); + await loadBackground(fake, `corrupt-${tag}`); + assert.deepEqual(await send(fake.listeners, { type: "poll-now", payload: {} }), { ok: true }, tag); + const state = fake.local.get("pts_positions_state"); + if (tag === "mixed") assert.deepEqual(state.positions.map((p) => p.symbol), ["TCS.NS"]); + else assert.ok(!state || state.positions.length === 0, tag); + } +}); From c267b6e7f26b32564b00ef2dd605c047b3f8d16f Mon Sep 17 00:00:00 2001 From: harsha balakrishnan Date: Tue, 13 Oct 2026 09:56:54 +0530 Subject: [PATCH 6/6] docs: add architecture, contributing, security, releasing; refresh store listing; widen CI; readiness scores --- .github/workflows/test.yml | 13 +++++++++ AGENTS.md | 7 +++-- ARCHITECTURE.md | 55 ++++++++++++++++++++++++++++++++++++ CHANGELOG.md | 6 ++++ CONTRIBUTING.md | 26 +++++++++++++++++ PRIVACY.md | 2 +- README.md | 10 +++++-- RELEASING.md | 10 +++++++ SECURITY.md | 22 +++++++++++++++ STORE_LISTING.md | 34 +++++++++++++++++++--- docs/PRODUCTION_READINESS.md | 29 +++++++++++++++++++ 11 files changed, 204 insertions(+), 10 deletions(-) create mode 100644 ARCHITECTURE.md create mode 100644 CONTRIBUTING.md create mode 100644 RELEASING.md create mode 100644 SECURITY.md diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index dd33f24..7b887f9 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -23,7 +23,20 @@ jobs: uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 with: node-version: ${{ matrix.node-version }} + cache: npm + - name: Install dev dependencies + run: npm ci - name: Syntax and manifest checks run: npm run check + - name: Lint + run: npm run lint + - name: Version consistency + run: npm run check:version + - name: Sensitive-content scan + run: npm run scan - name: Test suite run: npm test + - name: Dependency audit + run: npm run audit:deps + - name: Package extension + run: npm run package diff --git a/AGENTS.md b/AGENTS.md index a05b339..379a4a4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,11 +1,14 @@ # AGENTS.md -MyTicker: Manifest V3 Chrome extension (ticker strip with local portfolio P&L). No build step, no runtime dependencies, Node 20+. +MyTicker: Manifest V3 Chrome extension (ticker strip with local portfolio P&L). No build step, no runtime dependencies, Node 20.19+ (dev-only ESLint). ## Commands - `npm run check` - syntax-check shipped scripts and verify manifest file references - `npm test` - `node --test` over `tests/*.test.mjs` and `test_fixtures/test_*.mjs` -- `npm run verify` - both of the above +- `npm run lint` - ESLint; `npm run scan` - secret/personal-path scan (repo is public) +- `npm run verify` - check, lint, version consistency, scan, tests +- `npm run package` - build `dist/myticker-.zip` (never publish) +- Docs: ARCHITECTURE.md, CONTRIBUTING.md, SECURITY.md, RELEASING.md ## Layout - `background.js` service worker (polling, state); `contentScript.js` strip UI; `popup.*` scoreboard; `options.*` setup/import diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md new file mode 100644 index 0000000..12dea9c --- /dev/null +++ b/ARCHITECTURE.md @@ -0,0 +1,55 @@ +# Architecture + +MyTicker is a Manifest V3 Chrome extension with no build step and no runtime dependencies. Everything runs in the browser; there is no MyTicker server. + +## Components + +| File | Context | Responsibility | +|------|---------|----------------| +| `background.js` | Service worker (module) | Polls providers on a `chrome.alarms` alarm, merges quotes, writes state, handles messages from extension pages | +| `contentScript.js`, `contentShared.js` | Content script, all pages, top frame only | Renders the strip inside a closed Shadow DOM; reads state from storage; never reads page content | +| `popup.*`, `onboarding.js` | Extension page | P&L scoreboard and setup checklist | +| `options.*`, `csvParser.js` | Extension page | Key vault, CSV import, settings, diagnostics copy button | +| `shared.js` | Shared | Storage keys, settings migration, P&L maths, freshness and activation rules | +| `priceProviders.js` | Service worker | Yahoo, Finnhub, CoinGecko and Binance fetchers and response parsers | +| `vault.js` | Extension contexts | AES-GCM vault for the optional Finnhub key (PBKDF2-SHA-256) | +| `metrics.js` | Extension contexts | Local-only counters (`pts_metrics`) | + +## Data flow + +1. The user imports a CSV in Options. `csvParser.js` validates it (size, row, column and holding caps) and holdings land in `chrome.storage.local` (`pts_holdings`). +2. An alarm wakes `background.js`. It reads holdings, asks `priceProviders.js` for quotes, and writes `pts_positions_state` and `pts_price_history` (last 15 minutes). +3. The content script, popup and options page read that state from storage and render it. They never call providers. + +## Network boundary + +Only `background.js` makes outbound requests, and only to the four hosts in `host_permissions`: `finnhub.io`, `query1.finance.yahoo.com`, `api.coingecko.com`, `data-api.binance.vision`. A test (`tests/network-boundary.test.mjs`) keeps the manifest hosts equal to the hosts the code contacts. Requests carry a ticker symbol or public crypto id, plus the Finnhub key for Finnhub only. No holdings, quantities or P&L leave the browser. + +## Failure handling + +- Every request has a 12 second timeout. +- HTTP 429 and 5xx pause that provider with exponential backoff (1 to 30 minutes, honouring `Retry-After`). The pause is persisted in `pts_provider_backoff`, so a restarted worker keeps it. +- Malformed responses and provider errors give partial results, never a failed refresh. Missing quotes are marked stale in the strip and popup. +- A 30 second cache avoids duplicate fetches. Malformed stored holdings are ignored rather than failing every refresh. +- Settings carry `pts_settings_schema_version` and are migrated on read. + +## Storage + +| Key | Area | Contents | +|-----|------|----------| +| `pts_holdings`, `pts_watchlist` | local | Imported holdings and crypto watchlist | +| `pts_finnhub_vault` | local | Encrypted Finnhub key record | +| `pts_positions_state`, `pts_price_history`, `pts_poll_health`, `pts_provider_backoff` | local | Derived state and provider health | +| `pts_metrics`, `pts_diagnostics_log` | local | Counts and dates only; bounded, allowlisted log with no symbols or amounts | +| `pts_settings`, `pts_settings_schema_version` | sync | Preferences only | +| Vault unlock material | session | Derived key for the current browser session; trusted extension contexts only | + +Portfolio data is never written to `chrome.storage.sync`. + +## Content script isolation + +The strip lives in a closed Shadow DOM host with its own stylesheet (`ticker.css`, the only web-accessible resource), so page CSS cannot restyle it and it adds no globals to the page. It runs in the isolated world, in the top frame only, and reserves a fixed strip of layout space at `document_start` to avoid layout shift. See `docs/PRODUCTION_READINESS.md` for the verification status. + +## Packaging + +`npm run package` builds `dist/myticker-.zip` deterministically from the files the manifest actually loads. See [RELEASING.md](RELEASING.md). diff --git a/CHANGELOG.md b/CHANGELOG.md index 154d462..30a0c0b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,12 @@ All notable changes to MyTicker. Versions follow `manifest.json`. ## Unreleased: production hardening +### Build, packaging and docs +- ESLint (dev-only), lockfile, `.nvmrc`; deterministic store packager (`npm run package`), version-consistency check and sensitive-content scan; CI runs all of them plus `npm audit`. +- Extension-page CSP now also restricts `connect-src` to the four provider hosts and sets `base-uri`, `form-action` and `frame-src`. +- Malformed stored holdings are ignored instead of failing every refresh. +- Added ARCHITECTURE, CONTRIBUTING, SECURITY and RELEASING docs; refreshed STORE_LISTING (permission justifications, screenshots list); fixed stale repository links. + ### Fixed - **Indian day P&L was a five-day change.** Yahoo's `chartPreviousClose` is the close before the first bar of the 5-day chart. Day P&L now uses the previous session's daily close (split-adjusted). - A Finnhub previous close of `0` no longer turns a whole position's value into day P&L. Missing previous closes show `—` instead of a 15-minute change labelled "day". diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..ec134d4 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,26 @@ +# Contributing + +Thanks for helping. MyTicker has no build step and no runtime dependencies; dev tools (ESLint) are the only npm packages. + +## Setup + +```bash +nvm use # or any Node >= 20.19 (.nvmrc pins the CI version) +npm ci +npm run verify # syntax, lint, version consistency, sensitive-content scan, tests +``` + +Load the repo root as an unpacked extension at `chrome://extensions` to try changes. + +## Rules + +- Tests must never call real provider APIs; use `test_fixtures/providers/`. +- No telemetry and no new outbound hosts. Adding a host means updating `host_permissions`, `PRIVACY.md`, `STORE_LISTING.md` and the network-boundary test, and needs maintainer sign-off. +- Never mix INR and USD in totals; no FX conversion. +- Never log or commit keys, holdings, symbols with quantities, or personal paths. This repo is public. `npm run scan` checks tracked files. +- Keep the manifest version, `package.json`, the README badge and `CHANGELOG.md` in step (`npm run check:version`). +- Small, focused commits; add or update tests for behaviour changes. + +## Pull requests + +Run `npm run verify` and `npm run audit:deps` first, describe the user-visible change, and note any permission or privacy impact. Security issues: see [SECURITY.md](SECURITY.md) instead of opening an issue. diff --git a/PRIVACY.md b/PRIVACY.md index 1ea1b7e..28e0669 100644 --- a/PRIVACY.md +++ b/PRIVACY.md @@ -70,4 +70,4 @@ Alternatively, you can clear extension data via Chrome Settings → Privacy → ## Contact -If you have questions about this privacy policy, please open an issue on [GitHub](https://github.com/harshabala/myticker-extension/issues). +If you have questions about this privacy policy, please open an issue on [GitHub](https://github.com/harshabala/myticker/issues). diff --git a/README.md b/README.md index b05f879..464c1da 100644 --- a/README.md +++ b/README.md @@ -161,19 +161,23 @@ Writer: **background.js** for refresh/activation; **options.js** for import outc ### Dev install and tests -Requires Node 20+. There are no npm dependencies, so there is nothing to install. +Requires Node 20.19+ (`.nvmrc`). There are no runtime dependencies; `npm ci` installs ESLint only (dev). ```bash +npm ci npm run check # syntax-check every shipped script; verify manifest file references +npm run lint # ESLint +npm run verify # check + lint + version consistency + sensitive-content scan + tests +npm run package # dist/myticker-.zip for the store (does not publish) npm test # node --test: tests/*.test.mjs plus every test_fixtures/test_*.mjs script ``` -CI (`.github/workflows/test.yml`) runs both on every push and pull request. Tests never call the real +CI (`.github/workflows/test.yml`) runs check, lint, version check, scan, tests, `npm audit` and packaging on every push and pull request. Tests never call the real Yahoo, Finnhub, CoinGecko, or Binance APIs; provider responses come from `test_fixtures/providers/`. Load unpacked from the repo root. Manual E2E: save key → import `test_fixtures/sample_holdings_zerodha.csv` → keep popup open until first poll. -Task checklist: [docs/TASKS.md](docs/TASKS.md) · Privacy: [PRIVACY.md](PRIVACY.md) +Task checklist: [docs/TASKS.md](docs/TASKS.md) · [ARCHITECTURE.md](ARCHITECTURE.md) · [CONTRIBUTING.md](CONTRIBUTING.md) · [SECURITY.md](SECURITY.md) · [RELEASING.md](RELEASING.md) · Privacy: [PRIVACY.md](PRIVACY.md) ### Permissions diff --git a/RELEASING.md b/RELEASING.md new file mode 100644 index 0000000..2e12618 --- /dev/null +++ b/RELEASING.md @@ -0,0 +1,10 @@ +# Releasing + +Maintainers only. Nothing here is automated, and nothing publishes to the Chrome Web Store. + +1. Bump `version` in `manifest.json`, `package.json` and `package-lock.json`, and the README badge; add a dated CHANGELOG section. `npm run check:version` verifies they agree. +2. `npm ci && npm run verify && npm run audit:deps`. +3. `npm run package` writes `dist/myticker-.zip` (deterministic; prints size and sha256). `dist/` is git-ignored. +4. Load the zip contents unpacked and smoke test: import `test_fixtures/sample_holdings_zerodha.csv`, confirm the strip and popup. +5. Capture screenshots (see `STORE_LISTING.md`), then upload the zip in the Chrome Web Store dashboard yourself. +6. Tag the release in git after the store accepts it. diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..6c2ddc2 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,22 @@ +# Security policy + +## Reporting a vulnerability + +Please report privately using GitHub's "Report a vulnerability" (Security tab of this repository). Do not open a public issue for security problems. Expect an acknowledgement within a few days; this is a volunteer project. + +## Scope and design + +- No server, no accounts, no telemetry. Holdings and settings stay in `chrome.storage`; see [PRIVACY.md](PRIVACY.md). +- Permissions are `storage`, `alarms`, four price-API hosts, and a content script on all pages that renders the strip and does not read page content. +- The optional Finnhub key is encrypted with AES-256-GCM (PBKDF2-SHA-256, 600,000 iterations); only derived unlock material is kept, in session storage readable by extension pages only. +- Extension-page CSP: `script-src 'self'`, restricted `connect-src`, `object-src`, `base-uri`, `form-action` and frames. Tests pin the CSP and the host list. +- Messages to the service worker are checked by sender; CSV import is size- and shape-capped. + +## Dependencies + +There are no runtime dependencies. Dev dependencies are pinned in `package-lock.json`; run `npm run audit:deps`. + +## Known limitations + +- Finnhub requires the key as a URL `token=` parameter. It is sent only to `https://finnhub.io`. +- The unlock code protects the key at rest in the browser profile; it does not defend against malware running as the user. diff --git a/STORE_LISTING.md b/STORE_LISTING.md index 6f55dd1..4e1645d 100644 --- a/STORE_LISTING.md +++ b/STORE_LISTING.md @@ -51,9 +51,9 @@ The ticker tape starts on all pages before page content so it can reserve its sm ### 🛠 Setup (30 seconds) -1. Get a free API key from finnhub.io -2. Paste it in Settings → Test connection -3. Import your broker CSV or add crypto +1. Import your broker CSV (Indian NSE/BSE and crypto prices work with no key) +2. For US stocks, optionally paste a free key from finnhub.io in Settings and use Test connection +3. Optionally add crypto 4. Done — your ticker is live! ## Category @@ -63,4 +63,30 @@ Productivity English ## Privacy Policy URL -https://github.com/harshabala/myticker-extension/blob/main/PRIVACY.md +https://github.com/harshabala/myticker/blob/main/PRIVACY.md + +## Single purpose +Show an ambient stock and crypto ticker strip, with local portfolio P&L, on web pages. + +## Permission justifications (for the developer dashboard) +- `storage`: save holdings, settings, the encrypted optional API-key vault and the price cache in the browser. Nothing is uploaded. +- `alarms`: schedule periodic price polling from the Manifest V3 service worker. +- Host permissions `https://query1.finance.yahoo.com/*`, `https://finnhub.io/*`, `https://api.coingecko.com/*`, `https://data-api.binance.vision/*`: fetch quotes from exactly these four providers. Only ticker symbols (or public crypto ids, and for Finnhub the user's own key) are sent. +- Content script on all URLs at `document_start`: render the strip and reserve its layout space before the page paints, so pages do not jump. It runs in the top frame only, inside a closed Shadow DOM, and does not read or modify page content. +- Web-accessible resource `ticker.css`: lets the strip's Shadow DOM load its own stylesheet. +- Remote code: none. All code is in the package; the extension-page CSP is `script-src 'self'`. + +## Data usage disclosures +No personally identifiable, health, financial-account, authentication-credential (beyond the optional user-supplied API key, stored encrypted locally), location, web-history or user-activity data is collected or transmitted to the developer. Portfolio holdings stay local. Not sold or used for unrelated purposes. + +## Screenshots to upload (1280x800 or 640x400, up to 5) +Existing sources are in `docs/screenshots/` (1600x900 and 1600x1000, so resize or recapture at 1280x800): +1. `ticker-tape-in-action.png`: the strip on a news page +2. `popup-on-moneycontrol.png`: the P&L scoreboard popup +3. To capture: Options page with CSV import (no real holdings; use the sample CSV) +4. To capture: Options key-vault section (no real key) + +Icons are in `icons/` (16, 32, 48, 128). A 440x280 small promo tile is optional and not yet created. + +## Submission +Not submitted. Build the upload with `npm run package` (see RELEASING.md); the maintainer submits it. diff --git a/docs/PRODUCTION_READINESS.md b/docs/PRODUCTION_READINESS.md index 8c60acf..8d93101 100644 --- a/docs/PRODUCTION_READINESS.md +++ b/docs/PRODUCTION_READINESS.md @@ -22,3 +22,32 @@ Audited at commit `c2eee32` (v0.5.0, main). Sensitive-content scan of tracked files: no personal absolute paths, emails, tokens or keys found. Commit author emails are the owner's public noreply-style proton addresses. Unmerged remote branches (not touched, not deleted): `feature/live-market-tape` (61 commits ahead), `feature/myticker-mvp`, `feature/tape-layout-reservation`, `docs/release-gate-audit`, `harsha/focused-burnell-333e39`, `backup/local-main-2026-07`, `sync/local-work-20261011`, `chore/sync-local-work-scrubbed`. + +## After this work + +| # | Dimension | Before | After | Evidence | +|---|-----------|--------|-------|----------| +| 1 | Build and run reproducible | 7 | 9 | Lockfile, `.nvmrc`, `npm ci`, one `verify` entry, deterministic zip. Not 10: not verified on a second machine or in CI (Actions is failing account-wide). | +| 2 | Tests | 8 | 8 | 97 passing (was 91): CSP/network boundary, packager, malformed holdings. Popup/options UI still thinly covered; no coverage floor. | +| 3 | Static quality | 4 | 7 | ESLint 10 clean, found and fixed unused code. No formatter, no type checking. | +| 4 | Security and privacy | 8 | 9 | `npm audit` clean (dev deps only), tightened CSP, secret/personal-path scan, SECURITY.md. No third-party security review. | +| 5 | Reliability | 8 | 8 | Malformed stored holdings no longer break refresh. Local data has no schema version (settings do). | +| 6 | Config and packaging | 3 | 9 | Zip packager, version check, CHANGELOG, store listing with permission justifications, RELEASING.md. Not 10: store submission is owner-only. | +| 7 | CI | 4 | 7 | Workflow runs check, lint, version, scan, test, audit, package on Node 20 and 22. Not 10 because it cannot be seen passing (Actions billing failure); the same commands pass locally. | +| 8 | Docs | 5 | 9 | ARCHITECTURE, CONTRIBUTING, SECURITY, RELEASING added; PRIVACY and STORE_LISTING links fixed. | +| 9 | UX, accessibility, performance | 7 | 7 | Unchanged. Contrast and heavy-page cost not measured; store screenshots at the required size still to capture. | +| 10 | Operability | 7 | 7 | Unchanged: local bounded diagnostics log and copy button. No triage runbook beyond ARCHITECTURE. | + +Overall: not a 10. The extension is store-ready in the repository; remaining gaps are owner actions and manual browser verification. + +Checked and found fine: manifest permissions and the four host permissions match the code (test-enforced); the content script uses a closed Shadow DOM, top frame only, with one web-accessible stylesheet; provider requests have timeouts, persisted backoff, a 30 s cache and partial-result handling; portfolio data is local only (`chrome.storage.local`, never sync) and documented in PRIVACY.md. + +## Blocked on owner + +- Chrome Web Store developer account, listing submission and review (nothing was submitted). +- Capture store screenshots at 1280x800 without real holdings, and an optional 440x280 promo tile. +- Confirm the privacy policy URL and support contact for the listing; enable GitHub private vulnerability reporting for SECURITY.md. +- Fix GitHub Actions billing so CI can run. +- Manual test in Chrome on heavy pages (contrast, layout shift, performance), which could not be run here. +- Tagging and publishing a release. +