@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
@@ -17,11 +17,11 @@ import { getConfigRootDir, isEnoent, logger } from "@gajae-code/utils";
17
17
  import {
18
18
  type AuthCredential,
19
19
  type AuthCredentialIfAbsentResult,
20
- type AuthCredentialSnapshotEntry,
21
20
  type AuthCredentialStore,
22
21
  assertCanonicalMCPOAuthBinding,
23
22
  type CachedCredentialHealth,
24
23
  type CachedUsagePresentation,
24
+ type CredentialDispatchTicket,
25
25
  type CredentialInventoryRecord,
26
26
  type MCPOAuthRefreshClient,
27
27
  type OAuthCredential,
@@ -37,6 +37,7 @@ import {
37
37
  AuthBrokerCredentialMetadataUnsupportedError,
38
38
  AuthBrokerStreamUnsupportedError,
39
39
  } from "./client";
40
+ import { cleanReason } from "./redact";
40
41
  import type {
41
42
  CredentialMetadataRecord,
42
43
  RefresherSchedule,
@@ -72,6 +73,11 @@ const MAX_WAIT_MS = 5_000;
72
73
  const BACKGROUND_WAIT_MS = 30_000;
73
74
  const BACKGROUND_BACKOFF_INITIAL_MS = 500;
74
75
  const BACKGROUND_BACKOFF_MAX_MS = 30_000;
76
+
77
+ function epochRank(epoch: string): number | undefined {
78
+ const match = /^(\d+)-/.exec(epoch);
79
+ return match ? Number(match[1]) : undefined;
80
+ }
75
81
  const PRESENTATION_FRESH_MS = 5 * 60_000;
76
82
  const PRESENTATION_RETENTION_MS = 24 * 60 * 60_000;
77
83
  const PRESENTATION_SIDECAR_VERSION = 1;
@@ -138,12 +144,7 @@ function presentationRecordKey(provider: string, identityDigest: string): string
138
144
  }
139
145
 
140
146
  function safePresentationReason(value: unknown): string | null {
141
- if (value === null || value === undefined) return null;
142
- let reason = value instanceof Error ? value.message : String(value);
143
- reason = reason.replace(/bearer\s+[^\s,;]+/gi, "Bearer [redacted]");
144
- reason = reason.replace(/(api[_-]?key|token|secret|authorization)[=:]\s*[^\s,;]+/gi, "$1=[redacted]");
145
- reason = reason.replace(/[\r\n\t ]+/g, " ").trim();
146
- return reason.length > 256 ? `${reason.slice(0, 253)}...` : reason || null;
147
+ return cleanReason(value) ?? null;
147
148
  }
148
149
 
149
150
  export interface RemoteAuthCredentialStoreOptions {
@@ -168,10 +169,17 @@ export class RemoteAuthCredentialStore implements AuthCredentialStore {
168
169
  #snapshot: SnapshotResponse = emptySnapshot();
169
170
  #snapshotReceivedAt = Date.now();
170
171
  #generation = 0;
172
+ #epoch?: string;
173
+ #retiredEpochs = new Set<string>();
174
+ #snapshotAuthorityTail: Promise<void> = Promise.resolve();
175
+ #snapshotListeners = new Set<() => void>();
171
176
  #backgroundAbort = new AbortController();
172
177
  #cache: Map<string, CacheEntry> = new Map();
173
178
  #usageCache?: UsageCacheEntry;
174
179
  #usageInflight?: Promise<UsageReport[] | null>;
180
+ #scopedUsageCache = new Map<Provider, UsageCacheEntry>();
181
+ #scopedUsageInflight = new Map<Provider, Promise<UsageReport[] | null>>();
182
+ #scopedUsageFailures = new Map<Provider, number>();
175
183
  #usageCacheEpoch = 0;
176
184
  #inventoryMetadata = new Map<number, CredentialMetadataRecord>();
177
185
  #inventoryMetadataGeneration = -1;
@@ -201,6 +209,8 @@ export class RemoteAuthCredentialStore implements AuthCredentialStore {
201
209
  #streamingActive = false;
202
210
  /** Latched once the broker has answered 404 — never try the stream again. */
203
211
  #streamingUnsupported = false;
212
+ #loadingInitialSnapshot = true;
213
+ #snapshotAuthoritative = false;
204
214
 
205
215
  constructor(opts: RemoteAuthCredentialStoreOptions) {
206
216
  this.#client = opts.client;
@@ -208,6 +218,8 @@ export class RemoteAuthCredentialStore implements AuthCredentialStore {
208
218
  this.#presentationPath = opts.presentationPath ?? defaultPresentationSidecarPath();
209
219
  this.#presentationAuthority = createHash("sha256").update(this.#client.baseUrl).digest("hex");
210
220
  this.#applySnapshot(opts.initialSnapshot ?? emptySnapshot(), opts.initialSnapshot?.generation ?? 0, false);
221
+ this.#loadingInitialSnapshot = false;
222
+ this.#snapshotAuthoritative = opts.initialSnapshot !== undefined;
211
223
  this.#setInventoryState("pending", this.#generation, {
212
224
  status: "pending",
213
225
  reason: "credential metadata sync pending",
@@ -269,9 +281,11 @@ export class RemoteAuthCredentialStore implements AuthCredentialStore {
269
281
  if (!record.health && !record.usage) continue;
270
282
  this.#persistedPresentations.set(key, record);
271
283
  }
272
- this.#reconcilePersistedPresentations();
273
- this.#hydratePresentations();
274
- this.#queuePresentationWrite();
284
+ if (this.#snapshotAuthoritative) {
285
+ this.#reconcilePersistedPresentations();
286
+ this.#hydratePresentations();
287
+ this.#queuePresentationWrite();
288
+ }
275
289
  } catch (error) {
276
290
  logger.debug("auth-broker presentation sidecar invalid", { error: String(error) });
277
291
  }
@@ -373,10 +387,50 @@ export class RemoteAuthCredentialStore implements AuthCredentialStore {
373
387
  };
374
388
  }
375
389
 
376
- #applySnapshot(snapshot: SnapshotResponse, generation: number, scheduleMetadata = true): void {
377
- const generationChanged = generation !== this.#generation || this.#inventoryMetadataGeneration !== generation;
390
+ #applySnapshot(snapshot: SnapshotResponse, generation: number, scheduleMetadata = true): boolean {
391
+ // Broker generations are process-local and reset when the broker restarts.
392
+ // A lower generation from a newer server timestamp is therefore a new
393
+ // broker epoch, while an older timestamp is an out-of-order response from
394
+ // the current epoch and must be discarded.
395
+ if (this.#epoch) {
396
+ if (!snapshot.epoch) return false;
397
+ if (snapshot.epoch === this.#epoch) {
398
+ if (generation < this.#generation) return false;
399
+ if (generation === this.#generation && snapshot.serverNowMs < this.#snapshot.serverNowMs) return false;
400
+ } else {
401
+ if (this.#retiredEpochs.has(snapshot.epoch)) return false;
402
+ const currentRank = epochRank(this.#epoch);
403
+ const incomingRank = epochRank(snapshot.epoch);
404
+ // An unseen epoch is only safe to accept when both broker epochs carry
405
+ // the authoritative monotonic sequence prefix. Opaque epoch strings
406
+ // cannot prove that a delayed response is newer, so fail closed rather
407
+ // than letting it replace the current credential snapshot.
408
+ if (currentRank === undefined || incomingRank === undefined || incomingRank <= currentRank) return false;
409
+ this.#retiredEpochs.add(this.#epoch);
410
+ }
411
+ } else if (snapshot.epoch) {
412
+ // First epoch-bearing response establishes the authority namespace.
413
+ } else if (
414
+ (generation < this.#generation && snapshot.serverNowMs <= this.#snapshot.serverNowMs) ||
415
+ (generation === this.#generation && snapshot.serverNowMs < this.#snapshot.serverNowMs)
416
+ ) {
417
+ // Epoch-less legacy brokers use serverNowMs as the restart discriminator:
418
+ // accept a reset generation only when the broker proves it is newer than
419
+ // the last accepted legacy snapshot, while delayed old responses fail closed.
420
+ if (
421
+ (generation < this.#generation && snapshot.serverNowMs <= this.#snapshot.serverNowMs) ||
422
+ (generation === this.#generation && snapshot.serverNowMs < this.#snapshot.serverNowMs)
423
+ )
424
+ return false;
425
+ }
426
+ const generationChanged =
427
+ generation !== this.#generation ||
428
+ snapshot.epoch !== this.#epoch ||
429
+ this.#inventoryMetadataGeneration !== generation;
378
430
  this.#snapshot = snapshot;
379
431
  this.#generation = generation;
432
+ if (!this.#loadingInitialSnapshot) this.#snapshotAuthoritative = true;
433
+ this.#epoch = snapshot.epoch ?? this.#epoch;
380
434
  this.#snapshotReceivedAt = Date.now();
381
435
  if (generationChanged) {
382
436
  this.#inventoryMetadata.clear();
@@ -398,8 +452,76 @@ export class RemoteAuthCredentialStore implements AuthCredentialStore {
398
452
  if (scheduleMetadata) this.#scheduleInventoryMetadataSync();
399
453
  }
400
454
  }
401
- this.#reconcilePersistedPresentations();
402
- this.#hydratePresentations();
455
+ if (this.#snapshotAuthoritative) {
456
+ this.#reconcilePersistedPresentations();
457
+ this.#hydratePresentations();
458
+ }
459
+ for (const listener of this.#snapshotListeners) listener();
460
+ return true;
461
+ }
462
+
463
+ /**
464
+ * A full GET can race the SSE stream. Once a newer snapshot from the same
465
+ * broker epoch has been accepted, an older GET is merely superseded data —
466
+ * not an authority failure that should turn a healthy request into a 503.
467
+ * Epoch regressions remain rejected so a delayed response from a retired
468
+ * broker cannot cross an authority boundary.
469
+ */
470
+ #isSupersededSnapshot(snapshot: SnapshotResponse, generation: number): boolean {
471
+ if (this.#epoch !== undefined || snapshot.epoch !== undefined) {
472
+ return (
473
+ snapshot.epoch === this.#epoch &&
474
+ (generation < this.#generation ||
475
+ (generation === this.#generation && snapshot.serverNowMs < this.#snapshot.serverNowMs))
476
+ );
477
+ }
478
+ return (
479
+ (generation < this.#generation && snapshot.serverNowMs <= this.#snapshot.serverNowMs) ||
480
+ (generation === this.#generation && snapshot.serverNowMs < this.#snapshot.serverNowMs)
481
+ );
482
+ }
483
+
484
+ onSnapshotChanged(listener: () => void): () => void {
485
+ this.#snapshotListeners.add(listener);
486
+ return () => this.#snapshotListeners.delete(listener);
487
+ }
488
+
489
+ #withSnapshotAuthority<T>(operation: () => Promise<T>): Promise<T> {
490
+ const run = this.#snapshotAuthorityTail.then(operation);
491
+ this.#snapshotAuthorityTail = run.then(
492
+ () => undefined,
493
+ () => undefined,
494
+ );
495
+ return run;
496
+ }
497
+
498
+ /**
499
+ * Queue a provider-admission ticket behind all currently pending snapshot
500
+ * authority work. Snapshot applications that arrive after this ticket wait
501
+ * for its release, so a revocation is ordered either before admission or
502
+ * after it — never in the middle of the dispatch boundary.
503
+ */
504
+ async acquireCredentialDispatchTicket(_provider: Provider, signal?: AbortSignal): Promise<CredentialDispatchTicket> {
505
+ const previous = this.#snapshotAuthorityTail;
506
+ const deferred = Promise.withResolvers<void>();
507
+ const ticketTail = previous.then(
508
+ () => deferred.promise,
509
+ () => deferred.promise,
510
+ );
511
+ this.#snapshotAuthorityTail = ticketTail;
512
+ let released = false;
513
+ const release = (): void => {
514
+ if (released) return;
515
+ released = true;
516
+ deferred.resolve();
517
+ };
518
+ try {
519
+ await this.#raceWithSignal(previous, signal);
520
+ } catch (error) {
521
+ release();
522
+ throw error;
523
+ }
524
+ return { release };
403
525
  }
404
526
 
405
527
  #setInventoryState(
@@ -471,6 +593,20 @@ export class RemoteAuthCredentialStore implements AuthCredentialStore {
471
593
  for (let attempt = 0; attempt < 2; attempt += 1) {
472
594
  try {
473
595
  const metadata = await this.#client.fetchCredentialMetadata();
596
+ if (metadata.epoch !== this.#epoch) {
597
+ if (attempt === 0) {
598
+ await this.refreshSnapshot().catch(() => {});
599
+ continue;
600
+ }
601
+ this.#inventoryMetadata.clear();
602
+ this.#inventoryMetadataGeneration = -1;
603
+ this.#setInventoryState("mismatch", this.#generation, {
604
+ status: "mismatch",
605
+ reason: "credential metadata epoch mismatch",
606
+ generation: this.#generation,
607
+ });
608
+ return this.getInventoryMetadataState();
609
+ }
474
610
  if (metadata.generation !== this.#generation) {
475
611
  if (attempt === 0) {
476
612
  await this.refreshSnapshot().catch(() => {});
@@ -542,10 +678,19 @@ export class RemoteAuthCredentialStore implements AuthCredentialStore {
542
678
  try {
543
679
  const result = await this.#client.fetchSnapshot({
544
680
  ifGenerationGt: this.#generation,
681
+ ifEpoch: this.#epoch,
545
682
  waitMs: BACKGROUND_WAIT_MS,
546
683
  signal: this.#backgroundAbort.signal,
547
684
  });
548
- if (result.status === 200) this.#applySnapshot(result.snapshot, result.generation);
685
+ if (result.status === 200) {
686
+ await this.#withSnapshotAuthority(async () => {
687
+ if (!this.#applySnapshot(result.snapshot, result.generation)) {
688
+ if (!this.#isSupersededSnapshot(result.snapshot, result.generation)) {
689
+ throw new Error("Auth broker background snapshot authority was rejected");
690
+ }
691
+ }
692
+ });
693
+ }
549
694
  backoffMs = BACKGROUND_BACKOFF_INITIAL_MS;
550
695
  } catch (error) {
551
696
  if (this.#closed || this.#backgroundAbort.signal.aborted) break;
@@ -562,39 +707,61 @@ export class RemoteAuthCredentialStore implements AuthCredentialStore {
562
707
  for await (const event of iterator) {
563
708
  if (this.#closed || this.#backgroundAbort.signal.aborted) break;
564
709
  this.#streamingActive = true;
565
- this.#applyStreamEvent(event);
710
+ await this.#applyStreamEvent(event);
566
711
  }
567
712
  } finally {
568
713
  this.#streamingActive = false;
569
714
  }
570
715
  }
571
716
 
572
- #applyStreamEvent(event: SnapshotStreamEvent): void {
573
- switch (event.kind) {
574
- case "snapshot": {
575
- // Strip the discriminator so we store the wire-shape SnapshotResponse.
576
- const { kind: _kind, ...snapshot } = event;
577
- if (snapshot.generation < this.#generation) {
578
- logger.debug("auth-broker stream snapshot older than local; ignoring", {
579
- local: this.#generation,
580
- incoming: snapshot.generation,
581
- });
717
+ async #applyStreamEvent(event: SnapshotStreamEvent): Promise<void> {
718
+ await this.#withSnapshotAuthority(async () => {
719
+ switch (event.kind) {
720
+ case "snapshot": {
721
+ // Strip the discriminator so we store the wire-shape SnapshotResponse.
722
+ const { kind: _kind, ...snapshot } = event;
723
+ if (!this.#applySnapshot(snapshot, snapshot.generation)) {
724
+ if (!this.#isSupersededSnapshot(snapshot, snapshot.generation)) {
725
+ throw new Error("Auth broker stream snapshot authority was rejected");
726
+ }
727
+ }
728
+ return;
729
+ }
730
+ case "entry": {
731
+ const applied = this.#applyStreamEntry(
732
+ event.entry,
733
+ event.refresher,
734
+ event.generation,
735
+ event.serverNowMs,
736
+ event.epoch,
737
+ );
738
+ if (!applied && !this.#isSupersededStreamEvent(event)) {
739
+ throw new Error("Auth broker stream entry authority was rejected");
740
+ }
741
+ return;
742
+ }
743
+ case "removed": {
744
+ const applied = this.#removeStreamCredential(
745
+ event.id,
746
+ event.refresher,
747
+ event.generation,
748
+ event.serverNowMs,
749
+ event.epoch,
750
+ );
751
+ if (!applied && !this.#isSupersededStreamEvent(event)) {
752
+ throw new Error("Auth broker stream removal authority was rejected");
753
+ }
582
754
  return;
583
755
  }
584
- this.#applySnapshot(snapshot, snapshot.generation);
585
- return;
586
- }
587
- case "entry": {
588
- if (event.generation < this.#generation) return;
589
- this.#applyStreamEntry(event.entry, event.refresher, event.generation, event.serverNowMs);
590
- return;
591
- }
592
- case "removed": {
593
- if (event.generation < this.#generation) return;
594
- this.#removeStreamCredential(event.id, event.refresher, event.generation, event.serverNowMs);
595
- return;
596
756
  }
597
- }
757
+ });
758
+ }
759
+
760
+ #isSupersededStreamEvent(event: SnapshotStreamEvent): boolean {
761
+ return this.#isSupersededSnapshot(
762
+ { ...this.#snapshot, epoch: event.epoch, generation: event.generation, serverNowMs: event.serverNowMs },
763
+ event.generation,
764
+ );
598
765
  }
599
766
 
600
767
  #applyStreamEntry(
@@ -602,18 +769,33 @@ export class RemoteAuthCredentialStore implements AuthCredentialStore {
602
769
  refresher: RefresherSchedule,
603
770
  generation: number,
604
771
  serverNowMs: number,
605
- ): void {
772
+ epoch?: string,
773
+ ): boolean {
774
+ if (!epoch && this.#epoch && epochRank(this.#epoch) !== undefined) return false;
606
775
  const index = this.#snapshot.credentials.findIndex(candidate => candidate.id === entry.id);
607
776
  const credentials =
608
777
  index === -1
609
778
  ? [...this.#snapshot.credentials, entry]
610
779
  : this.#snapshot.credentials.map((candidate, i) => (i === index ? entry : candidate));
611
- this.#applySnapshot({ ...this.#snapshot, generation, serverNowMs, refresher, credentials }, generation);
780
+ return this.#applySnapshot(
781
+ { ...this.#snapshot, epoch, generation, serverNowMs, refresher, credentials },
782
+ generation,
783
+ );
612
784
  }
613
785
 
614
- #removeStreamCredential(id: number, refresher: RefresherSchedule, generation: number, serverNowMs: number): void {
786
+ #removeStreamCredential(
787
+ id: number,
788
+ refresher: RefresherSchedule,
789
+ generation: number,
790
+ serverNowMs: number,
791
+ epoch?: string,
792
+ ): boolean {
793
+ if (!epoch && this.#epoch && epochRank(this.#epoch) !== undefined) return false;
615
794
  const credentials = this.#snapshot.credentials.filter(entry => entry.id !== id);
616
- this.#applySnapshot({ ...this.#snapshot, generation, serverNowMs, refresher, credentials }, generation);
795
+ return this.#applySnapshot(
796
+ { ...this.#snapshot, epoch, generation, serverNowMs, refresher, credentials },
797
+ generation,
798
+ );
617
799
  }
618
800
 
619
801
  /**
@@ -628,9 +810,20 @@ export class RemoteAuthCredentialStore implements AuthCredentialStore {
628
810
  }
629
811
 
630
812
  /** Re-hydrate the in-memory snapshot from the broker. */
631
- async refreshSnapshot(): Promise<SnapshotResponse> {
632
- const result = await this.#client.fetchSnapshot();
633
- if (result.status === 200) this.#applySnapshot(result.snapshot, result.generation);
813
+ async refreshSnapshot(signal?: AbortSignal): Promise<SnapshotResponse> {
814
+ const result = await this.#client.fetchSnapshot({ signal });
815
+ if (result.status === 200) {
816
+ await this.#raceWithSignal(
817
+ this.#withSnapshotAuthority(async () => {
818
+ if (!this.#applySnapshot(result.snapshot, result.generation)) {
819
+ if (!this.#isSupersededSnapshot(result.snapshot, result.generation)) {
820
+ throw new Error("Auth broker snapshot authority was rejected");
821
+ }
822
+ }
823
+ }),
824
+ signal,
825
+ );
826
+ }
634
827
  return this.#snapshot;
635
828
  }
636
829
 
@@ -643,6 +836,7 @@ export class RemoteAuthCredentialStore implements AuthCredentialStore {
643
836
  provider: entry.provider,
644
837
  credential: entry.credential as AuthCredential,
645
838
  disabledCause: null,
839
+ ...(entry.revision === undefined ? {} : { revision: entry.revision }),
646
840
  });
647
841
  }
648
842
  return out;
@@ -654,30 +848,68 @@ export class RemoteAuthCredentialStore implements AuthCredentialStore {
654
848
  * authoritative row, so we just mirror it.
655
849
  */
656
850
  updateAuthCredential(id: number, credential: AuthCredential): void {
657
- for (const entry of this.#snapshot.credentials) {
658
- if (entry.id !== id) continue;
659
- entry.credential = credential as typeof entry.credential;
660
- return;
661
- }
851
+ void id;
852
+ void credential;
853
+ throw new Error("Remote auth-broker credentials must be updated through broker authority");
662
854
  }
663
855
 
664
856
  deleteAuthCredential(_id: number, _disabledCause: string): void {
665
857
  throw new Error("Remote auth-broker credentials can only be disabled on the broker host");
666
858
  }
667
859
 
860
+ allocateMonotonicSequence(_key: string, _expiresAtSec: number): number {
861
+ throw new Error("Remote auth-broker credentials cannot allocate broker incarnation sequences");
862
+ }
863
+
668
864
  tryDisableAuthCredentialIfMatches(_id: number, _expectedData: string, _disabledCause: string): boolean {
669
865
  return false;
670
866
  }
671
867
 
868
+ /** Disable one credential through the authenticated broker mutation endpoint. */
869
+ async disableAuthCredentialRemote(
870
+ credentialId: number,
871
+ disabledCause: string,
872
+ signal?: AbortSignal,
873
+ expectedRevision?: number,
874
+ ): Promise<boolean> {
875
+ try {
876
+ await this.#client.disableCredential(credentialId, disabledCause, signal, expectedRevision);
877
+ } catch (error) {
878
+ if (!isErrorStatus(error, 404)) throw error;
879
+ // A peer may have disabled the row first. Reconcile the local mirror so
880
+ // AuthStorage can select a fallback without ever mutating remote state
881
+ // through the read-only synchronous methods.
882
+ await this.refreshSnapshot().catch(refreshError => {
883
+ logger.debug("auth-broker snapshot refresh after remote disable miss failed", {
884
+ error: String(refreshError),
885
+ });
886
+ });
887
+ return false;
888
+ }
889
+ this.#removeCredentialEntry(credentialId);
890
+ this.#maybeRefreshSnapshot("disable");
891
+ return true;
892
+ }
893
+
672
894
  async waitForFreshSnapshot(maxWaitMs: number, opts: { signal?: AbortSignal } = {}): Promise<boolean> {
673
895
  const previousGeneration = this.#generation;
896
+ const previousEpoch = this.#epoch;
674
897
  const result = await this.#client.fetchSnapshot({
675
898
  ifGenerationGt: this.#generation,
899
+ ifEpoch: this.#epoch,
676
900
  waitMs: maxWaitMs,
677
901
  signal: opts.signal,
678
902
  });
679
- if (result.status === 200) this.#applySnapshot(result.snapshot, result.generation);
680
- return this.#generation !== previousGeneration;
903
+ if (result.status === 200) {
904
+ await this.#withSnapshotAuthority(async () => {
905
+ if (!this.#applySnapshot(result.snapshot, result.generation)) {
906
+ if (!this.#isSupersededSnapshot(result.snapshot, result.generation)) {
907
+ throw new Error("Auth broker freshness snapshot authority was rejected");
908
+ }
909
+ }
910
+ });
911
+ }
912
+ return this.#generation !== previousGeneration || this.#epoch !== previousEpoch;
681
913
  }
682
914
 
683
915
  async prepareForRequest(credentialId: number, opts: { signal?: AbortSignal } = {}): Promise<boolean> {
@@ -689,12 +921,17 @@ export class RemoteAuthCredentialStore implements AuthCredentialStore {
689
921
  }
690
922
 
691
923
  async markCredentialSuspect(credentialId: number, opts: { signal?: AbortSignal } = {}): Promise<void> {
692
- const { entry } = await this.#client.refreshCredential(credentialId, opts.signal);
693
- if (entry.credential.type !== "oauth") {
694
- throw new Error(`Broker returned non-OAuth credential for id=${credentialId}`);
924
+ const current = this.#snapshot.credentials.find(entry => entry.id === credentialId);
925
+ if (current?.credential.type !== "oauth") {
926
+ // API-key rows have no refresh token, and a stale id may already have
927
+ // disappeared. A 401 means the broker may have rotated or removed the
928
+ // row; refresh the authoritative snapshot so the caller can reselect a
929
+ // live key instead of hitting the OAuth endpoint.
930
+ await this.refreshSnapshot(opts.signal);
931
+ return;
695
932
  }
696
- this.#applyCredentialEntry(entry);
697
- this.#maybeRefreshSnapshot("suspect credential refresh");
933
+ await this.#client.refreshCredential(credentialId, opts.signal);
934
+ await this.refreshSnapshot(opts.signal);
698
935
  }
699
936
 
700
937
  replaceAuthCredentialsForProvider(_provider: string, _credentials: AuthCredential[]): StoredAuthCredential[] {
@@ -728,8 +965,7 @@ export class RemoteAuthCredentialStore implements AuthCredentialStore {
728
965
  async deleteAuthCredentialsRemote(provider: string, disabledCause: string): Promise<void> {
729
966
  const existing = this.listAuthCredentials(provider);
730
967
  for (const entry of existing) await this.#client.disableCredential(entry.id, disabledCause);
731
- this.#removeProviderEntries(provider);
732
- this.#maybeRefreshSnapshot("delete");
968
+ await this.refreshSnapshot();
733
969
  }
734
970
 
735
971
  /**
@@ -740,9 +976,8 @@ export class RemoteAuthCredentialStore implements AuthCredentialStore {
740
976
  * any concurrent peer (refresh, generation bump) stays in sync.
741
977
  */
742
978
  async upsertAuthCredentialRemote(provider: string, credential: AuthCredential): Promise<StoredAuthCredential[]> {
743
- const { entries } = await this.#client.uploadCredential(provider, credential);
744
- this.#applyProviderEntries(provider, entries);
745
- this.#maybeRefreshSnapshot("upload");
979
+ await this.#client.uploadCredential(provider, credential);
980
+ await this.refreshSnapshot();
746
981
  return this.listAuthCredentials(provider);
747
982
  }
748
983
 
@@ -750,9 +985,8 @@ export class RemoteAuthCredentialStore implements AuthCredentialStore {
750
985
  provider: string,
751
986
  credential: AuthCredential,
752
987
  ): Promise<AuthCredentialIfAbsentResult> {
753
- const { inserted, reason, entries } = await this.#client.uploadCredentialIfAbsent(provider, credential);
754
- this.#applyProviderEntries(provider, entries);
755
- this.#maybeRefreshSnapshot("upload-if-absent");
988
+ const { inserted, reason } = await this.#client.uploadCredentialIfAbsent(provider, credential);
989
+ await this.refreshSnapshot();
756
990
  return { inserted, reason, provider, entries: this.listAuthCredentials(provider) };
757
991
  }
758
992
 
@@ -771,38 +1005,18 @@ export class RemoteAuthCredentialStore implements AuthCredentialStore {
771
1005
  }
772
1006
  await this.refreshSnapshot();
773
1007
  for (const credential of credentials) {
774
- const { entries } = await this.#client.uploadCredential(provider, credential);
775
- this.#applyProviderEntries(provider, entries);
1008
+ await this.#client.uploadCredential(provider, credential);
1009
+ await this.refreshSnapshot();
776
1010
  }
777
1011
  this.#maybeRefreshSnapshot("replace");
778
1012
  return this.listAuthCredentials(provider);
779
1013
  }
780
1014
 
781
- #applyProviderEntries(provider: string, entries: AuthCredentialSnapshotEntry[]): void {
782
- // `entries` is the broker's authoritative post-upsert list of rows for
783
- // `provider`. Drop our existing rows for the same provider and splice in
784
- // the fresh set — preserving every other provider's rows in place.
785
- const others = this.#snapshot.credentials.filter(entry => entry.provider !== provider);
786
- const incoming = entries.map(entry => ({ ...entry, rotatesInMs: null }));
787
- this.#snapshot = { ...this.#snapshot, credentials: [...others, ...incoming] };
788
- }
789
-
790
- #removeProviderEntries(provider: string): void {
791
- const credentials = this.#snapshot.credentials.filter(entry => entry.provider !== provider);
1015
+ #removeCredentialEntry(credentialId: number): void {
1016
+ const credentials = this.#snapshot.credentials.filter(entry => entry.id !== credentialId);
1017
+ if (credentials.length === this.#snapshot.credentials.length) return;
792
1018
  this.#applySnapshot({ ...this.#snapshot, credentials }, this.#generation, false);
793
1019
  }
794
- #applyCredentialEntry(entry: AuthCredentialSnapshotEntry): void {
795
- const incoming = { ...entry, rotatesInMs: null };
796
- const index = this.#snapshot.credentials.findIndex(candidate => candidate.id === entry.id);
797
- if (index === -1) {
798
- this.#snapshot = { ...this.#snapshot, credentials: [...this.#snapshot.credentials, incoming] };
799
- return;
800
- }
801
- const credentials = [...this.#snapshot.credentials];
802
- credentials[index] = incoming;
803
- this.#snapshot = { ...this.#snapshot, credentials };
804
- }
805
-
806
1020
  /**
807
1021
  * Fire-and-forget `refreshSnapshot()` after a write. When the SSE stream is
808
1022
  * active the broker will deliver the new generation push, so the extra GET
@@ -863,6 +1077,9 @@ export class RemoteAuthCredentialStore implements AuthCredentialStore {
863
1077
  #invalidateUsageCache(): void {
864
1078
  this.#usageCache = undefined;
865
1079
  this.#usageInflight = undefined;
1080
+ this.#scopedUsageCache.clear();
1081
+ this.#scopedUsageInflight.clear();
1082
+ this.#scopedUsageFailures.clear();
866
1083
  this.#usageCacheEpoch += 1;
867
1084
  }
868
1085
 
@@ -878,9 +1095,8 @@ export class RemoteAuthCredentialStore implements AuthCredentialStore {
878
1095
  _credential: OAuthCredential,
879
1096
  signal?: AbortSignal,
880
1097
  ): Promise<OAuthCredentials> {
881
- let entry: AuthCredentialSnapshotEntry;
882
1098
  try {
883
- ({ entry } = await this.#client.refreshCredential(credentialId, signal));
1099
+ await this.#client.refreshCredential(credentialId, signal);
884
1100
  } catch (error) {
885
1101
  if (isErrorStatus(error, 404) && !this.#streamingActive) {
886
1102
  await this.refreshSnapshot().catch(refreshError => {
@@ -891,15 +1107,12 @@ export class RemoteAuthCredentialStore implements AuthCredentialStore {
891
1107
  }
892
1108
  throw error;
893
1109
  }
894
- if (!this.#streamingActive) {
895
- await this.refreshSnapshot().catch(error => {
896
- logger.debug("auth-broker snapshot refresh after credential refresh failed", { error: String(error) });
897
- });
898
- }
899
- if (entry.credential.type !== "oauth") {
900
- throw new Error(`Broker returned non-OAuth credential for id=${credentialId}`);
1110
+ await this.refreshSnapshot(signal);
1111
+ const accepted = this.#snapshot.credentials.find(candidate => candidate.id === credentialId);
1112
+ if (accepted?.credential.type !== "oauth") {
1113
+ throw new Error(`Broker snapshot no longer contains OAuth credential id=${credentialId}`);
901
1114
  }
902
- const refreshed = entry.credential;
1115
+ const refreshed = accepted.credential;
903
1116
  return {
904
1117
  access: refreshed.access,
905
1118
  refresh: REMOTE_REFRESH_SENTINEL,
@@ -917,21 +1130,21 @@ export class RemoteAuthCredentialStore implements AuthCredentialStore {
917
1130
  client: MCPOAuthRefreshClient,
918
1131
  signal?: AbortSignal,
919
1132
  ): Promise<OAuthCredential> {
920
- const { entry } = await this.#client.refreshMCPCredential(credentialId, client, signal);
921
- if (entry.credential.type !== "oauth") {
922
- throw new Error(`Broker returned non-OAuth credential for id=${credentialId}`);
1133
+ await this.#client.refreshMCPCredential(credentialId, client, signal);
1134
+ await this.refreshSnapshot(signal);
1135
+ const accepted = this.#snapshot.credentials.find(candidate => candidate.id === credentialId);
1136
+ if (accepted?.credential.type !== "oauth") {
1137
+ throw new Error(`Broker snapshot no longer contains OAuth credential id=${credentialId}`);
923
1138
  }
924
1139
  assertCanonicalMCPOAuthBinding(credential.mcpBinding);
925
- assertCanonicalMCPOAuthBinding(entry.credential.mcpBinding);
1140
+ assertCanonicalMCPOAuthBinding(accepted.credential.mcpBinding);
926
1141
  if (
927
- entry.credential.mcpBinding.resourceOrigin !== credential.mcpBinding.resourceOrigin ||
928
- entry.credential.mcpBinding.tokenEndpoint !== credential.mcpBinding.tokenEndpoint
1142
+ accepted.credential.mcpBinding.resourceOrigin !== credential.mcpBinding.resourceOrigin ||
1143
+ accepted.credential.mcpBinding.tokenEndpoint !== credential.mcpBinding.tokenEndpoint
929
1144
  ) {
930
1145
  throw new Error("Broker returned mismatched MCP OAuth credential binding");
931
1146
  }
932
- this.#applyCredentialEntry(entry);
933
- this.#maybeRefreshSnapshot("MCP credential refresh");
934
- return entry.credential;
1147
+ return accepted.credential;
935
1148
  }
936
1149
 
937
1150
  /**
@@ -944,6 +1157,10 @@ export class RemoteAuthCredentialStore implements AuthCredentialStore {
944
1157
  return this.#raceWithSignal(this.#loadUsageReports(), signal);
945
1158
  }
946
1159
 
1160
+ async fetchUsageReportsForProvider(provider: Provider, signal?: AbortSignal): Promise<UsageReport[] | null> {
1161
+ return this.#raceWithSignal(this.#loadUsageReports(provider), signal);
1162
+ }
1163
+
947
1164
  /** Synchronous, zero-network usage presentation read. */
948
1165
  peekCachedUsagePresentation(provider: Provider, credentialId: number): CachedUsagePresentation | undefined {
949
1166
  const entry = this.#snapshot.credentials.find(candidate => candidate.id === credentialId);
@@ -1075,7 +1292,16 @@ export class RemoteAuthCredentialStore implements AuthCredentialStore {
1075
1292
  credential: OAuthCredential,
1076
1293
  signal?: AbortSignal,
1077
1294
  ): Promise<UsageReport | null> {
1078
- const reports = await this.#raceWithSignal(this.#loadUsageReports(), signal);
1295
+ let reports: UsageReport[] | null;
1296
+ try {
1297
+ reports = await this.#raceWithSignal(this.#loadUsageReports(provider), signal);
1298
+ } catch (error) {
1299
+ // A caller cancellation is control flow, not a missing usage report.
1300
+ // Preserve it so selection/dispatch can stop promptly; only ordinary
1301
+ // broker/provider failures degrade to "no usage".
1302
+ if (signal?.aborted) throw error;
1303
+ return null;
1304
+ }
1079
1305
  if (!reports) return null;
1080
1306
  return matchUsageReport(reports, provider, credential);
1081
1307
  }
@@ -1160,7 +1386,41 @@ export class RemoteAuthCredentialStore implements AuthCredentialStore {
1160
1386
  }
1161
1387
  }
1162
1388
 
1163
- #loadUsageReports(): Promise<UsageReport[] | null> {
1389
+ #loadUsageReports(provider?: Provider): Promise<UsageReport[] | null> {
1390
+ if (provider) {
1391
+ const cached = this.#scopedUsageCache.get(provider);
1392
+ if (cached && Date.now() - cached.fetchedAt < USAGE_CACHE_TTL_MS) return Promise.resolve(cached.reports);
1393
+ const failedAt = this.#scopedUsageFailures.get(provider);
1394
+ if (failedAt !== undefined && Date.now() - failedAt < USAGE_CACHE_TTL_MS) return Promise.resolve(null);
1395
+ const existing = this.#scopedUsageInflight.get(provider);
1396
+ if (existing) return existing;
1397
+ const epoch = this.#usageCacheEpoch;
1398
+ const inflight = this.#client
1399
+ .fetchUsage(undefined, provider)
1400
+ .then(body => {
1401
+ if (this.#usageCacheEpoch === epoch) {
1402
+ this.#scopedUsageCache.set(provider, { reports: body.reports, fetchedAt: Date.now() });
1403
+ this.#scopedUsageFailures.delete(provider);
1404
+ this.#recordUsageReports(body.reports, epoch);
1405
+ }
1406
+ return body.reports;
1407
+ })
1408
+ .catch(error => {
1409
+ logger.warn("auth-broker scoped usage fetch failed", {
1410
+ provider,
1411
+ error: cleanReason(error) ?? "Usage unavailable.",
1412
+ });
1413
+ if (this.#usageCacheEpoch === epoch) this.#scopedUsageFailures.set(provider, Date.now());
1414
+ return null;
1415
+ })
1416
+ .finally(() => {
1417
+ if (this.#scopedUsageInflight.get(provider) === inflight) {
1418
+ this.#scopedUsageInflight.delete(provider);
1419
+ }
1420
+ });
1421
+ this.#scopedUsageInflight.set(provider, inflight);
1422
+ return inflight;
1423
+ }
1164
1424
  const cached = this.#usageCache;
1165
1425
  if (cached && Date.now() - cached.fetchedAt < USAGE_CACHE_TTL_MS) {
1166
1426
  return Promise.resolve(cached.reports);
@@ -1194,6 +1454,7 @@ export class RemoteAuthCredentialStore implements AuthCredentialStore {
1194
1454
  this.#persistedPresentations.clear();
1195
1455
  });
1196
1456
  this.#closed = true;
1457
+ this.#snapshotListeners.clear();
1197
1458
  this.#cache.clear();
1198
1459
  this.#usagePresentations.clear();
1199
1460
  this.#inventoryMetadata.clear();
@@ -1217,9 +1478,7 @@ function snapshotIdentityLabel(entry: SnapshotEntry): string | null {
1217
1478
 
1218
1479
  function safePresentationUsageReport(report: UsageReport): SafeUsageReport {
1219
1480
  const sanitize = (value: string): string =>
1220
- value
1221
- .replace(/bearer\s+[^\s,;]+/gi, "Bearer [redacted]")
1222
- .replace(/(api[_-]?key|token|secret|authorization)[=:]\s*[^\s,;]+/gi, "$1=[redacted]")
1481
+ (cleanReason(value) ?? "Usage unavailable.")
1223
1482
  .replace(/[\u0000-\u001f\u007f-\u009f]/g, " ")
1224
1483
  .replace(/\s+/g, " ")
1225
1484
  .trim()