@gajae-code/ai 0.13.2 → 0.14.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 (81) hide show
  1. package/CHANGELOG.md +61 -2
  2. package/dist/types/auth-broker/client.d.ts +9 -1
  3. package/dist/types/auth-broker/redact.d.ts +7 -0
  4. package/dist/types/auth-broker/remote-store.d.ts +50 -9
  5. package/dist/types/auth-broker/types.d.ts +14 -0
  6. package/dist/types/auth-broker/wire-schemas.d.ts +25 -0
  7. package/dist/types/auth-storage.d.ts +200 -6
  8. package/dist/types/core.d.ts +1 -0
  9. package/dist/types/model-cache.d.ts +4 -1
  10. package/dist/types/model-manager.d.ts +11 -0
  11. package/dist/types/provider-models/openai-compat.d.ts +5 -0
  12. package/dist/types/provider-models/special.d.ts +3 -0
  13. package/dist/types/providers/anthropic.d.ts +31 -0
  14. package/dist/types/providers/cursor.d.ts +9 -1
  15. package/dist/types/providers/kiro-codewhisperer.d.ts +8 -0
  16. package/dist/types/providers/mock.d.ts +8 -0
  17. package/dist/types/providers/register-builtins.d.ts +1 -0
  18. package/dist/types/providers/transform-messages.d.ts +18 -0
  19. package/dist/types/types.d.ts +34 -8
  20. package/dist/types/usage/grok-cli.d.ts +5 -0
  21. package/dist/types/usage.d.ts +6 -0
  22. package/dist/types/utils/discovery/openai-compatible.d.ts +5 -0
  23. package/dist/types/utils/event-stream.d.ts +4 -2
  24. package/dist/types/utils/fallback-transport.d.ts +10 -0
  25. package/dist/types/utils/http-inspector.d.ts +1 -0
  26. package/dist/types/utils/idle-iterator.d.ts +13 -1
  27. package/dist/types/utils/json-parse.d.ts +19 -0
  28. package/dist/types/utils/oauth/callback-server.d.ts +13 -0
  29. package/dist/types/utils/oauth/kiro.d.ts +71 -0
  30. package/dist/types/utils/oauth/types.d.ts +1 -1
  31. package/dist/types/utils/parse-bind.d.ts +8 -5
  32. package/dist/types/utils/tool-call-healing.d.ts +7 -0
  33. package/dist/types/utils/tool-choice-capability.d.ts +11 -0
  34. package/package.json +3 -2
  35. package/src/auth-broker/client.ts +30 -0
  36. package/src/auth-broker/redact.ts +15 -0
  37. package/src/auth-broker/refresher.ts +4 -2
  38. package/src/auth-broker/remote-store.ts +693 -70
  39. package/src/auth-broker/server.ts +57 -12
  40. package/src/auth-broker/types.ts +16 -0
  41. package/src/auth-broker/wire-schemas.ts +21 -0
  42. package/src/auth-gateway/server.ts +84 -19
  43. package/src/auth-storage.ts +985 -41
  44. package/src/core.ts +1 -0
  45. package/src/model-cache.ts +23 -4
  46. package/src/model-manager.ts +70 -11
  47. package/src/model-thinking.ts +45 -1
  48. package/src/models.json +9604 -1932
  49. package/src/openai-completions-compat.ts +2 -1
  50. package/src/provider-models/descriptors.ts +7 -1
  51. package/src/provider-models/openai-compat.ts +52 -28
  52. package/src/provider-models/special.ts +12 -0
  53. package/src/providers/amazon-bedrock.ts +2 -1
  54. package/src/providers/anthropic.ts +831 -27
  55. package/src/providers/cursor.ts +83 -3
  56. package/src/providers/kiro-codewhisperer.ts +572 -0
  57. package/src/providers/mock.ts +15 -2
  58. package/src/providers/ollama.ts +9 -2
  59. package/src/providers/openai-codex-responses.ts +16 -9
  60. package/src/providers/openai-completions.ts +6 -1
  61. package/src/providers/openai-responses-shared.ts +180 -18
  62. package/src/providers/register-builtins.ts +24 -2
  63. package/src/providers/transform-messages.ts +64 -1
  64. package/src/stream.ts +25 -2
  65. package/src/types.ts +36 -7
  66. package/src/usage/grok-cli.ts +86 -1
  67. package/src/usage.ts +7 -0
  68. package/src/utils/discovery/openai-compatible.ts +89 -4
  69. package/src/utils/event-stream.ts +11 -2
  70. package/src/utils/fallback-transport.ts +44 -2
  71. package/src/utils/http-inspector.ts +1 -0
  72. package/src/utils/idle-iterator.ts +29 -6
  73. package/src/utils/json-parse.ts +80 -0
  74. package/src/utils/oauth/callback-server.ts +31 -1
  75. package/src/utils/oauth/index.ts +14 -1
  76. package/src/utils/oauth/kiro.ts +448 -0
  77. package/src/utils/oauth/synthetic.ts +2 -3
  78. package/src/utils/oauth/types.ts +1 -0
  79. package/src/utils/parse-bind.ts +27 -0
  80. package/src/utils/tool-call-healing.ts +13 -2
  81. package/src/utils/tool-choice-capability.ts +386 -6
@@ -7,24 +7,58 @@
7
7
  * usage reports cache TTL is 5 minutes per credential, so durability across
8
8
  * runs isn't required.
9
9
  */
10
+
11
+ import { createHash, randomUUID } from "node:crypto";
12
+ import * as fs from "node:fs/promises";
13
+ import * as path from "node:path";
10
14
  import { scheduler } from "node:timers/promises";
11
- import { logger } from "@gajae-code/utils";
15
+
16
+ import { getConfigRootDir, isEnoent, logger } from "@gajae-code/utils";
12
17
  import {
13
18
  type AuthCredential,
14
19
  type AuthCredentialIfAbsentResult,
15
20
  type AuthCredentialSnapshotEntry,
16
21
  type AuthCredentialStore,
17
22
  assertCanonicalMCPOAuthBinding,
23
+ type CachedCredentialHealth,
24
+ type CachedUsagePresentation,
25
+ type CredentialInventoryRecord,
18
26
  type MCPOAuthRefreshClient,
19
27
  type OAuthCredential,
20
28
  REMOTE_REFRESH_SENTINEL,
29
+ type SafeUsageReport,
21
30
  type StoredAuthCredential,
22
31
  } from "../auth-storage";
23
32
  import type { Provider } from "../types";
