@cortexkit/anthropic-auth-core 1.19.0 → 1.20.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/accounts.js CHANGED
@@ -2,10 +2,10 @@ import { createHash, randomUUID } from 'node:crypto';
2
2
  import { mkdir, readFile, rename, rm, stat, writeFile } from 'node:fs/promises';
3
3
  import { homedir } from 'node:os';
4
4
  import { dirname, join } from 'node:path';
5
- import { parseRetryAfterHeader, refreshClaudeOAuthToken } from "./auth.js";
6
- import { CACHE_1H_MODES, CLAUDE_CODE_VERSION, DEFAULT_CACHE_1H_MODE, } from "./constants.js";
7
- import { log, logger } from "./logger.js";
8
- import { tokenFingerprint } from "./token-fingerprint.js";
5
+ import { parseRetryAfterHeader, refreshClaudeOAuthToken } from './auth.js';
6
+ import { CACHE_1H_MODES, CLAUDE_CODE_VERSION, DEFAULT_CACHE_1H_MODE, } from './constants.js';
7
+ import { log, logger } from './logger.js';
8
+ import { tokenFingerprint } from './token-fingerprint.js';
9
9
  const setRefreshLockRenewalTimeout = globalThis.setTimeout.bind(globalThis);
10
10
  const clearRefreshLockRenewalTimeout = globalThis.clearTimeout.bind(globalThis);
11
11
  export const ACCOUNT_FILE_NAME = 'anthropic-auth.json';
@@ -116,6 +116,9 @@ function normalizeAccount(value) {
116
116
  return {
117
117
  ...normalizeAccountBase(value),
118
118
  type: 'oauth',
119
+ authLineageId: typeof value.authLineageId === 'string' && value.authLineageId.trim()
120
+ ? value.authLineageId
121
+ : undefined,
119
122
  access: typeof value.access === 'string' ? value.access : undefined,
120
123
  refresh: value.refresh,
121
124
  expires: typeof value.expires === 'number' ? value.expires : undefined,
@@ -126,6 +129,7 @@ function normalizeAccount(value) {
126
129
  lastQuotaRefreshError: normalizeOperationError(value.lastQuotaRefreshError),
127
130
  quota: normalizeQuota(value.quota),
128
131
  profile: normalizeOAuthAccountProfile(value.profile),
132
+ prime: normalizePrimeUsageCounters(value.prime),
129
133
  };
130
134
  }
131
135
  function normalizeOAuthAccountProfile(value) {
@@ -192,6 +196,27 @@ function normalizeQuotaWindow(value) {
192
196
  resetsAt: typeof value.resetsAt === 'string' ? value.resetsAt : undefined,
193
197
  };
194
198
  }
199
+ function normalizePrimeUsageCounters(value) {
200
+ if (!isRecord(value))
201
+ return undefined;
202
+ const count = Number(value.count);
203
+ const inputTokens = Number(value.inputTokens);
204
+ const outputTokens = Number(value.outputTokens);
205
+ const since = Number(value.since);
206
+ if (![count, inputTokens, outputTokens, since].every(Number.isFinite) ||
207
+ count < 0 ||
208
+ inputTokens < 0 ||
209
+ outputTokens < 0 ||
210
+ since < 0) {
211
+ return undefined;
212
+ }
213
+ return {
214
+ count: Math.floor(count),
215
+ inputTokens: Math.floor(inputTokens),
216
+ outputTokens: Math.floor(outputTokens),
217
+ since: Math.floor(since),
218
+ };
219
+ }
195
220
  function normalizeQuota(value) {
196
221
  if (!isRecord(value))
197
222
  return undefined;
@@ -326,6 +351,35 @@ function normalizeStorage(value) {
326
351
  relay: isRecord(value.relay) ? value.relay : undefined,
327
352
  logging: isRecord(value.logging) ? value.logging : undefined,
328
353
  killswitch: isRecord(value.killswitch) ? value.killswitch : undefined,
354
+ prime: (() => {
355
+ if (!isRecord(value.prime))
356
+ return undefined;
357
+ const enabled = typeof value.prime.enabled === 'boolean'
358
+ ? value.prime.enabled
359
+ : undefined;
360
+ const main = normalizePrimeUsageCounters(value.prime.main);
361
+ const mainAuthLineageId = typeof value.prime.mainAuthLineageId === 'string' &&
362
+ value.prime.mainAuthLineageId.trim()
363
+ ? value.prime.mainAuthLineageId
364
+ : undefined;
365
+ const mainAuthLineageRefreshTokenFingerprint = typeof value.prime.mainAuthLineageRefreshTokenFingerprint ===
366
+ 'string' && value.prime.mainAuthLineageRefreshTokenFingerprint.trim()
367
+ ? value.prime.mainAuthLineageRefreshTokenFingerprint
368
+ : undefined;
369
+ if (enabled === undefined &&
370
+ !main &&
371
+ !mainAuthLineageId &&
372
+ !mainAuthLineageRefreshTokenFingerprint)
373
+ return undefined;
374
+ return {
375
+ ...(enabled !== undefined && { enabled }),
376
+ ...(main && { main }),
377
+ ...(mainAuthLineageId && { mainAuthLineageId }),
378
+ ...(mainAuthLineageRefreshTokenFingerprint && {
379
+ mainAuthLineageRefreshTokenFingerprint,
380
+ }),
381
+ };
382
+ })(),
329
383
  accounts: value.accounts
330
384
  .map(normalizeAccount)
331
385
  .filter((account) => account != null),
@@ -424,6 +478,40 @@ function mergeConfigAndState(configValue, stateValue) {
424
478
  mainQuotaToken: mainQuotaSource.quotaToken,
425
479
  mainLastQuotaApiError: mainQuotaSource.lastQuotaApiError,
426
480
  }),
481
+ // Carry the main-side prime counters from the state file back into the
482
+ // merged storage so a subsequent read sees the cumulative counters. The
483
+ // `enabled` flag stays sourced from the config side; main-side counters
484
+ // live exclusively on the state file.
485
+ prime: (() => {
486
+ const configPrime = isRecord(configValue.prime)
487
+ ? configValue.prime
488
+ : undefined;
489
+ const mainCounters = normalizePrimeUsageCounters(mainState?.prime);
490
+ const mainAuthLineageId = typeof mainState?.primeAuthLineageId === 'string' &&
491
+ mainState.primeAuthLineageId.trim()
492
+ ? mainState.primeAuthLineageId
493
+ : undefined;
494
+ const mainAuthLineageRefreshTokenFingerprint = typeof mainState?.primeAuthLineageRefreshTokenFingerprint ===
495
+ 'string' && mainState.primeAuthLineageRefreshTokenFingerprint.trim()
496
+ ? mainState.primeAuthLineageRefreshTokenFingerprint
497
+ : undefined;
498
+ if (!configPrime &&
499
+ !mainCounters &&
500
+ !mainAuthLineageId &&
501
+ !mainAuthLineageRefreshTokenFingerprint)
502
+ return undefined;
503
+ return {
504
+ ...(configPrime &&
505
+ typeof configPrime.enabled === 'boolean' && {
506
+ enabled: configPrime.enabled,
507
+ }),
508
+ ...(mainCounters && { main: mainCounters }),
509
+ ...(mainAuthLineageId && { mainAuthLineageId }),
510
+ ...(mainAuthLineageRefreshTokenFingerprint && {
511
+ mainAuthLineageRefreshTokenFingerprint,
512
+ }),
513
+ };
514
+ })(),
427
515
  accounts,
428
516
  };
429
517
  }
