@linxiraos/pi-ai 1.2.1 → 1.3.1
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 +98 -0
- package/README.md +4 -2
- package/THIRD-PARTY-NOTICES.txt +55 -5
- package/dist/types/auth/cascade.d.ts +5 -3
- package/dist/types/auth/pool.d.ts +3 -3
- package/dist/types/auth/select.d.ts +5 -1
- package/dist/types/auth/types.d.ts +15 -5
- package/dist/types/auth/usage-cache.d.ts +3 -1
- package/dist/types/auth-gateway/clients.d.ts +67 -0
- package/dist/types/auth-gateway/dispatch.d.ts +28 -10
- package/dist/types/auth-gateway/http.d.ts +2 -0
- package/dist/types/auth-gateway/index.d.ts +1 -0
- package/dist/types/auth-gateway/server.d.ts +9 -4
- package/dist/types/auth-gateway/session-state.d.ts +6 -0
- package/dist/types/auth-gateway/types.d.ts +16 -2
- package/dist/types/dialect/coercion.d.ts +6 -0
- package/dist/types/dialect/deepseek.d.ts +13 -0
- package/dist/types/error/classes.d.ts +1 -1
- package/dist/types/error/flags.d.ts +15 -0
- package/dist/types/error/index.d.ts +1 -0
- package/dist/types/error/network-cause.d.ts +40 -0
- package/dist/types/images/shared.d.ts +26 -2
- package/dist/types/index.d.ts +1 -0
- package/dist/types/judgment/native.d.ts +62 -3
- package/dist/types/judgment/systemone.d.ts +9 -2
- package/dist/types/judgment/text.d.ts +11 -5
- package/dist/types/judgment/types.d.ts +37 -6
- package/dist/types/judgment/wire.d.ts +18 -3
- package/dist/types/provider-session-state.d.ts +2 -2
- package/dist/types/providers/anthropic-images.d.ts +7 -0
- package/dist/types/providers/anthropic-signature.d.ts +12 -2
- package/dist/types/providers/anthropic-state.d.ts +7 -0
- package/dist/types/providers/anthropic.d.ts +21 -6
- package/dist/types/providers/connection-prewarm.d.ts +15 -0
- package/dist/types/providers/cursor/exec-modern.d.ts +1 -1
- package/dist/types/providers/cursor-pi-args.d.ts +13 -0
- package/dist/types/providers/cursor.d.ts +7 -0
- package/dist/types/providers/ollama.d.ts +6 -1
- package/dist/types/providers/openai-codex-responses.d.ts +6 -0
- package/dist/types/providers/openai-responses.d.ts +9 -0
- package/dist/types/providers/openai-shared.d.ts +30 -0
- package/dist/types/providers/systemone-server.d.ts +7 -3
- package/dist/types/providers/transform-messages.d.ts +28 -5
- package/dist/types/registry/oauth/google-antigravity.d.ts +1 -1
- package/dist/types/stream.d.ts +0 -2
- package/dist/types/types.d.ts +33 -9
- package/dist/types/usage/muse-code.d.ts +1 -1
- package/dist/types/utils/dsml-leak.d.ts +17 -13
- package/dist/types/utils/narration.d.ts +9 -0
- package/dist/types/utils/openai-http.d.ts +2 -0
- package/dist/types/utils/request-body.d.ts +11 -0
- package/dist/types/utils/request-compression.d.ts +21 -0
- package/dist/types/utils/tool-call-arguments.d.ts +2 -1
- package/dist/types/utils/transport-fetch.d.ts +8 -0
- package/dist/types/utils.d.ts +1 -1
- package/package.json +7 -7
- package/src/auth/cascade.ts +23 -13
- package/src/auth/pool.ts +3 -3
- package/src/auth/refresh.ts +39 -12
- package/src/auth/select.ts +8 -2
- package/src/auth/types.ts +16 -5
- package/src/auth/usage-cache.ts +23 -7
- package/src/auth/usage.ts +2 -4
- package/src/auth-broker/remote-store.ts +31 -6
- package/src/auth-gateway/clients.ts +318 -0
- package/src/auth-gateway/dispatch.ts +66 -35
- package/src/auth-gateway/http.ts +11 -5
- package/src/auth-gateway/index.ts +1 -0
- package/src/auth-gateway/routes/embeddings.ts +4 -10
- package/src/auth-gateway/routes/images.ts +6 -10
- package/src/auth-gateway/routes/rerank.ts +4 -10
- package/src/auth-gateway/routes/speech.ts +10 -10
- package/src/auth-gateway/routes/systemone.ts +22 -12
- package/src/auth-gateway/routes/transcriptions.ts +4 -2
- package/src/auth-gateway/routes/video.ts +12 -15
- package/src/auth-gateway/server.ts +136 -53
- package/src/auth-gateway/session-state.ts +14 -0
- package/src/auth-gateway/types.ts +16 -2
- package/src/dialect/coercion.ts +16 -0
- package/src/dialect/deepseek.ts +344 -81
- package/src/dialect/hermes.ts +7 -11
- package/src/dialect/qwen3.ts +3 -11
- package/src/error/classes.ts +3 -2
- package/src/error/flags.ts +24 -3
- package/src/error/index.ts +1 -0
- package/src/error/network-cause.ts +179 -0
- package/src/images/codex-format-directive.md +3 -0
- package/src/images/google-antigravity.ts +2 -1
- package/src/images/google-generative-ai.ts +4 -2
- package/src/images/openai-hosted.ts +25 -2
- package/src/images/openai-images.ts +11 -6
- package/src/images/shared.ts +77 -3
- package/src/index.ts +1 -0
- package/src/judgment/chat.ts +17 -1
- package/src/judgment/native.ts +238 -12
- package/src/judgment/openai-decisions.ts +30 -6
- package/src/judgment/systemone.ts +153 -11
- package/src/judgment/text.ts +26 -7
- package/src/judgment/types.ts +46 -6
- package/src/judgment/wire.ts +19 -3
- package/src/provider-session-state.ts +2 -2
- package/src/providers/amazon-bedrock.ts +4 -1
- package/src/providers/anthropic-client.ts +1 -1
- package/src/providers/anthropic-images.ts +263 -0
- package/src/providers/anthropic-messages-server.ts +84 -1
- package/src/providers/anthropic-signature.ts +35 -8
- package/src/providers/anthropic-state.ts +13 -0
- package/src/providers/anthropic.ts +479 -397
- package/src/providers/connection-prewarm.ts +78 -0
- package/src/providers/cowork-fetch.ts +5 -1
- package/src/providers/cursor/exec-modern.ts +1 -0
- package/src/providers/cursor-pi-args.ts +19 -0
- package/src/providers/cursor.ts +53 -13
- package/src/providers/devin.ts +10 -4
- package/src/providers/factory-droid/gemini.ts +9 -3
- package/src/providers/google-gemini-cli.ts +11 -1
- package/src/providers/google-shared.ts +2 -6
- package/src/providers/images-server.ts +6 -1
- package/src/providers/ollama.ts +23 -5
- package/src/providers/openai-anthropic-shim.ts +1 -0
- package/src/providers/openai-chat-server.ts +29 -0
- package/src/providers/openai-codex/access-programs.ts +2 -1
- package/src/providers/openai-codex-responses.ts +49 -82
- package/src/providers/openai-completions.ts +117 -19
- package/src/providers/openai-responses.ts +25 -2
- package/src/providers/openai-shared.ts +36 -5
- package/src/providers/pi-native-server.ts +1 -0
- package/src/providers/systemone-server.ts +72 -12
- package/src/providers/transform-messages.ts +104 -55
- package/src/registry/oauth/google-antigravity.ts +1 -1
- package/src/stream.ts +13 -73
- package/src/types.ts +33 -9
- package/src/usage/claude.ts +2 -9
- package/src/usage/github-copilot.ts +26 -4
- package/src/usage/muse-code.ts +115 -29
- package/src/usage/openai-codex.ts +13 -7
- package/src/usage/opencode-go.ts +7 -8
- package/src/usage/xai-oauth.ts +2 -9
- package/src/utils/dsml-leak.ts +88 -33
- package/src/utils/leaked-thinking-stream.ts +10 -0
- package/src/utils/narration.ts +18 -0
- package/src/utils/openai-http.ts +27 -18
- package/src/utils/request-body.ts +27 -0
- package/src/utils/request-compression.ts +202 -0
- package/src/utils/request-debug.ts +23 -9
- package/src/utils/schema/dereference.ts +51 -18
- package/src/utils/schema/draft.ts +10 -2
- package/src/utils/schema/json-schema-validator.ts +4 -10
- package/src/utils/schema/normalize.ts +6 -9
- package/src/utils/tool-call-arguments.ts +2 -1
- package/src/utils/transport-fetch.ts +37 -0
- package/src/utils/validation.ts +26 -16
- package/src/utils.ts +38 -23
package/src/auth/refresh.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { logger } from "@linxiraos/pi-utils";
|
|
1
|
+
import { logger, postmortem } from "@linxiraos/pi-utils";
|
|
2
2
|
import * as AIError from "../error";
|
|
3
3
|
import { getOAuthProvider, normalizeOAuthCredentialExpiry, refreshOAuthToken } from "../registry/oauth";
|
|
4
4
|
import type { OAuthCredentials, OAuthProvider } from "../registry/oauth/types";
|
|
@@ -101,6 +101,9 @@ export class OAuthRefresher {
|
|
|
101
101
|
#oauthCredentialRefreshInFlight: Map<number, Promise<OAuthCredentials>> = new Map();
|
|
102
102
|
/** Access token this process last minted per credential id. */
|
|
103
103
|
#recentMints = new Map<number, { provider: string; access: string; at: number }>();
|
|
104
|
+
#pendingRefreshes = new Set<Promise<unknown>>();
|
|
105
|
+
#cancelRefreshCleanup?: () => void;
|
|
106
|
+
#shuttingDown = false;
|
|
104
107
|
|
|
105
108
|
constructor(deps: OAuthRefresherDeps) {
|
|
106
109
|
this.#deps = deps;
|
|
@@ -148,7 +151,33 @@ export class OAuthRefresher {
|
|
|
148
151
|
/**
|
|
149
152
|
* Refresh one stored OAuth credential under durable row ownership.
|
|
150
153
|
*/
|
|
151
|
-
|
|
154
|
+
refreshStored<T extends OAuthCredential = OAuthCredential>(
|
|
155
|
+
provider: string,
|
|
156
|
+
options: StoredOAuthRefreshOptions<T>,
|
|
157
|
+
): Promise<StoredOAuthRefreshResult<T>> {
|
|
158
|
+
if (this.#shuttingDown) return Promise.reject(new AIError.AbortError("OAuth refresh stopped during shutdown"));
|
|
159
|
+
this.#cancelRefreshCleanup ??= postmortem.register("oauth-refresh", () => this.#drainRefreshes(), {
|
|
160
|
+
exitOnly: true,
|
|
161
|
+
});
|
|
162
|
+
const pending = this.#refreshStored(provider, options);
|
|
163
|
+
this.#pendingRefreshes.add(pending);
|
|
164
|
+
const settled = () => {
|
|
165
|
+
this.#pendingRefreshes.delete(pending);
|
|
166
|
+
if (this.#pendingRefreshes.size === 0 && !this.#shuttingDown) {
|
|
167
|
+
this.#cancelRefreshCleanup?.();
|
|
168
|
+
this.#cancelRefreshCleanup = undefined;
|
|
169
|
+
}
|
|
170
|
+
};
|
|
171
|
+
void pending.then(settled, settled);
|
|
172
|
+
return pending;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
async #drainRefreshes(): Promise<void> {
|
|
176
|
+
this.#shuttingDown = true;
|
|
177
|
+
await Promise.allSettled(this.#pendingRefreshes);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
async #refreshStored<T extends OAuthCredential = OAuthCredential>(
|
|
152
181
|
provider: string,
|
|
153
182
|
options: StoredOAuthRefreshOptions<T>,
|
|
154
183
|
): Promise<StoredOAuthRefreshResult<T>> {
|
|
@@ -158,6 +187,7 @@ export class OAuthRefresher {
|
|
|
158
187
|
let leasedCredentialId: number | undefined;
|
|
159
188
|
|
|
160
189
|
while (leaseStore) {
|
|
190
|
+
if (this.#shuttingDown) throw new AIError.AbortError("OAuth refresh stopped during shutdown");
|
|
161
191
|
if (options.signal?.aborted) throw new AIError.AbortError("OAuth refresh ownership aborted by caller");
|
|
162
192
|
const rows = this.#deps.store.listAuthCredentials(provider);
|
|
163
193
|
this.#deps.pool.replace(
|
|
@@ -240,6 +270,9 @@ export class OAuthRefresher {
|
|
|
240
270
|
if (options.canRefresh && !options.canRefresh(current)) {
|
|
241
271
|
return { credential: current, refreshed: false, removed: false };
|
|
242
272
|
}
|
|
273
|
+
if (this.#shuttingDown || options.signal?.aborted) {
|
|
274
|
+
throw new AIError.AbortError("OAuth refresh stopped before token exchange");
|
|
275
|
+
}
|
|
243
276
|
const serialized = serializeCredential(provider, current);
|
|
244
277
|
if (!serialized) return { credential: current, refreshed: false, removed: false };
|
|
245
278
|
|
|
@@ -495,13 +528,7 @@ export class OAuthRefresher {
|
|
|
495
528
|
forceRefresh,
|
|
496
529
|
signal,
|
|
497
530
|
refresh: (current, refreshSignal) =>
|
|
498
|
-
this.#requestOAuthCredentialRefresh(
|
|
499
|
-
provider,
|
|
500
|
-
current,
|
|
501
|
-
credentialId,
|
|
502
|
-
signal && refreshSignal ? AbortSignal.any([signal, refreshSignal]) : (signal ?? refreshSignal),
|
|
503
|
-
reason,
|
|
504
|
-
),
|
|
531
|
+
this.#requestOAuthCredentialRefresh(provider, current, credentialId, refreshSignal, reason),
|
|
505
532
|
isDefinitiveFailure: error => AIError.isDefinitiveOAuthFailure(String(error)),
|
|
506
533
|
disabledCause: error => `oauth refresh failed: ${String(error)}`,
|
|
507
534
|
});
|
|
@@ -564,9 +591,9 @@ export class OAuthRefresher {
|
|
|
564
591
|
refreshPromise = refreshOAuthToken(provider as OAuthProvider, credential, signal);
|
|
565
592
|
}
|
|
566
593
|
}
|
|
567
|
-
//
|
|
568
|
-
//
|
|
569
|
-
//
|
|
594
|
+
// The caller may stop awaiting a refresh, but the leased token exchange
|
|
595
|
+
// must finish and persist any rotated token. Only the operation timeout
|
|
596
|
+
// aborts the exchange itself.
|
|
570
597
|
const cancellation = Promise.withResolvers<never>();
|
|
571
598
|
let onAbort: (() => void) | undefined;
|
|
572
599
|
const timeout = setTimeout(
|
package/src/auth/select.ts
CHANGED
|
@@ -144,7 +144,11 @@ export class CredentialSelector {
|
|
|
144
144
|
/**
|
|
145
145
|
* Selects a credential of the specified type for a provider.
|
|
146
146
|
* Returns both the credential and its index in the original array (for updates/removal).
|
|
147
|
-
*
|
|
147
|
+
* With `sessionId` the scan starts at the session's hashed index; without one it starts at the
|
|
148
|
+
* first stored credential and never advances round-robin. Model discovery peeks through this,
|
|
149
|
+
* and credential-scoped catalogs (e.g. GitHub Copilot) key their cache rows on the credential
|
|
150
|
+
* picked, so a rotating pick would read another account's cache namespace on every pass.
|
|
151
|
+
* Blocked credentials are skipped when possible.
|
|
148
152
|
*/
|
|
149
153
|
selectByType<T extends AuthCredential["type"]>(
|
|
150
154
|
provider: string,
|
|
@@ -171,7 +175,9 @@ export class CredentialSelector {
|
|
|
171
175
|
if (credentials.length === 1) return credentials[0];
|
|
172
176
|
|
|
173
177
|
const providerKey = providerTypeKey(provider, type);
|
|
174
|
-
const order =
|
|
178
|
+
const order = sessionId
|
|
179
|
+
? this.#getCredentialOrder(providerKey, sessionId, credentials.length)
|
|
180
|
+
: credentials.map((_, index) => index);
|
|
175
181
|
const fallback = credentials[order[0]];
|
|
176
182
|
|
|
177
183
|
for (const idx of order) {
|
package/src/auth/types.ts
CHANGED
|
@@ -112,7 +112,7 @@ export interface StoredCredentialBlock {
|
|
|
112
112
|
/**
|
|
113
113
|
* Identity slice of a disabled (soft-deleted) credential tombstone — cause and
|
|
114
114
|
* account identity only, never token material. Surfaced so auto-disabled
|
|
115
|
-
* accounts (e.g. an expired Anthropic OAuth grant) stay visible in `
|
|
115
|
+
* accounts (e.g. an expired Anthropic OAuth grant) stay visible in `zetacode usage`
|
|
116
116
|
* instead of silently vanishing until the user notices missing quota.
|
|
117
117
|
*/
|
|
118
118
|
export interface DisabledCredentialSummary {
|
|
@@ -707,6 +707,16 @@ export interface AuthSourceOptions {
|
|
|
707
707
|
env?: "dedicated" | "aliases" | "none";
|
|
708
708
|
}
|
|
709
709
|
|
|
710
|
+
/** Options for {@link KeysApi.peek}. */
|
|
711
|
+
export interface AuthPeekOptions {
|
|
712
|
+
/**
|
|
713
|
+
* Fall back to an expired OAuth access token when no stored account holds an
|
|
714
|
+
* unexpired one. Only for reads that never send the token, e.g. keying an
|
|
715
|
+
* offline model-cache lookup on the account it names.
|
|
716
|
+
*/
|
|
717
|
+
includeExpired?: boolean;
|
|
718
|
+
}
|
|
719
|
+
|
|
710
720
|
/** Where a provider's auth comes from ({@link CredentialOrigin}) plus whether any leg holds a concrete key. */
|
|
711
721
|
export interface AuthSource extends CredentialOrigin {
|
|
712
722
|
/** False when the only auth is an ambient AUTHENTICATED_SENTINEL source (AWS profile, ADC). */
|
|
@@ -830,7 +840,7 @@ export interface CredentialsApi {
|
|
|
830
840
|
* Force the backing store to revalidate its credential snapshot, then
|
|
831
841
|
* reload. Remote broker stores re-fetch the snapshot; local stores are
|
|
832
842
|
* always current, so only the reload runs. Callers that pair live
|
|
833
|
-
* per-credential data with stored identities (`
|
|
843
|
+
* per-credential data with stored identities (`zetacode usage`) use this so a
|
|
834
844
|
* disk-cached snapshot cannot misattribute fresh reports.
|
|
835
845
|
*/
|
|
836
846
|
revalidate(): Promise<void>;
|
|
@@ -889,7 +899,7 @@ export interface CredentialsApi {
|
|
|
889
899
|
*/
|
|
890
900
|
disable(id: number, disabledCause: string): Promise<boolean>;
|
|
891
901
|
/**
|
|
892
|
-
* Disabled credential tombstones for display surfaces (`
|
|
902
|
+
* Disabled credential tombstones for display surfaces (`zetacode usage`,
|
|
893
903
|
* broker `GET /v1/credentials/disabled`). Empty when the backing store
|
|
894
904
|
* keeps no tombstones or the remote broker predates the endpoint.
|
|
895
905
|
*/
|
|
@@ -941,9 +951,10 @@ export interface KeysApi {
|
|
|
941
951
|
* Peek at API key for a provider without refreshing OAuth tokens.
|
|
942
952
|
* Used for model discovery where we only need to know if credentials exist
|
|
943
953
|
* and get a best-effort token. For GitHub Copilot we preserve enterprise
|
|
944
|
-
* routing metadata so discovery can hit the correct host.
|
|
954
|
+
* routing metadata so discovery can hit the correct host. Repeated peeks
|
|
955
|
+
* return the same stored credential; they never rotate accounts.
|
|
945
956
|
*/
|
|
946
|
-
peek(provider: string): Promise<string | undefined>;
|
|
957
|
+
peek(provider: string, options?: AuthPeekOptions): Promise<string | undefined>;
|
|
947
958
|
/** Resolve the first active auth source by precedence without refreshing credentials. */
|
|
948
959
|
source(provider: string, options?: AuthSourceOptions): AuthSource | undefined;
|
|
949
960
|
/**
|
package/src/auth/usage-cache.ts
CHANGED
|
@@ -3,6 +3,7 @@ import type { AuthCredential, OAuthCredential } from "./types";
|
|
|
3
3
|
import type { CredentialPool } from "./pool";
|
|
4
4
|
import type { Provider } from "../types";
|
|
5
5
|
import type { UsageCredential, UsageProvider, UsageReport } from "../usage";
|
|
6
|
+
import { isRecord } from "../utils";
|
|
6
7
|
|
|
7
8
|
const USAGE_CACHE_PREFIX = "usage_cache:";
|
|
8
9
|
const USAGE_FORCE_REFRESH_CACHE_PREFIX = "force-refresh:";
|
|
@@ -53,6 +54,17 @@ function withoutRawPayload(value: unknown): unknown {
|
|
|
53
54
|
return rest;
|
|
54
55
|
}
|
|
55
56
|
|
|
57
|
+
/**
|
|
58
|
+
* End of a cached value's durable retention. A report is retained for 24h past
|
|
59
|
+
* its last successful fetch, so re-storing it as a failure fallback never
|
|
60
|
+
* extends that window; other values are retained for 24h from now.
|
|
61
|
+
*/
|
|
62
|
+
function retentionDeadline(value: unknown): number {
|
|
63
|
+
const fetchedAt = isRecord(value) ? value.fetchedAt : undefined;
|
|
64
|
+
const start = typeof fetchedAt === "number" && Number.isFinite(fetchedAt) && fetchedAt > 0 ? fetchedAt : Date.now();
|
|
65
|
+
return start + USAGE_LAST_GOOD_RETENTION_MS;
|
|
66
|
+
}
|
|
67
|
+
|
|
56
68
|
/** Convert a stored credential for usage providers; used by usage and health services. */
|
|
57
69
|
export function buildUsageCredential(credential: AuthCredential): UsageCredential {
|
|
58
70
|
if (credential.type === "api_key") {
|
|
@@ -203,15 +215,19 @@ export class UsageCache {
|
|
|
203
215
|
return parseUsageCacheEntry<T>(raw);
|
|
204
216
|
}
|
|
205
217
|
|
|
218
|
+
/** Last successful report under `key` while inside its retention window; the failure-path fallback. */
|
|
219
|
+
lastGood(key: string): UsageReport | null {
|
|
220
|
+
const report = this.getStale<UsageReport | null>(key)?.value;
|
|
221
|
+
return report && Date.now() < retentionDeadline(report) ? report : null;
|
|
222
|
+
}
|
|
223
|
+
|
|
206
224
|
set<T>(key: string, entry: UsageCacheEntry<T>): void {
|
|
207
225
|
const value = withoutRawPayload(entry.value);
|
|
208
|
-
const
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
});
|
|
212
|
-
|
|
213
|
-
value === null ? entry.expiresAt : Math.max(entry.expiresAt, Date.now() + USAGE_LAST_GOOD_RETENTION_MS);
|
|
214
|
-
this.#store.setCache(`${USAGE_CACHE_PREFIX}${key}`, payload, Math.floor(durableExpiresAt / 1000));
|
|
226
|
+
const deadline = value === null ? undefined : retentionDeadline(value);
|
|
227
|
+
// Failure cooldowns must not make a last-good report fresh beyond retention.
|
|
228
|
+
const expiresAt = deadline === undefined ? entry.expiresAt : Math.min(entry.expiresAt, deadline);
|
|
229
|
+
const payload = JSON.stringify({ value, expiresAt });
|
|
230
|
+
this.#store.setCache(`${USAGE_CACHE_PREFIX}${key}`, payload, Math.floor((deadline ?? expiresAt) / 1000));
|
|
215
231
|
}
|
|
216
232
|
|
|
217
233
|
deletePrefix(prefix: string): boolean {
|
package/src/auth/usage.ts
CHANGED
|
@@ -334,7 +334,7 @@ export class UsageService implements UsageApi {
|
|
|
334
334
|
|
|
335
335
|
const failure = this.#deps.cache.get<null>(failureKey);
|
|
336
336
|
if (failure && failure.expiresAt > now) {
|
|
337
|
-
return this.#deps.cache.
|
|
337
|
+
return this.#deps.cache.lastGood(cacheKey);
|
|
338
338
|
}
|
|
339
339
|
|
|
340
340
|
// Invalidation must not fork an already-running credential probe. Share it;
|
|
@@ -378,9 +378,7 @@ export class UsageService implements UsageApi {
|
|
|
378
378
|
// so an expired login does not display stale quota indefinitely.
|
|
379
379
|
const providerImpl = this.providerFor(request.provider);
|
|
380
380
|
const retainLastGood = !invalidated && providerImpl?.retainLastGoodOnFailure !== false;
|
|
381
|
-
const lastGood = retainLastGood
|
|
382
|
-
? (this.#deps.cache.getStale<UsageReport | null>(cacheKey)?.value ?? null)
|
|
383
|
-
: null;
|
|
381
|
+
const lastGood = retainLastGood ? this.#deps.cache.lastGood(cacheKey) : null;
|
|
384
382
|
const failureBackoffMs = providerImpl?.failureBackoffMs ?? USAGE_FAILURE_BACKOFF_MS;
|
|
385
383
|
const backoffJitter = failureBackoffMs * (Math.random() * 0.5 - 0.25);
|
|
386
384
|
const coolDown = Date.now() + failureBackoffMs + backoffJitter;
|
|
@@ -3,9 +3,9 @@
|
|
|
3
3
|
* snapshot. Refresh tokens never leave the broker; mutating methods (`replace*`,
|
|
4
4
|
* `upsert*`, `delete*ForProvider`) throw because login flows are server-side.
|
|
5
5
|
*
|
|
6
|
-
* Cache (`getCache`/`setCache`/`cleanExpiredCache`) is in-memory and ephemeral
|
|
7
|
-
*
|
|
8
|
-
*
|
|
6
|
+
* Cache (`getCache`/`setCache`/`cleanExpiredCache`) is in-memory and ephemeral.
|
|
7
|
+
* The bounded hot set preserves recently used session pins without keeping
|
|
8
|
+
* every gateway client's 30-day sticky row resident.
|
|
9
9
|
*/
|
|
10
10
|
import * as os from "node:os";
|
|
11
11
|
import { getAppName, getInstallId, logger, postmortem } from "@linxiraos/pi-utils";
|
|
@@ -36,6 +36,11 @@ import type {
|
|
|
36
36
|
SnapshotStreamEvent,
|
|
37
37
|
} from "./types";
|
|
38
38
|
|
|
39
|
+
// Bound both the number of distinct session pins and the size of caller-supplied
|
|
40
|
+
// keys/values; an evicted session falls back to normal credential selection.
|
|
41
|
+
const REMOTE_CACHE_MAX_ENTRIES = 4096;
|
|
42
|
+
const REMOTE_CACHE_MAX_CHARACTERS = 4 * 1024 * 1024;
|
|
43
|
+
|
|
39
44
|
/**
|
|
40
45
|
* Per-provider OAuth identities visible to this trusted broker client.
|
|
41
46
|
* Missing providers are unrestricted; an empty set excludes that provider's
|
|
@@ -289,6 +294,7 @@ export class RemoteAuthCredentialStore implements AuthCredentialStore {
|
|
|
289
294
|
/** Present while the background loop is parked; resolved by `#noteActivity` or `close()`. */
|
|
290
295
|
#activityWakeup: PromiseWithResolvers<void> | null = null;
|
|
291
296
|
#cache: Map<string, CacheEntry> = new Map();
|
|
297
|
+
#cacheCharacters = 0;
|
|
292
298
|
#usageCache?: UsageCacheEntry;
|
|
293
299
|
#usageInflight?: Promise<UsageReport[] | null>;
|
|
294
300
|
#credentialBlockReconcileAfter: Map<string, number> = new Map();
|
|
@@ -1177,28 +1183,46 @@ export class RemoteAuthCredentialStore implements AuthCredentialStore {
|
|
|
1177
1183
|
const entry = this.#cache.get(key);
|
|
1178
1184
|
if (!entry) return null;
|
|
1179
1185
|
if (entry.expiresAtSec * 1000 <= Date.now()) {
|
|
1180
|
-
this.#
|
|
1186
|
+
this.#deleteCache(key);
|
|
1181
1187
|
return null;
|
|
1182
1188
|
}
|
|
1189
|
+
this.#cache.delete(key);
|
|
1190
|
+
this.#cache.set(key, entry);
|
|
1183
1191
|
return entry.value;
|
|
1184
1192
|
}
|
|
1185
1193
|
|
|
1186
1194
|
setCache(key: string, value: string, expiresAtSec: number): void {
|
|
1187
1195
|
this.#noteActivity();
|
|
1196
|
+
this.#deleteCache(key);
|
|
1197
|
+
const size = key.length + value.length;
|
|
1198
|
+
if (size > REMOTE_CACHE_MAX_CHARACTERS) return;
|
|
1188
1199
|
this.#cache.set(key, { value, expiresAtSec });
|
|
1200
|
+
this.#cacheCharacters += size;
|
|
1201
|
+
while (this.#cache.size > REMOTE_CACHE_MAX_ENTRIES || this.#cacheCharacters > REMOTE_CACHE_MAX_CHARACTERS) {
|
|
1202
|
+
const oldest = this.#cache.keys().next().value;
|
|
1203
|
+
if (oldest === undefined) break;
|
|
1204
|
+
this.#deleteCache(oldest);
|
|
1205
|
+
}
|
|
1206
|
+
}
|
|
1207
|
+
|
|
1208
|
+
#deleteCache(key: string): void {
|
|
1209
|
+
const entry = this.#cache.get(key);
|
|
1210
|
+
if (!entry) return;
|
|
1211
|
+
this.#cacheCharacters -= key.length + entry.value.length;
|
|
1212
|
+
this.#cache.delete(key);
|
|
1189
1213
|
}
|
|
1190
1214
|
|
|
1191
1215
|
/** Drop all cache rows whose keys start with the supplied prefix. */
|
|
1192
1216
|
deleteCachePrefix(prefix: string): void {
|
|
1193
1217
|
for (const key of this.#cache.keys()) {
|
|
1194
|
-
if (key.startsWith(prefix)) this.#
|
|
1218
|
+
if (key.startsWith(prefix)) this.#deleteCache(key);
|
|
1195
1219
|
}
|
|
1196
1220
|
}
|
|
1197
1221
|
|
|
1198
1222
|
cleanExpiredCache(): void {
|
|
1199
1223
|
const nowSec = Math.floor(Date.now() / 1000);
|
|
1200
1224
|
for (const [key, entry] of this.#cache) {
|
|
1201
|
-
if (entry.expiresAtSec <= nowSec) this.#
|
|
1225
|
+
if (entry.expiresAtSec <= nowSec) this.#deleteCache(key);
|
|
1202
1226
|
}
|
|
1203
1227
|
}
|
|
1204
1228
|
|
|
@@ -1572,6 +1596,7 @@ export class RemoteAuthCredentialStore implements AuthCredentialStore {
|
|
|
1572
1596
|
// process exiting right after close() still sends it; failures are dropped.
|
|
1573
1597
|
void this.#flushObservedUsage().finally(() => this.#cancelObservedUsageExitFlush?.());
|
|
1574
1598
|
this.#cache.clear();
|
|
1599
|
+
this.#cacheCharacters = 0;
|
|
1575
1600
|
this.#usageOverlays.clear();
|
|
1576
1601
|
}
|
|
1577
1602
|
}
|
|
@@ -0,0 +1,318 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Scoped auth-gateway clients.
|
|
3
|
+
*
|
|
4
|
+
* A holder of the gateway token may call every served model, and recovery may
|
|
5
|
+
* serve it from any broker credential. A scoped client authenticates with its
|
|
6
|
+
* own bearer, configured only as a SHA-256 digest, and is limited to:
|
|
7
|
+
*
|
|
8
|
+
* - `models`: the served models it may call, as exact `provider/id` grants or
|
|
9
|
+
* `provider/*`. Absent allows every served model; `[]` allows none.
|
|
10
|
+
* - `accounts`: per provider, the broker OAuth accounts (identity keys, as in
|
|
11
|
+
* account pools) its requests may use. A listed provider never falls back to
|
|
12
|
+
* a sibling account or an API key; an empty list allows no account. When
|
|
13
|
+
* present, it is also a provider allow-list: a provider it omits is refused,
|
|
14
|
+
* so a binding can never be bypassed through another provider's accounts.
|
|
15
|
+
*
|
|
16
|
+
* Account binding reuses `AuthStorage.sessions.restrict`. Each admitted
|
|
17
|
+
* version of a client (its token, models and accounts) resolves a bound
|
|
18
|
+
* provider's credentials under a session id of its own, restricted before the
|
|
19
|
+
* token is admitted. A reload that changes any of them admits a new version
|
|
20
|
+
* under a new session; requests the old version admitted keep resolving under
|
|
21
|
+
* the old restriction until they finish, and only then is it lifted. A
|
|
22
|
+
* version's requests share that session's account pin.
|
|
23
|
+
*/
|
|
24
|
+
import { isRecord } from "@linxiraos/pi-utils";
|
|
25
|
+
import type { SessionRestrictionLease, SessionsApi } from "../auth/types";
|
|
26
|
+
import type { Api, Model } from "../types";
|
|
27
|
+
|
|
28
|
+
/** One scoped client. */
|
|
29
|
+
export interface AuthGatewayClient {
|
|
30
|
+
/** Operator-chosen name, logged with the client's requests. */
|
|
31
|
+
name: string;
|
|
32
|
+
/** Hex SHA-256 digest of the client's bearer token. */
|
|
33
|
+
tokenSha256: string;
|
|
34
|
+
/** `provider/id` or `provider/*` grants; absent allows every served model, `[]` none. */
|
|
35
|
+
models?: readonly string[];
|
|
36
|
+
/** Provider id → OAuth identity keys the client's requests to that provider may use. */
|
|
37
|
+
accounts?: Readonly<Record<string, readonly string[]>>;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** An admitted scoped client, as the gateway's routes see it. */
|
|
41
|
+
export interface AuthGatewayClientScope {
|
|
42
|
+
readonly name: string;
|
|
43
|
+
/** False when the client may call every served model. */
|
|
44
|
+
readonly limitsModels: boolean;
|
|
45
|
+
/** Whether the client may call `model`. */
|
|
46
|
+
allows(model: Model<Api>): boolean;
|
|
47
|
+
/**
|
|
48
|
+
* Session id the client's credential lookups for `provider` resolve under:
|
|
49
|
+
* its own restricted session when `accounts` lists the provider, otherwise
|
|
50
|
+
* the request's `sessionId`. Throws for a provider `accounts` omits, and for
|
|
51
|
+
* a bound provider once the restriction is lifted, so a lookup can never
|
|
52
|
+
* resolve unrestricted on the client's behalf.
|
|
53
|
+
*/
|
|
54
|
+
credentialSessionId(provider: string, sessionId: string): string;
|
|
55
|
+
/**
|
|
56
|
+
* Hold the client's account restriction while a request uses it; call the
|
|
57
|
+
* returned release once the request can no longer resolve a credential. A
|
|
58
|
+
* reload that retires the client lifts the restriction only after every
|
|
59
|
+
* hold is released. Take the first hold in the same tick as
|
|
60
|
+
* `authenticate`, before a reload can retire the client.
|
|
61
|
+
*/
|
|
62
|
+
retain(): () => void;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const CLIENT_FIELDS: Record<string, true> = { tokenSha256: true, models: true, accounts: true };
|
|
66
|
+
const SHA256_HEX = /^[0-9a-f]{64}$/i;
|
|
67
|
+
// C0 controls and DEL: a name is logged verbatim.
|
|
68
|
+
const CONTROL_CHARACTER = /[\u0000-\u001f\u007f]/;
|
|
69
|
+
|
|
70
|
+
function invalid(where: string, problem: string): Error {
|
|
71
|
+
return new Error(`Invalid auth-gateway clients: ${where}: ${problem}`);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Parse a clients file: a JSON object mapping each client name to
|
|
76
|
+
* `{ tokenSha256, models?, accounts? }`. Any malformed entry or unknown field
|
|
77
|
+
* rejects the whole file, since a misspelled `models` would otherwise allow
|
|
78
|
+
* every model. Errors name the offending field, never its value.
|
|
79
|
+
*/
|
|
80
|
+
export function parseAuthGatewayClients(text: string): AuthGatewayClient[] {
|
|
81
|
+
let value: unknown;
|
|
82
|
+
try {
|
|
83
|
+
value = JSON.parse(text);
|
|
84
|
+
} catch {
|
|
85
|
+
throw new Error("Invalid auth-gateway clients: not valid JSON");
|
|
86
|
+
}
|
|
87
|
+
if (!isRecord(value)) {
|
|
88
|
+
throw new Error("Invalid auth-gateway clients: expected an object mapping each client name to its entry");
|
|
89
|
+
}
|
|
90
|
+
const clients: AuthGatewayClient[] = [];
|
|
91
|
+
for (const [name, entry] of Object.entries(value)) {
|
|
92
|
+
if (!isRecord(entry)) throw invalid(name, "expected an object");
|
|
93
|
+
for (const field of Object.keys(entry)) {
|
|
94
|
+
if (!Object.hasOwn(CLIENT_FIELDS, field)) throw invalid(name, `unknown field "${field}"`);
|
|
95
|
+
}
|
|
96
|
+
const { tokenSha256, models, accounts } = entry;
|
|
97
|
+
if (typeof tokenSha256 !== "string") throw invalid(`${name}.tokenSha256`, "expected a hex SHA-256 digest");
|
|
98
|
+
const client: AuthGatewayClient = { name, tokenSha256 };
|
|
99
|
+
if (models !== undefined) {
|
|
100
|
+
if (!Array.isArray(models) || !models.every((grant): grant is string => typeof grant === "string")) {
|
|
101
|
+
throw invalid(`${name}.models`, 'expected a list of "provider/id" or "provider/*" grants');
|
|
102
|
+
}
|
|
103
|
+
client.models = models;
|
|
104
|
+
}
|
|
105
|
+
if (accounts !== undefined) {
|
|
106
|
+
if (!isRecord(accounts))
|
|
107
|
+
throw invalid(`${name}.accounts`, "expected an object mapping provider to identity keys");
|
|
108
|
+
// Null prototype: a provider named `__proto__` stays an own entry.
|
|
109
|
+
const pools: Record<string, readonly string[]> = Object.create(null);
|
|
110
|
+
for (const [provider, keys] of Object.entries(accounts)) {
|
|
111
|
+
if (!Array.isArray(keys) || !keys.every((key): key is string => typeof key === "string")) {
|
|
112
|
+
throw invalid(`${name}.accounts.${provider}`, "expected a list of OAuth identity keys");
|
|
113
|
+
}
|
|
114
|
+
pools[provider] = keys;
|
|
115
|
+
}
|
|
116
|
+
client.accounts = pools;
|
|
117
|
+
}
|
|
118
|
+
clients.push(client);
|
|
119
|
+
}
|
|
120
|
+
// Validate every entry here too, so a bad file fails where it is read.
|
|
121
|
+
compileClients(clients);
|
|
122
|
+
return clients;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** Hex SHA-256 digest of a bearer token, as `tokenSha256` records it. */
|
|
126
|
+
export function hashAuthGatewayToken(token: string): string {
|
|
127
|
+
return new Bun.CryptoHasher("sha256").update(token).digest("hex");
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
type RestrictionSessions = Pick<SessionsApi, "restrict" | "unrestrict">;
|
|
131
|
+
|
|
132
|
+
/** A validated client: its model grants compiled, its accounts restricted while it is admitted or still serving. */
|
|
133
|
+
class ClientScope implements AuthGatewayClientScope {
|
|
134
|
+
readonly name: string;
|
|
135
|
+
readonly tokenSha256: string;
|
|
136
|
+
readonly limitsModels: boolean;
|
|
137
|
+
/** Equal for the same entry read again, so a reload that leaves it unchanged keeps the scope and its pin. */
|
|
138
|
+
readonly policy: string;
|
|
139
|
+
/** Exact `provider/id` grants. */
|
|
140
|
+
readonly #models = new Set<string>();
|
|
141
|
+
/** Providers granted with `provider/*`. */
|
|
142
|
+
readonly #providers = new Set<string>();
|
|
143
|
+
readonly #accounts = new Map<string, readonly string[]>();
|
|
144
|
+
/** Whether `models` was given; absent allows every served model `accounts` permits. */
|
|
145
|
+
readonly #grantsModels: boolean;
|
|
146
|
+
/** Whether `accounts` was given, which makes it a provider allow-list too. */
|
|
147
|
+
readonly #bindsAccounts: boolean;
|
|
148
|
+
readonly #sessionId = crypto.randomUUID();
|
|
149
|
+
#leases: Array<[provider: string, lease: SessionRestrictionLease]> = [];
|
|
150
|
+
#sessions: RestrictionSessions | undefined;
|
|
151
|
+
#holds = 0;
|
|
152
|
+
#retired = false;
|
|
153
|
+
#released = false;
|
|
154
|
+
|
|
155
|
+
constructor(client: AuthGatewayClient) {
|
|
156
|
+
const { name } = client;
|
|
157
|
+
if (typeof name !== "string" || name.length === 0 || CONTROL_CHARACTER.test(name)) {
|
|
158
|
+
throw new Error("Invalid auth-gateway clients: a client name must be non-empty text");
|
|
159
|
+
}
|
|
160
|
+
if (!SHA256_HEX.test(client.tokenSha256)) throw invalid(`${name}.tokenSha256`, "expected 64 hex digits");
|
|
161
|
+
this.name = name;
|
|
162
|
+
this.tokenSha256 = client.tokenSha256.toLowerCase();
|
|
163
|
+
this.#grantsModels = client.models !== undefined;
|
|
164
|
+
this.#bindsAccounts = client.accounts !== undefined;
|
|
165
|
+
this.limitsModels = this.#grantsModels || this.#bindsAccounts;
|
|
166
|
+
for (const grant of client.models ?? []) {
|
|
167
|
+
const slash = grant.indexOf("/");
|
|
168
|
+
const provider = grant.slice(0, slash);
|
|
169
|
+
const id = grant.slice(slash + 1);
|
|
170
|
+
if (
|
|
171
|
+
slash <= 0 ||
|
|
172
|
+
id.length === 0 ||
|
|
173
|
+
provider.trim() !== provider ||
|
|
174
|
+
id.trim() !== id ||
|
|
175
|
+
(id.includes("*") && id !== "*")
|
|
176
|
+
) {
|
|
177
|
+
throw invalid(`${name}.models`, 'each grant must be "provider/id" or "provider/*"');
|
|
178
|
+
}
|
|
179
|
+
if (id === "*") this.#providers.add(provider);
|
|
180
|
+
else this.#models.add(grant);
|
|
181
|
+
}
|
|
182
|
+
for (const [provider, keys] of Object.entries(client.accounts ?? {})) {
|
|
183
|
+
// A provider id that matches no request would leave that provider unbound, so it must be exact.
|
|
184
|
+
if (provider.length === 0 || provider.trim() !== provider) {
|
|
185
|
+
throw invalid(`${name}.accounts`, "a provider id must be non-empty with no surrounding whitespace");
|
|
186
|
+
}
|
|
187
|
+
if (!keys.every(key => key.length > 0 && key.trim() === key)) {
|
|
188
|
+
throw invalid(`${name}.accounts`, 'expected identity keys such as "email:you@example.com|org:<org-id>"');
|
|
189
|
+
}
|
|
190
|
+
this.#accounts.set(provider, keys);
|
|
191
|
+
}
|
|
192
|
+
if (this.#bindsAccounts) {
|
|
193
|
+
// A grant `accounts` cannot serve is a typo or a misunderstanding; refusing it at load beats a silent 404.
|
|
194
|
+
const granted = [...this.#providers, ...Array.from(this.#models, grant => grant.slice(0, grant.indexOf("/")))];
|
|
195
|
+
const unbound = granted.find(provider => !this.#accounts.has(provider));
|
|
196
|
+
if (unbound !== undefined) {
|
|
197
|
+
throw invalid(`${name}.models`, `grants provider "${unbound}", which "accounts" does not list`);
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
this.policy = JSON.stringify([name, this.tokenSha256, client.models ?? null, client.accounts ?? null]);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
allows(model: Model<Api>): boolean {
|
|
204
|
+
if (this.#bindsAccounts && !this.#accounts.has(model.provider)) return false;
|
|
205
|
+
if (!this.#grantsModels) return true;
|
|
206
|
+
return this.#providers.has(model.provider) || this.#models.has(`${model.provider}/${model.id}`);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
credentialSessionId(provider: string, sessionId: string): string {
|
|
210
|
+
if (!this.#bindsAccounts) return sessionId;
|
|
211
|
+
if (!this.#accounts.has(provider)) {
|
|
212
|
+
throw new Error(`auth-gateway client ${this.name} is not bound to provider ${provider}`);
|
|
213
|
+
}
|
|
214
|
+
if (this.#released) throw new Error(`auth-gateway client ${this.name} was retired`);
|
|
215
|
+
return this.#sessionId;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
retain(): () => void {
|
|
219
|
+
this.#holds++;
|
|
220
|
+
let held = true;
|
|
221
|
+
return () => {
|
|
222
|
+
if (!held) return;
|
|
223
|
+
held = false;
|
|
224
|
+
this.#holds--;
|
|
225
|
+
this.#releaseIfDone();
|
|
226
|
+
};
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/** Restrict this scope's session to its accounts. */
|
|
230
|
+
restrict(sessions: RestrictionSessions): void {
|
|
231
|
+
this.#sessions = sessions;
|
|
232
|
+
for (const [provider, keys] of this.#accounts) {
|
|
233
|
+
this.#leases.push([provider, sessions.restrict(provider, this.#sessionId, keys)]);
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/** No longer admitted: lift the restriction once the last request holding it is done. */
|
|
238
|
+
retire(): void {
|
|
239
|
+
this.#retired = true;
|
|
240
|
+
this.#releaseIfDone();
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
#releaseIfDone(): void {
|
|
244
|
+
if (!this.#retired || this.#holds > 0) return;
|
|
245
|
+
this.#released = true;
|
|
246
|
+
for (const [provider, lease] of this.#leases) this.#sessions?.unrestrict(provider, this.#sessionId, lease);
|
|
247
|
+
this.#leases = [];
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/** Validate `clients` as one table and compile it, keyed by token digest. */
|
|
252
|
+
function compileClients(clients: readonly AuthGatewayClient[]): Map<string, ClientScope> {
|
|
253
|
+
const compiled = new Map<string, ClientScope>();
|
|
254
|
+
const names = new Set<string>();
|
|
255
|
+
for (const client of clients) {
|
|
256
|
+
const scope = new ClientScope(client);
|
|
257
|
+
if (names.has(scope.name)) throw invalid(scope.name, "duplicate client name");
|
|
258
|
+
if (compiled.has(scope.tokenSha256)) throw invalid(scope.name, "another client has the same tokenSha256");
|
|
259
|
+
names.add(scope.name);
|
|
260
|
+
compiled.set(scope.tokenSha256, scope);
|
|
261
|
+
}
|
|
262
|
+
return compiled;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* The scoped clients one gateway admits, looked up by the digest of the
|
|
267
|
+
* presented bearer.
|
|
268
|
+
*/
|
|
269
|
+
export class AuthGatewayClientTable {
|
|
270
|
+
readonly #sessions: RestrictionSessions;
|
|
271
|
+
/** Digests of the gateway's own tokens, which no client may claim. */
|
|
272
|
+
readonly #reserved: ReadonlySet<string>;
|
|
273
|
+
#byDigest = new Map<string, ClientScope>();
|
|
274
|
+
#closed = false;
|
|
275
|
+
|
|
276
|
+
constructor(sessions: RestrictionSessions, gatewayTokens: Iterable<string>) {
|
|
277
|
+
this.#sessions = sessions;
|
|
278
|
+
this.#reserved = new Set(Array.from(gatewayTokens, hashAuthGatewayToken));
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/**
|
|
282
|
+
* Replace the admitted clients. An invalid table throws and changes
|
|
283
|
+
* nothing. An unchanged client keeps its scope; a new or changed one is
|
|
284
|
+
* restricted before its token is admitted; the scopes left out are retired.
|
|
285
|
+
* Ignored once the table is closed.
|
|
286
|
+
*/
|
|
287
|
+
set(clients: readonly AuthGatewayClient[]): void {
|
|
288
|
+
if (this.#closed) return;
|
|
289
|
+
const next = compileClients(clients);
|
|
290
|
+
for (const scope of next.values()) {
|
|
291
|
+
if (this.#reserved.has(scope.tokenSha256)) {
|
|
292
|
+
throw invalid(scope.name, "tokenSha256 is the gateway token's digest; give the client its own token");
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
for (const [digest, scope] of next) {
|
|
296
|
+
const current = this.#byDigest.get(digest);
|
|
297
|
+
if (current?.policy === scope.policy) next.set(digest, current);
|
|
298
|
+
else scope.restrict(this.#sessions);
|
|
299
|
+
}
|
|
300
|
+
const previous = this.#byDigest;
|
|
301
|
+
this.#byDigest = next;
|
|
302
|
+
for (const [digest, scope] of previous) {
|
|
303
|
+
if (next.get(digest) !== scope) scope.retire();
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/** The client `token` authenticates, if any. */
|
|
308
|
+
authenticate(token: string): AuthGatewayClientScope | undefined {
|
|
309
|
+
return this.#byDigest.get(hashAuthGatewayToken(token));
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/** Admit no more clients and retire every scope. */
|
|
313
|
+
close(): void {
|
|
314
|
+
this.#closed = true;
|
|
315
|
+
for (const scope of this.#byDigest.values()) scope.retire();
|
|
316
|
+
this.#byDigest = new Map();
|
|
317
|
+
}
|
|
318
|
+
}
|