24
33
  import type { UsageReport } from "../usage";
25
34
  import type { OAuthCredentials } from "../utils/oauth/types";
26
- import { type AuthBrokerClient, AuthBrokerStreamUnsupportedError } from "./client";
27
- import type { RefresherSchedule, SnapshotEntry, SnapshotResponse, SnapshotStreamEvent } from "./types";
35
+ import {
36
+ type AuthBrokerClient,
37
+ AuthBrokerCredentialMetadataUnsupportedError,
38
+ AuthBrokerStreamUnsupportedError,
39
+ } from "./client";
40
+ import type {
41
+ CredentialMetadataRecord,
42
+ RefresherSchedule,
43
+ SnapshotEntry,
44
+ SnapshotResponse,
45
+ SnapshotStreamEvent,
46
+ } from "./types";
47
+
48
+ export type CredentialInventoryMetadataCapability = "pending" | "supported" | "unsupported" | "mismatch" | "failed";
49
+
50
+ export interface CachedInventoryNotice {
51
+ status: Exclude<CredentialInventoryMetadataCapability, "supported">;
52
+ reason: string;
53
+ generation?: number;
54
+ }
55
+
56
+ export interface CredentialInventoryMetadataState {
57
+ capability: CredentialInventoryMetadataCapability;
58
+ generation: number;
59
+ records: readonly CredentialInventoryRecord[];
60
+ notice?: CachedInventoryNotice;
61
+ }
28
62
 
29
63
  /**
30
64
  * Client-side TTL for the aggregate `/v1/usage` response. Set below the
@@ -38,6 +72,10 @@ const MAX_WAIT_MS = 5_000;
38
72
  const BACKGROUND_WAIT_MS = 30_000;
39
73
  const BACKGROUND_BACKOFF_INITIAL_MS = 500;
40
74
  const BACKGROUND_BACKOFF_MAX_MS = 30_000;
75
+ const PRESENTATION_FRESH_MS = 5 * 60_000;
76
+ const PRESENTATION_RETENTION_MS = 24 * 60 * 60_000;
77
+ const PRESENTATION_SIDECAR_VERSION = 1;
78
+ const DEFAULT_PRESENTATION_SIDECAR = path.join(getConfigRootDir(), "auth-broker-presentations.json");
41
79
 
42
80
  function emptySnapshot(): SnapshotResponse {
43
81
  return {
@@ -64,6 +102,39 @@ interface UsageCacheEntry {
64
102
  fetchedAt: number;
65
103
  }
66
104
 
105
+ interface PersistedPresentation {
106
+ credentialId: number;
107
+ provider: string;
108
+ identityDigest: string;
109
+ health?: {
110
+ v: 1;
111
+ status: "ok" | "failed" | "unverifiable";
112
+ reason: string | null;
113
+ checkedAt?: number;
114
+ retainUntil: number;
115
+ };
116
+ usage?: CachedUsagePresentation;
117
+ }
118
+
119
+ interface PresentationSidecarFile {
120
+ version: 1;
121
+ authorities: Record<string, Record<string, PersistedPresentation>>;
122
+ sourceHealth?: Record<string, Record<string, CacheEntry>>;
123
+ }
124
+
125
+ function presentationRecordKey(provider: string, identityDigest: string): string {
126
+ return `${provider}\u0000${identityDigest}`;
127
+ }
128
+
129
+ function safePresentationReason(value: unknown): string | null {
130
+ if (value === null || value === undefined) return null;
131
+ let reason = value instanceof Error ? value.message : String(value);
132
+ reason = reason.replace(/bearer\s+[^\s,;]+/gi, "Bearer [redacted]");
133
+ reason = reason.replace(/(api[_-]?key|token|secret|authorization)[=:]\s*[^\s,;]+/gi, "$1=[redacted]");
134
+ reason = reason.replace(/[\r\n\t ]+/g, " ").trim();
135
+ return reason.length > 256 ? `${reason.slice(0, 253)}...` : reason || null;
136
+ }
137
+
67
138
  export interface RemoteAuthCredentialStoreOptions {
68
139
  client: AuthBrokerClient;
69
140
  /**
@@ -76,6 +147,8 @@ export interface RemoteAuthCredentialStoreOptions {
76
147
  * to long-poll permanently when the broker returns 404. Default `true`.
77
148
  */
78
149
  streamSnapshots?: boolean;
150
+ /** Override the local redacted presentation sidecar path (primarily for tests). */
151
+ presentationPath?: string;
79
152
  }
80
153
 