@@ -464,6 +552,7 @@ function accountRuntimeState(account) {
464
552
  });
465
553
  }
466
554
  return objectWithDefinedEntries({
555
+ authLineageId: account.authLineageId,
467
556
  access: account.access,
468
557
  refresh: account.refresh,
469
558
  expires: account.expires,
@@ -473,9 +562,11 @@ function accountRuntimeState(account) {
473
562
  lastQuotaRefreshError: account.lastQuotaRefreshError,
474
563
  quota: account.quota,
475
564
  profile: account.profile,
565
+ prime: account.prime,
476
566
  });
477
567
  }
478
- function quotaSnapshotCheckedAt(quota) {
568
+ /** Returns the latest timestamp carried by any quota snapshot window. */
569
+ export function quotaSnapshotCheckedAt(quota) {
479
570
  return Math.max(quota?.five_hour?.checkedAt ?? 0, quota?.seven_day?.checkedAt ?? 0, ...(quota?.scoped?.map((window) => window.checkedAt) ?? []), quota?.checkedAt ?? 0);
480
571
  }
481
572
  function quotaSourcePrecedence(quota) {
@@ -652,6 +743,15 @@ function configFromStorage(storage) {
652
743
  cacheKeep: storage.cacheKeep,
653
744
  relay: storage.relay,
654
745
  killswitch: storage.killswitch,
746
+ prime: (() => {
747
+ // Config side carries ONLY the `enabled` flag — runtime counters and
748
+ // lineage bindings stay on the state file. Write `enabled` whenever it was explicitly set so a
749
+ // toggle off persists `{ enabled: false }` and is visible to a stale
750
+ // reader that only inspects the config.
751
+ if (typeof storage.prime?.enabled !== 'boolean')
752
+ return undefined;
753
+ return { enabled: storage.prime.enabled };
754
+ })(),
655
755
  accounts: storage.accounts.map(accountConfig),
656
756
  });
657
757
  }
