@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
package/src/auth-storage.ts
CHANGED
|
@@ -184,6 +184,116 @@ export interface StoredAuthCredential {
|
|
|
184
184
|
provider: string;
|
|
185
185
|
credential: AuthCredential;
|
|
186
186
|
disabledCause: string | null;
|
|
187
|
+
/** Monotonic local row revision used by optimistic hard-removal actions. */
|
|
188
|
+
revision?: number;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Payload-free inventory projection used by account-management and presentation
|
|
193
|
+
* surfaces. This deliberately has no credential/token fields; `listAuthCredentials`
|
|
194
|
+
* remains the active full-fidelity selection contract.
|
|
195
|
+
*/
|
|
196
|
+
export interface CredentialInventoryRecord {
|
|
197
|
+
id: number;
|
|
198
|
+
provider: string;
|
|
199
|
+
credentialKind: "oauth" | "api_key";
|
|
200
|
+
identityLabel: string | null;
|
|
201
|
+
accountId?: string;
|
|
202
|
+
email?: string;
|
|
203
|
+
projectId?: string;
|
|
204
|
+
disabled: boolean;
|
|
205
|
+
disabledCause: string | null;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/** Safe usage observation supplied by a remote store's presentation cache. */
|
|
209
|
+
export interface CachedUsagePresentation {
|
|
210
|
+
credentialId: number;
|
|
211
|
+
provider: string;
|
|
212
|
+
inventoryGeneration: number;
|
|
213
|
+
identityDigest: string;
|
|
214
|
+
usage: SafeUsageReport;
|
|
215
|
+
fetchedAt: number;
|
|
216
|
+
freshUntil: number;
|
|
217
|
+
retainUntil: number;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/** Opaque local action target for an all-or-nothing OAuth hard removal. */
|
|
221
|
+
export interface CredentialRemovalTarget {
|
|
222
|
+
id: number;
|
|
223
|
+
provider: string;
|
|
224
|
+
expectedRevision: number;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
export type AuthCredentialHardRemovalResult =
|
|
228
|
+
| { kind: "removed"; ids: readonly number[] }
|
|
229
|
+
| { kind: "conflict"; currentIds: readonly number[] };
|
|
230
|
+
|
|
231
|
+
/** Usage report projection safe to cross a presentation boundary. */
|
|
232
|
+
export type SafeUsageReport = Omit<UsageReport, "raw">;
|
|
233
|
+
|
|
234
|
+
export type CachedUsageFreshness = "fresh" | "stale-last-good";
|
|
235
|
+
|
|
236
|
+
export interface CachedUsageReport {
|
|
237
|
+
report: SafeUsageReport;
|
|
238
|
+
fetchedAt: number;
|
|
239
|
+
freshUntil: number;
|
|
240
|
+
retainUntil: number;
|
|
241
|
+
freshness: CachedUsageFreshness;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
export type CachedCredentialHealthStatus = "ok" | "failed" | "unverifiable" | "unknown";
|
|
245
|
+
|
|
246
|
+
export interface CachedCredentialHealth {
|
|
247
|
+
status: CachedCredentialHealthStatus;
|
|
248
|
+
reason: string | null;
|
|
249
|
+
checkedAt?: number;
|
|
250
|
+
retainUntil?: number;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/** Safe result from an explicit API-key probe whose key bytes are invocation-only. */
|
|
254
|
+
export interface ApiKeyCredentialCheckResult {
|
|
255
|
+
provider: string;
|
|
256
|
+
type: "api_key";
|
|
257
|
+
ok: boolean | null;
|
|
258
|
+
reason?: string;
|
|
259
|
+
report?: SafeUsageReport;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/** Typed failure raised when an OAuth-only selector cannot be applied. */
|
|
263
|
+
export type OAuthCredentialSelectorFailureReason =
|
|
264
|
+
| "api-key-row"
|
|
265
|
+
| "api-key-provider"
|
|
266
|
+
| "override-active"
|
|
267
|
+
| "not-found"
|
|
268
|
+
| "disabled"
|
|
269
|
+
| "ambiguous"
|
|
270
|
+
| "gateway-managed";
|
|
271
|
+
|
|
272
|
+
export class OAuthCredentialSelectorError extends Error {
|
|
273
|
+
readonly reason: OAuthCredentialSelectorFailureReason;
|
|
274
|
+
readonly provider: string;
|
|
275
|
+
readonly selector: AuthCredentialSelector;
|
|
276
|
+
readonly candidateIds: readonly number[];
|
|
277
|
+
|
|
278
|
+
constructor(
|
|
279
|
+
reason: OAuthCredentialSelectorFailureReason,
|
|
280
|
+
provider: string,
|
|
281
|
+
selector: AuthCredentialSelector,
|
|
282
|
+
message: string,
|
|
283
|
+
candidateIds: readonly number[] = [],
|
|
284
|
+
) {
|
|
285
|
+
super(message);
|
|
286
|
+
this.name = "OAuthCredentialSelectorError";
|
|
287
|
+
this.reason = reason;
|
|
288
|
+
this.provider = provider;
|
|
289
|
+
this.selector = selector;
|
|
290
|
+
this.candidateIds = candidateIds;
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
export interface OAuthPinTarget {
|
|
295
|
+
credentialId: number;
|
|
296
|
+
canonicalSelector: AuthCredentialSelector;
|
|
187
297
|
}
|
|
188
298
|
|
|
189
299
|
/**
|
|
@@ -215,18 +325,25 @@ export interface CredentialHealthResult {
|
|
|
215
325
|
ok: boolean | null;
|
|
216
326
|
/** Failure / unverifiable reason; absent when `ok === true`. */
|
|
217
327
|
reason?: string;
|
|
218
|
-
|
|
219
|
-
report?: Omit<UsageReport, "raw">;
|
|
328
|
+
report?: SafeUsageReport;
|
|
220
329
|
}
|
|
221
330
|
|
|
222
331
|
export interface CheckCredentialsOptions {
|
|
223
332
|
signal?: AbortSignal;
|
|
333
|
+
provider?: string;
|
|
224
334
|
/** Per-credential probe timeout (ms). Defaults to the configured usage request timeout. */
|
|
225
335
|
timeoutMs?: number;
|
|
226
336
|
/** Provider → base URL override, same shape as {@link AuthStorage.fetchUsageReports}. */
|
|
227
337
|
baseUrlResolver?: (provider: Provider) => string | undefined;
|
|
228
338
|
}
|
|
229
339
|
|
|
340
|
+
/** Options for the explicit, invocation-only API-key probe. */
|
|
341
|
+
export interface ApiKeyCredentialCheckOptions {
|
|
342
|
+
signal?: AbortSignal;
|
|
343
|
+
timeoutMs?: number;
|
|
344
|
+
baseUrl?: string;
|
|
345
|
+
}
|
|
346
|
+
|
|
230
347
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
231
348
|
// Auth Broker Snapshot Types
|
|
232
349
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
@@ -316,6 +433,15 @@ export type OAuthRefreshLeaseClaim =
|
|
|
316
433
|
export interface AuthCredentialStore {
|
|
317
434
|
close(): void;
|
|
318
435
|
listAuthCredentials(provider?: string): StoredAuthCredential[];
|
|
436
|
+
/** Payload-free account inventory; active and soft-disabled rows are included. */
|
|
437
|
+
listCredentialInventory?(provider?: string): CredentialInventoryRecord[];
|
|
438
|
+
/** Local opaque removal targets; remote stores may omit this capability. */
|
|
439
|
+
listCredentialRemovalTargets?(provider?: string): CredentialRemovalTarget[];
|
|
440
|
+
/** Transactional local hard removal; remote stores must reject this capability. */
|
|
441
|
+
removeAuthCredentialsHard?(
|
|
442
|
+
provider: string,
|
|
443
|
+
targets: readonly CredentialRemovalTarget[],
|
|
444
|
+
): AuthCredentialHardRemovalResult;
|
|
319
445
|
updateAuthCredential(id: number, credential: AuthCredential): void;
|
|
320
446
|
deleteAuthCredential(id: number, disabledCause: string): void;
|
|
321
447
|
tryDisableAuthCredentialIfMatches(id: number, expectedData: string, disabledCause: string): boolean;
|
|
@@ -388,6 +514,21 @@ export interface AuthCredentialStore {
|
|
|
388
514
|
* `signal` propagates the agent's cancel down to the broker fetch.
|
|
389
515
|
*/
|
|
390
516
|
fetchUsageReports?(signal?: AbortSignal): Promise<UsageReport[] | null>;
|
|
517
|
+
/** Synchronous, zero-network usage presentation peek. */
|
|
518
|
+
peekCachedUsagePresentation?(provider: Provider, credentialId: number): CachedUsagePresentation | undefined;
|
|
519
|
+
/** Record a safe usage observation after an explicit fetch/check. */
|
|
520
|
+
recordUsagePresentation?(observation: CachedUsagePresentation): void;
|
|
521
|
+
/** Read a safe, durable health observation for one credential row. */
|
|
522
|
+
peekCachedCredentialHealth?(provider: Provider, credentialId: number): CachedCredentialHealth | undefined;
|
|
523
|
+
/** Persist a safe health observation for one credential row. */
|
|
524
|
+
recordCredentialHealth?(provider: Provider, credentialId: number, health: CachedCredentialHealth): void;
|
|
525
|
+
/** Persist a safe usage observation without exposing credential payloads. */
|
|
526
|
+
recordCredentialUsage?(provider: Provider, credentialId: number, report: SafeUsageReport): void;
|
|
527
|
+
/**
|
|
528
|
+
* Optional readiness hook for stores that must hydrate payload-free metadata
|
|
529
|
+
* before one-shot inventory consumers read their first snapshot.
|
|
530
|
+
*/
|
|
531
|
+
waitForReady?(): Promise<void>;
|
|
391
532
|
/**
|
|
392
533
|
* Optional store-supplied per-credential usage report lookup. When present,
|
|
393
534
|
* `AuthStorage` consults this before its own per-credential upstream fetch
|
|
@@ -434,8 +575,8 @@ export interface AuthCredentialStore {
|
|
|
434
575
|
replaceAuthCredentialsRemote?(provider: string, credentials: AuthCredential[]): Promise<StoredAuthCredential[]>;
|
|
435
576
|
/**
|
|
436
577
|
* Optional async write hook for clearing every credential for a provider
|
|
437
|
-
* (logout).
|
|
438
|
-
*
|
|
578
|
+
* (logout or a provider-wide invalidation). Remote stores must perform this
|
|
579
|
+
* through their authoritative broker rather than mutating the client cache.
|
|
439
580
|
*/
|
|
440
581
|
deleteAuthCredentialsRemote?(provider: string, disabledCause: string): Promise<void>;
|
|
441
582
|
}
|
|
@@ -728,7 +869,10 @@ const DEFAULT_RANKING_STRATEGIES = new Map<Provider, CredentialRankingStrategy>(
|
|
|
728
869
|
"grok-build",
|
|
729
870
|
{
|
|
730
871
|
findWindowLimits(report) {
|
|
731
|
-
|
|
872
|
+
const weekly = report.limits.find(limit => limit.id === "grok-build:weekly");
|
|
873
|
+
return {
|
|
874
|
+
secondary: weekly ?? report.limits.find(limit => limit.id === "grok-build:7d"),
|
|
875
|
+
};
|
|
732
876
|
},
|
|
733
877
|
windowDefaults: { primaryMs: 5 * 60 * 60 * 1000, secondaryMs: 30 * 24 * 60 * 60 * 1000 },
|
|
734
878
|
} satisfies CredentialRankingStrategy,
|
|
@@ -862,6 +1006,8 @@ type AuthApiKeyOptions = {
|
|
|
862
1006
|
signal?: AbortSignal;
|
|
863
1007
|
/** Pin selection to one stored credential instead of using round-robin/ranking. */
|
|
864
1008
|
credentialSelector?: AuthCredentialSelector;
|
|
1009
|
+
/** Prefer one stored OAuth credential while preserving quota-triggered fallback. */
|
|
1010
|
+
preferredCredentialSelector?: AuthCredentialSelector;
|
|
865
1011
|
};
|
|
866
1012
|
export type AuthCredentialSelectorKind = "id" | "email" | "account" | "project";
|
|
867
1013
|
|
|
@@ -898,6 +1044,27 @@ function isAbortSignalOption(
|
|
|
898
1044
|
return typeof value === "object" && value !== null && "aborted" in value && "addEventListener" in value;
|
|
899
1045
|
}
|
|
900
1046
|
|
|
1047
|
+
const HEALTH_CACHE_PREFIX = "account_health:v1:local:row:";
|
|
1048
|
+
const SOURCE_HEALTH_CACHE_PREFIX = "account_health:v1:source:";
|
|
1049
|
+
const PRESENTATION_RETENTION_MS = 24 * 60 * 60_000;
|
|
1050
|
+
|
|
1051
|
+
function safeUsageReport(report: UsageReport): SafeUsageReport {
|
|
1052
|
+
const { raw: _raw, ...safe } = report;
|
|
1053
|
+
return safe;
|
|
1054
|
+
}
|
|
1055
|
+
|
|
1056
|
+
function scrubHealthReason(reason: unknown, secrets: readonly string[] = []): string {
|
|
1057
|
+
let value = reason instanceof Error ? reason.message : String(reason);
|
|
1058
|
+
for (const secret of secrets) {
|
|
1059
|
+
if (secret.length > 0) value = value.split(secret).join("[redacted]");
|
|
1060
|
+
}
|
|
1061
|
+
value = value.replace(/bearer\s+[^\s,;]+/gi, "Bearer [redacted]");
|
|
1062
|
+
value = value.replace(/(api[_-]?key|token|secret|authorization)[=:]\s*[^\s,;]+/gi, "$1=[redacted]");
|
|
1063
|
+
value = value.replace(/[\r\n\t ]+/g, " ").trim();
|
|
1064
|
+
if (value.length > 256) value = `${value.slice(0, 253)}...`;
|
|
1065
|
+
return value || "credential check failed";
|
|
1066
|
+
}
|
|
1067
|
+
|
|
901
1068
|
function requiresOpenAICodexProModel(provider: string, modelId: string | undefined): boolean {
|
|
902
1069
|
return provider === "openai-codex" && typeof modelId === "string" && modelId.includes("-spark");
|
|
903
1070
|
}
|
|
@@ -1065,6 +1232,14 @@ export class AuthStorage {
|
|
|
1065
1232
|
#runtimeOverrides: Map<string, string> = new Map();
|
|
1066
1233
|
#configOverrides: Map<string, string> = new Map();
|
|
1067
1234
|
#runtimeCredentialSelectors: Map<string, AuthCredentialSelector> = new Map();
|
|
1235
|
+
/** Soft runtime credential preference per provider; quota failures may rotate away from it. */
|
|
1236
|
+
#runtimePreferredCredentialSelectors: Map<string, AuthCredentialSelector> = new Map();
|
|
1237
|
+
/** Credential selectors explicitly attached to a credential session scope. */
|
|
1238
|
+
#sessionCredentialSelectors: Map<string, Map<string, AuthCredentialSelector>> = new Map();
|
|
1239
|
+
/** Explicit AUTO masks suppress both scoped and process-global selectors for a scope/provider. */
|
|
1240
|
+
#sessionCredentialAutoMasks: Map<string, Set<string>> = new Map();
|
|
1241
|
+
/** Reference counts for sessions sharing one credential scope (top-level + subagents). */
|
|
1242
|
+
#credentialScopeLeases: Map<string, number> = new Map();
|
|
1068
1243
|
/** Tracks next credential index per provider:type key for round-robin distribution (non-session use). */
|
|
1069
1244
|
#providerRoundRobinIndex: Map<string, number> = new Map();
|
|
1070
1245
|
/** Tracks the last used credential per provider for a session (used for rate-limit switching). */
|
|
@@ -1159,6 +1334,10 @@ export class AuthStorage {
|
|
|
1159
1334
|
close(): void {
|
|
1160
1335
|
if (this.#closed) return;
|
|
1161
1336
|
this.#closed = true;
|
|
1337
|
+
this.#credentialScopeLeases.clear();
|
|
1338
|
+
this.#sessionCredentialSelectors.clear();
|
|
1339
|
+
this.#sessionCredentialAutoMasks.clear();
|
|
1340
|
+
this.#sessionLastCredential.clear();
|
|
1162
1341
|
this.#store.close();
|
|
1163
1342
|
}
|
|
1164
1343
|
|
|
@@ -1182,7 +1361,7 @@ export class AuthStorage {
|
|
|
1182
1361
|
const evidenceApiKey = resolvedApiKey;
|
|
1183
1362
|
let selectedCredential: ({ index: number } & StoredCredential) | undefined;
|
|
1184
1363
|
try {
|
|
1185
|
-
selectedCredential = this.#resolveSelectedStoredCredential(provider);
|
|
1364
|
+
selectedCredential = this.#resolveSelectedStoredCredential(provider, undefined, undefined);
|
|
1186
1365
|
} catch {
|
|
1187
1366
|
return crypto
|
|
1188
1367
|
.createHash("sha256")
|
|
@@ -1306,11 +1485,234 @@ export class AuthStorage {
|
|
|
1306
1485
|
*/
|
|
1307
1486
|
setRuntimeCredentialSelector(provider: string, selector: AuthCredentialSelector): void {
|
|
1308
1487
|
const storageProvider = resolveOAuthStorageProvider(provider);
|
|
1488
|
+
if (this.#runtimePreferredCredentialSelectors.has(storageProvider)) {
|
|
1489
|
+
throw new Error(`Credential selector cannot be combined with a preferred credential selector for ${provider}`);
|
|
1490
|
+
}
|
|
1309
1491
|
this.#assertCredentialSelectorUsable(storageProvider, selector);
|
|
1310
1492
|
this.#runtimeCredentialSelectors.set(storageProvider, selector);
|
|
1311
1493
|
this.#bumpGeneration("set-runtime-credential-selector", provider);
|
|
1312
1494
|
}
|
|
1313
1495
|
|
|
1496
|
+
/** Acquire a reference-counted credential scope for a session or shared subagent scope. */
|
|
1497
|
+
acquireCredentialScope(scopeId: string): void {
|
|
1498
|
+
const scope = scopeId.trim();
|
|
1499
|
+
if (!scope) throw new Error("Credential scope id must not be empty");
|
|
1500
|
+
this.#credentialScopeLeases.set(scope, (this.#credentialScopeLeases.get(scope) ?? 0) + 1);
|
|
1501
|
+
}
|
|
1502
|
+
|
|
1503
|
+
/** Whether a credential scope already has at least one live owner. */
|
|
1504
|
+
hasCredentialScopeLease(scopeId: string): boolean {
|
|
1505
|
+
const scope = scopeId.trim();
|
|
1506
|
+
return scope.length > 0 && (this.#credentialScopeLeases.get(scope) ?? 0) > 0;
|
|
1507
|
+
}
|
|
1508
|
+
|
|
1509
|
+
/** Release one credential-scope lease; final release clears only that scope's derived state. */
|
|
1510
|
+
releaseCredentialScope(scopeId: string): void {
|
|
1511
|
+
const scope = scopeId.trim();
|
|
1512
|
+
if (!scope) return;
|
|
1513
|
+
const leases = this.#credentialScopeLeases.get(scope);
|
|
1514
|
+
if (leases === undefined) return;
|
|
1515
|
+
if (leases > 1) {
|
|
1516
|
+
this.#credentialScopeLeases.set(scope, leases - 1);
|
|
1517
|
+
return;
|
|
1518
|
+
}
|
|
1519
|
+
this.#credentialScopeLeases.delete(scope);
|
|
1520
|
+
this.#sessionCredentialSelectors.delete(scope);
|
|
1521
|
+
this.#sessionCredentialAutoMasks.delete(scope);
|
|
1522
|
+
for (const [provider, sessions] of this.#sessionLastCredential) {
|
|
1523
|
+
if (!sessions.delete(scope)) continue;
|
|
1524
|
+
if (sessions.size === 0) this.#sessionLastCredential.delete(provider);
|
|
1525
|
+
}
|
|
1526
|
+
}
|
|
1527
|
+
|
|
1528
|
+
/** Set the selector derived from a durable session pin or a session seed. */
|
|
1529
|
+
setSessionCredentialSelector(scopeId: string, provider: string, selector: AuthCredentialSelector): void {
|
|
1530
|
+
const scope = scopeId.trim();
|
|
1531
|
+
if (!scope) throw new Error("Credential scope id must not be empty");
|
|
1532
|
+
const storageProvider = resolveOAuthStorageProvider(provider);
|
|
1533
|
+
this.#assertCredentialSelectorUsable(storageProvider, selector);
|
|
1534
|
+
const selectors = this.#sessionCredentialSelectors.get(scope) ?? new Map<string, AuthCredentialSelector>();
|
|
1535
|
+
selectors.set(storageProvider, selector);
|
|
1536
|
+
this.#sessionCredentialSelectors.set(scope, selectors);
|
|
1537
|
+
this.#sessionCredentialAutoMasks.get(scope)?.delete(storageProvider);
|
|
1538
|
+
this.#bumpGeneration("set-session-credential-selector", storageProvider);
|
|
1539
|
+
}
|
|
1540
|
+
|
|
1541
|
+
/** Explicitly mask persistent/process-global selection and return the provider to AUTO for one scope. */
|
|
1542
|
+
setSessionCredentialAuto(provider: string, scopeId: string): void {
|
|
1543
|
+
const scope = scopeId.trim();
|
|
1544
|
+
if (!scope) throw new Error("Credential scope id must not be empty");
|
|
1545
|
+
const storageProvider = resolveOAuthStorageProvider(provider);
|
|
1546
|
+
this.#sessionCredentialSelectors.get(scope)?.delete(storageProvider);
|
|
1547
|
+
const masks = this.#sessionCredentialAutoMasks.get(scope) ?? new Set<string>();
|
|
1548
|
+
masks.add(storageProvider);
|
|
1549
|
+
this.#sessionCredentialAutoMasks.set(scope, masks);
|
|
1550
|
+
this.#bumpGeneration("set-session-credential-auto", storageProvider);
|
|
1551
|
+
}
|
|
1552
|
+
|
|
1553
|
+
/** Clear a scope's explicit selector and AUTO mask, restoring normal precedence. */
|
|
1554
|
+
clearSessionCredentialSelector(provider: string, scopeId: string): void {
|
|
1555
|
+
const scope = scopeId.trim();
|
|
1556
|
+
if (!scope) return;
|
|
1557
|
+
const storageProvider = resolveOAuthStorageProvider(provider);
|
|
1558
|
+
const selectors = this.#sessionCredentialSelectors.get(scope);
|
|
1559
|
+
const masks = this.#sessionCredentialAutoMasks.get(scope);
|
|
1560
|
+
const changed = Boolean(selectors?.delete(storageProvider) || masks?.delete(storageProvider));
|
|
1561
|
+
if (selectors?.size === 0) this.#sessionCredentialSelectors.delete(scope);
|
|
1562
|
+
if (masks?.size === 0) this.#sessionCredentialAutoMasks.delete(scope);
|
|
1563
|
+
if (changed) this.#bumpGeneration("clear-session-credential-selector", storageProvider);
|
|
1564
|
+
}
|
|
1565
|
+
|
|
1566
|
+
/** Whether the effective selection for a scope is explicitly pinned (AUTO masks are not pins). */
|
|
1567
|
+
hasSessionCredentialSelector(provider: string, scopeId?: string): boolean {
|
|
1568
|
+
if (!scopeId) return false;
|
|
1569
|
+
return this.#getCredentialSelector(provider, undefined, scopeId) !== undefined;
|
|
1570
|
+
}
|
|
1571
|
+
|
|
1572
|
+
/** Whether this scope explicitly masks provider pins and uses AUTO ranking. */
|
|
1573
|
+
hasSessionCredentialAuto(provider: string, scopeId?: string): boolean {
|
|
1574
|
+
if (!scopeId) return false;
|
|
1575
|
+
return this.#sessionCredentialAutoMasks.get(scopeId)?.has(resolveOAuthStorageProvider(provider)) === true;
|
|
1576
|
+
}
|
|
1577
|
+
|
|
1578
|
+
/** Resolve the effective selector precedence for a provider/scope. */
|
|
1579
|
+
resolveEffectiveCredentialSelector(
|
|
1580
|
+
provider: string,
|
|
1581
|
+
scopeId?: string,
|
|
1582
|
+
explicitSelector?: AuthCredentialSelector,
|
|
1583
|
+
): AuthCredentialSelector | undefined {
|
|
1584
|
+
return this.#getCredentialSelector(
|
|
1585
|
+
provider,
|
|
1586
|
+
explicitSelector ? { credentialSelector: explicitSelector } : undefined,
|
|
1587
|
+
scopeId,
|
|
1588
|
+
);
|
|
1589
|
+
}
|
|
1590
|
+
|
|
1591
|
+
/** Validate and canonicalize an OAuth-only selector for account pinning. */
|
|
1592
|
+
resolveOAuthPinTarget(provider: string, selector: AuthCredentialSelector): OAuthPinTarget {
|
|
1593
|
+
const storageProvider = resolveOAuthStorageProvider(provider);
|
|
1594
|
+
if (
|
|
1595
|
+
this.#runtimeOverrides.has(storageProvider) ||
|
|
1596
|
+
this.#configOverrides.has(storageProvider) ||
|
|
1597
|
+
getEnvApiKey(storageProvider)
|
|
1598
|
+
) {
|
|
1599
|
+
throw new OAuthCredentialSelectorError(
|
|
1600
|
+
"override-active",
|
|
1601
|
+
storageProvider,
|
|
1602
|
+
selector,
|
|
1603
|
+
`Credential selector ${this.#formatCredentialSelector(selector)} cannot be used for ${storageProvider} while an API-key override is active; remove the override or choose AUTO`,
|
|
1604
|
+
);
|
|
1605
|
+
}
|
|
1606
|
+
const allRows = this.#store.listCredentialInventory?.(storageProvider) ?? [];
|
|
1607
|
+
const matchingCredentials = this.#getStoredCredentials(storageProvider).filter(entry =>
|
|
1608
|
+
this.#credentialMatchesSelector(entry, selector),
|
|
1609
|
+
);
|
|
1610
|
+
const matchingIds = new Set(matchingCredentials.map(entry => entry.id));
|
|
1611
|
+
const matchingRows = allRows.filter(
|
|
1612
|
+
row => matchingIds.has(row.id) && !row.disabled && row.credentialKind === "oauth",
|
|
1613
|
+
);
|
|
1614
|
+
if (matchingRows.length === 0) {
|
|
1615
|
+
const providerRows = allRows.filter(row => row.provider === storageProvider);
|
|
1616
|
+
if (
|
|
1617
|
+
selector.kind === "id" &&
|
|
1618
|
+
providerRows.some(row => String(row.id) === selector.value && row.credentialKind === "api_key")
|
|
1619
|
+
) {
|
|
1620
|
+
throw new OAuthCredentialSelectorError(
|
|
1621
|
+
"api-key-row",
|
|
1622
|
+
storageProvider,
|
|
1623
|
+
selector,
|
|
1624
|
+
`Credential ${selector.value} is an API-key row and cannot be pinned; choose an OAuth account`,
|
|
1625
|
+
);
|
|
1626
|
+
}
|
|
1627
|
+
if (providerRows.length > 0 && providerRows.every(row => row.credentialKind === "api_key")) {
|
|
1628
|
+
throw new OAuthCredentialSelectorError(
|
|
1629
|
+
"api-key-provider",
|
|
1630
|
+
storageProvider,
|
|
1631
|
+
selector,
|
|
1632
|
+
`Provider ${storageProvider} has no OAuth credentials to pin`,
|
|
1633
|
+
);
|
|
1634
|
+
}
|
|
1635
|
+
const disabled = providerRows.find(
|
|
1636
|
+
row => row.disabled && this.#credentialMatchesInventorySelector(row, selector),
|
|
1637
|
+
);
|
|
1638
|
+
if (disabled) {
|
|
1639
|
+
throw new OAuthCredentialSelectorError(
|
|
1640
|
+
"disabled",
|
|
1641
|
+
storageProvider,
|
|
1642
|
+
selector,
|
|
1643
|
+
`Credential ${this.#formatCredentialSelector(selector)} is disabled${disabled.disabledCause ? `: ${disabled.disabledCause}` : ""}; run /login ${storageProvider} or choose an active account`,
|
|
1644
|
+
);
|
|
1645
|
+
}
|
|
1646
|
+
throw new OAuthCredentialSelectorError(
|
|
1647
|
+
"not-found",
|
|
1648
|
+
storageProvider,
|
|
1649
|
+
selector,
|
|
1650
|
+
`No active OAuth credential found for ${storageProvider} matching ${this.#formatCredentialSelector(selector)}; run /login ${storageProvider} or choose AUTO`,
|
|
1651
|
+
);
|
|
1652
|
+
}
|
|
1653
|
+
if (matchingRows.length > 1) {
|
|
1654
|
+
throw new OAuthCredentialSelectorError(
|
|
1655
|
+
"ambiguous",
|
|
1656
|
+
storageProvider,
|
|
1657
|
+
selector,
|
|
1658
|
+
`Selector ${this.#formatCredentialSelector(selector)} matches multiple OAuth credentials; choose id:<row-id>`,
|
|
1659
|
+
matchingRows.map(row => row.id),
|
|
1660
|
+
);
|
|
1661
|
+
}
|
|
1662
|
+
const target = matchingRows[0];
|
|
1663
|
+
if (!target || target.disabled || target.credentialKind !== "oauth") {
|
|
1664
|
+
throw new OAuthCredentialSelectorError(
|
|
1665
|
+
"disabled",
|
|
1666
|
+
storageProvider,
|
|
1667
|
+
selector,
|
|
1668
|
+
`Credential ${this.#formatCredentialSelector(selector)} is not an active OAuth credential; choose an active account`,
|
|
1669
|
+
);
|
|
1670
|
+
}
|
|
1671
|
+
return { credentialId: target.id, canonicalSelector: { kind: "id", value: String(target.id) } };
|
|
1672
|
+
}
|
|
1673
|
+
|
|
1674
|
+
/** Return all local inventory rows, including soft-disabled metadata, without payloads. */
|
|
1675
|
+
listCredentialInventory(provider?: string): CredentialInventoryRecord[] {
|
|
1676
|
+
return this.#store.listCredentialInventory?.(provider) ?? [];
|
|
1677
|
+
}
|
|
1678
|
+
|
|
1679
|
+
/** Return local OAuth hard-removal action targets, including disabled rows. */
|
|
1680
|
+
listCredentialRemovalTargets(provider?: string): CredentialRemovalTarget[] {
|
|
1681
|
+
return this.#store.listCredentialRemovalTargets?.(provider) ?? [];
|
|
1682
|
+
}
|
|
1683
|
+
|
|
1684
|
+
/** Remove selected local OAuth rows atomically; conflict leaves all rows intact. */
|
|
1685
|
+
removeAuthCredentialsHard(
|
|
1686
|
+
provider: string,
|
|
1687
|
+
targets: readonly CredentialRemovalTarget[],
|
|
1688
|
+
): AuthCredentialHardRemovalResult {
|
|
1689
|
+
const storageProvider = resolveOAuthStorageProvider(provider);
|
|
1690
|
+
const result = this.#store.removeAuthCredentialsHard?.(storageProvider, targets) ?? {
|
|
1691
|
+
kind: "conflict",
|
|
1692
|
+
currentIds: [],
|
|
1693
|
+
};
|
|
1694
|
+
if (result.kind !== "removed") return result;
|
|
1695
|
+
const removed = new Set(result.ids);
|
|
1696
|
+
const previousEntries = this.#getStoredCredentials(storageProvider);
|
|
1697
|
+
const entries = previousEntries.filter(entry => !removed.has(entry.id));
|
|
1698
|
+
this.#setStoredCredentials(storageProvider, entries);
|
|
1699
|
+
this.#usageRequestInFlight.clear();
|
|
1700
|
+
this.#usageReportsInFlight.clear();
|
|
1701
|
+
this.#usageCache.deletePrefix?.(`report:${storageProvider}:`);
|
|
1702
|
+
for (const [scopeId, selectors] of this.#sessionCredentialSelectors) {
|
|
1703
|
+
const selector = selectors.get(storageProvider);
|
|
1704
|
+
const selected = selector
|
|
1705
|
+
? previousEntries.find(entry => this.#credentialMatchesSelector(entry, selector))
|
|
1706
|
+
: undefined;
|
|
1707
|
+
if (selected && removed.has(selected.id)) this.clearSessionCredentialSelector(storageProvider, scopeId);
|
|
1708
|
+
}
|
|
1709
|
+
for (const [sessionId, sticky] of this.#sessionLastCredential.get(storageProvider) ?? []) {
|
|
1710
|
+
if (removed.has(previousEntries[sticky.index]?.id ?? -1))
|
|
1711
|
+
this.#clearSessionCredential(storageProvider, sessionId);
|
|
1712
|
+
}
|
|
1713
|
+
this.#resetProviderAssignments(storageProvider);
|
|
1714
|
+
return result;
|
|
1715
|
+
}
|
|
1314
1716
|
/**
|
|
1315
1717
|
* Remove a runtime credential selector.
|
|
1316
1718
|
*/
|
|
@@ -1321,6 +1723,58 @@ export class AuthStorage {
|
|
|
1321
1723
|
}
|
|
1322
1724
|
}
|
|
1323
1725
|
|
|
1726
|
+
/** Whether a provider currently has a soft runtime credential preference. */
|
|
1727
|
+
hasRuntimePreferredCredentialSelector(provider: string): boolean {
|
|
1728
|
+
return this.#runtimePreferredCredentialSelectors.has(resolveOAuthStorageProvider(provider));
|
|
1729
|
+
}
|
|
1730
|
+
|
|
1731
|
+
/** Resolve an unqualified preferred selector to the single active OAuth provider it matches. */
|
|
1732
|
+
resolveRuntimePreferredCredentialSelectorProvider(selector: AuthCredentialSelector): string {
|
|
1733
|
+
const providers = [...this.#data.entries()]
|
|
1734
|
+
.filter(([, entries]) =>
|
|
1735
|
+
entries.some(
|
|
1736
|
+
entry => entry.credential.type === "oauth" && this.#credentialMatchesSelector(entry, selector),
|
|
1737
|
+
),
|
|
1738
|
+
)
|
|
1739
|
+
.map(([provider]) => provider);
|
|
1740
|
+
if (providers.length === 0) {
|
|
1741
|
+
throw new Error(`No active credential found matching ${this.#formatCredentialSelector(selector)}`);
|
|
1742
|
+
}
|
|
1743
|
+
if (providers.length > 1) {
|
|
1744
|
+
throw new Error(
|
|
1745
|
+
`Preferred credential selector ${this.#formatCredentialSelector(selector)} matches multiple providers; use provider/${this.#formatCredentialSelector(selector)}`,
|
|
1746
|
+
);
|
|
1747
|
+
}
|
|
1748
|
+
return providers[0]!;
|
|
1749
|
+
}
|
|
1750
|
+
|
|
1751
|
+
/**
|
|
1752
|
+
* Prefer one stored OAuth credential for a provider while retaining quota
|
|
1753
|
+
* fallback to the rest of the pool (not persisted to disk). Used for CLI
|
|
1754
|
+
* `--prefer-credential`. Unlike {@link setRuntimeCredentialSelector}, a
|
|
1755
|
+
* quota/rate-limit failure on the preferred row still rotates to another
|
|
1756
|
+
* active credential instead of failing the session.
|
|
1757
|
+
*/
|
|
1758
|
+
setRuntimePreferredCredentialSelector(provider: string, selector: AuthCredentialSelector): void {
|
|
1759
|
+
const storageProvider = resolveOAuthStorageProvider(provider);
|
|
1760
|
+
if (this.#runtimeCredentialSelectors.has(storageProvider)) {
|
|
1761
|
+
throw new Error(`Preferred credential selector cannot be combined with a credential selector for ${provider}`);
|
|
1762
|
+
}
|
|
1763
|
+
this.#assertPreferredCredentialSelectorUsable(storageProvider, selector);
|
|
1764
|
+
this.#runtimePreferredCredentialSelectors.set(storageProvider, selector);
|
|
1765
|
+
this.#bumpGeneration("set-runtime-preferred-credential-selector", provider);
|
|
1766
|
+
}
|
|
1767
|
+
|
|
1768
|
+
/**
|
|
1769
|
+
* Remove a runtime preferred credential selector.
|
|
1770
|
+
*/
|
|
1771
|
+
removeRuntimePreferredCredentialSelector(provider: string): void {
|
|
1772
|
+
const storageProvider = resolveOAuthStorageProvider(provider);
|
|
1773
|
+
if (this.#runtimePreferredCredentialSelectors.delete(storageProvider)) {
|
|
1774
|
+
this.#bumpGeneration("remove-runtime-preferred-credential-selector", provider);
|
|
1775
|
+
}
|
|
1776
|
+
}
|
|
1777
|
+
|
|
1324
1778
|
/**
|
|
1325
1779
|
* Remove a runtime API key override.
|
|
1326
1780
|
*/
|
|
@@ -1353,6 +1807,11 @@ export class AuthStorage {
|
|
|
1353
1807
|
return this.#runtimeCredentialSelectors.has(resolveOAuthStorageProvider(provider));
|
|
1354
1808
|
}
|
|
1355
1809
|
|
|
1810
|
+
/** Whether the effective selector for a session scope is pinned. */
|
|
1811
|
+
hasEffectiveCredentialSelector(provider: string, sessionId?: string): boolean {
|
|
1812
|
+
return this.#getCredentialSelector(provider, undefined, sessionId) !== undefined;
|
|
1813
|
+
}
|
|
1814
|
+
|
|
1356
1815
|
/**
|
|
1357
1816
|
* Opaque stored row id of the credential this session is currently using.
|
|
1358
1817
|
*
|
|
@@ -1372,6 +1831,62 @@ export class AuthStorage {
|
|
|
1372
1831
|
return this.#getStoredCredentials(storageProvider)[session.index]?.id;
|
|
1373
1832
|
}
|
|
1374
1833
|
|
|
1834
|
+
/**
|
|
1835
|
+
* Force a running session's OAuth credential for a provider to a specific
|
|
1836
|
+
* stored row, independent of quota/rate-limit state. Used for a mid-session
|
|
1837
|
+
* `/credential <selector>` switch that has nothing to do with exhaustion —
|
|
1838
|
+
* the user just wants a different account for the rest of the session.
|
|
1839
|
+
*
|
|
1840
|
+
* This mutates ONLY the session-scoped sticky pointer
|
|
1841
|
+
* ({@link AuthStorage.#recordSessionCredential}), never a provider-wide
|
|
1842
|
+
* runtime override, so it cannot bleed into other sessions in the same
|
|
1843
|
+
* process whose credential identity differs. The sticky pointer is keyed by
|
|
1844
|
+
* `sessionId`, and subagents/team workers inherit their parent's
|
|
1845
|
+
* `credentialSessionId` by design so they keep using the same account as
|
|
1846
|
+
* the parent — a switch therefore applies to the whole session family
|
|
1847
|
+
* sharing that identity, not to unrelated sessions.
|
|
1848
|
+
*
|
|
1849
|
+
* Fails closed rather than silently no-op when a stronger override already
|
|
1850
|
+
* decides this provider's credential every call: a hard pin
|
|
1851
|
+
* ({@link AuthStorage.setRuntimeCredentialSelector}, `--credential`), a
|
|
1852
|
+
* runtime API-key override (`--api-key`), or a config-sourced API key
|
|
1853
|
+
* (`models.yml` `apiKey`) would each re-decide the credential on the very
|
|
1854
|
+
* next {@link AuthStorage.getApiKey} call and make this switch appear to
|
|
1855
|
+
* silently do nothing.
|
|
1856
|
+
*
|
|
1857
|
+
* Deliberately does not touch credential-blocked state: if the target row
|
|
1858
|
+
* is still backoff-blocked from a prior quota failure, the existing
|
|
1859
|
+
* `#resolveOAuthSelection` ranking safely ignores this sticky pointer and
|
|
1860
|
+
* falls back to a usable account instead of re-issuing a request that would
|
|
1861
|
+
* just draw another 429/quota error.
|
|
1862
|
+
*/
|
|
1863
|
+
switchSessionCredential(provider: string, sessionId: string, selector: AuthCredentialSelector): void {
|
|
1864
|
+
const storageProvider = resolveOAuthStorageProvider(provider);
|
|
1865
|
+
if (this.#runtimeOverrides.has(storageProvider)) {
|
|
1866
|
+
throw new Error(
|
|
1867
|
+
`Cannot switch credential for ${provider}: a runtime API key override (--api-key) is active and always wins`,
|
|
1868
|
+
);
|
|
1869
|
+
}
|
|
1870
|
+
if (this.#configOverrides.has(storageProvider)) {
|
|
1871
|
+
throw new Error(
|
|
1872
|
+
`Cannot switch credential for ${provider}: a config API key override (models.yml) is active and always wins`,
|
|
1873
|
+
);
|
|
1874
|
+
}
|
|
1875
|
+
if (this.#runtimeCredentialSelectors.has(storageProvider)) {
|
|
1876
|
+
throw new Error(
|
|
1877
|
+
`Cannot switch credential for ${provider}: --credential already pins this session to one stored row`,
|
|
1878
|
+
);
|
|
1879
|
+
}
|
|
1880
|
+
const matched = this.#findCredentialBySelector(storageProvider, selector);
|
|
1881
|
+
if (matched?.credential.type !== "oauth") {
|
|
1882
|
+
throw new Error(
|
|
1883
|
+
`No active OAuth credential found for ${provider} matching ${this.#formatCredentialSelector(selector)}`,
|
|
1884
|
+
);
|
|
1885
|
+
}
|
|
1886
|
+
this.#recordSessionCredential(storageProvider, sessionId, "oauth", matched.index);
|
|
1887
|
+
this.#bumpGeneration("switch-session-credential");
|
|
1888
|
+
}
|
|
1889
|
+
|
|
1375
1890
|
/**
|
|
1376
1891
|
* Register a per-provider API key sourced from user configuration
|
|
1377
1892
|
* (e.g. `models.yml` `providers.<name>.apiKey`). Higher priority than
|
|
@@ -1419,6 +1934,7 @@ export class AuthStorage {
|
|
|
1419
1934
|
* Reload credentials from storage.
|
|
1420
1935
|
*/
|
|
1421
1936
|
async reload(): Promise<void> {
|
|
1937
|
+
await this.#store.waitForReady?.();
|
|
1422
1938
|
const records = this.#store.listAuthCredentials();
|
|
1423
1939
|
const grouped = new Map<string, StoredCredential[]>();
|
|
1424
1940
|
for (const record of records) {
|
|
@@ -1528,6 +2044,7 @@ export class AuthStorage {
|
|
|
1528
2044
|
for (const entry of removed) {
|
|
1529
2045
|
this.#store.deleteAuthCredential(entry.id, "deduplicated duplicate credential");
|
|
1530
2046
|
}
|
|
2047
|
+
this.#clearSelectorsForRemovedCredential(provider, new Set(removed.map(entry => entry.id)), entries);
|
|
1531
2048
|
this.#resetProviderAssignments(provider);
|
|
1532
2049
|
}
|
|
1533
2050
|
return kept.reverse();
|
|
@@ -1682,8 +2199,38 @@ export class AuthStorage {
|
|
|
1682
2199
|
return undefined;
|
|
1683
2200
|
}
|
|
1684
2201
|
|
|
1685
|
-
#
|
|
1686
|
-
|
|
2202
|
+
#credentialMatchesInventorySelector(row: CredentialInventoryRecord, selector: AuthCredentialSelector): boolean {
|
|
2203
|
+
if (row.disabled || row.credentialKind !== "oauth") return false;
|
|
2204
|
+
switch (selector.kind) {
|
|
2205
|
+
case "id":
|
|
2206
|
+
return String(row.id) === selector.value;
|
|
2207
|
+
case "email":
|
|
2208
|
+
return row.identityLabel?.toLowerCase() === selector.value.toLowerCase();
|
|
2209
|
+
case "account":
|
|
2210
|
+
case "project":
|
|
2211
|
+
return row.identityLabel === selector.value;
|
|
2212
|
+
}
|
|
2213
|
+
}
|
|
2214
|
+
#getCredentialSelector(
|
|
2215
|
+
provider: string,
|
|
2216
|
+
options?: AuthApiKeyOptions,
|
|
2217
|
+
sessionId?: string,
|
|
2218
|
+
): AuthCredentialSelector | undefined {
|
|
2219
|
+
if (options?.credentialSelector) return options.credentialSelector;
|
|
2220
|
+
const storageProvider = resolveOAuthStorageProvider(provider);
|
|
2221
|
+
if (sessionId) {
|
|
2222
|
+
if (this.#sessionCredentialAutoMasks.get(sessionId)?.has(storageProvider)) return undefined;
|
|
2223
|
+
const scoped = this.#sessionCredentialSelectors.get(sessionId)?.get(storageProvider);
|
|
2224
|
+
if (scoped) return scoped;
|
|
2225
|
+
}
|
|
2226
|
+
return this.#runtimeCredentialSelectors.get(storageProvider);
|
|
2227
|
+
}
|
|
2228
|
+
|
|
2229
|
+
#getPreferredCredentialSelector(provider: string, options?: AuthApiKeyOptions): AuthCredentialSelector | undefined {
|
|
2230
|
+
return (
|
|
2231
|
+
options?.preferredCredentialSelector ??
|
|
2232
|
+
this.#runtimePreferredCredentialSelectors.get(resolveOAuthStorageProvider(provider))
|
|
2233
|
+
);
|
|
1687
2234
|
}
|
|
1688
2235
|
|
|
1689
2236
|
#assertCredentialSelectorUsable(provider: string, selector: AuthCredentialSelector): void {
|
|
@@ -1702,11 +2249,32 @@ export class AuthStorage {
|
|
|
1702
2249
|
}
|
|
1703
2250
|
}
|
|
1704
2251
|
|
|
2252
|
+
/**
|
|
2253
|
+
* Validates a preferred-credential selector (`--prefer-credential`). Unlike
|
|
2254
|
+
* {@link AuthStorage.#assertCredentialSelectorUsable}, the match must resolve
|
|
2255
|
+
* to an OAuth row specifically — the soft-preference/quota-fallback path is
|
|
2256
|
+
* meaningless for a single static API key.
|
|
2257
|
+
*/
|
|
2258
|
+
#assertPreferredCredentialSelectorUsable(provider: string, selector: AuthCredentialSelector): void {
|
|
2259
|
+
if (this.#runtimeOverrides.has(provider) || this.#configOverrides.has(provider)) {
|
|
2260
|
+
throw new Error(
|
|
2261
|
+
`Preferred credential selector ${this.#formatCredentialSelector(selector)} cannot be used for ${provider} while an API key override is active`,
|
|
2262
|
+
);
|
|
2263
|
+
}
|
|
2264
|
+
const selected = this.#findCredentialBySelector(provider, selector);
|
|
2265
|
+
if (selected?.credential.type !== "oauth") {
|
|
2266
|
+
throw new Error(
|
|
2267
|
+
`No active credential found for ${provider} matching ${this.#formatCredentialSelector(selector)}`,
|
|
2268
|
+
);
|
|
2269
|
+
}
|
|
2270
|
+
}
|
|
2271
|
+
|
|
1705
2272
|
#resolveSelectedStoredCredential(
|
|
1706
2273
|
provider: string,
|
|
1707
2274
|
options?: AuthApiKeyOptions,
|
|
2275
|
+
sessionId?: string,
|
|
1708
2276
|
): ({ index: number } & StoredCredential) | undefined {
|
|
1709
|
-
const selector = this.#getCredentialSelector(provider, options);
|
|
2277
|
+
const selector = this.#getCredentialSelector(provider, options, sessionId);
|
|
1710
2278
|
if (!selector) return undefined;
|
|
1711
2279
|
this.#assertCredentialSelectorUsable(resolveOAuthStorageProvider(provider), selector);
|
|
1712
2280
|
const selected = this.#findCredentialBySelector(provider, selector);
|
|
@@ -1846,6 +2414,7 @@ export class AuthStorage {
|
|
|
1846
2414
|
if (!disabled) return false;
|
|
1847
2415
|
const updated = entries.filter((_value, idx) => idx !== index);
|
|
1848
2416
|
this.#setStoredCredentials(provider, updated);
|
|
2417
|
+
this.#clearSelectorsForRemovedCredential(provider, new Set([target.id]), entries);
|
|
1849
2418
|
this.#resetProviderAssignments(provider);
|
|
1850
2419
|
this.#emitCredentialDisabled({ provider, disabledCause });
|
|
1851
2420
|
return true;
|
|
@@ -1875,10 +2444,30 @@ export class AuthStorage {
|
|
|
1875
2444
|
provider,
|
|
1876
2445
|
entries.filter(entry => entry.id !== credentialId),
|
|
1877
2446
|
);
|
|
2447
|
+
this.#clearSelectorsForRemovedCredential(provider, new Set([credentialId]), entries);
|
|
1878
2448
|
this.#resetProviderAssignments(provider);
|
|
1879
2449
|
this.#emitCredentialDisabled({ provider, disabledCause });
|
|
1880
2450
|
}
|
|
1881
2451
|
|
|
2452
|
+
/** Clear every selector whose durable/in-memory target was just removed. */
|
|
2453
|
+
#clearSelectorsForRemovedCredential(
|
|
2454
|
+
provider: string,
|
|
2455
|
+
removedIds: ReadonlySet<number>,
|
|
2456
|
+
previousEntries: readonly StoredCredential[] = this.#getStoredCredentials(provider),
|
|
2457
|
+
): void {
|
|
2458
|
+
const storageProvider = resolveOAuthStorageProvider(provider);
|
|
2459
|
+
for (const [scopeId, selectors] of this.#sessionCredentialSelectors) {
|
|
2460
|
+
const selector = selectors.get(storageProvider);
|
|
2461
|
+
if (!selector) continue;
|
|
2462
|
+
const selected = previousEntries.find(entry => this.#credentialMatchesSelector(entry, selector));
|
|
2463
|
+
if (selected && removedIds.has(selected.id)) this.clearSessionCredentialSelector(storageProvider, scopeId);
|
|
2464
|
+
}
|
|
2465
|
+
for (const [sessionId, sticky] of this.#sessionLastCredential.get(storageProvider) ?? []) {
|
|
2466
|
+
if (removedIds.has(previousEntries[sticky.index]?.id ?? -1))
|
|
2467
|
+
this.#clearSessionCredential(storageProvider, sessionId);
|
|
2468
|
+
}
|
|
2469
|
+
}
|
|
2470
|
+
|
|
1882
2471
|
#emitCredentialDisabled(event: CredentialDisabledEvent): void {
|
|
1883
2472
|
if (this.#credentialDisabledListeners.size === 0) {
|
|
1884
2473
|
// No subscribers — buffer for later replay. Cap the backlog so a process that runs
|
|
@@ -2020,7 +2609,13 @@ export class AuthStorage {
|
|
|
2020
2609
|
} else {
|
|
2021
2610
|
this.#store.deleteAuthCredentialsForProvider(storageProvider, "deleted by user");
|
|
2022
2611
|
}
|
|
2612
|
+
const previousEntries = this.#getStoredCredentials(storageProvider);
|
|
2023
2613
|
this.#setStoredCredentials(storageProvider, []);
|
|
2614
|
+
this.#clearSelectorsForRemovedCredential(
|
|
2615
|
+
storageProvider,
|
|
2616
|
+
new Set(previousEntries.map(entry => entry.id)),
|
|
2617
|
+
previousEntries,
|
|
2618
|
+
);
|
|
2024
2619
|
this.#resetProviderAssignments(storageProvider);
|
|
2025
2620
|
}
|
|
2026
2621
|
|
|
@@ -2051,10 +2646,10 @@ export class AuthStorage {
|
|
|
2051
2646
|
return false;
|
|
2052
2647
|
}
|
|
2053
2648
|
|
|
2054
|
-
hasAuth(provider: string): boolean {
|
|
2649
|
+
hasAuth(provider: string, sessionId?: string): boolean {
|
|
2055
2650
|
const storageProvider = resolveOAuthStorageProvider(provider);
|
|
2056
2651
|
try {
|
|
2057
|
-
this.#resolveSelectedStoredCredential(storageProvider);
|
|
2652
|
+
this.#resolveSelectedStoredCredential(storageProvider, undefined, sessionId);
|
|
2058
2653
|
} catch {
|
|
2059
2654
|
return false;
|
|
2060
2655
|
}
|
|
@@ -2070,7 +2665,7 @@ export class AuthStorage {
|
|
|
2070
2665
|
const storageProvider = resolveOAuthStorageProvider(provider);
|
|
2071
2666
|
let selected: ({ index: number } & StoredCredential) | undefined;
|
|
2072
2667
|
try {
|
|
2073
|
-
selected = this.#resolveSelectedStoredCredential(storageProvider);
|
|
2668
|
+
selected = this.#resolveSelectedStoredCredential(storageProvider, undefined, sessionId);
|
|
2074
2669
|
} catch {
|
|
2075
2670
|
return undefined;
|
|
2076
2671
|
}
|
|
@@ -2097,7 +2692,7 @@ export class AuthStorage {
|
|
|
2097
2692
|
hasUsableAuth(provider: string): boolean {
|
|
2098
2693
|
const storageProvider = resolveOAuthStorageProvider(provider);
|
|
2099
2694
|
try {
|
|
2100
|
-
const selectedCredential = this.#resolveSelectedStoredCredential(storageProvider);
|
|
2695
|
+
const selectedCredential = this.#resolveSelectedStoredCredential(storageProvider, undefined, undefined);
|
|
2101
2696
|
if (this.hasRuntimeApiKey(storageProvider)) return true;
|
|
2102
2697
|
if (this.#configOverrides.has(storageProvider)) return true;
|
|
2103
2698
|
if (selectedCredential) {
|
|
@@ -2153,7 +2748,14 @@ export class AuthStorage {
|
|
|
2153
2748
|
/**
|
|
2154
2749
|
* Get OAuth credentials for a provider.
|
|
2155
2750
|
*/
|
|
2156
|
-
getOAuthCredential(provider: string): OAuthCredential | undefined {
|
|
2751
|
+
getOAuthCredential(provider: string, sessionId?: string): OAuthCredential | undefined {
|
|
2752
|
+
const selected = this.#resolveSelectedStoredCredential(
|
|
2753
|
+
resolveOAuthStorageProvider(provider),
|
|
2754
|
+
undefined,
|
|
2755
|
+
sessionId,
|
|
2756
|
+
);
|
|
2757
|
+
if (selected?.credential.type === "oauth") return selected.credential;
|
|
2758
|
+
if (selected) return undefined;
|
|
2157
2759
|
return this.#getCredentialsForProvider(provider).find(
|
|
2158
2760
|
(credential): credential is OAuthCredential => credential.type === "oauth",
|
|
2159
2761
|
);
|
|
@@ -2177,6 +2779,13 @@ export class AuthStorage {
|
|
|
2177
2779
|
if (this.#runtimeOverrides.has(provider) || this.#configOverrides.has(provider)) return undefined;
|
|
2178
2780
|
|
|
2179
2781
|
// Prefer the session-sticky credential when available.
|
|
2782
|
+
|
|
2783
|
+
const scopedSelection = this.#resolveSelectedStoredCredential(provider, undefined, sessionId);
|
|
2784
|
+
if (scopedSelection?.credential.type === "api_key") return undefined;
|
|
2785
|
+
if (scopedSelection?.credential.type === "oauth") {
|
|
2786
|
+
const accountId = scopedSelection.credential.accountId;
|
|
2787
|
+
return typeof accountId === "string" && accountId.length > 0 ? accountId : undefined;
|
|
2788
|
+
}
|
|
2180
2789
|
const sessionPref = this.#getSessionCredential(provider, sessionId);
|
|
2181
2790
|
// If the session has been routed to a stored API key, do not inject OAuth account_uuid.
|
|
2182
2791
|
if (sessionPref !== undefined && sessionPref.type !== "oauth") return undefined;
|
|
@@ -2623,6 +3232,7 @@ export class AuthStorage {
|
|
|
2623
3232
|
projectId: credential.projectId,
|
|
2624
3233
|
email: credential.email,
|
|
2625
3234
|
enterpriseUrl: credential.enterpriseUrl,
|
|
3235
|
+
mcpBinding: credential.mcpBinding,
|
|
2626
3236
|
};
|
|
2627
3237
|
}
|
|
2628
3238
|
|
|
@@ -2697,6 +3307,7 @@ export class AuthStorage {
|
|
|
2697
3307
|
projectId: credential.projectId,
|
|
2698
3308
|
email: credential.email,
|
|
2699
3309
|
enterpriseUrl: credential.enterpriseUrl,
|
|
3310
|
+
mcpBinding: credential.mcpBinding,
|
|
2700
3311
|
};
|
|
2701
3312
|
}
|
|
2702
3313
|
|
|
@@ -2710,6 +3321,7 @@ export class AuthStorage {
|
|
|
2710
3321
|
projectId: refreshed.projectId ?? credential.projectId,
|
|
2711
3322
|
email: refreshed.email ?? credential.email,
|
|
2712
3323
|
enterpriseUrl: refreshed.enterpriseUrl ?? credential.enterpriseUrl,
|
|
3324
|
+
mcpBinding: credential.mcpBinding,
|
|
2713
3325
|
};
|
|
2714
3326
|
}
|
|
2715
3327
|
|
|
@@ -2757,6 +3369,7 @@ export class AuthStorage {
|
|
|
2757
3369
|
projectId: next.projectId,
|
|
2758
3370
|
email: next.email,
|
|
2759
3371
|
enterpriseUrl: next.enterpriseUrl,
|
|
3372
|
+
mcpBinding: next.mcpBinding ?? existing.mcpBinding,
|
|
2760
3373
|
});
|
|
2761
3374
|
}
|
|
2762
3375
|
|
|
@@ -3208,9 +3821,172 @@ export class AuthStorage {
|
|
|
3208
3821
|
* Environment-variable API keys are not enumerated — the caller's intent
|
|
3209
3822
|
* here is "which of my stored credentials is broken".
|
|
3210
3823
|
*/
|
|
3824
|
+
/** Return a safe cache-only usage observation. */
|
|
3825
|
+
getCachedUsageReport(provider: Provider, credentialId: number, baseUrl?: string): CachedUsageReport | undefined {
|
|
3826
|
+
const storageProvider = resolveOAuthStorageProvider(provider);
|
|
3827
|
+
const presentation = this.#store.peekCachedUsagePresentation?.(storageProvider, credentialId);
|
|
3828
|
+
if (presentation) {
|
|
3829
|
+
const now = Date.now();
|
|
3830
|
+
if (presentation.retainUntil > now) {
|
|
3831
|
+
return {
|
|
3832
|
+
report: presentation.usage,
|
|
3833
|
+
fetchedAt: presentation.fetchedAt,
|
|
3834
|
+
freshUntil: presentation.freshUntil,
|
|
3835
|
+
retainUntil: presentation.retainUntil,
|
|
3836
|
+
freshness: presentation.freshUntil > now ? "fresh" : "stale-last-good",
|
|
3837
|
+
};
|
|
3838
|
+
}
|
|
3839
|
+
}
|
|
3840
|
+
const entry = this.#getStoredCredentials(storageProvider).find(candidate => candidate.id === credentialId);
|
|
3841
|
+
if (entry?.credential.type !== "oauth") return undefined;
|
|
3842
|
+
const request = this.#buildUsageRequestForOauth(storageProvider, entry.credential, baseUrl);
|
|
3843
|
+
const cached = this.#usageCache.getStale<UsageReport | null>(this.#buildUsageReportCacheKey(request));
|
|
3844
|
+
if (!cached || cached.value === null || cached.expiresAt + PRESENTATION_RETENTION_MS < Date.now())
|
|
3845
|
+
return undefined;
|
|
3846
|
+
return {
|
|
3847
|
+
report: safeUsageReport(cached.value),
|
|
3848
|
+
fetchedAt: cached.value.fetchedAt,
|
|
3849
|
+
freshUntil: cached.expiresAt,
|
|
3850
|
+
retainUntil: cached.expiresAt + PRESENTATION_RETENTION_MS,
|
|
3851
|
+
freshness: cached.expiresAt > Date.now() ? "fresh" : "stale-last-good",
|
|
3852
|
+
};
|
|
3853
|
+
}
|
|
3854
|
+
|
|
3855
|
+
/** Cache-only health observation; unknown means no retained explicit check. */
|
|
3856
|
+
getCachedCredentialHealth(credentialId: number): CachedCredentialHealth {
|
|
3857
|
+
const inventory = this.#store.listCredentialInventory?.() ?? [];
|
|
3858
|
+
const row = inventory.find(candidate => candidate.id === credentialId);
|
|
3859
|
+
if (row?.disabled) return { status: "failed", reason: scrubHealthReason(row.disabledCause ?? "disabled") };
|
|
3860
|
+
const remote = row ? this.#store.peekCachedCredentialHealth?.(row.provider as Provider, credentialId) : undefined;
|
|
3861
|
+
if (remote) return remote;
|
|
3862
|
+
const raw = this.#store.getCache(`${HEALTH_CACHE_PREFIX}${credentialId}`);
|
|
3863
|
+
if (!raw) return { status: "unknown", reason: null };
|
|
3864
|
+
try {
|
|
3865
|
+
const value = JSON.parse(raw) as {
|
|
3866
|
+
status?: unknown;
|
|
3867
|
+
reason?: unknown;
|
|
3868
|
+
checkedAt?: unknown;
|
|
3869
|
+
retainUntil?: unknown;
|
|
3870
|
+
};
|
|
3871
|
+
if (typeof value.retainUntil !== "number" || value.retainUntil <= Date.now())
|
|
3872
|
+
return { status: "unknown", reason: null };
|
|
3873
|
+
const status =
|
|
3874
|
+
value.status === "ok" || value.status === "failed" || value.status === "unverifiable"
|
|
3875
|
+
? value.status
|
|
3876
|
+
: "unknown";
|
|
3877
|
+
return {
|
|
3878
|
+
status,
|
|
3879
|
+
reason: typeof value.reason === "string" ? value.reason : null,
|
|
3880
|
+
checkedAt: typeof value.checkedAt === "number" ? value.checkedAt : undefined,
|
|
3881
|
+
retainUntil: value.retainUntil,
|
|
3882
|
+
};
|
|
3883
|
+
} catch {
|
|
3884
|
+
return { status: "unknown", reason: null };
|
|
3885
|
+
}
|
|
3886
|
+
}
|
|
3887
|
+
|
|
3888
|
+
peekCachedCredentialHealthForSource(provider: string, source: "env" | "config" | "runtime"): CachedCredentialHealth {
|
|
3889
|
+
const raw = this.#store.getCache(`${SOURCE_HEALTH_CACHE_PREFIX}${provider}:${source}`);
|
|
3890
|
+
if (!raw) return { status: "unknown", reason: null };
|
|
3891
|
+
try {
|
|
3892
|
+
const value = JSON.parse(raw) as CachedCredentialHealth;
|
|
3893
|
+
if (!value.retainUntil || value.retainUntil <= Date.now()) return { status: "unknown", reason: null };
|
|
3894
|
+
return {
|
|
3895
|
+
status:
|
|
3896
|
+
value.status === "ok" || value.status === "failed" || value.status === "unverifiable"
|
|
3897
|
+
? value.status
|
|
3898
|
+
: "unknown",
|
|
3899
|
+
reason: value.reason ? scrubHealthReason(value.reason) : null,
|
|
3900
|
+
checkedAt: value.checkedAt,
|
|
3901
|
+
retainUntil: value.retainUntil,
|
|
3902
|
+
};
|
|
3903
|
+
} catch {
|
|
3904
|
+
return { status: "unknown", reason: null };
|
|
3905
|
+
}
|
|
3906
|
+
}
|
|
3907
|
+
|
|
3908
|
+
recordCredentialHealthForSource(
|
|
3909
|
+
provider: string,
|
|
3910
|
+
source: "env" | "config" | "runtime",
|
|
3911
|
+
health: CachedCredentialHealth,
|
|
3912
|
+
): void {
|
|
3913
|
+
if (health.status === "unknown" || !health.retainUntil) return;
|
|
3914
|
+
const retainUntil = health.retainUntil;
|
|
3915
|
+
const payload: CachedCredentialHealth = {
|
|
3916
|
+
status: health.status,
|
|
3917
|
+
reason: health.reason ? scrubHealthReason(health.reason) : null,
|
|
3918
|
+
checkedAt: health.checkedAt ?? Date.now(),
|
|
3919
|
+
retainUntil,
|
|
3920
|
+
};
|
|
3921
|
+
this.#store.setCache(
|
|
3922
|
+
`${SOURCE_HEALTH_CACHE_PREFIX}${resolveOAuthStorageProvider(provider)}:${source}`,
|
|
3923
|
+
JSON.stringify(payload),
|
|
3924
|
+
Math.floor(retainUntil / 1000),
|
|
3925
|
+
);
|
|
3926
|
+
}
|
|
3927
|
+
|
|
3928
|
+
#recordCredentialHealth(provider: Provider, credentialId: number, health: CachedCredentialHealth): void {
|
|
3929
|
+
if (health.status !== "unknown") this.#store.recordCredentialHealth?.(provider, credentialId, health);
|
|
3930
|
+
if (health.status === "unknown" || !health.retainUntil) return;
|
|
3931
|
+
const healthPayload = {
|
|
3932
|
+
v: 1,
|
|
3933
|
+
status: health.status,
|
|
3934
|
+
reason: health.reason ? scrubHealthReason(health.reason) : null,
|
|
3935
|
+
checkedAt: health.checkedAt ?? Date.now(),
|
|
3936
|
+
retainUntil: health.retainUntil,
|
|
3937
|
+
};
|
|
3938
|
+
this.#store.setCache(
|
|
3939
|
+
`${HEALTH_CACHE_PREFIX}${credentialId}`,
|
|
3940
|
+
JSON.stringify(healthPayload),
|
|
3941
|
+
Math.floor(healthPayload.retainUntil / 1000),
|
|
3942
|
+
);
|
|
3943
|
+
}
|
|
3944
|
+
/** Explicit API-key probe; key bytes are not retained in the returned result. */
|
|
3945
|
+
async checkApiKeyCredential(
|
|
3946
|
+
provider: Provider,
|
|
3947
|
+
apiKey: string,
|
|
3948
|
+
options: ApiKeyCredentialCheckOptions = {},
|
|
3949
|
+
): Promise<ApiKeyCredentialCheckResult> {
|
|
3950
|
+
const providerImpl = this.#usageProviderResolver?.(provider);
|
|
3951
|
+
const base: ApiKeyCredentialCheckResult = { provider, type: "api_key", ok: null };
|
|
3952
|
+
if (!providerImpl) {
|
|
3953
|
+
base.reason = `unsupported API-key probe for ${provider}`;
|
|
3954
|
+
return base;
|
|
3955
|
+
}
|
|
3956
|
+
const request = this.#buildUsageRequest(provider, { type: "api_key", apiKey }, options.baseUrl);
|
|
3957
|
+
if (providerImpl.supports && !providerImpl.supports(request)) {
|
|
3958
|
+
base.reason = `unsupported API-key probe for ${provider}`;
|
|
3959
|
+
return base;
|
|
3960
|
+
}
|
|
3961
|
+
options.signal?.throwIfAborted();
|
|
3962
|
+
const timeoutMs = options.timeoutMs ?? this.#usageRequestTimeoutMs;
|
|
3963
|
+
const timeoutSignal = AbortSignal.timeout(timeoutMs);
|
|
3964
|
+
const signal = options.signal ? AbortSignal.any([options.signal, timeoutSignal]) : timeoutSignal;
|
|
3965
|
+
try {
|
|
3966
|
+
const report = await providerImpl.fetchUsage(
|
|
3967
|
+
{ ...request, signal },
|
|
3968
|
+
{
|
|
3969
|
+
fetch: this.#usageFetch,
|
|
3970
|
+
logger: this.#usageLogger,
|
|
3971
|
+
},
|
|
3972
|
+
);
|
|
3973
|
+
if (!report) {
|
|
3974
|
+
base.reason = "API-key probe returned no verifiable data";
|
|
3975
|
+
return base;
|
|
3976
|
+
}
|
|
3977
|
+
base.ok = true;
|
|
3978
|
+
base.report = safeUsageReport(report);
|
|
3979
|
+
return base;
|
|
3980
|
+
} catch (error) {
|
|
3981
|
+
base.ok = false;
|
|
3982
|
+
base.reason = scrubHealthReason(error, [apiKey]);
|
|
3983
|
+
return base;
|
|
3984
|
+
}
|
|
3985
|
+
}
|
|
3986
|
+
|
|
3211
3987
|
async checkCredentials(options?: CheckCredentialsOptions): Promise<CredentialHealthResult[]> {
|
|
3212
3988
|
options?.signal?.throwIfAborted();
|
|
3213
|
-
const stored = this.#store.listAuthCredentials();
|
|
3989
|
+
const stored = this.#store.listAuthCredentials(options?.provider);
|
|
3214
3990
|
const resolver = this.#usageProviderResolver;
|
|
3215
3991
|
const timeoutMs = options?.timeoutMs ?? this.#usageRequestTimeoutMs;
|
|
3216
3992
|
const ctx: UsageFetchContext = { fetch: this.#usageFetch, logger: this.#usageLogger };
|
|
@@ -3281,12 +4057,21 @@ export class AuthStorage {
|
|
|
3281
4057
|
params = { ...params, credential: refreshedCredential };
|
|
3282
4058
|
} catch (error) {
|
|
3283
4059
|
base.ok = false;
|
|
3284
|
-
base.reason = `oauth refresh failed: ${
|
|
3285
|
-
results.push(base);
|
|
3286
|
-
continue;
|
|
4060
|
+
base.reason = `oauth refresh failed: ${scrubHealthReason(error)}`;
|
|
3287
4061
|
}
|
|
3288
4062
|
}
|
|
3289
4063
|
}
|
|
4064
|
+
if (base.ok === false && base.reason?.startsWith("oauth refresh failed:")) {
|
|
4065
|
+
results.push(base);
|
|
4066
|
+
const healthPayload: CachedCredentialHealth = {
|
|
4067
|
+
status: "failed",
|
|
4068
|
+
reason: scrubHealthReason(base.reason),
|
|
4069
|
+
checkedAt: Date.now(),
|
|
4070
|
+
retainUntil: Date.now() + PRESENTATION_RETENTION_MS,
|
|
4071
|
+
};
|
|
4072
|
+
this.#recordCredentialHealth(row.provider as Provider, row.id, healthPayload);
|
|
4073
|
+
continue;
|
|
4074
|
+
}
|
|
3290
4075
|
|
|
3291
4076
|
try {
|
|
3292
4077
|
const report = await providerImpl.fetchUsage(params, ctx);
|
|
@@ -3300,13 +4085,27 @@ export class AuthStorage {
|
|
|
3300
4085
|
if (email) base.email = email;
|
|
3301
4086
|
const { raw: _raw, ...trimmed } = report;
|
|
3302
4087
|
base.report = trimmed;
|
|
4088
|
+
this.#usageCache.set(this.#buildUsageReportCacheKey(params), {
|
|
4089
|
+
value: report,
|
|
4090
|
+
expiresAt: Date.now() + USAGE_REPORT_TTL_MS,
|
|
4091
|
+
});
|
|
4092
|
+
this.#store.recordCredentialUsage?.(row.provider as Provider, row.id, trimmed);
|
|
3303
4093
|
}
|
|
3304
4094
|
} catch (error) {
|
|
3305
4095
|
base.ok = false;
|
|
3306
|
-
base.reason = error
|
|
4096
|
+
base.reason = scrubHealthReason(error, cred.type === "api_key" ? [cred.key] : []);
|
|
3307
4097
|
}
|
|
3308
4098
|
|
|
3309
4099
|
results.push(base);
|
|
4100
|
+
const healthPayload: CachedCredentialHealth = {
|
|
4101
|
+
status: base.ok === true ? "ok" : base.ok === false ? "failed" : "unverifiable",
|
|
4102
|
+
reason: base.reason
|
|
4103
|
+
? scrubHealthReason(base.reason, row.credential.type === "api_key" ? [row.credential.key] : [])
|
|
4104
|
+
: null,
|
|
4105
|
+
checkedAt: Date.now(),
|
|
4106
|
+
retainUntil: Date.now() + PRESENTATION_RETENTION_MS,
|
|
4107
|
+
};
|
|
4108
|
+
this.#recordCredentialHealth(row.provider as Provider, row.id, healthPayload);
|
|
3310
4109
|
}
|
|
3311
4110
|
|
|
3312
4111
|
return results;
|
|
@@ -3559,7 +4358,7 @@ export class AuthStorage {
|
|
|
3559
4358
|
});
|
|
3560
4359
|
return undefined;
|
|
3561
4360
|
}
|
|
3562
|
-
const selectedCredential = this.#resolveSelectedStoredCredential(provider, options);
|
|
4361
|
+
const selectedCredential = this.#resolveSelectedStoredCredential(provider, options, sessionId);
|
|
3563
4362
|
const selectedOAuthCredential =
|
|
3564
4363
|
selectedCredential?.credential.type === "oauth"
|
|
3565
4364
|
? { credential: selectedCredential.credential, index: selectedCredential.index }
|
|
@@ -3597,6 +4396,32 @@ export class AuthStorage {
|
|
|
3597
4396
|
.filter((selection): selection is { credential: OAuthCredential; index: number } => Boolean(selection))
|
|
3598
4397
|
.map(selection => ({ selection, usage: null, usageChecked: false }));
|
|
3599
4398
|
|
|
4399
|
+
// Soft `--prefer-credential` preference: reorder the preferred row to the
|
|
4400
|
+
// front when it is usable, ahead of the session-stickiness reorder below so
|
|
4401
|
+
// a blocked preferred row falls through to whatever the session already
|
|
4402
|
+
// stuck to (its own quota-triggered fallback) instead of overriding it.
|
|
4403
|
+
if (!selectedCredential) {
|
|
4404
|
+
const preferredSelector = this.#getPreferredCredentialSelector(provider, options);
|
|
4405
|
+
if (preferredSelector) {
|
|
4406
|
+
this.#assertPreferredCredentialSelectorUsable(resolveOAuthStorageProvider(provider), preferredSelector);
|
|
4407
|
+
}
|
|
4408
|
+
const preferredSelection = preferredSelector
|
|
4409
|
+
? this.#findCredentialBySelector(provider, preferredSelector)
|
|
4410
|
+
: undefined;
|
|
4411
|
+
if (
|
|
4412
|
+
preferredSelection?.credential.type === "oauth" &&
|
|
4413
|
+
!this.#isCredentialBlocked(providerKey, preferredSelection.index)
|
|
4414
|
+
) {
|
|
4415
|
+
const preferredCandidate = candidates.findIndex(
|
|
4416
|
+
candidate => candidate.selection.index === preferredSelection.index,
|
|
4417
|
+
);
|
|
4418
|
+
if (preferredCandidate > 0) {
|
|
4419
|
+
const [preferred] = candidates.splice(preferredCandidate, 1);
|
|
4420
|
+
candidates.unshift(preferred);
|
|
4421
|
+
}
|
|
4422
|
+
}
|
|
4423
|
+
}
|
|
4424
|
+
|
|
3600
4425
|
if (!selectedCredential && sessionPreferredIndex !== undefined && !requiresProModel) {
|
|
3601
4426
|
const sessionPreferredCandidate = candidates.findIndex(
|
|
3602
4427
|
candidate =>
|
|
@@ -4089,6 +4914,23 @@ export class AuthStorage {
|
|
|
4089
4914
|
if (attemptedCredentialId !== undefined) {
|
|
4090
4915
|
const latestRow = this.#store.listAuthCredentials(provider).find(row => row.id === attemptedCredentialId);
|
|
4091
4916
|
const latestCredential = latestRow?.credential;
|
|
4917
|
+
if (!latestRow) {
|
|
4918
|
+
// The row we just tried is no longer active: a peer disabled it after a
|
|
4919
|
+
// definitive refresh failure, the user removed it, or a re-login replaced
|
|
4920
|
+
// it with a new row. Our in-memory snapshot is stale, and the refresh
|
|
4921
|
+
// helper can only answer "credential disappeared" for it — an error that
|
|
4922
|
+
// classifies as transient and would temp-block a row that will never come
|
|
4923
|
+
// back. Long-lived processes (resumed sessions) would then keep replaying
|
|
4924
|
+
// the vanished credential until restart while a valid re-login row sits
|
|
4925
|
+
// unused in the store. Reload and re-resolve against the persisted truth.
|
|
4926
|
+
logger.debug("OAuth credential vanished from the store; reloading snapshot", {
|
|
4927
|
+
provider,
|
|
4928
|
+
index: selection.index,
|
|
4929
|
+
credentialId: attemptedCredentialId,
|
|
4930
|
+
});
|
|
4931
|
+
await this.reload();
|
|
4932
|
+
return this.#resolveOAuthSelection(provider, sessionId, options, reloadsUsed + 1);
|
|
4933
|
+
}
|
|
4092
4934
|
if (latestCredential?.type === "oauth" && latestCredential.refresh !== attemptedRefreshToken) {
|
|
4093
4935
|
logger.debug("OAuth refresh race detected; another process rotated token first", {
|
|
4094
4936
|
provider,
|
|
@@ -4110,7 +4952,7 @@ export class AuthStorage {
|
|
|
4110
4952
|
logger.warn("OAuth token refresh failed", {
|
|
4111
4953
|
provider,
|
|
4112
4954
|
index: selection.index,
|
|
4113
|
-
error:
|
|
4955
|
+
error: scrubHealthReason(error, [selection.credential.access, selection.credential.refresh]),
|
|
4114
4956
|
isDefinitiveFailure,
|
|
4115
4957
|
});
|
|
4116
4958
|
|
|
@@ -4155,7 +4997,7 @@ export class AuthStorage {
|
|
|
4155
4997
|
}
|
|
4156
4998
|
}
|
|
4157
4999
|
if (
|
|
4158
|
-
!this.#getCredentialSelector(provider, options) &&
|
|
5000
|
+
!this.#getCredentialSelector(provider, options, sessionId) &&
|
|
4159
5001
|
this.#getCredentialsForProvider(provider).some(credential => credential.type === "oauth")
|
|
4160
5002
|
) {
|
|
4161
5003
|
return this.#resolveOAuthSelection(provider, sessionId, options, reloadsUsed);
|
|
@@ -4165,8 +5007,8 @@ export class AuthStorage {
|
|
|
4165
5007
|
this.#markCredentialBlocked(providerKey, selection.index, Date.now() + 5 * 60 * 1000);
|
|
4166
5008
|
}
|
|
4167
5009
|
}
|
|
4168
|
-
if (this.#getCredentialSelector(provider, options)) {
|
|
4169
|
-
const selector = this.#getCredentialSelector(provider, options);
|
|
5010
|
+
if (this.#getCredentialSelector(provider, options, sessionId)) {
|
|
5011
|
+
const selector = this.#getCredentialSelector(provider, options, sessionId);
|
|
4170
5012
|
throw new Error(
|
|
4171
5013
|
`Selected credential for ${provider} (${selector ? this.#formatCredentialSelector(selector) : "unknown"}) is unavailable`,
|
|
4172
5014
|
);
|
|
@@ -4251,7 +5093,7 @@ export class AuthStorage {
|
|
|
4251
5093
|
const configKey = this.#configOverrides.get(provider);
|
|
4252
5094
|
if (configKey) return configKey;
|
|
4253
5095
|
|
|
4254
|
-
const selectedCredential = this.#resolveSelectedStoredCredential(provider);
|
|
5096
|
+
const selectedCredential = this.#resolveSelectedStoredCredential(provider, undefined, undefined);
|
|
4255
5097
|
if (selectedCredential?.credential.type === "api_key") {
|
|
4256
5098
|
return this.#resolveStoredApiKey(provider, selectedCredential.credential.key);
|
|
4257
5099
|
}
|
|
@@ -4311,7 +5153,7 @@ export class AuthStorage {
|
|
|
4311
5153
|
*/
|
|
4312
5154
|
async getApiKey(provider: string, sessionId?: string, options?: AuthApiKeyOptions): Promise<string | undefined> {
|
|
4313
5155
|
provider = resolveOAuthStorageProvider(provider);
|
|
4314
|
-
const selectedCredential = this.#resolveSelectedStoredCredential(provider, options);
|
|
5156
|
+
const selectedCredential = this.#resolveSelectedStoredCredential(provider, options, sessionId);
|
|
4315
5157
|
|
|
4316
5158
|
// Runtime override takes highest priority after selector validation.
|
|
4317
5159
|
const runtimeKey = this.#runtimeOverrides.get(provider);
|
|
@@ -4666,14 +5508,16 @@ export class AuthStorage {
|
|
|
4666
5508
|
* `POST /v1/credential/:id/disable`. Returns `false` when no such row exists.
|
|
4667
5509
|
*/
|
|
4668
5510
|
disableCredentialById(id: number, disabledCause: string): boolean {
|
|
5511
|
+
const cause = normalizeDisabledCause(disabledCause);
|
|
4669
5512
|
for (const [provider, entries] of this.#data) {
|
|
4670
5513
|
const index = entries.findIndex(entry => entry.id === id);
|
|
4671
5514
|
if (index === -1) continue;
|
|
4672
|
-
this.#store.deleteAuthCredential(id,
|
|
5515
|
+
this.#store.deleteAuthCredential(id, cause);
|
|
4673
5516
|
const next = entries.filter((_value, idx) => idx !== index);
|
|
4674
5517
|
this.#setStoredCredentials(provider, next);
|
|
5518
|
+
this.#clearSelectorsForRemovedCredential(provider, new Set([id]), entries);
|
|
4675
5519
|
this.#resetProviderAssignments(provider);
|
|
4676
|
-
this.#emitCredentialDisabled({ provider, disabledCause });
|
|
5520
|
+
this.#emitCredentialDisabled({ provider, disabledCause: cause });
|
|
4677
5521
|
return true;
|
|
4678
5522
|
}
|
|
4679
5523
|
return false;
|
|
@@ -4759,6 +5603,7 @@ type AuthRow = {
|
|
|
4759
5603
|
data: string;
|
|
4760
5604
|
disabled_cause: string | null;
|
|
4761
5605
|
identity_key: string | null;
|
|
5606
|
+
revision: number;
|
|
4762
5607
|
};
|
|
4763
5608
|
|
|
4764
5609
|
type SerializedCredentialRecord = {
|
|
@@ -4767,7 +5612,7 @@ type SerializedCredentialRecord = {
|
|
|
4767
5612
|
identityKey: string | null;
|
|
4768
5613
|
};
|
|
4769
5614
|
|
|
4770
|
-
const AUTH_SCHEMA_VERSION =
|
|
5615
|
+
const AUTH_SCHEMA_VERSION = 5;
|
|
4771
5616
|
const SQLITE_NOW_EPOCH = "CAST(strftime('%s','now') AS INTEGER)";
|
|
4772
5617
|
|
|
4773
5618
|
function normalizeStoredAccountId(accountId: string | null | undefined): string | null {
|
|
@@ -4827,12 +5672,19 @@ function deserializeCredential(row: AuthRow): AuthCredential | null {
|
|
|
4827
5672
|
}
|
|
4828
5673
|
|
|
4829
5674
|
function normalizeDisabledCause(disabledCause: string): string {
|
|
4830
|
-
const normalized = disabledCause
|
|
5675
|
+
const normalized = disabledCause
|
|
5676
|
+
.replace(/bearer\s+[^\s,;]+/gi, "Bearer [redacted]")
|
|
5677
|
+
.replace(/(api[_-]?key|token|secret|authorization)[=:]\s*[^\s,;]+/gi, "$1=[redacted]")
|
|
5678
|
+
.replace(/https?:\/\/[^\s?#]+\?[^\s]+/gi, value => value.split("?")[0] ?? "[redacted URL]")
|
|
5679
|
+
.replace(/[\u0000-\u001f\u007f]/g, " ")
|
|
5680
|
+
.replace(/\s+/g, " ")
|
|
5681
|
+
.trim()
|
|
5682
|
+
.slice(0, 240);
|
|
4831
5683
|
return normalized.length > 0 ? normalized : "disabled";
|
|
4832
5684
|
}
|
|
4833
5685
|
|
|
4834
5686
|
function toStoredAuthCredential(row: AuthRow, credential: AuthCredential): StoredAuthCredential {
|
|
4835
|
-
return { id: row.id, provider: row.provider, credential, disabledCause: row.disabled_cause };
|
|
5687
|
+
return { id: row.id, provider: row.provider, credential, disabledCause: row.disabled_cause, revision: row.revision };
|
|
4836
5688
|
}
|
|
4837
5689
|
|
|
4838
5690
|
function resolveProviderCredentialIdentityKey(provider: string, identifiers: string[]): string | null {
|
|
@@ -4941,6 +5793,8 @@ export class SqliteAuthCredentialStore implements AuthCredentialStore {
|
|
|
4941
5793
|
#db: Database;
|
|
4942
5794
|
#listActiveStmt: Statement;
|
|
4943
5795
|
#listActiveByProviderStmt: Statement;
|
|
5796
|
+
#listAllStmt: Statement;
|
|
5797
|
+
#listAllByProviderStmt: Statement;
|
|
4944
5798
|
#listDisabledByProviderStmt: Statement;
|
|
4945
5799
|
#insertStmt: Statement;
|
|
4946
5800
|
#updateStmt: Statement;
|
|
@@ -4960,28 +5814,34 @@ export class SqliteAuthCredentialStore implements AuthCredentialStore {
|
|
|
4960
5814
|
this.#initializeSchema();
|
|
4961
5815
|
|
|
4962
5816
|
this.#listActiveStmt = this.#db.prepare(
|
|
4963
|
-
"SELECT id, provider, credential_type, data, disabled_cause, identity_key FROM auth_credentials WHERE disabled_cause IS NULL ORDER BY id ASC",
|
|
5817
|
+
"SELECT id, provider, credential_type, data, disabled_cause, identity_key, revision FROM auth_credentials WHERE disabled_cause IS NULL ORDER BY id ASC",
|
|
4964
5818
|
);
|
|
4965
5819
|
this.#listActiveByProviderStmt = this.#db.prepare(
|
|
4966
|
-
"SELECT id, provider, credential_type, data, disabled_cause, identity_key FROM auth_credentials WHERE provider = ? AND disabled_cause IS NULL ORDER BY id ASC",
|
|
5820
|
+
"SELECT id, provider, credential_type, data, disabled_cause, identity_key, revision FROM auth_credentials WHERE provider = ? AND disabled_cause IS NULL ORDER BY id ASC",
|
|
5821
|
+
);
|
|
5822
|
+
this.#listAllStmt = this.#db.prepare(
|
|
5823
|
+
"SELECT id, provider, credential_type, data, disabled_cause, identity_key, revision FROM auth_credentials ORDER BY id ASC",
|
|
5824
|
+
);
|
|
5825
|
+
this.#listAllByProviderStmt = this.#db.prepare(
|
|
5826
|
+
"SELECT id, provider, credential_type, data, disabled_cause, identity_key, revision FROM auth_credentials WHERE provider = ? ORDER BY id ASC",
|
|
4967
5827
|
);
|
|
4968
5828
|
this.#listDisabledByProviderStmt = this.#db.prepare(
|
|
4969
|
-
"SELECT id, provider, credential_type, data, disabled_cause, identity_key FROM auth_credentials WHERE provider = ? AND disabled_cause IS NOT NULL ORDER BY id ASC",
|
|
5829
|
+
"SELECT id, provider, credential_type, data, disabled_cause, identity_key, revision FROM auth_credentials WHERE provider = ? AND disabled_cause IS NOT NULL ORDER BY id ASC",
|
|
4970
5830
|
);
|
|
4971
5831
|
this.#insertStmt = this.#db.prepare(
|
|
4972
|
-
`INSERT INTO auth_credentials (provider, credential_type, data, identity_key, created_at, updated_at) VALUES (?, ?, ?, ?, ${SQLITE_NOW_EPOCH}, ${SQLITE_NOW_EPOCH}) RETURNING id`,
|
|
5832
|
+
`INSERT INTO auth_credentials (provider, credential_type, data, identity_key, revision, created_at, updated_at) VALUES (?, ?, ?, ?, 1, ${SQLITE_NOW_EPOCH}, ${SQLITE_NOW_EPOCH}) RETURNING id`,
|
|
4973
5833
|
);
|
|
4974
5834
|
this.#updateStmt = this.#db.prepare(
|
|
4975
|
-
`UPDATE auth_credentials SET credential_type = ?, data = ?, identity_key = ?, updated_at = ${SQLITE_NOW_EPOCH} WHERE id = ?`,
|
|
5835
|
+
`UPDATE auth_credentials SET credential_type = ?, data = ?, identity_key = ?, revision = revision + 1, updated_at = ${SQLITE_NOW_EPOCH} WHERE id = ?`,
|
|
4976
5836
|
);
|
|
4977
5837
|
this.#deleteStmt = this.#db.prepare(
|
|
4978
|
-
`UPDATE auth_credentials SET disabled_cause = ?, updated_at = ${SQLITE_NOW_EPOCH} WHERE id = ?`,
|
|
5838
|
+
`UPDATE auth_credentials SET disabled_cause = ?, revision = revision + 1, updated_at = ${SQLITE_NOW_EPOCH} WHERE id = ?`,
|
|
4979
5839
|
);
|
|
4980
5840
|
this.#deleteIfMatchesStmt = this.#db.prepare(
|
|
4981
|
-
`UPDATE auth_credentials SET disabled_cause = ?, updated_at = ${SQLITE_NOW_EPOCH} WHERE id = ? AND data = ? AND disabled_cause IS NULL`,
|
|
5841
|
+
`UPDATE auth_credentials SET disabled_cause = ?, revision = revision + 1, updated_at = ${SQLITE_NOW_EPOCH} WHERE id = ? AND data = ? AND disabled_cause IS NULL`,
|
|
4982
5842
|
);
|
|
4983
5843
|
this.#deleteByProviderStmt = this.#db.prepare(
|
|
4984
|
-
`UPDATE auth_credentials SET disabled_cause = ?, updated_at = ${SQLITE_NOW_EPOCH} WHERE provider = ? AND disabled_cause IS NULL`,
|
|
5844
|
+
`UPDATE auth_credentials SET disabled_cause = ?, revision = revision + 1, updated_at = ${SQLITE_NOW_EPOCH} WHERE provider = ? AND disabled_cause IS NULL`,
|
|
4985
5845
|
);
|
|
4986
5846
|
this.#hardDeleteStmt = this.#db.prepare("DELETE FROM auth_credentials WHERE id = ?");
|
|
4987
5847
|
this.#getCacheStmt = this.#db.prepare(
|
|
@@ -5106,6 +5966,7 @@ export class SqliteAuthCredentialStore implements AuthCredentialStore {
|
|
|
5106
5966
|
data TEXT NOT NULL,
|
|
5107
5967
|
disabled_cause TEXT DEFAULT NULL,
|
|
5108
5968
|
identity_key TEXT DEFAULT NULL,
|
|
5969
|
+
revision INTEGER NOT NULL DEFAULT 1,
|
|
5109
5970
|
created_at INTEGER NOT NULL DEFAULT (${SQLITE_NOW_EPOCH}),
|
|
5110
5971
|
updated_at INTEGER NOT NULL DEFAULT (${SQLITE_NOW_EPOCH})
|
|
5111
5972
|
);
|
|
@@ -5130,6 +5991,9 @@ export class SqliteAuthCredentialStore implements AuthCredentialStore {
|
|
|
5130
5991
|
if (fromVersion < 4) {
|
|
5131
5992
|
this.#migrateAuthSchemaV3ToV4();
|
|
5132
5993
|
}
|
|
5994
|
+
if (fromVersion < 5) {
|
|
5995
|
+
this.#migrateAuthSchemaV4ToV5();
|
|
5996
|
+
}
|
|
5133
5997
|
}
|
|
5134
5998
|
|
|
5135
5999
|
#migrateAuthSchemaV0ToV1(): void {
|
|
@@ -5209,11 +6073,16 @@ export class SqliteAuthCredentialStore implements AuthCredentialStore {
|
|
|
5209
6073
|
});
|
|
5210
6074
|
migrate();
|
|
5211
6075
|
}
|
|
6076
|
+
#migrateAuthSchemaV4ToV5(): void {
|
|
6077
|
+
const columns = this.#db.prepare("PRAGMA table_info(auth_credentials)").all() as Array<{ name?: string }>;
|
|
6078
|
+
if (columns.some(column => column.name === "revision")) return;
|
|
6079
|
+
this.#db.run("ALTER TABLE auth_credentials ADD COLUMN revision INTEGER NOT NULL DEFAULT 1");
|
|
6080
|
+
}
|
|
5212
6081
|
|
|
5213
6082
|
#backfillCredentialIdentityKeys(): void {
|
|
5214
6083
|
const rows = this.#db
|
|
5215
6084
|
.prepare(
|
|
5216
|
-
"SELECT id, provider, credential_type, data, disabled_cause, identity_key FROM auth_credentials WHERE identity_key IS NULL ORDER BY id ASC",
|
|
6085
|
+
"SELECT id, provider, credential_type, data, disabled_cause, identity_key, revision FROM auth_credentials WHERE identity_key IS NULL ORDER BY id ASC",
|
|
5217
6086
|
)
|
|
5218
6087
|
.all() as AuthRow[];
|
|
5219
6088
|
if (rows.length === 0) return;
|
|
@@ -5241,6 +6110,79 @@ export class SqliteAuthCredentialStore implements AuthCredentialStore {
|
|
|
5241
6110
|
}
|
|
5242
6111
|
return results;
|
|
5243
6112
|
}
|
|
6113
|
+
|
|
6114
|
+
listCredentialInventory(provider?: string): CredentialInventoryRecord[] {
|
|
6115
|
+
const rows =
|
|
6116
|
+
provider === undefined
|
|
6117
|
+
? (this.#listAllStmt.all() as AuthRow[])
|
|
6118
|
+
: (this.#listAllByProviderStmt.all(provider) as AuthRow[]);
|
|
6119
|
+
const results: CredentialInventoryRecord[] = [];
|
|
6120
|
+
for (const row of rows) {
|
|
6121
|
+
const credential = deserializeCredential(row);
|
|
6122
|
+
if (!credential) continue;
|
|
6123
|
+
const identityLabel =
|
|
6124
|
+
credential.type === "oauth"
|
|
6125
|
+
? (credential.email ?? credential.accountId ?? credential.projectId ?? null)
|
|
6126
|
+
: null;
|
|
6127
|
+
results.push({
|
|
6128
|
+
id: row.id,
|
|
6129
|
+
provider: row.provider,
|
|
6130
|
+
credentialKind: credential.type,
|
|
6131
|
+
identityLabel,
|
|
6132
|
+
...(credential.type === "oauth" && credential.accountId ? { accountId: credential.accountId } : {}),
|
|
6133
|
+
...(credential.type === "oauth" && credential.email ? { email: credential.email } : {}),
|
|
6134
|
+
...(credential.type === "oauth" && credential.projectId ? { projectId: credential.projectId } : {}),
|
|
6135
|
+
disabled: row.disabled_cause !== null,
|
|
6136
|
+
disabledCause: row.disabled_cause,
|
|
6137
|
+
});
|
|
6138
|
+
}
|
|
6139
|
+
return results;
|
|
6140
|
+
}
|
|
6141
|
+
|
|
6142
|
+
listCredentialRemovalTargets(provider?: string): CredentialRemovalTarget[] {
|
|
6143
|
+
const rows =
|
|
6144
|
+
provider === undefined
|
|
6145
|
+
? (this.#listAllStmt.all() as AuthRow[])
|
|
6146
|
+
: (this.#listAllByProviderStmt.all(provider) as AuthRow[]);
|
|
6147
|
+
return rows
|
|
6148
|
+
.filter(row => row.credential_type === "oauth")
|
|
6149
|
+
.map(row => ({ id: row.id, provider: row.provider, expectedRevision: row.revision }));
|
|
6150
|
+
}
|
|
6151
|
+
removeAuthCredentialsHard(
|
|
6152
|
+
provider: string,
|
|
6153
|
+
targets: readonly CredentialRemovalTarget[],
|
|
6154
|
+
): AuthCredentialHardRemovalResult {
|
|
6155
|
+
const unique = [...new Map(targets.map(target => [target.id, target])).values()];
|
|
6156
|
+
const remove = this.#db.transaction((): AuthCredentialHardRemovalResult => {
|
|
6157
|
+
const currentIds: number[] = [];
|
|
6158
|
+
for (const target of unique) {
|
|
6159
|
+
const row = this.#db
|
|
6160
|
+
.prepare("SELECT id, provider, credential_type, revision FROM auth_credentials WHERE id = ?")
|
|
6161
|
+
.get(target.id) as
|
|
6162
|
+
| { id?: number; provider?: string; credential_type?: string; revision?: number }
|
|
6163
|
+
| undefined;
|
|
6164
|
+
if (
|
|
6165
|
+
!row ||
|
|
6166
|
+
row.provider !== provider ||
|
|
6167
|
+
row.credential_type !== "oauth" ||
|
|
6168
|
+
row.revision !== target.expectedRevision
|
|
6169
|
+
) {
|
|
6170
|
+
currentIds.push(row?.id ?? target.id);
|
|
6171
|
+
}
|
|
6172
|
+
}
|
|
6173
|
+
if (currentIds.length > 0) return { kind: "conflict", currentIds };
|
|
6174
|
+
for (const target of unique) {
|
|
6175
|
+
this.#hardDeleteStmt.run(target.id);
|
|
6176
|
+
this.#db.prepare("DELETE FROM oauth_refresh_leases WHERE credential_id = ?").run(target.id);
|
|
6177
|
+
this.#db.prepare("DELETE FROM cache WHERE key = ?").run(`${HEALTH_CACHE_PREFIX}${target.id}`);
|
|
6178
|
+
}
|
|
6179
|
+
this.#db
|
|
6180
|
+
.prepare("DELETE FROM cache WHERE substr(key, 1, ?) = ?")
|
|
6181
|
+
.run(`usage_cache:report:${provider}:`.length, `usage_cache:report:${provider}:`);
|
|
6182
|
+
return { kind: "removed", ids: unique.map(target => target.id) };
|
|
6183
|
+
});
|
|
6184
|
+
return remove();
|
|
6185
|
+
}
|
|
5244
6186
|
claimOAuthRefreshLease(
|
|
5245
6187
|
credentialId: number,
|
|
5246
6188
|
expectedRefresh: string,
|
|
@@ -5252,7 +6194,7 @@ export class SqliteAuthCredentialStore implements AuthCredentialStore {
|
|
|
5252
6194
|
const claim = this.#db.transaction((): OAuthRefreshLeaseClaim => {
|
|
5253
6195
|
const row = this.#db
|
|
5254
6196
|
.prepare(
|
|
5255
|
-
"SELECT id, provider, credential_type, data, disabled_cause, identity_key FROM auth_credentials WHERE id = ? AND disabled_cause IS NULL",
|
|
6197
|
+
"SELECT id, provider, credential_type, data, disabled_cause, identity_key, revision FROM auth_credentials WHERE id = ? AND disabled_cause IS NULL",
|
|
5256
6198
|
)
|
|
5257
6199
|
.get(credentialId) as AuthRow | undefined;
|
|
5258
6200
|
const credential = row ? deserializeCredential(row) : null;
|
|
@@ -5699,6 +6641,8 @@ export class SqliteAuthCredentialStore implements AuthCredentialStore {
|
|
|
5699
6641
|
this.#closed = true;
|
|
5700
6642
|
this.#listActiveStmt.finalize();
|
|
5701
6643
|
this.#listActiveByProviderStmt.finalize();
|
|
6644
|
+
this.#listAllStmt.finalize();
|
|
6645
|
+
this.#listAllByProviderStmt.finalize();
|
|
5702
6646
|
this.#listDisabledByProviderStmt.finalize();
|
|
5703
6647
|
this.#insertStmt.finalize();
|
|
5704
6648
|
this.#updateStmt.finalize();
|