@gajae-code/ai 0.18.11 → 0.19.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 +103 -0
- package/README.md +1 -1
- package/dist/types/auth-broker/wire-schemas.d.ts +30 -0
- package/dist/types/auth-storage.d.ts +98 -13
- package/dist/types/bedrock-claude-cache-policy.d.ts +1 -1
- package/dist/types/model-cache.d.ts +6 -4
- package/dist/types/model-manager.d.ts +2 -0
- package/dist/types/model-pricing.d.ts +8 -0
- package/dist/types/model-thinking.d.ts +11 -0
- package/dist/types/openai-completions-compat.d.ts +2 -1
- package/dist/types/provider-models/google.d.ts +2 -0
- package/dist/types/providers/amazon-bedrock.d.ts +17 -2
- package/dist/types/providers/anthropic.d.ts +25 -0
- package/dist/types/providers/aws-credential-config.d.ts +5 -0
- package/dist/types/providers/google-auth.d.ts +7 -0
- package/dist/types/providers/google-gemini-headers.d.ts +11 -6
- package/dist/types/providers/kiro-api-key.d.ts +5 -0
- package/dist/types/providers/openai-chat-server-schema.d.ts +2 -1
- package/dist/types/providers/openai-completions.d.ts +1 -1
- package/dist/types/providers/openai-responses-shared.d.ts +2 -1
- package/dist/types/stream.d.ts +33 -1
- package/dist/types/types.d.ts +26 -3
- package/dist/types/usage.d.ts +3 -0
- package/dist/types/utils/discovery/antigravity.d.ts +16 -1
- package/dist/types/utils/idle-iterator.d.ts +3 -2
- package/dist/types/utils/oauth/glm-zcode.d.ts +0 -30
- package/dist/types/utils/oauth/types.d.ts +9 -0
- package/package.json +4 -4
- package/src/auth-broker/remote-store.ts +62 -11
- package/src/auth-broker/wire-schemas.ts +2 -0
- package/src/auth-gateway/server.ts +3 -2
- package/src/auth-storage.ts +908 -246
- package/src/bedrock-claude-cache-policy.d.ts +1 -1
- package/src/bedrock-claude-cache-policy.ts +2 -2
- package/src/model-cache.ts +45 -10
- package/src/model-manager.ts +18 -6
- package/src/model-pricing.d.ts +8 -0
- package/src/model-pricing.ts +87 -0
- package/src/model-thinking.d.ts +11 -0
- package/src/model-thinking.ts +97 -12
- package/src/models.json +3176 -1670
- package/src/models.ts +10 -3
- package/src/openai-completions-compat.d.ts +2 -1
- package/src/openai-completions-compat.ts +5 -0
- package/src/provider-models/google.ts +11 -1
- package/src/provider-models/openai-compat.ts +26 -0
- package/src/providers/amazon-bedrock.d.ts +17 -2
- package/src/providers/amazon-bedrock.ts +76 -16
- package/src/providers/anthropic.d.ts +25 -0
- package/src/providers/anthropic.ts +230 -18
- package/src/providers/aws-credential-config.d.ts +5 -0
- package/src/providers/aws-credential-config.ts +29 -1
- package/src/providers/google-auth.d.ts +7 -0
- package/src/providers/google-auth.ts +165 -4
- package/src/providers/google-gemini-headers.d.ts +11 -6
- package/src/providers/google-gemini-headers.ts +16 -11
- package/src/providers/google-vertex.ts +2 -4
- package/src/providers/kiro-api-key.d.ts +5 -0
- package/src/providers/kiro-api-key.ts +379 -68
- package/src/providers/ollama.ts +19 -9
- package/src/providers/openai-anthropic-shim.ts +1 -0
- package/src/providers/openai-chat-server-schema.ts +1 -1
- package/src/providers/openai-chat-server.ts +14 -2
- package/src/providers/openai-codex-responses.ts +21 -14
- package/src/providers/openai-completions.d.ts +1 -1
- package/src/providers/openai-completions.ts +65 -4
- package/src/providers/openai-responses-server.ts +10 -1
- package/src/providers/openai-responses-shared.d.ts +2 -1
- package/src/providers/openai-responses-shared.ts +42 -1
- package/src/providers/openai-responses.ts +51 -10
- package/src/providers/pi-native-server.ts +1 -1
- package/src/providers/transform-messages.ts +32 -1
- package/src/stream.d.ts +33 -1
- package/src/stream.ts +111 -17
- package/src/types.d.ts +26 -3
- package/src/types.ts +55 -2
- package/src/usage/claude.ts +4 -2
- package/src/usage.ts +3 -0
- package/src/utils/discovery/antigravity.ts +192 -7
- package/src/utils/idle-iterator.d.ts +3 -2
- package/src/utils/idle-iterator.ts +30 -10
- package/src/utils/oauth/anthropic.ts +23 -22
- package/src/utils/oauth/api-key-validation.ts +34 -11
- package/src/utils/oauth/glm-zcode.d.ts +0 -30
- package/src/utils/oauth/glm-zcode.ts +15 -4
- package/src/utils/oauth/openai-codex.ts +41 -6
- package/src/utils/oauth/types.d.ts +9 -0
- package/src/utils/oauth/types.ts +9 -0
- package/src/utils/parse-bind.d.ts +35 -0
- package/src/utils/proxy.ts +35 -6
package/src/auth-storage.ts
CHANGED
|
@@ -237,6 +237,8 @@ export interface CredentialInventoryRecord {
|
|
|
237
237
|
accountId?: string;
|
|
238
238
|
email?: string;
|
|
239
239
|
projectId?: string;
|
|
240
|
+
organizationId?: string;
|
|
241
|
+
organizationName?: string;
|
|
240
242
|
disabled: boolean;
|
|
241
243
|
disabledCause: string | null;
|
|
242
244
|
}
|
|
@@ -696,6 +698,26 @@ export interface CredentialDisabledEvent {
|
|
|
696
698
|
*/
|
|
697
699
|
export type CredentialRankingMode = "balanced" | "earliest-reset";
|
|
698
700
|
|
|
701
|
+
/**
|
|
702
|
+
* One entry of a provider's ordered, persisted soft preference list.
|
|
703
|
+
*
|
|
704
|
+
* Entries are consulted in list order when a credential is selected. Usable
|
|
705
|
+
* entries under their cap are tried first, in list order; the remaining
|
|
706
|
+
* credentials follow in the normal ranking order. An entry whose most-used
|
|
707
|
+
* known usage window has reached `maxUsedFraction` is demoted behind every
|
|
708
|
+
* other credential, so it is used only when nothing else is available. A
|
|
709
|
+
* blocked or missing entry is skipped. Unknown usage never counts as over the
|
|
710
|
+
* cap.
|
|
711
|
+
*
|
|
712
|
+
* Runtime selectors (`--credential`, `--prefer-credential`) and session
|
|
713
|
+
* stickiness take precedence over configured preferences.
|
|
714
|
+
*/
|
|
715
|
+
export interface ConfiguredCredentialPreference {
|
|
716
|
+
selector: AuthCredentialSelector;
|
|
717
|
+
/** Used fraction in (0, 1] at which this entry stops being preferred. Absent means only blocking moves past it. */
|
|
718
|
+
maxUsedFraction?: number;
|
|
719
|
+
}
|
|
720
|
+
|
|
699
721
|
/**
|
|
700
722
|
* Whether credential selection may probe provider usage endpoints.
|
|
701
723
|
*
|
|
@@ -712,6 +734,8 @@ export type AuthStorageOptions = {
|
|
|
712
734
|
usageProviderResolver?: (provider: Provider) => UsageProvider | undefined;
|
|
713
735
|
rankingStrategyResolver?: (provider: Provider) => CredentialRankingStrategy | undefined;
|
|
714
736
|
credentialRankingMode?: CredentialRankingMode;
|
|
737
|
+
/** Persisted ordered soft preferences keyed by provider id. */
|
|
738
|
+
credentialPreferences?: Readonly<Record<string, readonly ConfiguredCredentialPreference[]>>;
|
|
715
739
|
usageFetch?: typeof fetch;
|
|
716
740
|
usageRequestTimeoutMs?: number;
|
|
717
741
|
usageLogger?: UsageLogger;
|
|
@@ -1129,14 +1153,64 @@ export type AuthApiKeyOptions = {
|
|
|
1129
1153
|
/** Prefer one stored OAuth credential while preserving quota-triggered fallback. */
|
|
1130
1154
|
preferredCredentialSelector?: AuthCredentialSelector;
|
|
1131
1155
|
};
|
|
1132
|
-
export type AuthCredentialSelectorKind = "id" | "email" | "account" | "project";
|
|
1156
|
+
export type AuthCredentialSelectorKind = "id" | "email" | "account" | "project" | "org";
|
|
1133
1157
|
|
|
1134
1158
|
export interface AuthCredentialSelector {
|
|
1135
1159
|
kind: AuthCredentialSelectorKind;
|
|
1136
1160
|
value: string;
|
|
1137
1161
|
}
|
|
1138
1162
|
|
|
1139
|
-
|
|
1163
|
+
/**
|
|
1164
|
+
* Whether an OAuth credential belongs to the organization named by an `org:`
|
|
1165
|
+
* selector value: the organization id, or its display name (case-insensitive).
|
|
1166
|
+
*/
|
|
1167
|
+
export function oauthCredentialMatchesOrganization(
|
|
1168
|
+
credential: Pick<OAuthCredentials, "organizationId" | "organizationName">,
|
|
1169
|
+
value: string,
|
|
1170
|
+
): boolean {
|
|
1171
|
+
const wanted = value.trim().toLowerCase();
|
|
1172
|
+
if (wanted.length === 0) return false;
|
|
1173
|
+
return (
|
|
1174
|
+
credential.organizationId?.trim().toLowerCase() === wanted ||
|
|
1175
|
+
credential.organizationName?.trim().toLowerCase() === wanted
|
|
1176
|
+
);
|
|
1177
|
+
}
|
|
1178
|
+
|
|
1179
|
+
/**
|
|
1180
|
+
* The items an `org:` selector designates. An exact organization-id match wins
|
|
1181
|
+
* over display-name matches, because names are not unique: two organizations
|
|
1182
|
+
* can share a name, and a name can equal another organization's id. Callers
|
|
1183
|
+
* must treat more than one result as ambiguous rather than picking one.
|
|
1184
|
+
*/
|
|
1185
|
+
export function selectOrganizationMatches<T>(
|
|
1186
|
+
items: readonly T[],
|
|
1187
|
+
value: string,
|
|
1188
|
+
organizationOf: (item: T) => Pick<OAuthCredentials, "organizationId" | "organizationName"> | undefined,
|
|
1189
|
+
): T[] {
|
|
1190
|
+
const wanted = value.trim().toLowerCase();
|
|
1191
|
+
const matches = items.filter(item => {
|
|
1192
|
+
const organization = organizationOf(item);
|
|
1193
|
+
return organization !== undefined && oauthCredentialMatchesOrganization(organization, wanted);
|
|
1194
|
+
});
|
|
1195
|
+
const exact = matches.filter(item => organizationOf(item)?.organizationId?.trim().toLowerCase() === wanted);
|
|
1196
|
+
return exact.length > 0 ? exact : matches;
|
|
1197
|
+
}
|
|
1198
|
+
|
|
1199
|
+
/**
|
|
1200
|
+
* Human-facing identity of a stored OAuth credential. The organization name is
|
|
1201
|
+
* appended because one account (same email) can hold one credential per
|
|
1202
|
+
* organization.
|
|
1203
|
+
*/
|
|
1204
|
+
export function formatOAuthIdentityLabel(
|
|
1205
|
+
credential: Pick<OAuthCredentials, "email" | "accountId" | "projectId" | "organizationName">,
|
|
1206
|
+
): string | null {
|
|
1207
|
+
const base = credential.email ?? credential.accountId ?? credential.projectId ?? null;
|
|
1208
|
+
const organization = credential.organizationName?.trim();
|
|
1209
|
+
if (!organization) return base;
|
|
1210
|
+
return base ? `${base} (${organization})` : organization;
|
|
1211
|
+
}
|
|
1212
|
+
|
|
1213
|
+
type OAuthResolutionResult = { apiKey: string; credential: OAuthCredential; credentialRowId: number };
|
|
1140
1214
|
|
|
1141
1215
|
/**
|
|
1142
1216
|
* Refreshed OAuth access plus identity metadata returned by
|
|
@@ -1296,6 +1370,8 @@ function oauthNonTokenFieldsEqual(left: OAuthCredential, right: OAuthCredential)
|
|
|
1296
1370
|
left.projectId === right.projectId &&
|
|
1297
1371
|
left.enterpriseUrl === right.enterpriseUrl &&
|
|
1298
1372
|
left.profileArn === right.profileArn &&
|
|
1373
|
+
left.organizationId === right.organizationId &&
|
|
1374
|
+
left.organizationName === right.organizationName &&
|
|
1299
1375
|
left.mcpBinding?.resourceOrigin === right.mcpBinding?.resourceOrigin &&
|
|
1300
1376
|
left.mcpBinding?.tokenEndpoint === right.mcpBinding?.tokenEndpoint
|
|
1301
1377
|
);
|
|
@@ -1345,6 +1421,7 @@ function resolveMemoizedOAuthIdentityKey(provider: string, credential: OAuthCred
|
|
|
1345
1421
|
credential.projectId ?? "",
|
|
1346
1422
|
credential.enterpriseUrl ?? "",
|
|
1347
1423
|
credential.profileArn ?? "",
|
|
1424
|
+
credential.organizationId ?? "",
|
|
1348
1425
|
credential.access,
|
|
1349
1426
|
credential.refresh,
|
|
1350
1427
|
].join("\u0000");
|
|
@@ -1448,6 +1525,15 @@ type ConfigApiKeyOwnerFork = {
|
|
|
1448
1525
|
* Reads from storage on reload(), manages round-robin credential selection,
|
|
1449
1526
|
* usage limit tracking, and OAuth token refresh.
|
|
1450
1527
|
*/
|
|
1528
|
+
/** Tier names that denote a model family rather than a plan or quota tier. */
|
|
1529
|
+
const MODEL_FAMILY_TIERS: ReadonlySet<string> = new Set(["opus", "sonnet", "haiku"]);
|
|
1530
|
+
|
|
1531
|
+
/** A recorded usage-limit backoff; `modelId` scopes it to one model or model family. */
|
|
1532
|
+
interface CredentialBackoffEntry {
|
|
1533
|
+
untilMs: number;
|
|
1534
|
+
modelId?: string;
|
|
1535
|
+
}
|
|
1536
|
+
|
|
1451
1537
|
export class AuthStorage {
|
|
1452
1538
|
static readonly #defaultBackoffMs = 60_000; // Default backoff when no reset time available
|
|
1453
1539
|
|
|
@@ -1472,6 +1558,10 @@ export class AuthStorage {
|
|
|
1472
1558
|
#runtimeCredentialSelectors: Map<string, AuthCredentialSelector> = new Map();
|
|
1473
1559
|
/** Soft runtime credential preference per provider; quota failures may rotate away from it. */
|
|
1474
1560
|
#runtimePreferredCredentialSelectors: Map<string, AuthCredentialSelector> = new Map();
|
|
1561
|
+
/** Persisted ordered soft credential preferences per provider; usage caps and quota failures move past entries. */
|
|
1562
|
+
#configuredCredentialPreferences: Map<string, readonly ConfiguredCredentialPreference[]> = new Map();
|
|
1563
|
+
/** Providers already warned that their preference list names one stored row twice. */
|
|
1564
|
+
#warnedDuplicateCredentialPreferences = new Set<string>();
|
|
1475
1565
|
/** Credential selectors explicitly attached to a credential session scope. */
|
|
1476
1566
|
#sessionCredentialSelectors: Map<string, Map<string, AuthCredentialSelector>> = new Map();
|
|
1477
1567
|
/** Explicit AUTO masks suppress both scoped and process-global selectors for a scope/provider. */
|
|
@@ -1485,7 +1575,7 @@ export class AuthStorage {
|
|
|
1485
1575
|
/** Tracks the last used credential per provider for a session (used for rate-limit switching). */
|
|
1486
1576
|
#sessionLastCredential: Map<string, Map<string, { type: AuthCredential["type"]; index: number }>> = new Map();
|
|
1487
1577
|
/** Maps provider:type -> credentialIndex -> blockedUntilMs for temporary backoff. */
|
|
1488
|
-
#credentialBackoff: Map<string, Map<number,
|
|
1578
|
+
#credentialBackoff: Map<string, Map<number, CredentialBackoffEntry[]>> = new Map();
|
|
1489
1579
|
#usageProviderResolver?: (provider: Provider) => UsageProvider | undefined;
|
|
1490
1580
|
#rankingStrategyResolver?: (provider: Provider) => CredentialRankingStrategy | undefined;
|
|
1491
1581
|
#usageCache: UsageCache;
|
|
@@ -1562,6 +1652,11 @@ export class AuthStorage {
|
|
|
1562
1652
|
: DEFAULT_USAGE_REQUEST_TIMEOUT_MS;
|
|
1563
1653
|
this.#usageFetchLeaseMs = usageTimeoutForLease + USAGE_FETCH_LEASE_GRACE_MS;
|
|
1564
1654
|
this.#credentialRankingMode = options.credentialRankingMode ?? "balanced";
|
|
1655
|
+
for (const [provider, preferences] of Object.entries(options.credentialPreferences ?? {})) {
|
|
1656
|
+
if (preferences.length > 0) {
|
|
1657
|
+
this.#configuredCredentialPreferences.set(resolveOAuthStorageProvider(provider), preferences);
|
|
1658
|
+
}
|
|
1659
|
+
}
|
|
1565
1660
|
this.#refreshOAuthCredentialOverride = options.refreshOAuthCredential;
|
|
1566
1661
|
this.#fetchUsageReportsOverride = options.fetchUsageReports;
|
|
1567
1662
|
this.#fetchUsageReportsForProviderOverride = options.fetchUsageReportsForProvider;
|
|
@@ -1693,10 +1788,19 @@ export class AuthStorage {
|
|
|
1693
1788
|
#hasConfigOverride(provider: string, owner?: object): boolean {
|
|
1694
1789
|
return this.#configOverrideRegistration(provider, owner) !== undefined;
|
|
1695
1790
|
}
|
|
1696
|
-
|
|
1791
|
+
/**
|
|
1792
|
+
* Return provider-specific discovery evidence. `stagedConfigApiKey` lets a read-only
|
|
1793
|
+
* config candidate derive the same fingerprint before its owner-scoped override is published.
|
|
1794
|
+
*/
|
|
1795
|
+
getProviderEvidenceGeneration(
|
|
1796
|
+
provider: string,
|
|
1797
|
+
resolvedApiKey?: string,
|
|
1798
|
+
owner?: object,
|
|
1799
|
+
stagedConfigApiKey?: { apiKey: string },
|
|
1800
|
+
): string {
|
|
1697
1801
|
const storageProvider = resolveOAuthStorageProvider(provider);
|
|
1698
1802
|
provider = storageProvider;
|
|
1699
|
-
const configOverride = this.#configOverrideRegistration(storageProvider, owner);
|
|
1803
|
+
const configOverride = stagedConfigApiKey ?? this.#configOverrideRegistration(storageProvider, owner);
|
|
1700
1804
|
const ownerGeneration = owner ? this.#getOwnerProviderGeneration(owner, storageProvider) : 0;
|
|
1701
1805
|
const ownerFallbackGeneration = owner ? this.#getOwnerFallbackGeneration(owner) : this.#fallbackGeneration;
|
|
1702
1806
|
const generation = owner
|
|
@@ -2087,8 +2191,9 @@ export class AuthStorage {
|
|
|
2087
2191
|
);
|
|
2088
2192
|
}
|
|
2089
2193
|
const allRows = this.#store.listCredentialInventory?.(storageProvider) ?? [];
|
|
2090
|
-
const matchingCredentials = this.#
|
|
2091
|
-
this.#
|
|
2194
|
+
const matchingCredentials = this.#entriesDesignatedBySelector(
|
|
2195
|
+
this.#getStoredCredentials(storageProvider),
|
|
2196
|
+
selector,
|
|
2092
2197
|
);
|
|
2093
2198
|
const matchingIds = new Set(matchingCredentials.map(entry => entry.id));
|
|
2094
2199
|
const matchingRows = allRows.filter(
|
|
@@ -2138,7 +2243,7 @@ export class AuthStorage {
|
|
|
2138
2243
|
"ambiguous",
|
|
2139
2244
|
storageProvider,
|
|
2140
2245
|
selector,
|
|
2141
|
-
`Selector ${this.#formatCredentialSelector(selector)} matches multiple OAuth credentials; choose id:<row-id>`,
|
|
2246
|
+
`Selector ${this.#formatCredentialSelector(selector)} matches multiple OAuth credentials (ids: ${matchingRows.map(row => row.id).join(", ")}); choose one with id:<row-id>`,
|
|
2142
2247
|
matchingRows.map(row => row.id),
|
|
2143
2248
|
);
|
|
2144
2249
|
}
|
|
@@ -2185,10 +2290,9 @@ export class AuthStorage {
|
|
|
2185
2290
|
this.#usageCache.deletePrefix?.("reports:");
|
|
2186
2291
|
for (const [scopeId, selectors] of this.#sessionCredentialSelectors) {
|
|
2187
2292
|
const selector = selectors.get(storageProvider);
|
|
2188
|
-
const selected = selector
|
|
2189
|
-
|
|
2190
|
-
|
|
2191
|
-
if (selected && removed.has(selected.id)) this.clearSessionCredentialSelector(storageProvider, scopeId);
|
|
2293
|
+
const selected = selector ? this.#entriesSelectedBySelector(previousEntries, selector) : [];
|
|
2294
|
+
if (selected.some(entry => removed.has(entry.id)))
|
|
2295
|
+
this.clearSessionCredentialSelector(storageProvider, scopeId);
|
|
2192
2296
|
}
|
|
2193
2297
|
for (const [sessionId, sticky] of this.#sessionLastCredential.get(storageProvider) ?? []) {
|
|
2194
2298
|
if (removed.has(previousEntries[sticky.index]?.id ?? -1))
|
|
@@ -2308,6 +2412,19 @@ export class AuthStorage {
|
|
|
2308
2412
|
return this.#getCredentialSelector(provider, undefined, sessionId) !== undefined;
|
|
2309
2413
|
}
|
|
2310
2414
|
|
|
2415
|
+
/** Whether any runtime/session scope has a credential pin, including an unavailable hard pin. */
|
|
2416
|
+
hasAnyCredentialPin(provider: string): boolean {
|
|
2417
|
+
const storageProvider = resolveOAuthStorageProvider(provider);
|
|
2418
|
+
if (this.#runtimeCredentialSelectors.has(storageProvider)) return true;
|
|
2419
|
+
for (const selectors of this.#sessionCredentialSelectors.values()) {
|
|
2420
|
+
if (selectors.has(storageProvider)) return true;
|
|
2421
|
+
}
|
|
2422
|
+
for (const unavailable of this.#sessionCredentialUnavailable.values()) {
|
|
2423
|
+
if (unavailable.has(storageProvider)) return true;
|
|
2424
|
+
}
|
|
2425
|
+
return false;
|
|
2426
|
+
}
|
|
2427
|
+
|
|
2311
2428
|
/**
|
|
2312
2429
|
* Opaque stored row id of the credential this session is currently using.
|
|
2313
2430
|
*
|
|
@@ -2327,6 +2444,82 @@ export class AuthStorage {
|
|
|
2327
2444
|
return this.#getStoredCredentials(storageProvider)[session.index]?.id;
|
|
2328
2445
|
}
|
|
2329
2446
|
|
|
2447
|
+
/** Return the stored OAuth row pinned by a credential selector without resolving tokens. */
|
|
2448
|
+
getSelectedOAuthCredentialRowId(
|
|
2449
|
+
provider: string,
|
|
2450
|
+
sessionId?: string,
|
|
2451
|
+
options?: Pick<AuthApiKeyOptions, "owner">,
|
|
2452
|
+
): number | undefined {
|
|
2453
|
+
const storageProvider = resolveOAuthStorageProvider(provider);
|
|
2454
|
+
try {
|
|
2455
|
+
const selected = this.#resolveSelectedStoredCredential(storageProvider, options, sessionId);
|
|
2456
|
+
return selected?.credential.type === "oauth" ? selected.id : undefined;
|
|
2457
|
+
} catch {
|
|
2458
|
+
return undefined;
|
|
2459
|
+
}
|
|
2460
|
+
}
|
|
2461
|
+
|
|
2462
|
+
/** Return the usable OAuth row selected by a soft preferred-credential selector. */
|
|
2463
|
+
getPreferredOAuthCredentialRowId(provider: string): number | undefined {
|
|
2464
|
+
const preferredSelector = this.#getPreferredCredentialSelector(provider);
|
|
2465
|
+
if (!preferredSelector) return undefined;
|
|
2466
|
+
const selected = this.#findCredentialBySelector(provider, preferredSelector);
|
|
2467
|
+
if (selected?.credential.type !== "oauth" || !this.isCredentialAvailable(provider, selected.id)) return undefined;
|
|
2468
|
+
return selected.id;
|
|
2469
|
+
}
|
|
2470
|
+
|
|
2471
|
+
/** Return the stored API-key row pinned by a credential selector without resolving values. */
|
|
2472
|
+
getSelectedApiKeyCredentialRowId(
|
|
2473
|
+
provider: string,
|
|
2474
|
+
sessionId?: string,
|
|
2475
|
+
options?: Pick<AuthApiKeyOptions, "owner">,
|
|
2476
|
+
): number | undefined {
|
|
2477
|
+
const storageProvider = resolveOAuthStorageProvider(provider);
|
|
2478
|
+
try {
|
|
2479
|
+
const selected = this.#resolveSelectedStoredCredential(storageProvider, options, sessionId);
|
|
2480
|
+
return selected?.credential.type === "api_key" ? selected.id : undefined;
|
|
2481
|
+
} catch {
|
|
2482
|
+
return undefined;
|
|
2483
|
+
}
|
|
2484
|
+
}
|
|
2485
|
+
|
|
2486
|
+
/**
|
|
2487
|
+
* Preview the stored API-key row request dispatch would try for this session.
|
|
2488
|
+
* Requires a session id so the deterministic session order is used instead
|
|
2489
|
+
* of advancing the provider's round-robin cursor. Does not resolve key values
|
|
2490
|
+
* or mutate session selection.
|
|
2491
|
+
*/
|
|
2492
|
+
getProspectiveApiKeyCredentialRowId(
|
|
2493
|
+
provider: string,
|
|
2494
|
+
sessionId: string | undefined,
|
|
2495
|
+
options?: Pick<AuthApiKeyOptions, "owner">,
|
|
2496
|
+
): number | undefined {
|
|
2497
|
+
if (!sessionId) return undefined;
|
|
2498
|
+
const storageProvider = resolveOAuthStorageProvider(provider);
|
|
2499
|
+
let selected: IndexedStoredCredential | undefined;
|
|
2500
|
+
try {
|
|
2501
|
+
selected = this.#resolveSelectedStoredCredential(storageProvider, options, sessionId);
|
|
2502
|
+
} catch {
|
|
2503
|
+
return undefined;
|
|
2504
|
+
}
|
|
2505
|
+
if (selected?.credential.type === "api_key") {
|
|
2506
|
+
const usable = this.#storedApiKeyUsability(storageProvider, selected.credential.key);
|
|
2507
|
+
return selected.credential.key.startsWith("!") && usable !== true ? undefined : selected.id;
|
|
2508
|
+
}
|
|
2509
|
+
const candidate = this.#selectApiKeyCredential(storageProvider, sessionId);
|
|
2510
|
+
if (!candidate) return undefined;
|
|
2511
|
+
const usable = this.#storedApiKeyUsability(storageProvider, candidate.credential.key);
|
|
2512
|
+
return candidate.credential.key.startsWith("!") && usable !== true ? undefined : candidate.id;
|
|
2513
|
+
}
|
|
2514
|
+
|
|
2515
|
+
/** Cached usability of a stored API key; never resolves command-backed values. */
|
|
2516
|
+
getStoredApiKeyUsability(provider: string, rowId: number): boolean | undefined {
|
|
2517
|
+
const storageProvider = resolveOAuthStorageProvider(provider);
|
|
2518
|
+
const entry = this.#getStoredCredentials(storageProvider).find(candidate => candidate.id === rowId);
|
|
2519
|
+
if (entry?.credential.type !== "api_key") return undefined;
|
|
2520
|
+
return this.#storedApiKeyUsability(storageProvider, entry.credential.key);
|
|
2521
|
+
}
|
|
2522
|
+
|
|
2330
2523
|
/** Whether an enabled stored row is currently available outside quota backoff. */
|
|
2331
2524
|
isCredentialAvailable(provider: string, rowId: number): boolean {
|
|
2332
2525
|
const storageProvider = resolveOAuthStorageProvider(provider);
|
|
@@ -2782,28 +2975,30 @@ export class AuthStorage {
|
|
|
2782
2975
|
return deduped.reverse();
|
|
2783
2976
|
}
|
|
2784
2977
|
|
|
2785
|
-
#pruneDuplicateStoredCredentials(
|
|
2786
|
-
|
|
2787
|
-
|
|
2788
|
-
|
|
2978
|
+
#pruneDuplicateStoredCredentials(
|
|
2979
|
+
provider: string,
|
|
2980
|
+
entries: StoredCredential[],
|
|
2981
|
+
preferredCredentialId?: number,
|
|
2982
|
+
): StoredCredential[] {
|
|
2983
|
+
const selectedByIdentity = new Map<string, number>();
|
|
2789
2984
|
for (let index = entries.length - 1; index >= 0; index -= 1) {
|
|
2790
2985
|
const entry = entries[index];
|
|
2791
|
-
|
|
2792
|
-
|
|
2793
|
-
|
|
2794
|
-
|
|
2986
|
+
if (entry.credential.type !== "oauth") continue;
|
|
2987
|
+
const identityKey = this.#resolveOAuthDedupeIdentityKey(provider, entry.credential);
|
|
2988
|
+
if (!identityKey) continue;
|
|
2989
|
+
if (!selectedByIdentity.has(identityKey) || entry.id === preferredCredentialId) {
|
|
2990
|
+
selectedByIdentity.set(identityKey, entry.id);
|
|
2795
2991
|
}
|
|
2796
|
-
|
|
2797
|
-
|
|
2798
|
-
|
|
2799
|
-
|
|
2800
|
-
|
|
2801
|
-
|
|
2802
|
-
|
|
2803
|
-
|
|
2804
|
-
|
|
2805
|
-
|
|
2806
|
-
kept.push(entry);
|
|
2992
|
+
}
|
|
2993
|
+
|
|
2994
|
+
const selectedIds = new Set(selectedByIdentity.values());
|
|
2995
|
+
const kept: StoredCredential[] = [];
|
|
2996
|
+
const removed: StoredCredential[] = [];
|
|
2997
|
+
for (const entry of entries) {
|
|
2998
|
+
const identityKey =
|
|
2999
|
+
entry.credential.type === "oauth" ? this.#resolveOAuthDedupeIdentityKey(provider, entry.credential) : null;
|
|
3000
|
+
if (identityKey && !selectedIds.has(entry.id)) removed.push(entry);
|
|
3001
|
+
else kept.push(entry);
|
|
2807
3002
|
}
|
|
2808
3003
|
if (removed.length > 0) {
|
|
2809
3004
|
for (const entry of removed) {
|
|
@@ -2812,7 +3007,7 @@ export class AuthStorage {
|
|
|
2812
3007
|
this.#clearSelectorsForRemovedCredential(provider, new Set(removed.map(entry => entry.id)), entries);
|
|
2813
3008
|
this.#resetProviderAssignments(provider);
|
|
2814
3009
|
}
|
|
2815
|
-
return kept
|
|
3010
|
+
return kept;
|
|
2816
3011
|
}
|
|
2817
3012
|
|
|
2818
3013
|
/** Returns all credentials for a provider as an array */
|
|
@@ -2864,32 +3059,60 @@ export class AuthStorage {
|
|
|
2864
3059
|
return order;
|
|
2865
3060
|
}
|
|
2866
3061
|
|
|
2867
|
-
/**
|
|
2868
|
-
|
|
3062
|
+
/**
|
|
3063
|
+
* Returns the block expiry for a credential, cleaning up expired entries.
|
|
3064
|
+
* A block recorded for one model only applies to that model; a block without a
|
|
3065
|
+
* model applies to the whole credential. `modelId` is the requested model, so
|
|
3066
|
+
* omitting it asks only about credential-wide blocks.
|
|
3067
|
+
*/
|
|
3068
|
+
#getCredentialBlockedUntil(providerKey: string, credentialIndex: number, modelId?: string): number | undefined {
|
|
2869
3069
|
const backoffMap = this.#credentialBackoff.get(providerKey);
|
|
2870
3070
|
if (!backoffMap) return undefined;
|
|
2871
|
-
const
|
|
2872
|
-
if (!
|
|
2873
|
-
|
|
2874
|
-
|
|
2875
|
-
|
|
2876
|
-
|
|
2877
|
-
|
|
2878
|
-
|
|
3071
|
+
const entries = backoffMap.get(credentialIndex);
|
|
3072
|
+
if (!entries || entries.length === 0) return undefined;
|
|
3073
|
+
const now = Date.now();
|
|
3074
|
+
const live = entries.filter(entry => entry.untilMs > now);
|
|
3075
|
+
if (live.length !== entries.length) {
|
|
3076
|
+
if (live.length > 0) backoffMap.set(credentialIndex, live);
|
|
3077
|
+
else backoffMap.delete(credentialIndex);
|
|
3078
|
+
if (backoffMap.size === 0) this.#credentialBackoff.delete(providerKey);
|
|
3079
|
+
}
|
|
3080
|
+
let blockedUntil: number | undefined;
|
|
3081
|
+
const wanted = modelId?.trim().toLowerCase();
|
|
3082
|
+
// A recorded scope may name a model or a model-family tier, so match a tier
|
|
3083
|
+
// against the requested model id the same way limit scoping does.
|
|
3084
|
+
const entryApplies = (entryModel: string): boolean => {
|
|
3085
|
+
if (wanted === undefined) return false;
|
|
3086
|
+
const scope = entryModel.toLowerCase();
|
|
3087
|
+
if (scope === wanted) return true;
|
|
3088
|
+
return MODEL_FAMILY_TIERS.has(scope) && wanted.includes(scope);
|
|
3089
|
+
};
|
|
3090
|
+
for (const entry of live) {
|
|
3091
|
+
if (entry.modelId !== undefined && !entryApplies(entry.modelId)) continue;
|
|
3092
|
+
if (blockedUntil === undefined || entry.untilMs > blockedUntil) blockedUntil = entry.untilMs;
|
|
2879
3093
|
}
|
|
2880
3094
|
return blockedUntil;
|
|
2881
3095
|
}
|
|
2882
3096
|
|
|
2883
3097
|
/** Checks if a credential is temporarily blocked due to usage limits. */
|
|
2884
|
-
#isCredentialBlocked(providerKey: string, credentialIndex: number): boolean {
|
|
2885
|
-
return this.#getCredentialBlockedUntil(providerKey, credentialIndex) !== undefined;
|
|
3098
|
+
#isCredentialBlocked(providerKey: string, credentialIndex: number, modelId?: string): boolean {
|
|
3099
|
+
return this.#getCredentialBlockedUntil(providerKey, credentialIndex, modelId) !== undefined;
|
|
2886
3100
|
}
|
|
2887
3101
|
|
|
2888
|
-
/** Marks a credential as blocked until the specified time. */
|
|
2889
|
-
#markCredentialBlocked(
|
|
2890
|
-
|
|
2891
|
-
|
|
2892
|
-
|
|
3102
|
+
/** Marks a credential as blocked until the specified time, optionally for one model. */
|
|
3103
|
+
#markCredentialBlocked(
|
|
3104
|
+
providerKey: string,
|
|
3105
|
+
credentialIndex: number,
|
|
3106
|
+
blockedUntilMs: number,
|
|
3107
|
+
modelId?: string,
|
|
3108
|
+
): void {
|
|
3109
|
+
const scoped = modelId?.trim() || undefined;
|
|
3110
|
+
const backoffMap = this.#credentialBackoff.get(providerKey) ?? new Map<number, CredentialBackoffEntry[]>();
|
|
3111
|
+
const entries = backoffMap.get(credentialIndex) ?? [];
|
|
3112
|
+
const existing = entries.find(entry => entry.modelId === scoped);
|
|
3113
|
+
if (existing) existing.untilMs = Math.max(existing.untilMs, blockedUntilMs);
|
|
3114
|
+
else entries.push({ untilMs: blockedUntilMs, modelId: scoped });
|
|
3115
|
+
backoffMap.set(credentialIndex, entries);
|
|
2893
3116
|
this.#credentialBackoff.set(providerKey, backoffMap);
|
|
2894
3117
|
}
|
|
2895
3118
|
|
|
@@ -2949,16 +3172,60 @@ export class AuthStorage {
|
|
|
2949
3172
|
return entry.credential.type === "oauth" && entry.credential.accountId === selector.value;
|
|
2950
3173
|
case "project":
|
|
2951
3174
|
return entry.credential.type === "oauth" && entry.credential.projectId === selector.value;
|
|
3175
|
+
case "org":
|
|
3176
|
+
return (
|
|
3177
|
+
entry.credential.type === "oauth" && oauthCredentialMatchesOrganization(entry.credential, selector.value)
|
|
3178
|
+
);
|
|
2952
3179
|
}
|
|
2953
3180
|
}
|
|
2954
3181
|
|
|
2955
3182
|
#findCredentialBySelector(provider: string, selector: AuthCredentialSelector): IndexedStoredCredential | undefined {
|
|
2956
3183
|
const stored = this.#getStoredCredentials(provider);
|
|
2957
|
-
|
|
2958
|
-
|
|
2959
|
-
|
|
3184
|
+
const designated = this.#entriesDesignatedBySelector(stored, selector);
|
|
3185
|
+
// Organization names are not unique, so an organization selector naming several rows is rejected rather than
|
|
3186
|
+
// resolved to the first. Email/account/project selectors keep resolving to the first row: pins persisted in
|
|
3187
|
+
// config and session history use those kinds, and they are not organization-aware.
|
|
3188
|
+
if (selector.kind === "org" && designated.length > 1) {
|
|
3189
|
+
const ids = designated.map(entry => entry.id);
|
|
3190
|
+
throw new OAuthCredentialSelectorError(
|
|
3191
|
+
"ambiguous",
|
|
3192
|
+
resolveOAuthStorageProvider(provider),
|
|
3193
|
+
selector,
|
|
3194
|
+
`Selector ${this.#formatCredentialSelector(selector)} matches multiple stored credentials for ${provider} (ids: ${ids.join(", ")}); choose one with id:<row-id>`,
|
|
3195
|
+
ids,
|
|
3196
|
+
);
|
|
2960
3197
|
}
|
|
2961
|
-
|
|
3198
|
+
const [entry] = designated;
|
|
3199
|
+
return entry ? { ...entry, index: stored.indexOf(entry) } : undefined;
|
|
3200
|
+
}
|
|
3201
|
+
|
|
3202
|
+
/**
|
|
3203
|
+
* Rows whose removal invalidates a selector, mirroring {@link AuthStorage.#findCredentialBySelector}: the row an
|
|
3204
|
+
* email/account/project/id selector resolves to (the first match), or every row an `org:` selector designates,
|
|
3205
|
+
* because removing one of several designated organizations would otherwise retarget the selector to another.
|
|
3206
|
+
*/
|
|
3207
|
+
#entriesSelectedBySelector<T extends StoredCredential>(
|
|
3208
|
+
entries: readonly T[],
|
|
3209
|
+
selector: AuthCredentialSelector,
|
|
3210
|
+
): T[] {
|
|
3211
|
+
const designated = this.#entriesDesignatedBySelector(entries, selector);
|
|
3212
|
+
return selector.kind === "org" ? designated : designated.slice(0, 1);
|
|
3213
|
+
}
|
|
3214
|
+
|
|
3215
|
+
/**
|
|
3216
|
+
* Every stored row a selector designates. An `org:` value prefers an exact organization-id match over
|
|
3217
|
+
* display-name matches; more than one designated row means the selector is ambiguous.
|
|
3218
|
+
*/
|
|
3219
|
+
#entriesDesignatedBySelector<T extends StoredCredential>(
|
|
3220
|
+
entries: readonly T[],
|
|
3221
|
+
selector: AuthCredentialSelector,
|
|
3222
|
+
): T[] {
|
|
3223
|
+
if (selector.kind === "org") {
|
|
3224
|
+
return selectOrganizationMatches(entries, selector.value, entry =>
|
|
3225
|
+
entry.credential.type === "oauth" ? entry.credential : undefined,
|
|
3226
|
+
);
|
|
3227
|
+
}
|
|
3228
|
+
return entries.filter(entry => this.#credentialMatchesSelector(entry, selector));
|
|
2962
3229
|
}
|
|
2963
3230
|
|
|
2964
3231
|
#credentialMatchesInventorySelector(row: CredentialInventoryRecord, selector: AuthCredentialSelector): boolean {
|
|
@@ -2967,10 +3234,13 @@ export class AuthStorage {
|
|
|
2967
3234
|
case "id":
|
|
2968
3235
|
return String(row.id) === selector.value;
|
|
2969
3236
|
case "email":
|
|
2970
|
-
return row.identityLabel?.toLowerCase() === selector.value.toLowerCase();
|
|
3237
|
+
return (row.email ?? row.identityLabel)?.toLowerCase() === selector.value.toLowerCase();
|
|
2971
3238
|
case "account":
|
|
3239
|
+
return (row.accountId ?? row.identityLabel) === selector.value;
|
|
2972
3240
|
case "project":
|
|
2973
|
-
return row.identityLabel === selector.value;
|
|
3241
|
+
return (row.projectId ?? row.identityLabel) === selector.value;
|
|
3242
|
+
case "org":
|
|
3243
|
+
return oauthCredentialMatchesOrganization(row, selector.value);
|
|
2974
3244
|
}
|
|
2975
3245
|
}
|
|
2976
3246
|
#getCredentialSelector(
|
|
@@ -2995,18 +3265,152 @@ export class AuthStorage {
|
|
|
2995
3265
|
);
|
|
2996
3266
|
}
|
|
2997
3267
|
|
|
3268
|
+
/**
|
|
3269
|
+
* Providers whose configured preference list names one stored credential row through more than one entry, for
|
|
3270
|
+
* example `email:` and `account:` selectors for the same login. Startup rejects such a list.
|
|
3271
|
+
*/
|
|
3272
|
+
findDuplicateCredentialPreferences(): Array<{ provider: string; credentialId: number }> {
|
|
3273
|
+
const duplicates: Array<{ provider: string; credentialId: number }> = [];
|
|
3274
|
+
for (const [provider, preferences] of this.#configuredCredentialPreferences) {
|
|
3275
|
+
const credentialId = this.#duplicateConfiguredPreferenceRow(provider, preferences);
|
|
3276
|
+
if (credentialId !== undefined) duplicates.push({ provider, credentialId });
|
|
3277
|
+
}
|
|
3278
|
+
return duplicates;
|
|
3279
|
+
}
|
|
3280
|
+
|
|
3281
|
+
/** Id of the first stored row that two entries of `preferences` both designate; undefined when every entry differs. */
|
|
3282
|
+
#duplicateConfiguredPreferenceRow(
|
|
3283
|
+
provider: string,
|
|
3284
|
+
preferences: readonly ConfiguredCredentialPreference[],
|
|
3285
|
+
): number | undefined {
|
|
3286
|
+
const designatedIds = new Set<number>();
|
|
3287
|
+
for (const { selector } of preferences) {
|
|
3288
|
+
const credentialId = this.#findCredentialBySelector(provider, selector)?.id;
|
|
3289
|
+
if (credentialId === undefined) continue;
|
|
3290
|
+
if (designatedIds.has(credentialId)) return credentialId;
|
|
3291
|
+
designatedIds.add(credentialId);
|
|
3292
|
+
}
|
|
3293
|
+
return undefined;
|
|
3294
|
+
}
|
|
3295
|
+
|
|
3296
|
+
/**
|
|
3297
|
+
* Reorder `candidates` by the provider's configured preference list: usable entries under their cap first, in list
|
|
3298
|
+
* order; then the remaining candidates in their ranked order; then entries at or over their cap, in list order.
|
|
3299
|
+
* Missing and blocked entries are skipped and keep their ranked position. A list that names one stored row twice
|
|
3300
|
+
* (startup rejects it, but a later login can make two entries resolve to one row) is ignored as a whole rather than
|
|
3301
|
+
* applying one entry's position and cap and dropping the other's.
|
|
3302
|
+
*/
|
|
3303
|
+
async #applyConfiguredCredentialPreferences(
|
|
3304
|
+
provider: string,
|
|
3305
|
+
providerKey: string,
|
|
3306
|
+
candidates: Array<{
|
|
3307
|
+
selection: OAuthCredentialSelection;
|
|
3308
|
+
usage: UsageReport | null;
|
|
3309
|
+
usageChecked: boolean;
|
|
3310
|
+
}>,
|
|
3311
|
+
strategy: CredentialRankingStrategy | undefined,
|
|
3312
|
+
options?: AuthApiKeyOptions,
|
|
3313
|
+
): Promise<void> {
|
|
3314
|
+
const preferences = this.#configuredCredentialPreferences.get(resolveOAuthStorageProvider(provider));
|
|
3315
|
+
if (!preferences) return;
|
|
3316
|
+
const duplicateCredentialId = this.#duplicateConfiguredPreferenceRow(provider, preferences);
|
|
3317
|
+
if (duplicateCredentialId !== undefined) {
|
|
3318
|
+
const storageProvider = resolveOAuthStorageProvider(provider);
|
|
3319
|
+
if (!this.#warnedDuplicateCredentialPreferences.has(storageProvider)) {
|
|
3320
|
+
this.#warnedDuplicateCredentialPreferences.add(storageProvider);
|
|
3321
|
+
logger.warn("Configured credential preferences name one account twice; using normal ranking", {
|
|
3322
|
+
provider: storageProvider,
|
|
3323
|
+
credentialId: duplicateCredentialId,
|
|
3324
|
+
});
|
|
3325
|
+
}
|
|
3326
|
+
return;
|
|
3327
|
+
}
|
|
3328
|
+
type Candidate = (typeof candidates)[number];
|
|
3329
|
+
const matched: Array<{ candidate: Candidate; maxUsedFraction: number | undefined }> = [];
|
|
3330
|
+
const claimed = new Set<Candidate>();
|
|
3331
|
+
for (const preference of preferences) {
|
|
3332
|
+
const position = this.#findUsableOAuthCandidate(provider, providerKey, candidates, preference.selector);
|
|
3333
|
+
if (position < 0) continue;
|
|
3334
|
+
const candidate = candidates[position]!;
|
|
3335
|
+
claimed.add(candidate);
|
|
3336
|
+
matched.push({ candidate, maxUsedFraction: preference.maxUsedFraction });
|
|
3337
|
+
}
|
|
3338
|
+
if (matched.length === 0) return;
|
|
3339
|
+
// Ranking already fetched usage for strategy-backed providers; fetch only what a cap still needs.
|
|
3340
|
+
await Promise.all(
|
|
3341
|
+
matched.map(async ({ candidate, maxUsedFraction }) => {
|
|
3342
|
+
if (maxUsedFraction === undefined || candidate.usageChecked) return;
|
|
3343
|
+
candidate.usage = await this.#getUsageReport(provider, candidate.selection.credential, {
|
|
3344
|
+
baseUrl: options?.baseUrl,
|
|
3345
|
+
timeoutMs: this.#usageRequestTimeoutMs,
|
|
3346
|
+
signal: options?.signal,
|
|
3347
|
+
});
|
|
3348
|
+
candidate.usageChecked = true;
|
|
3349
|
+
}),
|
|
3350
|
+
);
|
|
3351
|
+
const preferred: Candidate[] = [];
|
|
3352
|
+
const capped: Candidate[] = [];
|
|
3353
|
+
for (const { candidate, maxUsedFraction } of matched) {
|
|
3354
|
+
const usedFraction =
|
|
3355
|
+
maxUsedFraction === undefined ? undefined : this.#maxKnownUsedFraction(candidate.usage, strategy);
|
|
3356
|
+
if (maxUsedFraction !== undefined && usedFraction !== undefined && usedFraction >= maxUsedFraction) {
|
|
3357
|
+
capped.push(candidate);
|
|
3358
|
+
} else {
|
|
3359
|
+
preferred.push(candidate);
|
|
3360
|
+
}
|
|
3361
|
+
}
|
|
3362
|
+
const rest = candidates.filter(candidate => !claimed.has(candidate));
|
|
3363
|
+
candidates.splice(0, candidates.length, ...preferred, ...rest, ...capped);
|
|
3364
|
+
}
|
|
3365
|
+
|
|
3366
|
+
/**
|
|
3367
|
+
* Position in `candidates` of the OAuth row `selector` designates, or -1 when it is missing, not OAuth, or blocked.
|
|
3368
|
+
* Candidates are matched by stored row id: `#findCredentialBySelector` indexes the full provider list (API-key rows
|
|
3369
|
+
* included) while candidate selections and the OAuth backoff map use OAuth-only indices.
|
|
3370
|
+
*/
|
|
3371
|
+
#findUsableOAuthCandidate(
|
|
3372
|
+
provider: string,
|
|
3373
|
+
providerKey: string,
|
|
3374
|
+
candidates: ReadonlyArray<{ selection: OAuthCredentialSelection }>,
|
|
3375
|
+
selector: AuthCredentialSelector,
|
|
3376
|
+
): number {
|
|
3377
|
+
const designated = this.#findCredentialBySelector(provider, selector);
|
|
3378
|
+
if (designated?.credential.type !== "oauth") return -1;
|
|
3379
|
+
const position = candidates.findIndex(candidate => candidate.selection.id === designated.id);
|
|
3380
|
+
if (position < 0) return -1;
|
|
3381
|
+
return this.#isCredentialBlocked(providerKey, candidates[position]!.selection.index) ? -1 : position;
|
|
3382
|
+
}
|
|
3383
|
+
|
|
3384
|
+
/** Highest reported used fraction across known usage windows; undefined when no window reports one. */
|
|
3385
|
+
#maxKnownUsedFraction(
|
|
3386
|
+
usage: UsageReport | null,
|
|
3387
|
+
strategy: CredentialRankingStrategy | undefined,
|
|
3388
|
+
): number | undefined {
|
|
3389
|
+
if (!usage) return undefined;
|
|
3390
|
+
const limits = strategy ? Object.values(strategy.findWindowLimits(usage)) : usage.limits;
|
|
3391
|
+
let maxUsed: number | undefined;
|
|
3392
|
+
for (const limit of limits) {
|
|
3393
|
+
const usedFraction = limit?.amount.usedFraction;
|
|
3394
|
+
if (typeof usedFraction !== "number" || !Number.isFinite(usedFraction)) continue;
|
|
3395
|
+
maxUsed = maxUsed === undefined ? usedFraction : Math.max(maxUsed, usedFraction);
|
|
3396
|
+
}
|
|
3397
|
+
return maxUsed;
|
|
3398
|
+
}
|
|
3399
|
+
|
|
2998
3400
|
#assertCredentialSelectorUsable(provider: string, selector: AuthCredentialSelector, owner?: object): void {
|
|
2999
3401
|
if (this.#runtimeOverrides.has(provider)) {
|
|
3000
3402
|
throw new Error(
|
|
3001
3403
|
`Credential selector ${this.#formatCredentialSelector(selector)} cannot be used for ${provider} while a runtime API key override is active`,
|
|
3002
3404
|
);
|
|
3003
3405
|
}
|
|
3004
|
-
|
|
3406
|
+
const selected = this.#findCredentialBySelector(provider, selector);
|
|
3407
|
+
const configOverride = this.#configOverrideRegistration(provider, owner);
|
|
3408
|
+
if (configOverride && !(configOverride.envSourced && selected?.credential.type === "api_key")) {
|
|
3005
3409
|
throw new Error(
|
|
3006
3410
|
`Credential selector ${this.#formatCredentialSelector(selector)} cannot be used for ${provider} while a config API key override is active`,
|
|
3007
3411
|
);
|
|
3008
3412
|
}
|
|
3009
|
-
if (!
|
|
3413
|
+
if (!selected) {
|
|
3010
3414
|
throw new Error(`No credential found for ${provider} matching ${this.#formatCredentialSelector(selector)}`);
|
|
3011
3415
|
}
|
|
3012
3416
|
}
|
|
@@ -3160,7 +3564,7 @@ export class AuthStorage {
|
|
|
3160
3564
|
const updated = [...entries];
|
|
3161
3565
|
updated[index] = { id: target.id, credential };
|
|
3162
3566
|
const configurationGeneration = this.#getProviderConfigurationGeneration(provider);
|
|
3163
|
-
this.#setStoredCredentials(provider, updated);
|
|
3567
|
+
this.#setStoredCredentials(provider, this.#pruneDuplicateStoredCredentials(provider, updated, target.id));
|
|
3164
3568
|
// Callers use the refresh generation to discount configuration bumps caused by a refresh, so it only
|
|
3165
3569
|
// advances when this refresh bumped configuration; token rotation for a known account does not.
|
|
3166
3570
|
if (
|
|
@@ -3279,8 +3683,8 @@ export class AuthStorage {
|
|
|
3279
3683
|
for (const [scopeId, selectors] of this.#sessionCredentialSelectors) {
|
|
3280
3684
|
const selector = selectors.get(storageProvider);
|
|
3281
3685
|
if (!selector) continue;
|
|
3282
|
-
const selected =
|
|
3283
|
-
if (selected
|
|
3686
|
+
const selected = this.#entriesSelectedBySelector(previousEntries, selector);
|
|
3687
|
+
if (selected.some(entry => removedIds.has(entry.id))) {
|
|
3284
3688
|
this.markSessionCredentialUnavailable(scopeId, storageProvider, selector);
|
|
3285
3689
|
}
|
|
3286
3690
|
}
|
|
@@ -3510,16 +3914,12 @@ export class AuthStorage {
|
|
|
3510
3914
|
getEffectiveCredentialType(
|
|
3511
3915
|
provider: string,
|
|
3512
3916
|
sessionId?: string,
|
|
3513
|
-
options?: Pick<AuthApiKeyOptions, "owner">,
|
|
3917
|
+
options?: Pick<AuthApiKeyOptions, "owner" | "credentialSelector">,
|
|
3514
3918
|
): AuthCredential["type"] | undefined {
|
|
3515
3919
|
const storageProvider = resolveOAuthStorageProvider(provider);
|
|
3516
3920
|
let selected: ({ index: number } & StoredCredential) | undefined;
|
|
3517
3921
|
try {
|
|
3518
|
-
selected = this.#resolveSelectedStoredCredential(
|
|
3519
|
-
storageProvider,
|
|
3520
|
-
options?.owner ? { owner: options.owner } : undefined,
|
|
3521
|
-
sessionId,
|
|
3522
|
-
);
|
|
3922
|
+
selected = this.#resolveSelectedStoredCredential(storageProvider, options, sessionId);
|
|
3523
3923
|
} catch {
|
|
3524
3924
|
return undefined;
|
|
3525
3925
|
}
|
|
@@ -3541,17 +3941,14 @@ export class AuthStorage {
|
|
|
3541
3941
|
return undefined;
|
|
3542
3942
|
}
|
|
3543
3943
|
|
|
3544
|
-
/**
|
|
3545
|
-
|
|
3546
|
-
|
|
3547
|
-
|
|
3944
|
+
/** Check whether configured auth is usable without resolving credentials or refreshing OAuth. */
|
|
3945
|
+
hasUsableAuth(
|
|
3946
|
+
provider: string,
|
|
3947
|
+
options?: Pick<AuthApiKeyOptions, "owner" | "credentialSelector"> & { sessionId?: string },
|
|
3948
|
+
): boolean {
|
|
3548
3949
|
const storageProvider = resolveOAuthStorageProvider(provider);
|
|
3549
3950
|
try {
|
|
3550
|
-
const selectedCredential = this.#resolveSelectedStoredCredential(
|
|
3551
|
-
storageProvider,
|
|
3552
|
-
options?.owner ? { owner: options.owner } : undefined,
|
|
3553
|
-
undefined,
|
|
3554
|
-
);
|
|
3951
|
+
const selectedCredential = this.#resolveSelectedStoredCredential(storageProvider, options, options?.sessionId);
|
|
3555
3952
|
if (this.hasRuntimeApiKey(storageProvider)) return true;
|
|
3556
3953
|
if (this.#hasConfigOverride(storageProvider, options?.owner)) return true;
|
|
3557
3954
|
if (selectedCredential) {
|
|
@@ -4133,6 +4530,8 @@ export class AuthStorage {
|
|
|
4133
4530
|
email: credential.email,
|
|
4134
4531
|
enterpriseUrl: credential.enterpriseUrl,
|
|
4135
4532
|
profileArn: credential.profileArn,
|
|
4533
|
+
organizationId: credential.organizationId,
|
|
4534
|
+
organizationName: credential.organizationName,
|
|
4136
4535
|
mcpBinding: credential.mcpBinding,
|
|
4137
4536
|
};
|
|
4138
4537
|
}
|
|
@@ -4149,6 +4548,9 @@ export class AuthStorage {
|
|
|
4149
4548
|
if (enterpriseUrl) parts.push(`enterprise:${enterpriseUrl}`);
|
|
4150
4549
|
const profileArn = credential.profileArn?.trim();
|
|
4151
4550
|
if (profileArn) parts.push(`profile:${profileArn}`);
|
|
4551
|
+
// One account can hold a credential per organization, each with its own limits.
|
|
4552
|
+
const organizationId = credential.organizationId?.trim();
|
|
4553
|
+
if (organizationId) parts.push(`org:${organizationId}`);
|
|
4152
4554
|
// Only fall back to a secret-derived key when stable credential identity is unavailable.
|
|
4153
4555
|
// Including the token hash when accountId/email/profileArn are present causes cache misses on
|
|
4154
4556
|
// every OAuth refresh — usage data is per-account, not per-token.
|
|
@@ -4211,6 +4613,8 @@ export class AuthStorage {
|
|
|
4211
4613
|
email: credential.email,
|
|
4212
4614
|
enterpriseUrl: credential.enterpriseUrl,
|
|
4213
4615
|
profileArn: credential.profileArn,
|
|
4616
|
+
organizationId: credential.organizationId,
|
|
4617
|
+
organizationName: credential.organizationName,
|
|
4214
4618
|
mcpBinding: credential.mcpBinding,
|
|
4215
4619
|
};
|
|
4216
4620
|
}
|
|
@@ -4226,6 +4630,8 @@ export class AuthStorage {
|
|
|
4226
4630
|
email: refreshed.email ?? credential.email,
|
|
4227
4631
|
enterpriseUrl: refreshed.enterpriseUrl ?? credential.enterpriseUrl,
|
|
4228
4632
|
profileArn: refreshed.profileArn ?? credential.profileArn,
|
|
4633
|
+
organizationId: refreshed.organizationId ?? credential.organizationId,
|
|
4634
|
+
organizationName: refreshed.organizationName ?? credential.organizationName,
|
|
4229
4635
|
mcpBinding: credential.mcpBinding,
|
|
4230
4636
|
};
|
|
4231
4637
|
}
|
|
@@ -4245,7 +4651,8 @@ export class AuthStorage {
|
|
|
4245
4651
|
entry.credential.accountId === previous.accountId &&
|
|
4246
4652
|
entry.credential.email === previous.email &&
|
|
4247
4653
|
entry.credential.projectId === previous.projectId &&
|
|
4248
|
-
entry.credential.profileArn === previous.profileArn
|
|
4654
|
+
entry.credential.profileArn === previous.profileArn &&
|
|
4655
|
+
entry.credential.organizationId === previous.organizationId
|
|
4249
4656
|
);
|
|
4250
4657
|
});
|
|
4251
4658
|
return match?.id;
|
|
@@ -4266,7 +4673,8 @@ export class AuthStorage {
|
|
|
4266
4673
|
entry.credential.accountId === previous.accountId &&
|
|
4267
4674
|
entry.credential.email === previous.email &&
|
|
4268
4675
|
entry.credential.projectId === previous.projectId &&
|
|
4269
|
-
entry.credential.profileArn === previous.profileArn
|
|
4676
|
+
entry.credential.profileArn === previous.profileArn &&
|
|
4677
|
+
entry.credential.organizationId === previous.organizationId
|
|
4270
4678
|
);
|
|
4271
4679
|
});
|
|
4272
4680
|
if (index === -1) return;
|
|
@@ -4285,6 +4693,8 @@ export class AuthStorage {
|
|
|
4285
4693
|
email: next.email,
|
|
4286
4694
|
enterpriseUrl: next.enterpriseUrl,
|
|
4287
4695
|
profileArn: next.profileArn ?? existing.profileArn,
|
|
4696
|
+
organizationId: next.organizationId ?? existing.organizationId,
|
|
4697
|
+
organizationName: next.organizationName ?? existing.organizationName,
|
|
4288
4698
|
mcpBinding: next.mcpBinding ?? existing.mcpBinding,
|
|
4289
4699
|
},
|
|
4290
4700
|
persist,
|
|
@@ -4650,6 +5060,9 @@ export class AuthStorage {
|
|
|
4650
5060
|
const email = this.#getUsageReportMetadataValue(report, "email");
|
|
4651
5061
|
if (email) identifiers.push(`email:${email.toLowerCase()}`);
|
|
4652
5062
|
if (report.provider === "openai-codex" || report.provider === "anthropic") {
|
|
5063
|
+
// One Anthropic account can sign in to several organizations; each has its own limits.
|
|
5064
|
+
const orgId = report.provider === "anthropic" ? this.#getUsageReportMetadataValue(report, "orgId") : undefined;
|
|
5065
|
+
if (orgId) return identifiers.map(identifier => `${report.provider}:${identifier}|org:${orgId}`.toLowerCase());
|
|
4653
5066
|
return identifiers.map(identifier => `${report.provider}:${identifier.toLowerCase()}`);
|
|
4654
5067
|
}
|
|
4655
5068
|
const accountId = this.#getUsageReportMetadataValue(report, "accountId");
|
|
@@ -4749,15 +5162,40 @@ export class AuthStorage {
|
|
|
4749
5162
|
}
|
|
4750
5163
|
|
|
4751
5164
|
/** Returns true if usage indicates rate limit has been reached. */
|
|
4752
|
-
|
|
4753
|
-
|
|
5165
|
+
/**
|
|
5166
|
+
* True when an exhausted limit applies to `modelId`. A limit scoped to one
|
|
5167
|
+
* model (or one tier's model entry) does not exhaust the credential for other
|
|
5168
|
+
* models; a limit with no model scope and not marked shared-blocking applies to
|
|
5169
|
+
* every model.
|
|
5170
|
+
*/
|
|
5171
|
+
#isUsageLimitReached(report: UsageReport, modelId?: string): boolean {
|
|
5172
|
+
return report.limits.some(
|
|
5173
|
+
limit => this.#limitAppliesToModel(limit, modelId) && this.#isUsageLimitExhausted(limit),
|
|
5174
|
+
);
|
|
5175
|
+
}
|
|
5176
|
+
|
|
5177
|
+
/**
|
|
5178
|
+
* Whether a usage limit constrains the requested model. Only model-family
|
|
5179
|
+
* tiers are treated as model scoping: `scope.tier` also carries plans
|
|
5180
|
+
* (Codex/Copilot) and quota tiers (Gemini), which apply to every model.
|
|
5181
|
+
*/
|
|
5182
|
+
#limitAppliesToModel(limit: UsageLimit, modelId?: string): boolean {
|
|
5183
|
+
const wanted = modelId?.trim();
|
|
5184
|
+
// Without a requested model, every limit applies: callers that do not know
|
|
5185
|
+
// the model keep today's conservative "any exhausted limit blocks".
|
|
5186
|
+
if (wanted === undefined) return true;
|
|
5187
|
+
const scopedModel = limit.scope?.modelId?.trim();
|
|
5188
|
+
if (scopedModel) return wanted === scopedModel;
|
|
5189
|
+
const tier = limit.scope?.tier?.trim().toLowerCase();
|
|
5190
|
+
if (tier && MODEL_FAMILY_TIERS.has(tier)) return wanted.toLowerCase().includes(tier);
|
|
5191
|
+
return true;
|
|
4754
5192
|
}
|
|
4755
5193
|
|
|
4756
5194
|
/** Extracts the earliest reset timestamp from exhausted windows (in ms). */
|
|
4757
|
-
#getUsageResetAtMs(report: UsageReport, nowMs: number): number | undefined {
|
|
5195
|
+
#getUsageResetAtMs(report: UsageReport, nowMs: number, modelId?: string): number | undefined {
|
|
4758
5196
|
const candidates: number[] = [];
|
|
4759
5197
|
for (const limit of report.limits) {
|
|
4760
|
-
if (!this.#isUsageLimitExhausted(limit)) continue;
|
|
5198
|
+
if (!this.#limitAppliesToModel(limit, modelId) || !this.#isUsageLimitExhausted(limit)) continue;
|
|
4761
5199
|
const window = limit.window;
|
|
4762
5200
|
if (window?.resetsAt && window.resetsAt > nowMs) {
|
|
4763
5201
|
candidates.push(window.resetsAt);
|
|
@@ -4767,6 +5205,33 @@ export class AuthStorage {
|
|
|
4767
5205
|
return Math.min(...candidates);
|
|
4768
5206
|
}
|
|
4769
5207
|
|
|
5208
|
+
/**
|
|
5209
|
+
* Backoff targets implied by an exhausted usage report: a credential-wide
|
|
5210
|
+
* until for limits that are not model-scoped, plus one entry per exhausted
|
|
5211
|
+
* model-scoped limit. A model-scoped limit must not take the whole credential
|
|
5212
|
+
* out of rotation for other models.
|
|
5213
|
+
*/
|
|
5214
|
+
#exhaustedLimitBlockTargets(
|
|
5215
|
+
report: UsageReport,
|
|
5216
|
+
nowMs: number,
|
|
5217
|
+
): { wideUntil?: number; scoped: Array<{ modelId: string; untilMs: number }> } {
|
|
5218
|
+
let wideUntil: number | undefined;
|
|
5219
|
+
const scoped: Array<{ modelId: string; untilMs: number }> = [];
|
|
5220
|
+
for (const limit of report.limits) {
|
|
5221
|
+
if (!this.#isUsageLimitExhausted(limit)) continue;
|
|
5222
|
+
const resetsAt = limit.window?.resetsAt;
|
|
5223
|
+
if (!resetsAt || resetsAt <= nowMs) continue;
|
|
5224
|
+
const scopeModel = limit.scope?.modelId?.trim();
|
|
5225
|
+
const tier = limit.scope?.tier?.trim().toLowerCase();
|
|
5226
|
+
if (!scopeModel && !(tier && MODEL_FAMILY_TIERS.has(tier))) {
|
|
5227
|
+
wideUntil = wideUntil === undefined ? resetsAt : Math.min(wideUntil, resetsAt);
|
|
5228
|
+
continue;
|
|
5229
|
+
}
|
|
5230
|
+
scoped.push({ modelId: scopeModel ?? tier!, untilMs: resetsAt });
|
|
5231
|
+
}
|
|
5232
|
+
return wideUntil === undefined ? { scoped } : { wideUntil, scoped };
|
|
5233
|
+
}
|
|
5234
|
+
|
|
4770
5235
|
async #getUsageReport(
|
|
4771
5236
|
provider: Provider,
|
|
4772
5237
|
credential: OAuthCredential,
|
|
@@ -5295,7 +5760,15 @@ export class AuthStorage {
|
|
|
5295
5760
|
async markUsageLimitReached(
|
|
5296
5761
|
provider: string,
|
|
5297
5762
|
sessionId: string | undefined,
|
|
5298
|
-
options?: {
|
|
5763
|
+
options?: {
|
|
5764
|
+
retryAfterMs?: number;
|
|
5765
|
+
baseUrl?: string;
|
|
5766
|
+
signal?: AbortSignal;
|
|
5767
|
+
owner?: object;
|
|
5768
|
+
rowId?: number;
|
|
5769
|
+
/** Scope the block to one model; other models keep using the credential. */
|
|
5770
|
+
modelId?: string;
|
|
5771
|
+
},
|
|
5299
5772
|
): Promise<UsageLimitMarkResult> {
|
|
5300
5773
|
provider = resolveOAuthStorageProvider(provider);
|
|
5301
5774
|
const ownerOverride = this.#configOverrideRegistration(provider, options?.owner);
|
|
@@ -5316,11 +5789,19 @@ export class AuthStorage {
|
|
|
5316
5789
|
const now = Date.now();
|
|
5317
5790
|
let blockedUntil = now + (options?.retryAfterMs ?? AuthStorage.#defaultBackoffMs);
|
|
5318
5791
|
|
|
5792
|
+
// Model-scoped exhausted limits only constrain their own model; an
|
|
5793
|
+
// unscoped or shared limit raises the credential-wide backoff.
|
|
5794
|
+
let scopedBlocks: Array<{ modelId: string; untilMs: number }> = [];
|
|
5795
|
+
let reportHasWideLimit = false;
|
|
5319
5796
|
if (initial.credential.type === "oauth" && this.#rankingStrategyResolver?.(provider)) {
|
|
5320
5797
|
const report = await this.#getUsageReport(provider, initial.credential, options);
|
|
5321
|
-
if (report
|
|
5322
|
-
const
|
|
5323
|
-
if (
|
|
5798
|
+
if (report) {
|
|
5799
|
+
const targets = this.#exhaustedLimitBlockTargets(report, Date.now());
|
|
5800
|
+
if (targets.wideUntil !== undefined) {
|
|
5801
|
+
reportHasWideLimit = true;
|
|
5802
|
+
if (targets.wideUntil > blockedUntil) blockedUntil = targets.wideUntil;
|
|
5803
|
+
}
|
|
5804
|
+
scopedBlocks = targets.scoped;
|
|
5324
5805
|
}
|
|
5325
5806
|
}
|
|
5326
5807
|
|
|
@@ -5338,7 +5819,11 @@ export class AuthStorage {
|
|
|
5338
5819
|
candidate.entry.credential.type === credentialKind &&
|
|
5339
5820
|
candidate.entry.id !== failedRowId &&
|
|
5340
5821
|
(!enabledIds || enabledIds.has(candidate.entry.id)) &&
|
|
5341
|
-
!this.#isCredentialBlocked(
|
|
5822
|
+
!this.#isCredentialBlocked(
|
|
5823
|
+
this.#getProviderTypeKey(provider, credentialKind),
|
|
5824
|
+
candidate.index,
|
|
5825
|
+
options?.modelId,
|
|
5826
|
+
),
|
|
5342
5827
|
)
|
|
5343
5828
|
.map(candidate => candidate.entry.id);
|
|
5344
5829
|
};
|
|
@@ -5351,7 +5836,15 @@ export class AuthStorage {
|
|
|
5351
5836
|
};
|
|
5352
5837
|
}
|
|
5353
5838
|
const providerKey = this.#getProviderTypeKey(provider, credentialKind);
|
|
5354
|
-
|
|
5839
|
+
// A report whose only exhausted limits are model-scoped must not take the
|
|
5840
|
+
// whole credential out of rotation; the caller's retry-after backoff applies
|
|
5841
|
+
// to those models instead.
|
|
5842
|
+
if (!(scopedBlocks.length > 0 && !reportHasWideLimit)) {
|
|
5843
|
+
this.#markCredentialBlocked(providerKey, targetIndex, blockedUntil, options?.modelId);
|
|
5844
|
+
}
|
|
5845
|
+
for (const scoped of scopedBlocks) {
|
|
5846
|
+
this.#markCredentialBlocked(providerKey, targetIndex, scoped.untilMs, scoped.modelId);
|
|
5847
|
+
}
|
|
5355
5848
|
return {
|
|
5356
5849
|
state: "marked",
|
|
5357
5850
|
failedRowId,
|
|
@@ -5360,6 +5853,30 @@ export class AuthStorage {
|
|
|
5360
5853
|
};
|
|
5361
5854
|
}
|
|
5362
5855
|
|
|
5856
|
+
/**
|
|
5857
|
+
* Opaque stored row IDs for credentials whose resolved key matches `apiKey`.
|
|
5858
|
+
* Unlike the session's sticky row pointer, these remain tied to the actual key
|
|
5859
|
+
* when concurrent requests share a credential session ID.
|
|
5860
|
+
* A row that cannot be resolved is skipped so an unrelated resolver failure
|
|
5861
|
+
* cannot prevent callers from using already-known credential identity.
|
|
5862
|
+
*/
|
|
5863
|
+
async getCredentialRowIdsMatchingApiKey(provider: string, apiKey: string): Promise<number[]> {
|
|
5864
|
+
const storageProvider = resolveOAuthStorageProvider(provider);
|
|
5865
|
+
const rowIds: number[] = [];
|
|
5866
|
+
for (const entry of this.#getStoredCredentials(storageProvider)) {
|
|
5867
|
+
try {
|
|
5868
|
+
if (await this.#credentialMatchesApiKey(storageProvider, entry.credential, apiKey)) rowIds.push(entry.id);
|
|
5869
|
+
} catch (error) {
|
|
5870
|
+
logger.warn("Failed to resolve stored API key while matching credential rows", {
|
|
5871
|
+
provider: storageProvider,
|
|
5872
|
+
credentialId: entry.id,
|
|
5873
|
+
errorType: error instanceof Error ? error.name : typeof error,
|
|
5874
|
+
});
|
|
5875
|
+
}
|
|
5876
|
+
}
|
|
5877
|
+
return rowIds;
|
|
5878
|
+
}
|
|
5879
|
+
|
|
5363
5880
|
/**
|
|
5364
5881
|
* Mark the stored credential whose key matches `apiKey` usage-limited for
|
|
5365
5882
|
* `retryAfterMs` (or the default backoff). Used when the caller knows
|
|
@@ -5492,7 +6009,11 @@ export class AuthStorage {
|
|
|
5492
6009
|
args.order.map(async idx => {
|
|
5493
6010
|
const selection = args.credentials[idx];
|
|
5494
6011
|
if (!selection) return null;
|
|
5495
|
-
const blockedUntil = this.#getCredentialBlockedUntil(
|
|
6012
|
+
const blockedUntil = this.#getCredentialBlockedUntil(
|
|
6013
|
+
args.providerKey,
|
|
6014
|
+
selection.index,
|
|
6015
|
+
args.options?.modelId,
|
|
6016
|
+
);
|
|
5496
6017
|
if (blockedUntil !== undefined) return { selection, usage: null, usageChecked: false, blockedUntil };
|
|
5497
6018
|
const usage = await this.#getUsageReport(args.provider, selection.credential, {
|
|
5498
6019
|
baseUrl: args.options?.baseUrl,
|
|
@@ -5530,13 +6051,29 @@ export class AuthStorage {
|
|
|
5530
6051
|
const { selection, usage, usageChecked } = result;
|
|
5531
6052
|
let { blockedUntil } = result;
|
|
5532
6053
|
let blocked = blockedUntil !== undefined;
|
|
5533
|
-
if (!blocked && usage && this.#isUsageLimitReached(usage)) {
|
|
5534
|
-
const resetAtMs = this.#getUsageResetAtMs(usage, nowMs);
|
|
6054
|
+
if (!blocked && usage && this.#isUsageLimitReached(usage, args.options?.modelId)) {
|
|
6055
|
+
const resetAtMs = this.#getUsageResetAtMs(usage, nowMs, args.options?.modelId);
|
|
5535
6056
|
blockedUntil = resetAtMs ?? Date.now() + AuthStorage.#defaultBackoffMs;
|
|
5536
|
-
this.#
|
|
6057
|
+
const targets = this.#exhaustedLimitBlockTargets(usage, nowMs);
|
|
6058
|
+
if (targets.wideUntil === undefined && targets.scoped.length > 0) {
|
|
6059
|
+
for (const scoped of targets.scoped) {
|
|
6060
|
+
this.#markCredentialBlocked(args.providerKey, selection.index, scoped.untilMs, scoped.modelId);
|
|
6061
|
+
}
|
|
6062
|
+
} else {
|
|
6063
|
+
this.#markCredentialBlocked(args.providerKey, selection.index, blockedUntil);
|
|
6064
|
+
}
|
|
5537
6065
|
blocked = true;
|
|
5538
6066
|
}
|
|
5539
|
-
|
|
6067
|
+
// Rank on the windows that apply to the requested model so a limit scoped
|
|
6068
|
+
// to another model does not depress this credential's drain metrics.
|
|
6069
|
+
const scopedUsage =
|
|
6070
|
+
usage && args.options?.modelId
|
|
6071
|
+
? {
|
|
6072
|
+
...usage,
|
|
6073
|
+
limits: usage.limits.filter(limit => this.#limitAppliesToModel(limit, args.options?.modelId)),
|
|
6074
|
+
}
|
|
6075
|
+
: usage;
|
|
6076
|
+
const windows = scopedUsage ? strategy.findWindowLimits(scopedUsage) : undefined;
|
|
5540
6077
|
const primary = windows?.primary;
|
|
5541
6078
|
const secondary = windows?.secondary;
|
|
5542
6079
|
const secondaryTarget = secondary ?? primary;
|
|
@@ -5663,7 +6200,8 @@ export class AuthStorage {
|
|
|
5663
6200
|
// (no preference) and sessions whose preferred is blocked still rank, so we pick the account
|
|
5664
6201
|
// with the most headroom proactively and fall back intelligently when rate-limited.
|
|
5665
6202
|
const sessionPreferredIsAvailable =
|
|
5666
|
-
sessionPreferredIndex !== undefined &&
|
|
6203
|
+
sessionPreferredIndex !== undefined &&
|
|
6204
|
+
!this.#isCredentialBlocked(providerKey, sessionPreferredIndex, options?.modelId);
|
|
5667
6205
|
const shouldRank = !selectedCredential && checkUsage && !sessionPreferredIsAvailable;
|
|
5668
6206
|
const candidates = shouldRank
|
|
5669
6207
|
? await this.#rankOAuthSelections({ providerKey, provider, order, credentials, options, strategy: strategy! })
|
|
@@ -5685,27 +6223,25 @@ export class AuthStorage {
|
|
|
5685
6223
|
options?.owner,
|
|
5686
6224
|
);
|
|
5687
6225
|
}
|
|
5688
|
-
const
|
|
5689
|
-
? this.#
|
|
5690
|
-
:
|
|
5691
|
-
if (
|
|
5692
|
-
preferredSelection?.credential.type === "oauth" &&
|
|
5693
|
-
!this.#isCredentialBlocked(providerKey, preferredSelection.index)
|
|
5694
|
-
) {
|
|
5695
|
-
const preferredCandidate = candidates.findIndex(
|
|
5696
|
-
candidate => candidate.selection.index === preferredSelection.index,
|
|
5697
|
-
);
|
|
6226
|
+
const preferredCandidate = preferredSelector
|
|
6227
|
+
? this.#findUsableOAuthCandidate(provider, providerKey, candidates, preferredSelector)
|
|
6228
|
+
: -1;
|
|
6229
|
+
if (preferredCandidate >= 0) {
|
|
5698
6230
|
if (preferredCandidate > 0) {
|
|
5699
6231
|
const [preferred] = candidates.splice(preferredCandidate, 1);
|
|
5700
6232
|
candidates.unshift(preferred);
|
|
5701
6233
|
}
|
|
6234
|
+
} else if (!preferredSelector && !sessionPreferredIsAvailable) {
|
|
6235
|
+
// Configured preferences choose a credential; a session that already sticks to a usable one keeps it, so
|
|
6236
|
+
// the list (and any usage fetch its caps need) is consulted only at selection time.
|
|
6237
|
+
await this.#applyConfiguredCredentialPreferences(provider, providerKey, candidates, strategy, options);
|
|
5702
6238
|
}
|
|
5703
6239
|
}
|
|
5704
6240
|
|
|
5705
6241
|
if (!selectedCredential && sessionPreferredIndex !== undefined) {
|
|
5706
6242
|
const sessionPreferredCandidate = candidates.findIndex(
|
|
5707
6243
|
candidate =>
|
|
5708
|
-
!this.#isCredentialBlocked(providerKey, candidate.selection.index) &&
|
|
6244
|
+
!this.#isCredentialBlocked(providerKey, candidate.selection.index, options?.modelId) &&
|
|
5709
6245
|
candidate.selection.index === sessionPreferredIndex,
|
|
5710
6246
|
);
|
|
5711
6247
|
if (sessionPreferredCandidate > 0) {
|
|
@@ -5771,7 +6307,7 @@ export class AuthStorage {
|
|
|
5771
6307
|
if (resolved) return resolved;
|
|
5772
6308
|
}
|
|
5773
6309
|
|
|
5774
|
-
if (fallback && this.#isCredentialBlocked(providerKey, fallback.selection.index)) {
|
|
6310
|
+
if (fallback && this.#isCredentialBlocked(providerKey, fallback.selection.index, options?.modelId)) {
|
|
5775
6311
|
return this.#tryOAuthCredential(
|
|
5776
6312
|
provider,
|
|
5777
6313
|
fallback.selection,
|
|
@@ -5939,6 +6475,8 @@ export class AuthStorage {
|
|
|
5939
6475
|
projectId: claim.credential.projectId,
|
|
5940
6476
|
enterpriseUrl: claim.credential.enterpriseUrl,
|
|
5941
6477
|
profileArn: claim.credential.profileArn,
|
|
6478
|
+
organizationId: claim.credential.organizationId,
|
|
6479
|
+
organizationName: claim.credential.organizationName,
|
|
5942
6480
|
mcpBinding: claim.credential.mcpBinding,
|
|
5943
6481
|
persistedByLease: true,
|
|
5944
6482
|
};
|
|
@@ -6059,6 +6597,8 @@ export class AuthStorage {
|
|
|
6059
6597
|
projectId: effectiveRefreshed.projectId ?? credential.projectId,
|
|
6060
6598
|
enterpriseUrl: effectiveRefreshed.enterpriseUrl ?? credential.enterpriseUrl,
|
|
6061
6599
|
profileArn: effectiveRefreshed.profileArn ?? credential.profileArn,
|
|
6600
|
+
organizationId: effectiveRefreshed.organizationId ?? credential.organizationId,
|
|
6601
|
+
organizationName: effectiveRefreshed.organizationName ?? credential.organizationName,
|
|
6062
6602
|
mcpBinding: (effectiveRefreshed as RefreshedOAuthCredentials).mcpBinding ?? credential.mcpBinding,
|
|
6063
6603
|
};
|
|
6064
6604
|
if (refreshLease) {
|
|
@@ -6164,7 +6704,7 @@ export class AuthStorage {
|
|
|
6164
6704
|
): Promise<OAuthResolutionResult | undefined> {
|
|
6165
6705
|
const { checkUsage, allowBlocked, prefetchedUsage = null, usagePrechecked = false } = usageOptions;
|
|
6166
6706
|
if (!this.#reconcileOAuthCredentialSelection(provider, selection)) return undefined;
|
|
6167
|
-
if (!allowBlocked && this.#isCredentialBlocked(providerKey, selection.index)) {
|
|
6707
|
+
if (!allowBlocked && this.#isCredentialBlocked(providerKey, selection.index, options?.modelId)) {
|
|
6168
6708
|
return undefined;
|
|
6169
6709
|
}
|
|
6170
6710
|
|
|
@@ -6186,8 +6726,8 @@ export class AuthStorage {
|
|
|
6186
6726
|
});
|
|
6187
6727
|
usageChecked = true;
|
|
6188
6728
|
}
|
|
6189
|
-
if (checkUsage && !allowBlocked && usage && this.#isUsageLimitReached(usage)) {
|
|
6190
|
-
const resetAtMs = this.#getUsageResetAtMs(usage, Date.now());
|
|
6729
|
+
if (checkUsage && !allowBlocked && usage && this.#isUsageLimitReached(usage, options?.modelId)) {
|
|
6730
|
+
const resetAtMs = this.#getUsageResetAtMs(usage, Date.now(), options?.modelId);
|
|
6191
6731
|
this.#markCredentialBlocked(
|
|
6192
6732
|
providerKey,
|
|
6193
6733
|
selection.index,
|
|
@@ -6247,6 +6787,8 @@ export class AuthStorage {
|
|
|
6247
6787
|
projectId: result.newCredentials.projectId ?? selection.credential.projectId,
|
|
6248
6788
|
enterpriseUrl: result.newCredentials.enterpriseUrl ?? selection.credential.enterpriseUrl,
|
|
6249
6789
|
profileArn: result.newCredentials.profileArn ?? selection.credential.profileArn,
|
|
6790
|
+
organizationId: result.newCredentials.organizationId ?? selection.credential.organizationId,
|
|
6791
|
+
organizationName: result.newCredentials.organizationName ?? selection.credential.organizationName,
|
|
6250
6792
|
mcpBinding: refreshedAuthority.mcpBinding,
|
|
6251
6793
|
};
|
|
6252
6794
|
this.#replaceCredentialAt(
|
|
@@ -6266,8 +6808,8 @@ export class AuthStorage {
|
|
|
6266
6808
|
});
|
|
6267
6809
|
usageChecked = true;
|
|
6268
6810
|
}
|
|
6269
|
-
if (checkUsage && !allowBlocked && usage && this.#isUsageLimitReached(usage)) {
|
|
6270
|
-
const resetAtMs = this.#getUsageResetAtMs(usage, Date.now());
|
|
6811
|
+
if (checkUsage && !allowBlocked && usage && this.#isUsageLimitReached(usage, options?.modelId)) {
|
|
6812
|
+
const resetAtMs = this.#getUsageResetAtMs(usage, Date.now(), options?.modelId);
|
|
6271
6813
|
this.#markCredentialBlocked(
|
|
6272
6814
|
providerKey,
|
|
6273
6815
|
selection.index,
|
|
@@ -6279,7 +6821,7 @@ export class AuthStorage {
|
|
|
6279
6821
|
if (!this.#reconcileOAuthCredentialSelection(provider, selection)) return undefined;
|
|
6280
6822
|
if (!authCredentialEquals(selection.credential, updated)) return undefined;
|
|
6281
6823
|
this.#recordSessionCredential(provider, sessionId, "oauth", selection.index);
|
|
6282
|
-
return { apiKey: result.apiKey, credential: updated };
|
|
6824
|
+
return { apiKey: result.apiKey, credential: updated, credentialRowId: selection.id };
|
|
6283
6825
|
} catch (error) {
|
|
6284
6826
|
if (options?.signal?.aborted) throw error;
|
|
6285
6827
|
if (isSqliteError(error)) throw error;
|
|
@@ -6526,7 +7068,7 @@ export class AuthStorage {
|
|
|
6526
7068
|
*/
|
|
6527
7069
|
async peekApiKey(
|
|
6528
7070
|
provider: string,
|
|
6529
|
-
options?:
|
|
7071
|
+
options?: AuthApiKeyOptions & { sessionId?: string },
|
|
6530
7072
|
): Promise<string | undefined> {
|
|
6531
7073
|
provider = resolveOAuthStorageProvider(provider);
|
|
6532
7074
|
const runtimeKey = this.#runtimeOverrides.get(provider);
|
|
@@ -6537,11 +7079,7 @@ export class AuthStorage {
|
|
|
6537
7079
|
if (configKey && !configOverride?.envSourced) return configKey;
|
|
6538
7080
|
if (options?.sessionId && this.hasSessionCredentialUnavailable(provider, options.sessionId)) return undefined;
|
|
6539
7081
|
|
|
6540
|
-
const selectedCredential = this.#resolveSelectedStoredCredential(
|
|
6541
|
-
provider,
|
|
6542
|
-
options?.owner ? { owner: options.owner } : undefined,
|
|
6543
|
-
options?.sessionId,
|
|
6544
|
-
);
|
|
7082
|
+
const selectedCredential = this.#resolveSelectedStoredCredential(provider, options, options?.sessionId);
|
|
6545
7083
|
if (configKey) {
|
|
6546
7084
|
// Env-sourced (`apiKeyEnv`) override: same precedence as getApiKey —
|
|
6547
7085
|
// a stored api_key credential from `auth login` wins, stored OAuth
|
|
@@ -6576,7 +7114,7 @@ export class AuthStorage {
|
|
|
6576
7114
|
// A hard selector is an identity boundary. If its selected row cannot
|
|
6577
7115
|
// provide a current token, discovery must not continue into the shared
|
|
6578
7116
|
// credential pool and silently query another account's catalog.
|
|
6579
|
-
if (this.#getCredentialSelector(provider,
|
|
7117
|
+
if (this.#getCredentialSelector(provider, options, options?.sessionId)) return undefined;
|
|
6580
7118
|
|
|
6581
7119
|
const attemptedApiKeyIndices = new Set<number>();
|
|
6582
7120
|
for (;;) {
|
|
@@ -6626,6 +7164,31 @@ export class AuthStorage {
|
|
|
6626
7164
|
* 10. Fallback resolver (models.yml custom providers, last-resort)
|
|
6627
7165
|
*/
|
|
6628
7166
|
async getApiKey(provider: string, sessionId?: string, options?: AuthApiKeyOptions): Promise<string | undefined> {
|
|
7167
|
+
return this.#resolveApiKey(provider, sessionId, options);
|
|
7168
|
+
}
|
|
7169
|
+
|
|
7170
|
+
/**
|
|
7171
|
+
* Resolve an API key and retain the selected stored row identity across token refreshes
|
|
7172
|
+
* and concurrent changes to the session's sticky credential pointer.
|
|
7173
|
+
*/
|
|
7174
|
+
async getApiKeyWithCredentialRowId(
|
|
7175
|
+
provider: string,
|
|
7176
|
+
sessionId?: string,
|
|
7177
|
+
options?: AuthApiKeyOptions,
|
|
7178
|
+
): Promise<{ apiKey: string | undefined; credentialRowId: number | undefined }> {
|
|
7179
|
+
let credentialRowId: number | undefined;
|
|
7180
|
+
const apiKey = await this.#resolveApiKey(provider, sessionId, options, rowId => {
|
|
7181
|
+
credentialRowId = rowId;
|
|
7182
|
+
});
|
|
7183
|
+
return { apiKey, credentialRowId };
|
|
7184
|
+
}
|
|
7185
|
+
|
|
7186
|
+
async #resolveApiKey(
|
|
7187
|
+
provider: string,
|
|
7188
|
+
sessionId?: string,
|
|
7189
|
+
options?: AuthApiKeyOptions,
|
|
7190
|
+
onCredentialRowResolved?: (rowId: number) => void,
|
|
7191
|
+
): Promise<string | undefined> {
|
|
6629
7192
|
provider = resolveOAuthStorageProvider(provider);
|
|
6630
7193
|
const selectedCredential = this.#resolveSelectedStoredCredential(provider, options, sessionId);
|
|
6631
7194
|
|
|
@@ -6647,7 +7210,12 @@ export class AuthStorage {
|
|
|
6647
7210
|
// (validated at login, rotated on 401), while the pointed-to env
|
|
6648
7211
|
// value can go stale with no recovery path — prefer the stored
|
|
6649
7212
|
// credential. Stored OAuth credentials still yield to the override.
|
|
6650
|
-
const storedApiKey = await this.#resolveStoredApiKeyOverEnvConfig(
|
|
7213
|
+
const storedApiKey = await this.#resolveStoredApiKeyOverEnvConfig(
|
|
7214
|
+
provider,
|
|
7215
|
+
selectedCredential,
|
|
7216
|
+
sessionId,
|
|
7217
|
+
onCredentialRowResolved,
|
|
7218
|
+
);
|
|
6651
7219
|
if (storedApiKey) return storedApiKey;
|
|
6652
7220
|
return configKey;
|
|
6653
7221
|
}
|
|
@@ -6662,14 +7230,19 @@ export class AuthStorage {
|
|
|
6662
7230
|
|
|
6663
7231
|
if (selectedCredential?.credential.type === "api_key") {
|
|
6664
7232
|
this.#recordSessionCredential(provider, sessionId, "api_key", selectedCredential.index);
|
|
6665
|
-
|
|
7233
|
+
const apiKey = await this.#resolveStoredApiKey(provider, selectedCredential.credential.key);
|
|
7234
|
+
if (apiKey) onCredentialRowResolved?.(selectedCredential.id);
|
|
7235
|
+
return apiKey;
|
|
6666
7236
|
}
|
|
6667
7237
|
|
|
6668
7238
|
let oauthAttempted = false;
|
|
6669
7239
|
if (!selectedCredential && this.#getSessionCredential(provider, sessionId)?.type === "oauth") {
|
|
6670
7240
|
const oauthResolved = await this.#resolveOAuthSelection(provider, sessionId, options);
|
|
6671
7241
|
oauthAttempted = true;
|
|
6672
|
-
if (oauthResolved)
|
|
7242
|
+
if (oauthResolved) {
|
|
7243
|
+
onCredentialRowResolved?.(oauthResolved.credentialRowId);
|
|
7244
|
+
return oauthResolved.apiKey;
|
|
7245
|
+
}
|
|
6673
7246
|
}
|
|
6674
7247
|
|
|
6675
7248
|
const attemptedApiKeyIndices = new Set<number>();
|
|
@@ -6681,6 +7254,7 @@ export class AuthStorage {
|
|
|
6681
7254
|
const resolved = await this.#resolveStoredApiKey(provider, apiKeySelection.credential.key);
|
|
6682
7255
|
if (resolved) {
|
|
6683
7256
|
this.#recordSessionCredential(provider, sessionId, "api_key", apiKeySelection.index);
|
|
7257
|
+
onCredentialRowResolved?.(apiKeySelection.id);
|
|
6684
7258
|
return resolved;
|
|
6685
7259
|
}
|
|
6686
7260
|
}
|
|
@@ -6688,7 +7262,10 @@ export class AuthStorage {
|
|
|
6688
7262
|
|
|
6689
7263
|
if (!oauthAttempted) {
|
|
6690
7264
|
const oauthResolved = await this.#resolveOAuthSelection(provider, sessionId, options);
|
|
6691
|
-
if (oauthResolved)
|
|
7265
|
+
if (oauthResolved) {
|
|
7266
|
+
onCredentialRowResolved?.(oauthResolved.credentialRowId);
|
|
7267
|
+
return oauthResolved.apiKey;
|
|
7268
|
+
}
|
|
6692
7269
|
}
|
|
6693
7270
|
|
|
6694
7271
|
if (!selectedCredential) {
|
|
@@ -6699,6 +7276,7 @@ export class AuthStorage {
|
|
|
6699
7276
|
const resolved = await this.#resolveStoredApiKey(provider, apiKeySelection.credential.key);
|
|
6700
7277
|
if (resolved) {
|
|
6701
7278
|
this.#recordSessionCredential(provider, sessionId, "api_key", apiKeySelection.index);
|
|
7279
|
+
onCredentialRowResolved?.(apiKeySelection.id);
|
|
6702
7280
|
return resolved;
|
|
6703
7281
|
}
|
|
6704
7282
|
}
|
|
@@ -6717,21 +7295,25 @@ export class AuthStorage {
|
|
|
6717
7295
|
/**
|
|
6718
7296
|
* Resolve a stored api_key credential that outranks an env-sourced config
|
|
6719
7297
|
* override (`apiKeyEnv`). Mirrors the api_key branches of {@link getApiKey}:
|
|
6720
|
-
*
|
|
6721
|
-
* Returns undefined when no
|
|
6722
|
-
* env-sourced override in
|
|
7298
|
+
* a selected credential must resolve or fail closed; unpinned resolution then
|
|
7299
|
+
* tries the round-robin/session pool. Returns undefined when no unpinned
|
|
7300
|
+
* stored api_key credential resolves, leaving the env-sourced override in
|
|
7301
|
+
* effect.
|
|
6723
7302
|
*/
|
|
6724
7303
|
async #resolveStoredApiKeyOverEnvConfig(
|
|
6725
7304
|
provider: string,
|
|
6726
7305
|
selectedCredential: ({ index: number } & StoredCredential) | undefined,
|
|
6727
7306
|
sessionId?: string,
|
|
7307
|
+
onCredentialRowResolved?: (rowId: number) => void,
|
|
6728
7308
|
): Promise<string | undefined> {
|
|
6729
7309
|
if (selectedCredential?.credential.type === "api_key") {
|
|
6730
7310
|
const resolved = await this.#resolveStoredApiKey(provider, selectedCredential.credential.key);
|
|
6731
7311
|
if (resolved) {
|
|
6732
7312
|
this.#recordSessionCredential(provider, sessionId, "api_key", selectedCredential.index);
|
|
7313
|
+
onCredentialRowResolved?.(selectedCredential.id);
|
|
6733
7314
|
return resolved;
|
|
6734
7315
|
}
|
|
7316
|
+
throw new Error(`Selected credential for ${provider} (id:${selectedCredential.id}) is unavailable`);
|
|
6735
7317
|
}
|
|
6736
7318
|
const attemptedApiKeyIndices = new Set<number>();
|
|
6737
7319
|
for (;;) {
|
|
@@ -6741,6 +7323,7 @@ export class AuthStorage {
|
|
|
6741
7323
|
const resolved = await this.#resolveStoredApiKey(provider, apiKeySelection.credential.key);
|
|
6742
7324
|
if (resolved) {
|
|
6743
7325
|
this.#recordSessionCredential(provider, sessionId, "api_key", apiKeySelection.index);
|
|
7326
|
+
onCredentialRowResolved?.(apiKeySelection.id);
|
|
6744
7327
|
return resolved;
|
|
6745
7328
|
}
|
|
6746
7329
|
}
|
|
@@ -7023,6 +7606,8 @@ export class AuthStorage {
|
|
|
7023
7606
|
projectId: refreshed.projectId ?? target.credential.projectId,
|
|
7024
7607
|
enterpriseUrl: refreshed.enterpriseUrl ?? target.credential.enterpriseUrl,
|
|
7025
7608
|
profileArn: refreshed.profileArn ?? target.credential.profileArn,
|
|
7609
|
+
organizationId: refreshed.organizationId ?? target.credential.organizationId,
|
|
7610
|
+
organizationName: refreshed.organizationName ?? target.credential.organizationName,
|
|
7026
7611
|
mcpBinding: refreshed.mcpBinding,
|
|
7027
7612
|
};
|
|
7028
7613
|
this.#replaceCredentialAt(provider, index, updated, !refreshed.persistedByLease, id);
|
|
@@ -7134,7 +7719,7 @@ export class AuthStorage {
|
|
|
7134
7719
|
const credential = chosen.credential;
|
|
7135
7720
|
const identity =
|
|
7136
7721
|
credential.type === "oauth"
|
|
7137
|
-
? (credential
|
|
7722
|
+
? (formatOAuthIdentityLabel(credential) ?? `cred ${chosen.id}`)
|
|
7138
7723
|
: `cred ${chosen.id}`;
|
|
7139
7724
|
return `${baseLabel} · ${preferredType} #${chosen.id} (${identity})`;
|
|
7140
7725
|
}
|
|
@@ -7247,7 +7832,22 @@ function resolveProviderCredentialIdentityKey(provider: string, identifiers: str
|
|
|
7247
7832
|
|
|
7248
7833
|
function resolveCredentialIdentityKey(provider: string, credential: AuthCredential): string | null {
|
|
7249
7834
|
if (credential.type === "api_key") return null;
|
|
7250
|
-
return
|
|
7835
|
+
return withOrganizationIdentity(
|
|
7836
|
+
resolveProviderCredentialIdentityKey(provider, extractOAuthCredentialIdentifiers(credential)),
|
|
7837
|
+
credential,
|
|
7838
|
+
);
|
|
7839
|
+
}
|
|
7840
|
+
|
|
7841
|
+
/**
|
|
7842
|
+
* Scope an account identity key to the credential's organization. One account
|
|
7843
|
+
* (same email) can sign in to several organizations, and each login yields an
|
|
7844
|
+
* independent credential with its own plan and limits, so they must not
|
|
7845
|
+
* replace or dedupe each other.
|
|
7846
|
+
*/
|
|
7847
|
+
function withOrganizationIdentity(identityKey: string | null, credential: OAuthCredential): string | null {
|
|
7848
|
+
const organizationId = credential.organizationId?.trim();
|
|
7849
|
+
if (!identityKey || !organizationId) return identityKey;
|
|
7850
|
+
return `${identityKey}|org:${organizationId}`;
|
|
7251
7851
|
}
|
|
7252
7852
|
|
|
7253
7853
|
function resolveRowCredentialIdentityKey(provider: string, row: AuthRow): string | null {
|
|
@@ -7342,6 +7942,7 @@ function extractOAuthTokenIdentifiers(token: string | undefined): string[] | und
|
|
|
7342
7942
|
*/
|
|
7343
7943
|
export class SqliteAuthCredentialStore implements AuthCredentialStore {
|
|
7344
7944
|
#db: Database;
|
|
7945
|
+
#ownedStatements: Statement[] = [];
|
|
7345
7946
|
#listActiveStmt: Statement;
|
|
7346
7947
|
#listActiveByProviderStmt: Statement;
|
|
7347
7948
|
#listAllStmt: Statement;
|
|
@@ -7367,53 +7968,89 @@ export class SqliteAuthCredentialStore implements AuthCredentialStore {
|
|
|
7367
7968
|
this.#db = db;
|
|
7368
7969
|
this.#initializeSchema();
|
|
7369
7970
|
|
|
7370
|
-
this.#listActiveStmt = this.#
|
|
7971
|
+
this.#listActiveStmt = this.#prepareOwnedStatement(
|
|
7371
7972
|
"SELECT id, provider, credential_type, data, disabled_cause, identity_key, revision FROM auth_credentials WHERE disabled_cause IS NULL ORDER BY id ASC",
|
|
7372
7973
|
);
|
|
7373
|
-
this.#listActiveByProviderStmt = this.#
|
|
7974
|
+
this.#listActiveByProviderStmt = this.#prepareOwnedStatement(
|
|
7374
7975
|
"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",
|
|
7375
7976
|
);
|
|
7376
|
-
this.#listAllStmt = this.#
|
|
7977
|
+
this.#listAllStmt = this.#prepareOwnedStatement(
|
|
7377
7978
|
"SELECT id, provider, credential_type, data, disabled_cause, identity_key, revision FROM auth_credentials ORDER BY id ASC",
|
|
7378
7979
|
);
|
|
7379
|
-
this.#listAllByProviderStmt = this.#
|
|
7980
|
+
this.#listAllByProviderStmt = this.#prepareOwnedStatement(
|
|
7380
7981
|
"SELECT id, provider, credential_type, data, disabled_cause, identity_key, revision FROM auth_credentials WHERE provider = ? ORDER BY id ASC",
|
|
7381
7982
|
);
|
|
7382
|
-
this.#listDisabledByProviderStmt = this.#
|
|
7983
|
+
this.#listDisabledByProviderStmt = this.#prepareOwnedStatement(
|
|
7383
7984
|
"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",
|
|
7384
7985
|
);
|
|
7385
|
-
this.#insertStmt = this.#
|
|
7986
|
+
this.#insertStmt = this.#prepareOwnedStatement(
|
|
7386
7987
|
`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`,
|
|
7387
7988
|
);
|
|
7388
|
-
this.#updateStmt = this.#
|
|
7989
|
+
this.#updateStmt = this.#prepareOwnedStatement(
|
|
7389
7990
|
`UPDATE auth_credentials SET credential_type = ?, data = ?, identity_key = ?, revision = revision + 1, updated_at = ${SQLITE_NOW_EPOCH} WHERE id = ?`,
|
|
7390
7991
|
);
|
|
7391
|
-
this.#deleteStmt = this.#
|
|
7992
|
+
this.#deleteStmt = this.#prepareOwnedStatement(
|
|
7392
7993
|
`UPDATE auth_credentials SET disabled_cause = ?, revision = revision + 1, updated_at = ${SQLITE_NOW_EPOCH} WHERE id = ?`,
|
|
7393
7994
|
);
|
|
7394
|
-
this.#deleteIfMatchesStmt = this.#
|
|
7995
|
+
this.#deleteIfMatchesStmt = this.#prepareOwnedStatement(
|
|
7395
7996
|
`UPDATE auth_credentials SET disabled_cause = ?, revision = revision + 1, updated_at = ${SQLITE_NOW_EPOCH} WHERE id = ? AND data = ? AND disabled_cause IS NULL`,
|
|
7396
7997
|
);
|
|
7397
|
-
this.#deleteIfRevisionStmt = this.#
|
|
7998
|
+
this.#deleteIfRevisionStmt = this.#prepareOwnedStatement(
|
|
7398
7999
|
`UPDATE auth_credentials SET disabled_cause = ?, revision = revision + 1, updated_at = ${SQLITE_NOW_EPOCH} WHERE id = ? AND revision = ? AND disabled_cause IS NULL`,
|
|
7399
8000
|
);
|
|
7400
|
-
this.#deleteByProviderStmt = this.#
|
|
8001
|
+
this.#deleteByProviderStmt = this.#prepareOwnedStatement(
|
|
7401
8002
|
`UPDATE auth_credentials SET disabled_cause = ?, revision = revision + 1, updated_at = ${SQLITE_NOW_EPOCH} WHERE provider = ? AND disabled_cause IS NULL`,
|
|
7402
8003
|
);
|
|
7403
|
-
this.#hardDeleteStmt = this.#
|
|
7404
|
-
this.#getCacheStmt = this.#
|
|
8004
|
+
this.#hardDeleteStmt = this.#prepareOwnedStatement("DELETE FROM auth_credentials WHERE id = ?");
|
|
8005
|
+
this.#getCacheStmt = this.#prepareOwnedStatement(
|
|
7405
8006
|
`SELECT value FROM cache WHERE key = ? AND expires_at > ${SQLITE_NOW_EPOCH}`,
|
|
7406
8007
|
);
|
|
7407
|
-
this.#getCacheIncludingExpiredStmt = this.#
|
|
7408
|
-
this.#upsertCacheStmt = this.#
|
|
8008
|
+
this.#getCacheIncludingExpiredStmt = this.#prepareOwnedStatement("SELECT value FROM cache WHERE key = ?");
|
|
8009
|
+
this.#upsertCacheStmt = this.#prepareOwnedStatement(
|
|
7409
8010
|
"INSERT INTO cache (key, value, expires_at) VALUES (?, ?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value, expires_at = excluded.expires_at",
|
|
7410
8011
|
);
|
|
7411
|
-
this.#claimUsageFetchLeaseStmt = this.#
|
|
8012
|
+
this.#claimUsageFetchLeaseStmt = this.#prepareOwnedStatement(
|
|
7412
8013
|
"INSERT INTO cache (key, value, expires_at) VALUES (?, ?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value, expires_at = excluded.expires_at WHERE cache.expires_at <= ?",
|
|
7413
8014
|
);
|
|
7414
|
-
this.#releaseUsageFetchLeaseStmt = this.#
|
|
7415
|
-
this.#deleteCachePrefixStmt = this.#
|
|
7416
|
-
this.#deleteExpiredCacheStmt = this.#
|
|
8015
|
+
this.#releaseUsageFetchLeaseStmt = this.#prepareOwnedStatement("DELETE FROM cache WHERE key = ? AND value = ?");
|
|
8016
|
+
this.#deleteCachePrefixStmt = this.#prepareOwnedStatement("DELETE FROM cache WHERE substr(key, 1, ?) = ?");
|
|
8017
|
+
this.#deleteExpiredCacheStmt = this.#prepareOwnedStatement(
|
|
8018
|
+
`DELETE FROM cache WHERE expires_at <= ${SQLITE_NOW_EPOCH}`,
|
|
8019
|
+
);
|
|
8020
|
+
}
|
|
8021
|
+
|
|
8022
|
+
#prepareOwnedStatement(sql: string): Statement {
|
|
8023
|
+
const statement = this.#db.prepare(sql);
|
|
8024
|
+
this.#ownedStatements.push(statement);
|
|
8025
|
+
return statement;
|
|
8026
|
+
}
|
|
8027
|
+
|
|
8028
|
+
#withStatement<T>(sql: string, use: (statement: Statement) => T): T {
|
|
8029
|
+
const statement = this.#db.prepare(sql);
|
|
8030
|
+
let result!: T;
|
|
8031
|
+
let operationFailed = false;
|
|
8032
|
+
let operationError: unknown;
|
|
8033
|
+
try {
|
|
8034
|
+
result = use(statement);
|
|
8035
|
+
} catch (error) {
|
|
8036
|
+
operationFailed = true;
|
|
8037
|
+
operationError = error;
|
|
8038
|
+
}
|
|
8039
|
+
|
|
8040
|
+
try {
|
|
8041
|
+
statement.finalize();
|
|
8042
|
+
} catch (finalizeError) {
|
|
8043
|
+
if (operationFailed) {
|
|
8044
|
+
throw new AggregateError(
|
|
8045
|
+
[operationError, finalizeError],
|
|
8046
|
+
"SQLite statement operation and finalization failed",
|
|
8047
|
+
{ cause: operationError },
|
|
8048
|
+
);
|
|
8049
|
+
}
|
|
8050
|
+
throw finalizeError;
|
|
8051
|
+
}
|
|
8052
|
+
if (operationFailed) throw operationError;
|
|
8053
|
+
return result;
|
|
7417
8054
|
}
|
|
7418
8055
|
|
|
7419
8056
|
static async open(dbPath: string = getAgentDbPath()): Promise<SqliteAuthCredentialStore> {
|
|
@@ -7489,33 +8126,40 @@ export class SqliteAuthCredentialStore implements AuthCredentialStore {
|
|
|
7489
8126
|
}
|
|
7490
8127
|
|
|
7491
8128
|
#authCredentialsTableExists(): boolean {
|
|
7492
|
-
|
|
7493
|
-
|
|
7494
|
-
|
|
7495
|
-
|
|
8129
|
+
return this.#withStatement(
|
|
8130
|
+
"SELECT 1 AS present FROM sqlite_master WHERE type = 'table' AND name = 'auth_credentials'",
|
|
8131
|
+
statement => {
|
|
8132
|
+
const row = statement.get() as { present?: number } | undefined;
|
|
8133
|
+
return row?.present === 1;
|
|
8134
|
+
},
|
|
8135
|
+
);
|
|
7496
8136
|
}
|
|
7497
8137
|
|
|
7498
8138
|
#readAuthSchemaVersion(): number | null {
|
|
7499
|
-
|
|
7500
|
-
|
|
7501
|
-
|
|
7502
|
-
|
|
8139
|
+
return this.#withStatement("SELECT version FROM auth_schema_version WHERE id = 1", statement => {
|
|
8140
|
+
const row = statement.get() as { version?: number } | undefined;
|
|
8141
|
+
return typeof row?.version === "number" ? row.version : null;
|
|
8142
|
+
});
|
|
7503
8143
|
}
|
|
7504
8144
|
|
|
7505
8145
|
#writeAuthSchemaVersion(version: number): void {
|
|
7506
|
-
this.#
|
|
8146
|
+
this.#withStatement("INSERT OR REPLACE INTO auth_schema_version(id, version) VALUES (1, ?)", statement => {
|
|
8147
|
+
statement.run(version);
|
|
8148
|
+
});
|
|
7507
8149
|
}
|
|
7508
8150
|
|
|
7509
8151
|
#inferAuthSchemaVersion(): number {
|
|
7510
|
-
|
|
7511
|
-
|
|
7512
|
-
|
|
7513
|
-
|
|
7514
|
-
|
|
7515
|
-
|
|
7516
|
-
|
|
7517
|
-
|
|
7518
|
-
|
|
8152
|
+
return this.#withStatement("PRAGMA table_info(auth_credentials)", statement => {
|
|
8153
|
+
const cols = statement.all() as Array<{ name?: string }>;
|
|
8154
|
+
const hasDisabledCause = cols.some(column => column.name === "disabled_cause");
|
|
8155
|
+
const hasIdentityKey = cols.some(column => column.name === "identity_key");
|
|
8156
|
+
const hasAccountId = cols.some(column => column.name === "account_id");
|
|
8157
|
+
const hasEmail = cols.some(column => column.name === "email");
|
|
8158
|
+
if (hasIdentityKey) return 3;
|
|
8159
|
+
if (hasAccountId || hasEmail) return 2;
|
|
8160
|
+
if (hasDisabledCause) return 1;
|
|
8161
|
+
return 0;
|
|
8162
|
+
});
|
|
7519
8163
|
}
|
|
7520
8164
|
|
|
7521
8165
|
#createAuthCredentialsTable(): void {
|
|
@@ -7559,7 +8203,10 @@ export class SqliteAuthCredentialStore implements AuthCredentialStore {
|
|
|
7559
8203
|
|
|
7560
8204
|
#migrateAuthSchemaV0ToV1(): void {
|
|
7561
8205
|
const migrate = this.#db.transaction(() => {
|
|
7562
|
-
const v0Cols = this.#
|
|
8206
|
+
const v0Cols = this.#withStatement(
|
|
8207
|
+
"PRAGMA table_info(auth_credentials)",
|
|
8208
|
+
statement => statement.all() as Array<{ name?: string }>,
|
|
8209
|
+
);
|
|
7563
8210
|
const hasDisabled = v0Cols.some(col => col.name === "disabled");
|
|
7564
8211
|
|
|
7565
8212
|
this.#db.run("ALTER TABLE auth_credentials RENAME TO auth_credentials_v0");
|
|
@@ -7635,24 +8282,27 @@ export class SqliteAuthCredentialStore implements AuthCredentialStore {
|
|
|
7635
8282
|
migrate.immediate();
|
|
7636
8283
|
}
|
|
7637
8284
|
#migrateAuthSchemaV4ToV5(): void {
|
|
7638
|
-
const columns = this.#
|
|
8285
|
+
const columns = this.#withStatement(
|
|
8286
|
+
"PRAGMA table_info(auth_credentials)",
|
|
8287
|
+
statement => statement.all() as Array<{ name?: string }>,
|
|
8288
|
+
);
|
|
7639
8289
|
if (columns.some(column => column.name === "revision")) return;
|
|
7640
8290
|
this.#db.run("ALTER TABLE auth_credentials ADD COLUMN revision INTEGER NOT NULL DEFAULT 1");
|
|
7641
8291
|
}
|
|
7642
8292
|
|
|
7643
8293
|
#backfillCredentialIdentityKeys(): void {
|
|
7644
|
-
const rows = this.#
|
|
7645
|
-
|
|
7646
|
-
|
|
7647
|
-
|
|
7648
|
-
.all() as AuthRow[];
|
|
8294
|
+
const rows = this.#withStatement(
|
|
8295
|
+
"SELECT id, provider, credential_type, data, disabled_cause, identity_key, revision FROM auth_credentials WHERE identity_key IS NULL ORDER BY id ASC",
|
|
8296
|
+
statement => statement.all() as AuthRow[],
|
|
8297
|
+
);
|
|
7649
8298
|
if (rows.length === 0) return;
|
|
7650
8299
|
|
|
7651
|
-
|
|
7652
|
-
|
|
7653
|
-
|
|
7654
|
-
|
|
7655
|
-
|
|
8300
|
+
this.#withStatement("UPDATE auth_credentials SET identity_key = ? WHERE id = ?", updateIdentity => {
|
|
8301
|
+
for (const row of rows) {
|
|
8302
|
+
const identityKey = resolveRowCredentialIdentityKey(row.provider, row);
|
|
8303
|
+
updateIdentity.run(identityKey, row.id);
|
|
8304
|
+
}
|
|
8305
|
+
});
|
|
7656
8306
|
}
|
|
7657
8307
|
|
|
7658
8308
|
// ─── AuthCredentialStore interface ──────────────────────────────────────
|
|
@@ -7681,10 +8331,7 @@ export class SqliteAuthCredentialStore implements AuthCredentialStore {
|
|
|
7681
8331
|
for (const row of rows) {
|
|
7682
8332
|
const credential = deserializeCredential(row);
|
|
7683
8333
|
if (!credential) continue;
|
|
7684
|
-
const identityLabel =
|
|
7685
|
-
credential.type === "oauth"
|
|
7686
|
-
? (credential.email ?? credential.accountId ?? credential.projectId ?? null)
|
|
7687
|
-
: null;
|
|
8334
|
+
const identityLabel = credential.type === "oauth" ? formatOAuthIdentityLabel(credential) : null;
|
|
7688
8335
|
results.push({
|
|
7689
8336
|
id: row.id,
|
|
7690
8337
|
provider: row.provider,
|
|
@@ -7693,6 +8340,12 @@ export class SqliteAuthCredentialStore implements AuthCredentialStore {
|
|
|
7693
8340
|
...(credential.type === "oauth" && credential.accountId ? { accountId: credential.accountId } : {}),
|
|
7694
8341
|
...(credential.type === "oauth" && credential.email ? { email: credential.email } : {}),
|
|
7695
8342
|
...(credential.type === "oauth" && credential.projectId ? { projectId: credential.projectId } : {}),
|
|
8343
|
+
...(credential.type === "oauth" && credential.organizationId
|
|
8344
|
+
? { organizationId: credential.organizationId }
|
|
8345
|
+
: {}),
|
|
8346
|
+
...(credential.type === "oauth" && credential.organizationName
|
|
8347
|
+
? { organizationName: credential.organizationName }
|
|
8348
|
+
: {}),
|
|
7696
8349
|
disabled: row.disabled_cause !== null,
|
|
7697
8350
|
disabledCause: row.disabled_cause,
|
|
7698
8351
|
});
|
|
@@ -7715,11 +8368,13 @@ export class SqliteAuthCredentialStore implements AuthCredentialStore {
|
|
|
7715
8368
|
const remove = this.#db.transaction((): AuthCredentialHardRemovalResult => {
|
|
7716
8369
|
const currentIds: number[] = [];
|
|
7717
8370
|
for (const target of unique) {
|
|
7718
|
-
const row = this.#
|
|
7719
|
-
|
|
7720
|
-
|
|
7721
|
-
|
|
7722
|
-
|
|
8371
|
+
const row = this.#withStatement(
|
|
8372
|
+
"SELECT id, provider, credential_type, revision FROM auth_credentials WHERE id = ?",
|
|
8373
|
+
statement =>
|
|
8374
|
+
statement.get(target.id) as
|
|
8375
|
+
| { id?: number; provider?: string; credential_type?: string; revision?: number }
|
|
8376
|
+
| undefined,
|
|
8377
|
+
);
|
|
7723
8378
|
if (!row || row.provider !== provider || row.revision !== target.expectedRevision) {
|
|
7724
8379
|
currentIds.push(row?.id ?? target.id);
|
|
7725
8380
|
}
|
|
@@ -7727,12 +8382,16 @@ export class SqliteAuthCredentialStore implements AuthCredentialStore {
|
|
|
7727
8382
|
if (currentIds.length > 0) return { kind: "conflict", currentIds };
|
|
7728
8383
|
for (const target of unique) {
|
|
7729
8384
|
this.#hardDeleteStmt.run(target.id);
|
|
7730
|
-
this.#
|
|
7731
|
-
|
|
8385
|
+
this.#withStatement("DELETE FROM oauth_refresh_leases WHERE credential_id = ?", statement => {
|
|
8386
|
+
statement.run(target.id);
|
|
8387
|
+
});
|
|
8388
|
+
this.#withStatement("DELETE FROM cache WHERE key = ?", statement => {
|
|
8389
|
+
statement.run(`${HEALTH_CACHE_PREFIX}${target.id}`);
|
|
8390
|
+
});
|
|
7732
8391
|
}
|
|
7733
|
-
this.#
|
|
7734
|
-
.
|
|
7735
|
-
|
|
8392
|
+
this.#withStatement("DELETE FROM cache WHERE substr(key, 1, ?) = ?", statement => {
|
|
8393
|
+
statement.run(`usage_cache:report:${provider}:`.length, `usage_cache:report:${provider}:`);
|
|
8394
|
+
});
|
|
7736
8395
|
return { kind: "removed", ids: unique.map(target => target.id) };
|
|
7737
8396
|
});
|
|
7738
8397
|
return remove.immediate();
|
|
@@ -7749,11 +8408,10 @@ export class SqliteAuthCredentialStore implements AuthCredentialStore {
|
|
|
7749
8408
|
const enteredAtMs = Date.now();
|
|
7750
8409
|
const claim = this.#db.transaction((): OAuthRefreshLeaseClaim => {
|
|
7751
8410
|
const effectiveNowMs = nowMs + Math.max(0, Date.now() - enteredAtMs);
|
|
7752
|
-
const row = this.#
|
|
7753
|
-
|
|
7754
|
-
|
|
7755
|
-
|
|
7756
|
-
.get(credentialId) as AuthRow | undefined;
|
|
8411
|
+
const row = this.#withStatement(
|
|
8412
|
+
"SELECT id, provider, credential_type, data, disabled_cause, identity_key, revision FROM auth_credentials WHERE id = ? AND disabled_cause IS NULL",
|
|
8413
|
+
statement => statement.get(credentialId) as AuthRow | undefined,
|
|
8414
|
+
);
|
|
7757
8415
|
const credential = row ? deserializeCredential(row) : null;
|
|
7758
8416
|
if (credential?.type !== "oauth") return { kind: "missing" };
|
|
7759
8417
|
if (
|
|
@@ -7770,14 +8428,16 @@ export class SqliteAuthCredentialStore implements AuthCredentialStore {
|
|
|
7770
8428
|
) {
|
|
7771
8429
|
return { kind: "adopted", credential };
|
|
7772
8430
|
}
|
|
7773
|
-
const active = this.#
|
|
7774
|
-
|
|
7775
|
-
.get(credentialId) as { owner?: string; expires_at?: number } | undefined
|
|
8431
|
+
const active = this.#withStatement(
|
|
8432
|
+
"SELECT owner, expires_at FROM oauth_refresh_leases WHERE credential_id = ?",
|
|
8433
|
+
statement => statement.get(credentialId) as { owner?: string; expires_at?: number } | undefined,
|
|
8434
|
+
);
|
|
7776
8435
|
if (typeof active?.expires_at === "number" && active.expires_at > effectiveNowMs) {
|
|
7777
8436
|
if (active.owner === owner) {
|
|
7778
|
-
this.#
|
|
7779
|
-
|
|
7780
|
-
.run(effectiveNowMs + leaseMs, credentialId, owner)
|
|
8437
|
+
this.#withStatement(
|
|
8438
|
+
"UPDATE oauth_refresh_leases SET expires_at = ? WHERE credential_id = ? AND owner = ?",
|
|
8439
|
+
statement => statement.run(effectiveNowMs + leaseMs, credentialId, owner),
|
|
8440
|
+
);
|
|
7781
8441
|
return {
|
|
7782
8442
|
kind: "claimed",
|
|
7783
8443
|
credential,
|
|
@@ -7791,13 +8451,14 @@ export class SqliteAuthCredentialStore implements AuthCredentialStore {
|
|
|
7791
8451
|
return { kind: "busy", expiresAt: active.expires_at };
|
|
7792
8452
|
}
|
|
7793
8453
|
|
|
7794
|
-
this.#
|
|
8454
|
+
this.#withStatement("DELETE FROM oauth_refresh_leases WHERE credential_id = ?", statement => {
|
|
8455
|
+
statement.run(credentialId);
|
|
8456
|
+
});
|
|
7795
8457
|
const tokenFingerprint = crypto.createHash("sha256").update(credential.refresh).digest("hex");
|
|
7796
|
-
this.#
|
|
7797
|
-
|
|
7798
|
-
|
|
7799
|
-
|
|
7800
|
-
.run(credentialId, owner, tokenFingerprint, effectiveNowMs + leaseMs);
|
|
8458
|
+
this.#withStatement(
|
|
8459
|
+
"INSERT INTO oauth_refresh_leases (credential_id, owner, token_fingerprint, expires_at) VALUES (?, ?, ?, ?)",
|
|
8460
|
+
statement => statement.run(credentialId, owner, tokenFingerprint, effectiveNowMs + leaseMs),
|
|
8461
|
+
);
|
|
7801
8462
|
return { kind: "claimed", credential, lease: { credentialId, owner, tokenFingerprint } };
|
|
7802
8463
|
});
|
|
7803
8464
|
return claim.immediate();
|
|
@@ -7805,22 +8466,24 @@ export class SqliteAuthCredentialStore implements AuthCredentialStore {
|
|
|
7805
8466
|
|
|
7806
8467
|
completeOAuthRefreshLease(lease: OAuthRefreshLease, credential: OAuthCredential): boolean {
|
|
7807
8468
|
const serialized = serializeCredential(
|
|
7808
|
-
(
|
|
7809
|
-
|
|
7810
|
-
|
|
7811
|
-
| undefined
|
|
8469
|
+
this.#withStatement(
|
|
8470
|
+
"SELECT provider FROM auth_credentials WHERE id = ?",
|
|
8471
|
+
statement => statement.get(lease.credentialId) as { provider?: string } | undefined,
|
|
7812
8472
|
)?.provider ?? "",
|
|
7813
8473
|
credential,
|
|
7814
8474
|
);
|
|
7815
8475
|
if (!serialized) return false;
|
|
7816
8476
|
const complete = this.#db.transaction(() => {
|
|
7817
|
-
const leaseRow = this.#
|
|
7818
|
-
|
|
7819
|
-
|
|
8477
|
+
const leaseRow = this.#withStatement(
|
|
8478
|
+
"SELECT owner, token_fingerprint FROM oauth_refresh_leases WHERE credential_id = ?",
|
|
8479
|
+
statement =>
|
|
8480
|
+
statement.get(lease.credentialId) as { owner?: string; token_fingerprint?: string } | undefined,
|
|
8481
|
+
);
|
|
7820
8482
|
if (leaseRow?.owner !== lease.owner || leaseRow.token_fingerprint !== lease.tokenFingerprint) return false;
|
|
7821
|
-
const row = this.#
|
|
7822
|
-
|
|
7823
|
-
.get(lease.credentialId) as { data?: string } | undefined
|
|
8483
|
+
const row = this.#withStatement(
|
|
8484
|
+
"SELECT data FROM auth_credentials WHERE id = ? AND disabled_cause IS NULL",
|
|
8485
|
+
statement => statement.get(lease.credentialId) as { data?: string } | undefined,
|
|
8486
|
+
);
|
|
7824
8487
|
if (
|
|
7825
8488
|
!row ||
|
|
7826
8489
|
crypto
|
|
@@ -7830,18 +8493,18 @@ export class SqliteAuthCredentialStore implements AuthCredentialStore {
|
|
|
7830
8493
|
)
|
|
7831
8494
|
return false;
|
|
7832
8495
|
this.#updateStmt.run(serialized.credentialType, serialized.data, serialized.identityKey, lease.credentialId);
|
|
7833
|
-
this.#
|
|
7834
|
-
.
|
|
7835
|
-
|
|
8496
|
+
this.#withStatement("DELETE FROM oauth_refresh_leases WHERE credential_id = ? AND owner = ?", statement => {
|
|
8497
|
+
statement.run(lease.credentialId, lease.owner);
|
|
8498
|
+
});
|
|
7836
8499
|
return true;
|
|
7837
8500
|
});
|
|
7838
8501
|
return complete.immediate();
|
|
7839
8502
|
}
|
|
7840
8503
|
|
|
7841
8504
|
releaseOAuthRefreshLease(lease: OAuthRefreshLease): void {
|
|
7842
|
-
this.#
|
|
7843
|
-
.
|
|
7844
|
-
|
|
8505
|
+
this.#withStatement("DELETE FROM oauth_refresh_leases WHERE credential_id = ? AND owner = ?", statement => {
|
|
8506
|
+
statement.run(lease.credentialId, lease.owner);
|
|
8507
|
+
});
|
|
7845
8508
|
}
|
|
7846
8509
|
|
|
7847
8510
|
replaceAuthCredentialsForProvider(provider: string, credentials: AuthCredential[]): StoredAuthCredential[] {
|
|
@@ -8065,9 +8728,10 @@ export class SqliteAuthCredentialStore implements AuthCredentialStore {
|
|
|
8065
8728
|
}
|
|
8066
8729
|
|
|
8067
8730
|
updateAuthCredential(id: number, credential: AuthCredential): void {
|
|
8068
|
-
const providerRow = this.#
|
|
8069
|
-
|
|
8070
|
-
| undefined
|
|
8731
|
+
const providerRow = this.#withStatement(
|
|
8732
|
+
"SELECT provider FROM auth_credentials WHERE id = ?",
|
|
8733
|
+
statement => statement.get(id) as { provider?: string } | undefined,
|
|
8734
|
+
);
|
|
8071
8735
|
const provider = providerRow?.provider ?? "";
|
|
8072
8736
|
const serialized = serializeCredential(provider, credential);
|
|
8073
8737
|
if (!serialized) return;
|
|
@@ -8266,25 +8930,23 @@ export class SqliteAuthCredentialStore implements AuthCredentialStore {
|
|
|
8266
8930
|
close(): void {
|
|
8267
8931
|
if (this.#closed) return;
|
|
8268
8932
|
this.#closed = true;
|
|
8269
|
-
|
|
8270
|
-
this.#
|
|
8271
|
-
|
|
8272
|
-
|
|
8273
|
-
|
|
8274
|
-
|
|
8275
|
-
|
|
8276
|
-
|
|
8277
|
-
this.#
|
|
8278
|
-
|
|
8279
|
-
|
|
8280
|
-
|
|
8281
|
-
|
|
8282
|
-
|
|
8283
|
-
|
|
8284
|
-
|
|
8285
|
-
|
|
8286
|
-
|
|
8287
|
-
this.#deleteExpiredCacheStmt.finalize();
|
|
8288
|
-
this.#db.close(true);
|
|
8933
|
+
const errors: unknown[] = [];
|
|
8934
|
+
for (const statement of this.#ownedStatements) {
|
|
8935
|
+
try {
|
|
8936
|
+
statement.finalize();
|
|
8937
|
+
} catch (error) {
|
|
8938
|
+
errors.push(error);
|
|
8939
|
+
}
|
|
8940
|
+
}
|
|
8941
|
+
this.#ownedStatements.length = 0;
|
|
8942
|
+
try {
|
|
8943
|
+
this.#db.close(true);
|
|
8944
|
+
} catch (error) {
|
|
8945
|
+
errors.push(error);
|
|
8946
|
+
}
|
|
8947
|
+
if (errors.length === 1) throw errors[0];
|
|
8948
|
+
if (errors.length > 1) {
|
|
8949
|
+
throw new AggregateError(errors, "Failed to close SQLite auth credential store", { cause: errors[0] });
|
|
8950
|
+
}
|
|
8289
8951
|
}
|
|
8290
8952
|
}
|