@@ -827,6 +927,29 @@ function applyMainRefreshStatePatch(state, storage) {
827
927
  state.main.refreshLeaseUntil = storage.refresh?.mainRefreshLeaseUntil;
828
928
  state.main.refreshLeaseTokenHash = storage.refresh?.mainRefreshLeaseTokenHash;
829
929
  }
930
+ function applyMainPrimeStatePatch(state, storage) {
931
+ const incoming = storage.prime?.main;
932
+ const incomingAuthLineageId = storage.prime?.mainAuthLineageId;
933
+ const incomingAuthLineageRefreshTokenFingerprint = storage.prime?.mainAuthLineageRefreshTokenFingerprint;
934
+ if (!incoming &&
935
+ !incomingAuthLineageId &&
936
+ !incomingAuthLineageRefreshTokenFingerprint)
937
+ return;
938
+ state.main = state.main ?? {};
939
+ // Last-writer-wins on prime counters — the only writer is the prime manager
940
+ // itself, monotonically accumulating per success, so there is no race for an
941
+ // older write to overwrite a newer one within this process. Across processes
942
+ // the cross-process claim marker (#1247) keeps the fire exclusive.
943
+ if (incoming)
944
+ state.main.prime = incoming;
945
+ if (incomingAuthLineageId) {
946
+ state.main.primeAuthLineageId = incomingAuthLineageId;
947
+ }
948
+ if (incomingAuthLineageRefreshTokenFingerprint) {
949
+ state.main.primeAuthLineageRefreshTokenFingerprint =
950
+ incomingAuthLineageRefreshTokenFingerprint;
951
+ }
952
+ }
830
953
  function pruneUndefined(value) {
831
954
  if (Array.isArray(value))
832
955
  return value.map(pruneUndefined);
@@ -941,6 +1064,8 @@ async function saveAccountStateUnlocked(storage, path, scope) {
941
1064
  applyMainQuotaStatePatch(next, storage);
942
1065
  if (scope.mainRefresh)
943
1066
  applyMainRefreshStatePatch(next, storage);
1067
+ if (scope.mainPrime)
1068
+ applyMainPrimeStatePatch(next, storage);
944
1069
  if (scope.accounts) {
945
1070
  const ids = scope.accounts === true ? null : new Set(scope.accounts);
946
1071
  next.accounts = { ...(isRecord(next.accounts) ? next.accounts : {}) };
@@ -1284,6 +1409,203 @@ export async function setCacheKeepSubagentsEnabled(enabled, path = getAccountSto
1284
1409
  await saveAccounts(storage, path);
1285
1410
  return storage;
1286
1411
  }
1412
+ export function isPrimePersistentlyEnabled(storage) {
1413
+ return storage?.prime?.enabled === true;
1414
+ }
1415
+ export async function setPrimePersistentEnabled(enabled, path = getAccountStoragePath()) {
1416
+ const storage = (await loadAccounts(path)) ?? createEmptyStorage();
1417
+ storage.prime = {
1418
+ ...(storage.prime ?? {}),
1419
+ enabled,
1420
+ };
1421
+ await saveAccounts(storage, path);
1422
+ return storage;
1423
+ }
1424
+ /**
1425
+ * Return the stable prime marker identity for an OAuth account, seeding it in
1426
+ * runtime state when older storage has no lineage yet. Main lookups return
1427
+ * undefined when the live refresh token is unavailable and no lineage exists.
1428
+ */
1429
+ export async function getOrCreatePrimeAuthLineageId(accountId, path = getAccountStoragePath(), mainRefreshToken) {
1430
+ return enqueueSave(async () => {
1431
+ const configLock = await acquireAccountConfigWriteLock(path);
1432
+ try {
1433
+ const stateLock = await acquireAccountStateWriteLock(path);
1434
+ try {
1435
+ const storage = (await loadAccounts(path)) ?? createEmptyStorage();
1436
+ if (accountId === 'main') {
1437
+ const existing = storage.prime?.mainAuthLineageId;
1438
+ const observedFingerprint = mainRefreshToken
1439
+ ? tokenFingerprint(mainRefreshToken)
1440
+ : undefined;
1441
+ const boundFingerprint = storage.prime?.mainAuthLineageRefreshTokenFingerprint;
1442
+ if (existing) {
1443
+ if (!observedFingerprint ||
1444
+ boundFingerprint === observedFingerprint)
1445
+ return existing;
1446
+ if (boundFingerprint &&
1447
+ storage.refresh?.mainRefreshLeaseUntil &&
1448
+ storage.refresh.mainRefreshLeaseUntil > Date.now()) {
1449
+ // The owner binds before publishing its rotated credential. An
1450
+ // active lease makes either side of that handoff ambiguous, so
1451
+ // suppress this tick instead of classifying it as a re-login.
1452
+ return existing;
1453
+ }
1454
+ if (!boundFingerprint) {
1455
+ // Legacy lineages predate refresh-token binding. The first
1456
+ // observation attaches the credential without changing markers.
1457
+ storage.prime = {
1458
+ ...(storage.prime ?? {}),
1459
+ mainAuthLineageRefreshTokenFingerprint: observedFingerprint,
1460
+ };
1461
+ await saveAccountStateUnlocked(storage, path, {
1462
+ mainPrime: true,
1463
+ });
1464
+ return existing;
1465
+ }
1466
+ }
1467
+ // Without the live host credential there is no safe way to bind a
1468
+ // new identity. Skip this tick rather than creating an unbound
1469
+ // namespace that could churn or suppress another account.
1470
+ if (!observedFingerprint)
1471
+ return undefined;
1472
+ // A bound mismatch that did not pass through the owned refresh path
1473
+ // is a host credential replacement and needs a fresh marker identity.
1474
+ const authLineageId = randomUUID();
1475
+ storage.prime = {
1476
+ ...(storage.prime ?? {}),
1477
+ mainAuthLineageId: authLineageId,
1478
+ ...(observedFingerprint && {
1479
+ mainAuthLineageRefreshTokenFingerprint: observedFingerprint,
1480
+ }),
1481
+ };
1482
+ await saveAccountStateUnlocked(storage, path, { mainPrime: true });
1483
+ return authLineageId;
1484
+ }
1485
+ const account = storage.accounts.find((candidate) => candidate.id === accountId && isOAuthAccount(candidate));
1486
+ if (!account) {
1487
+ throw new Error(`getOrCreatePrimeAuthLineageId: OAuth account "${accountId}" not found`);
1488
+ }
1489
+ if (account.authLineageId)
1490
+ return account.authLineageId;
1491
+ // Upgrading changes the marker namespace once, so an already-primed
1492
+ // window may fire again; persisting the seed prevents every later token
1493
+ // rotation from repeating that migration cost.
1494
+ const authLineageId = randomUUID();
1495
+ account.authLineageId = authLineageId;
1496
+ await saveAccountStateUnlocked(storage, path, {
1497
+ accounts: [accountId],
1498
+ });
1499
+ return authLineageId;
1500
+ }
1501
+ finally {
1502
+ await stateLock.release();
1503
+ }
1504
+ }
1505
+ finally {
1506
+ await configLock.release();
1507
+ }
1508
+ });
1509
+ }
1510
+ /** Advance a main lineage across a refresh performed by this plugin. */
1511
+ export async function continueMainPrimeAuthLineageAfterRefresh(input, path = getAccountStoragePath()) {
1512
+ return enqueueSave(async () => {
1513
+ const configLock = await acquireAccountConfigWriteLock(path);
1514
+ try {
1515
+ const stateLock = await acquireAccountStateWriteLock(path);
1516
+ try {
1517
+ const storage = (await loadAccounts(path)) ?? createEmptyStorage();
1518
+ const authLineageId = storage.prime?.mainAuthLineageId;
1519
+ if (!authLineageId)
1520
+ return;
1521
+ const previousFingerprint = tokenFingerprint(input.previousRefreshToken);
1522
+ const currentFingerprint = tokenFingerprint(input.currentRefreshToken);
1523
+ const boundFingerprint = storage.prime?.mainAuthLineageRefreshTokenFingerprint;
1524
+ if (boundFingerprint &&
1525
+ boundFingerprint !== previousFingerprint &&
1526
+ boundFingerprint !== currentFingerprint) {
1527
+ // Another process observed a host replacement after this refresh
1528
+ // started; the stale rotation must not rebind that newer lineage.
1529
+ return;
1530
+ }
1531
+ storage.prime = {
1532
+ ...(storage.prime ?? {}),
1533
+ mainAuthLineageRefreshTokenFingerprint: currentFingerprint,
1534
+ };
1535
+ await saveAccountStateUnlocked(storage, path, { mainPrime: true });
1536
+ }
1537
+ finally {
1538
+ await stateLock.release();
1539
+ }
1540
+ }
1541
+ finally {
1542
+ await configLock.release();
1543
+ }
1544
+ });
1545
+ }
1546
+ /**
1547
+ * Atomically increment an account's cumulative prime counters and persist via
1548
+ * the scoped runtime-state path. The `main` account lives at state.main.prime;
1549
+ * every other account lives at state.accounts[id].prime. Config-side writes
1550
+ * are intentionally NOT triggered so prime counters cannot leak into
1551
+ * `anthropic-auth.json`. Callers should not depend on this function to mutate
1552
+ * the caller's storage object.
1553
+ */
1554
+ export async function incrementPrimeUsagePersistent(accountId, usage, path = getAccountStoragePath(), now = Date.now()) {
1555
+ const inputTokens = Number.isFinite(usage?.inputTokens)
1556
+ ? Math.max(0, Math.floor(usage.inputTokens))
1557
+ : 0;
1558
+ const outputTokens = Number.isFinite(usage?.outputTokens)
1559
+ ? Math.max(0, Math.floor(usage.outputTokens))
1560
+ : 0;
1561
+ return enqueueSave(async () => {
1562
+ // The config-write lock is the repository-wide outer lock for config and
1563
+ // runtime-state RMW operations; taking it before the state write preserves
1564
+ // saveAccountsLocked's config → state ordering across processes.
1565
+ const lock = await acquireAccountConfigWriteLock(path);
1566
+ try {
1567
+ const stateLock = await acquireAccountStateWriteLock(path);
1568
+ try {
1569
+ const storage = (await loadAccounts(path)) ?? createEmptyStorage();
1570
+ if (accountId === 'main') {
1571
+ const existing = storage.prime?.main;
1572
+ const next = {
1573
+ count: (existing?.count ?? 0) + 1,
1574
+ inputTokens: (existing?.inputTokens ?? 0) + inputTokens,
1575
+ outputTokens: (existing?.outputTokens ?? 0) + outputTokens,
1576
+ since: existing?.since ?? Math.floor(now),
1577
+ };
1578
+ storage.prime = { ...(storage.prime ?? {}), main: next };
1579
+ await saveAccountStateUnlocked(storage, path, { mainPrime: true });
1580
+ return next;
1581
+ }
1582
+ const index = storage.accounts.findIndex((account) => account.id === accountId);
1583
+ if (index < 0) {
1584
+ throw new Error(`incrementPrimeUsagePersistent: account "${accountId}" not found`);
1585
+ }
1586
+ const account = storage.accounts[index];
1587
+ const existing = account.prime;
1588
+ const next = {
1589
+ count: (existing?.count ?? 0) + 1,
1590
+ inputTokens: (existing?.inputTokens ?? 0) + inputTokens,
1591
+ outputTokens: (existing?.outputTokens ?? 0) + outputTokens,
1592
+ since: existing?.since ?? Math.floor(now),
1593
+ };
1594
+ storage.accounts[index] = { ...account, prime: next };
1595
+ await saveAccountStateUnlocked(storage, path, {
1596
+ accounts: [accountId],
1597
+ });
1598
+ return next;
1599
+ }
1600
+ finally {
1601
+ await stateLock.release();
1602
+ }
1603
+ }
1604
+ finally {
1605
+ await lock.release();
1606
+ }
1607
+ });
1608
+ }
1287
1609
  function getFallbackStatuses(storage) {
1288
1610
  return storage?.fallbackOn?.length ? storage.fallbackOn : DEFAULT_FALLBACK_ON;
1289
1611
  }
@@ -1418,6 +1740,27 @@ export function formatRefreshBackoffMessage(error, now) {
1418
1740
  const seconds = Math.max(1, Math.ceil(((error.nextRetryAt ?? now) - now) / 1000));
1419
1741
  return `Claude OAuth refresh is backed off for ${seconds}s after: ${error.message}`;
1420
1742
  }
1743
+ function createRefreshBackoffActiveError(error, now) {
1744
+ return Object.assign(new Error(formatRefreshBackoffMessage(error, now)), {
1745
+ refreshBackoffError: error,
1746
+ });
1747
+ }
1748
+ function existingRefreshBackoffError(error) {
1749
+ if (!error || typeof error !== 'object')
1750
+ return undefined;
1751
+ const existing = error
1752
+ .refreshBackoffError;
1753
+ return existing && typeof existing.message === 'string' ? existing : undefined;
1754
+ }
1755
+ export function getFallbackReauthLabels(storage) {
1756
+ if (!storage)
1757
+ return [];
1758
+ return storage.accounts
1759
+ .filter((account) => account.enabled !== false &&
1760
+ isOAuthAccount(account) &&
1761
+ isPermanentRefreshError(account.lastRefreshError))
1762
+ .map((account) => account.label?.trim() || account.id);
1763
+ }
1421
1764
  export function isQuotaPolicyAuthError(error) {
1422
1765
  const status = error.status;
1423
1766
  if (status === 403)
@@ -1454,7 +1797,7 @@ export function getPersistedLogLevel(storage) {
1454
1797
  return storage?.logging?.level;
1455
1798
  }
1456
1799
  export async function setLogLevelPersistent(level, path = getAccountStoragePath()) {
1457
- const { setLogLevel } = await import("./logger.js");
1800
+ const { setLogLevel } = await import('./logger.js');
1458
1801
  const storage = (await loadAccounts(path)) ?? createEmptyStorage();
1459
1802
  storage.logging = {
1460
1803
  ...(storage.logging ?? {}),
@@ -1486,15 +1829,18 @@ function failClosedOnUnknownQuota(storage) {
1486
1829
  return (storage?.quota?.failClosedOnUnknownQuota ??
1487
1830
  DEFAULT_FAIL_CLOSED_ON_UNKNOWN_QUOTA);
1488
1831
  }
1832
+ function normalizeScopedQuotaModel(value) {
1833
+ return value.toLowerCase().replace(/[^a-z0-9]/g, '');
1834
+ }
1489
1835
  function scopedQuotaModelKey(model) {
1490
1836
  if (typeof model !== 'string')
1491
1837
  return null;
1492
- const normalized = model.toLowerCase();
1838
+ const normalized = normalizeScopedQuotaModel(model);
1493
1839
  if (normalized.includes('fable'))
1494
1840
  return 'fable';
1495
1841
  if (normalized.includes('mythos'))
1496
1842
  return 'mythos';
1497
- return null;
1843
+ return normalized;
1498
1844
  }
1499
1845
  export function getScopedQuotaWindowForModel(quota, model) {
1500
1846
  const key = scopedQuotaModelKey(model);
@@ -1503,8 +1849,8 @@ export function getScopedQuotaWindowForModel(quota, model) {
1503
1849
  return quota?.scoped?.find((window) => {
1504
1850
  const haystack = [window.modelId, window.modelName, window.title]
1505
1851
  .filter((value) => typeof value === 'string')
1506
- .join(' ')
1507
- .toLowerCase();
1852
+ .map(normalizeScopedQuotaModel)
1853
+ .join(' ');
1508
1854
  return haystack.includes(key);
1509
1855
  });
1510
1856
  }
@@ -1997,6 +2343,11 @@ function formatErrorMessage(error) {
1997
2343
  return error instanceof Error ? error.message : String(error);
1998
2344
  }
1999
2345
  function recordRefreshError(account, error, now) {
2346
+ const existing = existingRefreshBackoffError(error);
2347
+ if (existing) {
2348
+ account.lastRefreshError = existing;
2349
+ return;
2350
+ }
2000
2351
  account.lastRefreshError = buildRefreshOperationError({
2001
2352
  error,
2002
2353
  now,
@@ -2005,7 +2356,7 @@ function recordRefreshError(account, error, now) {
2005
2356
  });
2006
2357
  }
2007
2358
  function recordQuotaRefreshError(account, error, now) {
2008
- if (isQuotaPolicyAuthError(error))
2359
+ if (isQuotaPolicyAuthError(error) || existingRefreshBackoffError(error))
2009
2360
  return;
2010
2361
  account.lastQuotaRefreshError = buildQuotaOperationError({
2011
2362
  error,
@@ -2031,12 +2382,17 @@ export class FallbackAccountManager {
2031
2382
  quotaTimer = null;
2032
2383
  quotaManager;
2033
2384
  onFallbackStorageChanged;
2385
+ setIntervalImpl;
2386
+ clearIntervalImpl;
2034
2387
  constructor(options = {}) {
2035
2388
  this.now = options.now ?? Date.now;
2036
2389
  this.fetchImpl = options.fetchImpl ?? fetch;
2037
2390
  this.configPath = options.configPath ?? getAccountStoragePath();
2038
2391
  this.quotaManager = options.quotaManager ?? null;
2039
2392
  this.onFallbackStorageChanged = options.onFallbackStorageChanged;
2393
+ this.setIntervalImpl = options.setIntervalImpl ?? globalThis.setInterval;
2394
+ this.clearIntervalImpl =
2395
+ options.clearIntervalImpl ?? globalThis.clearInterval;
2040
2396
  }
2041
2397
  /**
2042
2398
  * Seed QuotaManager from persisted account.quota if no cache entry exists
@@ -2047,7 +2403,7 @@ export class FallbackAccountManager {
2047
2403
  return;
2048
2404
  if (!account.quota)
2049
2405
  return;
2050
- const checkedAt = Math.max(account.quota.five_hour?.checkedAt ?? 0, account.quota.seven_day?.checkedAt ?? 0);
2406
+ const checkedAt = quotaSnapshotCheckedAt(account.quota);
2051
2407
  if (checkedAt <= 0)
2052
2408
  return;
2053
2409
  const existing = this.quotaManager.getFallback(account.id, account.access);
@@ -2075,7 +2431,7 @@ export class FallbackAccountManager {
2075
2431
  };
2076
2432
  void run().catch(() => { });
2077
2433
  if (!this.refreshTimer) {
2078
- this.refreshTimer = setInterval(() => {
2434
+ this.refreshTimer = this.setIntervalImpl(() => {
2079
2435
  void run().catch(() => { });
2080
2436
  }, BACKGROUND_TICK_MS + jitterMs(BACKGROUND_TICK_JITTER_MS));
2081
2437
  if ('unref' in this.refreshTimer)
@@ -2084,9 +2440,9 @@ export class FallbackAccountManager {
2084
2440
  }
2085
2441
  stopBackgroundRefresh() {
2086
2442
  if (this.refreshTimer)
2087
- clearInterval(this.refreshTimer);
2443
+ this.clearIntervalImpl(this.refreshTimer);
2088
2444
  if (this.quotaTimer)
2089
- clearInterval(this.quotaTimer);
2445
+ this.clearIntervalImpl(this.quotaTimer);
2090
2446
  this.refreshTimer = null;
2091
2447
  this.quotaTimer = null;
2092
2448
  }
@@ -2105,7 +2461,7 @@ export class FallbackAccountManager {
2105
2461
  const refreshError = next.lastRefreshError;
2106
2462
  if (refreshError &&
2107
2463
  refreshBackoffActive(refreshError, next.refresh, this.now())) {
2108
- throw new Error(formatRefreshBackoffMessage(refreshError, this.now()));
2464
+ throw createRefreshBackoffActiveError(refreshError, this.now());
2109
2465
  }
2110
2466
  next = await this.refreshAccount(next, storage);
2111
2467
  changed = true;
@@ -2120,7 +2476,7 @@ export class FallbackAccountManager {
2120
2476
  // the background refreshQuotaForDueAccounts() guard.
2121
2477
  if (stale &&
2122
2478
  !quotaBackoffActive(next.lastQuotaRefreshError, this.now())) {
2123
- next = await this.refreshAccountQuota(next, storage);
2479
+ next = (await this.refreshAccountQuota(next, storage)).account;
2124
2480
  changed = true;
2125
2481
  }
2126
2482
  // Single source of truth: evaluate quota policy from the unified
@@ -2280,7 +2636,7 @@ export class FallbackAccountManager {
2280
2636
  const refreshError = next.lastRefreshError;
2281
2637
  if (refreshError &&
2282
2638
  refreshBackoffActive(refreshError, next.refresh, this.now())) {
2283
- throw new Error(formatRefreshBackoffMessage(refreshError, this.now()));
2639
+ throw createRefreshBackoffActiveError(refreshError, this.now());
2284
2640
  }
2285
2641
  next = await this.refreshAccount(next, storage);
2286
2642
  changed = true;
@@ -2355,7 +2711,7 @@ export class FallbackAccountManager {
2355
2711
  if (refreshError &&
2356
2712
  refreshBackoffActive(refreshError, latestAccount.refresh, this.now())) {
2357
2713
  updateStoredAccount(storage, latestAccount);
2358
- throw new Error(formatRefreshBackoffMessage(refreshError, this.now()));
2714
+ throw createRefreshBackoffActiveError(refreshError, this.now());
2359
2715
  }
2360
2716
  }
2361
2717
  return null;
@@ -2406,11 +2762,14 @@ export class FallbackAccountManager {
2406
2762
  });
2407
2763
  const refreshed = await refreshClaudeOAuthToken({
2408
2764
  refreshToken,
2765
+ authLineageId: sourceAccount.authLineageId,
2409
2766
  fetchImpl: this.fetchImpl,
2410
2767
  now: this.now,
2411
2768
  });
2412
2769
  sourceAccount.access = refreshed.access;
2413
2770
  sourceAccount.refresh = refreshed.refresh;
2771
+ sourceAccount.authLineageId =
2772
+ refreshed.authLineageId ?? sourceAccount.authLineageId;
2414
2773
  sourceAccount.expires = refreshed.expires;
2415
2774
  sourceAccount.lastRefreshedAt =
2416
2775
  refreshed.expires - refreshed.expiresIn * 1000;
@@ -2438,15 +2797,18 @@ export class FallbackAccountManager {
2438
2797
  // deduplication and 429 backoff gating around the same quota API. Fall back
2439
2798
  // to a direct fetch only when no QuotaManager is wired (e.g. in isolation).
2440
2799
  const fetchSnapshot = (accessToken) => this.quotaManager
2441
- ? this.quotaManager.refreshFallback(target.id, accessToken)
2800
+ ? this.quotaManager.refreshFallbackWithMetadata(target.id, accessToken)
2442
2801
  : fetchOAuthQuotaSnapshot({
2443
2802
  accessToken,
2444
2803
  fetchImpl: this.fetchImpl,
2445
2804
  now: this.now,
2446
- });
2805
+ }).then((quota) => ({ quota, fetched: true }));
2447
2806
  const fetchStartedAt = this.now();
2807
+ let fetched = false;
2448
2808
  try {
2449
- target.quota = await fetchSnapshot(target.access);
2809
+ const result = await fetchSnapshot(target.access);
2810
+ target.quota = result.quota;
2811
+ fetched = result.fetched;
2450
2812
  }
2451
2813
  catch (error) {
2452
2814
  const message = error instanceof Error ? error.message : String(error);
@@ -2458,7 +2820,9 @@ export class FallbackAccountManager {
2458
2820
  if (!target.access)
2459
2821
  throw error;
2460
2822
  // 401 does not arm QuotaManager backoff, so this retry proceeds.
2461
- target.quota = await fetchSnapshot(target.access);
2823
+ const result = await fetchSnapshot(target.access);
2824
+ target.quota = result.quota;
2825
+ fetched = result.fetched;
2462
2826
  }
2463
2827
  const latestStorage = await this.load();
2464
2828
  const latestAccount = latestStorage?.accounts.find((candidate) => candidate.id === target.id && isOAuthAccount(candidate));
@@ -2467,7 +2831,7 @@ export class FallbackAccountManager {
2467
2831
  latestAccount.access !== target.access) {
2468
2832
  this.seedFallbackQuota(latestAccount, latestStorage);
2469
2833
  updateStoredAccount(storage, latestAccount);
2470
- return latestAccount;
2834
+ return { account: latestAccount, fetched: false };
2471
2835
  }
2472
2836
  if (latestStorage &&
2473
2837
  latestAccount?.access === target.access &&
@@ -2476,7 +2840,7 @@ export class FallbackAccountManager {
2476
2840
  quotaSnapshotIsFresh(latestAccount.quota, latestStorage, this.now())) {
2477
2841
  this.seedFallbackQuota(latestAccount, latestStorage);
2478
2842
  updateStoredAccount(storage, latestAccount);
2479
- return latestAccount;
2843
+ return { account: latestAccount, fetched };
2480
2844
  }
2481
2845
  target.lastQuotaRefreshError = undefined;
2482
2846
  updateStoredAccount(storage, target);
@@ -2491,6 +2855,6 @@ export class FallbackAccountManager {
2491
2855
  checkedAt: now,
2492
2856
  }, target.access);
2493
2857
  }
2494
- return target;
2858
+ return { account: target, fetched };
2495
2859
  }
2496
2860
  }
package/dist/auth.d.ts CHANGED
@@ -18,13 +18,16 @@ export type ClaudeOAuthRefreshResult = {
18
18
  refresh: string;
19
19
  expires: number;
20
20
  expiresIn: number;
21
+ authLineageId?: string;
21
22
  };
22
23
  export declare function refreshClaudeOAuthToken(input: {
23
24
  refreshToken: string;
25
+ authLineageId?: string;
24
26
  fetchImpl?: typeof fetch;
25
27
  now?: () => number;
26
28
  maxRetries?: number;
27
29
  baseDelayMs?: number;
30
+ setTimeoutImpl?: typeof globalThis.setTimeout;
28
31
  }): Promise<ClaudeOAuthRefreshResult>;
29
32
  export type AuthorizationResult = {
30
33
  url: string;