diff --git a/docs/store/listing.md b/docs/store/listing.md
index fa9cd5e..d2ab935 100644
--- a/docs/store/listing.md
+++ b/docs/store/listing.md
@@ -96,6 +96,18 @@ browser session.
persistent background process.
```
+- `declarativeNetRequestWithHostAccess`:
+
+ ```
+ Some providers' usage APIs (Cursor) reject requests whose Origin header
+ is not the provider's own site. While such a provider is enabled, one
+ rule sets the Origin header to that provider's origin on this
+ extension's own read-only usage requests to that provider's API — and
+ nothing else: the rule is scoped to requests initiated by this
+ extension, on hosts the user has explicitly granted, so no other page's
+ requests are affected. No request content is read or modified.
+ ```
+
- Optional host permission `https://claude.ai/*`:
```
diff --git a/package.json b/package.json
index 8bc477c..f1824e4 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "ration",
- "version": "0.2.0",
+ "version": "0.2.1",
"private": true,
"description": "Ration — AI Quota Tracker. A browser extension showing remaining quota across your AI subscriptions in one glance.",
"type": "module",
diff --git a/public/manifest.json b/public/manifest.json
index 19348ff..39f0bb6 100644
--- a/public/manifest.json
+++ b/public/manifest.json
@@ -1,10 +1,10 @@
{
"manifest_version": 3,
"name": "Ration — AI Quota Tracker",
- "version": "0.2.0",
+ "version": "0.2.1",
"description": "One glance at remaining quota across your AI subscriptions. No accounts, no telemetry, no credential access.",
"minimum_chrome_version": "120",
- "permissions": ["storage", "alarms"],
+ "permissions": ["storage", "alarms", "declarativeNetRequestWithHostAccess"],
"optional_host_permissions": [
"https://claude.ai/*",
"https://chatgpt.com/*",
diff --git a/site/privacy.html b/site/privacy.html
index 0ac0e63..3b32831 100644
--- a/site/privacy.html
+++ b/site/privacy.html
@@ -106,7 +106,8 @@
Permissions
- storage — to keep your readings and settings on your device.
- alarms — to refresh quota readings on a schedule (every 5 minutes per enabled provider).
- - Provider site access (e.g. claude.ai, chatgpt.com) — optional, requested only when you switch that provider on, and used solely to read your quota from that provider.
+ - Provider site access (e.g. claude.ai, chatgpt.com, cursor.com) — optional, requested only when you switch that provider on, and used solely to read your quota from that provider.
+ - declarativeNetRequestWithHostAccess — some providers' APIs (Cursor) reject requests unless the Origin header is the provider's own site; while such a provider is enabled, one rule sets that header on Ration's own read-only usage requests to that provider. The rule is scoped to Ration's own requests on sites you granted — it never touches any other page's traffic.
Changes and contact
diff --git a/src/adapters/cursor.ts b/src/adapters/cursor.ts
index a045135..65c90dc 100644
--- a/src/adapters/cursor.ts
+++ b/src/adapters/cursor.ts
@@ -122,6 +122,10 @@ export const cursorAdapter: ProviderAdapter = {
hostPermissions: ['https://cursor.com/*'],
dashboardUrl: 'https://cursor.com/dashboard',
minRefreshMs: 60_000,
+ // cursor.com rejects POSTs whose Origin isn't its own ("Invalid origin
+ // for state-changing request"), and extension requests carry a
+ // chrome-extension:// Origin. See background/request-rules.ts.
+ originOverride: { origin: 'https://cursor.com', urlPrefix: 'https://cursor.com/api/' },
async fetch(ctx: FetchContext): Promise {
const base = {
diff --git a/src/background/index.ts b/src/background/index.ts
index a132b8f..55101de 100644
--- a/src/background/index.ts
+++ b/src/background/index.ts
@@ -6,6 +6,7 @@
import type { Msg } from '../types';
import { getSettings, putSettings, removeProviderData, wipeAll } from '../lib/storage';
import { ALARM_NAME, ALARM_PERIOD_MIN, refreshDueProviders, updateBadge } from './refresh';
+import { syncOriginRules } from './request-rules';
function ensureAlarm(): void {
// Idempotent: re-creating an alarm with the same name just reschedules it.
@@ -15,11 +16,14 @@ function ensureAlarm(): void {
chrome.runtime.onInstalled.addListener(() => {
ensureAlarm();
void updateBadge();
+ void getSettings().then(syncOriginRules);
});
chrome.runtime.onStartup.addListener(() => {
ensureAlarm();
void updateBadge();
+ // Session rules don't survive browser restarts; re-sync from settings.
+ void getSettings().then(syncOriginRules);
});
chrome.alarms.onAlarm.addListener((alarm) => {
@@ -35,6 +39,7 @@ async function handleMessage(msg: Msg): Promise {
const settings = await getSettings();
settings.providers[msg.providerId] = { enabled: msg.enabled };
await putSettings(settings);
+ await syncOriginRules(settings);
if (msg.enabled) {
await refreshDueProviders('enable', msg.providerId);
} else {
@@ -45,6 +50,7 @@ async function handleMessage(msg: Msg): Promise {
}
case 'wipeAll':
await wipeAll();
+ await syncOriginRules(await getSettings());
await updateBadge();
return;
}
diff --git a/src/background/request-rules.ts b/src/background/request-rules.ts
new file mode 100644
index 0000000..6799812
--- /dev/null
+++ b/src/background/request-rules.ts
@@ -0,0 +1,49 @@
+/**
+ * Origin-header rules for providers whose API rejects extension-origin
+ * requests as CSRF (adapter.originOverride). A declarativeNetRequest session
+ * rule sets the Origin header to the provider's own origin — but ONLY on
+ * requests initiated by this extension (initiatorDomains scoping), so other
+ * pages' requests are untouched and the provider's CSRF protection is not
+ * weakened for anyone else. The user-agent and everything else stay honest;
+ * this only makes our read-only usage requests pass the same origin check
+ * the provider's own dashboard passes.
+ *
+ * Session rules don't survive a browser restart, so the service worker
+ * re-syncs them on startup/install and whenever a provider is toggled.
+ */
+import { adapters } from '../adapters';
+import type { Settings } from '../types';
+
+const RULE_ID_BASE = 100;
+
+export async function syncOriginRules(settings: Settings): Promise {
+ const dnr = chrome.declarativeNetRequest;
+ if (!dnr?.updateSessionRules) return;
+
+ const removeRuleIds: number[] = [];
+ const addRules: chrome.declarativeNetRequest.Rule[] = [];
+
+ adapters.forEach((adapter, index) => {
+ const ruleId = RULE_ID_BASE + index;
+ removeRuleIds.push(ruleId);
+ const override = adapter.originOverride;
+ if (!override || !settings.providers[adapter.id]?.enabled) return;
+ addRules.push({
+ id: ruleId,
+ priority: 1,
+ action: {
+ type: dnr.RuleActionType.MODIFY_HEADERS,
+ requestHeaders: [
+ { header: 'origin', operation: dnr.HeaderOperation.SET, value: override.origin },
+ ],
+ },
+ condition: {
+ urlFilter: `|${override.urlPrefix}`,
+ initiatorDomains: [chrome.runtime.id],
+ resourceTypes: [dnr.ResourceType.XMLHTTPREQUEST],
+ },
+ });
+ });
+
+ await dnr.updateSessionRules({ removeRuleIds, addRules });
+}
diff --git a/src/types.ts b/src/types.ts
index 8b5e2c0..42d6a06 100644
--- a/src/types.ts
+++ b/src/types.ts
@@ -62,6 +62,14 @@ export interface ProviderAdapter {
dashboardUrl: string;
/** Per-provider rate-limit floor: never refetch more often than this. */
minRefreshMs: number;
+ /**
+ * For providers whose API rejects extension-origin requests as CSRF
+ * ("Invalid origin for state-changing request"): while the provider is
+ * enabled, the extension's OWN requests matching `urlPrefix` get their
+ * Origin header set to `origin` via declarativeNetRequest. Scoped to
+ * requests initiated by this extension only — never other pages'.
+ */
+ originOverride?: { origin: string; urlPrefix: string };
/**
* MUST never throw and MUST never coerce an unparseable response to zero —
* every failure path returns a snapshot with an honest error status.
diff --git a/tests/chrome-fake.ts b/tests/chrome-fake.ts
index 298dcfd..ced40d5 100644
--- a/tests/chrome-fake.ts
+++ b/tests/chrome-fake.ts
@@ -3,12 +3,14 @@
export interface ChromeFake {
store: Map;
badge: { text: string; color: string };
+ sessionRules: Map;
uninstall: () => void;
}
export function installChromeFake(): ChromeFake {
const store = new Map();
const badge = { text: '', color: '' };
+ const sessionRules = new Map();
const local = {
async get(keys: string | string[] | null): Promise> {
@@ -44,12 +46,28 @@ export function installChromeFake(): ChromeFake {
alarms: {
create(): void {},
},
+ runtime: {
+ id: 'test-extension-id',
+ },
+ declarativeNetRequest: {
+ RuleActionType: { MODIFY_HEADERS: 'modifyHeaders' },
+ HeaderOperation: { SET: 'set' },
+ ResourceType: { XMLHTTPREQUEST: 'xmlhttprequest' },
+ async updateSessionRules(options: {
+ removeRuleIds?: number[];
+ addRules?: { id: number }[];
+ }): Promise {
+ for (const id of options.removeRuleIds ?? []) sessionRules.delete(id);
+ for (const rule of options.addRules ?? []) sessionRules.set(rule.id, rule);
+ },
+ },
};
(globalThis as { chrome?: unknown }).chrome = fake;
return {
store,
badge,
+ sessionRules,
uninstall: () => {
delete (globalThis as { chrome?: unknown }).chrome;
},
diff --git a/tests/request-rules.test.ts b/tests/request-rules.test.ts
new file mode 100644
index 0000000..27ec7f4
--- /dev/null
+++ b/tests/request-rules.test.ts
@@ -0,0 +1,59 @@
+import { afterEach, beforeEach, describe, expect, it } from 'vitest';
+import { adapters } from '../src/adapters';
+import { syncOriginRules } from '../src/background/request-rules';
+import type { Settings } from '../src/types';
+import { installChromeFake, type ChromeFake } from './chrome-fake';
+
+const settings = (enabled: string[]): Settings => ({
+ providers: Object.fromEntries(enabled.map((id) => [id, { enabled: true }])),
+});
+
+describe('syncOriginRules', () => {
+ let fake: ChromeFake;
+
+ beforeEach(() => {
+ fake = installChromeFake();
+ });
+
+ afterEach(() => {
+ fake.uninstall();
+ });
+
+ it('adds an origin rule for enabled providers that declare originOverride', async () => {
+ await syncOriginRules(settings(['cursor']));
+
+ expect(fake.sessionRules.size).toBe(1);
+ const rule = [...fake.sessionRules.values()][0] as {
+ action: { type: string; requestHeaders: { header: string; value: string }[] };
+ condition: { urlFilter: string; initiatorDomains: string[]; resourceTypes: string[] };
+ };
+ expect(rule.action.type).toBe('modifyHeaders');
+ expect(rule.action.requestHeaders).toEqual([
+ { header: 'origin', operation: 'set', value: 'https://cursor.com' },
+ ]);
+ expect(rule.condition.urlFilter).toBe('|https://cursor.com/api/');
+ // Scoped to the extension's own requests only — never other pages'.
+ expect(rule.condition.initiatorDomains).toEqual(['test-extension-id']);
+ });
+
+ it('adds no rules for providers without originOverride', async () => {
+ await syncOriginRules(settings(['claude', 'codex']));
+ expect(fake.sessionRules.size).toBe(0);
+ });
+
+ it('removes the rule when the provider is disabled', async () => {
+ await syncOriginRules(settings(['cursor']));
+ expect(fake.sessionRules.size).toBe(1);
+ await syncOriginRules(settings([]));
+ expect(fake.sessionRules.size).toBe(0);
+ });
+
+ it('is a no-op when declarativeNetRequest is unavailable', async () => {
+ delete (globalThis.chrome as { declarativeNetRequest?: unknown }).declarativeNetRequest;
+ await expect(syncOriginRules(settings(['cursor']))).resolves.toBeUndefined();
+ });
+
+ it('only cursor currently declares an originOverride', () => {
+ expect(adapters.filter((a) => a.originOverride).map((a) => a.id)).toEqual(['cursor']);
+ });
+});