diff --git a/.env.example b/.env.example index 27d926f..b48cba5 100644 --- a/.env.example +++ b/.env.example @@ -1,16 +1,4 @@ -GOOGLE_CLIENT_ID= -# Required whenever HOST binds beyond loopback (e.g. 0.0.0.0 for phone/LAN). -ALLOWED_EMAILS= -# Required for phone/LAN (raw IP) access. Google GIS cannot use raw IP origins. -DASHBOARD_PIN= -# Google-compatible public UI origin (Vercel static host). Default below. -PUBLIC_ORIGIN=https://agent-dashboard-ctt.vercel.app -# Optional: override auto-generated secret for cookie + Bearer token signing -# (~/.config/agent-deck/session-secret) -SESSION_SECRET= -# Optional LAN bind for phone access (LaunchAgent / serve:lan also set this) -# HOST=0.0.0.0 +# Optional overrides (all have defaults). # PORT=3847 -# UI → Mac API base for a Vercel (or other) static build. Use an HTTPS tunnel URL -# when the UI is on HTTPS; leave empty for same-origin Mac serve. -# VITE_API_BASE=https://your-mac-api.example +# CACHE_TTL_MS=10000 +# USAGE_RESETS_TTL_MS=180000 diff --git a/README.md b/README.md index 948db19..7bb3cb6 100644 --- a/README.md +++ b/README.md @@ -4,11 +4,13 @@ Local macOS dashboard for AI agent usage (Cursor, Claude Code, Codex), Mac resource meters, and your GitHub contribution calendar. -Runs entirely on your machine. Usage-reset lookups send your local Cursor, Codex, and Claude auth tokens from this Mac only to those vendors' usage APIs (api2.cursor.sh, auth.openai.com / chatgpt.com, api.anthropic.com) - not to Agent Deck or any other service. +Runs entirely on your machine at **http://127.0.0.1:3847**. No cloud host and no sign-in - open the URL and the dashboard loads. + +Usage-reset lookups send your local Cursor, Codex, and Claude auth tokens from this Mac only to those vendors' usage APIs (api2.cursor.sh, auth.openai.com / chatgpt.com, api.anthropic.com) - not to Agent Deck or any other service. ![Agent Deck](https://img.shields.io/badge/platform-macOS-black) ![Node](https://img.shields.io/badge/node-%3E%3D22-brightgreen) ![License](https://img.shields.io/badge/license-MIT-blue) -## Why the Mac still runs the API +## Why localhost Your machine is the source of truth: @@ -17,116 +19,47 @@ Your machine is the source of truth: - **CPU / GPU / Memory** only exist on this Mac - **GitHub** calendar is fetched with your local `gh` auth -Collectors must stay on this Mac. You can host a **static UI** on Vercel (see below) that talks to the Mac API over a tunnel or LAN URL - do not deploy the collectors to a public cloud. - -## Recommended setup (auto-start + dual auth) - -You do **not** need `npm run dev` day to day. That is only for developers hacking on the UI. +Collectors must stay on this Mac. This app is **localhost-only**. -1. Install once: +## Recommended setup ```bash git clone https://github.com/ctt062/agent-dashboard.git cd agent-dashboard npm install -cp .env.example .env -``` - -2. Configure `.env`: - -```bash -GOOGLE_CLIENT_ID=your-id.apps.googleusercontent.com -ALLOWED_EMAILS=you@gmail.com -DASHBOARD_PIN=your-phone-pin -PUBLIC_ORIGIN=https://agent-dashboard-ctt.vercel.app -``` - -Authorized JavaScript origins (Google Cloud Console) - Google GIS does **not** accept raw LAN IPs: - -- `http://127.0.0.1:3847` and `http://localhost:3847` (production serve) -- `http://127.0.0.1:5174` and `http://localhost:5174` (`npm run dev`) -- `https://agent-dashboard-ctt.vercel.app` (or your `PUBLIC_ORIGIN`) - -3. Build + install login auto-start: - -```bash npm run setup ``` -`npm run setup` defaults to LAN bind (`HOST=0.0.0.0`) and refuses to install unless `GOOGLE_CLIENT_ID`, `ALLOWED_EMAILS`, and `DASHBOARD_PIN` are set. - -After that, Agent Deck starts when you log into your Mac: - -- Desktop / localhost and the Vercel UI origin use **Google** sign-in (verified email + allowlist). -- Phone on a raw LAN IP uses **PIN** sign-in (`DASHBOARD_PIN`). +That builds the UI, installs a macOS LaunchAgent so Agent Deck starts at login, and opens **http://127.0.0.1:3847**. -`npm run dev` is optional (hot reload for coding). Prefer `npm run setup` for normal use. - -## Optional: static UI on Vercel - -Public UI: [https://agent-dashboard-ctt.vercel.app/](https://agent-dashboard-ctt.vercel.app/) - -1. Deploy the Vite `dist/` (SPA rewrite is in `vercel.json`). -2. Point the UI at your Mac API with either: - - build-time `VITE_API_BASE=https://your-mac-tunnel.example`, or - - runtime `public/runtime-config.js` → `window.__AGENT_DECK_API_BASE__` -3. Prefer an **HTTPS tunnel** to the Mac when the UI is on HTTPS (browsers block mixed content to bare `http://192.168.x.x`). -4. On the Mac, keep `PUBLIC_ORIGIN=https://agent-dashboard-ctt.vercel.app` so CORS allows that origin + localhost only (no wildcard, no raw LAN origins). -5. Sign-in returns a **Bearer token** stored in the browser (`sessionStorage`) and sent as `Authorization: Bearer …`. Do not rely on third-party cookies for Vercel → Mac. - -Collectors and `/api/*` still run on the Mac. +`npm run dev` is optional (hot reload for coding). ## Download -Day-to-day install is [Recommended setup](#recommended-setup-auto-start--dual-auth) (`.env` auth + `npm run setup`). Auth is required for dashboard data. - ### ZIP (no Git) 1. Open https://github.com/ctt062/agent-dashboard 2. Click **Code → Download ZIP** -3. Unzip, then continue from `cp .env.example .env` in Recommended setup. +3. Unzip, then `npm install` and `npm run setup`. ### One-command production serve -After `.env` is configured (see Recommended setup), build and serve API + static UI on one port: - ```bash npm run serve ``` -Open **http://127.0.0.1:3847** (Google sign-in). - -`npm start` alone also works after `npm run build` (serves `dist/` when present). Hot-reload coding: `npm run dev` → **http://127.0.0.1:5174**. - -## View on your phone (same Wi-Fi) - -Agent Deck is a local web app. Your Mac still runs the API; your phone opens it in Safari/Chrome and signs in with the **PIN** (Google cannot authorize raw IP origins). - -```bash -npm run serve:lan -``` - -The terminal prints a LAN URL like `http://192.168.x.x:3847`. Open that on your phone (same Wi-Fi) and enter `DASHBOARD_PIN`. On iOS you can use **Share → Add to Home Screen** for an app-like icon. - -Dev equivalent: - -```bash -npm run dev:lan -``` - -Then open the printed Vite URL (port `5174`) on your phone. +Open **http://127.0.0.1:3847**. -Only do this on a trusted network - LAN mode exposes local agent + Mac metrics to devices on that Wi-Fi. +`npm start` alone also works after `npm run build`. Hot-reload coding: `npm run dev` → **http://127.0.0.1:5174**. ## Start at login (macOS) -Use `npm run setup` from [Recommended setup](#recommended-setup-auto-start--dual-auth) (requires `.env` auth when LAN-bound). LaunchAgent only: - ```bash -npm run launchagent:install +npm run setup ``` -Remove with `npm run launchagent:uninstall`. +LaunchAgent only: `npm run launchagent:install` +Remove: `npm run launchagent:uninstall` ## Requirements @@ -142,18 +75,17 @@ Missing collectors degrade gracefully - each panel shows a short hint instead of ## Features -- **Date range**: Today / 7 days / 30 days / This month for agent share and charts -- **Usage resets**: Per-provider token/limit reset times (Cursor billing cycle via local dashboard API, Codex ChatGPT wham/usage windows, Claude rolling 5h/weekly with `/usage` guidance when exact times are unavailable) +- **Billing cycle view**: Plan usage % / limits plus relative agent activity for each provider’s current billing window +- **Usage resets**: Per-provider token/limit reset times - **Detailed agent stats**: period total, avg/day, active days, peak day, acceptance rate (Cursor), input/output tokens - **Dual-series charts** plus a cross-agent comparison chart -- **Dual auth**: Google on localhost / `PUBLIC_ORIGIN`; PIN on LAN IPs; Bearer token for Vercel → Mac API; `GOOGLE_CLIENT_ID` + `ALLOWED_EMAILS` + `DASHBOARD_PIN` required for LAN bind -- **Local web app**: `npm run setup` auto-starts at login; `serve:lan` / LaunchAgent for phone on the same Wi-Fi -- **Cached collectors** (~10s TTL) with parallel collection; usage-reset lookups cache separately (~3 min). Refresh bypasses both caches +- **Auto-start** at login via `npm run setup` +- **Cached collectors** (~10s TTL) with parallel collection; usage-reset lookups cache separately (~3 min) ## Stack - Vite + React + TypeScript UI -- Express API on port `3847` (Local Mac; also serves `dist/` after build) +- Express API on port `3847` (also serves `dist/` after build) - Collectors read local files / `top` / `ioreg` / `gh api` ## What the percentages mean @@ -162,40 +94,25 @@ Agent % is **relative share** of a local activity score across Cursor, Claude Co | Agent | Primary signal | |-------|----------------| -| Cursor | Accepted AI lines (`aiCodeTracking.dailyStats`) | +| Cursor | Accepted AI lines when present; otherwise Agent transcript / ACP session volume | | Claude Code | Tokens from `~/.claude/projects/**/*.jsonl`, else message volume | | Codex | Tokens from `~/.codex/sessions/**/*.jsonl`, else event volume | ## API -Default bind is `127.0.0.1`. Use `HOST=0.0.0.0` (or `npm run serve:lan`) for LAN/phone access. - -`/api/dashboard` and `/api/system` require auth (session cookie and/or `Authorization: Bearer …`). `/api/health` and `/api/auth/*` are reachable without a prior session (sign-in routes issue the token). +Bind is `127.0.0.1` only. Non-loopback `HOST` values are rejected. | Endpoint | Description | |----------|-------------| -| `GET /api/dashboard?range=1d\|7d\|30d\|month` | Full payload (agents + system + GitHub). Add `refresh=1` to bypass cache. Auth required. | -| `GET /api/system` | Mac snapshot only. Auth required. | -| `GET /api/health` | Liveness check (no auth) | -| `GET /api/auth/config` | Auth mode for this Host/Origin (`google` or `pin`) | - -Override host/port if needed: - -```bash -HOST=0.0.0.0 PORT=4000 npm start -``` +| `GET /api/dashboard` | Full payload for this billing cycle (agents + system + GitHub). Add `refresh=1` to bypass cache. | +| `GET /api/system` | Mac snapshot only. | +| `GET /api/health` | Liveness check. | ## Privacy - Dashboard stats come from files and tools already on your Mac -- Google sign-in uses Google Identity Services on localhost and `PUBLIC_ORIGIN`; LAN IPs use PIN instead -- Cross-origin Vercel UI uses Bearer tokens (not third-party cookies); Mac same-origin UI may still use session cookies -- When LAN bind is enabled, `GOOGLE_CLIENT_ID`, `ALLOWED_EMAILS`, and `DASHBOARD_PIN` are required -- Failed PIN attempts are rate-limited in memory per client IP -- Usage-reset times use local Cursor / Codex / Claude credentials on this machine only to call those vendors' usage APIs; tokens are not sent to Agent Deck or any other service -- Default LaunchAgent bind is LAN-capable (`HOST=0.0.0.0`) - use only on trusted Wi-Fi -- CORS allowlists localhost + `PUBLIC_ORIGIN` only (no wildcard) -- Static UI may live on Vercel; collectors and the API stay on your Mac +- Bound to localhost only - not exposed on your LAN or the public internet +- Usage-reset times use local Cursor / Codex / Claude credentials on this machine only to call those vendors' usage APIs ## License diff --git a/index.html b/index.html index 4af8db8..cdd9f74 100644 --- a/index.html +++ b/index.html @@ -20,7 +20,6 @@
- diff --git a/package-lock.json b/package-lock.json index f3d0b5f..7d32007 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,16 +10,12 @@ "license": "MIT", "dependencies": { "concurrently": "^10.0.4", - "cookie-session": "^2.1.1", "dotenv": "^17.4.2", "express": "^5.2.1", - "google-auth-library": "^10.9.1", "react": "^19.2.7", - "react-dom": "^19.2.7", - "recharts": "^3.10.1" + "react-dom": "^19.2.7" }, "devDependencies": { - "@types/cookie-session": "^2.0.49", "@types/express": "^5.0.6", "@types/node": "^24.13.3", "@types/react": "^19.2.17", @@ -862,32 +858,6 @@ "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@reduxjs/toolkit": { - "version": "2.12.0", - "resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.12.0.tgz", - "integrity": "sha512-KiT+RzZbp6mQET+Mg+h2c97+9j1sNflUxQkIHI7Yuzf6Peu+OYpmkn6nbHWmLLWj+1ZODUJFwGZ7gx3L9R9EOw==", - "license": "MIT", - "dependencies": { - "@standard-schema/spec": "^1.0.0", - "@standard-schema/utils": "^0.3.0", - "immer": "^11.0.0", - "redux": "^5.0.1", - "redux-thunk": "^3.1.0", - "reselect": "^5.1.0" - }, - "peerDependencies": { - "react": "^16.9.0 || ^17.0.0 || ^18 || ^19", - "react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0" - }, - "peerDependenciesMeta": { - "react": { - "optional": true - }, - "react-redux": { - "optional": true - } - } - }, "node_modules/@rolldown/binding-android-arm64": { "version": "1.1.5", "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.1.5.tgz", @@ -1152,18 +1122,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@standard-schema/spec": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", - "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", - "license": "MIT" - }, - "node_modules/@standard-schema/utils": { - "version": "0.3.0", - "resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz", - "integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==", - "license": "MIT" - }, "node_modules/@tybys/wasm-util": { "version": "0.10.3", "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz", @@ -1196,80 +1154,6 @@ "@types/node": "*" } }, - "node_modules/@types/cookie-session": { - "version": "2.0.49", - "resolved": "https://registry.npmjs.org/@types/cookie-session/-/cookie-session-2.0.49.tgz", - "integrity": "sha512-4E/bBjlqLhU5l4iGPR+NkVJH593hpNsT4dC3DJDr+ODm6Qpe13kZQVkezRIb+TYDXaBMemS3yLQ+0leba3jlkQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/express": "*", - "@types/keygrip": "*" - } - }, - "node_modules/@types/d3-array": { - "version": "3.2.2", - "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz", - "integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==", - "license": "MIT" - }, - "node_modules/@types/d3-color": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", - "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==", - "license": "MIT" - }, - "node_modules/@types/d3-ease": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz", - "integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==", - "license": "MIT" - }, - "node_modules/@types/d3-interpolate": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", - "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==", - "license": "MIT", - "dependencies": { - "@types/d3-color": "*" - } - }, - "node_modules/@types/d3-path": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz", - "integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==", - "license": "MIT" - }, - "node_modules/@types/d3-scale": { - "version": "4.0.9", - "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz", - "integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==", - "license": "MIT", - "dependencies": { - "@types/d3-time": "*" - } - }, - "node_modules/@types/d3-shape": { - "version": "3.1.8", - "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.8.tgz", - "integrity": "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==", - "license": "MIT", - "dependencies": { - "@types/d3-path": "*" - } - }, - "node_modules/@types/d3-time": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz", - "integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==", - "license": "MIT" - }, - "node_modules/@types/d3-timer": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz", - "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==", - "license": "MIT" - }, "node_modules/@types/express": { "version": "5.0.6", "resolved": "https://registry.npmjs.org/@types/express/-/express-5.0.6.tgz", @@ -1302,13 +1186,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@types/keygrip": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/@types/keygrip/-/keygrip-1.0.6.tgz", - "integrity": "sha512-lZuNAY9xeJt7Bx4t4dx0rYCDqGPW8RXhQZK1td7d4H6E9zYbLoOtjBvfwdTKpsyxQI/2jv+armjX/RW+ZNpXOQ==", - "dev": true, - "license": "MIT" - }, "node_modules/@types/node": { "version": "24.13.3", "resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.3.tgz", @@ -1337,7 +1214,7 @@ "version": "19.2.17", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.17.tgz", "integrity": "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "csstype": "^3.2.2" @@ -1374,12 +1251,6 @@ "@types/node": "*" } }, - "node_modules/@types/use-sync-external-store": { - "version": "0.0.6", - "resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz", - "integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==", - "license": "MIT" - }, "node_modules/@vitejs/plugin-react": { "version": "6.0.4", "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.4.tgz", @@ -1419,15 +1290,6 @@ "node": ">= 0.6" } }, - "node_modules/agent-base": { - "version": "7.1.4", - "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", - "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", - "license": "MIT", - "engines": { - "node": ">= 14" - } - }, "node_modules/ansi-regex": { "version": "6.2.2", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", @@ -1452,35 +1314,6 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, - "node_modules/base64-js": { - "version": "1.5.1", - "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", - "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT" - }, - "node_modules/bignumber.js": { - "version": "9.3.1", - "resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.3.1.tgz", - "integrity": "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==", - "license": "MIT", - "engines": { - "node": "*" - } - }, "node_modules/body-parser": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz", @@ -1518,12 +1351,6 @@ "url": "https://opencollective.com/express" } }, - "node_modules/buffer-equal-constant-time": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", - "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", - "license": "BSD-3-Clause" - }, "node_modules/bytes": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", @@ -1588,15 +1415,6 @@ "node": ">=20" } }, - "node_modules/clsx": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", - "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", - "license": "MIT", - "engines": { - "node": ">=6" - } - }, "node_modules/concurrently": { "version": "10.0.4", "resolved": "https://registry.npmjs.org/concurrently/-/concurrently-10.0.4.tgz", @@ -1652,30 +1470,6 @@ "node": ">= 0.6" } }, - "node_modules/cookie-session": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/cookie-session/-/cookie-session-2.1.1.tgz", - "integrity": "sha512-ji3kym/XZaFVew1+tIZk5ZLp9Z/fLv9rK1aZmpug0FsgE7Cu3ZDrUdRo7FT9vFjMYfNimrrUHJzywDwT7XEFlg==", - "license": "MIT", - "dependencies": { - "cookies": "0.9.1", - "debug": "3.2.7", - "on-headers": "~1.1.0", - "safe-buffer": "5.2.1" - }, - "engines": { - "node": ">= 0.10" - } - }, - "node_modules/cookie-session/node_modules/debug": { - "version": "3.2.7", - "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", - "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", - "license": "MIT", - "dependencies": { - "ms": "^2.1.1" - } - }, "node_modules/cookie-signature": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", @@ -1685,156 +1479,13 @@ "node": ">=6.6.0" } }, - "node_modules/cookies": { - "version": "0.9.1", - "resolved": "https://registry.npmjs.org/cookies/-/cookies-0.9.1.tgz", - "integrity": "sha512-TG2hpqe4ELx54QER/S3HQ9SRVnQnGBtKUz5bLQWtYAQ+o6GpgMs6sYUvaiJjVxb+UXwhRhAEP3m7LbsIZ77Hmw==", - "license": "MIT", - "dependencies": { - "depd": "~2.0.0", - "keygrip": "~1.1.0" - }, - "engines": { - "node": ">= 0.8" - } - }, "node_modules/csstype": { "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", - "devOptional": true, + "dev": true, "license": "MIT" }, - "node_modules/d3-array": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz", - "integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==", - "license": "ISC", - "dependencies": { - "internmap": "1 - 2" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/d3-color": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", - "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", - "license": "ISC", - "engines": { - "node": ">=12" - } - }, - "node_modules/d3-ease": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", - "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==", - "license": "BSD-3-Clause", - "engines": { - "node": ">=12" - } - }, - "node_modules/d3-format": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz", - "integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==", - "license": "ISC", - "engines": { - "node": ">=12" - } - }, - "node_modules/d3-interpolate": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", - "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", - "license": "ISC", - "dependencies": { - "d3-color": "1 - 3" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/d3-path": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz", - "integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==", - "license": "ISC", - "engines": { - "node": ">=12" - } - }, - "node_modules/d3-scale": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz", - "integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==", - "license": "ISC", - "dependencies": { - "d3-array": "2.10.0 - 3", - "d3-format": "1 - 3", - "d3-interpolate": "1.2.0 - 3", - "d3-time": "2.1.1 - 3", - "d3-time-format": "2 - 4" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/d3-shape": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz", - "integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==", - "license": "ISC", - "dependencies": { - "d3-path": "^3.1.0" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/d3-time": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz", - "integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==", - "license": "ISC", - "dependencies": { - "d3-array": "2 - 3" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/d3-time-format": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz", - "integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==", - "license": "ISC", - "dependencies": { - "d3-time": "1 - 3" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/d3-timer": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", - "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==", - "license": "ISC", - "engines": { - "node": ">=12" - } - }, - "node_modules/data-uri-to-buffer": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz", - "integrity": "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==", - "license": "MIT", - "engines": { - "node": ">= 12" - } - }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -1852,12 +1503,6 @@ } } }, - "node_modules/decimal.js-light": { - "version": "2.5.1", - "resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz", - "integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==", - "license": "MIT" - }, "node_modules/depd": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", @@ -1903,15 +1548,6 @@ "node": ">= 0.4" } }, - "node_modules/ecdsa-sig-formatter": { - "version": "1.0.11", - "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", - "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", - "license": "Apache-2.0", - "dependencies": { - "safe-buffer": "^5.0.1" - } - }, "node_modules/ee-first": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", @@ -1963,17 +1599,6 @@ "node": ">= 0.4" } }, - "node_modules/es-toolkit": { - "version": "1.50.0", - "resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.50.0.tgz", - "integrity": "sha512-OyZKhUVvEep9ITEiwHn8GKnMRQIVqoSIX7WnRbkWgJkllCujilqP2rD0u979tkl8wqyc8ICwlc1UBVv/Sl1G6w==", - "license": "MIT", - "workspaces": [ - "docs", - "benchmarks", - "tests/types" - ] - }, "node_modules/esbuild": { "version": "0.28.1", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz", @@ -2040,12 +1665,6 @@ "node": ">= 0.6" } }, - "node_modules/eventemitter3": { - "version": "5.0.4", - "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz", - "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==", - "license": "MIT" - }, "node_modules/express": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", @@ -2089,12 +1708,6 @@ "url": "https://opencollective.com/express" } }, - "node_modules/extend": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", - "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", - "license": "MIT" - }, "node_modules/fdir": { "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", @@ -2113,29 +1726,6 @@ } } }, - "node_modules/fetch-blob": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/fetch-blob/-/fetch-blob-3.2.0.tgz", - "integrity": "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/jimmywarting" - }, - { - "type": "paypal", - "url": "https://paypal.me/jimmywarting" - } - ], - "license": "MIT", - "dependencies": { - "node-domexception": "^1.0.0", - "web-streams-polyfill": "^3.0.3" - }, - "engines": { - "node": "^12.20 || >= 14.13" - } - }, "node_modules/finalhandler": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", @@ -2157,18 +1747,6 @@ "url": "https://opencollective.com/express" } }, - "node_modules/formdata-polyfill": { - "version": "4.0.10", - "resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz", - "integrity": "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==", - "license": "MIT", - "dependencies": { - "fetch-blob": "^3.1.2" - }, - "engines": { - "node": ">=12.20.0" - } - }, "node_modules/forwarded": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", @@ -2211,34 +1789,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/gaxios": { - "version": "7.3.0", - "resolved": "https://registry.npmjs.org/gaxios/-/gaxios-7.3.0.tgz", - "integrity": "sha512-RB5vLV+vvQeoFPCX4QMK6/hjVkbIamPp1QSUD0CiZcnj12qbpiL+pLbYtgD+oZkWl0tl9z+o2Utp+MpM3QRhBA==", - "license": "Apache-2.0", - "dependencies": { - "extend": "^3.0.2", - "https-proxy-agent": "^7.0.1", - "node-fetch": "^3.3.2" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/gcp-metadata": { - "version": "8.1.2", - "resolved": "https://registry.npmjs.org/gcp-metadata/-/gcp-metadata-8.1.2.tgz", - "integrity": "sha512-zV/5HKTfCeKWnxG0Dmrw51hEWFGfcF2xiXqcA3+J90WDuP0SvoiSO5ORvcBsifmx/FoIjgQN3oNOGaQ5PhLFkg==", - "license": "Apache-2.0", - "dependencies": { - "gaxios": "^7.0.0", - "google-logging-utils": "^1.0.0", - "json-bigint": "^1.0.0" - }, - "engines": { - "node": ">=18" - } - }, "node_modules/get-caller-file": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", @@ -2297,32 +1847,6 @@ "node": ">= 0.4" } }, - "node_modules/google-auth-library": { - "version": "10.9.1", - "resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-10.9.1.tgz", - "integrity": "sha512-i1ydyHrqcIxXkWh/uBmVkzCvIuq5yiK2ATndIe5XxKholrG/MTYP9xGYka4sQhrbIAgGjL2B6NOE7rFaiF3fXw==", - "license": "Apache-2.0", - "dependencies": { - "base64-js": "^1.3.0", - "ecdsa-sig-formatter": "^1.0.11", - "gaxios": "^7.1.4", - "gcp-metadata": "8.1.2", - "google-logging-utils": "1.1.3", - "jws": "^4.0.0" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/google-logging-utils": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/google-logging-utils/-/google-logging-utils-1.1.3.tgz", - "integrity": "sha512-eAmLkjDjAFCVXg7A1unxHsLf961m6y17QFqXqAXGj/gVkKFrEICfStRfwUlGNfeCEjNRa32JEWOUTlYXPyyKvA==", - "license": "Apache-2.0", - "engines": { - "node": ">=14" - } - }, "node_modules/gopd": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", @@ -2379,19 +1903,6 @@ "url": "https://opencollective.com/express" } }, - "node_modules/https-proxy-agent": { - "version": "7.0.6", - "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", - "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", - "license": "MIT", - "dependencies": { - "agent-base": "^7.1.2", - "debug": "4" - }, - "engines": { - "node": ">= 14" - } - }, "node_modules/iconv-lite": { "version": "0.7.3", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz", @@ -2408,31 +1919,12 @@ "url": "https://opencollective.com/express" } }, - "node_modules/immer": { - "version": "11.1.15", - "resolved": "https://registry.npmjs.org/immer/-/immer-11.1.15.tgz", - "integrity": "sha512-VrNANlmnWQnh5COXIIOQXM9oOJw7naGKlBT74ZOOR6lpVXc3gFEu9FJLDFcpCJ2j+NWr8TIwtWD//T6ZX6TKiQ==", - "license": "MIT", - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/immer" - } - }, "node_modules/inherits": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", "license": "ISC" }, - "node_modules/internmap": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz", - "integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==", - "license": "ISC", - "engines": { - "node": ">=12" - } - }, "node_modules/ipaddr.js": { "version": "1.9.1", "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", @@ -2448,48 +1940,6 @@ "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", "license": "MIT" }, - "node_modules/json-bigint": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/json-bigint/-/json-bigint-1.0.0.tgz", - "integrity": "sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ==", - "license": "MIT", - "dependencies": { - "bignumber.js": "^9.0.0" - } - }, - "node_modules/jwa": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", - "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==", - "license": "MIT", - "dependencies": { - "buffer-equal-constant-time": "^1.0.1", - "ecdsa-sig-formatter": "1.0.11", - "safe-buffer": "^5.0.1" - } - }, - "node_modules/jws": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz", - "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==", - "license": "MIT", - "dependencies": { - "jwa": "^2.0.1", - "safe-buffer": "^5.0.1" - } - }, - "node_modules/keygrip": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/keygrip/-/keygrip-1.1.0.tgz", - "integrity": "sha512-iYSchDJ+liQ8iwbSI2QqsQOvqv58eJCEanyJPJi+Khyu8smkcKSFUCbPwzFcL7YVtZ6eONjqRX/38caJ7QjRAQ==", - "license": "MIT", - "dependencies": { - "tsscmp": "1.0.6" - }, - "engines": { - "node": ">= 0.6" - } - }, "node_modules/lightningcss": { "version": "1.33.0", "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", @@ -2844,44 +2294,6 @@ "node": ">= 0.6" } }, - "node_modules/node-domexception": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz", - "integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==", - "deprecated": "Use your platform's native DOMException instead", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/jimmywarting" - }, - { - "type": "github", - "url": "https://paypal.me/jimmywarting" - } - ], - "license": "MIT", - "engines": { - "node": ">=10.5.0" - } - }, - "node_modules/node-fetch": { - "version": "3.3.2", - "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.3.2.tgz", - "integrity": "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==", - "license": "MIT", - "dependencies": { - "data-uri-to-buffer": "^4.0.0", - "fetch-blob": "^3.1.4", - "formdata-polyfill": "^4.0.10" - }, - "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/node-fetch" - } - }, "node_modules/object-inspect": { "version": "1.13.4", "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", @@ -2906,15 +2318,6 @@ "node": ">= 0.8" } }, - "node_modules/on-headers": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/on-headers/-/on-headers-1.1.0.tgz", - "integrity": "sha512-737ZY3yNnXy37FHkQxPzt4UZ2UWPWiCZWLvFZ4fu5cueciegX0zGPnrlY6bwRg4FdQOe9YU8MkmJwGhoMybl8A==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, "node_modules/once": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", @@ -3119,87 +2522,6 @@ "react": "^19.2.8" } }, - "node_modules/react-is": { - "version": "19.2.8", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.8.tgz", - "integrity": "sha512-s5un28nYxKJw5gvUHyW5PCC28CvBqLu9r3cWgzHT4Vo/5fqqkFcdRYsGcKf50WMPpjjFZS5d76fn3YCo2njKwQ==", - "license": "MIT", - "peer": true - }, - "node_modules/react-redux": { - "version": "9.3.0", - "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.3.0.tgz", - "integrity": "sha512-KQopgqFo/p/fgmAs5qz6p5RWaNAzq40WAu7fJIXnQpYxFPbJYtsJPWvGeF2rOBaY/kEuV77AVsX8TsQzKm+A/g==", - "license": "MIT", - "dependencies": { - "@types/use-sync-external-store": "^0.0.6", - "use-sync-external-store": "^1.4.0" - }, - "peerDependencies": { - "@types/react": "^18.2.25 || ^19", - "react": "^18.0 || ^19", - "redux": "^5.0.0" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "redux": { - "optional": true - } - } - }, - "node_modules/recharts": { - "version": "3.10.1", - "resolved": "https://registry.npmjs.org/recharts/-/recharts-3.10.1.tgz", - "integrity": "sha512-QXFrvt6IVcw7eeZCoyXTwkIJAX3Dv1nyVhMicXJ47GsGDDpcN8z6o644DibE9XjpBTThtsomLKnTV6lc+cVFUA==", - "license": "MIT", - "workspaces": [ - "www" - ], - "dependencies": { - "@reduxjs/toolkit": "^1.9.0 || 2.x.x", - "clsx": "^2.1.1", - "decimal.js-light": "^2.5.1", - "es-toolkit": "^1.39.3", - "eventemitter3": "^5.0.1", - "immer": "^11.1.8", - "react-redux": "8.x.x || 9.x.x", - "reselect": "5.2.0", - "tiny-invariant": "^1.3.3", - "use-sync-external-store": "^1.2.2", - "victory-vendor": "^37.0.2" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", - "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", - "react-is": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" - } - }, - "node_modules/redux": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", - "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==", - "license": "MIT" - }, - "node_modules/redux-thunk": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-3.1.0.tgz", - "integrity": "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==", - "license": "MIT", - "peerDependencies": { - "redux": "^5.0.0" - } - }, - "node_modules/reselect": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/reselect/-/reselect-5.2.0.tgz", - "integrity": "sha512-AgZ3UOZm3YndfrJ4OYjgrT7bmCm/1iqkjvEfH/oYjzh6PD2qw4QuT3jjnXIrpdt4MTpMXclMT3lXbmRY+XRakw==", - "license": "MIT" - }, "node_modules/rolldown": { "version": "1.1.5", "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.5.tgz", @@ -3259,26 +2581,6 @@ "tslib": "^2.1.0" } }, - "node_modules/safe-buffer": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", - "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT" - }, "node_modules/safer-buffer": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", @@ -3489,12 +2791,6 @@ "url": "https://github.com/chalk/supports-color?sponsor=1" } }, - "node_modules/tiny-invariant": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz", - "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==", - "license": "MIT" - }, "node_modules/tinyglobby": { "version": "0.2.17", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", @@ -3536,15 +2832,6 @@ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", "license": "0BSD" }, - "node_modules/tsscmp": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/tsscmp/-/tsscmp-1.0.6.tgz", - "integrity": "sha512-LxhtAkPDTkVCMQjt2h6eBVY28KCjikZqZfMcC15YBeNjkgUpdCfBu5HoiOTDu86v6smE8yOjyEktJ8hlbANHQA==", - "license": "MIT", - "engines": { - "node": ">=0.6.x" - } - }, "node_modules/tsx": { "version": "4.23.1", "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.1.tgz", @@ -3625,15 +2912,6 @@ "node": ">= 0.8" } }, - "node_modules/use-sync-external-store": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", - "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", - "license": "MIT", - "peerDependencies": { - "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" - } - }, "node_modules/vary": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", @@ -3643,28 +2921,6 @@ "node": ">= 0.8" } }, - "node_modules/victory-vendor": { - "version": "37.3.6", - "resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-37.3.6.tgz", - "integrity": "sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==", - "license": "MIT AND ISC", - "dependencies": { - "@types/d3-array": "^3.0.3", - "@types/d3-ease": "^3.0.0", - "@types/d3-interpolate": "^3.0.1", - "@types/d3-scale": "^4.0.2", - "@types/d3-shape": "^3.1.0", - "@types/d3-time": "^3.0.0", - "@types/d3-timer": "^3.0.0", - "d3-array": "^3.1.6", - "d3-ease": "^3.0.1", - "d3-interpolate": "^3.0.1", - "d3-scale": "^4.0.2", - "d3-shape": "^3.1.0", - "d3-time": "^3.0.0", - "d3-timer": "^3.0.1" - } - }, "node_modules/vite": { "version": "8.1.5", "resolved": "https://registry.npmjs.org/vite/-/vite-8.1.5.tgz", @@ -3743,15 +2999,6 @@ } } }, - "node_modules/web-streams-polyfill": { - "version": "3.3.3", - "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz", - "integrity": "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==", - "license": "MIT", - "engines": { - "node": ">= 8" - } - }, "node_modules/wrap-ansi": { "version": "9.0.2", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz", diff --git a/package.json b/package.json index 8650941..09e9b72 100644 --- a/package.json +++ b/package.json @@ -30,30 +30,24 @@ "dev": "concurrently -k -n api,web -c gray.bold,white.bold \"npm:dev:api\" \"npm:dev:web\"", "dev:api": "tsx watch server/index.ts", "dev:web": "vite", - "dev:lan": "concurrently -k -n api,web -c gray.bold,white.bold \"HOST=0.0.0.0 npm run dev:api\" \"VITE_HOST=0.0.0.0 npm run dev:web\"", "build": "tsc -b && vite build", "start": "tsx server/index.ts", "serve": "npm run build && npm start", - "serve:lan": "npm run build && HOST=0.0.0.0 npm start", "preview": "vite preview", "lint": "oxlint", - "test": "node --test server/lib/range.test.ts server/lib/lan-intent.test.ts server/lib/auth-mode.test.ts server/lib/bearer-token.test.ts server/lib/pin-rate-limit.test.ts src/lib/types.test.ts", + "test": "node --test server/lib/range.test.ts server/lib/localhost-intent.test.ts server/lib/localhost.test.ts server/lib/cursor-stamp.test.ts src/lib/types.test.ts src/lib/api.test.ts", "setup": "npm run build && npm run launchagent:install", "launchagent:install": "bash scripts/install-launchagent.sh", "launchagent:uninstall": "bash scripts/uninstall-launchagent.sh" }, "dependencies": { "concurrently": "^10.0.4", - "cookie-session": "^2.1.1", "dotenv": "^17.4.2", "express": "^5.2.1", - "google-auth-library": "^10.9.1", "react": "^19.2.7", - "react-dom": "^19.2.7", - "recharts": "^3.10.1" + "react-dom": "^19.2.7" }, "devDependencies": { - "@types/cookie-session": "^2.0.49", "@types/express": "^5.0.6", "@types/node": "^24.13.3", "@types/react": "^19.2.17", diff --git a/public/runtime-config.js b/public/runtime-config.js deleted file mode 100644 index 204de5a..0000000 --- a/public/runtime-config.js +++ /dev/null @@ -1,3 +0,0 @@ -// Optional runtime override for static hosts (Vercel). Set before the app bundle loads. -// Example: window.__AGENT_DECK_API_BASE__ = 'https://your-mac-tunnel.example'; -window.__AGENT_DECK_API_BASE__ = window.__AGENT_DECK_API_BASE__ || ''; diff --git a/scripts/install-launchagent.sh b/scripts/install-launchagent.sh index 591e1e2..d1af18f 100755 --- a/scripts/install-launchagent.sh +++ b/scripts/install-launchagent.sh @@ -1,5 +1,5 @@ #!/usr/bin/env bash -# Install a LaunchAgent that starts Agent Deck at login and opens the UI. +# Install a LaunchAgent that starts Agent Deck at login and opens localhost. set -euo pipefail ROOT="$(cd "$(dirname "$0")/.." && pwd)" @@ -23,7 +23,6 @@ fi mkdir -p "$HOME/Library/LaunchAgents" "$LOG_DIR" -# Prefer a built UI+API serve; build once if dist is missing. if [[ ! -d "$ROOT/dist" ]]; then echo "Building UI (one-time)…" (cd "$ROOT" && "$NPM_BIN" run build) @@ -47,13 +46,9 @@ strip_env_quotes() { printf '%s' "$val" } -# Default: LAN-capable so phone can open the same always-on server. -HOST_VALUE="${HOST:-0.0.0.0}" +HOST_VALUE="127.0.0.1" PORT_VALUE="${PORT:-3847}" if [[ -f "$ENV_FILE" ]]; then - # shellcheck disable=SC1090 - set -a - # Export only simple KEY=VALUE lines for LaunchAgent env while IFS= read -r line || [[ -n "$line" ]]; do [[ "$line" =~ ^[[:space:]]*# ]] && continue [[ -z "${line// }" ]] && continue @@ -63,41 +58,18 @@ if [[ -f "$ENV_FILE" ]]; then export "${key}=${val}" fi done <"$ENV_FILE" - set +a - HOST_VALUE="${HOST:-$HOST_VALUE}" PORT_VALUE="${PORT:-$PORT_VALUE}" fi -if [[ "$HOST_VALUE" == "0.0.0.0" || "$HOST_VALUE" == "::" ]]; then - missing=() - [[ -z "${GOOGLE_CLIENT_ID:-}" ]] && missing+=("GOOGLE_CLIENT_ID") - [[ -z "${ALLOWED_EMAILS:-}" ]] && missing+=("ALLOWED_EMAILS") - [[ -z "${DASHBOARD_PIN:-}" ]] && missing+=("DASHBOARD_PIN") - if [[ "${#missing[@]}" -gt 0 ]]; then - echo "LAN bind (HOST=${HOST_VALUE}) requires ${missing[*]} in ${ENV_FILE}." >&2 - exit 1 - fi -fi - -# Build EnvironmentVariables dict entries from .env (safe subset) ENV_XML="" ENV_XML+=" PATH\n ${LAUNCH_PATH}\n" ENV_XML+=" HOST\n ${HOST_VALUE}\n" ENV_XML+=" PORT\n ${PORT_VALUE}\n" -if [[ -n "${GOOGLE_CLIENT_ID:-}" ]]; then - ENV_XML+=" GOOGLE_CLIENT_ID\n ${GOOGLE_CLIENT_ID}\n" -fi -if [[ -n "${ALLOWED_EMAILS:-}" ]]; then - ENV_XML+=" ALLOWED_EMAILS\n ${ALLOWED_EMAILS}\n" +if [[ -n "${CACHE_TTL_MS:-}" ]]; then + ENV_XML+=" CACHE_TTL_MS\n ${CACHE_TTL_MS}\n" fi -if [[ -n "${DASHBOARD_PIN:-}" ]]; then - ENV_XML+=" DASHBOARD_PIN\n ${DASHBOARD_PIN}\n" -fi -if [[ -n "${PUBLIC_ORIGIN:-}" ]]; then - ENV_XML+=" PUBLIC_ORIGIN\n ${PUBLIC_ORIGIN}\n" -fi -if [[ -n "${SESSION_SECRET:-}" ]]; then - ENV_XML+=" SESSION_SECRET\n ${SESSION_SECRET}\n" +if [[ -n "${USAGE_RESETS_TTL_MS:-}" ]]; then + ENV_XML+=" USAGE_RESETS_TTL_MS\n ${USAGE_RESETS_TTL_MS}\n" fi cat >"$PLIST" </dev/null || true launchctl bootstrap "gui/$(id -u)" "$PLIST" launchctl enable "gui/$(id -u)/${LABEL}" 2>/dev/null || true -# Give the server a moment, then open the UI. sleep 2 "$OPEN_BIN" "http://127.0.0.1:${PORT_VALUE}" >/dev/null 2>&1 || true echo "Installed ${PLIST}" echo "Agent Deck starts automatically at login." -echo "Open http://127.0.0.1:${PORT_VALUE} (sign in with Google)." -if [[ "$HOST_VALUE" == "0.0.0.0" || "$HOST_VALUE" == "::" ]]; then - echo "LAN/phone: same Wi-Fi devices use this Mac's IP on port ${PORT_VALUE} with PIN sign-in." -fi -if [[ -z "${GOOGLE_CLIENT_ID:-}" ]]; then - echo "Warning: GOOGLE_CLIENT_ID is not set. Add it to ${ENV_FILE} then re-run npm run setup." -fi +echo "Open http://127.0.0.1:${PORT_VALUE}" echo "Logs: ${LOG_DIR}" echo "Uninstall: npm run launchagent:uninstall" diff --git a/server/auth.ts b/server/auth.ts deleted file mode 100644 index 1527ab6..0000000 --- a/server/auth.ts +++ /dev/null @@ -1,388 +0,0 @@ -import { randomBytes, timingSafeEqual } from 'node:crypto' -import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs' -import { homedir } from 'node:os' -import { join } from 'node:path' -import cookieSession from 'cookie-session' -import type { Express, NextFunction, Request, Response } from 'express' -import { OAuth2Client } from 'google-auth-library' -import { - type AuthMode, - authModeForHostAndOrigin, - corsAllowedOrigins, - isLanBindHost, - publicOriginFromEnv, -} from './lib/auth-mode.js' -import { - bearerFromAuthorization, - issueBearerToken, - verifyBearerToken, -} from './lib/bearer-token.js' -import { - checkPinRateLimit, - clearPinFailures, - createPinRateLimitStore, - recordPinFailure, -} from './lib/pin-rate-limit.js' - -export type AuthUser = { - email: string - name: string | null - picture: string | null - method: 'google' | 'pin' -} - -declare module 'express-serve-static-core' { - interface Request { - user?: AuthUser - } -} - -type SessionData = { - user?: AuthUser -} - -const pinAttemptStore = createPinRateLimitStore() - -function configDir(): string { - const dir = join(homedir(), '.config', 'agent-deck') - if (!existsSync(dir)) mkdirSync(dir, { recursive: true, mode: 0o700 }) - return dir -} - -function sessionSecret(): string { - if (process.env.SESSION_SECRET?.trim()) return process.env.SESSION_SECRET.trim() - const file = join(configDir(), 'session-secret') - if (existsSync(file)) return readFileSync(file, 'utf8').trim() - const secret = randomBytes(32).toString('hex') - writeFileSync(file, `${secret}\n`, { encoding: 'utf8', mode: 0o600 }) - return secret -} - -export function googleClientId(): string | null { - const id = process.env.GOOGLE_CLIENT_ID?.trim() - return id || null -} - -export function dashboardPin(): string | null { - const pin = process.env.DASHBOARD_PIN?.trim() - return pin || null -} - -export function allowedEmails(): Set | null { - const raw = process.env.ALLOWED_EMAILS?.trim() - if (!raw) return null - return new Set( - raw - .split(',') - .map((s) => s.trim().toLowerCase()) - .filter(Boolean), - ) -} - -export function publicOrigin(): string { - return publicOriginFromEnv() -} - -export function authModeForRequest(req: Request): AuthMode { - return authModeForHostAndOrigin({ - hostHeader: req.get('x-forwarded-host') || req.get('host'), - origin: req.get('origin'), - publicOrigin: publicOrigin(), - }) -} - -export function googleAuthReady(): boolean { - return Boolean(googleClientId()) -} - -export function pinAuthReady(): boolean { - return Boolean(dashboardPin()) -} - -/** True when the server can authenticate at least one supported mode. */ -export function authConfigured(): boolean { - return googleAuthReady() || pinAuthReady() -} - -export function assertLanAuthRequirements(host: string): void { - if (!isLanBindHost(host)) return - const missing: string[] = [] - if (!allowedEmails()?.size) missing.push('ALLOWED_EMAILS') - if (!dashboardPin()) missing.push('DASHBOARD_PIN') - if (!googleClientId()) missing.push('GOOGLE_CLIENT_ID') - if (missing.length === 0) return - console.error( - `LAN bind (HOST=${host}) requires ${missing.join(', ')} in .env. Refusing to start.`, - ) - process.exit(1) -} - -function pinMatches(input: string): boolean { - const expected = dashboardPin() - if (!expected) return false - const a = Buffer.from(input) - const b = Buffer.from(expected) - if (a.length !== b.length) { - timingSafeEqual(b, b) - return false - } - return timingSafeEqual(a, b) -} - -function pinRateLimitKey(req: Request): string { - return req.socket.remoteAddress || 'unknown' -} - -export function attachCors(app: Express): void { - const allowed = corsAllowedOrigins() - app.use((req, res, next) => { - const origin = req.get('origin') - if (origin && allowed.has(origin.replace(/\/$/, ''))) { - res.setHeader('Access-Control-Allow-Origin', origin) - res.setHeader('Access-Control-Allow-Credentials', 'true') - res.setHeader( - 'Access-Control-Allow-Headers', - 'Content-Type, Authorization', - ) - res.setHeader( - 'Access-Control-Allow-Methods', - 'GET,POST,OPTIONS', - ) - res.setHeader('Vary', 'Origin') - } - if (req.method === 'OPTIONS') { - res.status(204).end() - return - } - next() - }) -} - -export function attachSession(app: Express): void { - app.set('trust proxy', 1) - const secret = sessionSecret() - app.use( - cookieSession({ - name: 'agent_deck_session', - keys: [secret], - maxAge: 30 * 24 * 60 * 60 * 1000, - httpOnly: true, - sameSite: 'lax', - secure: false, - }), - ) -} - -function readSession(req: Request): SessionData { - return (req.session ?? {}) as SessionData -} - -function modeConfigured(mode: AuthMode): boolean { - return mode === 'google' ? googleAuthReady() : pinAuthReady() -} - -function resolveUser(req: Request): AuthUser | null { - const bearer = bearerFromAuthorization(req.get('authorization')) - if (bearer) { - const user = verifyBearerToken(bearer, sessionSecret()) - if (user) return user - } - return readSession(req).user ?? null -} - -function issueAuthResponse(req: Request, res: Response, user: AuthUser): void { - const session = readSession(req) - session.user = user - req.session = session - const token = issueBearerToken(user, sessionSecret()) - res.json({ user, token }) -} - -export function requireAuth(req: Request, res: Response, next: NextFunction): void { - const mode = authModeForRequest(req) - if (!modeConfigured(mode)) { - res.status(503).json({ - error: 'auth_not_configured', - mode, - message: - mode === 'google' - ? 'Set GOOGLE_CLIENT_ID (and ALLOWED_EMAILS when LAN bind is enabled) in .env, then restart Agent Deck.' - : 'Set DASHBOARD_PIN in .env for LAN / non-Google hosts, then restart Agent Deck.', - }) - return - } - const user = resolveUser(req) - if (!user?.email) { - res.status(401).json({ error: 'unauthorized', mode }) - return - } - req.user = user - next() -} - -export function mountAuthRoutes(app: Express): void { - app.get('/api/auth/config', (req, res) => { - const mode = authModeForRequest(req) - const configured = modeConfigured(mode) - res.json({ - mode, - configured, - clientId: mode === 'google' ? googleClientId() : null, - allowedEmailsConfigured: Boolean(allowedEmails()?.size), - publicOrigin: publicOrigin(), - pinConfigured: pinAuthReady(), - }) - }) - - app.get('/api/auth/me', (req, res) => { - const mode = authModeForRequest(req) - if (!modeConfigured(mode)) { - res.status(503).json({ - error: 'auth_not_configured', - configured: false, - mode, - }) - return - } - const user = resolveUser(req) - if (!user?.email) { - res.status(401).json({ error: 'unauthorized', configured: true, mode }) - return - } - res.json({ user, configured: true, mode }) - }) - - app.post('/api/auth/google', async (req, res) => { - const mode = authModeForRequest(req) - if (mode !== 'google') { - res.status(400).json({ - error: 'wrong_auth_mode', - message: 'This host uses PIN sign-in, not Google.', - mode, - }) - return - } - const clientId = googleClientId() - if (!clientId) { - res.status(503).json({ error: 'auth_not_configured', mode }) - return - } - const credential = - typeof req.body?.credential === 'string' ? req.body.credential : null - if (!credential) { - res.status(400).json({ error: 'missing_credential' }) - return - } - - try { - const client = new OAuth2Client(clientId) - const ticket = await client.verifyIdToken({ - idToken: credential, - audience: clientId, - }) - const payload = ticket.getPayload() - if (!payload?.email) { - res.status(401).json({ error: 'invalid_token' }) - return - } - if (!payload.email_verified) { - res.status(403).json({ - error: 'email_not_verified', - message: 'Google account email must be verified.', - }) - return - } - - const email = payload.email.toLowerCase() - const allow = allowedEmails() - const lanBound = isLanBindHost(process.env.HOST ?? '127.0.0.1') - if (lanBound && !allow?.size) { - res.status(503).json({ - error: 'allowlist_required', - message: 'ALLOWED_EMAILS is required when LAN bind is enabled.', - }) - return - } - if (allow && !allow.has(email)) { - res.status(403).json({ - error: 'email_not_allowed', - message: 'This Google account is not allowed to open Agent Deck.', - }) - return - } - - const user: AuthUser = { - email, - name: payload.name ?? null, - picture: payload.picture ?? null, - method: 'google', - } - issueAuthResponse(req, res, user) - } catch (err) { - res.status(401).json({ - error: 'verify_failed', - message: err instanceof Error ? err.message : String(err), - }) - } - }) - - app.post('/api/auth/pin', (req, res) => { - const mode = authModeForRequest(req) - if (mode !== 'pin') { - res.status(400).json({ - error: 'wrong_auth_mode', - message: 'This host uses Google sign-in, not PIN.', - mode, - }) - return - } - if (!dashboardPin()) { - res.status(503).json({ error: 'auth_not_configured', mode }) - return - } - - const rateKey = pinRateLimitKey(req) - const limited = checkPinRateLimit(pinAttemptStore, rateKey) - if (!limited.ok) { - const retryAfterSec = Math.max(1, Math.ceil(limited.retryAfterMs / 1000)) - res.setHeader('Retry-After', String(retryAfterSec)) - res.status(429).json({ - error: 'pin_rate_limited', - message: `Too many incorrect PIN attempts. Try again in ${retryAfterSec}s.`, - retryAfterMs: limited.retryAfterMs, - }) - return - } - - const pin = typeof req.body?.pin === 'string' ? req.body.pin : '' - if (!pinMatches(pin)) { - const result = recordPinFailure(pinAttemptStore, rateKey) - if (result.retryAfterMs > 0) { - const retryAfterSec = Math.max(1, Math.ceil(result.retryAfterMs / 1000)) - res.setHeader('Retry-After', String(retryAfterSec)) - res.status(429).json({ - error: 'pin_rate_limited', - message: `Too many incorrect PIN attempts. Try again in ${retryAfterSec}s.`, - retryAfterMs: result.retryAfterMs, - }) - return - } - res.status(401).json({ error: 'invalid_pin', message: 'Incorrect PIN.' }) - return - } - - clearPinFailures(pinAttemptStore, rateKey) - const user: AuthUser = { - email: 'pin@local', - name: 'PIN access', - picture: null, - method: 'pin', - } - issueAuthResponse(req, res, user) - }) - - app.post('/api/auth/logout', (req, res) => { - req.session = null - res.json({ ok: true }) - }) -} diff --git a/server/collectors/cursor.ts b/server/collectors/cursor.ts index df095ff..995e6dd 100644 --- a/server/collectors/cursor.ts +++ b/server/collectors/cursor.ts @@ -1,135 +1,273 @@ -import { existsSync } from 'node:fs' +import { + createReadStream, + existsSync, + readdirSync, + statSync, +} from 'node:fs' import { homedir } from 'node:os' import { join } from 'node:path' +import { createInterface } from 'node:readline' import { DatabaseSync } from 'node:sqlite' import { buildStats } from '../lib/agents.js' +import { localDateFromCursorStamp } from '../lib/cursor-stamp.js' +import { formatLocalDate } from '../lib/range.js' import type { AgentUsage, DailyPoint } from '../types.js' const DB = join( homedir(), 'Library/Application Support/Cursor/User/globalStorage/state.vscdb', ) +const PROJECTS = join(homedir(), '.cursor', 'projects') +const ACP_SESSIONS = join(homedir(), '.cursor', 'acp-sessions') const INSTALL_HINT = - 'Install Cursor and use Agent / Tab / Chat so AI line stats land in the local SQLite DB.' + 'Install Cursor and use Agent / Tab / Chat so local activity is recorded.' const EMPTY_HINT = - 'Use Agent / Tab / Chat so AI line stats land in the local SQLite DB.' + 'Use Agent / Tab / Chat in Cursor so activity appears in local stats.' -export function collectCursor(): AgentUsage { - const dailyMap = new Map() +const TIMESTAMP_RE = /([^<]+)<\/timestamp>/i + +type DayBucket = { + accepted: number + suggested: number + agentMessages: number + agentSessions: number +} + +function emptyDay(): DayBucket { + return { accepted: 0, suggested: 0, agentMessages: 0, agentSessions: 0 } +} + +function walkAgentTranscripts(dir: string, out: string[] = []): string[] { + if (!existsSync(dir)) return out + for (const name of readdirSync(dir)) { + const p = join(dir, name) + let st + try { + st = statSync(p) + } catch { + continue + } + if (st.isDirectory()) walkAgentTranscripts(p, out) + else if (name.endsWith('.jsonl') && p.includes('agent-transcripts')) { + out.push(p) + } + } + return out +} + +function bump( + map: Map, + date: string, + patch: Partial, +): void { + const cur = map.get(date) ?? emptyDay() + cur.accepted += patch.accepted ?? 0 + cur.suggested += patch.suggested ?? 0 + cur.agentMessages += patch.agentMessages ?? 0 + cur.agentSessions += patch.agentSessions ?? 0 + map.set(date, cur) +} + +async function ingestAgentTranscripts(dailyMap: Map): Promise<{ + files: number + messages: number +}> { + const files = walkAgentTranscripts(PROJECTS) + let messages = 0 + for (const file of files) { + const rl = createInterface({ + input: createReadStream(file, { encoding: 'utf8' }), + crlfDelay: Infinity, + }) + for await (const line of rl) { + if (!line.trim()) continue + let o: { role?: string; message?: unknown } + try { + o = JSON.parse(line) as { role?: string; message?: unknown } + } catch { + continue + } + if (o.role !== 'user' && o.role !== 'assistant') continue + const stamp = TIMESTAMP_RE.exec(JSON.stringify(o.message ?? o)) + const date = stamp ? localDateFromCursorStamp(stamp[1]) : null + if (!date) continue + messages += 1 + bump(dailyMap, date, { agentMessages: 1 }) + } + } + return { files: files.length, messages } +} + +function ingestAcpSessions(dailyMap: Map): number { + if (!existsSync(ACP_SESSIONS)) return 0 + let sessions = 0 + for (const name of readdirSync(ACP_SESSIONS)) { + const p = join(ACP_SESSIONS, name) + let st + try { + st = statSync(p) + } catch { + continue + } + if (!st.isDirectory()) continue + sessions += 1 + bump(dailyMap, formatLocalDate(new Date(st.mtimeMs)), { agentSessions: 1 }) + } + return sessions +} + +export async function collectCursor(): Promise { + const dailyMap = new Map() let acceptedLines = 0 let suggestedLines = 0 let composers = 0 let bubbles = 0 let costCents = 0 + let agentTranscriptFiles = 0 + let agentMessages = 0 + let agentSessions = 0 let note: string | undefined const dbExists = existsSync(DB) - if (!dbExists) { - return { - id: 'cursor', - name: 'Cursor', - score: 0, - available: false, - metrics: {}, - daily: [], - stats: buildStats([], 30), - note: 'Cursor state database not found.', - hint: INSTALL_HINT, - } - } + if (dbExists) { + try { + const db = new DatabaseSync(DB, { readOnly: true }) + const rows = db + .prepare( + `SELECT key, value FROM ItemTable WHERE key LIKE 'aiCodeTracking.dailyStats%' ORDER BY key ASC`, + ) + .all() as Array<{ key: string; value: string }> - try { - const db = new DatabaseSync(DB, { readOnly: true }) - const rows = db - .prepare( - `SELECT key, value FROM ItemTable WHERE key LIKE 'aiCodeTracking.dailyStats%' ORDER BY key ASC`, - ) - .all() as Array<{ key: string; value: string }> - - for (const row of rows) { - try { - const d = JSON.parse(row.value) as { - date?: string - composerAcceptedLines?: number - composerSuggestedLines?: number - tabAcceptedLines?: number - tabSuggestedLines?: number + for (const row of rows) { + try { + const d = JSON.parse(row.value) as { + date?: string + composerAcceptedLines?: number + composerSuggestedLines?: number + tabAcceptedLines?: number + tabSuggestedLines?: number + } + const date = d.date ?? row.key.slice(-10) + const accepted = + (d.composerAcceptedLines ?? 0) + (d.tabAcceptedLines ?? 0) + const suggested = + (d.composerSuggestedLines ?? 0) + (d.tabSuggestedLines ?? 0) + acceptedLines += accepted + suggestedLines += suggested + bump(dailyMap, date, { accepted, suggested }) + } catch { + /* skip bad row */ } - const date = d.date ?? row.key.slice(-10) - const accepted = - (d.composerAcceptedLines ?? 0) + (d.tabAcceptedLines ?? 0) - const suggested = - (d.composerSuggestedLines ?? 0) + (d.tabSuggestedLines ?? 0) - acceptedLines += accepted - suggestedLines += suggested - const cur = dailyMap.get(date) ?? { accepted: 0, suggested: 0 } - cur.accepted += accepted - cur.suggested += suggested - dailyMap.set(date, cur) - } catch { - /* skip bad row */ } - } - composers = ( - db - .prepare( - `SELECT COUNT(*) AS c FROM cursorDiskKV WHERE key LIKE 'composerData:%'`, - ) - .get() as { c: number } - ).c - bubbles = ( - db + composers = ( + db + .prepare( + `SELECT COUNT(*) AS c FROM cursorDiskKV WHERE key LIKE 'composerData:%'`, + ) + .get() as { c: number } + ).c + bubbles = ( + db + .prepare( + `SELECT COUNT(*) AS c FROM cursorDiskKV WHERE key LIKE 'bubbleId:%'`, + ) + .get() as { c: number } + ).c + + const composersWithUsage = db .prepare( - `SELECT COUNT(*) AS c FROM cursorDiskKV WHERE key LIKE 'bubbleId:%'`, + `SELECT value FROM cursorDiskKV WHERE key LIKE 'composerData:%'`, ) - .get() as { c: number } - ).c - - const composersWithUsage = db - .prepare( - `SELECT value FROM cursorDiskKV WHERE key LIKE 'composerData:%'`, - ) - .all() as Array<{ value: string }> - for (const row of composersWithUsage) { - try { - const d = JSON.parse(row.value) as { - usageData?: Record + .all() as Array<{ value: string }> + for (const row of composersWithUsage) { + try { + const d = JSON.parse(row.value) as { + usageData?: Record + } + for (const u of Object.values(d.usageData ?? {})) { + costCents += u.costInCents ?? 0 + } + } catch { + /* skip */ } - for (const u of Object.values(d.usageData ?? {})) { - costCents += u.costInCents ?? 0 - } - } catch { - /* skip */ } + db.close() + } catch (err) { + note = err instanceof Error ? err.message : String(err) } - db.close() + } + + try { + const transcripts = await ingestAgentTranscripts(dailyMap) + agentTranscriptFiles = transcripts.files + agentMessages = transcripts.messages + agentSessions = ingestAcpSessions(dailyMap) } catch (err) { - note = err instanceof Error ? err.message : String(err) + note = + note ?? + (err instanceof Error ? err.message : String(err)) } const daily: DailyPoint[] = [...dailyMap.entries()] .sort(([a], [b]) => a.localeCompare(b)) .slice(-90) - .map(([date, v]) => ({ - date, - primary: v.accepted, - secondary: v.suggested, - primaryLabel: 'accepted', - secondaryLabel: 'suggested', - extras: { accepted: v.accepted, suggested: v.suggested }, - })) + .map(([date, v]) => { + const primary = + v.accepted > 0 ? v.accepted : v.agentMessages > 0 ? v.agentMessages : v.agentSessions + const secondary = + v.accepted > 0 + ? v.suggested + : v.agentMessages > 0 + ? v.agentSessions + : undefined + return { + date, + primary, + secondary, + primaryLabel: + v.accepted > 0 + ? 'accepted' + : v.agentMessages > 0 + ? 'agentMessages' + : 'agentSessions', + secondaryLabel: + v.accepted > 0 + ? 'suggested' + : v.agentMessages > 0 + ? 'agentSessions' + : undefined, + extras: { + accepted: v.accepted, + suggested: v.suggested, + agentMessages: v.agentMessages, + agentSessions: v.agentSessions, + }, + } + }) const stats = buildStats(daily, 30) - const score = acceptedLines + composers * 50 + bubbles * 0.05 - const available = !note && (daily.length > 0 || composers > 0 || bubbles > 0) + const score = + acceptedLines + + agentMessages * 50 + + agentSessions * 100 + + composers * 5 + + bubbles * 0.02 + const available = + !note && + (daily.length > 0 || + composers > 0 || + bubbles > 0 || + agentMessages > 0 || + agentSessions > 0 || + dbExists) return { id: 'cursor', name: 'Cursor', score, - available, + available: Boolean(available), metrics: { acceptedLines, suggestedLines, @@ -139,15 +277,25 @@ export function collectCursor(): AgentUsage { : 0, composers, messages: bubbles, + agentTranscriptFiles, + agentMessages, + agentSessions, costUsd: Math.round((costCents / 100) * 100) / 100, }, daily, stats, note: note ?? - (daily.length === 0 - ? 'Cursor is installed but AI daily stats are empty yet.' - : undefined), - hint: !available && !note ? EMPTY_HINT : undefined, + (!dbExists && agentMessages === 0 && agentSessions === 0 + ? 'Cursor state database not found.' + : daily.length === 0 + ? 'Cursor is installed but no local AI activity was found yet.' + : undefined), + hint: + !available && !note + ? !dbExists && agentMessages === 0 + ? INSTALL_HINT + : EMPTY_HINT + : undefined, } } diff --git a/server/collectors/usageResets.ts b/server/collectors/usageResets.ts index 2dac57e..d37e7de 100644 --- a/server/collectors/usageResets.ts +++ b/server/collectors/usageResets.ts @@ -90,29 +90,50 @@ export async function collectCursorUsageReset(): Promise { const data = res.json as { billingCycleStart?: string | number billingCycleEnd?: string | number - planUsage?: { totalPercentUsed?: number; includedSpend?: number; limit?: number } + planUsage?: { + totalPercentUsed?: number + autoPercentUsed?: number + apiPercentUsed?: number + totalSpend?: number + includedSpend?: number + bonusSpend?: number + limit?: number + } + displayMessage?: string } const at = msOrSecToIso(data.billingCycleEnd) const start = msOrSecToIso(data.billingCycleStart) - const used = data.planUsage?.totalPercentUsed + const plan = data.planUsage + const used = plan?.totalPercentUsed + const auto = + typeof plan?.autoPercentUsed === 'number' + ? Math.round(plan.autoPercentUsed * 10) / 10 + : null + const api = + typeof plan?.apiPercentUsed === 'number' + ? Math.round(plan.apiPercentUsed * 10) / 10 + : null const windows: UsageResetWindow[] = [ { label: 'Billing cycle', at, usedPercent: typeof used === 'number' ? Math.round(used * 10) / 10 : undefined, - note: start - ? `Cycle started ${new Date(start).toLocaleString(undefined, { - month: 'short', - day: 'numeric', - hour: '2-digit', - minute: '2-digit', - hour12: false, - })}` - : 'Monthly Cursor included usage', + unit: 'plan', + // Prefer Auto + API breakdown over Cursor's generic displayMessage, + // which can lag behind totalPercentUsed. + note: + auto != null || api != null + ? [ + auto != null ? `Auto ${auto}%` : null, + api != null ? `API ${api}%` : null, + ] + .filter(Boolean) + .join(' · ') + : undefined, }, ] - return { ok: Boolean(at), windows } + return { ok: Boolean(at || start), windows, cycleStart: start } } type CodexAuthFile = { @@ -213,6 +234,18 @@ function windowFromCodex(raw: unknown, fallbackLabel: string): UsageResetWindow } } +function codexCycleStartIso(window: UsageResetWindow, raw: unknown): string | null { + if (!window.at) return null + const w = raw && typeof raw === 'object' + ? (raw as { limit_window_seconds?: number }) + : null + const secs = w?.limit_window_seconds + if (typeof secs !== 'number' || secs <= 0) return null + const end = Date.parse(window.at) + if (!Number.isFinite(end)) return null + return new Date(end - secs * 1000).toISOString() +} + export async function collectCodexUsageReset(): Promise { let { access, refresh } = codexAccessToken() if (!access && refresh) { @@ -258,11 +291,10 @@ export async function collectCodexUsageReset(): Promise { } } const windows: UsageResetWindow[] = [] - const primary = windowFromCodex(data.rate_limit?.primary_window, 'Primary') - const secondary = windowFromCodex( - data.rate_limit?.secondary_window, - 'Secondary', - ) + const primaryRaw = data.rate_limit?.primary_window + const secondaryRaw = data.rate_limit?.secondary_window + const primary = windowFromCodex(primaryRaw, 'Primary') + const secondary = windowFromCodex(secondaryRaw, 'Secondary') if (primary) windows.push(primary) if (secondary) windows.push(secondary) if (windows.length === 0) { @@ -280,7 +312,12 @@ export async function collectCodexUsageReset(): Promise { .join(' · '), } } - return { ok: true, windows } + const monthly = + windows.find((w) => /month|weekly|primary/i.test(w.label)) ?? windows[0] + const cycleStart = + codexCycleStartIso(monthly, primaryRaw) ?? + (secondary ? codexCycleStartIso(secondary, secondaryRaw) : null) + return { ok: true, windows, cycleStart } } function claudeOAuthAccessToken(): string | null { @@ -354,11 +391,32 @@ export async function collectClaudeUsageReset(): Promise { }) } } - if (windows.length > 0) return { ok: true, windows } + if (windows.length > 0) { + const weekly = + windows.find((w) => /week/i.test(w.label)) ?? windows[windows.length - 1] + let cycleStart: string | null = null + if (weekly.at) { + const end = Date.parse(weekly.at) + if (Number.isFinite(end)) { + // Claude weekly caps are rolling ~7 days. + cycleStart = new Date(end - 7 * 24 * 60 * 60 * 1000).toISOString() + } + } + if (!cycleStart) { + const d = new Date() + d.setDate(d.getDate() - 6) + d.setHours(0, 0, 0, 0) + cycleStart = d.toISOString() + } + return { ok: true, windows, cycleStart } + } } } // Claude Code Pro/Max limits are rolling windows; exact times live in `/usage`. + const fallbackStart = new Date() + fallbackStart.setDate(fallbackStart.getDate() - 6) + fallbackStart.setHours(0, 0, 0, 0) return { ok: true, windows: [ @@ -373,6 +431,7 @@ export async function collectClaudeUsageReset(): Promise { note: 'Rolling 7-day cap · not calendar Monday', }, ], + cycleStart: fallbackStart.toISOString(), } } diff --git a/server/index.ts b/server/index.ts index ad35b9d..bbbccce 100644 --- a/server/index.ts +++ b/server/index.ts @@ -1,20 +1,8 @@ import 'dotenv/config' import express from 'express' import { existsSync } from 'node:fs' -import { networkInterfaces } from 'node:os' import { dirname, join } from 'node:path' import { fileURLToPath } from 'node:url' -import { - assertLanAuthRequirements, - attachCors, - attachSession, - authConfigured, - dashboardPin, - googleAuthReady, - mountAuthRoutes, - publicOrigin, - requireAuth, -} from './auth.js' import { collectClaude } from './collectors/claude.js' import { collectCodex } from './collectors/codex.js' import { collectCursor } from './collectors/cursor.js' @@ -22,18 +10,19 @@ import { collectGithub } from './collectors/github.js' import { collectSystem } from './collectors/system.js' import { collectUsageResets } from './collectors/usageResets.js' import { applyRange, withShares } from './lib/agents.js' -import { parseRange, rangeStartDate } from './lib/range.js' +import { assertLocalhostOnly } from './lib/localhost.js' +import { parseRange, rangeStartDate, daysInRange } from './lib/range.js' import type { DashboardPayload, RawCollectors, UsageReset } from './types.js' const PORT = Number(process.env.PORT ?? 3847) -/** Default loopback. Set HOST=0.0.0.0 to reach from phone on the same LAN. */ +/** Localhost only. Non-loopback HOST values are rejected at startup. */ const HOST = process.env.HOST ?? '127.0.0.1' const CACHE_TTL_MS = Number(process.env.CACHE_TTL_MS ?? 10_000) const USAGE_RESETS_TTL_MS = Number(process.env.USAGE_RESETS_TTL_MS ?? 180_000) const __dirname = dirname(fileURLToPath(import.meta.url)) const DIST = join(__dirname, '../dist') -assertLanAuthRequirements(HOST) +assertLocalhostOnly(HOST) type UsageResetsPayload = { cursor: UsageReset @@ -46,19 +35,6 @@ let inflight: Promise | null = null let usageResetsCache: { at: number; data: UsageResetsPayload } | null = null let usageResetsInflight: Promise | null = null -function lanUrls(port: number): string[] { - const out: string[] = [] - const nets = networkInterfaces() - for (const entries of Object.values(nets)) { - for (const net of entries ?? []) { - if (net.family === 'IPv4' && !net.internal) { - out.push(`http://${net.address}:${port}`) - } - } - } - return out -} - async function getUsageResets(force = false): Promise { if (force) { usageResetsCache = null @@ -90,7 +66,7 @@ async function collectRaw(force = false): Promise<{ data: RawCollectors; cached: const execute = async (): Promise => { const [cursor, claude, codex, system, github, resets] = await Promise.all([ - Promise.resolve().then(() => collectCursor()), + collectCursor(), collectClaude(), collectCodex(), Promise.resolve().then(() => collectSystem()), @@ -100,36 +76,37 @@ async function collectRaw(force = false): Promise<{ data: RawCollectors; cached: cursor.usageReset = resets.cursor claude.usageReset = resets.claude codex.usageReset = resets.codex - const data: RawCollectors = { cursor, claude, codex, system, github } - cache = { at: Date.now(), data } - return data - } - - if (force) { - const data = await execute() - return { data, cached: false } + return { cursor, claude, codex, system, github } } if (!inflight) { - inflight = execute().finally(() => { - inflight = null - }) + inflight = execute() + .then((data) => { + cache = { at: Date.now(), data } + return data + }) + .finally(() => { + inflight = null + }) } + const data = await inflight return { data, cached: false } } async function buildPayload( - rangeParam: unknown, + rangeRaw: unknown, force = false, ): Promise { - const range = parseRange(rangeParam) - const since = rangeStartDate(range) + const range = parseRange(typeof rangeRaw === 'string' ? rangeRaw : undefined) const { data, cached } = await collectRaw(force) const agents = withShares( - [data.cursor, data.claude, data.codex].map((a) => - applyRange(a, range, since), - ), + [data.cursor, data.claude, data.codex].map((a) => { + const cycleStart = a.usageReset?.cycleStart + const since = rangeStartDate(range, new Date(), cycleStart) + const days = daysInRange(range, new Date(), cycleStart) + return applyRange(a, range, since, days) + }), ) return { generatedAt: new Date().toISOString(), @@ -143,25 +120,18 @@ async function buildPayload( const app = express() app.use(express.json({ limit: '32kb' })) -attachCors(app) -attachSession(app) -mountAuthRoutes(app) app.get('/api/health', (_req, res) => { res.json({ ok: true, dist: existsSync(DIST), host: HOST, - authConfigured: authConfigured(), - googleAuthReady: googleAuthReady(), - pinAuthReady: Boolean(dashboardPin()), - publicOrigin: publicOrigin(), cacheTtlMs: CACHE_TTL_MS, usageResetsTtlMs: USAGE_RESETS_TTL_MS, }) }) -app.get('/api/dashboard', requireAuth, async (req, res) => { +app.get('/api/dashboard', async (req, res) => { try { const force = req.query.refresh === '1' || req.query.refresh === 'true' const payload = await buildPayload(req.query.range, force) @@ -173,7 +143,7 @@ app.get('/api/dashboard', requireAuth, async (req, res) => { } }) -app.get('/api/system', requireAuth, (_req, res) => { +app.get('/api/system', (_req, res) => { res.json(collectSystem()) }) @@ -188,38 +158,7 @@ if (existsSync(DIST)) { app.listen(PORT, HOST, () => { const servingUi = existsSync(DIST) const mode = servingUi ? 'API + UI' : 'API only' - console.log(`agent-dashboard ${mode} on http://${HOST === '0.0.0.0' ? '127.0.0.1' : HOST}:${PORT}`) - console.log(`Public UI origin (Google): ${publicOrigin()}`) - if (!googleAuthReady()) { - console.log( - 'Auth: GOOGLE_CLIENT_ID is not set. Copy .env.example to .env and add a Google OAuth Web client ID.', - ) - } else { - console.log('Auth: Google sign-in on localhost and PUBLIC_ORIGIN.') - } - if (dashboardPin()) { - console.log('Auth: PIN sign-in enabled for LAN / non-Google hosts.') - } else { - console.log('Auth: DASHBOARD_PIN is not set (required for phone/LAN IP access).') - } - if (HOST === '0.0.0.0' || HOST === '::') { - const urls = lanUrls(PORT) - if (urls.length > 0) { - if (servingUi) { - console.log('Phone / LAN (same Wi-Fi, PIN sign-in):') - for (const url of urls) console.log(` ${url}`) - } else { - console.log('API only (LAN, same Wi-Fi):') - for (const url of urls) console.log(` ${url}`) - console.log( - 'Phone UI: open the Vite network URL on port 5174 (from `npm run dev:lan`), not these API URLs.', - ) - } - } else { - console.log('LAN bind enabled, but no non-loopback IPv4 address was found.') - } - console.log('Only use on a trusted network. This exposes local agent + Mac metrics.') - } + console.log(`agent-dashboard ${mode} on http://127.0.0.1:${PORT}`) if (!servingUi) { console.log('Tip: run `npm run build` (or `npm run serve`) to serve the UI from this port.') } diff --git a/server/lib/agents.ts b/server/lib/agents.ts index 22e3659..5d6ee7b 100644 --- a/server/lib/agents.ts +++ b/server/lib/agents.ts @@ -40,12 +40,23 @@ function sumExtra(daily: DailyPoint[], key: string): number { /** * Period activity score for the filtered window. - * Cursor: accepted AI lines only. Claude/Codex: tokens, else message/event volume. + * Cursor: accepted AI lines, else agent transcript/session volume. + * Claude/Codex: tokens, else message/event volume. */ function computePeriodScore(agent: AgentUsage, daily: DailyPoint[]): number { if (agent.id === 'cursor') { + const accepted = sumExtra(daily, 'accepted') + if (accepted > 0) return accepted const sumPrimary = daily.reduce((s, d) => s + d.primary, 0) - return sumExtra(daily, 'accepted') || sumPrimary + if (sumPrimary > 0 && daily.some((d) => d.primaryLabel === 'accepted')) { + return sumPrimary + } + const agentMessages = sumExtra(daily, 'agentMessages') + const agentSessions = sumExtra(daily, 'agentSessions') + if (agentMessages > 0 || agentSessions > 0) { + return agentMessages * 50 + agentSessions * 100 + } + return sumPrimary } const totalTokens = sumExtra(daily, 'tokens') @@ -97,15 +108,18 @@ function periodMetrics(agent: AgentUsage, daily: DailyPoint[]): Record s + d.primary, 0) - const accepted = sumExtra(daily, 'accepted') || sumPrimary + const accepted = sumExtra(daily, 'accepted') const suggested = sumExtra(daily, 'suggested') + const agentMessages = sumExtra(daily, 'agentMessages') + const agentSessions = sumExtra(daily, 'agentSessions') return { ...base, acceptedLines: accepted, suggestedLines: suggested, acceptanceRate: suggested > 0 ? Math.round((accepted / suggested) * 1000) / 10 : 0, + agentMessages, + agentSessions, periodScore: score, } } @@ -142,7 +156,12 @@ function periodMetrics(agent: AgentUsage, daily: DailyPoint[]): Record d.date >= since) const daily = normalizePeriodDaily(agent, filtered) - const stats = buildStats(daily, daysInRange(range)) + const stats = buildStats( + daily, + rangeDays ?? daysInRange(range, new Date(), agent.usageReset?.cycleStart), + ) const metrics = periodMetrics(agent, daily) const note = agent.note ?? (!hasInRangeActivity(daily) ? `No activity in the selected ${ - range === '1d' ? 'day' : range === 'month' ? 'month' : 'period' + range === 'month' ? 'billing cycle' : 'period' }.` : undefined) diff --git a/server/lib/auth-mode.test.ts b/server/lib/auth-mode.test.ts deleted file mode 100644 index 98b679f..0000000 --- a/server/lib/auth-mode.test.ts +++ /dev/null @@ -1,69 +0,0 @@ -import assert from 'node:assert/strict' -import { describe, it } from 'node:test' -import { - DEFAULT_PUBLIC_ORIGIN, - authModeForHostAndOrigin, - corsAllowedOrigins, - isLanBindHost, - isLoopbackHostname, - publicOriginFromEnv, -} from './auth-mode.ts' - -describe('auth-mode', () => { - it('treats loopback as Google-compatible', () => { - assert.equal(isLoopbackHostname('127.0.0.1'), true) - assert.equal(isLoopbackHostname('localhost'), true) - assert.equal(isLoopbackHostname('::1'), true) - assert.equal(isLoopbackHostname('192.168.1.10'), false) - }) - - it('requires LAN extras when binding non-loopback', () => { - assert.equal(isLanBindHost('0.0.0.0'), true) - assert.equal(isLanBindHost('::'), true) - assert.equal(isLanBindHost('127.0.0.1'), false) - assert.equal(isLanBindHost('localhost'), false) - }) - - it('selects google for localhost Host and PUBLIC_ORIGIN Origin', () => { - assert.equal( - authModeForHostAndOrigin({ - hostHeader: '127.0.0.1:3847', - origin: undefined, - }), - 'google', - ) - assert.equal( - authModeForHostAndOrigin({ - hostHeader: '192.168.1.20:3847', - origin: DEFAULT_PUBLIC_ORIGIN, - }), - 'google', - ) - }) - - it('selects pin for raw LAN IP Host without Google-compatible Origin', () => { - assert.equal( - authModeForHostAndOrigin({ - hostHeader: '192.168.1.20:3847', - origin: undefined, - }), - 'pin', - ) - assert.equal( - authModeForHostAndOrigin({ - hostHeader: '192.168.1.20:3847', - origin: 'http://192.168.1.20:3847', - }), - 'pin', - ) - }) - - it('defaults PUBLIC_ORIGIN and CORS allowlist', () => { - assert.equal(publicOriginFromEnv({}), DEFAULT_PUBLIC_ORIGIN) - const origins = corsAllowedOrigins({ PORT: '3847' }) - assert.ok(origins.has(DEFAULT_PUBLIC_ORIGIN)) - assert.ok(origins.has('http://127.0.0.1:3847')) - assert.ok(origins.has('http://127.0.0.1:5174')) - assert.equal(origins.has('http://192.168.1.20:3847'), false) - }) -}) diff --git a/server/lib/auth-mode.ts b/server/lib/auth-mode.ts deleted file mode 100644 index d56fcd4..0000000 --- a/server/lib/auth-mode.ts +++ /dev/null @@ -1,77 +0,0 @@ -export const DEFAULT_PUBLIC_ORIGIN = 'https://agent-dashboard-ctt.vercel.app' - -export type AuthMode = 'google' | 'pin' - -export function publicOriginFromEnv( - env: NodeJS.ProcessEnv = process.env, -): string { - const raw = env.PUBLIC_ORIGIN?.trim() || DEFAULT_PUBLIC_ORIGIN - return raw.replace(/\/$/, '') -} - -export function isLoopbackHostname(hostname: string): boolean { - const h = hostname.replace(/^\[|\]$/g, '').toLowerCase() - return h === 'localhost' || h === '127.0.0.1' || h === '::1' -} - -export function isLanBindHost(host: string): boolean { - const h = host.trim().toLowerCase() - if (!h || h === '0.0.0.0' || h === '::' || h === '[::]') return true - if (isLoopbackHostname(h)) return false - return true -} - -function hostnameOf(hostOrUrl: string): string | null { - const raw = hostOrUrl.trim() - if (!raw) return null - try { - if (raw.includes('://')) return new URL(raw).hostname.toLowerCase() - return new URL(`http://${raw}`).hostname.toLowerCase() - } catch { - return null - } -} - -/** Google GIS works on loopback and configured PUBLIC_ORIGIN; everything else uses PIN. */ -export function authModeForHostAndOrigin(input: { - hostHeader: string | null | undefined - origin: string | null | undefined - publicOrigin?: string -}): AuthMode { - const pub = (input.publicOrigin ?? DEFAULT_PUBLIC_ORIGIN).replace(/\/$/, '') - const pubHost = hostnameOf(pub) - - const origin = input.origin?.trim() - if (origin) { - const normalized = origin.replace(/\/$/, '') - if (normalized === pub) return 'google' - const originHost = hostnameOf(origin) - if (originHost && isLoopbackHostname(originHost)) return 'google' - return 'pin' - } - - const host = hostnameOf(input.hostHeader ?? '') - if (!host) return 'pin' - if (isLoopbackHostname(host)) return 'google' - if (pubHost && host === pubHost) return 'google' - return 'pin' -} - -export function corsAllowedOrigins( - env: NodeJS.ProcessEnv = process.env, -): Set { - const pub = publicOriginFromEnv(env) - const origins = new Set([ - pub, - 'http://127.0.0.1:3847', - 'http://localhost:3847', - 'http://127.0.0.1:5174', - 'http://localhost:5174', - ]) - const port = env.PORT?.trim() - if (port && port !== '3847') { - origins.add(`http://127.0.0.1:${port}`) - origins.add(`http://localhost:${port}`) - } - return origins -} diff --git a/server/lib/bearer-token.test.ts b/server/lib/bearer-token.test.ts deleted file mode 100644 index 2dab1ae..0000000 --- a/server/lib/bearer-token.test.ts +++ /dev/null @@ -1,38 +0,0 @@ -import assert from 'node:assert/strict' -import { describe, it } from 'node:test' -import { - bearerFromAuthorization, - issueBearerToken, - verifyBearerToken, -} from './bearer-token.ts' - -describe('bearer-token', () => { - const secret = 'test-secret-key' - const user = { - email: 'you@example.com', - name: 'You', - picture: null, - method: 'google' as const, - } - - it('issues a verifiable token', () => { - const token = issueBearerToken(user, secret, 60_000, 1_000) - const parsed = verifyBearerToken(token, secret, 1_500) - assert.deepEqual(parsed, user) - }) - - it('rejects expired, tampered, or wrong-secret tokens', () => { - const token = issueBearerToken(user, secret, 100, 1_000) - assert.equal(verifyBearerToken(token, secret, 1_200), null) - assert.equal(verifyBearerToken(token, 'other', 1_050), null) - assert.equal(verifyBearerToken(`${token}x`, secret, 1_050), null) - assert.equal(verifyBearerToken('not-a-token', secret), null) - }) - - it('parses Authorization Bearer headers', () => { - assert.equal(bearerFromAuthorization('Bearer abc.def'), 'abc.def') - assert.equal(bearerFromAuthorization('bearer abc.def'), 'abc.def') - assert.equal(bearerFromAuthorization('Basic abc'), null) - assert.equal(bearerFromAuthorization(undefined), null) - }) -}) diff --git a/server/lib/bearer-token.ts b/server/lib/bearer-token.ts deleted file mode 100644 index 093e9d7..0000000 --- a/server/lib/bearer-token.ts +++ /dev/null @@ -1,69 +0,0 @@ -import { createHmac, timingSafeEqual } from 'node:crypto' - -export type BearerUser = { - email: string - name: string | null - picture: string | null - method: 'google' | 'pin' -} - -type TokenPayload = { - u: BearerUser - exp: number -} - -const DEFAULT_TTL_MS = 30 * 24 * 60 * 60 * 1000 - -function sign(payload: string, secret: string): string { - return createHmac('sha256', secret).update(payload).digest('base64url') -} - -export function issueBearerToken( - user: BearerUser, - secret: string, - ttlMs = DEFAULT_TTL_MS, - now = Date.now(), -): string { - const body: TokenPayload = { u: user, exp: now + ttlMs } - const payload = Buffer.from(JSON.stringify(body), 'utf8').toString('base64url') - return `${payload}.${sign(payload, secret)}` -} - -export function verifyBearerToken( - token: string, - secret: string, - now = Date.now(), -): BearerUser | null { - const dot = token.indexOf('.') - if (dot <= 0 || dot === token.length - 1) return null - const payload = token.slice(0, dot) - const sig = token.slice(dot + 1) - const expected = sign(payload, secret) - const a = Buffer.from(sig) - const b = Buffer.from(expected) - if (a.length !== b.length || !timingSafeEqual(a, b)) return null - try { - const raw = JSON.parse( - Buffer.from(payload, 'base64url').toString('utf8'), - ) as TokenPayload - if (!raw?.u?.email || typeof raw.exp !== 'number') return null - if (raw.exp < now) return null - if (raw.u.method !== 'google' && raw.u.method !== 'pin') return null - return { - email: raw.u.email, - name: raw.u.name ?? null, - picture: raw.u.picture ?? null, - method: raw.u.method, - } - } catch { - return null - } -} - -export function bearerFromAuthorization( - header: string | null | undefined, -): string | null { - if (!header) return null - const match = /^Bearer\s+(\S+)$/i.exec(header.trim()) - return match?.[1] ?? null -} diff --git a/server/lib/cursor-stamp.test.ts b/server/lib/cursor-stamp.test.ts new file mode 100644 index 0000000..8ee2374 --- /dev/null +++ b/server/lib/cursor-stamp.test.ts @@ -0,0 +1,17 @@ +import assert from 'node:assert/strict' +import { describe, it } from 'node:test' +import { localDateFromCursorStamp } from './cursor-stamp.ts' + +describe('cursor transcript stamps', () => { + it('parses Cursor agent transcript timestamps to local YYYY-MM-DD', () => { + assert.equal( + localDateFromCursorStamp('Monday, Jul 27, 2026, 9:18 AM (UTC+8)'), + '2026-07-27', + ) + assert.equal( + localDateFromCursorStamp('Tuesday, Jul 28, 2026, 1:35 PM (UTC+8)'), + '2026-07-28', + ) + assert.equal(localDateFromCursorStamp('not a stamp'), null) + }) +}) diff --git a/server/lib/cursor-stamp.ts b/server/lib/cursor-stamp.ts new file mode 100644 index 0000000..f0b01b1 --- /dev/null +++ b/server/lib/cursor-stamp.ts @@ -0,0 +1,33 @@ +const MONTHS: Record = { + Jan: 0, + Feb: 1, + Mar: 2, + Apr: 3, + May: 4, + Jun: 5, + Jul: 6, + Aug: 7, + Sep: 8, + Oct: 9, + Nov: 10, + Dec: 11, +} + +function formatLocalDate(d: Date): string { + const y = d.getFullYear() + const m = String(d.getMonth() + 1).padStart(2, '0') + const day = String(d.getDate()).padStart(2, '0') + return `${y}-${m}-${day}` +} + +/** Parse Cursor transcript stamps like "Monday, Jul 27, 2026, 9:18 AM (UTC+8)". */ +export function localDateFromCursorStamp(raw: string): string | null { + const m = raw.match(/([A-Za-z]{3})[a-z]*\s+(\d{1,2}),\s+(\d{4})/i) + if (!m) return null + const mon = MONTHS[m[1].slice(0, 3)] + if (mon == null) return null + const day = Number(m[2]) + const year = Number(m[3]) + if (!year || !day) return null + return formatLocalDate(new Date(year, mon, day)) +} diff --git a/server/lib/lan-intent.test.ts b/server/lib/lan-intent.test.ts deleted file mode 100644 index 8843682..0000000 --- a/server/lib/lan-intent.test.ts +++ /dev/null @@ -1,59 +0,0 @@ -import assert from 'node:assert/strict' -import { readFileSync } from 'node:fs' -import { dirname, join } from 'node:path' -import { describe, it } from 'node:test' -import { fileURLToPath } from 'node:url' - -const root = join(dirname(fileURLToPath(import.meta.url)), '../..') - -describe('LAN phone webapp intent', () => { - it('exposes serve:lan and dev:lan with HOST=0.0.0.0', () => { - const pkg = JSON.parse(readFileSync(join(root, 'package.json'), 'utf8')) as { - scripts: Record - } - assert.match(pkg.scripts['serve:lan'], /HOST=0\.0\.0\.0/) - assert.match(pkg.scripts['dev:lan'], /HOST=0\.0\.0\.0/) - assert.match(pkg.scripts['dev:lan'], /VITE_HOST=0\.0\.0\.0/) - }) - - it('ships a standalone web manifest for Add to Home Screen', () => { - const manifest = JSON.parse( - readFileSync(join(root, 'public/manifest.webmanifest'), 'utf8'), - ) as { - name: string - short_name: string - display: string - start_url: string - } - assert.equal(manifest.name, 'Agent Deck') - assert.equal(manifest.short_name, 'Agent Deck') - assert.equal(manifest.display, 'standalone') - assert.equal(manifest.start_url, '/') - }) - - it('wires mobile PWA meta + manifest into index.html', () => { - const html = readFileSync(join(root, 'index.html'), 'utf8') - assert.match(html, /rel="manifest" href="\/manifest\.webmanifest"/) - assert.match(html, /name="apple-mobile-web-app-capable" content="yes"/) - assert.match(html, /viewport-fit=cover/) - }) - - it('documents Mac API + optional Vercel static UI and LAN PIN access', () => { - const readme = readFileSync(join(root, 'README.md'), 'utf8') - assert.match(readme, /Why the Mac still runs the API/) - assert.match(readme, /do not deploy the collectors to a public cloud/i) - assert.match(readme, /agent-dashboard-ctt\.vercel\.app/) - assert.match(readme, /DASHBOARD_PIN/) - assert.match(readme, /npm run serve:lan/) - assert.match(readme, /npm run dev:lan/) - assert.match(readme, /Add to Home Screen/) - assert.match(readme, /Google GIS does \*\*not\*\* accept raw LAN IPs/) - assert.match(readme, /127\.0\.0\.1:5174/) - assert.match(readme, /localhost:5174/) - assert.match(readme, /Bearer token/) - assert.doesNotMatch( - readme, - /Authorized JavaScript origins[\s\S]{0,400}192\.168/, - ) - }) -}) diff --git a/server/lib/localhost-intent.test.ts b/server/lib/localhost-intent.test.ts new file mode 100644 index 0000000..bd12579 --- /dev/null +++ b/server/lib/localhost-intent.test.ts @@ -0,0 +1,43 @@ +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import { dirname, join } from 'node:path' +import { describe, it } from 'node:test' +import { fileURLToPath } from 'node:url' + +const root = join(dirname(fileURLToPath(import.meta.url)), '../..') + +describe('localhost-only intent', () => { + it('does not ship auth, LAN, tunnel, or Vercel scripts', () => { + const pkg = JSON.parse(readFileSync(join(root, 'package.json'), 'utf8')) as { + scripts: Record + dependencies: Record + } + assert.equal(pkg.scripts['serve:lan'], undefined) + assert.equal(pkg.scripts['tunnel:install'], undefined) + assert.match(pkg.scripts.setup, /launchagent:install/) + assert.equal(pkg.dependencies['google-auth-library'], undefined) + assert.equal(pkg.dependencies['cookie-session'], undefined) + }) + + it('documents open-local dashboard without Google sign-in', () => { + const readme = readFileSync(join(root, 'README.md'), 'utf8') + assert.match(readme, /localhost-only/i) + assert.match(readme, /127\.0\.0\.1:3847/) + assert.match(readme, /No cloud host and no sign-in/) + assert.doesNotMatch(readme, /GOOGLE_CLIENT_ID/) + assert.doesNotMatch(readme, /ALLOWED_EMAILS/) + assert.doesNotMatch(readme, /vercel\.app/i) + assert.doesNotMatch(readme, /cloudflare/i) + assert.doesNotMatch(readme, /DASHBOARD_PIN/) + }) + + it('defaults LaunchAgent to 127.0.0.1 without Google env', () => { + const script = readFileSync( + join(root, 'scripts/install-launchagent.sh'), + 'utf8', + ) + assert.match(script, /HOST_VALUE="127\.0\.0\.1"/) + assert.match(script, /http:\/\/127\.0\.0\.1:\$\{PORT_VALUE\}/) + assert.doesNotMatch(script, /GOOGLE_CLIENT_ID|ALLOWED_EMAILS|SESSION_SECRET/) + }) +}) diff --git a/server/lib/localhost.test.ts b/server/lib/localhost.test.ts new file mode 100644 index 0000000..2f702ea --- /dev/null +++ b/server/lib/localhost.test.ts @@ -0,0 +1,23 @@ +import assert from 'node:assert/strict' +import { describe, it } from 'node:test' +import { + isLoopbackHostname, + isNonLoopbackBindHost, +} from './localhost.ts' + +describe('localhost bind helpers', () => { + it('treats loopback hostnames as local', () => { + assert.equal(isLoopbackHostname('localhost'), true) + assert.equal(isLoopbackHostname('127.0.0.1'), true) + assert.equal(isLoopbackHostname('::1'), true) + assert.equal(isLoopbackHostname('192.168.1.10'), false) + }) + + it('rejects LAN / all-interfaces binds', () => { + assert.equal(isNonLoopbackBindHost('0.0.0.0'), true) + assert.equal(isNonLoopbackBindHost('::'), true) + assert.equal(isNonLoopbackBindHost('192.168.1.10'), true) + assert.equal(isNonLoopbackBindHost('127.0.0.1'), false) + assert.equal(isNonLoopbackBindHost('localhost'), false) + }) +}) diff --git a/server/lib/localhost.ts b/server/lib/localhost.ts new file mode 100644 index 0000000..d2a0db5 --- /dev/null +++ b/server/lib/localhost.ts @@ -0,0 +1,21 @@ +/** Localhost bind helpers. Agent Deck refuses non-loopback HOST values. */ + +export function isLoopbackHostname(hostname: string): boolean { + const h = hostname.replace(/^\[|\]$/g, '').toLowerCase() + return h === 'localhost' || h === '127.0.0.1' || h === '::1' +} + +export function isNonLoopbackBindHost(host: string): boolean { + const h = host.trim().toLowerCase() + if (!h) return false + if (h === '0.0.0.0' || h === '::' || h === '[::]') return true + return !isLoopbackHostname(h) +} + +export function assertLocalhostOnly(host: string): void { + if (!isNonLoopbackBindHost(host)) return + console.error( + `HOST=${host} is not allowed. Agent Deck is localhost-only (use 127.0.0.1).`, + ) + process.exit(1) +} diff --git a/server/lib/pin-rate-limit.test.ts b/server/lib/pin-rate-limit.test.ts deleted file mode 100644 index 78c35b5..0000000 --- a/server/lib/pin-rate-limit.test.ts +++ /dev/null @@ -1,43 +0,0 @@ -import assert from 'node:assert/strict' -import { describe, it } from 'node:test' -import { - checkPinRateLimit, - clearPinFailures, - createPinRateLimitStore, - pinLockMsForFails, - recordPinFailure, -} from './pin-rate-limit.ts' - -describe('pin-rate-limit', () => { - it('locks after five failures with progressive backoff', () => { - assert.equal(pinLockMsForFails(4), 0) - assert.equal(pinLockMsForFails(5), 60_000) - assert.equal(pinLockMsForFails(6), 120_000) - assert.equal(pinLockMsForFails(10), 15 * 60_000) - }) - - it('rate-limits an IP after repeated failures and clears on success path', () => { - const store = createPinRateLimitStore() - const now = 1_000_000 - - for (let i = 0; i < 4; i++) { - const result = recordPinFailure(store, '10.0.0.1', now + i) - assert.equal(result.retryAfterMs, 0) - assert.equal(checkPinRateLimit(store, '10.0.0.1', now + i).ok, true) - } - - const locked = recordPinFailure(store, '10.0.0.1', now + 10) - assert.equal(locked.retryAfterMs, 60_000) - const blocked = checkPinRateLimit(store, '10.0.0.1', now + 11) - assert.equal(blocked.ok, false) - if (!blocked.ok) assert.ok(blocked.retryAfterMs > 0) - - assert.equal( - checkPinRateLimit(store, '10.0.0.1', now + 10 + 60_000).ok, - true, - ) - - clearPinFailures(store, '10.0.0.1') - assert.equal(checkPinRateLimit(store, '10.0.0.1', now + 20).ok, true) - }) -}) diff --git a/server/lib/pin-rate-limit.ts b/server/lib/pin-rate-limit.ts deleted file mode 100644 index 5fff154..0000000 --- a/server/lib/pin-rate-limit.ts +++ /dev/null @@ -1,50 +0,0 @@ -export type PinRateLimitState = { - fails: number - lockedUntil: number -} - -export type PinRateLimitStore = Map - -const MAX_ATTEMPTS_BEFORE_LOCK = 5 -const BASE_LOCK_MS = 60_000 -const MAX_LOCK_MS = 15 * 60_000 - -export function createPinRateLimitStore(): PinRateLimitStore { - return new Map() -} - -export function pinLockMsForFails(fails: number): number { - if (fails < MAX_ATTEMPTS_BEFORE_LOCK) return 0 - const extra = fails - MAX_ATTEMPTS_BEFORE_LOCK - return Math.min(MAX_LOCK_MS, BASE_LOCK_MS * 2 ** extra) -} - -export function checkPinRateLimit( - store: PinRateLimitStore, - ip: string, - now = Date.now(), -): { ok: true } | { ok: false; retryAfterMs: number } { - const entry = store.get(ip) - if (!entry || entry.lockedUntil <= now) return { ok: true } - return { ok: false, retryAfterMs: entry.lockedUntil - now } -} - -export function recordPinFailure( - store: PinRateLimitStore, - ip: string, - now = Date.now(), -): { lockedUntil: number; retryAfterMs: number } { - const prev = store.get(ip) - const fails = (prev?.fails ?? 0) + 1 - const lockMs = pinLockMsForFails(fails) - const lockedUntil = lockMs > 0 ? now + lockMs : 0 - store.set(ip, { fails, lockedUntil }) - return { - lockedUntil, - retryAfterMs: lockMs > 0 ? lockMs : 0, - } -} - -export function clearPinFailures(store: PinRateLimitStore, ip: string): void { - store.delete(ip) -} diff --git a/server/lib/range.test.ts b/server/lib/range.test.ts index 70e96fd..e567234 100644 --- a/server/lib/range.test.ts +++ b/server/lib/range.test.ts @@ -2,34 +2,31 @@ import assert from 'node:assert/strict' import { describe, it } from 'node:test' import { DATE_RANGES, + billingCycleStartDate, daysInRange, + inclusiveDayCount, parseRange, rangeLabel, rangeStartDate, } from './range.ts' -describe('date range helpers', () => { - it('includes This month alongside Today/7d/30d', () => { - assert.deepEqual(DATE_RANGES, ['1d', '7d', '30d', 'month']) - assert.equal(rangeLabel('1d'), 'Today') - assert.equal(rangeLabel('7d'), '7 days') - assert.equal(rangeLabel('30d'), '30 days') - assert.equal(rangeLabel('month'), 'This month') +describe('billing cycle range', () => { + it('only supports this billing cycle', () => { + assert.deepEqual(DATE_RANGES, ['month']) + assert.equal(parseRange(undefined), 'month') + assert.equal(parseRange('7d'), 'month') + assert.equal(rangeLabel(), 'This billing cycle') }) - it('parses month and rejects unknown values', () => { - assert.equal(parseRange('month'), 'month') - assert.equal(parseRange('7d'), '7d') - assert.equal(parseRange('nope'), '7d') - assert.equal(parseRange(undefined), '7d') - }) - - it('starts This month on the 1st of the local calendar month', () => { + it('uses provider cycleStart for the billing-cycle window', () => { const now = new Date(2026, 6, 28, 15, 30, 0) // Jul 28, 2026 local - assert.equal(rangeStartDate('month', now), '2026-07-01') - assert.equal(daysInRange('month', now), 28) - assert.equal(rangeStartDate('1d', now), '2026-07-28') - assert.equal(rangeStartDate('7d', now), '2026-07-22') - assert.equal(rangeStartDate('30d', now), '2026-06-29') + assert.equal( + billingCycleStartDate('2026-07-15T02:05:00.000Z', now), + '2026-07-15', + ) + assert.equal(rangeStartDate('month', now, '2026-07-15T02:05:00.000Z'), '2026-07-15') + assert.equal(daysInRange('month', now, '2026-07-15T02:05:00.000Z'), 14) + assert.equal(inclusiveDayCount('2026-07-28', '2026-07-28'), 1) + assert.equal(rangeStartDate('month', now, null), '2026-07-01') }) }) diff --git a/server/lib/range.ts b/server/lib/range.ts index 506cab0..e114d87 100644 --- a/server/lib/range.ts +++ b/server/lib/range.ts @@ -1,20 +1,8 @@ -export const DATE_RANGES = ['1d', '7d', '30d', 'month'] as const +export const DATE_RANGES = ['month'] as const export type DateRange = (typeof DATE_RANGES)[number] -export function parseRange(value: unknown): DateRange { - if (typeof value === 'string' && (DATE_RANGES as readonly string[]).includes(value)) { - return value as DateRange - } - return '7d' -} - -/** Inclusive day count for avg/day math. */ -export function daysInRange(range: DateRange, now = new Date()): number { - if (range === '1d') return 1 - if (range === '7d') return 7 - if (range === '30d') return 30 - // This month: from the 1st through today (inclusive) - return Math.max(1, now.getDate()) +export function parseRange(_value?: unknown): DateRange { + return 'month' } export function formatLocalDate(d: Date): string { @@ -31,22 +19,53 @@ export function localDateFromTimestamp(value: string): string | null { return formatLocalDate(d) } -/** Inclusive start date (YYYY-MM-DD) for the selected range ending today. */ -export function rangeStartDate(range: DateRange, now = new Date()): string { +/** Inclusive day count between YYYY-MM-DD start and end (local calendar). */ +export function inclusiveDayCount(startYmd: string, endYmd: string): number { + const [sy, sm, sd] = startYmd.split('-').map(Number) + const [ey, em, ed] = endYmd.split('-').map(Number) + const start = new Date(sy, sm - 1, sd) + const end = new Date(ey, em - 1, ed) + const diff = Math.round((end.getTime() - start.getTime()) / 86_400_000) + return Math.max(1, diff + 1) +} + +/** + * Start date for a billing-cycle window. + * Prefers provider cycleStart; falls back to the 1st of the local month. + */ +export function billingCycleStartDate( + cycleStartIso: string | null | undefined, + now = new Date(), +): string { + if (cycleStartIso) { + const ymd = localDateFromTimestamp(cycleStartIso) + if (ymd) return ymd + } const d = new Date(now) d.setHours(0, 0, 0, 0) - if (range === 'month') { - d.setDate(1) - return formatLocalDate(d) - } - const days = daysInRange(range, now) - d.setDate(d.getDate() - (days - 1)) + d.setDate(1) return formatLocalDate(d) } -export function rangeLabel(range: DateRange): string { - if (range === '1d') return 'Today' - if (range === '7d') return '7 days' - if (range === '30d') return '30 days' - return 'This month' +/** Inclusive day count for avg/day math in the billing cycle. */ +export function daysInRange( + _range: DateRange = 'month', + now = new Date(), + cycleStartIso?: string | null, +): number { + const start = billingCycleStartDate(cycleStartIso, now) + return inclusiveDayCount(start, formatLocalDate(now)) +} + +/** Inclusive start date (YYYY-MM-DD) for this billing cycle. */ +export function rangeStartDate( + _range: DateRange = 'month', + now = new Date(), + cycleStartIso?: string | null, +): string { + return billingCycleStartDate(cycleStartIso, now) +} + +export function rangeLabel(_range: DateRange = 'month'): string { + return 'This billing cycle' } diff --git a/server/types.ts b/server/types.ts index feefca4..0ddf251 100644 --- a/server/types.ts +++ b/server/types.ts @@ -24,12 +24,18 @@ export type UsageResetWindow = { /** ISO timestamp when this window resets; null if unknown / N/A */ at: string | null usedPercent?: number + /** Absolute usage in provider units when known (plan credits, etc.) */ + used?: number + limit?: number + unit?: string note?: string } export type UsageReset = { ok: boolean windows: UsageResetWindow[] + /** ISO start of the primary billing / plan cycle when known */ + cycleStart?: string | null error?: string } diff --git a/src/App.tsx b/src/App.tsx index 5079387..4b7b9c0 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,95 +1,55 @@ import { useCallback, useEffect, useRef, useState } from 'react' import { AgentPanel } from './components/AgentPanel' import { GithubPanel } from './components/GithubPanel' -import { Login } from './components/Login' import { SystemPanel } from './components/SystemPanel' -import { apiFetch, clearAuthToken } from './lib/api' -import type { DashboardPayload, DateRange } from './lib/types' +import { apiFetch, readApiJson } from './lib/api' +import type { DashboardPayload } from './lib/types' const REFRESH_MS = 15_000 -type AuthUser = { - email: string - name: string | null - picture: string | null - method?: 'google' | 'pin' -} - export default function App() { - const [authChecked, setAuthChecked] = useState(false) - const [user, setUser] = useState(null) - const [range, setRange] = useState('7d') const [data, setData] = useState(null) const [error, setError] = useState(null) const [loading, setLoading] = useState(true) const abortRef = useRef(null) const inFlightRef = useRef(false) - useEffect(() => { - void (async () => { - try { - const res = await apiFetch('/api/auth/me') - if (res.ok) { - const json = (await res.json()) as { user: AuthUser } - setUser(json.user) - } else { - setUser(null) - } - } catch { - setUser(null) - } finally { - setAuthChecked(true) - } - })() - }, []) - - const load = useCallback( - async (opts?: { refresh?: boolean; mode?: 'replace' | 'poll' }) => { - const mode = opts?.mode ?? 'replace' + const load = useCallback(async (opts?: { + refresh?: boolean + mode?: 'replace' | 'poll' + }) => { + const mode = opts?.mode ?? 'replace' - if (mode === 'poll') { - if (inFlightRef.current) return - } else { - abortRef.current?.abort() - } + if (mode === 'poll') { + if (inFlightRef.current) return + } else { + abortRef.current?.abort() + } - const controller = new AbortController() - abortRef.current = controller - inFlightRef.current = true - try { - const params = new URLSearchParams({ range }) - if (opts?.refresh) params.set('refresh', '1') - const res = await apiFetch(`/api/dashboard?${params}`, { - signal: controller.signal, - }) - if (res.status === 401 || res.status === 503) { - if (res.status === 401) clearAuthToken() - setUser(null) - setData(null) - throw new Error( - res.status === 503 - ? 'Sign-in is not configured on this Mac.' - : 'Signed out', - ) - } - if (!res.ok) throw new Error(`HTTP ${res.status}`) - const json = (await res.json()) as DashboardPayload - if (controller.signal.aborted) return - setData(json) - setError(null) - } catch (err) { - if (err instanceof Error && err.name === 'AbortError') return - setError(err instanceof Error ? err.message : String(err)) - } finally { - if (abortRef.current === controller) inFlightRef.current = false - if (!controller.signal.aborted) setLoading(false) - } - }, - [range], - ) + const controller = new AbortController() + abortRef.current = controller + inFlightRef.current = true + try { + const params = new URLSearchParams({ range: 'month' }) + if (opts?.refresh) params.set('refresh', '1') + const res = await apiFetch(`/api/dashboard?${params}`, { + signal: controller.signal, + }) + if (!res.ok) throw new Error(`HTTP ${res.status}`) + const json = await readApiJson(res) + if (controller.signal.aborted) return + setData(json) + setError(null) + } catch (err) { + if (err instanceof Error && err.name === 'AbortError') return + setError(err instanceof Error ? err.message : String(err)) + } finally { + if (abortRef.current === controller) inFlightRef.current = false + if (!controller.signal.aborted) setLoading(false) + } + }, []) useEffect(() => { - if (!user) return setLoading(true) void load({ mode: 'replace' }) const id = window.setInterval(() => void load({ mode: 'poll' }), REFRESH_MS) @@ -97,33 +57,7 @@ export default function App() { window.clearInterval(id) abortRef.current?.abort() } - }, [load, user]) - - async function logout() { - try { - await apiFetch('/api/auth/logout', { method: 'POST' }) - } catch { - /* ignore */ - } - clearAuthToken() - setUser(null) - setData(null) - } - - if (!authChecked) { - return ( -
-

