diff --git a/.gitignore b/.gitignore index cfda3195..487f2738 100644 --- a/.gitignore +++ b/.gitignore @@ -62,3 +62,7 @@ tests/mocks/fixtures/*.js tests/mocks/fixtures/*.d.ts tests/mocks/fixtures/*.js.map tests/mocks/fixtures/*.d.ts.map + +# Bar demo scaffolding (local dev only) +_serve*.ts +_serve*.log diff --git a/docs/ccs-bar.md b/docs/ccs-bar.md new file mode 100644 index 00000000..82bb9a47 --- /dev/null +++ b/docs/ccs-bar.md @@ -0,0 +1,84 @@ +# CCS Bar — Native macOS Menu Bar App + +CCS Bar is a native macOS menu-bar app that shows live subscription quota and usage at a glance for your Claude Code, Codex, and CLIProxy accounts, without opening the dashboard. + +## What It Is + +CCS Bar is a thin client of the CCS local web-server. It never talks to a provider directly: every call goes to `localhost`, and CCS performs any provider fetch server-side. Opening the menu fires a debounced force-refresh so the glance reflects live data without blocking the UI. + +It is macOS only. + +## What It Shows + +- Per-account quota percent and reset countdown +- Account tier +- Today, 7-day, and 30-day cost +- A 30-day usage sparkline +- Account state (active, paused, default) +- Native subscription rows for Claude Code and Codex + +## Requirements + +- macOS +- CCS CLI installed and configured (`ccs config` works) +- The CCS web-server reachable on loopback. `ccs bar` (or `ccs bar launch`) starts it for you. + +## Install + +```bash +ccs bar install +``` + +This downloads `CCS-Bar.app.zip` from the floating `ccs-bar-latest` GitHub release and installs `CCS Bar.app` into `~/Applications`. Downloads are restricted to `github.com` and `objects.githubusercontent.com`, and extraction is guarded against zip-slip. + +### Gatekeeper note + +The v1 builds use ad-hoc signing, so the first launch may be blocked by Gatekeeper. Either right-click the app and choose Open, or clear the quarantine attribute: + +```bash +xattr -dr com.apple.quarantine "$HOME/Applications/CCS Bar.app" +``` + +## Launch + +```bash +ccs bar # alias: ccs bar launch +``` + +This makes sure the web-server is up, writes the discovery file `~/.ccs/bar.json`, and opens the app. The discovery file looks like this: + +```json +{ "baseUrl": "http://127.0.0.1:3000", "port": 3000, "authMode": "loopback" } +``` + +The Swift app reads `~/.ccs/bar.json` to find the server. + +## Loopback / Localhost Requirement + +CCS Bar talks only to `http://127.0.0.1:`. v1 supports `authMode: "loopback"` only, meaning dashboard auth disabled on localhost. + +If you bind the dashboard beyond localhost (for example `--host` set to a non-loopback address) with dashboard auth disabled, the bar's read endpoints (`GET /api/bar/summary`, `GET /api/bar/analytics`) are refused for non-loopback callers, and the app cannot reach the server. Keep the dashboard on loopback for CCS Bar to work. + +## Uninstall + +```bash +ccs bar uninstall +``` + +This removes `~/Applications/CCS Bar.app` and the installed version pin. It is a no-op if the app is not present. + +## Troubleshooting + +- Server failed to start: usually a port conflict. Free the port or re-run `ccs bar` to pick a fresh one. +- App won't open (Gatekeeper): right-click and Open, or clear quarantine with the `xattr` command above. +- Blank app: the web-server is not running. Re-run `ccs bar` and confirm `~/.ccs/bar.json` exists. +- Quota not updating: re-open the menu to force a refresh, or confirm the server is still reachable on loopback. + +## Development + +The source lives in `macos-bar/`. Contributors can build and run the logic checks with a Swift 5.9+ toolchain (CommandLineTools is enough, full Xcode not required): + +```bash +swift build # build all targets, including the app +swift run ccs-bar-check # run the logic tests +``` diff --git a/macos-bar/Sources/CCSBarApp/BarNotifier.swift b/macos-bar/Sources/CCSBarApp/BarNotifier.swift index f0487cdc..5708ca9f 100644 --- a/macos-bar/Sources/CCSBarApp/BarNotifier.swift +++ b/macos-bar/Sources/CCSBarApp/BarNotifier.swift @@ -64,7 +64,7 @@ final class BarNotifier: NotificationDelivering { return } - // Already requested: post only when authorized; a denied state is a no-op. + // Already requested: post when authorized or still-unknown; a denied state is a no-op. if authState == .authorized || authState == .unknown { post(notification, on: center) } diff --git a/macos-bar/Sources/CCSBarApp/BarSubscriptionCard.swift b/macos-bar/Sources/CCSBarApp/BarSubscriptionCard.swift index dd3b33b8..34bbbc4e 100644 --- a/macos-bar/Sources/CCSBarApp/BarSubscriptionCard.swift +++ b/macos-bar/Sources/CCSBarApp/BarSubscriptionCard.swift @@ -164,7 +164,7 @@ struct BarSubscriptionCard: View { /// Terse window label for the bar list, at most 4-5 chars: /// five_hour → "5h" /// seven_day → "wk" - /// seven_day_opus → "Son" (sic — this is the Opus sub-budget inside the week) + /// seven_day_opus → "Opus" /// seven_day_sonnet → "Son" /// /// Fall back to the backend-supplied label truncated to 5 chars so unknown diff --git a/macos-bar/Sources/CCSBarCore/BarAlertEngine.swift b/macos-bar/Sources/CCSBarCore/BarAlertEngine.swift index 8c1182e3..2b35f2d9 100644 --- a/macos-bar/Sources/CCSBarCore/BarAlertEngine.swift +++ b/macos-bar/Sources/CCSBarCore/BarAlertEngine.swift @@ -147,6 +147,11 @@ public enum BarAlertEngine { let name = row.displayName ?? row.provider // (1) quotaRemainingBelow — fire the SINGLE most-severe crossed level. + // One alert per reset window (anti-spam): the fired-key embeds the reset + // bucket (row.nextReset ?? "noreset" below), so once an account crosses a + // level the alert is suppressed for the rest of that window even if quota + // recovers and then drops again. It re-arms automatically when nextReset + // rolls to a new window. if prefs.quotaEnabled, row.quotaStatus == "ok", let pct = row.quotaPercentage { let remaining = Int(pct.rounded()) // levels sorted desc; the most-severe crossed level is the smallest L diff --git a/src/cliproxy/quota/__tests__/quota-fetcher-claude.test.ts b/src/cliproxy/quota/__tests__/quota-fetcher-claude.test.ts index 4be8a59a..36b84903 100644 --- a/src/cliproxy/quota/__tests__/quota-fetcher-claude.test.ts +++ b/src/cliproxy/quota/__tests__/quota-fetcher-claude.test.ts @@ -622,6 +622,34 @@ describe('Claude Quota Fetcher', () => { expect(result.coreUsage?.fiveHour?.remainingPercent).toBe(60); }); + it('does NOT inner-retry on 429; returns retryable single-attempt result honoring Retry-After', async () => { + // Safety intent: 429 must NOT trigger an immediate, delay-free inner retry. + // The outer 10-min cache + circuit breaker honor Retry-After and bound total + // volume, so a single attempt is made and the retryable signal is surfaced. + createClaudeAccount('claude-429@example.com', { + access_token: 'rate-limited-token', + expired: '2099-01-01T00:00:00.000Z', + type: 'claude', + }); + + let attempt = 0; + global.fetch = mock(() => { + attempt += 1; + return Promise.resolve( + new Response('', { status: 429, headers: { 'Retry-After': '120' } }) + ); + }) as typeof fetch; + + const result = await fetchClaudeQuota('claude-429@example.com'); + + expect(result.success).toBe(false); + // Single attempt — no inner retry burned on the 429. + expect(attempt).toBe(1); + expect(result.httpStatus).toBe(429); + expect(result.retryable).toBe(true); + expect(result.errorDetail).toBe('retry-after:120'); + }); + it('clears the request timeout before retrying a retryable HTTP error', async () => { createClaudeAccount('claude-retry-timeout@example.com', { access_token: 'retry-timeout-token', diff --git a/src/cliproxy/quota/quota-fetcher-claude.ts b/src/cliproxy/quota/quota-fetcher-claude.ts index 03113a40..fe7f5b14 100644 --- a/src/cliproxy/quota/quota-fetcher-claude.ts +++ b/src/cliproxy/quota/quota-fetcher-claude.ts @@ -280,10 +280,10 @@ async function runClaudeUsageFetch( // Surface the upstream status + Retry-After so an outer caller (the // native collector's circuit-breaker) can honor backoff guidance. const retryAfter = response.headers.get('retry-after'); - if ( - attempt < CLAUDE_QUOTA_MAX_ATTEMPTS && - (response.status === 429 || response.status >= 500) - ) { + // Do not inner-retry 429 with no delay; the outer cache + circuit breaker + // honor Retry-After and bound total volume. Inner retry stays for + // transient 5xx only. + if (attempt < CLAUDE_QUOTA_MAX_ATTEMPTS && response.status >= 500) { clearTimeout(timeoutId); continue; } diff --git a/src/web-server/routes/index.ts b/src/web-server/routes/index.ts index e9909c3c..41e99b5e 100644 --- a/src/web-server/routes/index.ts +++ b/src/web-server/routes/index.ts @@ -44,6 +44,13 @@ export const apiRoutes = Router(); const REMOTE_WRITE_ACCESS_ERROR = 'Remote dashboard writes require localhost access when dashboard auth is disabled.'; +// CCS Bar endpoints (/api/bar/*) expose the user's native quota, tier, and cost +// snapshot. Unlike the rest of the read API these are sensitive even on GET, so +// they are gated for ALL methods (not just mutations) by the same +// localhost-when-auth-disabled choke point. +const BAR_LOCAL_ACCESS_ERROR = + 'CCS Bar endpoints require localhost access when dashboard auth is disabled.'; + function isMutationMethod(method: string): boolean { const normalized = method.toUpperCase(); return ( @@ -55,6 +62,17 @@ function isMutationMethod(method: string): boolean { } apiRoutes.use((req, res, next) => { + // /api/bar/* leaks native quota/tier/cost data; gate it for every method. + // This middleware runs before the '/bar' mount below, so req.path still + // carries the '/bar' prefix here. + // Exact segment match so a future sibling like '/barbaz' isn't accidentally gated. + if (req.path === '/bar' || req.path.startsWith('/bar/')) { + if (requireLocalAccessWhenAuthDisabled(req, res, BAR_LOCAL_ACCESS_ERROR)) { + next(); + } + return; + } + if (!isMutationMethod(req.method)) { next(); return; diff --git a/src/web-server/usage/bar-analytics.ts b/src/web-server/usage/bar-analytics.ts index 746624e2..965034fb 100644 --- a/src/web-server/usage/bar-analytics.ts +++ b/src/web-server/usage/bar-analytics.ts @@ -93,7 +93,7 @@ const SPARKLINE_DAYS = 30; const TOP_MODELS_LIMIT = 5; /** Local-time YYYY-MM-DD key for a Date (matches the user's calendar day). */ -function localDayKey(d: Date): string { +export function localDayKey(d: Date): string { const y = d.getFullYear(); const m = String(d.getMonth() + 1).padStart(2, '0'); const day = String(d.getDate()).padStart(2, '0'); diff --git a/src/web-server/usage/cliproxy-usage-transformer.ts b/src/web-server/usage/cliproxy-usage-transformer.ts index 1036e58e..0a6cfcdd 100644 --- a/src/web-server/usage/cliproxy-usage-transformer.ts +++ b/src/web-server/usage/cliproxy-usage-transformer.ts @@ -62,7 +62,7 @@ function createHistoryDetail( provider: string, model: string, detail: CliproxyRequestDetail, - accountMap?: Map + accountMap?: Map ): CliproxyUsageHistoryDetail { const pricingProvider = normalizeUsageProvider(provider) ?? provider.trim().toLowerCase(); const inputTokens = detail.tokens?.input_tokens ?? 0; @@ -204,12 +204,13 @@ function hasTrackedUsage(detail: CliproxyRequestDetail): boolean { * when they still report tracked token usage that analytics can account for. * * @param accountMap Optional auth_index → account email/id map. When provided, - * each detail's `accountId` is resolved from auth_index; falls back to - * `detail.source` when the index is not in the map. + * each detail's `accountId` is resolved from String(auth_index). When the index + * is absent from the map, `accountId` is left undefined so getTodayCostByAccount + * buckets the cost under 'unknown' rather than mis-attributing it. */ export function extractCliproxyUsageHistoryDetails( response: CliproxyUsageApiResponse, - accountMap?: Map + accountMap?: Map ): CliproxyUsageHistoryDetail[] { const apis = response?.usage?.apis; if (!apis) return []; diff --git a/src/web-server/usage/data-aggregator.ts b/src/web-server/usage/data-aggregator.ts index 09d51522..5faf386e 100644 --- a/src/web-server/usage/data-aggregator.ts +++ b/src/web-server/usage/data-aggregator.ts @@ -484,6 +484,7 @@ export function aggregateSessionUsage( // ============================================================================ import type { CliproxyUsageHistoryDetail } from './cliproxy-usage-transformer'; +import { localDayKey } from './bar-analytics'; /** * Compute per-account cost totals for a given calendar day. @@ -498,7 +499,9 @@ export function getTodayCostByAccount( details: CliproxyUsageHistoryDetail[], today?: string ): Record { - const dateKey = today ?? new Date().toISOString().slice(0, 10); + // Key on the LOCAL calendar day so a near-midnight record buckets into the + // same day the analytics panel shows (bar-analytics also keys on localDayKey). + const dateKey = today ?? localDayKey(new Date()); const result: Record = {}; for (const detail of details) { diff --git a/tests/unit/commands/bar-command.test.ts b/tests/unit/commands/bar-command.test.ts index 5b97b6d6..ca8bd313 100644 --- a/tests/unit/commands/bar-command.test.ts +++ b/tests/unit/commands/bar-command.test.ts @@ -45,9 +45,7 @@ function restoreConsole(): void { let moduleSeq = 0; async function loadHandleBarCommand() { moduleSeq++; - const mod = await import( - `../../../src/commands/bar/index?test=${Date.now()}-${moduleSeq}` - ); + const mod = await import(`../../../src/commands/bar/index?test=${Date.now()}-${moduleSeq}`); return mod.handleBarCommand as (args: string[]) => Promise; } @@ -242,7 +240,9 @@ describe('bar.json contract (launch subcommand)', () => { // Mock dependencies injected into handleBarLaunch const mockEnsureDashboard = async () => ({ port: 4242, baseUrl: 'http://127.0.0.1:4242' }); - const mockOpenApp = async (_appPath: string) => { calls.push(`open:${_appPath}`); }; + const mockOpenApp = async (_appPath: string) => { + calls.push(`open:${_appPath}`); + }; const mockGetCcsDir = () => ccsDir; const { handleBarLaunch } = await loadLaunchSubcommand(); @@ -273,14 +273,16 @@ describe('bar.json contract (launch subcommand)', () => { await handleBarLaunch([], { ensureDashboard: async () => ({ port: 9000, baseUrl: 'http://127.0.0.1:9000' }), - openApp: async () => { /* noop */ }, + openApp: async () => { + /* noop */ + }, getCcsDir: () => ccsDir, appInstallPath: path.join(tempHome, 'Applications', 'CCS Bar.app'), }); - const barJson = JSON.parse( - fs.readFileSync(path.join(ccsDir, 'bar.json'), 'utf8') - ) as { authMode: string }; + const barJson = JSON.parse(fs.readFileSync(path.join(ccsDir, 'bar.json'), 'utf8')) as { + authMode: string; + }; expect(barJson.authMode).toBe('loopback'); }); @@ -295,7 +297,9 @@ describe('bar.json contract (launch subcommand)', () => { await handleBarLaunch([], { ensureDashboard: async () => ({ port: 3000, baseUrl: 'http://127.0.0.1:3000' }), - openApp: async () => { throw new Error('App not found'); }, + openApp: async () => { + throw new Error('App not found'); + }, getCcsDir: () => ccsDir, appInstallPath: nonExistentApp, }); @@ -313,7 +317,9 @@ describe('bar.json contract (launch subcommand)', () => { await handleBarLaunch([], { ensureDashboard: async () => ({ port: 3001, baseUrl: 'http://127.0.0.1:3001' }), - openApp: async () => { throw new Error('open failed'); }, + openApp: async () => { + throw new Error('open failed'); + }, getCcsDir: () => ccsDir, appInstallPath: path.join(tempHome, 'Applications', 'CCS Bar.app'), }); @@ -330,8 +336,12 @@ describe('bar.json contract (launch subcommand)', () => { const { handleBarLaunch } = await loadLaunchSubcommand(); await handleBarLaunch([], { - ensureDashboard: async () => { throw new Error('port busy'); }, - openApp: async () => { /* noop */ }, + ensureDashboard: async () => { + throw new Error('port busy'); + }, + openApp: async () => { + /* noop */ + }, getCcsDir: () => ccsDir, appInstallPath: path.join(tempHome, 'Applications', 'CCS Bar.app'), }); @@ -469,8 +479,12 @@ describe('bar install subcommand', () => { await expect( handleBarInstall([], { - fetchReleaseAsset: async () => { throw new Error('network error'); }, - downloadAndExtract: async () => { /* noop */ }, + fetchReleaseAsset: async () => { + throw new Error('network error'); + }, + downloadAndExtract: async () => { + /* noop */ + }, verifyCompat: async () => ({ version: FAKE_VERSION, compatible: true }), getCcsDir: () => path.join(tempHome, '.ccs'), getAppsDir: () => appsDir, @@ -527,17 +541,6 @@ describe('bar install: redirect-following download (#8)', () => { expect(allOutput).not.toMatch(/\[X\]/); expect(allOutput).toMatch(/\[OK\]/); }); - - it('production defaultDownloadAndExtract passes maxRedirections:5 to undici (structural test)', async () => { - // This test verifies the production code path uses maxRedirections. - // We test validateDownloadUrl directly (exported) and confirm the URL shape expected - // by defaultDownloadAndExtract is accepted for github.com and githubusercontent.com. - const { validateDownloadUrl } = await loadInstallSubcommand(); - - // Both the initial github.com URL and the 302 target must pass host validation. - expect(() => validateDownloadUrl(REDIRECT_URL)).not.toThrow(); - expect(() => validateDownloadUrl(FINAL_URL)).not.toThrow(); - }); }); // --------------------------------------------------------------------------- @@ -602,9 +605,7 @@ describe('bar install: host allowlist validation (#9)', () => { it('validateDownloadUrl accepts github.com URLs', async () => { const { validateDownloadUrl } = await loadInstallSubcommand(); expect(() => - validateDownloadUrl( - 'https://github.com/kaitranntt/ccs/releases/download/tag/CCS-Bar.app.zip' - ) + validateDownloadUrl('https://github.com/kaitranntt/ccs/releases/download/tag/CCS-Bar.app.zip') ).not.toThrow(); }); @@ -631,17 +632,17 @@ describe('bar install: host allowlist validation (#9)', () => { it('validateDownloadUrl rejects untrusted hostnames', async () => { const { validateDownloadUrl } = await loadInstallSubcommand(); - expect(() => - validateDownloadUrl('https://evil.example.com/CCS-Bar.app.zip') - ).toThrow(/allowlist|trusted/i); + expect(() => validateDownloadUrl('https://evil.example.com/CCS-Bar.app.zip')).toThrow( + /allowlist|trusted/i + ); }); it('validateDownloadUrl rejects a URL that looks like github but is not', async () => { const { validateDownloadUrl } = await loadInstallSubcommand(); // A domain that ends in github.com.attacker.com must be rejected - expect(() => - validateDownloadUrl('https://github.com.attacker.com/download/file.zip') - ).toThrow(/allowlist|trusted/i); + expect(() => validateDownloadUrl('https://github.com.attacker.com/download/file.zip')).toThrow( + /allowlist|trusted/i + ); }); it('handleBarInstall surfaces a clear error for non-github download URLs', async () => { diff --git a/tests/unit/web-server/api-routes-bar-local-access-guard.test.ts b/tests/unit/web-server/api-routes-bar-local-access-guard.test.ts new file mode 100644 index 00000000..bae4b770 --- /dev/null +++ b/tests/unit/web-server/api-routes-bar-local-access-guard.test.ts @@ -0,0 +1,187 @@ +/** + * Security gate for /api/bar/* — these endpoints expose the user's native + * quota, tier, and cost snapshot, so unlike the rest of the read API they must + * be refused for non-loopback callers when dashboard auth is disabled. + * + * The gate lives in the top-level apiRoutes middleware (one choke point), so we + * exercise it by mounting the real apiRoutes and toggling auth via env, mirroring + * api-routes-remote-write-guard.test.ts. + */ + +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from 'bun:test'; +import bcrypt from 'bcrypt'; +import express from 'express'; +import type { Server } from 'http'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { apiRoutes } from '../../../src/web-server/routes'; +import { + authMiddleware, + createSessionMiddleware, +} from '../../../src/web-server/middleware/auth-middleware'; + +const BAR_LOCAL_ACCESS_ERROR = + 'CCS Bar endpoints require localhost access when dashboard auth is disabled.'; + +describe('api-routes /api/bar/* local-access guard', () => { + let server: Server; + let baseUrl = ''; + let forcedRemoteAddress = '127.0.0.1'; + let tempHome = ''; + let originalDashboardAuthEnabled: string | undefined; + let originalCcsHome: string | undefined; + let originalCodexHome: string | undefined; + + beforeAll(async () => { + const app = express(); + app.use(express.json()); + app.use((req, _res, next) => { + Object.defineProperty(req.socket, 'remoteAddress', { + value: forcedRemoteAddress, + configurable: true, + }); + next(); + }); + app.use('/api', apiRoutes); + + await new Promise((resolve, reject) => { + server = app.listen(0, '127.0.0.1'); + server.once('error', reject); + server.once('listening', () => resolve()); + }); + + const address = server.address(); + if (!address || typeof address === 'string') { + throw new Error('Unable to resolve test server port'); + } + baseUrl = `http://127.0.0.1:${address.port}`; + }); + + afterAll(async () => { + await new Promise((resolve) => server.close(() => resolve())); + }); + + beforeEach(() => { + originalDashboardAuthEnabled = process.env.CCS_DASHBOARD_AUTH_ENABLED; + originalCcsHome = process.env.CCS_HOME; + originalCodexHome = process.env.CODEX_HOME; + tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-api-routes-bar-guard-')); + process.env.CCS_HOME = tempHome; + process.env.CODEX_HOME = path.join(tempHome, '.codex'); + process.env.CCS_DASHBOARD_AUTH_ENABLED = 'false'; + forcedRemoteAddress = '192.168.2.50'; + }); + + afterEach(() => { + if (originalDashboardAuthEnabled !== undefined) { + process.env.CCS_DASHBOARD_AUTH_ENABLED = originalDashboardAuthEnabled; + } else { + delete process.env.CCS_DASHBOARD_AUTH_ENABLED; + } + + if (originalCcsHome !== undefined) { + process.env.CCS_HOME = originalCcsHome; + } else { + delete process.env.CCS_HOME; + } + + if (originalCodexHome !== undefined) { + process.env.CODEX_HOME = originalCodexHome; + } else { + delete process.env.CODEX_HOME; + } + + if (tempHome && fs.existsSync(tempHome)) { + fs.rmSync(tempHome, { recursive: true, force: true }); + tempHome = ''; + } + }); + + it('rejects a non-loopback GET /api/bar/summary when dashboard auth is disabled', async () => { + const response = await fetch(`${baseUrl}/api/bar/summary`); + + // 403 from the gate means the bar handler never ran (no quota/cost data + // loaded) — the body is the gate error, not a summary array. + expect(response.status).toBe(403); + expect(await response.json()).toEqual({ error: BAR_LOCAL_ACCESS_ERROR }); + }); + + it('rejects a non-loopback GET /api/bar/analytics when dashboard auth is disabled', async () => { + const response = await fetch(`${baseUrl}/api/bar/analytics`); + + expect(response.status).toBe(403); + expect(await response.json()).toEqual({ error: BAR_LOCAL_ACCESS_ERROR }); + }); + + it('allows a loopback GET /api/bar/summary when dashboard auth is disabled', async () => { + forcedRemoteAddress = '127.0.0.1'; + + const response = await fetch(`${baseUrl}/api/bar/summary`, { + headers: { Host: '127.0.0.1' }, + }); + + // Loopback passes the gate; the real handler degrades gracefully against an + // empty temp CCS_HOME and returns a 200 array. + expect(response.status).toBe(200); + expect(Array.isArray(await response.json())).toBe(true); + }); + + it('allows a non-loopback GET /api/bar/summary when dashboard auth is ENABLED', async () => { + // With auth enabled the helper returns true regardless of peer address, so + // an authenticated remote dashboard keeps working. We log in to get a session + // cookie, then a remote (non-loopback) GET must pass. + const password = 'testpassword123'; + process.env.CCS_DASHBOARD_AUTH_ENABLED = 'true'; + process.env.CCS_DASHBOARD_USERNAME = 'admin'; + process.env.CCS_DASHBOARD_PASSWORD_HASH = await bcrypt.hash(password, 4); + + const authApp = express(); + authApp.use(express.json()); + authApp.use((req, _res, next) => { + Object.defineProperty(req.socket, 'remoteAddress', { + value: '203.0.113.7', + configurable: true, + }); + next(); + }); + authApp.use(createSessionMiddleware()); + authApp.use(authMiddleware); + authApp.use('/api', apiRoutes); + + const authServer = await new Promise((resolve, reject) => { + const instance = authApp.listen(0, '127.0.0.1'); + instance.once('error', reject); + instance.once('listening', () => resolve(instance)); + }); + + try { + const address = authServer.address(); + if (!address || typeof address === 'string') { + throw new Error('Unable to resolve auth-enabled test server port'); + } + const authBaseUrl = `http://127.0.0.1:${address.port}`; + + const loginResponse = await fetch(`${authBaseUrl}/api/auth/login`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ username: 'admin', password }), + }); + const cookie = loginResponse.headers.get('set-cookie'); + expect(loginResponse.status).toBe(200); + expect(cookie).toBeTruthy(); + + const response = await fetch(`${authBaseUrl}/api/bar/summary`, { + headers: { Cookie: cookie as string }, + }); + + // Not the gate's 403 — auth-enabled bypasses the localhost requirement. + expect(response.status).toBe(200); + expect(Array.isArray(await response.json())).toBe(true); + } finally { + await new Promise((resolve) => authServer.close(() => resolve())); + delete process.env.CCS_DASHBOARD_USERNAME; + delete process.env.CCS_DASHBOARD_PASSWORD_HASH; + } + }, 15000); +}); diff --git a/tests/unit/web-server/usage/account-attribution.test.ts b/tests/unit/web-server/usage/account-attribution.test.ts index d149d8e3..e1af00b9 100644 --- a/tests/unit/web-server/usage/account-attribution.test.ts +++ b/tests/unit/web-server/usage/account-attribution.test.ts @@ -10,24 +10,38 @@ */ import { describe, expect, it, beforeEach, afterEach } from 'bun:test'; -import type { CliproxyUsageApiResponse, CliproxyManagementAuthFile } from '../../../../src/cliproxy/services/stats-fetcher'; +import type { + CliproxyUsageApiResponse, + CliproxyManagementAuthFile, +} from '../../../../src/cliproxy/services/stats-fetcher'; // ============================================================================ // HELPERS & FIXTURES // ============================================================================ -const TODAY = new Date().toISOString().slice(0, 10); // YYYY-MM-DD +// Local calendar day (matches production getTodayCostByAccount, which keys on +// localDayKey — not a UTC ISO slice). Fixture timestamps below use the SAME +// local day with no trailing Z so they bucket consistently with production. +function localDay(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}`; +} +const TODAY = localDay(new Date()); // YYYY-MM-DD, local -function makeResponse(entries: Array<{ - provider: string; - model: string; - auth_index: number; - source: string; - timestamp: string; - input: number; - output: number; - failed?: boolean; -}>): CliproxyUsageApiResponse { +function makeResponse( + entries: Array<{ + provider: string; + model: string; + auth_index: number; + source: string; + timestamp: string; + input: number; + output: number; + failed?: boolean; + }> +): CliproxyUsageApiResponse { const apis: CliproxyUsageApiResponse['usage'] = { apis: {} }; for (const e of entries) { if (!apis.apis![e.provider]) { @@ -100,7 +114,9 @@ const authFileMap: Map = new Map([ describe('extractCliproxyUsageHistoryDetails with accountMap', () => { it('populates accountId from accountMap when auth_index is present', async () => { - const { extractCliproxyUsageHistoryDetails } = await import('../../../../src/web-server/usage/cliproxy-usage-transformer'); + const { extractCliproxyUsageHistoryDetails } = await import( + '../../../../src/web-server/usage/cliproxy-usage-transformer' + ); const details = extractCliproxyUsageHistoryDetails(twoAccountResponse, authFileMap); @@ -108,14 +124,16 @@ describe('extractCliproxyUsageHistoryDetails with accountMap', () => { const bobDetails = details.filter((d) => d.accountId === 'bob@example.com'); expect(aliceDetails).toHaveLength(2); // auth_index 0 appears twice - expect(bobDetails).toHaveLength(1); // auth_index 1 appears once + expect(bobDetails).toHaveLength(1); // auth_index 1 appears once }); it('leaves accountId undefined when auth_index is not in accountMap (no source fallback)', async () => { // Fix #7/#13/#15: detail.source is a CLIProxy source label, not an email. // Using it as a cost key caused mis-attribution. When auth_index is absent from the // map, accountId must be undefined so getTodayCostByAccount buckets under 'unknown'. - const { extractCliproxyUsageHistoryDetails } = await import('../../../../src/web-server/usage/cliproxy-usage-transformer'); + const { extractCliproxyUsageHistoryDetails } = await import( + '../../../../src/web-server/usage/cliproxy-usage-transformer' + ); // Use string key matching buildAuthIndexToAccountMap's String(auth_index) output const partialMap: Map = new Map([['0', 'alice@example.com']]); @@ -133,7 +151,9 @@ describe('extractCliproxyUsageHistoryDetails with accountMap', () => { }); it('does not include accountId when no accountMap is provided (backward compat)', async () => { - const { extractCliproxyUsageHistoryDetails } = await import('../../../../src/web-server/usage/cliproxy-usage-transformer'); + const { extractCliproxyUsageHistoryDetails } = await import( + '../../../../src/web-server/usage/cliproxy-usage-transformer' + ); const details = extractCliproxyUsageHistoryDetails(twoAccountResponse); @@ -143,7 +163,9 @@ describe('extractCliproxyUsageHistoryDetails with accountMap', () => { }); it('does not expose source or auth_index on returned history details', async () => { - const { extractCliproxyUsageHistoryDetails } = await import('../../../../src/web-server/usage/cliproxy-usage-transformer'); + const { extractCliproxyUsageHistoryDetails } = await import( + '../../../../src/web-server/usage/cliproxy-usage-transformer' + ); const details = extractCliproxyUsageHistoryDetails(twoAccountResponse, authFileMap); @@ -160,7 +182,9 @@ describe('extractCliproxyUsageHistoryDetails with accountMap', () => { describe('CliproxyUsageHistoryDetail type', () => { it('allows accountId as optional string field', async () => { - const { normalizeCliproxyUsageHistoryDetail } = await import('../../../../src/web-server/usage/cliproxy-usage-transformer'); + const { normalizeCliproxyUsageHistoryDetail } = await import( + '../../../../src/web-server/usage/cliproxy-usage-transformer' + ); const withAccount = normalizeCliproxyUsageHistoryDetail({ model: 'claude-sonnet-4-5', @@ -179,7 +203,9 @@ describe('CliproxyUsageHistoryDetail type', () => { }); it('normalizes detail without accountId (remains undefined)', async () => { - const { normalizeCliproxyUsageHistoryDetail } = await import('../../../../src/web-server/usage/cliproxy-usage-transformer'); + const { normalizeCliproxyUsageHistoryDetail } = await import( + '../../../../src/web-server/usage/cliproxy-usage-transformer' + ); const noAccount = normalizeCliproxyUsageHistoryDetail({ model: 'claude-sonnet-4-5', @@ -203,12 +229,16 @@ describe('CliproxyUsageHistoryDetail type', () => { describe('getTodayCostByAccount', () => { it('returns per-account cost totals for today', async () => { - const { getTodayCostByAccount } = await import('../../../../src/web-server/usage/data-aggregator'); + const { getTodayCostByAccount } = await import( + '../../../../src/web-server/usage/data-aggregator' + ); const details = []; // Simulate alice's two requests today - const { extractCliproxyUsageHistoryDetails } = await import('../../../../src/web-server/usage/cliproxy-usage-transformer'); + const { extractCliproxyUsageHistoryDetails } = await import( + '../../../../src/web-server/usage/cliproxy-usage-transformer' + ); const todayDetails = extractCliproxyUsageHistoryDetails(twoAccountResponse, authFileMap); const result = getTodayCostByAccount(todayDetails, TODAY); @@ -225,7 +255,9 @@ describe('getTodayCostByAccount', () => { }); it('returns empty object when no details exist for today', async () => { - const { getTodayCostByAccount } = await import('../../../../src/web-server/usage/data-aggregator'); + const { getTodayCostByAccount } = await import( + '../../../../src/web-server/usage/data-aggregator' + ); const result = getTodayCostByAccount([], TODAY); @@ -233,8 +265,12 @@ describe('getTodayCostByAccount', () => { }); it('filters out details from days other than today', async () => { - const { getTodayCostByAccount } = await import('../../../../src/web-server/usage/data-aggregator'); - const { extractCliproxyUsageHistoryDetails } = await import('../../../../src/web-server/usage/cliproxy-usage-transformer'); + const { getTodayCostByAccount } = await import( + '../../../../src/web-server/usage/data-aggregator' + ); + const { extractCliproxyUsageHistoryDetails } = await import( + '../../../../src/web-server/usage/cliproxy-usage-transformer' + ); const yesterdayResponse = makeResponse([ { @@ -254,8 +290,12 @@ describe('getTodayCostByAccount', () => { }); it('accumulates costs across multiple details for the same account', async () => { - const { getTodayCostByAccount } = await import('../../../../src/web-server/usage/data-aggregator'); - const { extractCliproxyUsageHistoryDetails } = await import('../../../../src/web-server/usage/cliproxy-usage-transformer'); + const { getTodayCostByAccount } = await import( + '../../../../src/web-server/usage/data-aggregator' + ); + const { extractCliproxyUsageHistoryDetails } = await import( + '../../../../src/web-server/usage/cliproxy-usage-transformer' + ); const details = extractCliproxyUsageHistoryDetails(twoAccountResponse, authFileMap); @@ -271,8 +311,12 @@ describe('getTodayCostByAccount', () => { it('details without accountId are grouped under the "unknown" key', async () => { // Fix #7/#13/#15: when no accountMap is provided, accountId is undefined on all details. // getTodayCostByAccount buckets these under 'unknown' — not under detail.source. - const { getTodayCostByAccount } = await import('../../../../src/web-server/usage/data-aggregator'); - const { extractCliproxyUsageHistoryDetails } = await import('../../../../src/web-server/usage/cliproxy-usage-transformer'); + const { getTodayCostByAccount } = await import( + '../../../../src/web-server/usage/data-aggregator' + ); + const { extractCliproxyUsageHistoryDetails } = await import( + '../../../../src/web-server/usage/cliproxy-usage-transformer' + ); // no accountMap — accountId will be undefined on all details const details = extractCliproxyUsageHistoryDetails(twoAccountResponse); @@ -293,7 +337,9 @@ describe('getTodayCostByAccount', () => { describe('backward compatibility: profile-based aggregation unaffected', () => { it('transformCliproxyToDailyUsage works without accountMap', async () => { - const { transformCliproxyToDailyUsage } = await import('../../../../src/web-server/usage/cliproxy-usage-transformer'); + const { transformCliproxyToDailyUsage } = await import( + '../../../../src/web-server/usage/cliproxy-usage-transformer' + ); const daily = transformCliproxyToDailyUsage(twoAccountResponse); @@ -303,7 +349,8 @@ describe('backward compatibility: profile-based aggregation unaffected', () => { }); it('buildCliproxyUsageHistoryAggregates preserves existing shape', async () => { - const { buildCliproxyUsageHistoryAggregates, extractCliproxyUsageHistoryDetails } = await import('../../../../src/web-server/usage/cliproxy-usage-transformer'); + const { buildCliproxyUsageHistoryAggregates, extractCliproxyUsageHistoryDetails } = + await import('../../../../src/web-server/usage/cliproxy-usage-transformer'); const details = extractCliproxyUsageHistoryDetails(twoAccountResponse); const { daily, hourly, monthly } = buildCliproxyUsageHistoryAggregates(details); @@ -338,7 +385,9 @@ describe('backward compatibility: profile-based aggregation unaffected', () => { describe('buildAuthIndexToAccountMap', () => { it('builds map from auth files with auth_index and email', async () => { - const { buildAuthIndexToAccountMap } = await import('../../../../src/cliproxy/services/stats-fetcher'); + const { buildAuthIndexToAccountMap } = await import( + '../../../../src/cliproxy/services/stats-fetcher' + ); const authFiles: CliproxyManagementAuthFile[] = [ { auth_index: 0, provider: 'anthropic', email: 'alice@example.com' }, @@ -354,7 +403,9 @@ describe('buildAuthIndexToAccountMap', () => { }); it('skips entries missing auth_index', async () => { - const { buildAuthIndexToAccountMap } = await import('../../../../src/cliproxy/services/stats-fetcher'); + const { buildAuthIndexToAccountMap } = await import( + '../../../../src/cliproxy/services/stats-fetcher' + ); const authFiles: CliproxyManagementAuthFile[] = [ { provider: 'anthropic', email: 'nobody@example.com' }, // no auth_index @@ -368,7 +419,9 @@ describe('buildAuthIndexToAccountMap', () => { }); it('skips entries missing email', async () => { - const { buildAuthIndexToAccountMap } = await import('../../../../src/cliproxy/services/stats-fetcher'); + const { buildAuthIndexToAccountMap } = await import( + '../../../../src/cliproxy/services/stats-fetcher' + ); const authFiles: CliproxyManagementAuthFile[] = [ { auth_index: 4, provider: 'anthropic' }, // no email @@ -382,7 +435,9 @@ describe('buildAuthIndexToAccountMap', () => { }); it('returns empty map for empty auth files array', async () => { - const { buildAuthIndexToAccountMap } = await import('../../../../src/cliproxy/services/stats-fetcher'); + const { buildAuthIndexToAccountMap } = await import( + '../../../../src/cliproxy/services/stats-fetcher' + ); const map = buildAuthIndexToAccountMap([]); @@ -390,7 +445,9 @@ describe('buildAuthIndexToAccountMap', () => { }); it('handles numeric and string auth_index keys consistently', async () => { - const { buildAuthIndexToAccountMap } = await import('../../../../src/cliproxy/services/stats-fetcher'); + const { buildAuthIndexToAccountMap } = await import( + '../../../../src/cliproxy/services/stats-fetcher' + ); const authFiles: CliproxyManagementAuthFile[] = [ { auth_index: 7, provider: 'anthropic', email: 'alice@example.com' }, diff --git a/ui/src/components/profiles/ccs-bar-banner.tsx b/ui/src/components/profiles/ccs-bar-banner.tsx new file mode 100644 index 00000000..d8fb9f3f --- /dev/null +++ b/ui/src/components/profiles/ccs-bar-banner.tsx @@ -0,0 +1,101 @@ +/** + * CCS Bar Feature Banner + * Dismissible announcement banner promoting the native macOS menu-bar app. + * + * Rendered only on macOS: the CTA is an install action (`ccs bar install`) that + * has no effect on other platforms, so showing it elsewhere would be misleading. + */ + +/* eslint-disable react-hooks/set-state-in-effect */ +import { useState, useEffect } from 'react'; +import { useTranslation } from 'react-i18next'; +import { X, MonitorDot, ExternalLink } from 'lucide-react'; +import { Button } from '@/components/ui/button'; + +const BANNER_DISMISSED_KEY = 'ccs:ccs-bar-banner-dismissed'; + +// User-facing docs page for CCS Bar (flat Markdown in the ccs/cli docs tree). +const CCS_BAR_DOCS_URL = 'https://github.com/kaitranntt/ccs/blob/main/docs/ccs-bar.md'; + +// Lightweight, dependency-free macOS detection. Kept inline because no other +// component needs platform detection; a shared hook would be premature. +const isMacOS = + typeof navigator !== 'undefined' && + /Mac|iPhone|iPad/i.test(navigator.userAgent || navigator.platform || ''); + +interface CcsBarBannerProps { + onInstallClick?: () => void; +} + +export function CcsBarBanner({ onInstallClick }: CcsBarBannerProps) { + const { t } = useTranslation(); + const [dismissed, setDismissed] = useState(true); // Start hidden to avoid flash + + // Check localStorage on mount + useEffect(() => { + const isDismissed = localStorage.getItem(BANNER_DISMISSED_KEY) === 'true'; + setDismissed(isDismissed); + }, []); + + const handleDismiss = () => { + localStorage.setItem(BANNER_DISMISSED_KEY, 'true'); + setDismissed(true); + }; + + if (!isMacOS) return null; + if (dismissed) return null; + + return ( +
+
+
+
+ +
+
+

+ {t('ccsBarBanner.new')}: {t('ccsBarBanner.title')} +

+

+ {t('ccsBarBanner.description')}{' '} + + ccs bar install + +

+
+
+ +
+ {onInstallClick && ( + + )} + + Learn more + + + +
+
+
+ ); +} diff --git a/ui/src/components/profiles/ccs-bar-promo-card.tsx b/ui/src/components/profiles/ccs-bar-promo-card.tsx new file mode 100644 index 00000000..70869390 --- /dev/null +++ b/ui/src/components/profiles/ccs-bar-promo-card.tsx @@ -0,0 +1,53 @@ +/** + * CCS Bar Promo Card + * Permanent promotional card for the native macOS menu-bar app, shown in the + * providers sidebar footer. + * + * Rendered only on macOS: the install CTA has no effect elsewhere. + */ + +import { useTranslation } from 'react-i18next'; +import { Button } from '@/components/ui/button'; +import { MonitorDot } from 'lucide-react'; + +// Lightweight, dependency-free macOS detection (see ccs-bar-banner.tsx). +const isMacOS = + typeof navigator !== 'undefined' && + /Mac|iPhone|iPad/i.test(navigator.userAgent || navigator.platform || ''); + +interface CcsBarPromoCardProps { + onInstallClick: () => void; +} + +export function CcsBarPromoCard({ onInstallClick }: CcsBarPromoCardProps) { + const { t } = useTranslation(); + + if (!isMacOS) return null; + + return ( +
+
+
+ +
+
+

+ {t('ccsBarPromo.title')} +

+

+ {t('ccsBarPromo.description')} +

+
+ +
+
+ ); +} diff --git a/ui/src/components/profiles/index.ts b/ui/src/components/profiles/index.ts index d3724660..6a05ba14 100644 --- a/ui/src/components/profiles/index.ts +++ b/ui/src/components/profiles/index.ts @@ -20,5 +20,10 @@ export { OpenRouterModelPicker } from './openrouter-model-picker'; export { OpenRouterPromoCard } from './openrouter-promo-card'; export { OpenRouterQuickStart } from './openrouter-quick-start'; export { AlibabaCodingPlanPromoCard } from './alibaba-coding-plan-promo-card'; + +// CCS Bar (native macOS menu-bar app) promo components +export { CcsBarBanner } from './ccs-bar-banner'; +export { CcsBarPromoCard } from './ccs-bar-promo-card'; + export { ModelTierMapping } from './model-tier-mapping'; export type { TierMapping } from './model-tier-mapping'; diff --git a/ui/src/lib/i18n.ts b/ui/src/lib/i18n.ts index 431281ee..343a6542 100644 --- a/ui/src/lib/i18n.ts +++ b/ui/src/lib/i18n.ts @@ -2475,6 +2475,17 @@ const resources = { title: 'OpenRouter', description: 'Access hundreds of models from one API endpoint.', }, + ccsBarBanner: { + new: 'NEW', + title: 'CCS Bar for macOS', + description: 'See live subscription quota and usage from your menu bar. Install with', + install: 'Install', + }, + ccsBarPromo: { + title: 'CCS Bar (macOS)', + description: 'Live quota and usage in your menu bar.', + install: 'Install', + }, profileCard: { profile: 'Profile', openRouter: 'OpenRouter profile', diff --git a/ui/src/pages/api.tsx b/ui/src/pages/api.tsx index 532126ad..0f44ff7c 100644 --- a/ui/src/pages/api.tsx +++ b/ui/src/pages/api.tsx @@ -22,6 +22,8 @@ import { OpenRouterBanner } from '@/components/profiles/openrouter-banner'; import { OpenRouterQuickStart } from '@/components/profiles/openrouter-quick-start'; import { OpenRouterPromoCard } from '@/components/profiles/openrouter-promo-card'; import { AlibabaCodingPlanPromoCard } from '@/components/profiles/alibaba-coding-plan-promo-card'; +import { CcsBarBanner } from '@/components/profiles/ccs-bar-banner'; +import { CcsBarPromoCard } from '@/components/profiles/ccs-bar-promo-card'; import { useProfiles, useDeleteProfile, @@ -41,6 +43,15 @@ import { useTranslation } from 'react-i18next'; import { toast } from 'sonner'; import { useNavigate } from 'react-router-dom'; +// CCS Bar is installed via the `ccs bar install` CLI command, not from the +// dashboard. The promo CTA therefore opens the user-facing docs page where the +// install/launch steps live, rather than triggering an in-app action. +const CCS_BAR_DOCS_URL = 'https://github.com/kaitranntt/ccs/blob/main/docs/ccs-bar.md'; + +function openCcsBarDocs() { + window.open(CCS_BAR_DOCS_URL, '_blank', 'noopener,noreferrer'); +} + export function ApiPage() { const { t } = useTranslation(); const navigate = useNavigate(); @@ -213,6 +224,7 @@ export function ApiPage() { return (
setCreateDialogOpen(true)} /> + openCcsBarDocs()} />
@@ -363,6 +375,7 @@ export function ApiPage() { setCreateDialogOpen(true); }} /> + openCcsBarDocs()} />