@gajae-code/ai 0.15.5 → 0.15.6

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.
Files changed (58) hide show
  1. package/CHANGELOG.md +15 -0
  2. package/dist/types/auth-broker/client.d.ts +6 -2
  3. package/dist/types/auth-broker/remote-store.d.ts +14 -2
  4. package/dist/types/auth-broker/types.d.ts +6 -0
  5. package/dist/types/auth-broker/wire-schemas.d.ts +19 -0
  6. package/dist/types/auth-gateway/server.d.ts +39 -5
  7. package/dist/types/auth-gateway/types.d.ts +16 -2
  8. package/dist/types/auth-storage.d.ts +92 -24
  9. package/dist/types/provider-models/openai-compat.d.ts +1 -0
  10. package/dist/types/providers/register-builtins.d.ts +12 -12
  11. package/dist/types/stream.d.ts +2 -1
  12. package/dist/types/types.d.ts +22 -2
  13. package/dist/types/utils/oauth/api-key-login.d.ts +4 -1
  14. package/dist/types/utils/oauth/api-key-validation.d.ts +12 -6
  15. package/dist/types/utils/oauth/commandcode.d.ts +1 -0
  16. package/dist/types/utils/oauth/types.d.ts +1 -1
  17. package/dist/types/utils/retry.d.ts +2 -0
  18. package/package.json +3 -3
  19. package/src/auth-broker/client.ts +41 -13
  20. package/src/auth-broker/redact.ts +25 -1
  21. package/src/auth-broker/remote-store.ts +374 -115
  22. package/src/auth-broker/server.ts +131 -91
  23. package/src/auth-broker/types.ts +6 -0
  24. package/src/auth-broker/wire-schemas.ts +6 -0
  25. package/src/auth-gateway/server.ts +447 -79
  26. package/src/auth-gateway/types.ts +28 -2
  27. package/src/auth-storage.ts +658 -154
  28. package/src/cli.ts +1 -0
  29. package/src/models.json +1023 -0
  30. package/src/provider-models/descriptors.ts +3 -1
  31. package/src/provider-models/openai-compat.ts +41 -1
  32. package/src/providers/anthropic.ts +7 -1
  33. package/src/providers/azure-openai-responses.ts +4 -1
  34. package/src/providers/cursor.ts +256 -101
  35. package/src/providers/gitlab-duo.ts +18 -1
  36. package/src/providers/google-gemini-cli.ts +3 -0
  37. package/src/providers/google-shared.ts +3 -0
  38. package/src/providers/kiro-codewhisperer.ts +24 -8
  39. package/src/providers/ollama.ts +3 -0
  40. package/src/providers/openai-codex-responses.ts +20 -6
  41. package/src/providers/openai-completions.ts +11 -1
  42. package/src/providers/openai-responses.ts +10 -1
  43. package/src/providers/pi-native-client.ts +1 -0
  44. package/src/providers/pi-native-server.ts +24 -0
  45. package/src/providers/register-builtins.d.ts +12 -12
  46. package/src/providers/register-builtins.ts +16 -3
  47. package/src/stream.d.ts +2 -1
  48. package/src/stream.ts +175 -67
  49. package/src/types.d.ts +22 -2
  50. package/src/types.ts +27 -1
  51. package/src/utils/oauth/api-key-login.ts +13 -2
  52. package/src/utils/oauth/api-key-validation.ts +242 -41
  53. package/src/utils/oauth/commandcode.ts +17 -0
  54. package/src/utils/oauth/index.ts +20 -5
  55. package/src/utils/oauth/types.d.ts +1 -1
  56. package/src/utils/oauth/types.ts +1 -0
  57. package/src/utils/retry.d.ts +2 -0
  58. package/src/utils/retry.ts +15 -2
@@ -47,6 +47,20 @@ export type ApiKeyCredential = {
47
47
  key: string;
48
48
  };
49
49
 