81
154
  export class RemoteAuthCredentialStore implements AuthCredentialStore {
@@ -89,6 +162,24 @@ export class RemoteAuthCredentialStore implements AuthCredentialStore {
89
162
  #usageCache?: UsageCacheEntry;
90
163
  #usageInflight?: Promise<UsageReport[] | null>;
91
164
  #usageCacheEpoch = 0;
165
+ #inventoryMetadata = new Map<number, CredentialMetadataRecord>();
166
+ #inventoryMetadataGeneration = -1;
167
+ #inventoryState: CredentialInventoryMetadataState = {
168
+ capability: "pending",
169
+ generation: 0,
170
+ records: [],
171
+ notice: { status: "pending", reason: "credential metadata sync pending", generation: 0 },
172
+ };
173
+ #inventorySyncInflight?: Promise<Readonly<CredentialInventoryMetadataState>>;
174
+ #inventoryMetadataUnsupported = false;
175
+ #usagePresentations = new Map<number, CachedUsagePresentation>();
176
+ readonly #presentationPath: string;
177
+ readonly #presentationAuthority: string;
178
+ #persistedPresentations = new Map<string, PersistedPresentation>();
179
+ #sidecarAuthorities: Record<string, Record<string, PersistedPresentation>> = {};
180
+ #persistedSourceHealth: Record<string, Record<string, CacheEntry>> = {};
181
+ #presentationReady: Promise<void>;
182
+ #presentationWriteChain: Promise<void> = Promise.resolve();
92
183
  #closed = false;
93
184
  /**
94
185
  * `true` once the SSE consumer received its first frame and hasn't dropped
@@ -103,7 +194,16 @@ export class RemoteAuthCredentialStore implements AuthCredentialStore {
103
194
  constructor(opts: RemoteAuthCredentialStoreOptions) {
104
195
  this.#client = opts.client;
105
196
  this.#streamSnapshots = opts.streamSnapshots ?? true;
106
- this.#applySnapshot(opts.initialSnapshot ?? emptySnapshot(), opts.initialSnapshot?.generation ?? 0);
197
+ this.#presentationPath = opts.presentationPath ?? DEFAULT_PRESENTATION_SIDECAR;
198
+ this.#presentationAuthority = createHash("sha256").update(this.#client.baseUrl).digest("hex");
199
+ this.#applySnapshot(opts.initialSnapshot ?? emptySnapshot(), opts.initialSnapshot?.generation ?? 0, false);
200
+ this.#setInventoryState("pending", this.#generation, {
201
+ status: "pending",
202
+ reason: "credential metadata sync pending",
203
+ generation: this.#generation,
204
+ });
205
+ this.#presentationReady = this.#loadPresentationSidecar();
206
+ this.#scheduleInventoryMetadataSync();
107
207
  void this.#runBackground();
108
208
  }
109
209
 
@@ -111,14 +211,300 @@ export class RemoteAuthCredentialStore implements AuthCredentialStore {
111
211
  return this.#client;
112
212
  }
113
213
 
214
+ /** Wait for redacted presentation hydration and initial inventory metadata. */
215
+ async waitForReady(): Promise<void> {
216
+ await this.#presentationReady;
217
+ await this.syncInventoryMetadata();
218
+ }
219
+
220
+ /** Await pending atomic sidecar writes (useful to bounded shutdown callers). */
221
+ async flushPresentationPersistence(): Promise<void> {
222
+ await this.#presentationReady;
223
+ await this.#presentationWriteChain;
224
+ }
225
+
226
+ async #loadPresentationSidecar(): Promise<void> {
227
+ let raw: string;
228
+ try {
229
+ const stat = await fs.lstat(this.#presentationPath);
230
+ if (stat.isSymbolicLink() || !stat.isFile()) return;
231
+ raw = await fs.readFile(this.#presentationPath, "utf8");
232
+ } catch (error) {
233
+ if (isEnoent(error)) return;
234
+ logger.debug("auth-broker presentation sidecar unavailable", { error: String(error) });
235
+ return;
236
+ }
237
+ try {
238
+ const parsed = JSON.parse(raw) as Partial<PresentationSidecarFile>;
239
+ if (parsed.version !== PRESENTATION_SIDECAR_VERSION || !parsed.authorities) return;
240
+ this.#sidecarAuthorities = parsed.authorities;
241
+ this.#persistedSourceHealth = parsed.sourceHealth ?? {};
242
+ for (const [key, entry] of Object.entries(this.#persistedSourceHealth[this.#presentationAuthority] ?? {})) {
243
+ if (entry.expiresAtSec * 1000 > Date.now()) this.#cache.set(key, entry);
244
+ }
245
+ const records = this.#sidecarAuthorities[this.#presentationAuthority];
246
+ if (!records || typeof records !== "object") return;
247
+ const now = Date.now();
248
+ for (const [key, candidate] of Object.entries(records)) {
249
+ if (!candidate || typeof candidate !== "object") continue;
250
+ const record = candidate as PersistedPresentation;
251
+ if (!Number.isInteger(record.credentialId) || typeof record.provider !== "string") continue;
252
+ if (record.health && (!Number.isFinite(record.health.retainUntil) || record.health.retainUntil <= now)) {
253
+ delete record.health;
254
+ }
255
+ if (record.usage && (!Number.isFinite(record.usage.retainUntil) || record.usage.retainUntil <= now)) {
256
+ delete record.usage;
257
+ }
258
+ if (!record.health && !record.usage) continue;
259
+ this.#persistedPresentations.set(key, record);
260
+ }
261
+ this.#reconcilePersistedPresentations();
262
+ this.#hydratePresentations();
263
+ this.#queuePresentationWrite();
264
+ } catch (error) {
265
+ logger.debug("auth-broker presentation sidecar invalid", { error: String(error) });
266
+ }
267
+ }
268
+
269
+ #hydratePresentations(): void {
270
+ for (const entry of this.#snapshot.credentials) {
271
+ const key = presentationRecordKey(entry.provider, this.#identityDigestForEntry(entry));
272
+ const persisted = this.#persistedPresentations.get(key);
273
+ if (!persisted) continue;
274
+ if (persisted.usage) {
275
+ this.#usagePresentations.set(entry.id, {
276
+ ...persisted.usage,
277
+ credentialId: entry.id,
278
+ provider: entry.provider,
279
+ inventoryGeneration: this.#generation,
280
+ identityDigest: this.#identityDigestForEntry(entry),
281
+ });
282
+ }
283
+ if (persisted.health && persisted.health.retainUntil <= Date.now()) delete persisted.health;
284
+ }
285
+ }
286
+
287
+ #reconcilePersistedPresentations(): void {
288
+ if (this.#persistedPresentations.size === 0) return;
289
+ let changed = false;
290
+ const current = new Map(
291
+ this.#snapshot.credentials.map(entry => [
292
+ entry.id,
293
+ { provider: entry.provider, identityDigest: this.#identityDigestForEntry(entry) },
294
+ ]),
295
+ );
296
+ for (const [key, record] of this.#persistedPresentations) {
297
+ const identity = current.get(record.credentialId);
298
+ if (
299
+ !identity ||
300
+ identity.provider !== record.provider ||
301
+ key !== presentationRecordKey(identity.provider, identity.identityDigest)
302
+ ) {
303
+ this.#persistedPresentations.delete(key);
304
+ changed = true;
305
+ }
306
+ }
307
+ if (changed) this.#queuePresentationWrite();
308
+ }
309
+
310
+ #queuePresentationWrite(): void {
311
+ const next = this.#presentationWriteChain
312
+ .catch(() => {})
313
+ .then(async () => {
314
+ const parsed: PresentationSidecarFile = {
315
+ version: 1,
316
+ authorities: {
317
+ ...this.#sidecarAuthorities,
318
+ [this.#presentationAuthority]: Object.fromEntries(this.#persistedPresentations),
319
+ },
320
+ sourceHealth: this.#persistedSourceHealth,
321
+ };
322
+ this.#sidecarAuthorities = parsed.authorities;
323
+ const directory = path.dirname(this.#presentationPath);
324
+ await fs.mkdir(directory, { recursive: true, mode: 0o700 });
325
+ const existing = await fs.lstat(this.#presentationPath).catch(error => {
326
+ if (isEnoent(error)) return undefined;
327
+ throw error;
328
+ });
329
+ if (existing?.isSymbolicLink() || (existing && !existing.isFile()))
330
+ throw new Error("Auth-broker presentation sidecar path is unsafe");
331
+ const temporary = `${this.#presentationPath}.${process.pid}.${randomUUID()}.tmp`;
332
+ try {
333
+ try {
334
+ await fs.writeFile(temporary, JSON.stringify(parsed), { flag: "wx", mode: 0o600 });
335
+ } catch (error) {
336
+ if (isEnoent(error)) return;
337
+ throw error;
338
+ }
339
+ await fs.chmod(temporary, 0o600).catch(() => {});
340
+ await fs.rename(temporary, this.#presentationPath).catch(error => {
341
+ if (!isEnoent(error)) throw error;
342
+ });
343
+ } finally {
344
+ await fs.unlink(temporary).catch(() => {});
345
+ }
346
+ });
347
+ this.#presentationWriteChain = next;
348
+ }
349
+
114
350
  get snapshot(): SnapshotResponse {
115
351
  return this.#snapshot;
116
352
  }
