diff --git a/src/utils/__tests__/usage-fetch.test.ts b/src/utils/__tests__/usage-fetch.test.ts index f66bea14..22b1794e 100644 --- a/src/utils/__tests__/usage-fetch.test.ts +++ b/src/utils/__tests__/usage-fetch.test.ts @@ -182,7 +182,9 @@ process.stdout.write(JSON.stringify({ fs.mkdirSync(bin, { recursive: true }); fs.mkdirSync(claudeConfig, { recursive: true }); - fs.writeFileSync(securityScript, '#!/bin/sh\necho \'{"claudeAiOauth":{"accessToken":"test-token"}}\'\n'); + // Keep the macOS Keychain stub in sync with credential changes made by + // the tests, so every platform exercises the same login and tokens. + fs.writeFileSync(securityScript, '#!/bin/sh\n/bin/cat "$CLAUDE_CONFIG_DIR/.credentials.json"\n'); fs.chmodSync(securityScript, 0o755); fs.writeFileSync(credentialsFile, JSON.stringify({ claudeAiOauth: { accessToken: 'test-token' } })); @@ -196,7 +198,9 @@ process.stdout.write(JSON.stringify({ function runProbe(options: ProbeOptions): UsageProbeResult { const env = Object.fromEntries(Object.entries(process.env).filter(([key]) => { const normalizedKey = key.toUpperCase(); - return normalizedKey !== 'CLAUDE_CONFIG_DIR' && normalizedKey !== 'HTTPS_PROXY'; + return normalizedKey !== 'CLAUDE_CONFIG_DIR' + && normalizedKey !== 'CLAUDE_SECURESTORAGE_CONFIG_DIR' + && normalizedKey !== 'HTTPS_PROXY'; })); Object.assign(env, { @@ -256,6 +260,20 @@ function parseLockContents(lockContents: string | null): { blockedUntil: number; return lockContents ? JSON.parse(lockContents) as { blockedUntil: number; error?: string } : null; } +// createTokenHome writes an access-token-only credentials file; these tests +// need a refresh token alongside it to exercise the account fingerprint. +function writeUsageCredentials(claudeConfig: string, claudeAiOauth: { accessToken: string; refreshToken?: string }): void { + fs.writeFileSync(path.join(claudeConfig, '.credentials.json'), JSON.stringify({ claudeAiOauth })); +} + +function seedUsageCache(home: string, contents: Record): { cacheFile: string; mtimeMs: number } { + const cacheDir = path.join(home, '.cache', 'ccstatusline'); + fs.mkdirSync(cacheDir, { recursive: true }); + const cacheFile = path.join(cacheDir, 'usage.json'); + fs.writeFileSync(cacheFile, JSON.stringify(contents)); + return { cacheFile, mtimeMs: fs.statSync(cacheFile).mtimeMs }; +} + describe('fetchUsageData error handling', () => { const nowMs = 2200000000000; const successResponseBody = JSON.stringify({ @@ -1102,6 +1120,250 @@ describe('fetchUsageData error handling', () => { } }); + it.each([ + ['fresh', 5000], + ['stale', 200000] + ])('serves a %s legacy cache during backoff without rewriting the cache or lock', (_state, cacheAgeMs) => { + const harness = createProbeHarness(); + + try { + const home = harness.createTokenHome('legacy-cache-backoff'); + writeUsageCredentials(home.claudeConfig, { + accessToken: 'current-access-token', + refreshToken: 'current-refresh-token' + }); + const legacyHash = createHash('sha256').update('current-access-token').digest('hex').slice(0, 16); + const { cacheFile, mtimeMs } = seedUsageCache(home.home, { sessionUsage: 5, tokenHash: legacyHash }); + const cacheContents = fs.readFileSync(cacheFile, 'utf8'); + const probeNowMs = mtimeMs + cacheAgeMs; + const lockFile = path.join(home.home, '.cache', 'ccstatusline', 'usage.lock'); + const lockContents = JSON.stringify({ + blockedUntil: Math.floor(probeNowMs / 1000) + 3600, + error: 'rate-limited' + }); + fs.writeFileSync(lockFile, lockContents); + const lockMtimeMs = fs.statSync(lockFile).mtimeMs; + + const result = harness.runProbe({ + claudeConfigDir: home.claudeConfig, + home: home.home, + mode: 'unexpected', + nowMs: probeNowMs, + pathDir: home.bin, + requiredFields: ['sessionUsage'] + }); + + expect(result.first).toEqual({ sessionUsage: 5 }); + expect(result.second).toEqual(result.first); + expect(result.requestCount).toBe(0); + expect(result.lockContents).toBe(lockContents); + expect(fs.statSync(lockFile).mtimeMs).toBe(lockMtimeMs); + expect(fs.readFileSync(cacheFile, 'utf8')).toBe(cacheContents); + expect(fs.statSync(cacheFile).mtimeMs).toBe(mtimeMs); + } finally { + harness.cleanup(); + } + }); + + it.each(['other-access-token', 'other-refresh-token', undefined])('rejects a cache fingerprint from %s when both current tokens are available', (cachedToken) => { + const harness = createProbeHarness(); + + try { + const home = harness.createTokenHome('unrelated-cache-backoff'); + writeUsageCredentials(home.claudeConfig, { + accessToken: 'current-access-token', + refreshToken: 'current-refresh-token' + }); + const tokenHash = cachedToken === undefined + ? undefined + : createHash('sha256').update(cachedToken).digest('hex').slice(0, 16); + const { mtimeMs } = seedUsageCache(home.home, { sessionUsage: 5, tokenHash }); + const probeNowMs = mtimeMs + 5000; + fs.writeFileSync(path.join(home.home, '.cache', 'ccstatusline', 'usage.lock'), JSON.stringify({ + blockedUntil: Math.floor(probeNowMs / 1000) + 3600, + error: 'rate-limited' + })); + + const result = harness.runProbe({ + claudeConfigDir: home.claudeConfig, + home: home.home, + mode: 'unexpected', + nowMs: probeNowMs, + pathDir: home.bin, + requiredFields: ['sessionUsage'] + }); + + expect(result.first).toEqual({ error: 'rate-limited' }); + expect(result.second).toEqual(result.first); + expect(result.requestCount).toBe(0); + } finally { + harness.cleanup(); + } + }); + + it.each(['current-refresh-token', ''])('writes the preferred fingerprint after a successful fetch with refresh token %j', (refreshToken) => { + const harness = createProbeHarness(); + + try { + const home = harness.createTokenHome('legacy-cache-migration'); + writeUsageCredentials(home.claudeConfig, { accessToken: 'current-access-token', refreshToken }); + const legacyHash = createHash('sha256').update('current-access-token').digest('hex').slice(0, 16); + const { cacheFile, mtimeMs } = seedUsageCache(home.home, { sessionUsage: 5, tokenHash: legacyHash }); + + const result = harness.runProbe({ + claudeConfigDir: home.claudeConfig, + home: home.home, + mode: 'success', + nowMs: mtimeMs + 200000, + pathDir: home.bin, + requiredFields: ['sessionUsage'], + responseBody: successResponseBody + }); + + expect(result.first.sessionUsage).toBe(42); + expect(result.requestCount).toBe(1); + const cache = JSON.parse(fs.readFileSync(cacheFile, 'utf8')) as Record; + const expectedToken = refreshToken || 'current-access-token'; + expect(cache.tokenHash).toBe(createHash('sha256').update(expectedToken).digest('hex').slice(0, 16)); + } finally { + harness.cleanup(); + } + }); + + it.each([ + ['current-access-token', { sessionUsage: 5 }], + ['', { error: 'rate-limited' }] + ])('uses only the access-token fingerprint when the refresh token is empty (cached token %j)', (cachedToken, expected) => { + const harness = createProbeHarness(); + + try { + const home = harness.createTokenHome('empty-refresh-token'); + writeUsageCredentials(home.claudeConfig, { accessToken: 'current-access-token', refreshToken: '' }); + const tokenHash = createHash('sha256').update(cachedToken).digest('hex').slice(0, 16); + const { mtimeMs } = seedUsageCache(home.home, { sessionUsage: 5, tokenHash }); + const probeNowMs = mtimeMs + 5000; + fs.writeFileSync(path.join(home.home, '.cache', 'ccstatusline', 'usage.lock'), JSON.stringify({ + blockedUntil: Math.floor(probeNowMs / 1000) + 3600, + error: 'rate-limited' + })); + + const result = harness.runProbe({ + claudeConfigDir: home.claudeConfig, + home: home.home, + mode: 'unexpected', + nowMs: probeNowMs, + pathDir: home.bin, + requiredFields: ['sessionUsage'] + }); + + expect(result.first).toEqual(expected); + expect(result.second).toEqual(result.first); + expect(result.requestCount).toBe(0); + } finally { + harness.cleanup(); + } + }); + + it('serves a fresh cache after the access token was refreshed (same login)', () => { + const harness = createProbeHarness(); + + try { + const home = harness.createTokenHome('access-token-refreshed'); + // The access token was reissued since the cache was written; the + // refresh token, which identifies the login, is unchanged. + writeUsageCredentials(home.claudeConfig, { + accessToken: 'reissued-access-token', + refreshToken: 'stable-refresh-token' + }); + const fingerprint = createHash('sha256').update('stable-refresh-token').digest('hex').slice(0, 16); + const { mtimeMs } = seedUsageCache(home.home, { sessionUsage: 5, tokenHash: fingerprint }); + + const result = harness.runProbe({ + claudeConfigDir: home.claudeConfig, + home: home.home, + mode: 'unexpected', + nowMs: mtimeMs + 5000, + pathDir: home.bin, + requiredFields: ['sessionUsage'] + }); + + // A refresh must not look like an account switch: the cache stands + // and no request is made. + expect(result.requestCount).toBe(0); + expect(result.first.sessionUsage).toBe(5); + } finally { + harness.cleanup(); + } + }); + + it('serves a stale cache through a rate-limit backoff after the access token was refreshed', () => { + const harness = createProbeHarness(); + + try { + const home = harness.createTokenHome('access-token-refreshed-rate-limit'); + writeUsageCredentials(home.claudeConfig, { + accessToken: 'reissued-access-token', + refreshToken: 'stable-refresh-token' + }); + const fingerprint = createHash('sha256').update('stable-refresh-token').digest('hex').slice(0, 16); + const { mtimeMs } = seedUsageCache(home.home, { sessionUsage: 5, tokenHash: fingerprint }); + + // Cache is past CACHE_MAX_AGE and a server-issued backoff is active, + // so the stale-cache fallback is the only thing standing between the + // widgets and error text for the rest of the window. + const backedOffNowMs = mtimeMs + 200000; + fs.writeFileSync(path.join(home.home, '.cache', 'ccstatusline', 'usage.lock'), JSON.stringify({ + blockedUntil: Math.floor(backedOffNowMs / 1000) + 3600, + error: 'rate-limited' + })); + + const result = harness.runProbe({ + claudeConfigDir: home.claudeConfig, + home: home.home, + mode: 'unexpected', + nowMs: backedOffNowMs, + pathDir: home.bin, + requiredFields: ['sessionUsage'] + }); + + expect(result.first).toEqual({ sessionUsage: 5 }); + expect(result.second).toEqual(result.first); + expect(result.requestCount).toBe(0); + } finally { + harness.cleanup(); + } + }); + + it('refetches when the refresh token changes (account switch)', () => { + const harness = createProbeHarness(); + + try { + const home = harness.createTokenHome('refresh-token-switched'); + writeUsageCredentials(home.claudeConfig, { + accessToken: 'other-account-access-token', + refreshToken: 'other-account-refresh-token' + }); + const previousFingerprint = createHash('sha256').update('previous-refresh-token').digest('hex').slice(0, 16); + const { mtimeMs } = seedUsageCache(home.home, { sessionUsage: 5, tokenHash: previousFingerprint }); + + const result = harness.runProbe({ + claudeConfigDir: home.claudeConfig, + home: home.home, + mode: 'success', + nowMs: mtimeMs + 5000, + pathDir: home.bin, + requiredFields: ['sessionUsage'], + responseBody: successResponseBody + }); + + // A different login still invalidates the cache immediately. + expect(result.requestCount).toBe(1); + expect(result.first.sessionUsage).toBe(42); + } finally { + harness.cleanup(); + } + }); + it('treats enabled extra usage without a monthly limit as complete for extra usage widget fields', () => { const harness = createProbeHarness(); diff --git a/src/utils/__tests__/usage-token.test.ts b/src/utils/__tests__/usage-token.test.ts index 9421f8d1..38fabdaa 100644 --- a/src/utils/__tests__/usage-token.test.ts +++ b/src/utils/__tests__/usage-token.test.ts @@ -15,6 +15,7 @@ import { import * as claudeSettings from '../claude-settings'; import { getMacKeychainConfigDirService, + getUsageCredentials, getUsageToken, parseMacKeychainCredentialCandidates } from '../usage-fetch'; @@ -52,8 +53,8 @@ function makeConfigDirService(configDir: string): string { return `Claude Code-credentials-${createHash('sha256').update(configDir).digest('hex').slice(0, 8)}`; } -function makeTokenPayload(token: string): string { - return JSON.stringify({ claudeAiOauth: { accessToken: token } }); +function makeTokenPayload(token: string, refreshToken?: string): string { + return JSON.stringify({ claudeAiOauth: { accessToken: token, refreshToken } }); } function encodeAsciiAsHex(value: string): string { @@ -258,14 +259,19 @@ describe('getUsageToken', () => { mockCredentialsFile(); mockedExecFileSync.mockImplementation((command: string, args?: string[]) => { if (command === 'security' && args?.[0] === 'find-generic-password' && args[2] === configDirService) { - return makeTokenPayload('profile-token'); + return makeTokenPayload('profile-token', 'profile-refresh-token'); } throw new Error(`Unexpected security args: ${args?.join(' ')}`); }); expect(getUsageToken()).toBe('profile-token'); + expect(getUsageCredentials()).toEqual({ + accessToken: 'profile-token', + refreshToken: 'profile-refresh-token' + }); expect(getSecurityCallLog()).toEqual([ + `find-generic-password -s ${configDirService} -w`, `find-generic-password -s ${configDirService} -w` ]); }); @@ -275,7 +281,7 @@ describe('getUsageToken', () => { process.env.CLAUDE_CONFIG_DIR = '/fake/claude'; vi.spyOn(process, 'platform', 'get').mockReturnValue('darwin'); - mockCredentialsFile(makeTokenPayload('file-token')); + mockCredentialsFile(makeTokenPayload('file-token', 'file-refresh-token')); mockedExecFileSync.mockImplementation((command: string, args?: string[]) => { if (command === 'security' && args?.[0] === 'find-generic-password' && args[2] === configDirService) { throw new Error('missing profile credential'); @@ -285,7 +291,12 @@ describe('getUsageToken', () => { }); expect(getUsageToken()).toBe('file-token'); + expect(getUsageCredentials()).toEqual({ + accessToken: 'file-token', + refreshToken: 'file-refresh-token' + }); expect(getSecurityCallLog()).toEqual([ + `find-generic-password -s ${configDirService} -w`, `find-generic-password -s ${configDirService} -w` ]); }); diff --git a/src/utils/usage-fetch.ts b/src/utils/usage-fetch.ts index 7e1e6f0f..964ba8be 100644 --- a/src/utils/usage-fetch.ts +++ b/src/utils/usage-fetch.ts @@ -38,6 +38,18 @@ const MACOS_SECURITY_DUMP_MAX_BUFFER = 8 * 1024 * 1024; export interface FetchUsageDataOptions { requiredFields?: readonly UsageDataField[] } +// The access token is what the API is called with; the refresh token is kept +// alongside it only to fingerprint the account (see getUsageCacheIdentity). +interface UsageCredentials { + accessToken: string; + refreshToken?: string; +} + +interface UsageCacheIdentity { + preferredHash: string; + accessTokenHash: string; +} + const EXTRA_USAGE_DETAIL_FIELDS = new Set([ 'extraUsageLimit', 'extraUsageUsed', @@ -62,7 +74,12 @@ const WINDOW_RESET_FIELD_SENTINELS: Partial [bucket.resetField, bucket.usageField])) }; -const UsageCredentialsSchema = z.object({ claudeAiOauth: z.object({ accessToken: z.string().nullable().optional() }).optional() }); +const UsageCredentialsSchema = z.object({ + claudeAiOauth: z.object({ + accessToken: z.string().nullable().optional(), + refreshToken: z.string().nullable().optional() + }).optional() +}); const UsageLockErrorSchema = z.enum(['timeout', 'rate-limited', 'parse-error']); const UsageLockSchema = z.object({ blockedUntil: z.number(), @@ -198,9 +215,16 @@ function parseJsonWithSchema(rawJson: string, schema: z.ZodType): T | null } } -function parseUsageAccessToken(rawJson: string): string | null { - const parsed = parseJsonWithSchema(rawJson, UsageCredentialsSchema); - return parsed?.claudeAiOauth?.accessToken ?? null; +function parseUsageCredentials(rawJson: string): UsageCredentials | null { + const oauth = parseJsonWithSchema(rawJson, UsageCredentialsSchema)?.claudeAiOauth; + if (!oauth?.accessToken) { + return null; + } + + return { + accessToken: oauth.accessToken, + refreshToken: oauth.refreshToken ?? undefined + }; } function parseCachedUsageData(rawJson: string): UsageData | null { @@ -231,25 +255,37 @@ function parseCachedUsageData(rawJson: string): UsageData | null { }; } -// One-way fingerprint of the usage token, persisted alongside the cache so a -// login switch (e.g. enterprise<->personal, a different token) invalidates the -// cache immediately instead of waiting out the TTL. A truncated SHA-256 is a -// stable identifier, not the token itself, so it is safe to write to disk. +// One-way fingerprint of the usage credentials, persisted alongside the cache +// so a login switch (e.g. enterprise<->personal, a different account) +// invalidates the cache immediately instead of waiting out the TTL. A +// truncated SHA-256 is a stable identifier, not the token itself, so it is +// safe to write to disk. function fingerprintUsageToken(token: string): string { return createHash('sha256').update(token).digest('hex').slice(0, 16); } +// Prefer the refresh token so access-token rotation preserves the cache when +// the refresh token is unchanged. Also accept the current access-token hash +// from older caches; the next successful fetch writes the preferred hash. +function getUsageCacheIdentity(credentials: UsageCredentials): UsageCacheIdentity { + const accessTokenHash = fingerprintUsageToken(credentials.accessToken); + return { + preferredHash: credentials.refreshToken ? fingerprintUsageToken(credentials.refreshToken) : accessTokenHash, + accessTokenHash + }; +} + function readCachedTokenHash(rawJson: string): string | undefined { return parseJsonWithSchema(rawJson, CachedTokenHashSchema)?.tokenHash; } -function tokenHashMatches(cachedHash: string | undefined, currentHash: string | null): boolean { +function tokenHashMatches(cachedHash: string | undefined, identity: UsageCacheIdentity | null): boolean { // With no current token we cannot fingerprint-gate, so fall through to the // existing no-token handling rather than discarding an otherwise usable cache. - if (currentHash === null) { + if (identity === null) { return true; } - return cachedHash === currentHash; + return cachedHash === identity.preferredHash || cachedHash === identity.accessTokenHash; } // parsed is a UsageApiResponseSchema-derived looseObject: declared keys (like @@ -374,11 +410,11 @@ function hasRequiredUsageFields(data: UsageData, requiredFields: readonly UsageD function getStaleUsageOrError( error: UsageError, now: number, - currentTokenHash: string | null, + cacheIdentity: UsageCacheIdentity | null, errorCacheMaxAge = LOCK_MAX_AGE, requiredFields: readonly UsageDataField[] = [] ): UsageData { - const stale = readStaleUsageCache(currentTokenHash); + const stale = readStaleUsageCache(cacheIdentity); if (stale && !stale.error && hasRequiredUsageFields(stale, requiredFields)) { return cacheUsageData(stale, now); } @@ -487,9 +523,9 @@ function readMacKeychainSecret(service: string): string | null { } } -function readUsageTokenFromMacKeychainService(service: string): string | null { +function readUsageCredentialsFromMacKeychainService(service: string): UsageCredentials | null { const secret = readMacKeychainSecret(service); - return secret ? parseUsageAccessToken(secret) : null; + return secret ? parseUsageCredentials(secret) : null; } function listMacKeychainCredentialCandidates(): string[] { @@ -511,23 +547,23 @@ function listMacKeychainCredentialCandidates(): string[] { } } -function readUsageTokenFromMacKeychainCandidates(): string | null { +function readUsageCredentialsFromMacKeychainCandidates(): UsageCredentials | null { const candidates = listMacKeychainCredentialCandidates(); for (const service of candidates) { - const token = readUsageTokenFromMacKeychainService(service); - if (token) { - return token; + const credentials = readUsageCredentialsFromMacKeychainService(service); + if (credentials) { + return credentials; } } return null; } -function readUsageTokenFromCredentialsFile(): string | null { +function readUsageCredentialsFromCredentialsFile(): UsageCredentials | null { try { const credFile = path.join(getClaudeConfigDir(), '.credentials.json'); - return parseUsageAccessToken(fs.readFileSync(credFile, 'utf8')); + return parseUsageCredentials(fs.readFileSync(credFile, 'utf8')); } catch { return null; } @@ -552,9 +588,9 @@ export function getMacKeychainConfigDirService(): string | null { return `${MACOS_USAGE_CREDENTIALS_SERVICE}-${suffix}`; } -export function getUsageToken(): string | null { +export function getUsageCredentials(): UsageCredentials | null { if (process.platform !== 'darwin') { - return readUsageTokenFromCredentialsFile(); + return readUsageCredentialsFromCredentialsFile(); } const configDirService = getMacKeychainConfigDirService(); @@ -564,19 +600,23 @@ export function getUsageToken(): string | null { // other profiles (or MCP servers), so fall through only to the // profile's own .credentials.json rather than surface another // account's usage. - return readUsageTokenFromMacKeychainService(configDirService) - ?? readUsageTokenFromCredentialsFile(); + return readUsageCredentialsFromMacKeychainService(configDirService) + ?? readUsageCredentialsFromCredentialsFile(); } - return readUsageTokenFromMacKeychainService(MACOS_USAGE_CREDENTIALS_SERVICE) - ?? readUsageTokenFromMacKeychainCandidates() - ?? readUsageTokenFromCredentialsFile(); + return readUsageCredentialsFromMacKeychainService(MACOS_USAGE_CREDENTIALS_SERVICE) + ?? readUsageCredentialsFromMacKeychainCandidates() + ?? readUsageCredentialsFromCredentialsFile(); +} + +export function getUsageToken(): string | null { + return getUsageCredentials()?.accessToken ?? null; } -function readStaleUsageCache(currentTokenHash: string | null): UsageData | null { +function readStaleUsageCache(cacheIdentity: UsageCacheIdentity | null): UsageData | null { try { const rawCache = fs.readFileSync(CACHE_FILE, 'utf8'); - if (!tokenHashMatches(readCachedTokenHash(rawCache), currentTokenHash)) { + if (!tokenHashMatches(readCachedTokenHash(rawCache), cacheIdentity)) { return null; } return parseCachedUsageData(rawCache); @@ -767,12 +807,13 @@ export async function fetchUsageData(options: FetchUsageDataOptions = {}): Promi } } - // Resolve the token up front (before lock/rate-limit checks so auth - // failures are not masked as timeout) and fingerprint it so the file cache - // can be invalidated on an account switch: a different token, written by a + // Resolve the credentials up front (before lock/rate-limit checks so auth + // failures are not masked as timeout) and fingerprint them so the file cache + // can be invalidated on an account switch: a different login, written by a // logout/login, no longer matches the cached fingerprint. - const token = getUsageToken(); - const currentTokenHash = token ? fingerprintUsageToken(token) : null; + const credentials = getUsageCredentials(); + const token = credentials?.accessToken ?? null; + const cacheIdentity = credentials ? getUsageCacheIdentity(credentials) : null; // Check file cache try { @@ -782,7 +823,7 @@ export async function fetchUsageData(options: FetchUsageDataOptions = {}): Promi const rawCache = fs.readFileSync(CACHE_FILE, 'utf8'); const fileData = parseCachedUsageData(rawCache); if (fileData && !fileData.error - && tokenHashMatches(readCachedTokenHash(rawCache), currentTokenHash) + && tokenHashMatches(readCachedTokenHash(rawCache), cacheIdentity) && hasRequiredUsageFields(fileData, requiredFields)) { return cacheUsageData(fileData, now); } @@ -792,7 +833,7 @@ export async function fetchUsageData(options: FetchUsageDataOptions = {}): Promi } if (!token) { - return getStaleUsageOrError('no-credentials', now, currentTokenHash, LOCK_MAX_AGE, requiredFields); + return getStaleUsageOrError('no-credentials', now, cacheIdentity, LOCK_MAX_AGE, requiredFields); } const activeLock = readActiveUsageLock(now); @@ -800,7 +841,7 @@ export async function fetchUsageData(options: FetchUsageDataOptions = {}): Promi return getStaleUsageOrError( activeLock.error, now, - currentTokenHash, + cacheIdentity, Math.max(1, activeLock.blockedUntil - now), requiredFields ); @@ -814,29 +855,29 @@ export async function fetchUsageData(options: FetchUsageDataOptions = {}): Promi if (response.kind === 'rate-limited') { writeUsageLock(now + response.retryAfterSeconds, 'rate-limited'); - return getStaleUsageOrError('rate-limited', now, currentTokenHash, response.retryAfterSeconds, requiredFields); + return getStaleUsageOrError('rate-limited', now, cacheIdentity, response.retryAfterSeconds, requiredFields); } if (response.kind === 'error') { - return getStaleUsageOrError('api-error', now, currentTokenHash, LOCK_MAX_AGE, requiredFields); + return getStaleUsageOrError('api-error', now, cacheIdentity, LOCK_MAX_AGE, requiredFields); } const usageData = parseUsageApiResponse(response.body); if (!usageData) { writeUsageLock(now + LOCK_MAX_AGE, 'parse-error'); - return getStaleUsageOrError('parse-error', now, currentTokenHash, LOCK_MAX_AGE, requiredFields); + return getStaleUsageOrError('parse-error', now, cacheIdentity, LOCK_MAX_AGE, requiredFields); } // Validate we got actual data if (usageData.sessionUsage === undefined && usageData.weeklyUsage === undefined) { writeUsageLock(now + LOCK_MAX_AGE, 'parse-error'); - return getStaleUsageOrError('parse-error', now, currentTokenHash, LOCK_MAX_AGE, requiredFields); + return getStaleUsageOrError('parse-error', now, cacheIdentity, LOCK_MAX_AGE, requiredFields); } // Save to cache try { ensureCacheDirExists(); - fs.writeFileSync(CACHE_FILE, JSON.stringify({ ...usageData, tokenHash: currentTokenHash ?? undefined })); + fs.writeFileSync(CACHE_FILE, JSON.stringify({ ...usageData, tokenHash: cacheIdentity?.preferredHash })); } catch { // Ignore cache write errors } @@ -851,6 +892,6 @@ export async function fetchUsageData(options: FetchUsageDataOptions = {}): Promi return cacheUsageData(usageData, now); } catch { writeUsageLock(now + LOCK_MAX_AGE, 'parse-error'); - return getStaleUsageOrError('parse-error', now, currentTokenHash, LOCK_MAX_AGE, requiredFields); + return getStaleUsageOrError('parse-error', now, cacheIdentity, LOCK_MAX_AGE, requiredFields); } }