50
+ /**
51
+ * Extracts the bearer token from the structured API-key form used by OAuth
52
+ * providers that need to carry token metadata alongside the access token.
53
+ */
54
+ export function extractStructuredApiKeyToken(apiKey: string): string | undefined {
55
+ if (!apiKey.startsWith("{")) return undefined;
56
+ try {
57
+ const parsed = JSON.parse(apiKey) as { token?: unknown };
58
+ return typeof parsed.token === "string" ? parsed.token : undefined;
59
+ } catch {
60
+ return undefined;
61
+ }
62
+ }
63
+
50
64
  export interface MCPOAuthBinding {
51
65
  /** Exact HTTP(S) origin of the MCP resource endpoint. */
52
66
  resourceOrigin: string;
@@ -369,6 +383,7 @@ export interface AuthCredentialSnapshotEntry {
369
383
  provider: string;
370
384
  credential: SnapshotCredential;
371
385
  identityKey: string | null;
386
+ revision?: number;
372
387
  }
373
388
 
374
389
  export type AuthCredentialIfAbsentReason =
@@ -430,8 +445,21 @@ export type OAuthRefreshLeaseClaim =
430
445
  | { kind: "busy"; expiresAt: number }
431
446
  | { kind: "missing" };
432
447
 
448
+ /**
449
+ * Store-owned ticket that orders a provider admission against remote
450
+ * credential snapshot application. The ticket is intentionally released at
451
+ * provider admission, not response completion.
452
+ */
453
+ export interface CredentialDispatchTicket {
454
+ release(): void;
455
+ }
456
+
433
457
  export interface AuthCredentialStore {
434
458
  close(): void;
459
+ refreshSnapshot?(signal?: AbortSignal): Promise<unknown>;
460
+ onSnapshotChanged?(listener: () => void): () => void;
461
+ /** Order provider admission with remote snapshot authority application. */
462
+ acquireCredentialDispatchTicket?(provider: Provider, signal?: AbortSignal): Promise<CredentialDispatchTicket>;
435
463
  listAuthCredentials(provider?: string): StoredAuthCredential[];
436
464
  /** Payload-free account inventory; active and soft-disabled rows are included. */
437
465
  listCredentialInventory?(provider?: string): CredentialInventoryRecord[];
@@ -445,12 +473,15 @@ export interface AuthCredentialStore {
445
473
  updateAuthCredential(id: number, credential: AuthCredential): void;
446
474
  deleteAuthCredential(id: number, disabledCause: string): void;
447
475
  tryDisableAuthCredentialIfMatches(id: number, expectedData: string, disabledCause: string): boolean;
476
+ tryDisableAuthCredentialIfRevision?(id: number, expectedRevision: number, disabledCause: string): boolean;
448
477
  replaceAuthCredentialsForProvider(provider: string, credentials: AuthCredential[]): StoredAuthCredential[];
449
478
  upsertAuthCredentialForProvider(provider: string, credential: AuthCredential): StoredAuthCredential[];
450
479
  upsertAuthCredentialForProviderIfAbsent(provider: string, credential: AuthCredential): AuthCredentialIfAbsentResult;
451
480
  deleteAuthCredentialsForProvider(provider: string, disabledCause: string): void;
452
481
  getCache(key: string, options?: { includeExpired?: boolean }): string | null;
453
482
  setCache(key: string, value: string, expiresAtSec: number): void;
483
+ /** Atomically allocate a durable sequence for broker restart epochs. */
484
+ allocateMonotonicSequence(key: string, expiresAtSec: number): number;
454
485
  deleteCachePrefix?(prefix: string): void;
455
486
  cleanExpiredCache(): void;
456
487
  /**
@@ -514,6 +545,7 @@ export interface AuthCredentialStore {
514
545
  * `signal` propagates the agent's cancel down to the broker fetch.
515
546
  */
516
547
  fetchUsageReports?(signal?: AbortSignal): Promise<UsageReport[] | null>;
548
+ fetchUsageReportsForProvider?(provider: Provider, signal?: AbortSignal): Promise<UsageReport[] | null>;
517
549
  /** Synchronous, zero-network usage presentation peek. */
518
550
  peekCachedUsagePresentation?(provider: Provider, credentialId: number): CachedUsagePresentation | undefined;
519
551
  /** Record a safe usage observation after an explicit fetch/check. */
@@ -552,6 +584,21 @@ export interface AuthCredentialStore {
552
584
  * {@link AuthStorage.invalidateCredentialMatching} fall back to `reload()`.
553
585
  */
554
586
  markCredentialSuspect?(credentialId: number, opts?: { signal?: AbortSignal }): Promise<void>;
587
+ /**
588
+ * Optional async write hook to disable one credential through an authoritative
589
+ * remote store. Remote clients MUST use this hook instead of the synchronous
590
+ * local delete methods when an OAuth refresh fails definitively.
591
+ *
592
+ * Returns `false` when the row is already absent (for example, a peer
593
+ * disabled it first). Implementations MUST NOT treat a failed remote write as
594
+ * a successful local deletion.
595
+ */
596
+ disableAuthCredentialRemote?(
597
+ credentialId: number,
598
+ disabledCause: string,
599
+ signal?: AbortSignal,
600
+ expectedRevision?: number,
601
+ ): Promise<boolean>;
555
602
  /**
556
603
  * Optional async write hook for upserting a single credential. When present,
557
604
  * `AuthStorage.#upsertOAuthCredential` routes through this instead of the
@@ -675,6 +722,7 @@ export type AuthStorageOptions = {
675
722
  * AuthStorage caller surfaces that to its own consumer unchanged.
676
723
  */
677
724
  fetchUsageReports?: (signal?: AbortSignal) => Promise<UsageReport[] | null>;
725
+ fetchUsageReportsForProvider?: (provider: Provider, signal?: AbortSignal) => Promise<UsageReport[] | null>;
678
726
  };
679
727
 
680
728
  // ─────────────────────────────────────────────────────────────────────────────
@@ -995,9 +1043,11 @@ type UsageRequestDescriptor = {
995
1043
  baseUrl?: string;
996
1044
  };
997
1045
 
998
- type AuthApiKeyOptions = {
1046
+ export type AuthApiKeyOptions = {
999
1047
  baseUrl?: string;
1000
1048
  modelId?: string;
1049
+ /** Select config registrations owned by one caller (for example a ModelRegistry). */
1050
+ owner?: object;
1001
1051
  /**
1002
1052
  * Caller's cancel signal. Threaded into any broker-bound OAuth refresh so
1003
1053
  * `ESC` / request abort actually kills a hung broker fetch instead of
@@ -1036,6 +1086,7 @@ export interface OAuthAccess {
1036
1086
  export interface InvalidateCredentialMatchingOptions {
1037
1087
  signal?: AbortSignal;
1038
1088
  sessionId?: string;
1089
+ owner?: object;
1039
1090
  }
1040
1091
 
1041
1092
  function isAbortSignalOption(
@@ -1054,17 +1105,40 @@ function safeUsageReport(report: UsageReport): SafeUsageReport {
1054
1105
  }
1055
1106
 
1056
1107
  function scrubHealthReason(reason: unknown, secrets: readonly string[] = []): string {
1057
- let value = reason instanceof Error ? reason.message : String(reason);
1108
+ let value = (reason instanceof Error ? reason.message : String(reason)).replace(/\u001b\[[0-?]*[ -/]*[@-~]/gu, "");
1058
1109
  for (const secret of secrets) {
1059
- if (secret.length > 0) value = value.split(secret).join("[redacted]");
1110
+ if (secret.length > 0) {
1111
+ value = value.split(secret).join("[redacted]");
1112
+ const escaped = [...secret].map(char => char.replace(/[\\^$.*+?()[\]{}|]/g, "\\$&"));
1113
+ value = value.replace(new RegExp(escaped.join("\\s*"), "gu"), "[redacted]");
1114
+ }
1060
1115
  }
1061
1116
  value = value.replace(/bearer\s+[^\s,;]+/gi, "Bearer [redacted]");
1062
1117
  value = value.replace(/(api[_-]?key|token|secret|authorization)[=:]\s*[^\s,;]+/gi, "$1=[redacted]");
1063
- value = value.replace(/[\r\n\t ]+/g, " ").trim();
1118
+ value = value
1119
+ .replace(/[\x00-\x1f\x7f-\x9f]+/gu, " ")
1120
+ .replace(/[\r\n\t ]+/g, " ")
1121
+ .trim();
1064
1122
  if (value.length > 256) value = `${value.slice(0, 253)}...`;
1065
1123
  return value || "credential check failed";
1066
1124
  }
1067
1125
 
1126
+ /** Read optional broker error detail without allowing hostile objects to escape classification. */
1127
+ /** @internal Tested directly because hostile accessors must preserve the original error identity. */
1128
+ export function readBrokerErrorBody(error: unknown): string | undefined {
1129
+ if (error === null || (typeof error !== "object" && typeof error !== "function")) return undefined;
1130
+ try {
1131
+ if (!("body" in error)) return undefined;
1132
+ const body = (error as { body?: unknown }).body;
1133
+ return typeof body === "string" ? body : undefined;
1134
+ } catch {
1135
+ // Preserve the original provider error and its terminal behavior. A proxy or
1136
+ // throwing accessor is untrusted provider data, not a reason to replace the
1137
+ // refresh failure with an inspection error.
1138
+ throw error;
1139
+ }
1140
+ }
1141
+
1068
1142
  function requiresOpenAICodexProModel(provider: string, modelId: string | undefined): boolean {
1069
1143
  return provider === "openai-codex" && typeof modelId === "string" && modelId.includes("-spark");
1070
1144
  }
@@ -1213,7 +1287,15 @@ class AuthStorageUsageCache implements UsageCache {
1213
1287
  // In-memory representation
1214
1288
  // ─────────────────────────────────────────────────────────────────────────────
1215
1289
 
1216
- type StoredCredential = { id: number; credential: AuthCredential };
1290
+ type StoredCredential = { id: number; credential: AuthCredential; revision?: number };
1291
+ type IndexedStoredCredential<T extends AuthCredential = AuthCredential> = {
1292
+ id: number;
1293
+ credential: T;
1294
+ index: number;
1295
+ revision?: number;
1296
+ };
1297
+ type OAuthCredentialSelection = IndexedStoredCredential<OAuthCredential>;
1298
+ type ConfigApiKeyRegistration = { apiKey: string; envSourced: boolean; order: number };
1217
1299
 
1218
1300
  // ─────────────────────────────────────────────────────────────────────────────
1219
1301
  // AuthStorage Class
@@ -1231,6 +1313,10 @@ export class AuthStorage {
1231
1313
  #data: Map<string, StoredCredential[]> = new Map();
1232
1314
  #runtimeOverrides: Map<string, string> = new Map();
1233
1315
  #configOverrides: Map<string, string> = new Map();
1316
+ /** Effective config override registrations, including owner-scoped model registries. */
1317
+ #configOverrideRegistrations: Map<string, Map<object, ConfigApiKeyRegistration>> = new Map();
1318
+ #unownedConfigOverrides: Map<string, ConfigApiKeyRegistration> = new Map();
1319
+ #configOverrideOrder = 0;
1234
1320
  /**
1235
1321
  * Providers whose config override was resolved from a models.yml `apiKeyEnv`
1236
1322
  * indirection rather than a literal `apiKey` pin. An env pointer is not a
@@ -1264,12 +1350,14 @@ export class AuthStorage {
1264
1350
  #credentialRankingMode: CredentialRankingMode = "balanced";
1265
1351
  #usageLogger?: UsageLogger;
1266
1352
  #fallbackResolver?: (provider: string) => string | undefined;
1353
+ #ownedFallbackResolvers: Map<object, (provider: string) => string | undefined> = new Map();
1267
1354
  #store: AuthCredentialStore;
1268
1355
  #configValueResolver: (config: string, cacheScope?: string) => Promise<string | undefined>;
1269
1356
  #resolvedStoredApiKeyValues: Map<string, Map<string, { fingerprint: string; usable: boolean }>> = new Map();
1270
1357
  #storedApiKeyResolutionInFlight: Map<string, Map<string, Promise<string | undefined>>> = new Map();
1271
1358
  #refreshOAuthCredentialOverride?: AuthStorageOptions["refreshOAuthCredential"];
1272
1359
  #fetchUsageReportsOverride?: AuthStorageOptions["fetchUsageReports"];
1360
+ #fetchUsageReportsForProviderOverride?: AuthStorageOptions["fetchUsageReportsForProvider"];
1273
1361
  #sourceLabel?: string;
1274
1362
  #credentialDisabledListeners: Set<(event: CredentialDisabledEvent) => void | Promise<void>> = new Set();
1275
1363
  /**
@@ -1299,6 +1387,10 @@ export class AuthStorage {
1299
1387
 
1300
1388
  constructor(store: AuthCredentialStore, options: AuthStorageOptions = {}) {
1301
1389
  this.#store = store;
1390
+ store.onSnapshotChanged?.(() => {
1391
+ this.#reloadCredentialRowsFromStore();
1392
+ void this.reload();
1393
+ });
1302
1394
  this.#configValueResolver = options.configValueResolver ?? defaultConfigValueResolver;
1303
1395
  this.#usageProviderResolver = options.usageProviderResolver ?? resolveDefaultUsageProvider;
1304
1396
  this.#rankingStrategyResolver = options.rankingStrategyResolver ?? resolveDefaultRankingStrategy;
@@ -1308,6 +1400,7 @@ export class AuthStorage {
1308
1400
  this.#credentialRankingMode = options.credentialRankingMode ?? "balanced";
1309
1401
  this.#refreshOAuthCredentialOverride = options.refreshOAuthCredential;
1310
1402
  this.#fetchUsageReportsOverride = options.fetchUsageReports;
1403
+ this.#fetchUsageReportsForProviderOverride = options.fetchUsageReportsForProvider;
1311
1404
  this.#sourceLabel = options.sourceLabel;
1312
1405
  if (options.onCredentialDisabled) {
1313
1406
  // Constructor-registered subscribers are permanent for this AuthStorage's lifetime;
@@ -1352,6 +1445,15 @@ export class AuthStorage {
1352
1445
  getGeneration(): number {
1353
1446
  return this.#generation;
1354
1447
  }
1448
+ getCache(key: string, options?: { includeExpired?: boolean }): string | null {
1449
+ return this.#store.getCache(key, options);
1450
+ }
1451
+ setCache(key: string, value: string, expiresAtSec: number): void {
1452
+ this.#store.setCache(key, value, expiresAtSec);
1453
+ }
1454
+ allocateMonotonicSequence(key: string, expiresAtSec: number): number {
1455
+ return this.#store.allocateMonotonicSequence(key, expiresAtSec);
1456
+ }
1355
1457
  getProviderConfigurationGeneration(provider: string): number {
1356
1458
  return this.#getProviderConfigurationGeneration(provider);
1357
1459
  }
@@ -1364,13 +1466,29 @@ export class AuthStorage {
1364
1466
  #getProviderConfigurationGeneration(provider: string): number {
1365
1467
  return this.#providerConfigurationGenerations.get(resolveOAuthStorageProvider(provider)) ?? 1;
1366
1468
  }
1367
- getProviderEvidenceGeneration(provider: string, resolvedApiKey?: string): string {
1469
+ #configOverrideRegistration(provider: string, owner?: object): ConfigApiKeyRegistration | undefined {
1470
+ const storageProvider = resolveOAuthStorageProvider(provider);
1471
+ if (owner) {
1472
+ return (
1473
+ this.#configOverrideRegistrations.get(storageProvider)?.get(owner) ??
1474
+ this.#unownedConfigOverrides.get(storageProvider)
1475
+ );
1476
+ }
1477
+ const apiKey = this.#configOverrides.get(storageProvider);
1478
+ return apiKey === undefined
1479
+ ? undefined
1480
+ : { apiKey, envSourced: this.#configOverrideEnvSourced.has(storageProvider), order: 0 };
1481
+ }
1482
+ #hasConfigOverride(provider: string, owner?: object): boolean {
1483
+ return this.#configOverrideRegistration(provider, owner) !== undefined;
1484
+ }
1485
+ getProviderEvidenceGeneration(provider: string, resolvedApiKey?: string, owner?: object): string {
1368
1486
  const storageProvider = resolveOAuthStorageProvider(provider);
1487
+ provider = storageProvider;
1369
1488
  const evidenceApiKey = resolvedApiKey;
1489
+ const configOverride = this.#configOverrideRegistration(storageProvider, owner);
1370
1490
  const storedLiteral =
1371
- evidenceApiKey === undefined ||
1372
- this.#runtimeOverrides.has(storageProvider) ||
1373
- this.#configOverrides.has(storageProvider)
1491
+ evidenceApiKey === undefined || this.#runtimeOverrides.has(storageProvider) || configOverride !== undefined
1374
1492
  ? undefined
1375
1493
  : this.#getCredentialsForProvider(storageProvider).find(
1376
1494
  (credential): credential is Extract<AuthCredential, { type: "api_key" }> =>
@@ -1387,7 +1505,7 @@ export class AuthStorage {
1387
1505
  }
1388
1506
  let selectedCredential: ({ index: number } & StoredCredential) | undefined;
1389
1507
  try {
1390
- selectedCredential = this.#resolveSelectedStoredCredential(provider, undefined, undefined);
1508
+ selectedCredential = this.#resolveSelectedStoredCredential(provider, owner ? { owner } : undefined, undefined);
1391
1509
  } catch {
1392
1510
  return crypto
1393
1511
  .createHash("sha256")
@@ -1403,7 +1521,7 @@ export class AuthStorage {
1403
1521
  credential.type === "oauth" && Number.isFinite(credential.expires) && credential.expires > Date.now(),
1404
1522
  );
1405
1523
  const effectiveEnvKey =
1406
- this.#runtimeOverrides.get(provider) || this.#configOverrides.get(provider) || hasApiKey || hasUsableOAuth
1524
+ this.#runtimeOverrides.get(provider) || configOverride?.apiKey || hasApiKey || hasUsableOAuth
1407
1525
  ? undefined
1408
1526
  : getEnvApiKey(provider);
1409
1527
  const storedApiKeyFingerprint = credentials
@@ -1551,12 +1669,21 @@ export class AuthStorage {
1551
1669
  }
1552
1670
  }
1553
1671
 
1554
- /** Set the selector derived from a durable session pin or a session seed. */
1555
- setSessionCredentialSelector(scopeId: string, provider: string, selector: AuthCredentialSelector): void {
1672
+ /**
1673
+ * Set the selector derived from a durable session pin or a session seed.
1674
+ * `owner` scopes config-override validation to one ModelRegistry; omitted
1675
+ * owners retain process-wide caller semantics.
1676
+ */
1677
+ setSessionCredentialSelector(
1678
+ scopeId: string,
1679
+ provider: string,
1680
+ selector: AuthCredentialSelector,
1681
+ owner?: object,
1682
+ ): void {
1556
1683
  const scope = scopeId.trim();
1557
1684
  if (!scope) throw new Error("Credential scope id must not be empty");
1558
1685
  const storageProvider = resolveOAuthStorageProvider(provider);
1559
- this.#assertCredentialSelectorUsable(storageProvider, selector);
1686
+ this.#assertCredentialSelectorUsable(storageProvider, selector, owner);
1560
1687
  const selectors = this.#sessionCredentialSelectors.get(scope) ?? new Map<string, AuthCredentialSelector>();
1561
1688
  selectors.set(storageProvider, selector);
1562
1689
  this.#sessionCredentialSelectors.set(scope, selectors);
@@ -1615,23 +1742,32 @@ export class AuthStorage {
1615
1742
  }
1616
1743
 
1617
1744
  /** @internal Return cache provenance for an exact stored literal API-key row without resolving its value. */
1618
- getStoredLiteralApiKeyEvidenceGeneration(provider: string, selector: AuthCredentialSelector): string | undefined {
1745
+ getStoredLiteralApiKeyEvidenceGeneration(
1746
+ provider: string,
1747
+ selector: AuthCredentialSelector,
1748
+ owner?: object,
1749
+ ): string | undefined {
1619
1750
  if (selector.kind !== "id") return undefined;
1620
1751
  const storageProvider = resolveOAuthStorageProvider(provider);
1621
- if (this.#runtimeOverrides.has(storageProvider) || this.#configOverrides.has(storageProvider)) return undefined;
1752
+ if (this.#runtimeOverrides.has(storageProvider) || this.#hasConfigOverride(storageProvider, owner))
1753
+ return undefined;
1622
1754
  const selected = this.#findCredentialBySelector(storageProvider, selector);
1623
1755
  if (selected?.credential.type !== "api_key") return undefined;
1624
1756
  const key = selected.credential.key;
1625
1757
  if (!key || key.startsWith("!") || process.env[key] !== undefined) return undefined;
1626
- return this.getProviderEvidenceGeneration(storageProvider, key);
1758
+ return this.getProviderEvidenceGeneration(storageProvider, key, owner);
1627
1759
  }
1628
1760
 
1629
- /** Validate and canonicalize an OAuth-only selector for account pinning. */
1630
- resolveOAuthPinTarget(provider: string, selector: AuthCredentialSelector): OAuthPinTarget {
1761
+ /**
1762
+ * Validate and canonicalize an OAuth-only selector for account pinning.
1763
+ * `owner` scopes config-override checks to one ModelRegistry; omitted owners
1764
+ * retain process-wide caller semantics.
1765
+ */
1766
+ resolveOAuthPinTarget(provider: string, selector: AuthCredentialSelector, owner?: object): OAuthPinTarget {
1631
1767
  const storageProvider = resolveOAuthStorageProvider(provider);
1632
1768
  if (
1633
1769
  this.#runtimeOverrides.has(storageProvider) ||
1634
- this.#configOverrides.has(storageProvider) ||
1770
+ this.#hasConfigOverride(storageProvider, owner) ||
1635
1771
  getEnvApiKey(storageProvider)
1636
1772
  ) {
1637
1773
  throw new OAuthCredentialSelectorError(
@@ -1828,8 +1964,8 @@ export class AuthStorage {
1828
1964
  }
1829
1965
 
1830
1966
  /** Whether a provider is currently authenticated by a config API-key override. */
1831
- hasConfigApiKey(provider: string): boolean {
1832
- return Boolean(this.#configOverrides.get(resolveOAuthStorageProvider(provider)));
1967
+ hasConfigApiKey(provider: string, owner?: object): boolean {
1968
+ return Boolean(this.#configOverrideRegistration(provider, owner)?.apiKey);
1833
1969
  }
1834
1970
 
1835
1971
  /**
@@ -1890,7 +2026,8 @@ export class AuthStorage {
1890
2026
  * runtime API-key override (`--api-key`), or a config-sourced API key
1891
2027
  * (`models.yml` `apiKey`) would each re-decide the credential on the very
1892
2028
  * next {@link AuthStorage.getApiKey} call and make this switch appear to
1893
- * silently do nothing.
2029
+ * silently do nothing. `owner` scopes the config-override check to one
2030
+ * ModelRegistry; omitted owners retain process-wide caller semantics.
1894
2031
  *
1895
2032
  * Deliberately does not touch credential-blocked state: if the target row
1896
2033
  * is still backoff-blocked from a prior quota failure, the existing
@@ -1898,14 +2035,19 @@ export class AuthStorage {
1898
2035
  * falls back to a usable account instead of re-issuing a request that would
1899
2036
  * just draw another 429/quota error.
1900
2037
  */
1901
- switchSessionCredential(provider: string, sessionId: string, selector: AuthCredentialSelector): void {
2038
+ switchSessionCredential(
2039
+ provider: string,
2040
+ sessionId: string,
2041
+ selector: AuthCredentialSelector,
2042
+ owner?: object,
2043
+ ): void {
1902
2044
  const storageProvider = resolveOAuthStorageProvider(provider);
1903
2045
  if (this.#runtimeOverrides.has(storageProvider)) {
1904
2046
  throw new Error(
1905
2047
  `Cannot switch credential for ${provider}: a runtime API key override (--api-key) is active and always wins`,
1906
2048
  );
1907
2049
  }
1908
- if (this.#configOverrides.has(storageProvider)) {
2050
+ if (this.#hasConfigOverride(storageProvider, owner)) {
1909
2051
  throw new Error(
1910
2052
  `Cannot switch credential for ${provider}: a config API key override (models.yml) is active and always wins`,
1911
2053
  );
@@ -1935,6 +2077,9 @@ export class AuthStorage {
1935
2077
  * Lower priority than {@link setRuntimeApiKey} so a CLI `--api-key`
1936
2078
  * still wins for the duration of a single invocation.
1937
2079
  *
2080
+ * `options.owner` scopes the override to one registry or other caller. The
2081
+ * unscoped form is process-wide and is retained for standalone callers.
2082
+ *
1938
2083
  * `options.envSourced` marks the value as resolved from a models.yml
1939
2084
  * `apiKeyEnv` indirection. Unlike a literal pin, an env pointer only says
1940
2085
  * where to look for a key; when the user has since run `auth login`, the
@@ -1942,44 +2087,111 @@ export class AuthStorage {
1942
2087
  * wins over the indirection (stored OAuth credentials still yield, so a
1943
2088
  * custom-endpoint bearer is never replaced by an upstream OAuth token).
1944
2089
  */
1945
- setConfigApiKey(provider: string, apiKey: string, options: { envSourced?: boolean } = {}): void {
2090
+ setConfigApiKey(provider: string, apiKey: string, options: { envSourced?: boolean; owner?: object } = {}): void {
1946
2091
  const storageProvider = resolveOAuthStorageProvider(provider);
1947
- this.#configOverrides.set(storageProvider, apiKey);
1948
- if (options.envSourced) {
1949
- this.#configOverrideEnvSourced.add(storageProvider);
2092
+ const registration = {
2093
+ apiKey,
2094
+ envSourced: options.envSourced === true,
2095
+ order: ++this.#configOverrideOrder,
2096
+ };
2097
+ if (options.owner) {
2098
+ const registrations = this.#configOverrideRegistrations.get(storageProvider) ?? new Map();
2099
+ registrations.set(options.owner, registration);
2100
+ this.#configOverrideRegistrations.set(storageProvider, registrations);
1950
2101
  } else {
1951
- this.#configOverrideEnvSourced.delete(storageProvider);
2102
+ this.#unownedConfigOverrides.set(storageProvider, registration);
1952
2103
  }
1953
- this.#bumpGeneration("set-config-api-key", storageProvider);
2104
+ this.#reconcileConfigApiKey(storageProvider, "set-config-api-key", true);
1954
2105
  }
1955
2106
 
1956
2107
  /**
1957
2108
  * Remove a single config-sourced API key override.
1958
2109
  */
1959
- removeConfigApiKey(provider: string): void {
2110
+ removeConfigApiKey(provider: string, owner?: object): void {
1960
2111
  const storageProvider = resolveOAuthStorageProvider(provider);
1961
- this.#configOverrideEnvSourced.delete(storageProvider);
1962
- if (this.#configOverrides.delete(storageProvider)) this.#bumpGeneration("remove-config-api-key", storageProvider);
2112
+ if (owner) {
2113
+ const registrations = this.#configOverrideRegistrations.get(storageProvider);
2114
+ if (!registrations?.delete(owner)) return;
2115
+ if (registrations.size === 0) this.#configOverrideRegistrations.delete(storageProvider);
2116
+ } else {
2117
+ if (!this.#unownedConfigOverrides.delete(storageProvider)) return;
2118
+ }
2119
+ this.#reconcileConfigApiKey(storageProvider, "remove-config-api-key", true);
1963
2120
  }
1964
2121
 
1965
2122
  /**
1966
- * Drop every config-sourced API key. Called by `ModelRegistry` before
1967
- * re-parsing `models.yml` so removed entries actually disappear.
2123
+ * Drop config-sourced API keys. An owner removes only its own registrations;
2124
+ * the unscoped form remains an explicit global reset for callers that own the
2125
+ * entire AuthStorage instance.
1968
2126
  */
1969
- clearConfigApiKeys(): void {
1970
- const providers = [...this.#configOverrides.keys()];
1971
- this.#configOverrideEnvSourced.clear();
1972
- if (providers.length === 0) return;
1973
- this.#configOverrides.clear();
1974
- for (const provider of providers) this.#bumpGeneration("clear-config-api-keys", provider);
2127
+ clearConfigApiKeys(owner?: object): void {
2128
+ if (owner) {
2129
+ const providers = [...this.#configOverrideRegistrations.entries()]
2130
+ .filter(([, registrations]) => registrations.has(owner))
2131
+ .map(([provider]) => provider);
2132
+ for (const provider of providers) this.removeConfigApiKey(provider, owner);
2133
+ return;
2134
+ }
2135
+ const providers = new Set([
2136
+ ...this.#configOverrides.keys(),
2137
+ ...this.#unownedConfigOverrides.keys(),
2138
+ ...this.#configOverrideRegistrations.keys(),
2139
+ ]);
2140
+ this.#unownedConfigOverrides.clear();
2141
+ this.#configOverrideRegistrations.clear();
2142
+ for (const provider of providers) this.#reconcileConfigApiKey(provider, "clear-config-api-keys", true);
2143
+ }
2144
+
2145
+ #reconcileConfigApiKey(provider: string, reason: string, forceGeneration = false): void {
2146
+ const previous = this.#configOverrides.get(provider);
2147
+ const previousEnvSourced = this.#configOverrideEnvSourced.has(provider);
2148
+ let winner: { apiKey: string; envSourced: boolean; order: number } | undefined =
2149
+ this.#unownedConfigOverrides.get(provider);
2150
+ for (const registration of this.#configOverrideRegistrations.get(provider)?.values() ?? []) {
2151
+ if (!winner || registration.order > winner.order) winner = registration;
2152
+ }
2153
+ if (!winner) {
2154
+ this.#configOverrides.delete(provider);
2155
+ this.#configOverrideEnvSourced.delete(provider);
2156
+ } else {
2157
+ this.#configOverrides.set(provider, winner.apiKey);
2158
+ if (winner.envSourced) this.#configOverrideEnvSourced.add(provider);
2159
+ else this.#configOverrideEnvSourced.delete(provider);
2160
+ }
2161
+ const current = this.#configOverrides.get(provider);
2162
+ const currentEnvSourced = this.#configOverrideEnvSourced.has(provider);
2163
+ if (forceGeneration || previous !== current || previousEnvSourced !== currentEnvSourced) {
2164
+ this.#bumpGeneration(reason, provider);
2165
+ }
1975
2166
  }
1976
2167
 
1977
2168
  /**
1978
2169
  * Set a fallback resolver for API keys not found in storage or env vars.
1979
2170
  * Used for custom provider keys from models.json.
1980
2171
  */
1981
- setFallbackResolver(resolver: (provider: string) => string | undefined): void {
2172
+ setFallbackResolver(resolver: (provider: string) => string | undefined, owner?: object): () => void {
2173
+ if (owner) {
2174
+ this.#ownedFallbackResolvers.set(owner, resolver);
2175
+ return () => {
2176
+ if (this.#ownedFallbackResolvers.get(owner) !== resolver) return;
2177
+ this.#ownedFallbackResolvers.delete(owner);
2178
+ };
2179
+ }
1982
2180
  this.#fallbackResolver = resolver;
2181
+ return () => {
2182
+ if (this.#fallbackResolver === resolver) this.#fallbackResolver = undefined;
2183
+ };
2184
+ }
2185
+
2186
+ #resolveFallback(provider: string, owner?: object): string | undefined {
2187
+ if (owner) {
2188
+ return this.#ownedFallbackResolvers.get(owner)?.(provider) ?? this.#fallbackResolver?.(provider);
2189
+ }
2190
+ for (const resolver of [...this.#ownedFallbackResolvers.values()].reverse()) {
2191
+ const value = resolver(provider);
2192
+ if (value !== undefined) return value;
2193
+ }
2194
+ return this.#fallbackResolver?.(provider);
1983
2195
  }
1984
2196
 
1985
2197
  /**
@@ -1987,11 +2199,27 @@ export class AuthStorage {
1987
2199
  */
1988
2200
  async reload(): Promise<void> {
1989
2201
  await this.#store.waitForReady?.();
2202
+ this.#reloadCredentialRowsFromStore();
2203
+ }
2204
+
2205
+ /**
2206
+ * Acquire a store-owned provider-admission ticket when the backing store
2207
+ * provides one (for example, a remote broker snapshot store). Local stores
2208
+ * need no additional ordering and return `undefined`.
2209
+ */
2210
+ async acquireCredentialDispatchTicket(
2211
+ provider: Provider,
2212
+ signal?: AbortSignal,
2213
+ ): Promise<CredentialDispatchTicket | undefined> {
2214
+ return this.#store.acquireCredentialDispatchTicket?.(resolveOAuthStorageProvider(provider), signal);
2215
+ }
2216
+
2217
+ #reloadCredentialRowsFromStore(): void {
1990
2218
  const records = this.#store.listAuthCredentials();
1991
2219
  const grouped = new Map<string, StoredCredential[]>();
1992
2220
  for (const record of records) {
1993
2221
  const list = grouped.get(record.provider) ?? [];
1994
- list.push({ id: record.id, credential: record.credential });
2222
+ list.push({ id: record.id, credential: record.credential, revision: record.revision });
1995
2223
  grouped.set(record.provider, list);
1996
2224
  }
1997
2225
 
@@ -2032,6 +2260,12 @@ export class AuthStorage {
2032
2260
  #setStoredCredentials(provider: string, credentials: StoredCredential[]): void {
2033
2261
  const current = this.#data.get(provider) ?? [];
2034
2262
  if (storedCredentialArraysEqual(current, credentials)) return;
2263
+ const identityOrderChanged =
2264
+ current.length !== credentials.length ||
2265
+ current.some(
2266
+ (entry, index) =>
2267
+ entry.id !== credentials[index]?.id || entry.credential.type !== credentials[index]?.credential.type,
2268
+ );
2035
2269
  this.#resolvedStoredApiKeyValues.delete(provider);
2036
2270
  this.#storedApiKeyResolutionInFlight.delete(provider);
2037
2271
  if (credentials.length === 0) {
@@ -2039,6 +2273,7 @@ export class AuthStorage {
2039
2273
  } else {
2040
2274
  this.#data.set(provider, credentials);
2041
2275
  }
2276
+ if (identityOrderChanged) this.#resetProviderAssignments(resolveOAuthStorageProvider(provider));
2042
2277
  this.#bumpGeneration("credentials", provider);
2043
2278
  }
2044
2279
 
@@ -2239,10 +2474,7 @@ export class AuthStorage {
2239
2474
  }
2240
2475
  }
2241
2476
 
2242
- #findCredentialBySelector(
2243
- provider: string,
2244
- selector: AuthCredentialSelector,
2245
- ): ({ index: number } & StoredCredential) | undefined {
2477
+ #findCredentialBySelector(provider: string, selector: AuthCredentialSelector): IndexedStoredCredential | undefined {
2246
2478
  const stored = this.#getStoredCredentials(provider);
2247
2479
  for (let index = 0; index < stored.length; index++) {
2248
2480
  const entry = stored[index];
@@ -2285,13 +2517,13 @@ export class AuthStorage {
2285
2517
  );
2286
2518
  }
2287
2519
 
2288
- #assertCredentialSelectorUsable(provider: string, selector: AuthCredentialSelector): void {
2520
+ #assertCredentialSelectorUsable(provider: string, selector: AuthCredentialSelector, owner?: object): void {
2289
2521
  if (this.#runtimeOverrides.has(provider)) {
2290
2522
  throw new Error(
2291
2523
  `Credential selector ${this.#formatCredentialSelector(selector)} cannot be used for ${provider} while a runtime API key override is active`,
2292
2524
  );
2293
2525
  }
2294
- if (this.#configOverrides.has(provider)) {
2526
+ if (this.#hasConfigOverride(provider, owner)) {
2295
2527
  throw new Error(
2296
2528
  `Credential selector ${this.#formatCredentialSelector(selector)} cannot be used for ${provider} while a config API key override is active`,
2297
2529
  );
@@ -2307,8 +2539,8 @@ export class AuthStorage {
2307
2539
  * to an OAuth row specifically — the soft-preference/quota-fallback path is
2308
2540
  * meaningless for a single static API key.
2309
2541
  */
2310
- #assertPreferredCredentialSelectorUsable(provider: string, selector: AuthCredentialSelector): void {
2311
- if (this.#runtimeOverrides.has(provider) || this.#configOverrides.has(provider)) {
2542
+ #assertPreferredCredentialSelectorUsable(provider: string, selector: AuthCredentialSelector, owner?: object): void {
2543
+ if (this.#runtimeOverrides.has(provider) || this.#hasConfigOverride(provider, owner)) {
2312
2544
  throw new Error(
2313
2545
  `Preferred credential selector ${this.#formatCredentialSelector(selector)} cannot be used for ${provider} while an API key override is active`,
2314
2546
  );
@@ -2325,10 +2557,10 @@ export class AuthStorage {
2325
2557
  provider: string,
2326
2558
  options?: AuthApiKeyOptions,
2327
2559
  sessionId?: string,
2328
- ): ({ index: number } & StoredCredential) | undefined {
2560
+ ): IndexedStoredCredential | undefined {
2329
2561
  const selector = this.#getCredentialSelector(provider, options, sessionId);
2330
2562
  if (!selector) return undefined;
2331
- this.#assertCredentialSelectorUsable(resolveOAuthStorageProvider(provider), selector);
2563
+ this.#assertCredentialSelectorUsable(resolveOAuthStorageProvider(provider), selector, options?.owner);
2332
2564
  const selected = this.#findCredentialBySelector(provider, selector);
2333
2565
  if (!selected) {
2334
2566
  throw new Error(`No credential found for ${provider} matching ${this.#formatCredentialSelector(selector)}`);
@@ -2345,13 +2577,23 @@ export class AuthStorage {
2345
2577
  type: T,
2346
2578
  sessionId?: string,
2347
2579
  isUsable?: (credential: Extract<AuthCredential, { type: T }>, index: number) => boolean | undefined,
2348
- ): { credential: Extract<AuthCredential, { type: T }>; index: number } | undefined {
2349
- const credentials = this.#getCredentialsForProvider(provider)
2350
- .map((credential, index) => ({ credential, index }))
2580
+ ): IndexedStoredCredential<Extract<AuthCredential, { type: T }>> | undefined {
2581
+ const credentials = this.#getStoredCredentials(provider)
2582
+ .map((entry, index) => ({ entry, index }))
2351
2583
  .filter(
2352
- (entry): entry is { credential: Extract<AuthCredential, { type: T }>; index: number } =>
2353
- entry.credential.type === type,
2354
- );
2584
+ (
2585
+ item,
2586
+ ): item is {
2587
+ entry: StoredCredential & { credential: Extract<AuthCredential, { type: T }> };
2588
+ index: number;
2589
+ } => item.entry.credential.type === type,
2590
+ )
2591
+ .map(({ entry, index }) => ({
2592
+ id: entry.id,
2593
+ credential: entry.credential,
2594
+ index,
2595
+ revision: entry.revision,
2596
+ }));
2355
2597
 
2356
2598
  if (credentials.length === 0) return undefined;
2357
2599
 
@@ -2377,12 +2619,14 @@ export class AuthStorage {
2377
2619
  sessionId?: string,
2378
2620
  excludedIndices: ReadonlySet<number> = new Set(),
2379
2621
  includeKnownUnusable = false,
2380
- ): { credential: ApiKeyCredential; index: number } | undefined {
2381
- const credentials = this.#getCredentialsForProvider(provider)
2382
- .map((credential, index) => ({ credential, index }))
2622
+ ): IndexedStoredCredential<ApiKeyCredential> | undefined {
2623
+ const credentials = this.#getStoredCredentials(provider)
2624
+ .map((entry, index) => ({ entry, index }))
2383
2625
  .filter(
2384
- (entry): entry is { credential: ApiKeyCredential; index: number } => entry.credential.type === "api_key",
2385
- );
2626
+ (item): item is { entry: StoredCredential & { credential: ApiKeyCredential }; index: number } =>
2627
+ item.entry.credential.type === "api_key",
2628
+ )
2629
+ .map(({ entry, index }) => ({ id: entry.id, credential: entry.credential, index, revision: entry.revision }));
2386
2630
  if (credentials.length === 0) return undefined;
2387
2631
 
2388
2632
  const providerKey = this.#getProviderTypeKey(provider, "api_key");
@@ -2421,11 +2665,20 @@ export class AuthStorage {
2421
2665
  }
2422
2666
 
2423
2667
  /** Updates a credential at index after OAuth token refresh. */
2424
- #replaceCredentialAt(provider: string, index: number, credential: AuthCredential, persist = true): void {
2668
+ #replaceCredentialAt(
2669
+ provider: string,
2670
+ index: number,
2671
+ credential: AuthCredential,
2672
+ persist = true,
2673
+ expectedId?: number,
2674
+ ): void {
2425
2675
  const entries = this.#getStoredCredentials(provider);
2426
2676
  if (index < 0 || index >= entries.length) return;
2427
2677
  const target = entries[index];
2428
- if (persist) this.#store.updateAuthCredential(target.id, credential);
2678
+ if (expectedId !== undefined && target.id !== expectedId) {
2679
+ throw new Error("Credential authority changed during refresh");
2680
+ }
2681
+ if (persist && !this.#store.refreshSnapshot) this.#store.updateAuthCredential(target.id, credential);
2429
2682
  const updated = [...entries];
2430
2683
  updated[index] = { id: target.id, credential };
2431
2684
  this.#setStoredCredentials(provider, updated);
@@ -2456,15 +2709,18 @@ export class AuthStorage {
2456
2709
  index: number,
2457
2710
  expectedCredential: AuthCredential,
2458
2711
  disabledCause: string,
2712
+ expectedId?: number,
2459
2713
  ): boolean {
2460
2714
  const entries = this.#getStoredCredentials(provider);
2461
- if (index < 0 || index >= entries.length) return false;
2462
- const target = entries[index];
2715
+ const targetIndex = expectedId === undefined ? index : entries.findIndex(entry => entry.id === expectedId);
2716
+ if (targetIndex < 0 || targetIndex >= entries.length) return false;
2717
+ const target = entries[targetIndex];
2718
+ if (expectedId !== undefined && target.id !== expectedId) return false;
2463
2719
  const serialized = serializeCredential(provider, expectedCredential);
2464
2720
  if (!serialized) return false;
2465
2721
  const disabled = this.#store.tryDisableAuthCredentialIfMatches(target.id, serialized.data, disabledCause);
2466
2722
  if (!disabled) return false;
2467
- const updated = entries.filter((_value, idx) => idx !== index);
2723
+ const updated = entries.filter((_value, idx) => idx !== targetIndex);
2468
2724
  this.#setStoredCredentials(provider, updated);
2469
2725
  this.#clearSelectorsForRemovedCredential(provider, new Set([target.id]), entries);
2470
2726
  this.#resetProviderAssignments(provider);
@@ -2501,6 +2757,35 @@ export class AuthStorage {
2501
2757
  this.#emitCredentialDisabled({ provider, disabledCause });
2502
2758
  }
2503
2759
 
2760
+ /**
2761
+ * Disable one credential through an authoritative remote store and reconcile
2762
+ * this AuthStorage instance only after that write succeeds. Remote stores
2763
+ * must never be mutated through the synchronous local delete path because
2764
+ * their snapshots do not own persistence authority.
2765
+ */
2766
+ async #disableCredentialRemotely(
2767
+ provider: string,
2768
+ credentialId: number,
2769
+ disabledCause: string,
2770
+ signal?: AbortSignal,
2771
+ expectedRevision?: number,
2772
+ ): Promise<boolean> {
2773
+ const disable = this.#store.disableAuthCredentialRemote?.bind(this.#store);
2774
+ if (!disable) return false;
2775
+ const disabled = await disable(credentialId, disabledCause, signal, expectedRevision);
2776
+ if (!disabled) return false;
2777
+ const entries = this.#getStoredCredentials(provider);
2778
+ if (!entries.some(entry => entry.id === credentialId)) return true;
2779
+ this.#setStoredCredentials(
2780
+ provider,
2781
+ entries.filter(entry => entry.id !== credentialId),
2782
+ );
2783
+ this.#clearSelectorsForRemovedCredential(provider, new Set([credentialId]), entries);
2784
+ this.#resetProviderAssignments(provider);
2785
+ this.#emitCredentialDisabled({ provider, disabledCause });
2786
+ return true;
2787
+ }
2788
+
2504
2789
  /** Clear every selector whose durable/in-memory target was just removed. */
2505
2790
  #clearSelectorsForRemovedCredential(
2506
2791
  provider: string,
@@ -2575,7 +2860,7 @@ export class AuthStorage {
2575
2860
  : this.#store.replaceAuthCredentialsForProvider(storageProvider, deduped);
2576
2861
  this.#setStoredCredentials(
2577
2862
  storageProvider,
2578
- stored.map(record => ({ id: record.id, credential: record.credential })),
2863
+ stored.map(record => ({ id: record.id, credential: record.credential, revision: record.revision })),
2579
2864
  );
2580
2865
  this.#resetProviderAssignments(storageProvider);
2581
2866
  }
@@ -2590,6 +2875,7 @@ export class AuthStorage {
2590
2875
  provider: entry.provider,
2591
2876
  credential: redacted,
2592
2877
  identityKey: resolveCredentialIdentityKey(provider, persisted),
2878
+ ...(entry.revision === undefined ? {} : { revision: entry.revision }),
2593
2879
  };
2594
2880
  });
2595
2881
  }
@@ -2606,14 +2892,15 @@ export class AuthStorage {
2606
2892
  async importCredentialIfAbsent(
2607
2893
  provider: string,
2608
2894
  credential: AuthCredential,
2895
+ owner?: object,
2609
2896
  ): Promise<AuthCredentialIfAbsentSnapshotResult> {
2610
2897
  const storageProvider = resolveOAuthStorageProvider(provider);
2611
2898
  if (this.#runtimeOverrides.has(storageProvider))
2612
2899
  return this.#snapshotSkipResult(storageProvider, "skipped-existing-runtime");
2613
- if (this.#configOverrides.has(storageProvider))
2900
+ if (this.#hasConfigOverride(storageProvider, owner))
2614
2901
  return this.#snapshotSkipResult(storageProvider, "skipped-existing-config");
2615
2902
  if (getEnvApiKey(storageProvider)) return this.#snapshotSkipResult(storageProvider, "skipped-existing-env");
2616
- if (this.#fallbackResolver?.(storageProvider))
2903
+ if (this.#resolveFallback(storageProvider, owner))
2617
2904
  return this.#snapshotSkipResult(storageProvider, "skipped-existing-fallback");
2618
2905
 
2619
2906
  const result = this.#store.upsertAuthCredentialRemoteIfAbsent
@@ -2621,7 +2908,7 @@ export class AuthStorage {
2621
2908
  : this.#store.upsertAuthCredentialForProviderIfAbsent(storageProvider, credential);
2622
2909
  this.#setStoredCredentials(
2623
2910
  storageProvider,
2624
- result.entries.map(entry => ({ id: entry.id, credential: entry.credential })),
2911
+ result.entries.map(entry => ({ id: entry.id, credential: entry.credential, revision: entry.revision })),
2625
2912
  );
2626
2913
  this.#resetProviderAssignments(storageProvider);
2627
2914
  if (result.inserted) this.#invalidateUsageCacheForProvider(storageProvider);
@@ -2639,7 +2926,7 @@ export class AuthStorage {
2639
2926
  : this.#store.upsertAuthCredentialForProvider(provider, credential);
2640
2927
  this.#setStoredCredentials(
2641
2928
  provider,
2642
- stored.map(record => ({ id: record.id, credential: record.credential })),
2929
+ stored.map(record => ({ id: record.id, credential: record.credential, revision: record.revision })),
2643
2930
  );
2644
2931
  this.#resetProviderAssignments(provider);
2645
2932
  this.#invalidateUsageCacheForProvider(provider);
@@ -2689,23 +2976,44 @@ export class AuthStorage {
2689
2976
  * Check if any form of auth is configured for a provider.
2690
2977
  * Unlike getApiKey(), this doesn't refresh OAuth tokens.
2691
2978
  */
2692
- #hasConfiguredAuth(storageProvider: string): boolean {
2979
+ #hasConfiguredAuth(storageProvider: string, owner?: object): boolean {
2693
2980
  if (this.hasRuntimeApiKey(storageProvider)) return true;
2694
- if (this.#configOverrides.has(storageProvider)) return true;
2981
+ if (this.#hasConfigOverride(storageProvider, owner)) return true;
2695
2982
  if (this.#getCredentialsForProvider(storageProvider).length > 0) return true;
2696
2983
  if (getEnvApiKey(storageProvider)) return true;
2697
- if (this.#fallbackResolver?.(storageProvider)) return true;
2984
+ if (this.#resolveFallback(storageProvider, owner)) return true;
2985
+ return false;
2986
+ }
2987
+
2988
+ disableCredentialByIdIfRevision(id: number, expectedRevision: number, disabledCause: string): boolean {
2989
+ const cause = normalizeDisabledCause(disabledCause);
2990
+ if (this.#store.tryDisableAuthCredentialIfRevision?.(id, expectedRevision, cause) !== true) return false;
2991
+ for (const [provider, entries] of this.#data) {
2992
+ if (!entries.some(entry => entry.id === id)) continue;
2993
+ this.#setStoredCredentials(
2994
+ provider,
2995
+ entries.filter(entry => entry.id !== id),
2996
+ );
2997
+ this.#clearSelectorsForRemovedCredential(provider, new Set([id]), entries);
2998
+ this.#resetProviderAssignments(provider);
2999
+ this.#emitCredentialDisabled({ provider, disabledCause: cause });
3000
+ return true;
3001
+ }
2698
3002
  return false;
2699
3003
  }
2700
3004
 
2701
- hasAuth(provider: string, sessionId?: string): boolean {
3005
+ hasAuth(provider: string, sessionId?: string, options?: Pick<AuthApiKeyOptions, "owner">): boolean {
2702
3006
  const storageProvider = resolveOAuthStorageProvider(provider);
2703
3007
  try {
2704
- this.#resolveSelectedStoredCredential(storageProvider, undefined, sessionId);
3008
+ this.#resolveSelectedStoredCredential(
3009
+ storageProvider,
3010
+ options?.owner ? { owner: options.owner } : undefined,
3011
+ sessionId,
3012
+ );
2705
3013
  } catch {
2706
3014
  return false;
2707
3015
  }
2708
- return this.#hasConfiguredAuth(storageProvider);
3016
+ return this.#hasConfiguredAuth(storageProvider, options?.owner);
2709
3017
  }
2710
3018
 
2711
3019
  /**
@@ -2713,15 +3021,24 @@ export class AuthStorage {
2713
3021
  * Mirrors getApiKey selector validation, overrides, session OAuth stickiness,
2714
3022
  * cached command-key usability, OAuth retry, and environment fallback order.
2715
3023
  */
2716
- getEffectiveCredentialType(provider: string, sessionId?: string): AuthCredential["type"] | undefined {
3024
+ getEffectiveCredentialType(
3025
+ provider: string,
3026
+ sessionId?: string,
3027
+ options?: Pick<AuthApiKeyOptions, "owner">,
3028
+ ): AuthCredential["type"] | undefined {
2717
3029
  const storageProvider = resolveOAuthStorageProvider(provider);
2718
3030
  let selected: ({ index: number } & StoredCredential) | undefined;
2719
3031
  try {
2720
- selected = this.#resolveSelectedStoredCredential(storageProvider, undefined, sessionId);
3032
+ selected = this.#resolveSelectedStoredCredential(
3033
+ storageProvider,
3034
+ options?.owner ? { owner: options.owner } : undefined,
3035
+ sessionId,
3036
+ );
2721
3037
  } catch {
2722
3038
  return undefined;
2723
3039
  }
2724
- if (this.hasRuntimeApiKey(storageProvider) || this.#configOverrides.has(storageProvider)) return "api_key";
3040
+ if (this.hasRuntimeApiKey(storageProvider) || this.#hasConfigOverride(storageProvider, options?.owner))
3041
+ return "api_key";
2725
3042
  if (selected) return selected.credential.type;
2726
3043
 
2727
3044
  const credentials = this.#getCredentialsForProvider(storageProvider);
@@ -2734,19 +3051,23 @@ export class AuthStorage {
2734
3051
  }
2735
3052
  if (credentials.some(credential => credential.type === "oauth")) return "oauth";
2736
3053
  if (apiKeys.length > 0) return "api_key";
2737
- if (getEnvApiKey(storageProvider) || this.#fallbackResolver?.(storageProvider)) return "api_key";
3054
+ if (getEnvApiKey(storageProvider) || this.#resolveFallback(storageProvider, options?.owner)) return "api_key";
2738
3055
  return undefined;
2739
3056
  }
2740
3057
 
2741
3058
  /**
2742
3059
  * Check whether configured auth is currently usable without resolving credentials.
2743
3060
  */
2744
- hasUsableAuth(provider: string): boolean {
3061
+ hasUsableAuth(provider: string, options?: Pick<AuthApiKeyOptions, "owner">): boolean {
2745
3062
  const storageProvider = resolveOAuthStorageProvider(provider);
2746
3063
  try {
2747
- const selectedCredential = this.#resolveSelectedStoredCredential(storageProvider, undefined, undefined);
3064
+ const selectedCredential = this.#resolveSelectedStoredCredential(
3065
+ storageProvider,
3066
+ options?.owner ? { owner: options.owner } : undefined,
3067
+ undefined,
3068
+ );
2748
3069
  if (this.hasRuntimeApiKey(storageProvider)) return true;
2749
- if (this.#configOverrides.has(storageProvider)) return true;
3070
+ if (this.#hasConfigOverride(storageProvider, options?.owner)) return true;
2750
3071
  if (selectedCredential) {
2751
3072
  if (selectedCredential.credential.type === "api_key") {
2752
3073
  return (
@@ -2787,7 +3108,7 @@ export class AuthStorage {
2787
3108
  } catch {
2788
3109
  return false;
2789
3110
  }
2790
- return Boolean(getEnvApiKey(storageProvider) || this.#fallbackResolver?.(storageProvider));
3111
+ return Boolean(getEnvApiKey(storageProvider) || this.#resolveFallback(storageProvider, options?.owner));
2791
3112
  }
2792
3113
 
2793
3114
  /**
@@ -2800,10 +3121,14 @@ export class AuthStorage {
2800
3121
  /**
2801
3122
  * Get OAuth credentials for a provider.
2802
3123
  */
2803
- getOAuthCredential(provider: string, sessionId?: string): OAuthCredential | undefined {
3124
+ getOAuthCredential(
3125
+ provider: string,
3126
+ sessionId?: string,
3127
+ options?: Pick<AuthApiKeyOptions, "owner">,
3128
+ ): OAuthCredential | undefined {
2804
3129
  const selected = this.#resolveSelectedStoredCredential(
2805
3130
  resolveOAuthStorageProvider(provider),
2806
- undefined,
3131
+ options?.owner ? { owner: options.owner } : undefined,
2807
3132
  sessionId,
2808
3133
  );
2809
3134
  if (selected?.credential.type === "oauth") return selected.credential;
@@ -2820,7 +3145,11 @@ export class AuthStorage {
2820
3145
  * first call before any `getApiKey` has been issued, or single-credential setups).
2821
3146
  * Returns `undefined` when no OAuth credential carries an `accountId`.
2822
3147
  */
2823
- getOAuthAccountId(provider: string, sessionId?: string): string | undefined {
3148
+ getOAuthAccountId(
3149
+ provider: string,
3150
+ sessionId?: string,
3151
+ options?: Pick<AuthApiKeyOptions, "owner">,
3152
+ ): string | undefined {
2824
3153
  provider = resolveOAuthStorageProvider(provider);
2825
3154
  const allCredentials = this.#getCredentialsForProvider(provider);
2826
3155
  const oauthCredentials = allCredentials.filter((c): c is OAuthCredential => c.type === "oauth");
@@ -2828,11 +3157,15 @@ export class AuthStorage {
2828
3157
 
2829
3158
  // Runtime / config overrides bypass OAuth account_uuid attribution — the
2830
3159
  // caller is authenticating with an explicit key, not the broker's OAuth.
2831
- if (this.#runtimeOverrides.has(provider) || this.#configOverrides.has(provider)) return undefined;
3160
+ if (this.#runtimeOverrides.has(provider) || this.#hasConfigOverride(provider, options?.owner)) return undefined;
2832
3161
 
2833
3162
  // Prefer the session-sticky credential when available.
2834
3163
 
2835
- const scopedSelection = this.#resolveSelectedStoredCredential(provider, undefined, sessionId);
3164
+ const scopedSelection = this.#resolveSelectedStoredCredential(
3165
+ provider,
3166
+ options?.owner ? { owner: options.owner } : undefined,
3167
+ sessionId,
3168
+ );
2836
3169
  if (scopedSelection?.credential.type === "api_key") return undefined;
2837
3170
  if (scopedSelection?.credential.type === "oauth") {
2838
3171
  const accountId = scopedSelection.credential.accountId;
@@ -2848,7 +3181,7 @@ export class AuthStorage {
2848
3181
  // account_uuid injection would misattribute traffic. Only apply this guard when
2849
3182
  // sessionPref is absent; a recorded OAuth sticky (sessionPref.type === "oauth") must
2850
3183
  // NOT be blocked even if an env key also happens to exist.
2851
- if (!sessionPref && (getEnvApiKey(provider) || this.#fallbackResolver?.(provider))) return undefined;
3184
+ if (!sessionPref && (getEnvApiKey(provider) || this.#resolveFallback(provider, options?.owner))) return undefined;
2852
3185
  // Resolve the sticky index against the full credential list — the index is
2853
3186
  // recorded against the unfiltered provider array (by #recordSessionCredential /
2854
3187
  // #tryOAuthCredential), not the OAuth-only subset, so dereferencing it into the
@@ -2973,6 +3306,16 @@ export class AuthStorage {
2973
3306
  credentials = await loginKimi(ctrl);
2974
3307
  break;
2975
3308
  }
3309
+ case "kiro": {
3310
+ const { loginKiro } = await import("./utils/oauth/kiro");
3311
+ credentials = await loginKiro({
3312
+ onAuth: (url, instructions) => ctrl.onAuth({ url, instructions }),
3313
+ onPrompt: ctrl.onPrompt,
3314
+ onProgress: ctrl.onProgress,
3315
+ signal: ctrl.signal,
3316
+ });
3317
+ break;
3318
+ }
2976
3319
  case "kilo": {
2977
3320
  const { loginKilo } = await import("./utils/oauth/kilo");
2978
3321
  credentials = await loginKilo(ctrl);
@@ -3004,6 +3347,12 @@ export class AuthStorage {
3004
3347
  await saveApiKeyCredential(apiKey);
3005
3348
  return;
3006
3349
  }
3350
+ case "commandcode-goat": {
3351
+ const { loginCommandCode } = await import("./utils/oauth/commandcode");
3352
+ const apiKey = await loginCommandCode(ctrl);
3353
+ await saveApiKeyCredential(apiKey);
3354
+ return;
3355
+ }
3007
3356
  case "lm-studio": {
3008
3357
  const { loginLmStudio } = await import("./utils/oauth/lm-studio");
3009
3358
  const apiKey = await loginLmStudio(ctrl);
@@ -3547,7 +3896,9 @@ export class AuthStorage {
3547
3896
  }
3548
3897
  return lastGood;
3549
3898
  })().finally(() => {
3550
- this.#usageRequestInFlight.delete(cacheKey);
3899
+ if (this.#usageRequestInFlight.get(cacheKey) === promise) {
3900
+ this.#usageRequestInFlight.delete(cacheKey);
3901
+ }
3551
3902
  });
3552
3903
 
3553
3904
  this.#usageRequestInFlight.set(cacheKey, promise);
@@ -3555,6 +3906,7 @@ export class AuthStorage {
3555
3906
  }
3556
3907
 
3557
3908
  #collectUsageRequests(options?: {
3909
+ provider?: Provider;
3558
3910
  baseUrlResolver?: (provider: Provider) => string | undefined;
3559
3911
  }): UsageRequestDescriptor[] {
3560
3912
  const resolver = this.#usageProviderResolver;
@@ -3568,6 +3920,7 @@ export class AuthStorage {
3568
3920
 
3569
3921
  for (const providerId of providers) {
3570
3922
  const provider = providerId as Provider;
3923
+ if (options?.provider && options.provider !== provider) continue;
3571
3924
  const providerImpl = resolver(provider);
3572
3925
  if (!providerImpl) continue;
3573
3926
  const baseUrl = options?.baseUrlResolver?.(provider);
@@ -3767,6 +4120,7 @@ export class AuthStorage {
3767
4120
  }
3768
4121
 
3769
4122
  async fetchUsageReports(options?: {
4123
+ provider?: Provider;
3770
4124
  baseUrlResolver?: (provider: Provider) => string | undefined;
3771
4125
  /** Caller's cancel signal; only rejects this caller, never the shared upstream fetch. */
3772
4126
  signal?: AbortSignal;
@@ -3777,6 +4131,15 @@ export class AuthStorage {
3777
4131
  // `RemoteAuthCredentialStore` implements the store hook so a gateway
3778
4132
  // backed by a broker automatically routes usage to the broker without
3779
4133
  // needing the caller to wire it explicitly.
4134
+ const scopedStoreFetch = options?.provider
4135
+ ? (this.#fetchUsageReportsForProviderOverride ?? this.#store.fetchUsageReportsForProvider?.bind(this.#store))
4136
+ : undefined;
4137
+ if (scopedStoreFetch && options?.provider) {
4138
+ return raceUsageWithSignal(scopedStoreFetch(options.provider), options.signal);
4139
+ }
4140
+ if (options?.provider && (this.#fetchUsageReportsOverride || this.#store.fetchUsageReports)) {
4141
+ throw new Error("Provider-scoped usage fetch is unavailable");
4142
+ }
3780
4143
  const override = this.#fetchUsageReportsOverride ?? this.#store.fetchUsageReports?.bind(this.#store);
3781
4144
  if (override) {
3782
4145
  // Reuse the in-flight map so concurrent callers (widget poll + format
@@ -3789,7 +4152,9 @@ export class AuthStorage {
3789
4152
  // Don't forward the caller signal into the shared fetch — first caller's
3790
4153
  // abort would otherwise cancel the upstream for every peer.
3791
4154
  shared = override().finally(() => {
3792
- this.#usageReportsInFlight.delete(OVERRIDE_KEY);
4155
+ if (this.#usageReportsInFlight.get(OVERRIDE_KEY) === shared) {
4156
+ this.#usageReportsInFlight.delete(OVERRIDE_KEY);
4157
+ }
3793
4158
  });
3794
4159
  this.#usageReportsInFlight.set(OVERRIDE_KEY, shared);
3795
4160
  }
@@ -3858,7 +4223,9 @@ export class AuthStorage {
3858
4223
  }
3859
4224
  return resolved;
3860
4225
  })().finally(() => {
3861
- this.#usageReportsInFlight.delete(cacheKey);
4226
+ if (this.#usageReportsInFlight.get(cacheKey) === promise) {
4227
+ this.#usageReportsInFlight.delete(cacheKey);
4228
+ }
3862
4229
  });
3863
4230
 
3864
4231
  this.#usageReportsInFlight.set(cacheKey, promise);
@@ -4192,9 +4559,11 @@ export class AuthStorage {
4192
4559
  async markUsageLimitReached(
4193
4560
  provider: string,
4194
4561
  sessionId: string | undefined,
4195
- options?: { retryAfterMs?: number; baseUrl?: string; signal?: AbortSignal },
4562
+ options?: { retryAfterMs?: number; baseUrl?: string; signal?: AbortSignal; owner?: object },
4196
4563
  ): Promise<boolean> {
4197
4564
  provider = resolveOAuthStorageProvider(provider);
4565
+ const ownerOverride = this.#configOverrideRegistration(provider, options?.owner);
4566
+ if (ownerOverride && !ownerOverride.envSourced) return false;
4198
4567
  const sessionCredential = this.#getSessionCredential(provider, sessionId);
4199
4568
  if (!sessionCredential) return false;
4200
4569
 
@@ -4294,12 +4663,12 @@ export class AuthStorage {
4294
4663
  providerKey: string;
4295
4664
  provider: string;
4296
4665
  order: number[];
4297
- credentials: Array<{ credential: OAuthCredential; index: number }>;
4666
+ credentials: OAuthCredentialSelection[];
4298
4667
  options?: AuthApiKeyOptions;
4299
4668
  strategy: CredentialRankingStrategy;
4300
4669
  }): Promise<
4301
4670
  Array<{
4302
- selection: { credential: OAuthCredential; index: number };
4671
+ selection: OAuthCredentialSelection;
4303
4672
  usage: UsageReport | null;
4304
4673
  usageChecked: boolean;
4305
4674
  }>
@@ -4307,7 +4676,7 @@ export class AuthStorage {
4307
4676
  const nowMs = Date.now();
4308
4677
  const { strategy } = args;
4309
4678
  const ranked: Array<{
4310
- selection: { credential: OAuthCredential; index: number };
4679
+ selection: OAuthCredentialSelection;
4311
4680
  usage: UsageReport | null;
4312
4681
  usageChecked: boolean;
4313
4682
  blocked: boolean;
@@ -4455,18 +4824,29 @@ export class AuthStorage {
4455
4824
  return undefined;
4456
4825
  }
4457
4826
  const selectedCredential = this.#resolveSelectedStoredCredential(provider, options, sessionId);
4458
- const selectedOAuthCredential =
4827
+ const selectedOAuthCredential: OAuthCredentialSelection | undefined =
4459
4828
  selectedCredential?.credential.type === "oauth"
4460
- ? { credential: selectedCredential.credential, index: selectedCredential.index }
4829
+ ? {
4830
+ id: selectedCredential.id,
4831
+ credential: selectedCredential.credential,
4832
+ index: selectedCredential.index,
4833
+ revision: selectedCredential.revision,
4834
+ }
4461
4835
  : undefined;
4462
4836
  if (selectedCredential && !selectedOAuthCredential) return undefined;
4463
4837
  const credentials = selectedOAuthCredential
4464
4838
  ? [selectedOAuthCredential]
4465
- : this.#getCredentialsForProvider(provider)
4466
- .map((credential, index) => ({ credential, index }))
4839
+ : this.#getStoredCredentials(provider)
4467
4840
  .filter(
4468
- (entry): entry is { credential: OAuthCredential; index: number } => entry.credential.type === "oauth",
4469
- );
4841
+ (entry): entry is StoredCredential & { credential: OAuthCredential } =>
4842
+ entry.credential.type === "oauth",
4843
+ )
4844
+ .map((entry, index) => ({
4845
+ id: entry.id,
4846
+ credential: entry.credential,
4847
+ index,
4848
+ revision: entry.revision,
4849
+ }));
4470
4850
 
4471
4851
  if (credentials.length === 0) return undefined;
4472
4852
 
@@ -4489,7 +4869,7 @@ export class AuthStorage {
4489
4869
  ? await this.#rankOAuthSelections({ providerKey, provider, order, credentials, options, strategy: strategy! })
4490
4870
  : order
4491
4871
  .map(idx => credentials[idx])
4492
- .filter((selection): selection is { credential: OAuthCredential; index: number } => Boolean(selection))
4872
+ .filter((selection): selection is OAuthCredentialSelection => Boolean(selection))
4493
4873
  .map(selection => ({ selection, usage: null, usageChecked: false }));
4494
4874
 
4495
4875
  // Soft `--prefer-credential` preference: reorder the preferred row to the
@@ -4499,7 +4879,11 @@ export class AuthStorage {
4499
4879
  if (!selectedCredential) {
4500
4880
  const preferredSelector = this.#getPreferredCredentialSelector(provider, options);
4501
4881
  if (preferredSelector) {
4502
- this.#assertPreferredCredentialSelectorUsable(resolveOAuthStorageProvider(provider), preferredSelector);
4882
+ this.#assertPreferredCredentialSelectorUsable(
4883
+ resolveOAuthStorageProvider(provider),
4884
+ preferredSelector,
4885
+ options?.owner,
4886
+ );
4503
4887
  }
4504
4888
  const preferredSelection = preferredSelector
4505
4889
  ? this.#findCredentialBySelector(provider, preferredSelector)
@@ -4531,14 +4915,15 @@ export class AuthStorage {
4531
4915
  }
4532
4916
  await Promise.all(
4533
4917
  candidates.map(async candidate => {
4918
+ if (!this.#reconcileOAuthCredentialSelection(provider, candidate.selection)) return;
4534
4919
  if (Date.now() + OAUTH_REFRESH_SKEW_MS < candidate.selection.credential.expires) return;
4535
- const latestCredential = this.#getCredentialsForProvider(provider)[candidate.selection.index];
4920
+ const latestCredential = candidate.selection.credential;
4536
4921
  if (latestCredential?.type === "oauth" && Date.now() + OAUTH_REFRESH_SKEW_MS < latestCredential.expires) {
4537
4922
  candidate.selection.credential = latestCredential;
4538
4923
  return;
4539
4924
  }
4540
4925
  try {
4541
- const credentialId = this.#getStoredCredentials(provider)[candidate.selection.index]?.id;
4926
+ const credentialId = candidate.selection.id;
4542
4927
  const refreshedCredentials = await this.#refreshOAuthCredential(
4543
4928
  provider,
4544
4929
  candidate.selection.credential,
@@ -4550,12 +4935,14 @@ export class AuthStorage {
4550
4935
  ...refreshedCredentials,
4551
4936
  type: "oauth",
4552
4937
  };
4938
+ if (!this.#reconcileOAuthCredentialSelection(provider, candidate.selection)) return;
4553
4939
  candidate.selection.credential = updated;
4554
4940
  this.#replaceCredentialAt(
4555
4941
  provider,
4556
4942
  candidate.selection.index,
4557
4943
  updated,
4558
4944
  !refreshedCredentials.persistedByLease,
4945
+ credentialId,
4559
4946
  );
4560
4947
  } catch {}
4561
4948
  }),
@@ -4781,6 +5168,15 @@ export class AuthStorage {
4781
5168
  }
4782
5169
  try {
4783
5170
  const refreshed = await Promise.race([refreshPromise, cancellation.promise]);
5171
+ let effectiveRefreshed = refreshed;
5172
+ if (this.#refreshOAuthCredentialOverride && this.#store.refreshSnapshot) {
5173
+ await this.#store.refreshSnapshot();
5174
+ const accepted = this.#store.listAuthCredentials(provider).find(row => row.id === credentialId)?.credential;
5175
+ if (accepted?.type !== "oauth") {
5176
+ throw new Error("Credential authority changed during refresh");
5177
+ }
5178
+ effectiveRefreshed = accepted;
5179
+ }
4784
5180
  // Return the FULL authority of the effective credential: rotated
4785
5181
  // tokens from upstream plus the identity metadata and MCP binding of
4786
5182
  // the (possibly guard-adopted) credential that was actually
@@ -4789,12 +5185,12 @@ export class AuthStorage {
4789
5185
  // next refresh token to the wrong endpoint — or relabel rotated
4790
5186
  // tokens with stale identity.
4791
5187
  const authority: RefreshedOAuthCredentials = {
4792
- ...refreshed,
4793
- accountId: refreshed.accountId ?? credential.accountId,
4794
- email: refreshed.email ?? credential.email,
4795
- projectId: refreshed.projectId ?? credential.projectId,
4796
- enterpriseUrl: refreshed.enterpriseUrl ?? credential.enterpriseUrl,
4797
- mcpBinding: (refreshed as RefreshedOAuthCredentials).mcpBinding ?? credential.mcpBinding,
5188
+ ...effectiveRefreshed,
5189
+ accountId: effectiveRefreshed.accountId ?? credential.accountId,
5190
+ email: effectiveRefreshed.email ?? credential.email,
5191
+ projectId: effectiveRefreshed.projectId ?? credential.projectId,
5192
+ enterpriseUrl: effectiveRefreshed.enterpriseUrl ?? credential.enterpriseUrl,
5193
+ mcpBinding: (effectiveRefreshed as RefreshedOAuthCredentials).mcpBinding ?? credential.mcpBinding,
4798
5194
  };
4799
5195
  if (refreshLease) {
4800
5196
  const completeLease = this.#store.completeOAuthRefreshLease?.bind(this.#store);
@@ -4823,15 +5219,28 @@ export class AuthStorage {
4823
5219
  }
4824
5220
  }
4825
5221
 
5222
+ #reconcileOAuthCredentialSelection(provider: string, selection: OAuthCredentialSelection): boolean {
5223
+ const entries = this.#getStoredCredentials(provider);
5224
+ const index = entries.findIndex(entry => entry.id === selection.id);
5225
+ if (index === -1) return false;
5226
+ const current = entries[index];
5227
+ if (current?.credential.type !== "oauth") return false;
5228
+ selection.index = index;
5229
+ selection.credential = current.credential;
5230
+ selection.revision = current.revision;
5231
+ return true;
5232
+ }
5233
+
4826
5234
  async #prepareOAuthCredentialForRequest(
4827
5235
  provider: string,
4828
- selection: { credential: OAuthCredential; index: number },
5236
+ selection: OAuthCredentialSelection,
4829
5237
  options: AuthApiKeyOptions | undefined,
4830
5238
  ): Promise<boolean> {
5239
+ if (!this.#reconcileOAuthCredentialSelection(provider, selection)) return false;
4831
5240
  const prepare = this.#store.prepareForRequest?.bind(this.#store);
4832
5241
  if (!prepare) return true;
4833
5242
  const stored = this.#getStoredCredentials(provider);
4834
- const selected = stored[selection.index];
5243
+ const selected = stored.find(entry => entry.id === selection.id);
4835
5244
  if (selected?.credential.type !== "oauth") return false;
4836
5245
 
4837
5246
  const prepared = await prepare(selected.id, { signal: options?.signal });
@@ -4839,21 +5248,22 @@ export class AuthStorage {
4839
5248
  const latestRows = this.#store.listAuthCredentials(provider);
4840
5249
  this.#setStoredCredentials(
4841
5250
  provider,
4842
- latestRows.map(row => ({ id: row.id, credential: row.credential })),
5251
+ latestRows.map(row => ({ id: row.id, credential: row.credential, revision: row.revision })),
4843
5252
  );
4844
- const latestIndex = latestRows.findIndex(row => row.id === selected.id);
5253
+ const latestIndex = latestRows.findIndex(row => row.id === selection.id);
4845
5254
  if (latestIndex === -1) return false;
4846
5255
  const latest = latestRows[latestIndex];
4847
5256
  if (latest?.credential.type !== "oauth") return false;
4848
5257
  selection.index = latestIndex;
4849
5258
  selection.credential = latest.credential;
5259
+ selection.revision = latest.revision;
4850
5260
  return true;
4851
5261
  }
4852
5262
 
4853
5263
  /** Attempts to use a single OAuth credential, checking usage and refreshing token. */
4854
5264
  async #tryOAuthCredential(
4855
5265
  provider: Provider,
4856
- selection: { credential: OAuthCredential; index: number },
5266
+ selection: OAuthCredentialSelection,
4857
5267
  providerKey: string,
4858
5268
  sessionId: string | undefined,
4859
5269
  options: AuthApiKeyOptions | undefined,
@@ -4873,6 +5283,7 @@ export class AuthStorage {
4873
5283
  usagePrechecked = false,
4874
5284
  enforceProRequirement,
4875
5285
  } = usageOptions;
5286
+ if (!this.#reconcileOAuthCredentialSelection(provider, selection)) return undefined;
4876
5287
  if (!allowBlocked && this.#isCredentialBlocked(providerKey, selection.index)) {
4877
5288
  return undefined;
4878
5289
  }
@@ -4912,6 +5323,8 @@ export class AuthStorage {
4912
5323
  }
4913
5324
 
4914
5325
  try {
5326
+ if (!this.#reconcileOAuthCredentialSelection(provider, selection)) return undefined;
5327
+ const selectionCredentialId = selection.id;
4915
5328
  let result: { newCredentials: OAuthCredentials; apiKey: string } | null;
4916
5329
  // The refresh result carries the effective (possibly guard-adopted)
4917
5330
  // credential's binding; `updated` must persist it or the next refresh
@@ -4922,7 +5335,7 @@ export class AuthStorage {
4922
5335
  const refreshedCredentials = await this.#refreshOAuthCredential(
4923
5336
  provider,
4924
5337
  selection.credential,
4925
- this.#getStoredCredentials(provider)[selection.index]?.id,
5338
+ selectionCredentialId,
4926
5339
  options?.signal,
4927
5340
  );
4928
5341
  refreshedAuthority = refreshedCredentials;
@@ -4939,7 +5352,7 @@ export class AuthStorage {
4939
5352
  const refreshedCredentials = await this.#refreshOAuthCredential(
4940
5353
  provider,
4941
5354
  selection.credential,
4942
- this.#getStoredCredentials(provider)[selection.index]?.id,
5355
+ selectionCredentialId,
4943
5356
  options?.signal,
4944
5357
  );
4945
5358
  refreshedAuthority = refreshedCredentials;
@@ -4960,7 +5373,13 @@ export class AuthStorage {
4960
5373
  enterpriseUrl: result.newCredentials.enterpriseUrl ?? selection.credential.enterpriseUrl,
4961
5374
  mcpBinding: refreshedAuthority.mcpBinding,
4962
5375
  };
4963
- this.#replaceCredentialAt(provider, selection.index, updated, !refreshedAuthority.persistedByLease);
5376
+ this.#replaceCredentialAt(
5377
+ provider,
5378
+ selection.index,
5379
+ updated,
5380
+ !refreshedAuthority.persistedByLease,
5381
+ selectionCredentialId,
5382
+ );
4964
5383
 
4965
5384
  if ((checkUsage && !allowBlocked) || requiresProModel) {
4966
5385
  const sameAccount = selection.credential.accountId === updated.accountId;
@@ -4984,10 +5403,17 @@ export class AuthStorage {
4984
5403
  return undefined;
4985
5404
  }
4986
5405
  }
5406
+ if (!this.#reconcileOAuthCredentialSelection(provider, selection)) return undefined;
5407
+ if (!authCredentialEquals(selection.credential, updated)) return undefined;
4987
5408
  this.#recordSessionCredential(provider, sessionId, "oauth", selection.index);
4988
5409
  return { apiKey: result.apiKey, credential: updated };
4989
5410
  } catch (error) {
4990
- const errorMsg = String(error);
5411
+ // Auth-broker errors retain the sanitized upstream body separately from
5412
+ // their transport message. Include that body for failure classification
5413
+ // (the broker's 500 envelope otherwise hides `invalid_grant`) while
5414
+ // preserving the original error object for callers and diagnostics.
5415
+ const brokerBody = readBrokerErrorBody(error);
5416
+ const errorMsg = [String(error), brokerBody].filter((part): part is string => Boolean(part)).join(" ");
4991
5417
  // Peer-rotation recovery runs before ANY failure classification: a
4992
5418
  // concurrent process may have rotated the refresh token, which
4993
5419
  // invalidates the snapshot token we just attempted. Re-read the row —
@@ -5006,7 +5432,7 @@ export class AuthStorage {
5006
5432
  // selection snapshot would misread that adoption as a fresh peer
5007
5433
  // rotation and loop reload-retry instead of classifying the failure.
5008
5434
  const attemptedRefreshToken = getAttemptedRefreshToken(error) ?? selection.credential.refresh;
5009
- const attemptedCredentialId = this.#getStoredCredentials(provider)[selection.index]?.id;
5435
+ const attemptedCredentialId = selection.id;
5010
5436
  if (attemptedCredentialId !== undefined) {
5011
5437
  const latestRow = this.#store.listAuthCredentials(provider).find(row => row.id === attemptedCredentialId);
5012
5438
  const latestCredential = latestRow?.credential;
@@ -5062,6 +5488,7 @@ export class AuthStorage {
5062
5488
  selection.index,
5063
5489
  selection.credential,
5064
5490
  `oauth refresh failed: ${errorMsg}`,
5491
+ attemptedCredentialId,
5065
5492
  );
5066
5493
  if (!disabled) {
5067
5494
  // The CAS predicate compares the row's serialized `data`, so it also
@@ -5082,7 +5509,35 @@ export class AuthStorage {
5082
5509
  index: selection.index,
5083
5510
  credentialId: attemptedCredentialId,
5084
5511
  });
5085
- this.#disableCredentialById(provider, attemptedCredentialId, `oauth refresh failed: ${errorMsg}`);
5512
+ const disabledCause = `oauth refresh failed: ${errorMsg}`;
5513
+ if (this.#store.disableAuthCredentialRemote) {
5514
+ try {
5515
+ const disabled = await this.#disableCredentialRemotely(
5516
+ provider,
5517
+ attemptedCredentialId,
5518
+ disabledCause,
5519
+ options?.signal,
5520
+ selection.revision,
5521
+ );
5522
+ if (!disabled) {
5523
+ await this.reload();
5524
+ return this.#resolveOAuthSelection(provider, sessionId, options, reloadsUsed + 1);
5525
+ }
5526
+ } catch (disableError) {
5527
+ // A failed broker mutation must not replace the provider's
5528
+ // original authentication failure. We also deliberately do
5529
+ // not remove the row locally: remote authority may still be
5530
+ // active and can only be changed by its broker.
5531
+ logger.warn("OAuth refresh remote disable failed", {
5532
+ provider,
5533
+ credentialId: attemptedCredentialId,
5534
+ error: String(disableError),
5535
+ });
5536
+ throw error;
5537
+ }
5538
+ } else {
5539
+ this.#disableCredentialById(provider, attemptedCredentialId, disabledCause);
5540
+ }
5086
5541
  } else {
5087
5542
  logger.debug("OAuth refresh disable lost CAS; reloading after peer rotation", {
5088
5543
  provider,
@@ -5181,15 +5636,20 @@ export class AuthStorage {
5181
5636
  * and get a best-effort token. For GitHub Copilot we preserve enterprise
5182
5637
  * routing metadata so discovery can hit the correct host.
5183
5638
  */
5184
- async peekApiKey(provider: string): Promise<string | undefined> {
5639
+ async peekApiKey(provider: string, options?: Pick<AuthApiKeyOptions, "owner">): Promise<string | undefined> {
5185
5640
  provider = resolveOAuthStorageProvider(provider);
5186
5641
  const runtimeKey = this.#runtimeOverrides.get(provider);
5187
5642
  if (runtimeKey) return runtimeKey;
5188
5643
 
5189
- const configKey = this.#configOverrides.get(provider);
5190
- if (configKey && !this.#configOverrideEnvSourced.has(provider)) return configKey;
5644
+ const configOverride = this.#configOverrideRegistration(provider, options?.owner);
5645
+ const configKey = configOverride?.apiKey;
5646
+ if (configKey && !configOverride?.envSourced) return configKey;
5191
5647
 
5192
- const selectedCredential = this.#resolveSelectedStoredCredential(provider, undefined, undefined);
5648
+ const selectedCredential = this.#resolveSelectedStoredCredential(
5649
+ provider,
5650
+ options?.owner ? { owner: options.owner } : undefined,
5651
+ undefined,
5652
+ );
5193
5653
  if (configKey) {
5194
5654
  // Env-sourced (`apiKeyEnv`) override: same precedence as getApiKey —
5195
5655
  // a stored api_key credential from `auth login` wins, stored OAuth
@@ -5239,7 +5699,7 @@ export class AuthStorage {
5239
5699
  }
5240
5700
  }
5241
5701
 
5242
- return getEnvApiKey(provider) || this.#fallbackResolver?.(provider);
5702
+ return getEnvApiKey(provider) || this.#resolveFallback(provider, options?.owner);
5243
5703
  }
5244
5704
 
5245
5705
  /**
@@ -5270,9 +5730,10 @@ export class AuthStorage {
5270
5730
  // (e.g. an auth-gateway) and supplied the bearer for that endpoint —
5271
5731
  // honor it instead of forwarding an upstream OAuth token that the proxy
5272
5732
  // won't accept.
5273
- const configKey = this.#configOverrides.get(provider);
5733
+ const configOverride = this.#configOverrideRegistration(provider, options?.owner);
5734
+ const configKey = configOverride?.apiKey;
5274
5735
  if (configKey) {
5275
- if (!this.#configOverrideEnvSourced.has(provider)) return configKey;
5736
+ if (!configOverride?.envSourced) return configKey;
5276
5737
  // The override is an `apiKeyEnv` indirection, not a pinned value. A
5277
5738
  // stored api_key credential from `auth login` is actively managed
5278
5739
  // (validated at login, rotated on 401), while the pointed-to env
@@ -5334,7 +5795,7 @@ export class AuthStorage {
5334
5795
  if (sessionId) this.#sessionLastCredential.get(provider)?.delete(sessionId);
5335
5796
  const envKey = getEnvApiKey(provider);
5336
5797
  if (envKey) return envKey;
5337
- return this.#fallbackResolver?.(provider) ?? undefined;
5798
+ return this.#resolveFallback(provider, options?.owner) ?? undefined;
5338
5799
  }
5339
5800
 
5340
5801
  /**
@@ -5393,7 +5854,7 @@ export class AuthStorage {
5393
5854
  // Runtime / config overrides intentionally short-circuit OAuth: when the
5394
5855
  // user has pinned an API key, they expect the OAuth identity to be
5395
5856
  // suppressed (same contract as `getOAuthAccountId`).
5396
- if (this.#runtimeOverrides.has(provider) || this.#configOverrides.has(provider)) {
5857
+ if (this.#runtimeOverrides.has(provider) || this.#hasConfigOverride(provider, options?.owner)) {
5397
5858
  return undefined;
5398
5859
  }
5399
5860
  const resolved = await this.#resolveOAuthSelection(provider, sessionId, options);
@@ -5409,13 +5870,7 @@ export class AuthStorage {
5409
5870
  }
5410
5871
 
5411
5872
  #extractStructuredApiKeyToken(apiKey: string): string | undefined {
5412
- if (!apiKey.startsWith("{")) return undefined;
5413
- try {
5414
- const parsed = JSON.parse(apiKey) as { token?: unknown };
5415
- return typeof parsed.token === "string" ? parsed.token : undefined;
5416
- } catch {
5417
- return undefined;
5418
- }
5873
+ return extractStructuredApiKeyToken(apiKey);
5419
5874
  }
5420
5875
 
5421
5876
  async #credentialMatchesApiKey(provider: string, credential: AuthCredential, apiKey: string): Promise<boolean> {
@@ -5445,6 +5900,9 @@ export class AuthStorage {
5445
5900
  const signal = isAbortSignalOption(optionsOrSignal) ? optionsOrSignal : optionsOrSignal?.signal;
5446
5901
  const sessionId = isAbortSignalOption(optionsOrSignal) ? undefined : optionsOrSignal?.sessionId;
5447
5902
  const storageProvider = resolveOAuthStorageProvider(provider);
5903
+ const owner = isAbortSignalOption(optionsOrSignal) ? undefined : optionsOrSignal?.owner;
5904
+ const ownerOverride = this.#configOverrideRegistration(storageProvider, owner);
5905
+ if (ownerOverride && !ownerOverride.envSourced && ownerOverride.apiKey === apiKey) return false;
5448
5906
  const stored = this.#getStoredCredentials(storageProvider);
5449
5907
  let matched: { id: number; type: AuthCredential["type"]; index: number } | undefined;
5450
5908
  for (let index = 0; index < stored.length; index++) {
@@ -5477,7 +5935,7 @@ export class AuthStorage {
5477
5935
  const latestRows = this.#store.listAuthCredentials(storageProvider);
5478
5936
  this.#setStoredCredentials(
5479
5937
  storageProvider,
5480
- latestRows.map(row => ({ id: row.id, credential: row.credential })),
5938
+ latestRows.map(row => ({ id: row.id, credential: row.credential, revision: row.revision })),
5481
5939
  );
5482
5940
  return true;
5483
5941
  }
@@ -5504,6 +5962,7 @@ export class AuthStorage {
5504
5962
  provider,
5505
5963
  credential: redacted,
5506
5964
  identityKey: resolveCredentialIdentityKey(provider, credential),
5965
+ ...(entry.revision === undefined ? {} : { revision: entry.revision }),
5507
5966
  });
5508
5967
  }
5509
5968
  }
@@ -5639,12 +6098,14 @@ export class AuthStorage {
5639
6098
  enterpriseUrl: refreshed.enterpriseUrl ?? target.credential.enterpriseUrl,
5640
6099
  mcpBinding: refreshed.mcpBinding,
5641
6100
  };
5642
- this.#replaceCredentialAt(provider, index, updated, !refreshed.persistedByLease);
6101
+ this.#replaceCredentialAt(provider, index, updated, !refreshed.persistedByLease, id);
6102
+ const persisted = this.#store.listAuthCredentials(provider).find(entry => entry.id === id);
5643
6103
  return {
5644
6104
  id,
5645
6105
  provider,
5646
6106
  credential: { ...updated, refresh: REMOTE_REFRESH_SENTINEL },
5647
6107
  identityKey: resolveCredentialIdentityKey(provider, updated),
6108
+ ...(persisted?.revision === undefined ? {} : { revision: persisted.revision }),
5648
6109
  };
5649
6110
  }
5650
6111
  throw new Error(`No credential with id=${id}`);
@@ -5684,7 +6145,7 @@ export class AuthStorage {
5684
6145
  const stored = this.#store.upsertAuthCredentialForProvider(provider, credential);
5685
6146
  this.#setStoredCredentials(
5686
6147
  provider,
5687
- stored.map(entry => ({ id: entry.id, credential: entry.credential })),
6148
+ stored.map(entry => ({ id: entry.id, credential: entry.credential, revision: entry.revision })),
5688
6149
  );
5689
6150
  this.#resetProviderAssignments(provider);
5690
6151
  return this.#toSnapshotEntries(provider, stored);
@@ -5704,15 +6165,21 @@ export class AuthStorage {
5704
6165
  *
5705
6166
  * The string is purely informational; consumers must not parse it.
5706
6167
  */
5707
- describeCredentialSource(provider: string, sessionId?: string): string | undefined {
6168
+ describeCredentialSource(
6169
+ provider: string,
6170
+ sessionId?: string,
6171
+ options?: Pick<AuthApiKeyOptions, "owner">,
6172
+ ): string | undefined {
6173
+ provider = resolveOAuthStorageProvider(provider);
5708
6174
  if (this.#runtimeOverrides.has(provider)) {
5709
6175
  return "runtime override (--api-key)";
5710
6176
  }
5711
- if (this.#configOverrides.has(provider)) {
6177
+ const configOverride = this.#configOverrideRegistration(provider, options?.owner);
6178
+ if (configOverride) {
5712
6179
  // An `apiKeyEnv` indirection loses to a stored api_key credential
5713
6180
  // (see getApiKey); describe the credential that actually wins.
5714
6181
  const shadowed = this.#getStoredCredentials(provider).some(entry => entry.credential.type === "api_key");
5715
- if (!this.#configOverrideEnvSourced.has(provider) || !shadowed) {
6182
+ if (!configOverride.envSourced || !shadowed) {
5716
6183
  return "config override (models.yml)";
5717
6184
  }
5718
6185
  }
@@ -5721,7 +6188,7 @@ export class AuthStorage {
5721
6188
  const stored = this.#getStoredCredentials(provider);
5722
6189
  if (stored.length === 0) {
5723
6190
  if (getEnvApiKey(provider)) return `env ${baseLabel ? `(fallback over ${baseLabel})` : ""}`.trim();
5724
- if (this.#fallbackResolver?.(provider) !== undefined) return `fallback resolver`;
6191
+ if (this.#resolveFallback(provider, options?.owner) !== undefined) return `fallback resolver`;
5725
6192
  return undefined;
5726
6193
  }
5727
6194
 
@@ -5955,6 +6422,7 @@ export class SqliteAuthCredentialStore implements AuthCredentialStore {
5955
6422
  #updateStmt: Statement;
5956
6423
  #deleteStmt: Statement;
5957
6424
  #deleteIfMatchesStmt: Statement;
6425
+ #deleteIfRevisionStmt: Statement;
5958
6426
  #deleteByProviderStmt: Statement;
5959
6427
  #hardDeleteStmt: Statement;
5960
6428
  #getCacheStmt: Statement;
@@ -5995,6 +6463,9 @@ export class SqliteAuthCredentialStore implements AuthCredentialStore {
5995
6463
  this.#deleteIfMatchesStmt = this.#db.prepare(
5996
6464
  `UPDATE auth_credentials SET disabled_cause = ?, revision = revision + 1, updated_at = ${SQLITE_NOW_EPOCH} WHERE id = ? AND data = ? AND disabled_cause IS NULL`,
5997
6465
  );
6466
+ this.#deleteIfRevisionStmt = this.#db.prepare(
6467
+ `UPDATE auth_credentials SET disabled_cause = ?, revision = revision + 1, updated_at = ${SQLITE_NOW_EPOCH} WHERE id = ? AND revision = ? AND disabled_cause IS NULL`,
6468
+ );
5998
6469
  this.#deleteByProviderStmt = this.#db.prepare(
5999
6470
  `UPDATE auth_credentials SET disabled_cause = ?, revision = revision + 1, updated_at = ${SQLITE_NOW_EPOCH} WHERE provider = ? AND disabled_cause IS NULL`,
6000
6471
  );
@@ -6431,6 +6902,7 @@ export class SqliteAuthCredentialStore implements AuthCredentialStore {
6431
6902
  id: row.id,
6432
6903
  credential: deserializeCredential(row),
6433
6904
  identityKey: resolveRowCredentialIdentityKey(providerName, row),
6905
+ revision: row.revision,
6434
6906
  }));
6435
6907
 
6436
6908
  const result: StoredAuthCredential[] = [];
@@ -6447,7 +6919,13 @@ export class SqliteAuthCredentialStore implements AuthCredentialStore {
6447
6919
  if (match) {
6448
6920
  matchedExistingIds.add(match.id);
6449
6921
  this.#updateStmt.run(serialized.credentialType, serialized.data, serialized.identityKey, match.id);
6450
- result.push({ id: match.id, provider: providerName, credential, disabledCause: null });
6922
+ result.push({
6923
+ id: match.id,
6924
+ provider: providerName,
6925
+ credential,
6926
+ disabledCause: null,
6927
+ revision: match.revision + 1,
6928
+ });
6451
6929
  } else {
6452
6930
  const row = this.#insertStmt.get(
6453
6931
  providerName,
@@ -6456,7 +6934,7 @@ export class SqliteAuthCredentialStore implements AuthCredentialStore {
6456
6934
  serialized.identityKey,
6457
6935
  ) as { id?: number } | undefined;
6458
6936
  if (row?.id) {
6459
- result.push({ id: row.id, provider: providerName, credential, disabledCause: null });
6937
+ result.push({ id: row.id, provider: providerName, credential, disabledCause: null, revision: 1 });
6460
6938
  }
6461
6939
  }
6462
6940
  }
@@ -6549,6 +7027,7 @@ export class SqliteAuthCredentialStore implements AuthCredentialStore {
6549
7027
  id: number;
6550
7028
  credential: AuthCredential;
6551
7029
  identityKey: string | null;
7030
+ revision: number;
6552
7031
  }> = [];
6553
7032
  for (const row of existingRows) {
6554
7033
  const activeCredential = deserializeCredential(row);
@@ -6557,6 +7036,7 @@ export class SqliteAuthCredentialStore implements AuthCredentialStore {
6557
7036
  id: row.id,
6558
7037
  credential: activeCredential,
6559
7038
  identityKey: resolveRowCredentialIdentityKey(providerName, row),
7039
+ revision: row.revision,
6560
7040
  });
6561
7041
  }
6562
7042
  if (existing.length > 0) {
@@ -6589,6 +7069,7 @@ export class SqliteAuthCredentialStore implements AuthCredentialStore {
6589
7069
  provider: providerName,
6590
7070
  credential: row.credential,
6591
7071
  disabledCause: null,
7072
+ revision: row.revision,
6592
7073
  })),
6593
7074
  };
6594
7075
  }
@@ -6676,6 +7157,17 @@ export class SqliteAuthCredentialStore implements AuthCredentialStore {
6676
7157
  }
6677
7158
  }
6678
7159
 
7160
+ tryDisableAuthCredentialIfRevision(id: number, expectedRevision: number, disabledCause: string): boolean {
7161
+ try {
7162
+ const result = this.#deleteIfRevisionStmt.run(normalizeDisabledCause(disabledCause), id, expectedRevision) as {
7163
+ changes: number;
7164
+ };
7165
+ return result.changes === 1;
7166
+ } catch {
7167
+ return false;
7168
+ }
7169
+ }
7170
+
6679
7171
  deleteAuthCredentialsForProvider(provider: string, disabledCause: string): void {
6680
7172
  try {
6681
7173
  this.#deleteByProviderStmt.run(normalizeDisabledCause(disabledCause), provider);
@@ -6702,6 +7194,17 @@ export class SqliteAuthCredentialStore implements AuthCredentialStore {
6702
7194
  }
6703
7195
  }
6704
7196
 
7197
+ allocateMonotonicSequence(key: string, expiresAtSec: number): number {
7198
+ const allocate = this.#db.transaction(() => {
7199
+ const row = this.#getCacheIncludingExpiredStmt.get(key) as { value?: string } | undefined;
7200
+ const current = Number(row?.value);
7201
+ const next = Number.isSafeInteger(current) && current >= 0 ? current + 1 : 1;
7202
+ this.#upsertCacheStmt.run(key, String(next), expiresAtSec);
7203
+ return next;
7204
+ });
7205
+ return allocate();
7206
+ }
7207
+
6705
7208
  deleteCachePrefix(prefix: string): void {
6706
7209
  if (prefix.length === 0) return;
6707
7210
  try {
@@ -6796,6 +7299,7 @@ export class SqliteAuthCredentialStore implements AuthCredentialStore {
6796
7299
  this.#updateStmt.finalize();
6797
7300
  this.#deleteStmt.finalize();
6798
7301
  this.#deleteIfMatchesStmt.finalize();
7302
+ this.#deleteIfRevisionStmt.finalize();
6799
7303
  this.#deleteByProviderStmt.finalize();
6800
7304
  this.#hardDeleteStmt.finalize();
6801
7305
  this.#getCacheStmt.finalize();