117
353
 
118
- #applySnapshot(snapshot: SnapshotResponse, generation: number): void {
354
+ getInventoryMetadataState(): Readonly<CredentialInventoryMetadataState> {
355
+ const records = this.#buildInventoryRecords().map(record => ({ ...record }));
356
+ const notice = this.#inventoryState.notice ? { ...this.#inventoryState.notice } : undefined;
357
+ return {
358
+ capability: this.#inventoryState.capability,
359
+ generation: this.#inventoryState.generation,
360
+ records,
361
+ ...(notice ? { notice } : {}),
362
+ };
363
+ }
364
+
365
+ #applySnapshot(snapshot: SnapshotResponse, generation: number, scheduleMetadata = true): void {
366
+ const generationChanged = generation !== this.#generation || this.#inventoryMetadataGeneration !== generation;
119
367
  this.#snapshot = snapshot;
120
368
  this.#generation = generation;
121
369
  this.#snapshotReceivedAt = Date.now();
370
+ if (generationChanged) {
371
+ this.#inventoryMetadata.clear();
372
+ this.#inventoryMetadataGeneration = -1;
373
+ this.#invalidateUsageCache();
374
+ this.#reconcileUsagePresentations();
375
+ if (this.#inventoryMetadataUnsupported) {
376
+ this.#setInventoryState("unsupported", generation, {
377
+ status: "unsupported",
378
+ reason: "credential metadata endpoint unsupported; disabled rows unavailable",
379
+ generation,
380
+ });
381
+ } else {
382
+ this.#setInventoryState("pending", generation, {
383
+ status: "pending",
384
+ reason: "credential metadata sync pending",
385
+ generation,
386
+ });
387
+ if (scheduleMetadata) this.#scheduleInventoryMetadataSync();
388
+ }
389
+ }
390
+ this.#reconcilePersistedPresentations();
391
+ this.#hydratePresentations();
392
+ }
393
+
394
+ #setInventoryState(
395
+ capability: CredentialInventoryMetadataCapability,
396
+ generation: number,
397
+ notice?: CachedInventoryNotice,
398
+ ): void {
399
+ const records = this.#buildInventoryRecords();
400
+ this.#inventoryState = {
401
+ capability,
402
+ generation,
403
+ records,
404
+ ...(notice ? { notice } : {}),
405
+ };
406
+ }
407
+
408
+ #buildInventoryRecords(): CredentialInventoryRecord[] {
409
+ const metadataReady = this.#inventoryMetadataGeneration === this.#generation;
410
+ const records: CredentialInventoryRecord[] = [];
411
+ const seen = new Set<number>();
412
+ for (const entry of this.#snapshot.credentials) {
413
+ const metadata = metadataReady ? this.#inventoryMetadata.get(entry.id) : undefined;
414
+ seen.add(entry.id);
415
+ records.push({
416
+ id: entry.id,
417
+ provider: entry.provider,
418
+ credentialKind: metadata?.type ?? entry.credential.type,
419
+ identityLabel: metadata?.identity ?? snapshotIdentityLabel(entry),
420
+ disabled: false,
421
+ disabledCause: null,
422
+ });
423
+ }
424
+ if (metadataReady) {
425
+ for (const metadata of this.#inventoryMetadata.values()) {
426
+ if (seen.has(metadata.id) || metadata.disabledCause === null) continue;
427
+ records.push({
428
+ id: metadata.id,
429
+ provider: metadata.provider,
430
+ credentialKind: metadata.type,
431
+ identityLabel: metadata.identity,
432
+ disabled: true,
433
+ disabledCause: metadata.disabledCause,
434
+ });
435
+ }
436
+ }
437
+ return records;
438
+ }
439
+
440
+ #scheduleInventoryMetadataSync(): void {
441
+ if (this.#closed || this.#inventoryMetadataUnsupported) return;
442
+ if (this.#inventorySyncInflight) return;
443
+ void this.syncInventoryMetadata().catch(() => {});
444
+ }
445
+
446
+ async syncInventoryMetadata(): Promise<Readonly<CredentialInventoryMetadataState>> {
447
+ if (this.#inventoryMetadataUnsupported) return this.getInventoryMetadataState();
448
+ if (this.#inventoryState.capability === "supported" && this.#inventoryMetadataGeneration === this.#generation) {
449
+ return this.getInventoryMetadataState();
450
+ }
451
+ if (this.#inventorySyncInflight) return this.#inventorySyncInflight;
452
+ const inflight = this.#syncInventoryMetadata().finally(() => {
453
+ this.#inventorySyncInflight = undefined;
454
+ });
455
+ this.#inventorySyncInflight = inflight;
456
+ return inflight;
457
+ }
458
+
459
+ async #syncInventoryMetadata(): Promise<Readonly<CredentialInventoryMetadataState>> {
460
+ for (let attempt = 0; attempt < 2; attempt += 1) {
461
+ try {
462
+ const metadata = await this.#client.fetchCredentialMetadata();
463
+ if (metadata.generation !== this.#generation) {
464
+ if (attempt === 0) {
465
+ await this.refreshSnapshot().catch(() => {});
466
+ continue;
467
+ }
468
+ this.#inventoryMetadata.clear();
469
+ this.#inventoryMetadataGeneration = -1;
470
+ this.#setInventoryState("mismatch", this.#generation, {
471
+ status: "mismatch",
472
+ reason: "credential metadata generation mismatch",
473
+ generation: this.#generation,
474
+ });
475
+ return this.getInventoryMetadataState();
476
+ }
477
+ this.#inventoryMetadata = new Map(metadata.credentials.map(record => [record.id, { ...record }]));
478
+ this.#inventoryMetadataGeneration = metadata.generation;
479
+ this.#setInventoryState("supported", metadata.generation);
480
+ this.#reconcileUsagePresentations();
481
+ return this.getInventoryMetadataState();
482
+ } catch (error) {
483
+ if (error instanceof AuthBrokerCredentialMetadataUnsupportedError || isErrorStatus(error, 404)) {
484
+ this.#inventoryMetadataUnsupported = true;
485
+ this.#inventoryMetadata.clear();
486
+ this.#inventoryMetadataGeneration = -1;
487
+ this.#setInventoryState("unsupported", this.#generation, {
488
+ status: "unsupported",
489
+ reason: "credential metadata endpoint unsupported; disabled rows unavailable",
490
+ generation: this.#generation,
491
+ });
492
+ return this.getInventoryMetadataState();
493
+ }
494
+ this.#setInventoryState("failed", this.#generation, {
495
+ status: "failed",
496
+ reason: "credential metadata sync failed; retry explicitly",
497
+ generation: this.#generation,
498
+ });
499
+ return this.getInventoryMetadataState();
500
+ }
501
+ }
502
+ this.#setInventoryState("mismatch", this.#generation, {
503
+ status: "mismatch",
504
+ reason: "credential metadata generation mismatch",
505
+ generation: this.#generation,
506
+ });
507
+ return this.getInventoryMetadataState();
122
508
  }
