diff --git a/.github/ISSUE_TEMPLATE/adapter_broken.yml b/.github/ISSUE_TEMPLATE/adapter_broken.yml new file mode 100644 index 0000000..20dffb5 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/adapter_broken.yml @@ -0,0 +1,44 @@ +name: Adapter broken (grey "!" on a provider) +description: A provider stopped reading — its endpoint probably changed. Your capture is usually a few-line fix. +labels: [adapter-broken] +body: + - type: dropdown + id: provider + attributes: + label: Provider + options: + - Claude (claude.ai) + - Codex (chatgpt.com) + - Other (say which below) + validations: + required: true + - type: input + id: error + attributes: + label: Error shown on the card + description: Hover or read the card's message, e.g. "schema_mismatch …" or "endpoint_not_verified". + placeholder: "Error: No known claude.ai usage endpoint matched …" + validations: + required: true + - type: checkboxes + id: loggedin + attributes: + label: Sanity check + options: + - label: I am logged into the provider's site in this browser profile + required: true + - type: textarea + id: capture + attributes: + label: Captured response shape (redact all ids!) + description: | + Log into the provider's site → DevTools → Network tab → open the provider's own + usage/settings page → find the usage request → copy its URL path and response JSON here. + **Redact every UUID, email, and account identifier** — we only need field names, + value types, and rough numbers. + render: json + - type: input + id: version + attributes: + label: Ration version + placeholder: "0.1.0" diff --git a/.github/ISSUE_TEMPLATE/adapter_request.yml b/.github/ISSUE_TEMPLATE/adapter_request.yml new file mode 100644 index 0000000..b19202b --- /dev/null +++ b/.github/ISSUE_TEMPLATE/adapter_request.yml @@ -0,0 +1,35 @@ +name: New provider request +description: Ask for a provider to be supported — or better, volunteer to add it (single-file contribution!). +labels: [adapter-request] +body: + - type: input + id: provider + attributes: + label: Provider + placeholder: "Cursor / Gemini / Grok / …" + validations: + required: true + - type: dropdown + id: credential + attributes: + label: Where does its usage data live? + description: This decides how (and whether) an extension can read it. + options: + - Behind a website login I have in my browser (best case) + - Behind a local file / CLI token on disk (extensions can't read files) + - Behind an API key I'd have to paste + - Not sure + validations: + required: true + - type: input + id: dashboard + attributes: + label: Where do you check usage today? + placeholder: "https://…/settings/usage" + - type: checkboxes + id: volunteer + attributes: + label: Contribution + options: + - label: I'm willing to record fixtures from my account (redacted) + - label: I'd like to write the adapter myself with guidance diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml new file mode 100644 index 0000000..8021bfa --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -0,0 +1,31 @@ +name: Bug report +description: Something in the extension misbehaves (not a provider reading failure — use "Adapter broken" for that). +labels: [bug] +body: + - type: textarea + id: what + attributes: + label: What happened? + description: What did you do, what did you expect, what happened instead? + validations: + required: true + - type: input + id: version + attributes: + label: Ration version + placeholder: "0.1.0" + validations: + required: true + - type: input + id: browser + attributes: + label: Browser and version + placeholder: "Chrome 129" + validations: + required: true + - type: textarea + id: console + attributes: + label: Console output (optional) + description: chrome://extensions → Ration → "Inspect views: service worker" — paste relevant errors. Redact anything personal. + render: text diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000..1f8e2f5 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,5 @@ +blank_issues_enabled: false +contact_links: + - name: Question / discussion + url: https://github.com/devudilip/ration/discussions + about: Ask questions or discuss ideas that aren't a bug or a request. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..5a0e10a --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,40 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + +jobs: + ci: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + + - run: npm ci + + - name: Typecheck + run: npm run typecheck + + - name: Test + run: npm test + + - name: Build + run: npm run build + + - name: Manifest sanity + run: node -e "JSON.parse(require('fs').readFileSync('dist/manifest.json','utf8')); console.log('manifest OK')" + + - name: No runtime dependencies (auditability commitment) + run: node -e "const d = require('./package.json').dependencies; if (d && Object.keys(d).length) { console.error('runtime deps found:', d); process.exit(1); } console.log('zero runtime deps OK')" + + - name: Upload unpacked extension + uses: actions/upload-artifact@v4 + with: + name: ration-unpacked + path: dist/ diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..6de233e --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +node_modules/ +dist/ +*.zip +.DS_Store diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..cc246a2 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,136 @@ +# Contributing to Ration + +Thanks for helping! The most valuable contribution is a **provider +adapter** — Ration's coverage is designed to grow through single-file +community contributions. + +## Dev setup + +```sh +npm install +npm run watch # rebuilds dist/ on save +``` + +Load `dist/` as an unpacked extension at `chrome://extensions` (Developer +mode → Load unpacked), and reload it there after changes. `npm run ci` runs +exactly what GitHub Actions runs: typecheck, tests, build. + +## Adding a provider adapter + +An adapter is five artifacts: + +1. `src/adapters/.ts` — the adapter itself +2. One line in `src/adapters/index.ts` — import it and append to the array +3. Its origin added to `optional_host_permissions` in `public/manifest.json` +4. `tests/fixtures//*.json` — recorded responses (redacted!) +5. `tests/.test.ts` — fixture tests + +### The contract (`src/types.ts`) + +Your adapter implements `ProviderAdapter` and returns `ProviderSnapshot`s +made of `QuotaLane`s. Three rules are non-negotiable, and reviewers will +check them: + +- **Never throw.** Every failure path — logged out, rate-limited, server + error, unparseable body — returns a snapshot with the matching `status` + (`unauthenticated` / `rate_limited` / `error`). One broken adapter must + never take down the popup. +- **Never coerce to zero.** Validate the response with `src/lib/validate.ts` + (see `matchVariant` for handling multiple known shapes). If the shape + doesn't match, return `status: 'error'` with code `schema_mismatch`. A + confidently wrong number is worse than no number. +- **Read raw field names, not inferred labels.** Codex, for example, has + been observed with both `primary_window`/`secondary_window` and + `five_hour_limit`/`weekly_limit`. Match each known shape literally as a + named variant and record which one matched in `snapshot.schemaVariant`. + Mislabeling which window is which produces a confidently wrong display. + +`headroomPct` (0–100 remaining) is the universal comparator. A provider's +overall headroom is the **minimum** across its lanes — you're blocked by +whichever ceiling you hit first. + +### Politeness rules + +We read undocumented endpoints as guests: + +- Set `minRefreshMs` to at least `60_000`. The scheduler additionally + enforces a hard floor of one request per provider per minute and + exponential backoff on failures — your adapter doesn't need to implement + backoff, just report honest statuses. +- Use `ctx.fetch(url, { credentials: 'include', headers: { Accept: + 'application/json' } })`. Do not spoof another client's identity, add + fake client headers, or construct `Authorization` headers. Cookie-riding + Tier A adapters must never read credential values at all. +- **Anthropic-specific:** PRs that read, store, or transmit Claude Code / + claude.ai OAuth tokens will be declined — Anthropic's terms restrict + those tokens to their own products (see README). + +### Recording a fixture + +1. Log into the provider's site, open DevTools → Network. +2. Open the provider's own usage/settings page and find the usage request. +3. Copy the response JSON into `tests/fixtures//`. +4. **Redact every identifier** — org/user UUIDs, emails, plan names if + sensitive. Keep the shape and realistic numbers. + +Also record (or hand-write) at least: a logged-out response, and a +deliberately wrong shape (for the `schema_mismatch` test). + +### Worked example: a hypothetical Cursor adapter + +```ts +// src/adapters/cursor.ts +import type { FetchContext, ProviderAdapter, ProviderSnapshot } from '../types'; +import { clampPct } from '../lib/headroom'; +import { matchVariant, num, obj, optional } from '../lib/validate'; + +const variants = { + usage_summary: obj({ + included_usd_used: num, + included_usd_limit: num, + // ...match the real field names you observed, literally + }), +}; + +export const cursorAdapter: ProviderAdapter = { + id: 'cursor', + displayName: 'Cursor', + tier: 'A', + hostPermissions: ['https://cursor.com/*'], + dashboardUrl: 'https://cursor.com/settings', + minRefreshMs: 60_000, + async fetch(ctx: FetchContext): Promise { + // 1. ctx.fetch the usage endpoint with credentials: 'include' + // 2. map 401/403 → 'unauthenticated', 429 → 'rate_limited' + // 3. matchVariant(variants, body) — mismatch → 'schema_mismatch' error + // 4. build QuotaLanes; for currency lanes: + // kind: 'currency', used, limit, + // headroomPct: clampPct(100 * (1 - used / limit)) + // ... + }, +}; +``` + +Then in `src/adapters/index.ts`: + +```ts +import { cursorAdapter } from './cursor'; +export const adapters = [claudeAdapter, codexAdapter, cursorAdapter] as const; +``` + +Look at `src/adapters/codex.ts` (simpler) and `src/adapters/claude.ts` +(endpoint probing, per-provider cache) for complete, tested references, and +mirror `tests/codex.test.ts` for the test checklist: happy path per +variant, unauthenticated, rate-limited, unknown shape → `schema_mismatch`, +never-throws, and no `Authorization` header. + +## Non-adapter contributions + +Bug fixes and small improvements: open a PR. New surface area (options +pages, notifications, history) tends to be roadmap-sensitive — open an +issue first so we don't waste your time. + +## Commit style + +Conventional commits (`feat(adapter): …`, `fix: …`, `docs: …`). CI must be +green: `npm run ci` locally before pushing. diff --git a/LICENSE b/LICENSE index 261eeb9..19970ce 100644 --- a/LICENSE +++ b/LICENSE @@ -186,7 +186,7 @@ same "printed page" as the copyright notice for easier identification within third-party archives. - Copyright [yyyy] [name of copyright owner] + Copyright 2026 Ration contributors Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. diff --git a/README.md b/README.md new file mode 100644 index 0000000..734562d --- /dev/null +++ b/README.md @@ -0,0 +1,130 @@ +# Ration — AI Quota Tracker + +**One glance at remaining quota across your AI subscriptions.** A browser +extension that answers a single question: *which of my AI tools has capacity +right now?* + +If you pay for Claude, Codex/ChatGPT, Cursor and friends, each one meters +usage differently, resets on a different cycle, and buries the number behind +a different settings page. Nobody checks three dashboards before starting a +long agent run — so nobody checks at all, until they get cut off forty +minutes in. Ration puts all of it one click away, in the browser where those +dashboards already live. + +- **Popup**: provider cards sorted by headroom — the tool with the most + capacity on top, per-limit bars, reset countdowns, and the age of every + reading. +- **Badge**: the extension icon shows the *lowest* headroom across your + enabled providers — the wall you'll hit first. Quiet when everything is + fine, amber under 40%, red under 15%, grey `!` when a reading failed. +- **No accounts, no telemetry, no backend. Ever.** Everything stays in your + browser profile. + +## Supported providers + +| Provider | Status | How it reads your quota | +|---|---|---| +| Claude (claude.ai) | ✅ v0.1 | Your existing claude.ai browser session | +| Codex (chatgpt.com) | ✅ v0.1 | Your existing chatgpt.com browser session | +| Cursor | 🙏 PRs welcome | — | +| Gemini | 🙏 PRs welcome | — | +| Grok | 🙏 PRs welcome | — | +| GitHub Copilot | 🙏 PRs welcome | — | + +Adding a provider is designed to be a single-file contribution — see +[CONTRIBUTING.md](CONTRIBUTING.md). + +## Install (unpacked, v0.1) + +```sh +git clone https://github.com/devudilip/ration.git +cd ration +npm install +npm run build +``` + +Then in Chrome (or any Chromium browser): + +1. Open `chrome://extensions`, enable **Developer mode**. +2. **Load unpacked** → select the `dist/` folder. +3. Click the Ration icon and toggle on the providers you use. Each toggle + asks for permission to that provider's site only, at that moment. +4. You must be logged into the provider's site in this browser — Ration + reads the same usage endpoint the provider's own settings page uses. + +Time to first value should be under 20 seconds. + +## How it works — and what it never does + +Ration rides your **existing browser login**. When you enable a provider it +asks Chrome for permission to that provider's origin, then calls the same +internal usage endpoint the provider's own settings page calls, with +`credentials: 'include'` so your session cookie is attached by the browser +itself. + +Privacy and safety commitments, in order of importance: + +- **No credentials are ever read, stored, or transmitted.** The extension + never requests the `cookies` permission, never sees cookie values, and + never constructs an `Authorization` header (there's a test asserting + this). It cannot leak what it cannot see. +- **Zero telemetry.** No analytics, no error reporting service, no remote + config, no runtime dependencies at all. What you see in this repo is the + entire behavior. +- **Everything is local.** Snapshots live in `chrome.storage.local` (never + synced), and **Clear all data** in the popup footer wipes every byte + Ration has stored. +- **Polite polling.** One scheduled refresh per provider every 5 minutes, + a hard floor of one request per provider per minute regardless of what + you click, and exponential backoff (up to 60 min) whenever a provider + errors or rate-limits. We are guests on these endpoints. + +### The Claude Code OAuth constraint + +Anthropic's consumer terms restrict Claude Code / claude.ai OAuth tokens to +those products. **Ration does not ship, bundle, or document any path that +uses a Claude Code OAuth token** — Claude support works only by riding your +own claude.ai browser session, exactly like opening Settings → Usage +yourself. Please don't send PRs that handle Anthropic OAuth tokens; they +will be declined to protect contributors' and users' accounts. + +### These endpoints are undocumented — they will break + +Every endpoint Ration reads is internal to its provider and can change +without notice. When that happens the affected card shows an error and the +badge shows a grey `!` — **a failed reading always looks failed; Ration +never renders a guess as a number.** If you see a grey `!` that isn't a +logged-out session, please +[open an "adapter broken" issue](../../issues/new/choose): with your help +(DevTools → Network tab → the provider's usage page → copy the response +shape, **redact any ids**) fixes are typically a few lines. + +The claude.ai usage endpoint in particular is community-verified: the +adapter probes a short list of candidate paths and reports honestly if none +matches your account. Captures from real accounts are the fastest way to +pin it down. + +## Development + +```sh +npm install +npm run watch # rebuild dist/ on save +npm test # vitest, fixture-based — no network +npm run typecheck +npm run ci # what CI runs: typecheck + test + build +``` + +Reload the extension from `chrome://extensions` after a rebuild. + +## Roadmap + +v0.1 is deliberately small: two providers, popup, badge, scheduled refresh. +Next, roughly in order — history sparklines, more providers (Cursor, +Gemini, Grok, Copilot), opt-in threshold notifications, Firefox, store +builds. See the issues for current plans; the +[product requirements discussion](../../issues) is open. + +## License + +[Apache-2.0](LICENSE). Contributions welcome — see +[CONTRIBUTING.md](CONTRIBUTING.md). diff --git a/build.mjs b/build.mjs new file mode 100644 index 0000000..57e07cb --- /dev/null +++ b/build.mjs @@ -0,0 +1,33 @@ +import * as esbuild from 'esbuild'; +import { cp, mkdir, rm } from 'node:fs/promises'; + +const watch = process.argv.includes('--watch'); + +const options = { + entryPoints: [ + { in: 'src/background/index.ts', out: 'background' }, + { in: 'src/popup/main.ts', out: 'popup' }, + ], + outdir: 'dist', + bundle: true, + format: 'esm', + target: 'chrome120', + sourcemap: watch ? 'inline' : false, + logLevel: 'info', +}; + +async function copyStatic() { + await mkdir('dist', { recursive: true }); + await cp('public', 'dist', { recursive: true }); +} + +await rm('dist', { recursive: true, force: true }); +await copyStatic(); + +if (watch) { + const ctx = await esbuild.context(options); + await ctx.watch(); + console.log('watching src/ — reload the extension from chrome://extensions after changes'); +} else { + await esbuild.build(options); +} diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..111851e --- /dev/null +++ b/package-lock.json @@ -0,0 +1,1976 @@ +{ + "name": "ration", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "ration", + "version": "0.1.0", + "license": "Apache-2.0", + "devDependencies": { + "@types/chrome": "^0.0.287", + "@types/node": "^26.4.0", + "esbuild": "^0.24.0", + "typescript": "^5.7.0", + "vitest": "^2.1.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.24.2.tgz", + "integrity": "sha512-thpVCb/rhxE/BnMLQ7GReQLLN8q9qbHmI55F4489/ByVg2aQaQ6kbcLb6FHkocZzQhxc4gx0sCk0tJkKBFzDhA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.24.2.tgz", + "integrity": "sha512-tmwl4hJkCfNHwFB3nBa8z1Uy3ypZpxqxfTQOcHX+xRByyYgunVbZ9MzUUfb0RxaHIMnbHagwAxuTL+tnNM+1/Q==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.24.2.tgz", + "integrity": "sha512-cNLgeqCqV8WxfcTIOeL4OAtSmL8JjcN6m09XIgro1Wi7cF4t/THaWEa7eL5CMoMBdjoHOTh/vwTO/o2TRXIyzg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.24.2.tgz", + "integrity": "sha512-B6Q0YQDqMx9D7rvIcsXfmJfvUYLoP722bgfBlO5cGvNVb5V/+Y7nhBE3mHV9OpxBf4eAS2S68KZztiPaWq4XYw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.24.2.tgz", + "integrity": "sha512-kj3AnYWc+CekmZnS5IPu9D+HWtUI49hbnyqk0FLEJDbzCIQt7hg7ucF1SQAilhtYpIujfaHr6O0UHlzzSPdOeA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.24.2.tgz", + "integrity": "sha512-WeSrmwwHaPkNR5H3yYfowhZcbriGqooyu3zI/3GGpF8AyUdsrrP0X6KumITGA9WOyiJavnGZUwPGvxvwfWPHIA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.24.2.tgz", + "integrity": "sha512-UN8HXjtJ0k/Mj6a9+5u6+2eZ2ERD7Edt1Q9IZiB5UZAIdPnVKDoG7mdTVGhHJIeEml60JteamR3qhsr1r8gXvg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.24.2.tgz", + "integrity": "sha512-TvW7wE/89PYW+IevEJXZ5sF6gJRDY/14hyIGFXdIucxCsbRmLUcjseQu1SyTko+2idmCw94TgyaEZi9HUSOe3Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.24.2.tgz", + "integrity": "sha512-n0WRM/gWIdU29J57hJyUdIsk0WarGd6To0s+Y+LwvlC55wt+GT/OgkwoXCXvIue1i1sSNWblHEig00GBWiJgfA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.24.2.tgz", + "integrity": "sha512-7HnAD6074BW43YvvUmE/35Id9/NB7BeX5EoNkK9obndmZBUk8xmJJeU7DwmUeN7tkysslb2eSl6CTrYz6oEMQg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.24.2.tgz", + "integrity": "sha512-sfv0tGPQhcZOgTKO3oBE9xpHuUqguHvSo4jl+wjnKwFpapx+vUDcawbwPNuBIAYdRAvIDBfZVvXprIj3HA+Ugw==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.24.2.tgz", + "integrity": "sha512-CN9AZr8kEndGooS35ntToZLTQLHEjtVB5n7dl8ZcTZMonJ7CCfStrYhrzF97eAecqVbVJ7APOEe18RPI4KLhwQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.24.2.tgz", + "integrity": "sha512-iMkk7qr/wl3exJATwkISxI7kTcmHKE+BlymIAbHO8xanq/TjHaaVThFF6ipWzPHryoFsesNQJPE/3wFJw4+huw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.24.2.tgz", + "integrity": "sha512-shsVrgCZ57Vr2L8mm39kO5PPIb+843FStGt7sGGoqiiWYconSxwTiuswC1VJZLCjNiMLAMh34jg4VSEQb+iEbw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.24.2.tgz", + "integrity": "sha512-4eSFWnU9Hhd68fW16GD0TINewo1L6dRrB+oLNNbYyMUAeOD2yCK5KXGK1GH4qD/kT+bTEXjsyTCiJGHPZ3eM9Q==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.24.2.tgz", + "integrity": "sha512-S0Bh0A53b0YHL2XEXC20bHLuGMOhFDO6GN4b3YjRLK//Ep3ql3erpNcPlEFed93hsQAjAQDNsvcK+hV90FubSw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.24.2.tgz", + "integrity": "sha512-8Qi4nQcCTbLnK9WoMjdC9NiTG6/E38RNICU6sUNqK0QFxCYgoARqVqxdFmWkdonVsvGqWhmm7MO0jyTqLqwj0Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.24.2.tgz", + "integrity": "sha512-wuLK/VztRRpMt9zyHSazyCVdCXlpHkKm34WUyinD2lzK07FAHTq0KQvZZlXikNWkDGoT6x3TD51jKQ7gMVpopw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.24.2.tgz", + "integrity": "sha512-VefFaQUc4FMmJuAxmIHgUmfNiLXY438XrL4GDNV1Y1H/RW3qow68xTwjZKfj/+Plp9NANmzbH5R40Meudu8mmw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.24.2.tgz", + "integrity": "sha512-YQbi46SBct6iKnszhSvdluqDmxCJA+Pu280Av9WICNwQmMxV7nLRHZfjQzwbPs3jeWnuAhE9Jy0NrnJ12Oz+0A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.24.2.tgz", + "integrity": "sha512-+iDS6zpNM6EnJyWv0bMGLWSWeXGN/HTaF/LXHXHwejGsVi+ooqDfMCCTerNFxEkM3wYVcExkeGXNqshc9iMaOA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.24.2.tgz", + "integrity": "sha512-hTdsW27jcktEvpwNHJU4ZwWFGkz2zRJUz8pvddmXPtXDzVKTTINmlmga3ZzwcuMpUvLw7JkLy9QLKyGpD2Yxig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.24.2.tgz", + "integrity": "sha512-LihEQ2BBKVFLOC9ZItT9iFprsE9tqjDjnbulhHoFxYQtQfai7qfluVODIYxt1PgdoyQkz23+01rzwNwYfutxUQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.24.2.tgz", + "integrity": "sha512-q+iGUwfs8tncmFC9pcnD5IvRHAzmbwQ3GPS5/ceCyHdjXubwQWI12MKWSNSMYLJMq23/IUCvJMS76PDqXe1fxA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.24.2.tgz", + "integrity": "sha512-7VTgWzgMGvup6aSqDPLiW5zHaxYJGTO4OokMjIlrCtf+VpEL+cXKtCvg723iguPYI5oaUNdS+/V7OU2gvXVWEg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz", + "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@napi-rs/lzma-linux-x64-gnu": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz", + "integrity": "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^22.20 || ^24.12 || >=25" + } + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.63.1.tgz", + "integrity": "sha512-UZ8sUxPTiHWYX9QNdJedb1kDZSpS1t/VPWBWGSgqHNi9w3Cu6IXvu2mzbhiTiPvtrqgTQJ+zqiAq2iPIPilpaQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.63.1.tgz", + "integrity": "sha512-cQ4nFQABN5cDvDpbvJ7bMStCpnaVxynZrRMfUJYgxcIk9Sh54FIO1vtfkg0B69REjER77ioZ/ov+eAApx/KmLQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.63.1.tgz", + "integrity": "sha512-FQNqd1lRy/0QhDk3xeRIkSBiCpXCiDnZO3YLVdcDKN1UBiKToNftCzcXYNLshmPDUMlu2TdeS8tGcsU6f3YF1Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.63.1.tgz", + "integrity": "sha512-pvD16V939D3CloK0+qikpGaxiPrDUXTe7Y5cWOMkMSy7m1cawa8EGy/kXYi/G/cKAC4HDAbSnzCIk1WmsoOKXg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.63.1.tgz", + "integrity": "sha512-pcFGeL2345VwdTnJhA6zLbew+YgWB0qBG2+dMtXjCicf6+rm6kO6cOoh5VnTe0ZMrMRgRyuHmCJxZWrIdzYuOw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.63.1.tgz", + "integrity": "sha512-mRJlqSRulVzcKq/LKA6ICSIc3K/l4fzlVn/gePn2nXIHy8seRi5z/eeRE0d/XMBxcMldiXtQTSpRj0tkkC3g8Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.63.1.tgz", + "integrity": "sha512-YDUNvVM85TI3g/1OpnqKP1h4NeW/j64DfWMf+G3M809xNk1bJSnpFp4sh83NpmVE5DXnkh8ULor4LTVZKoYLHw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.63.1.tgz", + "integrity": "sha512-7Mcn71p9ZuQFAj+h+dhQXy/yeLePRS2yKRnmW1DijA9thKO5qap0GNOIQK4yQ6iP3SU0Mrb/yWo8h8vgRba8lw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.63.1.tgz", + "integrity": "sha512-4YiLQTX6U4CSl0L9cluep9A9W6UmTfqBDc2/CH6wlu54pl4E7Jn3cOD8oxzvBDEGk/JMKgJ47C8g+radF7mwvg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.63.1.tgz", + "integrity": "sha512-2ra8F7w8OquwZN9z2/fKFnli69wa8PLwaVzRMIPGb13ByMJwC28Fbp8YcVGoUhlYMTt7j5j9bNgpysrN2UM+vw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.63.1.tgz", + "integrity": "sha512-Sy20ncyhjmBP0Ml+UvQbimjlk6VFgjW5uNP+qqwHB00mTE8Bl2C1TuHTlRwK2YoXeZbee5lP2XevBWVkAQAtSQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.63.1.tgz", + "integrity": "sha512-noITLp8oNjYliPnGWmLyelIHwULGqbHloQHGw1rtxbWhTuWooRpnZarZQJ1y9EUC4szuCusCc+HEpUtxpIwYvA==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.63.1.tgz", + "integrity": "sha512-hlxxXd+F1mWiAcaFR7Sv9ZQT6m6UfI8+Vy/kFJzztq2pDMU/0wZ9sish0iszNZvsQDo8Gc0i5yuFEOz5dDf6fA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.63.1.tgz", + "integrity": "sha512-EF7OpqQTQ/BvGqLzUi4rEHuagCV9MugAUXSHemwPW5vxZ75RR+jxO/2j95Ph2dalMpFHSVECjRoioHZgA9zOYA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.63.1.tgz", + "integrity": "sha512-wQO3JesW9PRkwlabQ27y7sPfVOOTLRG73I4F2UYHG5PXun3J9U3y+b7ezVKSYbsvSKGQ1k1cq8Qlun4C9kLt3w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.63.1.tgz", + "integrity": "sha512-ouAGwhO6wHRXdnOVCOsB0tRFkA7nhNB2Nwax6oECXN0YiN8EYUTBAOudADOB1PI+yDL61TeNx/u7MVCzksNbkQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.63.1.tgz", + "integrity": "sha512-q2R38Sn+1J8RxhfJ+T54wSWmyKXWec+9jgDfqO2AtArEqHO5R2aeayp5H5OYLr5UYDVGsVaZPEFUooMhYCdz5A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.63.1.tgz", + "integrity": "sha512-gfI5T24WLLuFfSKw7Go/zDXjAAV0fny0swTaDv+WjK7vqcw4cRhFfdsyKL1n+ukI+ooBxn3bVQnyrn06WpI50w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.63.1.tgz", + "integrity": "sha512-4h6XqthmB4Hspji84wvgk+ElodTsGj+dbZqHJHHtKxj4mYq0ANSEEPX9ys3moJueqsRjwpaJYH7874Itwnj2ow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.63.1.tgz", + "integrity": "sha512-dlfCOa87o1VAYegLQ9EKilx2JCeRofiyPGhTCmqnuXZ6bMPiycO1rq1+sKoulAp7pGLIsTIw+1x5R+zgh5LhhA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.63.1.tgz", + "integrity": "sha512-cjkLbOlfcm3QGhMM1J5zaZjsw1GggbN6rw9UTSSRrPrR1KkcXnN7Uq9rPw34xImQ9VOY9GN+6u2Zj80B9ptkcw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.63.1.tgz", + "integrity": "sha512-Li1KdUnWGE4N3e1F/B4RTB1ms+nG4WBgjByO46pkeBVX/2UBsY53xf5vK9WygVmnH3RwncIST7lkSdLSY6P9lg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.63.1.tgz", + "integrity": "sha512-t4ZYOSoLTgwhuFMrmTMLx/+i1DQVK7HYqMc6kY46EApwi8X0nIVphzdNoThU3xt6n+N5urG1/gxBdCaKDLavfg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.63.1.tgz", + "integrity": "sha512-RgroPfMmKlD1RzSDxvwgcPiy2HNQKoYV7OmwIXDsk73uKW5t6B/V8KIy27SMv/FNXFo/oSBtWc9J0X7t91ezZg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.63.1.tgz", + "integrity": "sha512-at8QVep6S3h5Y6gSbdGU06bRY5WJkf6WUduM9YtvYMbYhB1MOFfUgc6kehitQXzOtMSaT70q7f9ydPhpqu821w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/chrome": { + "version": "0.0.287", + "resolved": "https://registry.npmjs.org/@types/chrome/-/chrome-0.0.287.tgz", + "integrity": "sha512-wWhBNPNXZHwycHKNYnexUcpSbrihVZu++0rdp6GEk5ZgAglenLx+RwdEouh6FrHS0XQiOxSd62yaujM1OoQlZQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/filesystem": "*", + "@types/har-format": "*" + } + }, + "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/filesystem": { + "version": "0.0.36", + "resolved": "https://registry.npmjs.org/@types/filesystem/-/filesystem-0.0.36.tgz", + "integrity": "sha512-vPDXOZuannb9FZdxgHnqSwAG/jvdGM8Wq+6N4D/d80z+D4HWH+bItqsZaVRQykAn6WEVeEkLm2oQigyHtgb0RA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/filewriter": "*" + } + }, + "node_modules/@types/filewriter": { + "version": "0.0.33", + "resolved": "https://registry.npmjs.org/@types/filewriter/-/filewriter-0.0.33.tgz", + "integrity": "sha512-xFU8ZXTw4gd358lb2jw25nxY9QAgqn2+bKKjKOYfNCzN4DKCFetK7sPtrlpg66Ywe3vWY9FNxprZawAh9wfJ3g==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/har-format": { + "version": "1.2.16", + "resolved": "https://registry.npmjs.org/@types/har-format/-/har-format-1.2.16.tgz", + "integrity": "sha512-fluxdy7ryD3MV6h8pTfTYpy/xQzCFC7m89nOH9y94cNqJ1mDIDPut7MnRHI3F6qRmh/cT2fUjG1MLdCNb4hE9A==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "26.4.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-26.4.0.tgz", + "integrity": "sha512-faiGnoIrLH/V8cibOMEAZ8pMw6oXqSukl29ra4mN8GdaB2ZewzeaLj+INpV5N+Z1eKWzY+IzaIZH2EIR6YZRNQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~8.3.0" + } + }, + "node_modules/@vitest/expect": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-2.1.9.tgz", + "integrity": "sha512-UJCIkTBenHeKT1TTlKMJWy1laZewsRIzYighyYiJKZreqtdxSos/S1t+ktRMQWu2CKqaarrkeszJx1cgC5tGZw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "2.1.9", + "@vitest/utils": "2.1.9", + "chai": "^5.1.2", + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-2.1.9.tgz", + "integrity": "sha512-tVL6uJgoUdi6icpxmdrn5YNo3g3Dxv+IHJBr0GXHaEdTcw3F+cPKnsXFhli6nO+f/6SDKPHEK1UN+k+TQv0Ehg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "2.1.9", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.12" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^5.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-2.1.9.tgz", + "integrity": "sha512-KhRIdGV2U9HOUzxfiHmY8IFHTdqtOhIzCpd8WRdJiE7D/HUcZVD0EgQCVjm+Q9gkUXWgBvMmTtZgIG48wq7sOQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-2.1.9.tgz", + "integrity": "sha512-ZXSSqTFIrzduD63btIfEyOmNcBmQvgOVsPNPe0jYtESiXkhd8u2erDLnMxmGrDCwHCCHE7hxwRDCT3pt0esT4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "2.1.9", + "pathe": "^1.1.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-2.1.9.tgz", + "integrity": "sha512-oBO82rEjsxLNJincVhLhaxxZdEtV0EFHMK5Kmx5sJ6H9L183dHECjiefOAdnqpIgT5eZwT04PoggUnW88vOBNQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "2.1.9", + "magic-string": "^0.30.12", + "pathe": "^1.1.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-2.1.9.tgz", + "integrity": "sha512-E1B35FwzXXTs9FHNK6bDszs7mtydNi5MIfUWpceJ8Xbfb1gBMscAnwLbEu+B44ed6W3XjL9/ehLPHR1fkf1KLQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyspy": "^3.0.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-2.1.9.tgz", + "integrity": "sha512-v0psaMSkNJ3A2NMrUEHFRzJtDPFn+/VWZ5WxImB21T9fjucJRmS7xCS3ppEnARb9y11OAzaD+P2Ps+b+BGX5iQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "2.1.9", + "loupe": "^3.1.2", + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/cac": { + "version": "6.7.14", + "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/chai": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", + "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "assertion-error": "^2.0.1", + "check-error": "^2.1.1", + "deep-eql": "^5.0.1", + "loupe": "^3.1.0", + "pathval": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/check-error": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz", + "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 16" + } + }, + "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-eql": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", + "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/es-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "dev": true, + "license": "MIT" + }, + "node_modules/esbuild": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.24.2.tgz", + "integrity": "sha512-+9egpBW8I3CD5XPe0n6BfT5fxLzxrlDzqydF3aviG+9ni1lDC/OvMHcxqEFV0+LANZG5R1bFMWfUrjVsdwxJvA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.24.2", + "@esbuild/android-arm": "0.24.2", + "@esbuild/android-arm64": "0.24.2", + "@esbuild/android-x64": "0.24.2", + "@esbuild/darwin-arm64": "0.24.2", + "@esbuild/darwin-x64": "0.24.2", + "@esbuild/freebsd-arm64": "0.24.2", + "@esbuild/freebsd-x64": "0.24.2", + "@esbuild/linux-arm": "0.24.2", + "@esbuild/linux-arm64": "0.24.2", + "@esbuild/linux-ia32": "0.24.2", + "@esbuild/linux-loong64": "0.24.2", + "@esbuild/linux-mips64el": "0.24.2", + "@esbuild/linux-ppc64": "0.24.2", + "@esbuild/linux-riscv64": "0.24.2", + "@esbuild/linux-s390x": "0.24.2", + "@esbuild/linux-x64": "0.24.2", + "@esbuild/netbsd-arm64": "0.24.2", + "@esbuild/netbsd-x64": "0.24.2", + "@esbuild/openbsd-arm64": "0.24.2", + "@esbuild/openbsd-x64": "0.24.2", + "@esbuild/sunos-x64": "0.24.2", + "@esbuild/win32-arm64": "0.24.2", + "@esbuild/win32-ia32": "0.24.2", + "@esbuild/win32-x64": "0.24.2" + } + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/loupe": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", + "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "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/nanoid": { + "version": "3.3.18", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz", + "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/pathe": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz", + "integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/pathval": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz", + "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.16" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/postcss": { + "version": "8.5.26", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz", + "integrity": "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.17", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/rollup": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.63.1.tgz", + "integrity": "sha512-3Df9jsstwhccuEfmAMi9l8XUh/GOkVObmFTU7CCVBysEbcOZLl84jCtaAZMcPiMz2EGKsATzQcU+Xr3n/wU6cg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@napi-rs/lzma-linux-x64-gnu": "1.5.1", + "@rollup/rollup-android-arm-eabi": "4.63.1", + "@rollup/rollup-android-arm64": "4.63.1", + "@rollup/rollup-darwin-arm64": "4.63.1", + "@rollup/rollup-darwin-x64": "4.63.1", + "@rollup/rollup-freebsd-arm64": "4.63.1", + "@rollup/rollup-freebsd-x64": "4.63.1", + "@rollup/rollup-linux-arm-gnueabihf": "4.63.1", + "@rollup/rollup-linux-arm-musleabihf": "4.63.1", + "@rollup/rollup-linux-arm64-gnu": "4.63.1", + "@rollup/rollup-linux-arm64-musl": "4.63.1", + "@rollup/rollup-linux-loong64-gnu": "4.63.1", + "@rollup/rollup-linux-loong64-musl": "4.63.1", + "@rollup/rollup-linux-ppc64-gnu": "4.63.1", + "@rollup/rollup-linux-ppc64-musl": "4.63.1", + "@rollup/rollup-linux-riscv64-gnu": "4.63.1", + "@rollup/rollup-linux-riscv64-musl": "4.63.1", + "@rollup/rollup-linux-s390x-gnu": "4.63.1", + "@rollup/rollup-linux-x64-gnu": "4.63.1", + "@rollup/rollup-linux-x64-musl": "4.63.1", + "@rollup/rollup-openbsd-x64": "4.63.1", + "@rollup/rollup-openharmony-arm64": "4.63.1", + "@rollup/rollup-win32-arm64-msvc": "4.63.1", + "@rollup/rollup-win32-ia32-msvc": "4.63.1", + "@rollup/rollup-win32-x64-gnu": "4.63.1", + "@rollup/rollup-win32-x64-msvc": "4.63.1", + "fsevents": "~2.3.2" + } + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", + "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinypool": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", + "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + } + }, + "node_modules/tinyrainbow": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-1.2.0.tgz", + "integrity": "sha512-weEDEq7Z5eTHPDh4xjX789+fHfF+P8boiFB+0vbWzpbnbsEr/GRaohi/uMKxg8RZMXnl1ItAi/IUHWMsjDV7kQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tinyspy": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-3.0.2.tgz", + "integrity": "sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz", + "integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/vite": { + "version": "5.4.21", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/vite-node": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-2.1.9.tgz", + "integrity": "sha512-AM9aQ/IPrW/6ENLQg3AGY4K1N2TGZdR5e4gu/MmmR2xR3Ll1+dib+nook92g4TV3PXVyeyxdWwtaCAiUL0hMxA==", + "dev": true, + "license": "MIT", + "dependencies": { + "cac": "^6.7.14", + "debug": "^4.3.7", + "es-module-lexer": "^1.5.4", + "pathe": "^1.1.2", + "vite": "^5.0.0" + }, + "bin": { + "vite-node": "vite-node.mjs" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/vite/node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/vitest": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-2.1.9.tgz", + "integrity": "sha512-MSmPM9REYqDGBI8439mA4mWhV5sKmDlBKWIYbA3lRb2PTHACE0mgKwA8yQ2xq9vxDTuk4iPrECBAEW2aoFXY0Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "2.1.9", + "@vitest/mocker": "2.1.9", + "@vitest/pretty-format": "^2.1.9", + "@vitest/runner": "2.1.9", + "@vitest/snapshot": "2.1.9", + "@vitest/spy": "2.1.9", + "@vitest/utils": "2.1.9", + "chai": "^5.1.2", + "debug": "^4.3.7", + "expect-type": "^1.1.0", + "magic-string": "^0.30.12", + "pathe": "^1.1.2", + "std-env": "^3.8.0", + "tinybench": "^2.9.0", + "tinyexec": "^0.3.1", + "tinypool": "^1.0.1", + "tinyrainbow": "^1.2.0", + "vite": "^5.0.0", + "vite-node": "2.1.9", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@types/node": "^18.0.0 || >=20.0.0", + "@vitest/browser": "2.1.9", + "@vitest/ui": "2.1.9", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..5a7aff3 --- /dev/null +++ b/package.json @@ -0,0 +1,28 @@ +{ + "name": "ration", + "version": "0.1.0", + "private": true, + "description": "Ration — AI Quota Tracker. A browser extension showing remaining quota across your AI subscriptions in one glance.", + "type": "module", + "license": "Apache-2.0", + "repository": { + "type": "git", + "url": "https://github.com/devudilip/ration.git" + }, + "scripts": { + "build": "node build.mjs", + "watch": "node build.mjs --watch", + "typecheck": "tsc --noEmit", + "test": "vitest run", + "test:watch": "vitest", + "icons": "node scripts/gen-icons.mjs", + "ci": "npm run typecheck && npm run test && npm run build" + }, + "devDependencies": { + "@types/chrome": "^0.0.287", + "@types/node": "^26.4.0", + "esbuild": "^0.24.0", + "typescript": "^5.7.0", + "vitest": "^2.1.0" + } +} diff --git a/public/icons/icon128.png b/public/icons/icon128.png new file mode 100644 index 0000000..e780f4f Binary files /dev/null and b/public/icons/icon128.png differ diff --git a/public/icons/icon16.png b/public/icons/icon16.png new file mode 100644 index 0000000..72dee11 Binary files /dev/null and b/public/icons/icon16.png differ diff --git a/public/icons/icon32.png b/public/icons/icon32.png new file mode 100644 index 0000000..ea7207f Binary files /dev/null and b/public/icons/icon32.png differ diff --git a/public/icons/icon48.png b/public/icons/icon48.png new file mode 100644 index 0000000..fa78c70 Binary files /dev/null and b/public/icons/icon48.png differ diff --git a/public/manifest.json b/public/manifest.json new file mode 100644 index 0000000..fcdca68 --- /dev/null +++ b/public/manifest.json @@ -0,0 +1,31 @@ +{ + "manifest_version": 3, + "name": "Ration — AI Quota Tracker", + "version": "0.1.0", + "description": "One glance at remaining quota across your AI subscriptions. No accounts, no telemetry, no credential access.", + "minimum_chrome_version": "120", + "permissions": ["storage", "alarms"], + "optional_host_permissions": [ + "https://claude.ai/*", + "https://chatgpt.com/*" + ], + "host_permissions": [], + "background": { + "service_worker": "background.js", + "type": "module" + }, + "action": { + "default_popup": "popup.html", + "default_title": "Ration", + "default_icon": { + "16": "icons/icon16.png", + "32": "icons/icon32.png" + } + }, + "icons": { + "16": "icons/icon16.png", + "32": "icons/icon32.png", + "48": "icons/icon48.png", + "128": "icons/icon128.png" + } +} diff --git a/public/popup.css b/public/popup.css new file mode 100644 index 0000000..a9f099e --- /dev/null +++ b/public/popup.css @@ -0,0 +1,218 @@ +/* Ration popup. Should feel like a browser affordance, not a SaaS dashboard: + system font stack, no icon fonts, quiet colors, dark mode via + prefers-color-scheme. */ + +:root { + --bg: #ffffff; + --fg: #1f2937; + --fg-muted: #6b7280; + --border: #e5e7eb; + --card: #f9fafb; + --track: #e5e7eb; + --ok: #059669; + --warn: #b45309; + --bad: #b91c1c; + --accent: #2563eb; +} + +@media (prefers-color-scheme: dark) { + :root { + --bg: #1f2937; + --fg: #f3f4f6; + --fg-muted: #9ca3af; + --border: #374151; + --card: #111827; + --track: #374151; + --ok: #34d399; + --warn: #fbbf24; + --bad: #f87171; + --accent: #60a5fa; + } +} + +* { + box-sizing: border-box; +} + +body { + width: 340px; + margin: 0; + background: var(--bg); + color: var(--fg); + font: 13px/1.45 system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif; +} + +#header { + display: flex; + align-items: baseline; + justify-content: space-between; + padding: 10px 14px 8px; + border-bottom: 1px solid var(--border); +} + +#header h1 { + margin: 0; + font-size: 14px; + font-weight: 600; +} + +#age { + color: var(--fg-muted); + font-size: 11px; +} + +#cards { + padding: 6px 10px; +} + +.card { + border: 1px solid var(--border); + border-radius: 8px; + background: var(--card); + padding: 8px 10px; + margin: 6px 0; +} + +.card-head { + display: flex; + align-items: center; + gap: 6px; +} + +.status-glyph { + width: 14px; + text-align: center; + font-weight: 700; +} + +.status-ok .status-glyph { + color: var(--ok); +} +.status-warn .status-glyph { + color: var(--warn); +} +.status-bad .status-glyph { + color: var(--bad); +} + +.provider-name { + color: inherit; + font-weight: 600; + text-decoration: none; +} + +.provider-name:hover { + text-decoration: underline; +} + +.headroom { + margin-left: auto; + font-variant-numeric: tabular-nums; + font-weight: 600; +} + +.card-note { + margin: 4px 0 0 20px; + color: var(--fg-muted); +} + +.card-note a, +.link-button { + color: var(--accent); +} + +.lane { + display: grid; + grid-template-columns: minmax(72px, auto) 1fr auto; + align-items: center; + gap: 8px; + margin: 6px 0 0 20px; +} + +.lane-label { + color: var(--fg-muted); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.bar { + height: 6px; + border-radius: 3px; + background: var(--track); + overflow: hidden; +} + +.bar-fill { + height: 100%; + border-radius: 3px; + background: var(--ok); +} + +.bar-warn .bar-fill { + background: var(--warn); +} + +.bar-bad .bar-fill { + background: var(--bad); +} + +.lane-reset { + color: var(--fg-muted); + font-size: 11px; + font-variant-numeric: tabular-nums; + white-space: nowrap; +} + +.toggle-row { + display: flex; + align-items: center; + gap: 8px; + padding: 7px 4px; +} + +.toggle-row .provider-name { + font-weight: 500; +} + +.toggle-row input { + margin-left: auto; + accent-color: var(--accent); +} + +.section-label { + margin: 10px 4px 2px; + color: var(--fg-muted); + font-size: 11px; + text-transform: uppercase; + letter-spacing: 0.04em; +} + +#footer { + display: flex; + justify-content: space-between; + padding: 8px 14px 10px; + border-top: 1px solid var(--border); + font-size: 11px; +} + +.link-button { + border: 0; + padding: 0; + background: none; + font: inherit; + cursor: pointer; +} + +.link-button:hover { + text-decoration: underline; +} + +#repo { + color: var(--fg-muted); + text-decoration: none; +} + +#repo:hover { + text-decoration: underline; +} diff --git a/public/popup.html b/public/popup.html new file mode 100644 index 0000000..9c17184 --- /dev/null +++ b/public/popup.html @@ -0,0 +1,28 @@ + + + + + Ration + + + + +
+
+ + v0.1.0 +
+ + + diff --git a/scripts/gen-icons.mjs b/scripts/gen-icons.mjs new file mode 100644 index 0000000..5afdb62 --- /dev/null +++ b/scripts/gen-icons.mjs @@ -0,0 +1,99 @@ +// Generates the extension icons as PNGs using only node built-ins, +// so the repo needs no image tooling. Run: npm run icons +// Design: rounded dark square with three "ration bars" of decreasing fill — +// a tiny gauge, which is what the product is. +import { deflateSync } from 'node:zlib'; +import { writeFileSync, mkdirSync } from 'node:fs'; + +const OUT_DIR = new URL('../public/icons/', import.meta.url); +const BG = [0x1f, 0x29, 0x37, 255]; // slate +const BAR_BG = [0x4b, 0x5b, 0x71, 255]; +const BARS = [ + { fill: 0.85, color: [0x34, 0xd3, 0x99, 255] }, // green + { fill: 0.45, color: [0xfb, 0xbf, 0x24, 255] }, // amber + { fill: 0.15, color: [0xf8, 0x71, 0x71, 255] }, // red +]; + +function crc32(buf) { + let c, table = crc32.table; + if (!table) { + table = crc32.table = new Int32Array(256); + for (let n = 0; n < 256; n++) { + c = n; + for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1; + table[n] = c; + } + } + c = 0xffffffff; + for (const b of buf) c = table[(c ^ b) & 0xff] ^ (c >>> 8); + return (c ^ 0xffffffff) >>> 0; +} + +function chunk(type, data) { + const len = Buffer.alloc(4); + len.writeUInt32BE(data.length); + const body = Buffer.concat([Buffer.from(type, 'ascii'), data]); + const crc = Buffer.alloc(4); + crc.writeUInt32BE(crc32(body)); + return Buffer.concat([len, body, crc]); +} + +function png(size, pixels) { + const ihdr = Buffer.alloc(13); + ihdr.writeUInt32BE(size, 0); + ihdr.writeUInt32BE(size, 4); + ihdr[8] = 8; // bit depth + ihdr[9] = 6; // RGBA + const raw = Buffer.alloc(size * (size * 4 + 1)); + for (let y = 0; y < size; y++) { + raw[y * (size * 4 + 1)] = 0; // filter: none + pixels.copy(raw, y * (size * 4 + 1) + 1, y * size * 4, (y + 1) * size * 4); + } + return Buffer.concat([ + Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]), + chunk('IHDR', ihdr), + chunk('IDAT', deflateSync(raw, { level: 9 })), + chunk('IEND', Buffer.alloc(0)), + ]); +} + +function draw(size) { + const px = Buffer.alloc(size * size * 4); // transparent + const put = (x, y, [r, g, b, a]) => { + if (x < 0 || y < 0 || x >= size || y >= size) return; + const i = (y * size + x) * 4; + px[i] = r; px[i + 1] = g; px[i + 2] = b; px[i + 3] = a; + }; + const radius = Math.max(2, Math.round(size * 0.19)); + const inside = (x, y) => { + const r = radius; + const cx = x < r ? r : x >= size - r ? size - r - 1 : x; + const cy = y < r ? r : y >= size - r ? size - r - 1 : y; + if (cx === x || cy === y) return true; + return (x - cx) ** 2 + (y - cy) ** 2 <= r * r; + }; + for (let y = 0; y < size; y++) + for (let x = 0; x < size; x++) if (inside(x, y)) put(x, y, BG); + + const pad = Math.max(2, Math.round(size * 0.19)); + const trackW = size - pad * 2; + const barH = Math.max(1, Math.round(size * 0.11)); + const gap = Math.max(1, Math.round(size * 0.09)); + const totalH = BARS.length * barH + (BARS.length - 1) * gap; + let y0 = Math.round((size - totalH) / 2); + for (const { fill, color } of BARS) { + const fillW = Math.max(1, Math.round(trackW * fill)); + for (let dy = 0; dy < barH; dy++) + for (let dx = 0; dx < trackW; dx++) + put(pad + dx, y0 + dy, dx < fillW ? color : BAR_BG); + y0 += barH + gap; + } + return px; +} + +mkdirSync(OUT_DIR, { recursive: true }); +for (const size of [16, 32, 48, 128]) { + const file = new URL(`icon${size}.png`, OUT_DIR); + writeFileSync(file, png(size, draw(size))); + console.log(`wrote public/icons/icon${size}.png`); +} diff --git a/src/adapters/claude.ts b/src/adapters/claude.ts new file mode 100644 index 0000000..03b00e5 --- /dev/null +++ b/src/adapters/claude.ts @@ -0,0 +1,250 @@ +/** + * Claude.ai adapter (Tier A). + * + * LEGAL GUARDRAIL — do not weaken this in a PR: + * Anthropic's terms restrict Claude Code / claude.ai OAuth tokens to those + * products. This adapter therefore NEVER constructs an Authorization header, + * never reads, stores, or handles any token or cookie value, and the + * extension never requests the `cookies` permission. It only rides the + * user's existing claude.ai browser session via `credentials: 'include'`, + * exactly like the Settings → Usage page the user could open themselves. + * See README "Legal & privacy". + * + * The usage endpoint behind Settings → Usage is undocumented and has not + * been pinned by the maintainers yet, so this adapter probes a short list of + * candidate paths; the first response that is 200 AND validates against a + * known schema wins and is cached. If none verifies, the honest result is an + * `endpoint_not_verified` error (grey "!") with instructions in the README / + * issue templates for capturing the real endpoint from DevTools. + */ +import type { FetchContext, ProviderAdapter, ProviderSnapshot, QuotaLane } from '../types'; +import { clampPct } from '../lib/headroom'; +import { + arr, + matchVariant, + nullable, + num, + obj, + optional, + str, + type Result, + type Schema, +} from '../lib/validate'; + +export const CLAUDE_ADAPTER_VERSION = 1; +const ORIGIN = 'https://claude.ai'; +const ORGANIZATIONS_URL = `${ORIGIN}/api/organizations`; + +/** + * Candidate usage endpoints, tried in order. Community-verified fixes to this + * list are the expected way Claude support solidifies — see the + * "adapter broken" issue template. + */ +export const USAGE_PATH_CANDIDATES = [ + (orgId: string) => `${ORIGIN}/api/organizations/${orgId}/usage`, + (orgId: string) => `${ORIGIN}/api/organizations/${orgId}/usage_limits`, +]; + +interface ClaudeCache { + orgId?: string; + usageUrl?: string; +} + +const orgSchema = obj({ + uuid: str, + capabilities: optional(arr(str)), +}); +const orgsSchema = arr(orgSchema); + +/** Accept either an ISO string or epoch seconds for reset timestamps. */ +const numOrStr: Schema = (v, path) => + typeof v === 'number' || typeof v === 'string' + ? ({ ok: true, value: v } as Result) + : { ok: false, path, expected: 'number|string' }; + +interface UsageWindow { + utilization: number; + resets_at?: number | string | null; +} + +const windowSchema: Schema = obj({ + utilization: num, + resets_at: optional(nullable(numOrStr)), +}); + +interface UsageShape { + five_hour?: UsageWindow; + seven_day?: UsageWindow; + seven_day_sonnet?: UsageWindow; + seven_day_opus?: UsageWindow; +} + +const usageVariants = { + org_usage_windows: obj({ + five_hour: optional(windowSchema), + seven_day: optional(windowSchema), + seven_day_sonnet: optional(windowSchema), + seven_day_opus: optional(windowSchema), + }), +}; + +const LANE_LABELS: Record = { + five_hour: 'Session (5h)', + seven_day: 'Weekly (all models)', + seven_day_sonnet: 'Weekly (Sonnet)', + seven_day_opus: 'Weekly (Opus)', +}; + +function windowToLane(id: keyof UsageShape, w: UsageWindow): QuotaLane { + let resetsAt: string | null = null; + if (typeof w.resets_at === 'number') { + resetsAt = new Date(w.resets_at * 1000).toISOString(); + } else if (typeof w.resets_at === 'string') { + const parsed = Date.parse(w.resets_at); + resetsAt = Number.isNaN(parsed) ? null : new Date(parsed).toISOString(); + } + return { + id, + label: LANE_LABELS[id], + kind: 'percent', + used: clampPct(w.utilization), + limit: 100, + resetsAt, + headroomPct: clampPct(100 - w.utilization), + }; +} + +function toLanes(shape: UsageShape): QuotaLane[] { + const lanes: QuotaLane[] = []; + for (const id of Object.keys(LANE_LABELS) as (keyof UsageShape)[]) { + const w = shape[id]; + if (w) lanes.push(windowToLane(id, w)); + } + return lanes; +} + +async function readJson(response: Response): Promise { + try { + return await response.json(); + } catch { + return undefined; + } +} + +export const claudeAdapter: ProviderAdapter = { + id: 'claude', + displayName: 'Claude', + tier: 'A', + hostPermissions: ['https://claude.ai/*'], + dashboardUrl: 'https://claude.ai/settings/usage', + minRefreshMs: 60_000, + + async fetch(ctx: FetchContext): Promise { + const base = { + providerId: this.id, + displayName: this.displayName, + adapterVersion: CLAUDE_ADAPTER_VERSION, + fetchedAt: ctx.now().toISOString(), + }; + const failed = ( + status: 'unauthenticated' | 'rate_limited' | 'error', + code: string, + message: string, + ): ProviderSnapshot => ({ ...base, status, lanes: [], error: { code, message } }); + + const cache = (await ctx.cache.get()) ?? {}; + + // Step 1: discover the organization id (cached after first success). + let orgId = cache.orgId; + if (!orgId) { + let response: Response; + try { + response = await ctx.fetch(ORGANIZATIONS_URL, { + credentials: 'include', + headers: { Accept: 'application/json' }, + }); + } catch (err) { + return failed('error', 'network', err instanceof Error ? err.message : String(err)); + } + if (response.status === 401 || response.status === 403) { + return failed('unauthenticated', 'not_logged_in', 'Log in at claude.ai'); + } + if (response.status === 429) { + return failed('rate_limited', 'rate_limited', 'claude.ai rate-limited us; backing off'); + } + if (!response.ok) { + return failed('error', `http_${response.status}`, `claude.ai returned ${response.status}`); + } + const body = await readJson(response); + if (body === undefined) { + // Logged-out claude.ai serves the app shell HTML instead of JSON. + return failed('unauthenticated', 'not_logged_in', 'Log in at claude.ai'); + } + const orgs = orgsSchema(body, 'organizations'); + if (!orgs.ok) { + return failed( + 'error', + 'schema_mismatch', + `Unexpected /api/organizations shape at '${orgs.path}'`, + ); + } + if (orgs.value.length === 0) { + return failed('error', 'no_organization', 'No claude.ai organization found'); + } + const chatOrg = orgs.value.find((o) => o.capabilities?.includes('chat')); + orgId = (chatOrg ?? orgs.value[0]!).uuid; + await ctx.cache.set({ ...cache, orgId }); + } + + // Step 2: fetch usage — verified path first, then remaining candidates. + const candidates = USAGE_PATH_CANDIDATES.map((make) => make(orgId)); + const urls = cache.usageUrl + ? [cache.usageUrl, ...candidates.filter((u) => u !== cache.usageUrl)] + : candidates; + + let sawServerError: string | undefined; + for (const url of urls) { + let response: Response; + try { + response = await ctx.fetch(url, { + credentials: 'include', + headers: { Accept: 'application/json' }, + }); + } catch (err) { + return failed('error', 'network', err instanceof Error ? err.message : String(err)); + } + if (response.status === 401 || response.status === 403) { + // Session may have expired since the org id was cached. + await ctx.cache.set({}); + return failed('unauthenticated', 'not_logged_in', 'Log in at claude.ai'); + } + if (response.status === 429) { + return failed('rate_limited', 'rate_limited', 'claude.ai rate-limited us; backing off'); + } + if (!response.ok) { + if (response.status >= 500) sawServerError = `http_${response.status}`; + continue; // 404 etc. — try the next candidate + } + const body = await readJson(response); + if (body === undefined) continue; + const match = matchVariant(usageVariants, body); + if (!match.ok) continue; + const lanes = toLanes(match.value); + if (lanes.length === 0) continue; // validated but empty — not the usage endpoint + + if (cache.usageUrl !== url) { + await ctx.cache.set({ orgId, usageUrl: url }); + } + return { ...base, status: 'ok', schemaVariant: match.variant, lanes }; + } + + if (sawServerError) { + return failed('error', sawServerError, 'claude.ai returned a server error'); + } + return failed( + 'error', + 'endpoint_not_verified', + 'No known claude.ai usage endpoint matched — help pin it via the "adapter broken" issue template', + ); + }, +}; diff --git a/src/adapters/codex.ts b/src/adapters/codex.ts new file mode 100644 index 0000000..ac3af8a --- /dev/null +++ b/src/adapters/codex.ts @@ -0,0 +1,172 @@ +/** + * Codex / ChatGPT adapter (Tier A). + * + * Rides the user's existing chatgpt.com browser session via + * `credentials: 'include'` — no tokens are read, stored, or constructed. + * + * The endpoint is undocumented and has been observed with more than one + * field-name convention (`primary_window`/`secondary_window` vs. + * `five_hour_limit`/`weekly_limit`). Field names are matched literally per + * variant — never inferred from display labels — because mislabeling which + * window is which produces a confidently wrong display, which is worse than + * no display. An unknown shape is an error state, never a zeroed lane. + */ +import type { FetchContext, ProviderAdapter, ProviderSnapshot, QuotaLane } from '../types'; +import { clampPct } from '../lib/headroom'; +import { arr, matchVariant, nullable, num, obj, optional, str, type Schema } from '../lib/validate'; + +export const CODEX_ADAPTER_VERSION = 1; +const USAGE_URL = 'https://chatgpt.com/backend-api/wham/usage'; + +interface UsageWindow { + used_percent: number; + resets_at?: number | null; + resets_in_seconds?: number | null; +} + +const windowSchema: Schema = obj({ + used_percent: num, + resets_at: optional(nullable(num)), + resets_in_seconds: optional(nullable(num)), +}); + +interface AdditionalLimit extends UsageWindow { + name?: string; +} + +const additionalLimitSchema: Schema = obj({ + name: optional(str), + used_percent: num, + resets_at: optional(nullable(num)), + resets_in_seconds: optional(nullable(num)), +}); + +interface WindowsShape { + primary_window: UsageWindow; + secondary_window?: UsageWindow; + additional_rate_limits?: AdditionalLimit[]; +} + +interface NamedShape { + five_hour_limit: UsageWindow; + weekly_limit?: UsageWindow; + additional_rate_limits?: AdditionalLimit[]; +} + +const variants = { + wham_windows: obj({ + primary_window: windowSchema, + secondary_window: optional(windowSchema), + additional_rate_limits: optional(arr(additionalLimitSchema)), + }) as Schema, + wham_named: obj({ + five_hour_limit: windowSchema, + weekly_limit: optional(windowSchema), + additional_rate_limits: optional(arr(additionalLimitSchema)), + }) as Schema, +}; + +function windowToLane(id: string, label: string, w: UsageWindow, now: Date): QuotaLane { + let resetsAt: string | null = null; + if (typeof w.resets_at === 'number') { + resetsAt = new Date(w.resets_at * 1000).toISOString(); + } else if (typeof w.resets_in_seconds === 'number') { + resetsAt = new Date(now.getTime() + w.resets_in_seconds * 1000).toISOString(); + } + return { + id, + label, + kind: 'percent', + used: clampPct(w.used_percent), + limit: 100, + resetsAt, + headroomPct: clampPct(100 - w.used_percent), + }; +} + +function toLanes(value: WindowsShape | NamedShape, now: Date): QuotaLane[] { + const lanes: QuotaLane[] = []; + if ('primary_window' in value) { + lanes.push(windowToLane('session', 'Session', value.primary_window, now)); + if (value.secondary_window) { + lanes.push(windowToLane('weekly', 'Weekly', value.secondary_window, now)); + } + } else { + lanes.push(windowToLane('session', 'Session (5h)', value.five_hour_limit, now)); + if (value.weekly_limit) { + lanes.push(windowToLane('weekly', 'Weekly', value.weekly_limit, now)); + } + } + (value.additional_rate_limits ?? []).forEach((extra, i) => { + const name = extra.name ?? `limit ${i + 1}`; + lanes.push(windowToLane(`extra:${extra.name ?? i}`, name, extra, now)); + }); + return lanes; +} + +export const codexAdapter: ProviderAdapter = { + id: 'codex', + displayName: 'Codex', + tier: 'A', + hostPermissions: ['https://chatgpt.com/*'], + dashboardUrl: 'https://chatgpt.com/#settings/Usage', + minRefreshMs: 60_000, + + async fetch(ctx: FetchContext): Promise { + const base = { + providerId: this.id, + displayName: this.displayName, + adapterVersion: CODEX_ADAPTER_VERSION, + fetchedAt: ctx.now().toISOString(), + }; + const failed = ( + status: 'unauthenticated' | 'rate_limited' | 'error', + code: string, + message: string, + ): ProviderSnapshot => ({ ...base, status, lanes: [], error: { code, message } }); + + let response: Response; + try { + response = await ctx.fetch(USAGE_URL, { + credentials: 'include', + headers: { Accept: 'application/json' }, + }); + } catch (err) { + return failed('error', 'network', err instanceof Error ? err.message : String(err)); + } + + if (response.status === 401 || response.status === 403) { + return failed('unauthenticated', 'not_logged_in', 'Log in at chatgpt.com'); + } + if (response.status === 429) { + return failed('rate_limited', 'rate_limited', 'chatgpt.com rate-limited us; backing off'); + } + if (!response.ok) { + return failed('error', `http_${response.status}`, `chatgpt.com returned ${response.status}`); + } + + let body: unknown; + try { + body = await response.json(); + } catch { + return failed('error', 'not_json', 'Response was not JSON (interstitial page?)'); + } + + const match = matchVariant(variants, body); + if (!match.ok) { + const first = match.errors[0]; + return failed( + 'error', + 'schema_mismatch', + `Unrecognized response shape (variant '${first?.variant}' diverged at '${first?.path}')`, + ); + } + + return { + ...base, + status: 'ok', + schemaVariant: match.variant, + lanes: toLanes(match.value, ctx.now()), + }; + }, +}; diff --git a/src/adapters/index.ts b/src/adapters/index.ts new file mode 100644 index 0000000..2691c90 --- /dev/null +++ b/src/adapters/index.ts @@ -0,0 +1,14 @@ +/** + * The adapter registry. Adding a provider is: create `src/adapters/.ts`, + * import it here, append it to this array, add its origin to + * `optional_host_permissions` in public/manifest.json, and ship fixtures + + * tests. See CONTRIBUTING.md for the worked example. + */ +import type { ProviderAdapter } from '../types'; +import { claudeAdapter } from './claude'; +import { codexAdapter } from './codex'; + +export const adapters: readonly ProviderAdapter[] = [claudeAdapter, codexAdapter]; + +export const getAdapter = (id: string): ProviderAdapter | undefined => + adapters.find((adapter) => adapter.id === id); diff --git a/src/background/backoff.ts b/src/background/backoff.ts new file mode 100644 index 0000000..ff6f9de --- /dev/null +++ b/src/background/backoff.ts @@ -0,0 +1,37 @@ +/** + * Exponential backoff for polite polling of undocumented endpoints. + * Pure math here; the persisted BackoffRecord lives in storage so backoff + * state survives service-worker restarts. + */ +import type { BackoffRecord } from '../types'; + +export const BASE_DELAY_MS = 5 * 60 * 1000; +export const MAX_DELAY_MS = 60 * 60 * 1000; +/** Never more than one request per provider per minute, regardless of trigger. */ +export const HARD_FLOOR_MS = 60 * 1000; + +/** + * 5 min * 2^failCount, capped at 60 min, with ±25% jitter. + * `random` is injected (0..1) so tests are deterministic. + */ +export function nextDelayMs(failCount: number, random: () => number = Math.random): number { + const exp = Math.min(BASE_DELAY_MS * 2 ** failCount, MAX_DELAY_MS); + const jitter = 0.75 + random() * 0.5; + return Math.round(exp * jitter); +} + +export function bumpBackoff( + record: BackoffRecord, + nowMs: number, + random: () => number = Math.random, +): BackoffRecord { + return { + failCount: record.failCount + 1, + nextAllowedAt: nowMs + nextDelayMs(record.failCount, random), + lastAttemptAt: record.lastAttemptAt, + }; +} + +export function resetBackoff(record: BackoffRecord): BackoffRecord { + return { failCount: 0, nextAllowedAt: 0, lastAttemptAt: record.lastAttemptAt }; +} diff --git a/src/background/badge.ts b/src/background/badge.ts new file mode 100644 index 0000000..958d6ba --- /dev/null +++ b/src/background/badge.ts @@ -0,0 +1,51 @@ +/** + * Badge policy: the icon shows the LOWEST headroom across enabled providers — + * the number that will block you first — and stays visually silent when + * everything is fine. + */ +import { isStale, providerHeadroom } from '../lib/headroom'; +import type { ProviderSnapshot, Settings } from '../types'; + +export const COLOR_AMBER = '#f9ab00'; +export const COLOR_RED = '#d93025'; +export const COLOR_GREY = '#9aa0a6'; + +export type BadgeState = { text: string; color: string } | { clear: true }; + +export function computeBadge( + snapshots: Record, + settings: Settings, + now: Date, +): BadgeState { + const enabledIds = Object.entries(settings.providers) + .filter(([, v]) => v.enabled) + .map(([id]) => id); + if (enabledIds.length === 0) return { clear: true }; + + const headrooms: number[] = []; + for (const id of enabledIds) { + const snap = snapshots[id]; + // No snapshot yet = first fetch in flight; not an error condition. + if (!snap) continue; + const headroom = providerHeadroom(snap); + if (snap.status !== 'ok' || headroom === null || isStale(snap, now)) { + return { text: '!', color: COLOR_GREY }; + } + headrooms.push(headroom); + } + if (headrooms.length === 0) return { clear: true }; + + const min = Math.min(...headrooms); + if (min > 40) return { clear: true }; + const text = `${Math.round(min)}`; + return { text, color: min < 15 ? COLOR_RED : COLOR_AMBER }; +} + +export function applyBadge(state: BadgeState): void { + if ('clear' in state) { + void chrome.action.setBadgeText({ text: '' }); + return; + } + void chrome.action.setBadgeText({ text: state.text }); + void chrome.action.setBadgeBackgroundColor({ color: state.color }); +} diff --git a/src/background/index.ts b/src/background/index.ts new file mode 100644 index 0000000..a132b8f --- /dev/null +++ b/src/background/index.ts @@ -0,0 +1,58 @@ +/** + * MV3 service-worker entry: event wiring only. The worker is ephemeral, so + * every handler reloads its state from chrome.storage.local — no module-level + * mutable state. + */ +import type { Msg } from '../types'; +import { getSettings, putSettings, removeProviderData, wipeAll } from '../lib/storage'; +import { ALARM_NAME, ALARM_PERIOD_MIN, refreshDueProviders, updateBadge } from './refresh'; + +function ensureAlarm(): void { + // Idempotent: re-creating an alarm with the same name just reschedules it. + chrome.alarms.create(ALARM_NAME, { periodInMinutes: ALARM_PERIOD_MIN }); +} + +chrome.runtime.onInstalled.addListener(() => { + ensureAlarm(); + void updateBadge(); +}); + +chrome.runtime.onStartup.addListener(() => { + ensureAlarm(); + void updateBadge(); +}); + +chrome.alarms.onAlarm.addListener((alarm) => { + if (alarm.name === ALARM_NAME) void refreshDueProviders('alarm'); +}); + +async function handleMessage(msg: Msg): Promise { + switch (msg.type) { + case 'refresh': + await refreshDueProviders('popup', msg.providerId); + return; + case 'setEnabled': { + const settings = await getSettings(); + settings.providers[msg.providerId] = { enabled: msg.enabled }; + await putSettings(settings); + if (msg.enabled) { + await refreshDueProviders('enable', msg.providerId); + } else { + await removeProviderData(msg.providerId); + await updateBadge(); + } + return; + } + case 'wipeAll': + await wipeAll(); + await updateBadge(); + return; + } +} + +chrome.runtime.onMessage.addListener((msg: Msg, _sender, sendResponse) => { + handleMessage(msg) + .then(() => sendResponse({ ok: true })) + .catch((err: unknown) => sendResponse({ ok: false, error: String(err) })); + return true; // keep the message channel open for the async response +}); diff --git a/src/background/refresh.ts b/src/background/refresh.ts new file mode 100644 index 0000000..cb77568 --- /dev/null +++ b/src/background/refresh.ts @@ -0,0 +1,90 @@ +/** + * Refresh orchestration — the politeness policy in code. These gates are + * non-negotiable: we poll undocumented endpoints as guests. + * + * - hard floor: never more than 1 request per provider per 60s, any trigger + * - persisted exponential backoff gate on prior failures + * - freshness gate: popup opens reuse a recent snapshot instead of fetching + */ +import { adapters as registry, getAdapter } from '../adapters'; +import type { FetchContext, ProviderAdapter } from '../types'; +import { + getAdapterCache, + getAllSnapshots, + getBackoff, + getSettings, + getSnapshot, + putAdapterCache, + putBackoff, + putSnapshot, +} from '../lib/storage'; +import { bumpBackoff, HARD_FLOOR_MS, resetBackoff } from './backoff'; +import { applyBadge, computeBadge } from './badge'; + +export const ALARM_NAME = 'ration:tick'; +export const ALARM_PERIOD_MIN = 5; +/** Slightly under the alarm period so timing jitter can't skip every other tick. */ +const ALARM_FRESH_MS = ALARM_PERIOD_MIN * 60_000 - 30_000; + +export type RefreshTrigger = 'alarm' | 'popup' | 'enable'; + +function makeContext(adapter: ProviderAdapter): FetchContext { + return { + fetch: globalThis.fetch.bind(globalThis), + now: () => new Date(), + cache: { + get: () => getAdapterCache(adapter.id), + set: (value: T) => putAdapterCache(adapter.id, value), + }, + }; +} + +export async function refreshDueProviders( + trigger: RefreshTrigger, + onlyId?: string, + adapters: readonly ProviderAdapter[] = registry, +): Promise { + const settings = await getSettings(); + const targets = onlyId + ? adapters.filter((a) => a.id === onlyId) + : adapters; + + // Sequential on purpose: it naturally staggers requests across providers. + for (const adapter of targets) { + if (!settings.providers[adapter.id]?.enabled) continue; + + const backoff = await getBackoff(adapter.id); + const nowMs = Date.now(); + if (nowMs - backoff.lastAttemptAt < HARD_FLOOR_MS) continue; + if (nowMs < backoff.nextAllowedAt) continue; + + if (trigger !== 'enable') { + const existing = await getSnapshot(adapter.id); + const freshMs = trigger === 'popup' ? adapter.minRefreshMs : ALARM_FRESH_MS; + if (existing && nowMs - Date.parse(existing.fetchedAt) < freshMs) continue; + } + + // Persist the attempt BEFORE fetching so the hard floor holds even if + // the service worker is killed mid-flight. + await putBackoff(adapter.id, { ...backoff, lastAttemptAt: nowMs }); + + const snapshot = await adapter.fetch(makeContext(adapter)); + + const latest = await getBackoff(adapter.id); + if (snapshot.status === 'rate_limited' || snapshot.status === 'error') { + await putBackoff(adapter.id, bumpBackoff(latest, nowMs)); + } else { + await putBackoff(adapter.id, resetBackoff(latest)); + } + await putSnapshot(snapshot); + } + + await updateBadge(); +} + +export async function updateBadge(): Promise { + const [settings, snapshots] = await Promise.all([getSettings(), getAllSnapshots()]); + applyBadge(computeBadge(snapshots, settings, new Date())); +} + +export { getAdapter }; diff --git a/src/lib/headroom.ts b/src/lib/headroom.ts new file mode 100644 index 0000000..9fee9e2 --- /dev/null +++ b/src/lib/headroom.ts @@ -0,0 +1,24 @@ +import type { ProviderSnapshot } from '../types'; + +export function clampPct(pct: number): number { + return Math.min(100, Math.max(0, pct)); +} + +/** + * A provider's overall headroom is the MINIMUM across its lanes: you are + * blocked by whichever ceiling you hit first, so 12% five-hour + 80% weekly + * is 12% headroom, not an average. + */ +export function providerHeadroom(snapshot: ProviderSnapshot): number | null { + if (snapshot.status !== 'ok' || snapshot.lanes.length === 0) return null; + return Math.min(...snapshot.lanes.map((lane) => clampPct(lane.headroomPct))); +} + +/** Snapshots older than this are treated as stale (grey "!"). */ +export const STALE_AFTER_MS = 15 * 60 * 1000; + +export function isStale(snapshot: ProviderSnapshot, now: Date): boolean { + const fetched = Date.parse(snapshot.fetchedAt); + if (Number.isNaN(fetched)) return true; + return now.getTime() - fetched > STALE_AFTER_MS; +} diff --git a/src/lib/storage.ts b/src/lib/storage.ts new file mode 100644 index 0000000..faff6ea --- /dev/null +++ b/src/lib/storage.ts @@ -0,0 +1,83 @@ +/** + * Typed access to chrome.storage.local — the only state store. MV3 service + * workers are killed aggressively, so nothing may live in memory between + * events; everything round-trips through these keys. + * + * Keys are namespaced `v1:` so a future schema migration is a key-prefix + * switch. `v1:history:` is reserved for the 7-day history feature but is + * not written in v0.1. + */ +import type { BackoffRecord, ProviderSnapshot, Settings } from '../types'; + +export const KEY_SETTINGS = 'v1:settings'; +export const keySnapshot = (providerId: string) => `v1:snapshot:${providerId}`; +export const keyBackoff = (providerId: string) => `v1:backoff:${providerId}`; +/** Adapter-private cache, e.g. Claude's discovered org id. */ +export const keyAdapterCache = (providerId: string) => `v1:cache:${providerId}`; + +const SNAPSHOT_PREFIX = 'v1:snapshot:'; + +export async function getSettings(): Promise { + const got = await chrome.storage.local.get(KEY_SETTINGS); + return (got[KEY_SETTINGS] as Settings | undefined) ?? { providers: {} }; +} + +export async function putSettings(settings: Settings): Promise { + await chrome.storage.local.set({ [KEY_SETTINGS]: settings }); +} + +export async function getSnapshot(providerId: string): Promise { + const key = keySnapshot(providerId); + const got = await chrome.storage.local.get(key); + return got[key] as ProviderSnapshot | undefined; +} + +export async function getAllSnapshots(): Promise> { + const all = await chrome.storage.local.get(null); + const out: Record = {}; + for (const [key, value] of Object.entries(all)) { + if (key.startsWith(SNAPSHOT_PREFIX)) { + out[key.slice(SNAPSHOT_PREFIX.length)] = value as ProviderSnapshot; + } + } + return out; +} + +export async function putSnapshot(snapshot: ProviderSnapshot): Promise { + await chrome.storage.local.set({ [keySnapshot(snapshot.providerId)]: snapshot }); +} + +export async function removeProviderData(providerId: string): Promise { + await chrome.storage.local.remove([ + keySnapshot(providerId), + keyBackoff(providerId), + keyAdapterCache(providerId), + ]); +} + +export const EMPTY_BACKOFF: BackoffRecord = { failCount: 0, nextAllowedAt: 0, lastAttemptAt: 0 }; + +export async function getBackoff(providerId: string): Promise { + const key = keyBackoff(providerId); + const got = await chrome.storage.local.get(key); + return (got[key] as BackoffRecord | undefined) ?? EMPTY_BACKOFF; +} + +export async function putBackoff(providerId: string, record: BackoffRecord): Promise { + await chrome.storage.local.set({ [keyBackoff(providerId)]: record }); +} + +export async function getAdapterCache(providerId: string): Promise { + const key = keyAdapterCache(providerId); + const got = await chrome.storage.local.get(key); + return got[key] as T | undefined; +} + +export async function putAdapterCache(providerId: string, value: T): Promise { + await chrome.storage.local.set({ [keyAdapterCache(providerId)]: value }); +} + +/** One-click privacy wipe: everything Ration has ever stored. */ +export async function wipeAll(): Promise { + await chrome.storage.local.clear(); +} diff --git a/src/lib/time.ts b/src/lib/time.ts new file mode 100644 index 0000000..ed437c2 --- /dev/null +++ b/src/lib/time.ts @@ -0,0 +1,32 @@ +/** Pure time formatting for the popup. */ + +/** "just now", "40s ago", "3m ago", "2h ago" */ +export function formatAge(fetchedAt: string, now: Date): string { + const ms = now.getTime() - Date.parse(fetchedAt); + if (Number.isNaN(ms) || ms < 0) return 'unknown'; + const s = Math.floor(ms / 1000); + if (s < 10) return 'just now'; + if (s < 60) return `${s}s ago`; + const m = Math.floor(s / 60); + if (m < 60) return `${m}m ago`; + const h = Math.floor(m / 60); + if (h < 48) return `${h}h ago`; + return `${Math.floor(h / 24)}d ago`; +} + +/** "2h 14m", "4d", "38m", or null when unknown/past. */ +export function formatCountdown(resetsAt: string | null, now: Date): string | null { + if (resetsAt === null) return null; + const ms = Date.parse(resetsAt) - now.getTime(); + if (Number.isNaN(ms) || ms <= 0) return null; + const totalMin = Math.ceil(ms / 60000); + if (totalMin < 60) return `${totalMin}m`; + const totalHours = Math.floor(totalMin / 60); + if (totalHours < 24) { + const min = totalMin % 60; + return min === 0 ? `${totalHours}h` : `${totalHours}h ${min}m`; + } + const days = Math.floor(totalHours / 24); + const hours = totalHours % 24; + return hours === 0 ? `${days}d` : `${days}d ${hours}h`; +} diff --git a/src/lib/validate.ts b/src/lib/validate.ts new file mode 100644 index 0000000..5f08003 --- /dev/null +++ b/src/lib/validate.ts @@ -0,0 +1,87 @@ +/** + * Tiny combinator-style schema validator. Hand-rolled instead of zod to keep + * the extension at zero runtime dependencies (an auditability commitment, see + * README) — our needs are narrow: objects, primitives, arrays, nullable / + * optional fields, and named variants so adapters can log which known + * response shape matched. + * + * The one non-negotiable semantic: a mismatch is a failure with a precise + * path. Nothing is ever coerced or defaulted — a shape drift must surface as + * an error state, never as a confidently wrong zero. + */ + +export type Result = + | { ok: true; value: T } + | { ok: false; path: string; expected: string }; + +export type Schema = (value: unknown, path: string) => Result; + +const fail = (path: string, expected: string): Result => ({ ok: false, path, expected }); + +export const num: Schema = (v, path) => + typeof v === 'number' && Number.isFinite(v) ? { ok: true, value: v } : fail(path, 'number'); + +export const str: Schema = (v, path) => + typeof v === 'string' ? { ok: true, value: v } : fail(path, 'string'); + +export const bool: Schema = (v, path) => + typeof v === 'boolean' ? { ok: true, value: v } : fail(path, 'boolean'); + +export function nullable(schema: Schema): Schema { + return (v, path) => (v === null ? { ok: true, value: null } : schema(v, path)); +} + +/** Missing or undefined is accepted as undefined; present values must match. */ +export function optional(schema: Schema): Schema { + return (v, path) => (v === undefined ? { ok: true, value: undefined } : schema(v, path)); +} + +export function arr(schema: Schema): Schema { + return (v, path) => { + if (!Array.isArray(v)) return fail(path, 'array'); + const out: T[] = []; + for (let i = 0; i < v.length; i++) { + const r = schema(v[i], `${path}[${i}]`); + if (!r.ok) return r; + out.push(r.value); + } + return { ok: true, value: out }; + }; +} + +export function obj(shape: { + [K in keyof T]: Schema; +}): Schema { + return (v, path) => { + if (typeof v !== 'object' || v === null || Array.isArray(v)) return fail(path, 'object'); + const out = {} as T; + for (const key of Object.keys(shape) as (keyof T & string)[]) { + const r = shape[key]((v as Record)[key], path ? `${path}.${key}` : key); + if (!r.ok) return r; + out[key] = r.value; + } + return { ok: true, value: out }; + }; +} + +export type VariantResult = + | { ok: true; variant: string; value: T } + | { ok: false; errors: { variant: string; path: string; expected: string }[] }; + +/** + * Try each named schema variant in order; the first match wins and its name is + * recorded (adapters store it as snapshot.schemaVariant). On total failure the + * per-variant errors give the exact path each one diverged at. + */ +export function matchVariant( + variants: Record>, + value: unknown, +): VariantResult { + const errors: { variant: string; path: string; expected: string }[] = []; + for (const [name, schema] of Object.entries(variants)) { + const r = schema(value, ''); + if (r.ok) return { ok: true, variant: name, value: r.value }; + errors.push({ variant: name, path: r.path, expected: r.expected }); + } + return { ok: false, errors }; +} diff --git a/src/popup/main.ts b/src/popup/main.ts new file mode 100644 index 0000000..f071516 --- /dev/null +++ b/src/popup/main.ts @@ -0,0 +1,205 @@ +/** + * Popup renderer. Reads only from chrome.storage.local — it never fetches. + * It renders the last stored snapshots instantly, asks the service worker + * for a refresh, and re-renders as results stream into storage. + * + * DOM is built with createElement/textContent only: lane labels come from + * provider responses, so nothing remote-derived ever meets innerHTML. + */ +import { adapters } from '../adapters'; +import { providerHeadroom } from '../lib/headroom'; +import { getAllSnapshots, getSettings } from '../lib/storage'; +import { formatAge, formatCountdown } from '../lib/time'; +import type { Msg, ProviderAdapter, ProviderSnapshot, Settings } from '../types'; + +const cardsEl = document.getElementById('cards') as HTMLElement; +const ageEl = document.getElementById('age') as HTMLElement; +const wipeEl = document.getElementById('wipe') as HTMLButtonElement; + +const send = (msg: Msg): Promise => chrome.runtime.sendMessage(msg); + +function el( + tag: K, + className?: string, + text?: string, +): HTMLElementTagNameMap[K] { + const node = document.createElement(tag); + if (className) node.className = className; + if (text !== undefined) node.textContent = text; + return node; +} + +function statusClass(headroom: number | null): string { + if (headroom === null) return 'status-bad'; + if (headroom < 15) return 'status-bad'; + if (headroom <= 40) return 'status-warn'; + return 'status-ok'; +} + +function barClass(headroom: number): string { + if (headroom < 15) return 'bar bar-bad'; + if (headroom <= 40) return 'bar bar-warn'; + return 'bar'; +} + +function providerLink(adapter: ProviderAdapter): HTMLAnchorElement { + const link = el('a', 'provider-name', adapter.displayName); + link.href = adapter.dashboardUrl; + link.target = '_blank'; + link.rel = 'noreferrer noopener'; + link.title = `Open the ${adapter.displayName} usage dashboard`; + return link; +} + +function renderOkCard(adapter: ProviderAdapter, snap: ProviderSnapshot, now: Date): HTMLElement { + const headroom = providerHeadroom(snap); + const card = el('div', `card ${statusClass(headroom)}`); + const head = el('div', 'card-head'); + head.append(el('span', 'status-glyph', headroom !== null && headroom > 40 ? '✓' : '⚠')); + head.append(providerLink(adapter)); + head.append(el('span', 'headroom', headroom === null ? '—' : `${Math.round(headroom)}%`)); + card.append(head); + + for (const lane of snap.lanes) { + const row = el('div', 'lane'); + row.append(el('span', 'lane-label', lane.label)); + const bar = el('div', barClass(lane.headroomPct)); + const fill = el('div', 'bar-fill'); + fill.style.width = `${Math.min(100, Math.max(0, lane.headroomPct))}%`; + bar.title = `${Math.round(lane.headroomPct)}% left`; + bar.append(fill); + row.append(bar); + const countdown = formatCountdown(lane.resetsAt, now); + row.append(el('span', 'lane-reset', countdown ? `resets ${countdown}` : '')); + card.append(row); + } + return card; +} + +function renderProblemCard(adapter: ProviderAdapter, snap: ProviderSnapshot): HTMLElement { + const card = el('div', 'card status-bad'); + const head = el('div', 'card-head'); + head.append(el('span', 'status-glyph', '✕')); + head.append(providerLink(adapter)); + card.append(head); + + const note = el('div', 'card-note'); + if (snap.status === 'unauthenticated') { + note.append('Not logged in — '); + const login = el('a', undefined, `open ${adapter.displayName} ›`); + login.href = new URL(adapter.dashboardUrl).origin; + login.target = '_blank'; + login.rel = 'noreferrer noopener'; + note.append(login); + } else if (snap.status === 'rate_limited') { + note.textContent = 'Rate-limited by the provider — backing off, will retry.'; + } else { + note.textContent = snap.error + ? `Error: ${snap.error.message}` + : 'Something went wrong reading this provider.'; + } + card.append(note); + return card; +} + +function renderPendingCard(adapter: ProviderAdapter): HTMLElement { + const card = el('div', 'card'); + const head = el('div', 'card-head'); + head.append(el('span', 'status-glyph', '…')); + head.append(providerLink(adapter)); + card.append(head); + card.append(el('div', 'card-note', 'Fetching…')); + return card; +} + +function renderToggleRow(adapter: ProviderAdapter, enabled: boolean): HTMLElement { + const row = el('div', 'toggle-row'); + row.append(el('span', 'provider-name', adapter.displayName)); + const toggle = el('input'); + toggle.type = 'checkbox'; + toggle.checked = enabled; + toggle.title = `Track ${adapter.displayName}`; + toggle.addEventListener('change', () => { + void onToggle(adapter, toggle); + }); + row.append(toggle); + return row; +} + +async function onToggle(adapter: ProviderAdapter, toggle: HTMLInputElement): Promise { + if (toggle.checked) { + // Host permission must be requested from the popup: it needs a user gesture. + const granted = await chrome.permissions.request({ origins: adapter.hostPermissions }); + if (!granted) { + toggle.checked = false; + return; + } + await send({ type: 'setEnabled', providerId: adapter.id, enabled: true }); + } else { + await send({ type: 'setEnabled', providerId: adapter.id, enabled: false }); + await chrome.permissions.remove({ origins: adapter.hostPermissions }); + } + await render(); +} + +async function render(): Promise { + const [settings, snapshots]: [Settings, Record] = await Promise.all([ + getSettings(), + getAllSnapshots(), + ]); + const now = new Date(); + + const enabled = adapters.filter((a) => settings.providers[a.id]?.enabled); + const disabled = adapters.filter((a) => !settings.providers[a.id]?.enabled); + + const ok: { adapter: ProviderAdapter; snap: ProviderSnapshot; headroom: number }[] = []; + const problems: { adapter: ProviderAdapter; snap: ProviderSnapshot }[] = []; + const pending: ProviderAdapter[] = []; + + for (const adapter of enabled) { + const snap = snapshots[adapter.id]; + if (!snap) { + pending.push(adapter); + } else if (snap.status === 'ok' && providerHeadroom(snap) !== null) { + ok.push({ adapter, snap, headroom: providerHeadroom(snap)! }); + } else { + problems.push({ adapter, snap }); + } + } + // The routing answer: most headroom first. + ok.sort((a, b) => b.headroom - a.headroom); + + cardsEl.replaceChildren(); + for (const { adapter, snap } of ok) cardsEl.append(renderOkCard(adapter, snap, now)); + for (const { adapter, snap } of problems) cardsEl.append(renderProblemCard(adapter, snap)); + for (const adapter of pending) cardsEl.append(renderPendingCard(adapter)); + + if (disabled.length > 0) { + cardsEl.append(el('div', 'section-label', enabled.length > 0 ? 'More providers' : 'Providers')); + for (const adapter of disabled) cardsEl.append(renderToggleRow(adapter, false)); + } + + const fetchTimes = Object.values(snapshots) + .map((s) => Date.parse(s.fetchedAt)) + .filter((t) => !Number.isNaN(t)); + ageEl.textContent = + fetchTimes.length > 0 + ? `⟳ ${formatAge(new Date(Math.max(...fetchTimes)).toISOString(), now)}` + : ''; +} + +wipeEl.addEventListener('click', () => { + void send({ type: 'wipeAll' }).then(render); +}); + +chrome.storage.onChanged.addListener((_changes, area) => { + if (area === 'local') void render(); +}); + +// Timestamps ("40s ago", countdowns) drift while the popup is open. +setInterval(() => void render(), 30_000); + +void render(); +// Ask for fresh data after first paint; the worker applies its freshness +// and rate-limit gates, and results stream back via storage.onChanged. +void send({ type: 'refresh' }); diff --git a/src/types.ts b/src/types.ts new file mode 100644 index 0000000..8b5e2c0 --- /dev/null +++ b/src/types.ts @@ -0,0 +1,88 @@ +/** + * The adapter contract. Every provider normalizes to these shapes; this is + * what makes adding a provider a single-file contribution (see CONTRIBUTING.md). + */ + +export type QuotaKind = 'percent' | 'currency' | 'count'; + +export interface QuotaLane { + /** Stable id, e.g. 'five_hour', 'included_usage'. */ + id: string; + /** Human label, e.g. 'Session (5h)', 'Weekly'. */ + label: string; + kind: QuotaKind; + used: number; + /** null = provider reports a percentage only, no absolute limit. */ + limit: number | null; + /** ISO 8601; null = unknown. */ + resetsAt: string | null; + /** 0–100 remaining. THE normalized field every provider must compute. */ + headroomPct: number; +} + +export type ProviderStatus = 'ok' | 'unauthenticated' | 'rate_limited' | 'error'; + +export interface ProviderSnapshot { + providerId: string; + displayName: string; + status: ProviderStatus; + lanes: QuotaLane[]; + /** ISO 8601 time this snapshot was fetched. Staleness is derived from it. */ + fetchedAt: string; + /** Which response-schema variant matched, for endpoint-drift forensics. */ + schemaVariant?: string; + /** Bumped whenever an adapter's parsing logic changes. */ + adapterVersion: number; + error?: { code: string; message: string }; +} + +export interface FetchContext { + /** Injected so fixture tests never touch the network. */ + fetch: typeof globalThis.fetch; + /** Injected so fetchedAt / reset math is deterministic in tests. */ + now: () => Date; + /** + * Small per-provider persistent cache (e.g. a discovered org id), backed by + * chrome.storage.local in production and an in-memory map in tests. + */ + cache: { + get(): Promise; + set(value: T): Promise; + }; +} + +export interface ProviderAdapter { + id: string; + displayName: string; + /** Where the credential lives. Tier A = existing browser session cookie. */ + tier: 'A' | 'B' | 'C'; + /** Origins requested via chrome.permissions.request when the user enables it. */ + hostPermissions: string[]; + /** Link to the provider's own usage dashboard. */ + dashboardUrl: string; + /** Per-provider rate-limit floor: never refetch more often than this. */ + minRefreshMs: number; + /** + * MUST never throw and MUST never coerce an unparseable response to zero — + * every failure path returns a snapshot with an honest error status. + */ + fetch(ctx: FetchContext): Promise; +} + +/** Popup → service-worker message protocol. */ +export type Msg = + | { type: 'refresh'; providerId?: string } + | { type: 'setEnabled'; providerId: string; enabled: boolean } + | { type: 'wipeAll' }; + +export interface Settings { + providers: Record; +} + +export interface BackoffRecord { + failCount: number; + /** Epoch ms before which the provider must not be fetched again. */ + nextAllowedAt: number; + /** Epoch ms of the last attempt; enforces the hard 60s/provider floor. */ + lastAttemptAt: number; +} diff --git a/tests/backoff.test.ts b/tests/backoff.test.ts new file mode 100644 index 0000000..dd2d0ff --- /dev/null +++ b/tests/backoff.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it } from 'vitest'; +import { + BASE_DELAY_MS, + bumpBackoff, + MAX_DELAY_MS, + nextDelayMs, + resetBackoff, +} from '../src/background/backoff'; +import type { BackoffRecord } from '../src/types'; + +const mid = () => 0.5; // jitter factor exactly 1.0 + +describe('nextDelayMs', () => { + it('starts at 5 minutes and doubles per failure', () => { + expect(nextDelayMs(0, mid)).toBe(BASE_DELAY_MS); + expect(nextDelayMs(1, mid)).toBe(BASE_DELAY_MS * 2); + expect(nextDelayMs(2, mid)).toBe(BASE_DELAY_MS * 4); + }); + + it('caps at 60 minutes', () => { + expect(nextDelayMs(4, mid)).toBe(MAX_DELAY_MS); + expect(nextDelayMs(50, mid)).toBe(MAX_DELAY_MS); + }); + + it('applies ±25% jitter', () => { + expect(nextDelayMs(0, () => 0)).toBe(BASE_DELAY_MS * 0.75); + expect(nextDelayMs(0, () => 1)).toBe(BASE_DELAY_MS * 1.25); + }); +}); + +describe('bumpBackoff / resetBackoff', () => { + const now = 1_000_000; + const rec: BackoffRecord = { failCount: 1, nextAllowedAt: 0, lastAttemptAt: now - 100 }; + + it('bump increments failCount and schedules from the previous count', () => { + const b = bumpBackoff(rec, now, mid); + expect(b.failCount).toBe(2); + expect(b.nextAllowedAt).toBe(now + BASE_DELAY_MS * 2); + expect(b.lastAttemptAt).toBe(rec.lastAttemptAt); + }); + + it('reset clears the gate but preserves lastAttemptAt (hard floor)', () => { + const r = resetBackoff(rec); + expect(r).toEqual({ failCount: 0, nextAllowedAt: 0, lastAttemptAt: rec.lastAttemptAt }); + }); +}); diff --git a/tests/badge.test.ts b/tests/badge.test.ts new file mode 100644 index 0000000..ef96b20 --- /dev/null +++ b/tests/badge.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, it } from 'vitest'; +import { COLOR_AMBER, COLOR_GREY, COLOR_RED, computeBadge } from '../src/background/badge'; +import { STALE_AFTER_MS } from '../src/lib/headroom'; +import type { ProviderSnapshot, Settings } from '../src/types'; + +const now = new Date('2026-08-28T12:00:00Z'); + +const snap = (id: string, headroomPct: number, over: Partial = {}): ProviderSnapshot => ({ + providerId: id, + displayName: id, + status: 'ok', + lanes: [ + { id: 'l', label: 'L', kind: 'percent', used: 100 - headroomPct, limit: 100, resetsAt: null, headroomPct }, + ], + fetchedAt: now.toISOString(), + adapterVersion: 1, + ...over, +}); + +const settings = (...ids: string[]): Settings => ({ + providers: Object.fromEntries(ids.map((id) => [id, { enabled: true }])), +}); + +describe('computeBadge', () => { + it('clears when no provider is enabled, or none has data yet', () => { + expect(computeBadge({}, { providers: {} }, now)).toEqual({ clear: true }); + expect(computeBadge({}, settings('a'), now)).toEqual({ clear: true }); + }); + + it('is quiet above 40% headroom', () => { + expect(computeBadge({ a: snap('a', 41) }, settings('a'), now)).toEqual({ clear: true }); + }); + + it('shows amber at 15–40% and red below 15%', () => { + expect(computeBadge({ a: snap('a', 40) }, settings('a'), now)).toEqual({ + text: '40', + color: COLOR_AMBER, + }); + expect(computeBadge({ a: snap('a', 15) }, settings('a'), now)).toEqual({ + text: '15', + color: COLOR_AMBER, + }); + expect(computeBadge({ a: snap('a', 14.9) }, settings('a'), now)).toEqual({ + text: '15', + color: COLOR_RED, + }); + }); + + it('uses the minimum across providers — the wall you hit first', () => { + const snaps = { a: snap('a', 90), b: snap('b', 22) }; + expect(computeBadge(snaps, settings('a', 'b'), now)).toEqual({ + text: '22', + color: COLOR_AMBER, + }); + }); + + it('any errored provider wins with grey !', () => { + const snaps = { a: snap('a', 90), b: snap('b', 90, { status: 'error', lanes: [] }) }; + expect(computeBadge(snaps, settings('a', 'b'), now)).toEqual({ text: '!', color: COLOR_GREY }); + }); + + it('a stale snapshot is grey ! even if its numbers look fine', () => { + const old = new Date(now.getTime() - STALE_AFTER_MS - 1000).toISOString(); + const snaps = { a: snap('a', 90, { fetchedAt: old }) }; + expect(computeBadge(snaps, settings('a'), now)).toEqual({ text: '!', color: COLOR_GREY }); + }); + + it('ignores snapshots of disabled providers', () => { + const snaps = { a: snap('a', 90), b: snap('b', 5, { status: 'error' }) }; + expect(computeBadge(snaps, settings('a'), now)).toEqual({ clear: true }); + }); +}); diff --git a/tests/chrome-fake.ts b/tests/chrome-fake.ts new file mode 100644 index 0000000..298dcfd --- /dev/null +++ b/tests/chrome-fake.ts @@ -0,0 +1,57 @@ +/** Minimal in-memory chrome.* fake covering what the background code uses. */ + +export interface ChromeFake { + store: Map; + badge: { text: string; color: string }; + uninstall: () => void; +} + +export function installChromeFake(): ChromeFake { + const store = new Map(); + const badge = { text: '', color: '' }; + + const local = { + async get(keys: string | string[] | null): Promise> { + const out: Record = {}; + const wanted = + keys === null ? [...store.keys()] : typeof keys === 'string' ? [keys] : keys; + for (const key of wanted) { + if (store.has(key)) out[key] = store.get(key); + } + return out; + }, + async set(items: Record): Promise { + for (const [key, value] of Object.entries(items)) store.set(key, value); + }, + async remove(keys: string | string[]): Promise { + for (const key of typeof keys === 'string' ? [keys] : keys) store.delete(key); + }, + async clear(): Promise { + store.clear(); + }, + }; + + const fake = { + storage: { local }, + action: { + async setBadgeText({ text }: { text: string }): Promise { + badge.text = text; + }, + async setBadgeBackgroundColor({ color }: { color: string }): Promise { + badge.color = color; + }, + }, + alarms: { + create(): void {}, + }, + }; + + (globalThis as { chrome?: unknown }).chrome = fake; + return { + store, + badge, + uninstall: () => { + delete (globalThis as { chrome?: unknown }).chrome; + }, + }; +} diff --git a/tests/claude.test.ts b/tests/claude.test.ts new file mode 100644 index 0000000..f594a68 --- /dev/null +++ b/tests/claude.test.ts @@ -0,0 +1,127 @@ +import { readFileSync } from 'node:fs'; +import { describe, expect, it } from 'vitest'; +import { claudeAdapter } from '../src/adapters/claude'; +import { htmlResponse, jsonResponse, makeCtx } from './helpers'; + +const fixture = (name: string): unknown => + JSON.parse(readFileSync(new URL(`./fixtures/claude/${name}`, import.meta.url), 'utf8')); + +const CHAT_ORG = '00000000-0000-4000-8000-0000000000ca'; +const orgs = () => fixture('organizations.json'); +const usage = () => fixture('usage.expected.json'); + +/** Happy-path responder: org discovery, then usage on the first candidate. */ +const happyResponder = (url: string): Response => { + if (url === 'https://claude.ai/api/organizations') return jsonResponse(orgs()); + if (url === `https://claude.ai/api/organizations/${CHAT_ORG}/usage`) { + return jsonResponse(usage()); + } + return jsonResponse({ error: 'not found' }, 404); +}; + +describe('claude adapter', () => { + it('discovers the chat-capable org and parses usage windows', async () => { + const ctx = makeCtx(happyResponder); + const snap = await claudeAdapter.fetch(ctx); + + expect(snap.status).toBe('ok'); + expect(snap.schemaVariant).toBe('org_usage_windows'); + expect(snap.lanes.map((l) => [l.id, l.headroomPct])).toEqual([ + ['five_hour', 96], + ['seven_day', 97], + ['seven_day_opus', 96], + ]); + // ISO string and epoch-seconds reset formats both normalize + expect(snap.lanes[0]?.resetsAt).toBe('2026-08-28T13:08:00.000Z'); + expect(snap.lanes[2]?.resetsAt).toBe(new Date(1756825200 * 1000).toISOString()); + }); + + it('caches org id and verified usage URL — second fetch is one request', async () => { + const ctx = makeCtx(happyResponder); + await claudeAdapter.fetch(ctx); + const requestsAfterFirst = ctx.requests.length; + expect(ctx.cached()).toEqual({ + orgId: CHAT_ORG, + usageUrl: `https://claude.ai/api/organizations/${CHAT_ORG}/usage`, + }); + + const snap = await claudeAdapter.fetch(ctx); + expect(snap.status).toBe('ok'); + expect(ctx.requests.length).toBe(requestsAfterFirst + 1); + }); + + it('falls through candidates until one validates', async () => { + const ctx = makeCtx((url) => { + if (url === 'https://claude.ai/api/organizations') return jsonResponse(orgs()); + if (url.endsWith('/usage')) return jsonResponse({}, 404); + if (url.endsWith('/usage_limits')) return jsonResponse(usage()); + return jsonResponse({}, 404); + }); + const snap = await claudeAdapter.fetch(ctx); + expect(snap.status).toBe('ok'); + expect(ctx.cached()).toMatchObject({ + usageUrl: `https://claude.ai/api/organizations/${CHAT_ORG}/usage_limits`, + }); + }); + + it('reports endpoint_not_verified when no candidate matches', async () => { + const ctx = makeCtx((url) => + url === 'https://claude.ai/api/organizations' + ? jsonResponse(orgs()) + : jsonResponse(fixture('usage.unknown-shape.json')), + ); + const snap = await claudeAdapter.fetch(ctx); + expect(snap.status).toBe('error'); + expect(snap.error?.code).toBe('endpoint_not_verified'); + expect(snap.lanes).toEqual([]); + }); + + it('treats a logged-out HTML shell as unauthenticated', async () => { + const ctx = makeCtx(() => htmlResponse(200)); + const snap = await claudeAdapter.fetch(ctx); + expect(snap.status).toBe('unauthenticated'); + }); + + it('maps 401 to unauthenticated and clears the cache on session expiry', async () => { + const ctx = makeCtx(happyResponder); + await claudeAdapter.fetch(ctx); // populate cache + + let loggedOut = false; + const ctx2 = makeCtx((url) => { + if (loggedOut) return jsonResponse({}, 401); + return happyResponder(url); + }); + await claudeAdapter.fetch(ctx2); + loggedOut = true; + const snap = await claudeAdapter.fetch(ctx2); + expect(snap.status).toBe('unauthenticated'); + expect(ctx2.cached()).toEqual({}); + }); + + it('maps 429 to rate_limited', async () => { + const ctx = makeCtx(() => jsonResponse({}, 429)); + const snap = await claudeAdapter.fetch(ctx); + expect(snap.status).toBe('rate_limited'); + }); + + it('never throws on network failure', async () => { + const ctx = makeCtx(() => { + throw new Error('offline'); + }); + const snap = await claudeAdapter.fetch(ctx); + expect(snap.status).toBe('error'); + expect(snap.error?.code).toBe('network'); + }); + + it('LEGAL: never constructs an Authorization header on any request', async () => { + const ctx = makeCtx(happyResponder); + await claudeAdapter.fetch(ctx); + + expect(ctx.requests.length).toBeGreaterThan(1); + for (const req of ctx.requests) { + expect(req.init?.credentials).toBe('include'); + const headers = Object.keys((req.init?.headers as Record) ?? {}); + expect(headers.map((h) => h.toLowerCase())).not.toContain('authorization'); + } + }); +}); diff --git a/tests/codex.test.ts b/tests/codex.test.ts new file mode 100644 index 0000000..9e3dd6f --- /dev/null +++ b/tests/codex.test.ts @@ -0,0 +1,96 @@ +import { readFileSync } from 'node:fs'; +import { describe, expect, it } from 'vitest'; +import { codexAdapter } from '../src/adapters/codex'; +import { htmlResponse, jsonResponse, makeCtx, NOW } from './helpers'; + +const fixture = (name: string): unknown => + JSON.parse(readFileSync(new URL(`./fixtures/codex/${name}`, import.meta.url), 'utf8')); + +describe('codex adapter', () => { + it('parses the primary_window/secondary_window variant', async () => { + const ctx = makeCtx(() => jsonResponse(fixture('wham-usage.primary-window.json'))); + const snap = await codexAdapter.fetch(ctx); + + expect(snap.status).toBe('ok'); + expect(snap.schemaVariant).toBe('wham_windows'); + expect(snap.lanes.map((l) => [l.id, l.headroomPct])).toEqual([ + ['session', 81], + ['weekly', 96], + ]); + // resets_in_seconds is relative to the injected clock + expect(snap.lanes[0]?.resetsAt).toBe(new Date(NOW.getTime() + 16_980_000).toISOString()); + // resets_at is epoch seconds + expect(snap.lanes[1]?.resetsAt).toBe(new Date(1756897800 * 1000).toISOString()); + }); + + it('parses the five_hour_limit/weekly_limit variant (field-name drift)', async () => { + const ctx = makeCtx(() => jsonResponse(fixture('wham-usage.five-hour-limit.json'))); + const snap = await codexAdapter.fetch(ctx); + + expect(snap.status).toBe('ok'); + expect(snap.schemaVariant).toBe('wham_named'); + expect(snap.lanes.map((l) => [l.id, l.headroomPct])).toEqual([ + ['session', 12], + ['weekly', 60], + ]); + }); + + it('maps additional_rate_limits to extra lanes', async () => { + const ctx = makeCtx(() => jsonResponse(fixture('wham-usage.additional-limits.json'))); + const snap = await codexAdapter.fetch(ctx); + + expect(snap.status).toBe('ok'); + expect(snap.lanes.map((l) => l.id)).toEqual(['session', 'extra:gpt-5-pro', 'extra:1']); + expect(snap.lanes[1]?.label).toBe('gpt-5-pro'); + expect(snap.lanes[1]?.headroomPct).toBe(25); + }); + + it('reports an unknown shape as schema_mismatch, never a zeroed lane', async () => { + const ctx = makeCtx(() => jsonResponse(fixture('wham-usage.unknown-shape.json'))); + const snap = await codexAdapter.fetch(ctx); + + expect(snap.status).toBe('error'); + expect(snap.error?.code).toBe('schema_mismatch'); + expect(snap.lanes).toEqual([]); + }); + + it('maps 401/403 to unauthenticated and 429 to rate_limited', async () => { + for (const [status, expected] of [ + [401, 'unauthenticated'], + [403, 'unauthenticated'], + [429, 'rate_limited'], + [500, 'error'], + ] as const) { + const ctx = makeCtx(() => jsonResponse({}, status)); + const snap = await codexAdapter.fetch(ctx); + expect(snap.status).toBe(expected); + } + }); + + it('treats a non-JSON body as an error state', async () => { + const ctx = makeCtx(() => htmlResponse(200)); + const snap = await codexAdapter.fetch(ctx); + expect(snap.status).toBe('error'); + expect(snap.error?.code).toBe('not_json'); + }); + + it('never throws on network failure', async () => { + const ctx = makeCtx(() => { + throw new Error('offline'); + }); + const snap = await codexAdapter.fetch(ctx); + expect(snap.status).toBe('error'); + expect(snap.error?.code).toBe('network'); + }); + + it('rides the browser session: credentials include, no Authorization header', async () => { + const ctx = makeCtx(() => jsonResponse(fixture('wham-usage.primary-window.json'))); + await codexAdapter.fetch(ctx); + + expect(ctx.requests).toHaveLength(1); + const init = ctx.requests[0]?.init; + expect(init?.credentials).toBe('include'); + const headers = Object.keys((init?.headers as Record) ?? {}); + expect(headers.map((h) => h.toLowerCase())).not.toContain('authorization'); + }); +}); diff --git a/tests/fixtures/claude/organizations.json b/tests/fixtures/claude/organizations.json new file mode 100644 index 0000000..fc5dc81 --- /dev/null +++ b/tests/fixtures/claude/organizations.json @@ -0,0 +1,12 @@ +[ + { + "uuid": "00000000-0000-4000-8000-00000000feed", + "name": "API Workspace", + "capabilities": ["api"] + }, + { + "uuid": "00000000-0000-4000-8000-0000000000ca", + "name": "Personal", + "capabilities": ["chat", "claude_pro"] + } +] diff --git a/tests/fixtures/claude/usage.expected.json b/tests/fixtures/claude/usage.expected.json new file mode 100644 index 0000000..acde428 --- /dev/null +++ b/tests/fixtures/claude/usage.expected.json @@ -0,0 +1,14 @@ +{ + "five_hour": { + "utilization": 4, + "resets_at": "2026-08-28T13:08:00Z" + }, + "seven_day": { + "utilization": 3, + "resets_at": "2026-09-02T15:00:00Z" + }, + "seven_day_opus": { + "utilization": 4, + "resets_at": 1756825200 + } +} diff --git a/tests/fixtures/claude/usage.unknown-shape.json b/tests/fixtures/claude/usage.unknown-shape.json new file mode 100644 index 0000000..094f8b2 --- /dev/null +++ b/tests/fixtures/claude/usage.unknown-shape.json @@ -0,0 +1,6 @@ +{ + "five_hour": { + "utilization": "4%", + "resets_at": "2026-08-28T13:08:00Z" + } +} diff --git a/tests/fixtures/codex/wham-usage.additional-limits.json b/tests/fixtures/codex/wham-usage.additional-limits.json new file mode 100644 index 0000000..990fbfc --- /dev/null +++ b/tests/fixtures/codex/wham-usage.additional-limits.json @@ -0,0 +1,17 @@ +{ + "primary_window": { + "used_percent": 10, + "resets_in_seconds": 3600 + }, + "additional_rate_limits": [ + { + "name": "gpt-5-pro", + "used_percent": 75, + "resets_in_seconds": 86400 + }, + { + "used_percent": 20, + "resets_at": null + } + ] +} diff --git a/tests/fixtures/codex/wham-usage.five-hour-limit.json b/tests/fixtures/codex/wham-usage.five-hour-limit.json new file mode 100644 index 0000000..320a5cf --- /dev/null +++ b/tests/fixtures/codex/wham-usage.five-hour-limit.json @@ -0,0 +1,10 @@ +{ + "five_hour_limit": { + "used_percent": 88, + "resets_in_seconds": 4200 + }, + "weekly_limit": { + "used_percent": 40, + "resets_in_seconds": 500000 + } +} diff --git a/tests/fixtures/codex/wham-usage.primary-window.json b/tests/fixtures/codex/wham-usage.primary-window.json new file mode 100644 index 0000000..7b8b045 --- /dev/null +++ b/tests/fixtures/codex/wham-usage.primary-window.json @@ -0,0 +1,10 @@ +{ + "primary_window": { + "used_percent": 19, + "resets_in_seconds": 16980 + }, + "secondary_window": { + "used_percent": 4, + "resets_at": 1756897800 + } +} diff --git a/tests/fixtures/codex/wham-usage.unknown-shape.json b/tests/fixtures/codex/wham-usage.unknown-shape.json new file mode 100644 index 0000000..ad3e784 --- /dev/null +++ b/tests/fixtures/codex/wham-usage.unknown-shape.json @@ -0,0 +1,6 @@ +{ + "usage": { + "percent_used": "19", + "window": "5h" + } +} diff --git a/tests/headroom.test.ts b/tests/headroom.test.ts new file mode 100644 index 0000000..e0152c0 --- /dev/null +++ b/tests/headroom.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it } from 'vitest'; +import { isStale, providerHeadroom, STALE_AFTER_MS } from '../src/lib/headroom'; +import type { ProviderSnapshot, QuotaLane } from '../src/types'; + +const lane = (headroomPct: number): QuotaLane => ({ + id: 'l', + label: 'L', + kind: 'percent', + used: 100 - headroomPct, + limit: 100, + resetsAt: null, + headroomPct, +}); + +const snap = (over: Partial): ProviderSnapshot => ({ + providerId: 'p', + displayName: 'P', + status: 'ok', + lanes: [], + fetchedAt: new Date(0).toISOString(), + adapterVersion: 1, + ...over, +}); + +describe('providerHeadroom', () => { + it('is the minimum across lanes, not an average', () => { + expect(providerHeadroom(snap({ lanes: [lane(12), lane(80)] }))).toBe(12); + }); + + it('clamps out-of-range lane values into 0–100', () => { + expect(providerHeadroom(snap({ lanes: [lane(-5), lane(120)] }))).toBe(0); + expect(providerHeadroom(snap({ lanes: [lane(120)] }))).toBe(100); + }); + + it('is null for non-ok status or no lanes', () => { + expect(providerHeadroom(snap({ status: 'error', lanes: [lane(50)] }))).toBeNull(); + expect(providerHeadroom(snap({ lanes: [] }))).toBeNull(); + }); +}); + +describe('isStale', () => { + const t0 = new Date('2026-08-28T12:00:00Z'); + + it('is fresh within the window and stale beyond it', () => { + const fresh = snap({ fetchedAt: new Date(t0.getTime() - STALE_AFTER_MS + 1000).toISOString() }); + const stale = snap({ fetchedAt: new Date(t0.getTime() - STALE_AFTER_MS - 1000).toISOString() }); + expect(isStale(fresh, t0)).toBe(false); + expect(isStale(stale, t0)).toBe(true); + }); + + it('treats an unparseable timestamp as stale', () => { + expect(isStale(snap({ fetchedAt: 'garbage' }), t0)).toBe(true); + }); +}); diff --git a/tests/helpers.ts b/tests/helpers.ts new file mode 100644 index 0000000..44fe04f --- /dev/null +++ b/tests/helpers.ts @@ -0,0 +1,49 @@ +import type { FetchContext } from '../src/types'; + +export const NOW = new Date('2026-08-28T12:00:00Z'); + +export interface RecordedRequest { + url: string; + init?: RequestInit; +} + +/** + * Fake FetchContext: serves canned responses per URL (or a single fallback) + * and records every request so tests can assert what was sent — including + * that no Authorization header is ever constructed. + */ +export function makeCtx( + responder: (url: string) => Response | Promise, +): FetchContext & { requests: RecordedRequest[]; cached: () => unknown } { + const requests: RecordedRequest[] = []; + let cached: unknown; + const fetchImpl = (async (input: RequestInfo | URL, init?: RequestInit) => { + const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url; + requests.push({ url, init }); + return responder(url); + }) as typeof globalThis.fetch; + return { + fetch: fetchImpl, + now: () => NOW, + cache: { + get: async () => cached as T | undefined, + set: async (value) => { + cached = value; + }, + }, + requests, + cached: () => cached, + }; +} + +export const jsonResponse = (body: unknown, status = 200): Response => + new Response(JSON.stringify(body), { + status, + headers: { 'Content-Type': 'application/json' }, + }); + +export const htmlResponse = (status = 200): Response => + new Response('log in', { + status, + headers: { 'Content-Type': 'text/html' }, + }); diff --git a/tests/refresh.test.ts b/tests/refresh.test.ts new file mode 100644 index 0000000..f8af966 --- /dev/null +++ b/tests/refresh.test.ts @@ -0,0 +1,149 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { refreshDueProviders } from '../src/background/refresh'; +import { HARD_FLOOR_MS } from '../src/background/backoff'; +import { keyBackoff, KEY_SETTINGS, keySnapshot } from '../src/lib/storage'; +import type { BackoffRecord, ProviderAdapter, ProviderSnapshot } from '../src/types'; +import { installChromeFake, type ChromeFake } from './chrome-fake'; + +function makeAdapter( + id: string, + result: () => ProviderSnapshot, +): ProviderAdapter & { calls: number[] } { + const calls: number[] = []; + return { + id, + displayName: id, + tier: 'A', + hostPermissions: [], + dashboardUrl: 'https://example.com', + minRefreshMs: 60_000, + calls, + async fetch() { + calls.push(Date.now()); + return result(); + }, + }; +} + +const okSnapshot = (id: string): ProviderSnapshot => ({ + providerId: id, + displayName: id, + status: 'ok', + lanes: [ + { id: 'l', label: 'L', kind: 'percent', used: 10, limit: 100, resetsAt: null, headroomPct: 90 }, + ], + fetchedAt: new Date().toISOString(), + adapterVersion: 1, +}); + +const errorSnapshot = (id: string): ProviderSnapshot => ({ + ...okSnapshot(id), + status: 'error', + lanes: [], + error: { code: 'network', message: 'boom' }, +}); + +describe('refreshDueProviders', () => { + let fake: ChromeFake; + + beforeEach(() => { + fake = installChromeFake(); + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-08-28T12:00:00Z')); + }); + + afterEach(() => { + vi.useRealTimers(); + fake.uninstall(); + }); + + const enable = (...ids: string[]) => + fake.store.set(KEY_SETTINGS, { + providers: Object.fromEntries(ids.map((id) => [id, { enabled: true }])), + }); + + it('skips disabled providers entirely', async () => { + const adapter = makeAdapter('a', () => okSnapshot('a')); + await refreshDueProviders('alarm', undefined, [adapter]); + expect(adapter.calls).toHaveLength(0); + }); + + it('fetches enabled providers and stores the snapshot', async () => { + enable('a'); + const adapter = makeAdapter('a', () => okSnapshot('a')); + await refreshDueProviders('alarm', undefined, [adapter]); + expect(adapter.calls).toHaveLength(1); + expect(fake.store.get(keySnapshot('a'))).toMatchObject({ providerId: 'a', status: 'ok' }); + }); + + it('enforces the 60s hard floor across triggers', async () => { + enable('a'); + const adapter = makeAdapter('a', () => okSnapshot('a')); + await refreshDueProviders('enable', 'a', [adapter]); + vi.advanceTimersByTime(HARD_FLOOR_MS - 1000); + await refreshDueProviders('popup', undefined, [adapter]); + await refreshDueProviders('enable', 'a', [adapter]); + expect(adapter.calls).toHaveLength(1); + }); + + it('popup opens reuse a fresh snapshot instead of refetching', async () => { + enable('a'); + const adapter = makeAdapter('a', () => okSnapshot('a')); + await refreshDueProviders('popup', undefined, [adapter]); + vi.advanceTimersByTime(2 * 60_000); // past the hard floor + // a snapshot fresher than adapter.minRefreshMs must be reused, not refetched + fake.store.set(keySnapshot('a'), { ...okSnapshot('a'), fetchedAt: new Date().toISOString() }); + await refreshDueProviders('popup', undefined, [adapter]); + expect(adapter.calls).toHaveLength(1); + }); + + it('bumps persisted backoff on failure and respects the gate', async () => { + enable('a'); + const adapter = makeAdapter('a', () => errorSnapshot('a')); + await refreshDueProviders('alarm', undefined, [adapter]); + expect(adapter.calls).toHaveLength(1); + + const backoff = fake.store.get(keyBackoff('a')) as BackoffRecord; + expect(backoff.failCount).toBe(1); + expect(backoff.nextAllowedAt).toBeGreaterThan(Date.now()); + + // Next alarm tick is inside the backoff window: no fetch. + vi.advanceTimersByTime(4 * 60_000); + await refreshDueProviders('alarm', undefined, [adapter]); + expect(adapter.calls).toHaveLength(1); + }); + + it('resets backoff after a success', async () => { + enable('a'); + let fail = true; + const adapter = makeAdapter('a', () => (fail ? errorSnapshot('a') : okSnapshot('a'))); + await refreshDueProviders('alarm', undefined, [adapter]); + fail = false; + vi.advanceTimersByTime(61 * 60_000); // beyond max backoff + await refreshDueProviders('alarm', undefined, [adapter]); + expect(adapter.calls).toHaveLength(2); + const backoff = fake.store.get(keyBackoff('a')) as BackoffRecord; + expect(backoff.failCount).toBe(0); + expect(backoff.nextAllowedAt).toBe(0); + }); + + it('one broken adapter does not stop the others', async () => { + enable('a', 'b'); + const bad = makeAdapter('a', () => errorSnapshot('a')); + const good = makeAdapter('b', () => okSnapshot('b')); + await refreshDueProviders('alarm', undefined, [bad, good]); + expect(good.calls).toHaveLength(1); + expect(fake.store.get(keySnapshot('b'))).toMatchObject({ status: 'ok' }); + // and the badge reflects the error state + expect(fake.badge.text).toBe('!'); + }); + + it('updates the badge from stored snapshots', async () => { + enable('a'); + const low = { ...okSnapshot('a') }; + low.lanes = [{ ...low.lanes[0]!, headroomPct: 12 }]; + const adapter = makeAdapter('a', () => low); + await refreshDueProviders('alarm', undefined, [adapter]); + expect(fake.badge).toEqual({ text: '12', color: '#d93025' }); + }); +}); diff --git a/tests/time.test.ts b/tests/time.test.ts new file mode 100644 index 0000000..d374edc --- /dev/null +++ b/tests/time.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from 'vitest'; +import { formatAge, formatCountdown } from '../src/lib/time'; + +const now = new Date('2026-08-28T12:00:00Z'); +const ago = (ms: number) => new Date(now.getTime() - ms).toISOString(); +const ahead = (ms: number) => new Date(now.getTime() + ms).toISOString(); + +describe('formatAge', () => { + it('formats seconds, minutes, hours and days', () => { + expect(formatAge(ago(3_000), now)).toBe('just now'); + expect(formatAge(ago(40_000), now)).toBe('40s ago'); + expect(formatAge(ago(3 * 60_000), now)).toBe('3m ago'); + expect(formatAge(ago(2 * 3_600_000), now)).toBe('2h ago'); + expect(formatAge(ago(72 * 3_600_000), now)).toBe('3d ago'); + }); + + it('handles garbage and future timestamps', () => { + expect(formatAge('nope', now)).toBe('unknown'); + expect(formatAge(ahead(60_000), now)).toBe('unknown'); + }); +}); + +describe('formatCountdown', () => { + it('formats minutes, hours and days', () => { + expect(formatCountdown(ahead(38 * 60_000), now)).toBe('38m'); + expect(formatCountdown(ahead((2 * 60 + 14) * 60_000), now)).toBe('2h 14m'); + expect(formatCountdown(ahead(4 * 24 * 3_600_000), now)).toBe('4d'); + }); + + it('is null for unknown or past resets', () => { + expect(formatCountdown(null, now)).toBeNull(); + expect(formatCountdown(ago(1000), now)).toBeNull(); + expect(formatCountdown('garbage', now)).toBeNull(); + }); +}); diff --git a/tests/validate.test.ts b/tests/validate.test.ts new file mode 100644 index 0000000..0110b41 --- /dev/null +++ b/tests/validate.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, it } from 'vitest'; +import { arr, matchVariant, nullable, num, obj, optional, str } from '../src/lib/validate'; + +describe('validate', () => { + it('accepts matching primitives', () => { + expect(num(3.5, 'x')).toEqual({ ok: true, value: 3.5 }); + expect(str('hi', 'x')).toEqual({ ok: true, value: 'hi' }); + }); + + it('rejects NaN and Infinity as numbers', () => { + expect(num(NaN, 'x').ok).toBe(false); + expect(num(Infinity, 'x').ok).toBe(false); + }); + + it('never coerces: numeric strings are not numbers', () => { + const r = num('42', 'used'); + expect(r).toEqual({ ok: false, path: 'used', expected: 'number' }); + }); + + it('reports a precise path into nested objects and arrays', () => { + const schema = obj({ lanes: arr(obj({ used: num })) }); + const r = schema({ lanes: [{ used: 1 }, { used: 'oops' }] }, ''); + expect(r).toEqual({ ok: false, path: 'lanes[1].used', expected: 'number' }); + }); + + it('nullable accepts null, optional accepts missing', () => { + const schema = obj({ limit: nullable(num), note: optional(str) }); + expect(schema({ limit: null }, '')).toEqual({ + ok: true, + value: { limit: null, note: undefined }, + }); + expect(schema({ limit: undefined }, '').ok).toBe(false); + }); + + it('rejects arrays where objects are expected', () => { + expect(obj({ a: num })([], '').ok).toBe(false); + expect(arr(num)({}, '').ok).toBe(false); + }); + + it('matchVariant returns the first matching variant name', () => { + const r = matchVariant<{ x: number } | { y: number }>( + { a: obj({ x: num }), b: obj({ y: num }) }, + { y: 1 }, + ); + expect(r).toEqual({ ok: true, variant: 'b', value: { y: 1 } }); + }); + + it('matchVariant reports each variant divergence path on total failure', () => { + const r = matchVariant<{ x: number } | { y: number }>( + { a: obj({ x: num }), b: obj({ y: num }) }, + { z: 1 }, + ); + expect(r.ok).toBe(false); + if (!r.ok) { + expect(r.errors).toEqual([ + { variant: 'a', path: 'x', expected: 'number' }, + { variant: 'b', path: 'y', expected: 'number' }, + ]); + } + }); +}); diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..0949cf5 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "types": ["chrome", "node"], + "strict": true, + "noUncheckedIndexedAccess": true, + "noFallthroughCasesInSwitch": true, + "forceConsistentCasingInFileNames": true, + "skipLibCheck": true, + "noEmit": true + }, + "include": ["src", "tests", "build.mjs", "scripts"] +}