@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.
- package/CHANGELOG.md +61 -2
- package/dist/types/auth-broker/client.d.ts +9 -1
- package/dist/types/auth-broker/redact.d.ts +7 -0
- package/dist/types/auth-broker/remote-store.d.ts +50 -9
- package/dist/types/auth-broker/types.d.ts +14 -0
- package/dist/types/auth-broker/wire-schemas.d.ts +25 -0
- package/dist/types/auth-storage.d.ts +200 -6
- package/dist/types/core.d.ts +1 -0
- package/dist/types/model-cache.d.ts +4 -1
- package/dist/types/model-manager.d.ts +11 -0
- package/dist/types/provider-models/openai-compat.d.ts +5 -0
- package/dist/types/provider-models/special.d.ts +3 -0
- package/dist/types/providers/anthropic.d.ts +31 -0
- package/dist/types/providers/cursor.d.ts +9 -1
- package/dist/types/providers/kiro-codewhisperer.d.ts +8 -0
- package/dist/types/providers/mock.d.ts +8 -0
- package/dist/types/providers/register-builtins.d.ts +1 -0
- package/dist/types/providers/transform-messages.d.ts +18 -0
- package/dist/types/types.d.ts +34 -8
- package/dist/types/usage/grok-cli.d.ts +5 -0
- package/dist/types/usage.d.ts +6 -0
- package/dist/types/utils/discovery/openai-compatible.d.ts +5 -0
- package/dist/types/utils/event-stream.d.ts +4 -2
- package/dist/types/utils/fallback-transport.d.ts +10 -0
- package/dist/types/utils/http-inspector.d.ts +1 -0
- package/dist/types/utils/idle-iterator.d.ts +13 -1
- package/dist/types/utils/json-parse.d.ts +19 -0
- package/dist/types/utils/oauth/callback-server.d.ts +13 -0
- package/dist/types/utils/oauth/kiro.d.ts +71 -0
- package/dist/types/utils/oauth/types.d.ts +1 -1
- package/dist/types/utils/parse-bind.d.ts +8 -5
- package/dist/types/utils/tool-call-healing.d.ts +7 -0
- package/dist/types/utils/tool-choice-capability.d.ts +11 -0
- package/package.json +3 -2
- package/src/auth-broker/client.ts +30 -0
- package/src/auth-broker/redact.ts +15 -0
- package/src/auth-broker/refresher.ts +4 -2
- package/src/auth-broker/remote-store.ts +693 -70
- package/src/auth-broker/server.ts +57 -12
- package/src/auth-broker/types.ts +16 -0
- package/src/auth-broker/wire-schemas.ts +21 -0
- package/src/auth-gateway/server.ts +84 -19
- package/src/auth-storage.ts +985 -41
- package/src/core.ts +1 -0
- package/src/model-cache.ts +23 -4
- package/src/model-manager.ts +70 -11
- package/src/model-thinking.ts +45 -1
- package/src/models.json +9604 -1932
- package/src/openai-completions-compat.ts +2 -1
- package/src/provider-models/descriptors.ts +7 -1
- package/src/provider-models/openai-compat.ts +52 -28
- package/src/provider-models/special.ts +12 -0
- package/src/providers/amazon-bedrock.ts +2 -1
- package/src/providers/anthropic.ts +831 -27
- package/src/providers/cursor.ts +83 -3
- package/src/providers/kiro-codewhisperer.ts +572 -0
- package/src/providers/mock.ts +15 -2
- package/src/providers/ollama.ts +9 -2
- package/src/providers/openai-codex-responses.ts +16 -9
- package/src/providers/openai-completions.ts +6 -1
- package/src/providers/openai-responses-shared.ts +180 -18
- package/src/providers/register-builtins.ts +24 -2
- package/src/providers/transform-messages.ts +64 -1
- package/src/stream.ts +25 -2
- package/src/types.ts +36 -7
- package/src/usage/grok-cli.ts +86 -1
- package/src/usage.ts +7 -0
- package/src/utils/discovery/openai-compatible.ts +89 -4
- package/src/utils/event-stream.ts +11 -2
- package/src/utils/fallback-transport.ts +44 -2
- package/src/utils/http-inspector.ts +1 -0
- package/src/utils/idle-iterator.ts +29 -6
- package/src/utils/json-parse.ts +80 -0
- package/src/utils/oauth/callback-server.ts +31 -1
- package/src/utils/oauth/index.ts +14 -1
- package/src/utils/oauth/kiro.ts +448 -0
- package/src/utils/oauth/synthetic.ts +2 -3
- package/src/utils/oauth/types.ts +1 -0
- package/src/utils/parse-bind.ts +27 -0
- package/src/utils/tool-call-healing.ts +13 -2
- 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
|
-
|
|
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 {
|
|
27
|
-
|
|
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.#
|
|
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
|
-
|
|
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.#
|
|
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.#
|
|
222
|
-
|
|
223
|
-
|
|
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(
|
|
261
|
-
|
|
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(
|
|
269
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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)
|
|
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
|