123
509
 
124
510
  async #runBackground(): Promise<void> {
@@ -211,16 +597,23 @@ export class RemoteAuthCredentialStore implements AuthCredentialStore {
211
597
  index === -1
212
598
  ? [...this.#snapshot.credentials, entry]
213
599
  : this.#snapshot.credentials.map((candidate, i) => (i === index ? entry : candidate));
214
- this.#snapshot = { ...this.#snapshot, generation, serverNowMs, refresher, credentials };
215
- this.#generation = generation;
216
- this.#snapshotReceivedAt = Date.now();
600
+ this.#applySnapshot({ ...this.#snapshot, generation, serverNowMs, refresher, credentials }, generation);
217
601
  }
218
602
 
219
603
  #removeStreamCredential(id: number, refresher: RefresherSchedule, generation: number, serverNowMs: number): void {
220
604
  const credentials = this.#snapshot.credentials.filter(entry => entry.id !== id);
221
- this.#snapshot = { ...this.#snapshot, generation, serverNowMs, refresher, credentials };
222
- this.#generation = generation;
223
- this.#snapshotReceivedAt = Date.now();
605
+ this.#applySnapshot({ ...this.#snapshot, generation, serverNowMs, refresher, credentials }, generation);
606
+ }
607
+
608
+ /**
609
+ * Payload-free inventory view. This method never performs network I/O; metadata
610
+ * rows appear only after an explicit or background metadata synchronization for
611
+ * the current snapshot generation.
612
+ */
613
+ listCredentialInventory(provider?: string): CredentialInventoryRecord[] {
614
+ return this.#buildInventoryRecords()
615
+ .filter(record => provider === undefined || record.provider === provider)
616
+ .map(record => ({ ...record }));
224
617
  }
225
618
 
226
619
  /** Re-hydrate the in-memory snapshot from the broker. */
@@ -257,19 +650,12 @@ export class RemoteAuthCredentialStore implements AuthCredentialStore {
257
650
  }
258
651
  }
259
652
 
260
- deleteAuthCredential(id: number, disabledCause: string): void {
261
- this.#removeCredentialById(id);
262
- // Fire-and-forget: tell the broker to persist the disable.
263
- this.#client.disableCredential(id, disabledCause).catch(error => {
264
- logger.warn("auth-broker disable propagation failed", { id, error: String(error) });
265
- });
653
+ deleteAuthCredential(_id: number, _disabledCause: string): void {
654
+ throw new Error("Remote auth-broker credentials can only be disabled on the broker host");
266
655
  }
267
656
 
268
- tryDisableAuthCredentialIfMatches(id: number, _expectedData: string, disabledCause: string): boolean {
269
- const found = this.#snapshot.credentials.find(entry => entry.id === id);
270
- if (!found) return false;
271
- this.deleteAuthCredential(id, disabledCause);
272
- return true;
657
+ tryDisableAuthCredentialIfMatches(_id: number, _expectedData: string, _disabledCause: string): boolean {
658
+ return false;
273
659
  }
274
660
 
275
661
  async waitForFreshSnapshot(maxWaitMs: number, opts: { signal?: AbortSignal } = {}): Promise<boolean> {
@@ -327,6 +713,14 @@ export class RemoteAuthCredentialStore implements AuthCredentialStore {
327
713
  );
328
714
  }
329
715
 
716
+ /** Logout authority remains on the broker; the client only mirrors its result. */
717
+ async deleteAuthCredentialsRemote(provider: string, disabledCause: string): Promise<void> {
718
+ const existing = this.listAuthCredentials(provider);
719
+ for (const entry of existing) await this.#client.disableCredential(entry.id, disabledCause);
720
+ this.#removeProviderEntries(provider);
721
+ this.#maybeRefreshSnapshot("delete");
722
+ }
723
+
330
724
  /**
331
725
  * Upsert a single credential through the broker. The broker server is the
332
726
  * canonical writer — see `POST /v1/credential`. The redacted snapshot
@@ -362,19 +756,9 @@ export class RemoteAuthCredentialStore implements AuthCredentialStore {
362
756
  ): Promise<StoredAuthCredential[]> {
363
757
  const existing = this.listAuthCredentials(provider);
364
758
  for (const entry of existing) {
365
- try {
366
- await this.#client.disableCredential(entry.id, "replaced by newer credential");
367
- } catch (error) {
368
- logger.warn("auth-broker disable during replace failed", {
369
- provider,
370
- id: entry.id,
371
- error: String(error),
372
- });
373
- }
759
+ await this.#client.disableCredential(entry.id, "replaced by newer credential");
374
760
  }
375
- // Snapshot reflects the disables before we add the new rows so a concurrent
376
- // reader cannot momentarily see old + new together for the same provider.
377
- this.#removeProviderEntries(provider);
761
+ await this.refreshSnapshot();
378
762
  for (const credential of credentials) {
379
763
  const { entries } = await this.#client.uploadCredential(provider, credential);
380
764
  this.#applyProviderEntries(provider, entries);
@@ -383,28 +767,6 @@ export class RemoteAuthCredentialStore implements AuthCredentialStore {
383
767
  return this.listAuthCredentials(provider);
384
768
  }
385
769
 
386
- /**
387
- * Logout: disable every active credential for the provider on the broker,
388
- * then drop them from the local snapshot. Refresh fetches the authoritative
389
- * post-state in the background.
390
- */
391
- async deleteAuthCredentialsRemote(provider: string, disabledCause: string): Promise<void> {
392
- const existing = this.listAuthCredentials(provider);
393
- for (const entry of existing) {
394
- try {
395
- await this.#client.disableCredential(entry.id, disabledCause);
396
- } catch (error) {
397
- logger.warn("auth-broker disable during delete failed", {
398
- provider,
399
- id: entry.id,
400
- error: String(error),
401
- });
402
- }
403
- }
404
- this.#removeProviderEntries(provider);
405
- this.#maybeRefreshSnapshot("delete");
406
- }
407
-
408
770
  #applyProviderEntries(provider: string, entries: AuthCredentialSnapshotEntry[]): void {
409
771
  // `entries` is the broker's authoritative post-upsert list of rows for
410
772
  // `provider`. Drop our existing rows for the same provider and splice in
@@ -413,6 +775,11 @@ export class RemoteAuthCredentialStore implements AuthCredentialStore {
413
775
  const incoming = entries.map(entry => ({ ...entry, rotatesInMs: null }));
414
776
  this.#snapshot = { ...this.#snapshot, credentials: [...others, ...incoming] };
415
777
  }
778
+
779
+ #removeProviderEntries(provider: string): void {
780
+ const credentials = this.#snapshot.credentials.filter(entry => entry.provider !== provider);
781
+ this.#applySnapshot({ ...this.#snapshot, credentials }, this.#generation, false);
782
+ }
416
783
  #applyCredentialEntry(entry: AuthCredentialSnapshotEntry): void {