Checking sign-in…

-
- ) - } - - if (!user) { - return - } - - const identity = - user.method === 'pin' ? 'PIN access' : user.email + }, [load]) return (
@@ -134,22 +68,18 @@ export default function App() {

- {identity} {data - ? ` · Updated ${new Date(data.generatedAt).toLocaleTimeString()}${ + ? `Updated ${new Date(data.generatedAt).toLocaleTimeString()}${ data.cached ? ' · cached' : '' }` : loading - ? ' · Loading…' - : ' · Offline'} + ? 'Loading…' + : 'Offline'}

-
@@ -161,13 +91,9 @@ export default function App() {

) : null} - {data && data.range === range ? ( + {data ? (
- +
@@ -176,9 +102,8 @@ export default function App() { ) : null}
- Runs only on this Mac. Agent % is relative share of local activity - scores for the selected date range (Cursor accepted lines / Claude & - Codex tokens or session volume). + Runs only on this Mac. Circles show each provider’s billing-cycle plan + usage (token / API allowance).
diff --git a/src/components/Login.tsx b/src/components/Login.tsx deleted file mode 100644 index fc6efe9..0000000 --- a/src/components/Login.tsx +++ /dev/null @@ -1,383 +0,0 @@ -import { useEffect, useRef, useState, type FormEvent } from 'react' -import { apiFetch, setAuthToken } from '../lib/api' - -type AuthMode = 'google' | 'pin' - -type AuthConfig = { - mode: AuthMode - configured: boolean - clientId: string | null - allowedEmailsConfigured: boolean - publicOrigin?: string - pinConfigured?: boolean -} - -type AuthUser = { - email: string - name: string | null - picture: string | null - method?: 'google' | 'pin' -} - -type Props = { - onSignedIn: (user: AuthUser) => void -} - -declare global { - interface Window { - google?: { - accounts: { - id: { - initialize: (cfg: { - client_id: string - callback: (response: { credential: string }) => void - auto_select?: boolean - cancel_on_tap_outside?: boolean - }) => void - renderButton: ( - parent: HTMLElement, - options: { - theme?: string - size?: string - text?: string - shape?: string - width?: number - }, - ) => void - prompt: () => void - } - } - } - } -} - -function loadGoogleScript(): Promise { - if (window.google?.accounts?.id) return Promise.resolve() - return new Promise((resolve, reject) => { - const existing = document.querySelector( - 'script[data-google-gsi]', - ) - if (existing) { - existing.addEventListener('load', () => resolve()) - existing.addEventListener('error', () => - reject(new Error('Failed to load Google sign-in script')), - ) - return - } - const script = document.createElement('script') - script.src = 'https://accounts.google.com/gsi/client' - script.async = true - script.defer = true - script.dataset.googleGsi = '1' - script.onload = () => resolve() - script.onerror = () => reject(new Error('Failed to load Google sign-in script')) - document.head.appendChild(script) - }) -} - -export function Login({ onSignedIn }: Props) { - const [config, setConfig] = useState(null) - const [error, setError] = useState(null) - const [busy, setBusy] = useState(false) - const [pin, setPin] = useState('') - const buttonRef = useRef(null) - - useEffect(() => { - void (async () => { - try { - const res = await apiFetch('/api/auth/config') - const json = (await res.json()) as AuthConfig - setConfig(json) - } catch (err) { - setError(err instanceof Error ? err.message : String(err)) - } - })() - }, []) - - useEffect(() => { - if (config?.mode !== 'google') return - if (!config.configured || !config.clientId || !buttonRef.current) return - let cancelled = false - - void (async () => { - try { - await loadGoogleScript() - if (cancelled || !buttonRef.current || !window.google) return - - window.google.accounts.id.initialize({ - client_id: config.clientId!, - callback: async (response) => { - setBusy(true) - setError(null) - try { - const res = await apiFetch('/api/auth/google', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ credential: response.credential }), - }) - const json = (await res.json()) as { - user?: AuthUser - token?: string - message?: string - error?: string - } - if (!res.ok || !json.user || !json.token) { - throw new Error(json.message ?? json.error ?? `HTTP ${res.status}`) - } - setAuthToken(json.token) - onSignedIn(json.user) - } catch (err) { - setError(err instanceof Error ? err.message : String(err)) - } finally { - setBusy(false) - } - }, - auto_select: false, - cancel_on_tap_outside: true, - }) - - buttonRef.current.innerHTML = '' - window.google.accounts.id.renderButton(buttonRef.current, { - theme: 'outline', - size: 'large', - text: 'continue_with', - shape: 'rectangular', - width: 280, - }) - } catch (err) { - if (!cancelled) { - setError(err instanceof Error ? err.message : String(err)) - } - } - })() - - return () => { - cancelled = true - } - }, [config, onSignedIn]) - - async function submitPin(event: FormEvent) { - event.preventDefault() - setBusy(true) - setError(null) - try { - const res = await apiFetch('/api/auth/pin', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ pin }), - }) - const json = (await res.json()) as { - user?: AuthUser - token?: string - message?: string - error?: string - } - if (!res.ok || !json.user || !json.token) { - throw new Error(json.message ?? json.error ?? `HTTP ${res.status}`) - } - setAuthToken(json.token) - onSignedIn(json.user) - } catch (err) { - setError(err instanceof Error ? err.message : String(err)) - } finally { - setBusy(false) - } - } - - const mode = config?.mode - const publicOrigin = - config?.publicOrigin ?? 'https://agent-dashboard-ctt.vercel.app' - - return ( -
-
-

Local Mac

-

Agent Deck

-

- {mode === 'pin' - ? 'Enter the dashboard PIN to open Agent Deck on this LAN address. Google sign-in is not available on raw IP hosts.' - : 'Sign in with Google to open your private agent dashboard. Google must verify your email before access is granted.'} -

- - {!config ? ( -

Checking sign-in…

- ) : !config.configured ? ( -
- {mode === 'pin' ? ( - <> -

PIN sign-in is not configured yet. On this Mac:

-
    -
  1. - Copy .env.example to .env -
  2. -
  3. - Set DASHBOARD_PIN (required for LAN / phone) -
  4. -
  5. - When HOST=0.0.0.0, also set{' '} - ALLOWED_EMAILS and GOOGLE_CLIENT_ID -
  6. -
  7. - Restart with npm run setup -
  8. -
- - ) : ( - <> -

Google sign-in is not configured yet. On this Mac:

-
    -
  1. - Create an OAuth Web client in Google Cloud - Console -
  2. -
  3. - Add authorized JavaScript origins:{' '} - http://127.0.0.1:3847,{' '} - http://localhost:3847,{' '} - http://127.0.0.1:5174,{' '} - http://localhost:5174, and{' '} - {publicOrigin} -
  4. -
  5. - Copy .env.example to .env and set{' '} - GOOGLE_CLIENT_ID -
  6. -
  7. - For LAN bind, set ALLOWED_EMAILS and{' '} - DASHBOARD_PIN -
  8. -
  9. - Restart with npm run setup -
  10. -
- - )} -
- ) : mode === 'pin' ? ( -
void submitPin(e)}> - - setPin(e.target.value)} - disabled={busy} - /> - -
- ) : ( - <> -
- {busy ?

Verifying email…

: null} - - )} - - {error ?

{error}

: null} -
- - -
- ) -} diff --git a/src/lib/api.test.ts b/src/lib/api.test.ts new file mode 100644 index 0000000..7a9ad5c --- /dev/null +++ b/src/lib/api.test.ts @@ -0,0 +1,20 @@ +import assert from 'node:assert/strict' +import { describe, it } from 'node:test' +import { readApiJson } from './api.ts' + +describe('readApiJson', () => { + it('parses JSON bodies', async () => { + const res = new Response(JSON.stringify({ ok: true }), { + headers: { 'content-type': 'application/json' }, + }) + const json = await readApiJson<{ ok: boolean }>(res) + assert.equal(json.ok, true) + }) + + it('rejects HTML responses', async () => { + const res = new Response('', { + headers: { 'content-type': 'text/html' }, + }) + await assert.rejects(() => readApiJson(res), /HTML instead of JSON/) + }) +}) diff --git a/src/lib/api.ts b/src/lib/api.ts index 1ba0914..b895157 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -1,68 +1,34 @@ -declare global { - interface Window { - __AGENT_DECK_API_BASE__?: string - } -} - -const TOKEN_KEY = 'agent_deck_token' - -function trimSlash(value: string): string { - return value.replace(/\/$/, '') -} - -/** Build-time VITE_API_BASE, overridden by runtime `window.__AGENT_DECK_API_BASE__`. */ -export function apiBase(): string { - const runtime = - typeof window !== 'undefined' ? window.__AGENT_DECK_API_BASE__ : undefined - if (typeof runtime === 'string' && runtime.trim()) { - return trimSlash(runtime.trim()) - } - const baked = import.meta.env.VITE_API_BASE - if (typeof baked === 'string' && baked.trim()) { - return trimSlash(baked.trim()) - } - return '' -} - -export function apiUrl(path: string): string { - const base = apiBase() +export function apiFetch( + path: string, + init?: RequestInit, +): Promise { const p = path.startsWith('/') ? path : `/${path}` - return `${base}${p}` -} + return fetch(p, init) +} + +/** Parse API JSON; surface a clear error when HTML is returned by mistake. */ +export async function readApiJson(res: Response): Promise { + const contentType = res.headers.get('content-type') ?? '' + const text = await res.text() + const trimmed = text.trimStart() + const looksLikeHtml = + trimmed.startsWith(' { - const headers = new Headers(init?.headers) - const token = getAuthToken() - if (token && !headers.has('Authorization')) { - headers.set('Authorization', `Bearer ${token}`) + throw new Error(`API response was not JSON (HTTP ${res.status}).`) } - return fetch(apiUrl(path), { - ...init, - headers, - credentials: 'include', - }) } diff --git a/src/lib/types.test.ts b/src/lib/types.test.ts index 693904f..080127c 100644 --- a/src/lib/types.test.ts +++ b/src/lib/types.test.ts @@ -2,10 +2,10 @@ import assert from 'node:assert/strict' import { describe, it } from 'node:test' import { formatResetAt, rangeLabel, DATE_RANGES } from '../../src/lib/types.ts' -describe('frontend range + reset display helpers', () => { - it('keeps This month in the UI date-range set', () => { - assert.deepEqual(DATE_RANGES, ['1d', '7d', '30d', 'month']) - assert.equal(rangeLabel('month'), 'This month') +describe('frontend billing-cycle helpers', () => { + it('only exposes this billing cycle', () => { + assert.deepEqual(DATE_RANGES, ['month']) + assert.equal(rangeLabel('month'), 'This billing cycle') }) it('formats provider reset timestamps for display (not local midnight only)', () => { diff --git a/src/lib/types.ts b/src/lib/types.ts index 0cd189a..0c9adc2 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -1,4 +1,4 @@ -export const DATE_RANGES = ['1d', '7d', '30d', 'month'] as const +export const DATE_RANGES = ['month'] as const export type DateRange = (typeof DATE_RANGES)[number] export type DailyPoint = { @@ -22,12 +22,16 @@ export type UsageResetWindow = { label: string at: string | null usedPercent?: number + used?: number + limit?: number + unit?: string note?: string } export type UsageReset = { ok: boolean windows: UsageResetWindow[] + cycleStart?: string | null error?: string } @@ -104,11 +108,8 @@ export function formatUptime(sec: number): string { return `${m}m` } -export function rangeLabel(range: DateRange): string { - if (range === '1d') return 'Today' - if (range === '7d') return '7 days' - if (range === '30d') return '30 days' - return 'This month' +export function rangeLabel(_range: DateRange = 'month'): string { + return 'This billing cycle' } export function formatResetAt(iso: string): string { diff --git a/vercel.json b/vercel.json deleted file mode 100644 index 0f32683..0000000 --- a/vercel.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "rewrites": [{ "source": "/(.*)", "destination": "/index.html" }] -}