Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .editorconfig
Original file line number Diff line number Diff line change
@@ -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
13 changes: 13 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -31,3 +31,4 @@ MyTicker.pem
.worktrees/
* 2.*
.DS_Store
test_fixtures/user_holdings.csv
1 change: 1 addition & 0 deletions .nvmrc
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
22
7 changes: 5 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
@@ -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-<version>.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
Expand Down
55 changes: 55 additions & 0 deletions ARCHITECTURE.md
Original file line number Diff line number Diff line change
@@ -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-<version>.zip` deterministically from the files the manifest actually loads. See [RELEASING.md](RELEASING.md).
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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".
Expand Down
26 changes: 26 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
@@ -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.
2 changes: 1 addition & 1 deletion PRIVACY.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
10 changes: 7 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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-<version>.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

Expand Down
10 changes: 10 additions & 0 deletions RELEASING.md
Original file line number Diff line number Diff line change
@@ -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-<version>.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.
22 changes: 22 additions & 0 deletions SECURITY.md
Original file line number Diff line number Diff line change
@@ -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.
34 changes: 30 additions & 4 deletions STORE_LISTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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.
15 changes: 12 additions & 3 deletions background.js
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,6 @@ import {
summarizeMarketState,
CONTENT_LIFECYCLE_STAGES,
CRYPTO_CATALOG,
resolveCryptoCatalogEntry,
normalizeCryptoId
} from "./shared.js";

Expand Down Expand Up @@ -394,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 = {
Expand Down Expand Up @@ -503,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;
}
Expand Down Expand Up @@ -542,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 [];
Expand Down
Loading
Loading