417
784
  const incoming = { ...entry, rotatesInMs: null };
418
785
  const index = this.#snapshot.credentials.findIndex(candidate => candidate.id === entry.id);
@@ -425,16 +792,6 @@ export class RemoteAuthCredentialStore implements AuthCredentialStore {
425
792
  this.#snapshot = { ...this.#snapshot, credentials };
426
793
  }
427
794
 
428
- #removeProviderEntries(provider: string): void {
429
- const next = this.#snapshot.credentials.filter(entry => entry.provider !== provider);
430
- this.#snapshot = { ...this.#snapshot, credentials: next };
431
- }
432
-
433
- #removeCredentialById(id: number): void {
434
- const next = this.#snapshot.credentials.filter(entry => entry.id !== id);
435
- this.#snapshot = { ...this.#snapshot, credentials: next };
436
- }
437
-
438
795
  /**
439
796
  * Fire-and-forget `refreshSnapshot()` after a write. When the SSE stream is
440
797
  * active the broker will deliver the new generation push, so the extra GET
@@ -458,7 +815,24 @@ export class RemoteAuthCredentialStore implements AuthCredentialStore {
458
815
  }
459
816
 
460
817
  setCache(key: string, value: string, expiresAtSec: number): void {
461
- this.#cache.set(key, { value, expiresAtSec });
818
+ let persistedValue = value;
819
+ if (key.startsWith("account_health:v1:source:")) {
820
+ try {
821
+ const parsed = JSON.parse(value) as { reason?: unknown } & Record<string, unknown>;
822
+ persistedValue = JSON.stringify({ ...parsed, reason: safePresentationReason(parsed.reason) });
823
+ } catch {
824
+ persistedValue = JSON.stringify({ status: "unknown", reason: null });
825
+ }
826
+ }
827
+ const entry = { value: persistedValue, expiresAtSec };
828
+ this.#cache.set(key, entry);
829
+ if (key.startsWith("account_health:v1:source:")) {
830
+ this.#persistedSourceHealth[this.#presentationAuthority] = {
831
+ ...(this.#persistedSourceHealth[this.#presentationAuthority] ?? {}),
832
+ [key]: entry,
833
+ };
834
+ this.#queuePresentationWrite();
835
+ }
462
836
  }
463
837
 
464
838
  cleanExpiredCache(): void {
@@ -493,7 +867,19 @@ export class RemoteAuthCredentialStore implements AuthCredentialStore {
493
867
  _credential: OAuthCredential,
494
868
  signal?: AbortSignal,
495
869
  ): Promise<OAuthCredentials> {
496
- const { entry } = await this.#client.refreshCredential(credentialId, signal);
870
+ let entry: AuthCredentialSnapshotEntry;
871
+ try {
872
+ ({ entry } = await this.#client.refreshCredential(credentialId, signal));
873
+ } catch (error) {
874
+ if (isErrorStatus(error, 404) && !this.#streamingActive) {
875
+ await this.refreshSnapshot().catch(refreshError => {
876
+ logger.debug("auth-broker snapshot refresh after missing credential refresh failed", {
877
+ error: String(refreshError),
878
+ });
879
+ });
880
+ }
881
+ throw error;
882
+ }
497
883
  if (!this.#streamingActive) {
498
884
  await this.refreshSnapshot().catch(error => {
499
885
  logger.debug("auth-broker snapshot refresh after credential refresh failed", { error: String(error) });
@@ -547,6 +933,123 @@ export class RemoteAuthCredentialStore implements AuthCredentialStore {
547
933
  return this.#raceWithSignal(this.#loadUsageReports(), signal);
548
934
  }
549
935
 
936
+ /** Synchronous, zero-network usage presentation read. */
937
+ peekCachedUsagePresentation(provider: Provider, credentialId: number): CachedUsagePresentation | undefined {
938
+ const entry = this.#snapshot.credentials.find(candidate => candidate.id === credentialId);
939
+ if (!entry || entry.provider !== provider) return undefined;
940
+ const cached = this.#usagePresentations.get(credentialId);
941
+ if (!cached) return undefined;
942
+ const now = Date.now();
943
+ if (cached.retainUntil <= now) {
944
+ this.#usagePresentations.delete(credentialId);
945
+ return undefined;
946
+ }
947
+ const identityDigest = this.#identityDigestForEntry(entry);
948
+ if (
949
+ cached.provider !== provider ||
950
+ cached.inventoryGeneration !== this.#generation ||
951
+ cached.identityDigest !== identityDigest
952
+ ) {
953
+ this.#usagePresentations.delete(credentialId);
954
+ return undefined;
955
+ }
956
+ return cloneUsagePresentation(cached);
957
+ }
958
+
959
+ /** Synchronous, zero-network health presentation read backed by the redacted sidecar. */
960
+ peekCachedCredentialHealth(provider: Provider, credentialId: number): CachedCredentialHealth | undefined {
961
+ const entry = this.#snapshot.credentials.find(candidate => candidate.id === credentialId);
962
+ if (!entry || entry.provider !== provider) return undefined;
963
+ const key = presentationRecordKey(provider, this.#identityDigestForEntry(entry));
964
+ const health = this.#persistedPresentations.get(key)?.health;
965
+ if (!health || health.retainUntil <= Date.now()) {
966
+ if (health) {
967
+ const record = this.#persistedPresentations.get(key);
968
+ if (record) {
969
+ delete record.health;
970
+ if (!record.usage) this.#persistedPresentations.delete(key);
971
+ this.#queuePresentationWrite();
972
+ }
973
+ }
974
+ return undefined;
975
+ }
976
+ return {
977
+ status: health.status,
978
+ reason: health.reason,
979
+ ...(health.checkedAt === undefined ? {} : { checkedAt: health.checkedAt }),
980
+ retainUntil: health.retainUntil,
981
+ };
982
+ }
983
+
984
+ /** Persist a safe health result without credential or bearer-token material. */
985
+ recordCredentialHealth(provider: Provider, credentialId: number, health: CachedCredentialHealth): void {
986
+ if (health.status === "unknown" || !health.retainUntil || health.retainUntil <= Date.now()) return;
987
+ const entry = this.#snapshot.credentials.find(candidate => candidate.id === credentialId);
988
+ if (!entry || entry.provider !== provider) return;
989
+ const identityDigest = this.#identityDigestForEntry(entry);
990
+ const key = presentationRecordKey(provider, identityDigest);
991
+ const record = this.#persistedPresentations.get(key) ?? { credentialId, provider, identityDigest };
992
+ record.health = {
993
+ v: 1,
994
+ status: health.status,
995
+ reason: safePresentationReason(health.reason),
996
+ ...(health.checkedAt === undefined ? {} : { checkedAt: health.checkedAt }),
997
+ retainUntil: health.retainUntil,
998
+ };
999
+ this.#persistedPresentations.set(key, record);
1000
+ this.#queuePresentationWrite();
1001
+ }
1002
+
1003
+ /** Record a safe usage observation after an explicit broker usage/check call. */
1004
+ recordUsagePresentation(observation: CachedUsagePresentation): void {
1005
+ if (!Number.isInteger(observation.credentialId)) return;
1006
+ if (!Number.isFinite(observation.inventoryGeneration) || observation.inventoryGeneration !== this.#generation)
1007
+ return;
1008
+ if (!Number.isFinite(observation.fetchedAt) || !Number.isFinite(observation.freshUntil)) return;
1009
+ if (!Number.isFinite(observation.retainUntil) || observation.retainUntil <= observation.fetchedAt) return;
1010
+ const entry = this.#snapshot.credentials.find(candidate => candidate.id === observation.credentialId);
1011
+ if (!entry || entry.provider !== observation.provider) return;
1012
+ if (this.#identityDigestForEntry(entry) !== observation.identityDigest) return;
1013
+ const usage = safePresentationUsageReport(observation.usage);
1014
+ const stored = cloneUsagePresentation({
1015
+ credentialId: observation.credentialId,
1016
+ provider: observation.provider,
1017
+ inventoryGeneration: observation.inventoryGeneration,
1018
+ identityDigest: observation.identityDigest,
1019
+ usage,
1020
+ fetchedAt: observation.fetchedAt,
1021
+ freshUntil: Math.min(observation.freshUntil, observation.fetchedAt + PRESENTATION_FRESH_MS),
1022
+ retainUntil: Math.min(observation.retainUntil, observation.fetchedAt + PRESENTATION_RETENTION_MS),
1023
+ });
1024
+ this.#usagePresentations.set(observation.credentialId, stored);
1025
+ const key = presentationRecordKey(observation.provider, observation.identityDigest);
1026
+ const persisted = this.#persistedPresentations.get(key) ?? {
1027
+ credentialId: observation.credentialId,
1028
+ provider: observation.provider,
1029
+ identityDigest: observation.identityDigest,
1030
+ };
1031
+ persisted.usage = stored;
1032
+ this.#persistedPresentations.set(key, persisted);
1033
+ this.#queuePresentationWrite();
1034
+ }
1035
+
1036
+ /** Persist an explicit usage/check report for the current credential identity. */
1037
+ recordCredentialUsage(provider: Provider, credentialId: number, report: SafeUsageReport): void {
1038
+ const entry = this.#snapshot.credentials.find(candidate => candidate.id === credentialId);
1039
+ if (!entry || entry.provider !== provider) return;
1040
+ const fetchedAt = Number.isFinite(report.fetchedAt) ? report.fetchedAt : Date.now();
1041
+ this.recordUsagePresentation({
1042
+ credentialId,
1043
+ provider,
1044
+ inventoryGeneration: this.#generation,
1045
+ identityDigest: this.#identityDigestForEntry(entry),
1046
+ usage: safePresentationUsageReport(report),
1047
+ fetchedAt,
1048
+ freshUntil: fetchedAt + PRESENTATION_FRESH_MS,
1049
+ retainUntil: fetchedAt + PRESENTATION_RETENTION_MS,
1050
+ });
1051
+ }
1052
+
550
1053
  /**
551
1054
  * Per-credential usage hook consumed by `AuthStorage.#getUsageReport`. Pulls
552
1055
  * the aggregate broker `/v1/usage` once and serves all callers from the
@@ -594,6 +1097,58 @@ export class RemoteAuthCredentialStore implements AuthCredentialStore {
594
1097
  });
595
1098
  }
596
1099
 
1100
+ #identityDigestForEntry(entry: SnapshotEntry): string {
1101
+ const credential = entry.credential;
1102
+ const identity =
1103
+ credential.type === "oauth"
1104
+ ? {
1105
+ accountId: credential.accountId ?? null,
1106
+ email: credential.email?.trim().toLowerCase() ?? null,
1107
+ projectId: credential.projectId ?? null,
1108
+ enterpriseUrl: credential.enterpriseUrl ?? null,
1109
+ mcpBinding: credential.mcpBinding ?? null,
1110
+ }
1111
+ : { type: credential.type };
1112
+ return createHash("sha256")
1113
+ .update(JSON.stringify({ id: entry.id, provider: entry.provider, type: credential.type, identity }))
1114
+ .digest("hex");
1115
+ }
1116
+
1117
+ #reconcileUsagePresentations(): void {
1118
+ for (const [credentialId, cached] of this.#usagePresentations) {
1119
+ const entry = this.#snapshot.credentials.find(candidate => candidate.id === credentialId);
1120
+ if (
1121
+ !entry ||
1122
+ entry.provider !== cached.provider ||
1123
+ this.#identityDigestForEntry(entry) !== cached.identityDigest
1124
+ ) {
1125
+ this.#usagePresentations.delete(credentialId);
1126
+ continue;
1127
+ }
1128
+ this.#usagePresentations.set(credentialId, { ...cached, inventoryGeneration: this.#generation });
1129
+ }
1130
+ }
1131
+
1132
+ #recordUsageReports(reports: UsageReport[], epoch: number): void {
1133
+ if (this.#usageCacheEpoch !== epoch) return;
1134
+ for (const entry of this.#snapshot.credentials) {
1135
+ if (entry.credential.type !== "oauth") continue;
1136
+ const report = matchUsageReport(reports, entry.provider, entry.credential);
1137
+ if (!report) continue;
1138
+ const fetchedAt = Number.isFinite(report.fetchedAt) ? report.fetchedAt : Date.now();
1139
+ this.recordUsagePresentation({
1140
+ credentialId: entry.id,
1141
+ provider: entry.provider,
1142
+ inventoryGeneration: this.#generation,
1143
+ identityDigest: this.#identityDigestForEntry(entry),
1144
+ usage: safePresentationUsageReport(report),
1145
+ fetchedAt,
1146
+ freshUntil: fetchedAt + PRESENTATION_FRESH_MS,
1147
+ retainUntil: fetchedAt + PRESENTATION_RETENTION_MS,
1148
+ });
1149
+ }
1150
+ }
1151
+
597
1152
  #loadUsageReports(): Promise<UsageReport[] | null> {
598
1153
  const cached = this.#usageCache;
599
1154
  if (cached && Date.now() - cached.fetchedAt < USAGE_CACHE_TTL_MS) {
@@ -604,7 +1159,10 @@ export class RemoteAuthCredentialStore implements AuthCredentialStore {
604
1159
  const inflight = this.#client
605
1160
  .fetchUsage()
606
1161
  .then(body => {
607
- if (this.#usageCacheEpoch === epoch) this.#usageCache = { reports: body.reports, fetchedAt: Date.now() };
1162
+ if (this.#usageCacheEpoch === epoch) {
1163
+ this.#usageCache = { reports: body.reports, fetchedAt: Date.now() };
1164
+ this.#recordUsageReports(body.reports, epoch);
1165
+ }
608
1166
  return body.reports;
609
1167
  })
610
1168
  .catch(error => {
@@ -620,12 +1178,77 @@ export class RemoteAuthCredentialStore implements AuthCredentialStore {
620
1178
 
621
1179
  close(): void {
622
1180
  if (this.#closed) return;
623
- this.#closed = true;
624
1181
  this.#backgroundAbort.abort();
1182
+ void this.#presentationWriteChain.finally(() => {
1183
+ this.#persistedPresentations.clear();
1184
+ });
1185
+ this.#closed = true;
625
1186
  this.#cache.clear();
1187
+ this.#usagePresentations.clear();
1188
+ this.#inventoryMetadata.clear();
1189
+ this.#inventorySyncInflight = undefined;
626
1190
  }
627
1191
  }
628
1192
 
1193
+ function isErrorStatus(error: unknown, status: number): boolean {
1194
+ return (
1195
+ typeof error === "object" &&
1196
+ error !== null &&
1197
+ "status" in error &&
1198
+ (error as { status?: unknown }).status === status
1199
+ );
1200
+ }
1201
+
1202
+ function snapshotIdentityLabel(entry: SnapshotEntry): string | null {
1203
+ if (entry.credential.type !== "oauth") return null;
1204
+ return entry.credential.email ?? entry.credential.accountId ?? entry.credential.projectId ?? null;
1205
+ }
1206
+
1207
+ function safePresentationUsageReport(report: UsageReport): SafeUsageReport {
1208
+ const sanitize = (value: string): string =>
1209
+ value
1210
+ .replace(/bearer\s+[^\s,;]+/gi, "Bearer [redacted]")
1211
+ .replace(/(api[_-]?key|token|secret|authorization)[=:]\s*[^\s,;]+/gi, "$1=[redacted]")
1212
+ .replace(/[\u0000-\u001f\u007f-\u009f]/g, " ")
1213
+ .replace(/\s+/g, " ")
1214
+ .trim()
1215
+ .slice(0, 160);
1216
+ const { raw: _raw, metadata: _metadata, ...safe } = report;
1217
+ return {
1218
+ ...safe,
1219
+ limits: safe.limits.map(limit => {
1220
+ const { accountId: _accountId, projectId: _projectId, orgId: _orgId, ...scope } = limit.scope;
1221
+ return {
1222
+ ...limit,
1223
+ id: sanitize(limit.id),
1224
+ label: sanitize(limit.label),
1225
+ ...(limit.window
1226
+ ? { window: { ...limit.window, id: sanitize(limit.window.id), label: sanitize(limit.window.label) } }
1227
+ : {}),
1228
+ ...(limit.notes ? { notes: limit.notes.map(sanitize) } : {}),
1229
+ scope,
1230
+ };
1231
+ }),
1232
+ };
1233
+ }
1234
+
1235
+ function cloneUsagePresentation(observation: CachedUsagePresentation): CachedUsagePresentation {
1236
+ return {
1237
+ ...observation,
1238
+ usage: {
1239
+ ...observation.usage,
1240
+ limits: observation.usage.limits.map(limit => ({
1241
+ ...limit,
1242
+ scope: { ...limit.scope },
1243
+ window: limit.window ? { ...limit.window } : undefined,
1244
+ amount: { ...limit.amount },
1245
+ notes: limit.notes ? [...limit.notes] : undefined,
1246
+ })),
1247
+ metadata: observation.usage.metadata ? { ...observation.usage.metadata } : undefined,
1248
+ },
1249
+ };
1250
+ }
1251
+
629
1252
  /**
630
1253
  * Match a broker-supplied usage report to a specific OAuth credential. The
631
1254
  * broker returns aggregate reports across all credentials it manages, so we