@gajae-code/ai 0.15.5 → 0.16.0

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